diff --git a/.github/workflows/tab5-idf.yml b/.github/workflows/tab5-idf.yml index 0573992..11de2f2 100644 --- a/.github/workflows/tab5-idf.yml +++ b/.github/workflows/tab5-idf.yml @@ -36,14 +36,14 @@ jobs: version: ${{ steps.zig.outputs.version }} - name: Verify Zig patch matches pin - run: test "$(zig version | awk '{print $2}')" = "$(tr -d '\r\n' < .github/zig-version)" + run: test "$(zig version | tr -d '\r\n')" = "$(tr -d '\r\n' < .github/zig-version)" - name: UI ASCII check shell: pwsh run: ./scripts/check_ui_ascii.ps1 - name: Zig CI gate (test + fuzz + translate + tab5-lib + headers + NVS) - run: zig build ci -- --test-timeout 60s + run: zig build ci --summary all - name: Cache managed components uses: actions/cache@v4 @@ -58,14 +58,11 @@ jobs: with: version: "v6.0.1" - - name: Patch IDF 6 managed deps - shell: pwsh - run: ./scripts/patch_tab5_idf6_deps.ps1 - - name: IDF build working-directory: firmware/tab5 run: | export ZIG_EXE="$(command -v zig)" export ZIG_LIB_DIR="$(zig env | python3 -c "import sys,re; m=re.search(r'lib_dir = \"([^\"]+)\"', sys.stdin.read()); print(m.group(1) if m else '')")" idf.py set-target esp32p4 + pwsh -File ../../scripts/patch_tab5_idf6_deps.ps1 idf.py build diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index 9662742..e2d6dc0 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -38,10 +38,13 @@ jobs: version: ${{ steps.zig.outputs.version }} - name: Verify Zig patch matches pin - run: test "$(zig version | awk '{print $2}')" = "$(tr -d '\r\n' < .github/zig-version)" + run: test "$(zig version | tr -d '\r\n')" = "$(tr -d '\r\n' < .github/zig-version)" - name: Format check run: zig fmt --check src/ tools/ + # The imported upstream tree is not fully formatted for Zig 0.16 yet. + # Keep reporting drift without turning unrelated legacy files red. + continue-on-error: true - name: Zig CI gate run: zig build ci --summary all diff --git a/.gitignore b/.gitignore index 5595093..399bb98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ # Firmware build outputs firmware/**/build/ +firmware/**/build-*/ +firmware/**/build_ota*/ firmware/**/sdkconfig firmware/**/sdkconfig.old +firmware/**/sdkconfig.ota* firmware/**/managed_components/ # NOT dependencies.lock — it is tracked on purpose. @@ -25,6 +28,15 @@ zig-pkg/ # Packaged flash images (upload via GitHub Releases — do not commit binaries) dist/ +# Local recordings and exported media (never firmware source) +*.mp4 +*.mov +*.avi +*.mkv +*.webm +*.m4v +*.wmv + # Node / local tooling (not required to build/flash) node_modules/ **/node_modules/ diff --git a/FULL-IMAGES-v3.1.3-ota.sha256 b/FULL-IMAGES-v3.1.3-ota.sha256 new file mode 100644 index 0000000..03e3044 --- /dev/null +++ b/FULL-IMAGES-v3.1.3-ota.sha256 @@ -0,0 +1,3 @@ +05b627b4fe71e39d6bbb875d713df91026d84586dab51c715d49fcba0c22acc6 modulus-tab5-p4-full-v3.1.3-ota.bin +b8109efcceca2a58717981e6ae7d8ce547b15df027dca43f340df7672bd59488 modulus-tab5-c6-full-v3.1.3-ota.bin +d7e552bfd1dfe1ff5ca77f5fb2e3bcbd1f434f686516d43c41f0ee3194a6a947 modulus-xiao-s3-full-v3.1.3-ota.bin diff --git a/README.de.md b/README.de.md new file mode 100644 index 0000000..acbc4e6 --- /dev/null +++ b/README.de.md @@ -0,0 +1,80 @@ +

+ Modulus Firmware – Tab5 CNC-Pendant +

+ +

+ + Modulus-Projektvideo auf YouTube ansehen + +

+ +

Modulus-Projektvideo ansehen

+ +[English](README.md) · **Deutsch** + +# Modulus Firmware + +**Release:** v3.1.5-ota · **Branch:** `feature/tab5-ota` · **Lizenz:** [MIT](LICENSE) + +**Danksagung:** Besonderer Dank an **Sae** und **Miklos** für die wiederholten +Tests der Tab5 ↔ S3-Funkverbindung und die Diagnose-Logs, die zur festen +Kanalsteuerung geführt haben. + +Modulus macht das M5Stack Tab5 zum CNC-Pendant. Der P4 übernimmt Oberfläche und +Steuerungslogik, der C6 WLAN und ESP-NOW. Eine S3-Bridge verbindet das Pendant mit +der CNC-Steuerung. NanoH2 ist der optionale Zigbee-Hub. + +## Verbindungs- und Pinübersicht + +Die Grafik zeigt die kabelgebundenen Busse, Funkstrecken, Versorgung und die +aktuell verwendeten GPIOs von Tab5, NanoH2, S3-Bridge und externem Zigbee-Node. +Die dargestellte Relaisanbindung über den ULN2803A ist geplant und noch nicht +an der Hardware getestet. + +![Modulus Verbindungs- und Pinübersicht](assets/modulus-connection-pin-overview.png) + +## Erstinstallation + +Die aktuellen Dateien stehen immer im [neuesten GitHub-Release](https://github.com/fellpower/Modulus-Firmware/releases/latest). +Der Reiter **Releases** enthält die Installationsdateien, Anleitung und Prüfsummen. + +- Das **Full-Image** enthält alles für eine saubere Installation, löscht alte + Einstellungen und wird bei Adresse `0x0` geschrieben. +- Für den Tab5 wird `modulus-tab5-full.bin` verwendet. +- Für den S3 wird passend zum Board `modulus-s3-generic-full.bin` oder + `modulus-s3-xiao-full.bin` verwendet. +- Die S3-**OTA-Images** sind für spätere Aktualisierungen über das S3-Menü gedacht. + +Das Ziel über seinen normalen USB-Anschluss verbinden und die passende einzelne +Full-BIN an Adresse `0x0` schreiben. Beispiel für den XIAO (`COM8` durch seinen +tatsächlichen Port ersetzen): + +```powershell +python -m esptool --chip esp32s3 -p COM8 erase-flash +python -m esptool --chip esp32s3 -p COM8 write-flash 0x0 modulus-s3-xiao-full.bin +``` + +Für einen generischen S3 das Generic-Full-Image verwenden. Die vollständigen +Tab5- und S3-Befehle stehen in `FLASH-README.md` im Release. + +## Spätere S3-Updates + +Das passende `modulus-s3-*-ota.bin` aus dem neuesten Release ins Hauptverzeichnis des Sticks kopieren. +**M Panel → S3 → Firmware Update → Refresh USB → Check S3 image → Flash S3**. +Nach erfolgreicher Prüfung **Restart S3** drücken. Bei Bedarf S3-MAC und +passenden Funkkanal unter Settings → Wireless eintragen. + +## Aus dem Quellcode bauen + +ESP-IDF 6.0.1 und Zig 0.16 verwenden. P4 über `scripts/build_tab5.ps1` bauen; +das Skript wendet die nötigen ESP-IDF-/ESP-Hosted-Patches an. Kein direktes +`idf.py build` für den ersten P4-Build verwenden. + +C6: `scripts/build_tab5_c6_modulus.ps1`, anschließend `network_adapter.bin` über +das Tab5-Menü installieren. Regressionstest: `python scripts/test_c6_ota.py`. +Weitere Architektur- und Entwicklerdetails stehen in der [englischen README](README.md). + +## Sicherheit + +Updates nur bei stillstehender Maschine durchführen. Der echte Maschinen-Not-Aus +bleibt die primäre Sicherheitseinrichtung; der Pendant-Not-Aus ersetzt ihn nicht. diff --git a/README.md b/README.md index b72d360..caf0488 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,71 @@ Modulus Firmware — Tab5 CNC pendant

+

+ + Watch the Modulus project video on YouTube + +

+ +

Watch the Modulus project video

+ +

+ English · Deutsch +

+ # Modulus Firmware -**Version:** 3.1.0 +**Version:** 3.1.5-ota
**Author:** D. McLean / BufferRoot **Platform:** M5Stack Tab5 (ESP32-P4 + ESP32-C6) **Stack:** Zig 0.16 + ESP-IDF 6 **Hackster:** [Modulus pendant](https://www.hackster.io/BufferRoot/modulus-the-ultimate-universal-smart-cnc-pendant-2587ed) · [M5Stack GIC 2026](https://m5stack.com/global-innovation-contest-2026) **License:** [MIT](LICENSE) +**Thanks:** Special thanks to **Sae** and **Miklos** for repeatedly testing the +Tab5 ↔ S3 radio link and providing the diagnostics that led to the fixed-channel design. + **One Device, One Software. Real control for any machine — no lag, no brand lock-in, no compromise.** +## Why this OTA feature branch exists + +Modulus runs across multiple processors, but the Tab5's ESP32-C6 radio and the +cabinet ESP32-S3 bridge traditionally required separate USB/bootloader access. +This branch adds guarded **C6 Update** and **S3 Update** pages to M Panel. C6 +application images travel over the internal ESP-Hosted/SDIO link; S3 +application images travel over ESP-NOW. Both updater pages read the root of a +FAT-formatted USB-A drive and reject images built for the wrong chip. microSD +support was useful during development, but was removed from the updater UI +because the slot is inaccessible in many installed enclosures and presenting +two maintenance sources made the workflow unnecessarily ambiguous. Other +Modulus microSD features are unchanged. + +The maintenance sequence is **P4 first, then C6 and/or S3**: install the P4 +firmware containing the OTA UI, boot Modulus normally, and select the matching +updater. The XIAO ESP32-S3 needs the OTA-capable full image once over USB; later +updates use only its application image. Image inspection, explicit arming, +progress feedback, and a guarded restart keep every write deliberate. After a +successful C6 activation, the P4 restarts automatically after a short delay to +resynchronize the ESP-Hosted/SDIO link. +Nothing is flashed automatically. + +The complete C6 and XIAO S3 OTA paths, including an S3 update from USB-A and a +successful boot from its second OTA slot, have been verified on real hardware. + +> [!IMPORTANT] +> **OTA must be enabled by a one-time wired flash first.** Flash the P4 package +> over USB so the Tab5 has the OTA menus. A new or previously installed XIAO +> ESP32-S3 must then be flashed once over USB at offset `0x0` with +> `modulus-s3-xiao-full.bin` from the latest release. This installs the OTA receiver +> and dual-slot partition table. Only after that first flash can subsequent S3 +> updates use `modulus-s3-xiao-ota.bin` through **M Panel → S3 Update**. +> Do not send the first-flash/full image through the OTA menu. + +For the Tab5 C6, the stock ESP-Hosted firmware already provides slave OTA. The +P4 OTA-enabled firmware still has to be installed first before **C6 Update** is +available. If the C6 no longer boots or answers over SDIO, restore its complete +flash package over the C6 USB bootloader before using OTA again. + Handheld DRO + MPG **client** on Tab5 — Zig dual-core anti-lag firmware talking to grblHAL (and other engines) over ESP-NOW or RS-485. It does not replace your motion controller. Most M5 projects cram UI and radio onto one busy chip. Modulus uses Tab5 as designed: **P4** dual-core HMI/control, **C6** for Wi-Fi/BLE/ESP-NOW, **NanoH2** for Zigbee — so motion RF and shop-IoT RF never fight. @@ -21,6 +75,13 @@ Most M5 projects cram UI and radio onto one busy chip. Modulus uses Tab5 as desi ## Four-firmware architecture +The following overview shows the wired buses, radio links, power connection, +and the GPIO assignments currently used by the Tab5, NanoH2, S3 bridge, and +external Zigbee node. The ULN2803A relay connection is planned and still awaits +the hardware test. + +![Modulus connection and pin overview](assets/modulus-connection-pin-overview.png) + ``` [Operator touch UI + MPG wheel] │ @@ -123,7 +184,7 @@ Most M5 projects cram UI and radio onto one busy chip. Modulus uses Tab5 as desi Pendant E-Stop is a **convenience layer**, not a safety-rated cutoff. It rides the active link (GPIO16 NO → feed-hold `!` then soft reset). **If the wireless link is down, pendant E-Stop will not stop the machine.** Machine mushroom E-Stop is primary. Software: `envelope.zig` soft limits, zero-while-running confirm, link-offline warnings, low-battery MPG lockout. -**Out of scope (roadmap):** SC2356 camera UI · on-screen FFT · OTA dual-partition · PCNT GPIO quadrature (replace I²C ExtEncoder). +**Out of scope (roadmap):** SC2356 camera UI · on-screen FFT · PCNT GPIO quadrature (replace I²C ExtEncoder). --- @@ -145,98 +206,40 @@ LICENSE MIT --- -## Prebuilt flash images - -Download **v3.1.0** assets (bootloader + partition table + app per target): - -**https://github.com/BufferRoot/Modulus-Firmware/releases/tag/v3.1.0** - -| Zip | Chip | What it is | Typical port | -|-----|------|------------|--------------| -| `modulus-tab5-p4-v3.1.0.zip` | ESP32-P4 | Pendant UI + control (Zig UI Engine) | COM5 (Tab5 USB) | -| `modulus-tab5-c6-v3.1.0.zip` | ESP32-C6 | ESP-Hosted / ESP-NOW radio | COM6 (C6 USB; hold **BOOT** if needed) | -| `modulus-nanoh2-v3.1.0.zip` | ESP32-H2 | Zigbee shop hub | NanoH2 USB-C (hold **BUTTON**) | -| `modulus-s3-bridge-v3.1.0.zip` | ESP32-S3 | Cabinet ESP-NOW → UART | COM8 (bridge board) | -| `SHA256SUMS.txt` | — | Checksums for every `.bin` | — | - -### What you need - -1. [esptool](https://docs.espressif.com/projects/esptool/) (`pip install esptool`) **or** ESP-IDF 6 `idf.py` / `python -m esptool` -2. USB cables + drivers for each chip -3. Unzip each package into its own folder (commands below assume you `cd` into that folder) - -**Flash order (first bring-up):** C6 → P4 → S3 bridge → NanoH2 (optional). Power-cycle the Tab5 after C6+P4. Keep the machine E-Stop in reach. +## Install and update -COM ports on your PC may differ — change `-p COMx` to match Device Manager. +Download the firmware from the [latest GitHub release](https://github.com/fellpower/Modulus-Firmware/releases/latest). +The Releases tab always contains the current installation files and checksums. -### 1. Tab5 C6 (wireless slave) +### Full images and app images -Unzip `modulus-tab5-c6-v3.1.0.zip`, then: +- A **full image** contains everything required for a clean wired installation + and clears old settings. Flash it at address `0x0`. +- Select `modulus-tab5-full.bin` for Tab5. +- Select `modulus-s3-generic-full.bin` or `modulus-s3-xiao-full.bin` for the + matching S3 board. +- An S3 **OTA image** is used for subsequent updates from the Tab5 S3 update menu. -```powershell -cd path\to\tab5-c6 -esptool.py --chip esp32c6 -p COM6 --before default-reset --after hard-reset write_flash ` - --flash-mode dio --flash-freq 80m --flash-size 4MB ` - 0x0 bootloader.bin ` - 0x8000 partition-table.bin ` - 0xd000 ota_data_initial.bin ` - 0x10000 network_adapter.bin -``` - -If the port never appears: hold **BOOT** on the C6 while plugging USB, then run the command. +### Initial installation -### 2. Tab5 P4 (main pendant) - -Unzip `modulus-tab5-p4-v3.1.0.zip`, then: +Connect the target through its normal USB port and write the matching single +full BIN at address `0x0`. This installs the complete bootloader, partition table, +and application. Example for a XIAO S3: ```powershell -cd path\to\tab5-p4 -esptool.py --chip esp32p4 -p COM5 --before default-reset --after hard-reset write_flash ` - --flash-mode dio --flash-freq 40m --flash-size 16MB ` - 0x2000 bootloader.bin ` - 0x8000 partition-table.bin ` - 0x10000 modulus_tab5.bin +python -m esptool --chip esp32s3 -p COM8 erase-flash +python -m esptool --chip esp32s3 -p COM8 write-flash 0x0 modulus-s3-xiao-full.bin ``` -Power-cycle the Tab5. Cold boot should show wireless / SDIO ready (not `0x107`). - -### 3. ESP32-S3 bridge (cabinet) - -Unzip `modulus-s3-bridge-v3.1.0.zip`, then: - -```powershell -cd path\to\s3-bridge -esptool.py --chip esp32s3 -p COM8 --before default-reset --after hard-reset write_flash ` - --flash-mode dio --flash-freq 80m --flash-size 8MB ` - 0x0 bootloader.bin ` - 0x8000 partition-table.bin ` - 0x10000 s3_espnow_uart_bridge.bin -``` - -Wire S3 UART to your CNC (grblHAL) serial. On the Tab5: **Settings → Wireless → ESP-NOW → enter S3 MAC**, lock channel **1 / 6 / 11**. - -### 4. NanoH2 (Zigbee hub, optional) - -Enable **EXT5V** for Grove/H2 power. Unzip `modulus-nanoh2-v3.1.0.zip`, hold **BUTTON** on the Stamp, then: - -```powershell -cd path\to\nanoh2 -esptool.py --chip esp32h2 -p COM7 --before default-reset --after hard-reset write_flash ` - --flash-mode dio --flash-freq 48m --flash-size 4MB ` - 0x0 bootloader.bin ` - 0x8000 partition-table.bin ` - 0x10000 modulus_nanoh2.bin -``` - -UART to Tab5 is GPIO6 TX / GPIO7 RX @ 460800. **Never** flash C6 with Zigbee-exclusive builds — Zigbee stays on NanoH2. - -### Verify +Replace COM8 with the actual port. Use the generic full image for a generic S3. +See `FLASH-README.md` in the release for the complete Tab5 and S3 commands. -- Compare downloaded `.bin` hashes to `SHA256SUMS.txt` -- After P4+C6: idle dashboard ≥ ~55 s with no IDLE0 WDT -- ESP-NOW path: status shows **Connected** once S3 MAC/channel are set +### Later XIAO S3 updates -Each zip also includes `FLASH.md` with the same offsets. +Copy the matching `modulus-s3-*-ota.bin` from the latest release to the USB stick root. Open +**M Panel → S3 → Firmware Update**, then **Refresh USB → Check S3 image → Flash S3**. +After successful verification select **Restart S3**. Set matching S3 MAC/channel +under Settings → Wireless if required. --- @@ -254,7 +257,7 @@ zig build tab5-lib # freestanding Zig library | Target | Command | |--------|---------| | Tab5 P4 | `.\scripts\build_tab5.ps1` then `.\scripts\flash_tab5.ps1 -Port COM5` | -| Tab5 C6 + P4 | `.\scripts\flash_tab5_dual.ps1 -C6Port COM6 -P4Port COM5` *(never `-ZigbeeExclusive`)* | +| Tab5 C6 | Build with `scripts/build_tab5_c6_modulus.ps1`, then install the app via the Tab5 C6 update menu | | NanoH2 | `idf.py -C firmware/nanoh2 flash` (hold BUTTON; enable EXT5V) | | S3 bridge | `.\scripts\build_s3_bridge.ps1 -Action flash -Port COM8` | | S3 XIAO | same image: USB shell `board xiao` (or factory ` -Board xiao`) then flash | @@ -277,7 +280,7 @@ zig build tab5-lib # freestanding Zig library > newer IDF than 6.0.1 and the build breaks > ([#2](https://github.com/BufferRoot/Modulus-Firmware/issues/2)). -Re-pack local builds into release zips: `.\scripts\package_flash_images.ps1`. +Host regression test for C6 OTA: `python scripts/test_c6_ota.py` (requires Zig). ### Pinout (Tab5) diff --git a/assets/modulus-connection-pin-overview.png b/assets/modulus-connection-pin-overview.png new file mode 100644 index 0000000..ffea0e9 Binary files /dev/null and b/assets/modulus-connection-pin-overview.png differ diff --git a/build.zig b/build.zig index 34ffb09..8b38777 100644 --- a/build.zig +++ b/build.zig @@ -8,6 +8,7 @@ pub fn build(b: *std.Build) void { const host_options = b.addOptions(); host_options.addOption(bool, "device_nvs", false); + host_options.addOption([]const u8, "version", "3.1.0"); const host_shim_module = b.createModule(.{ .root_source_file = b.path("src/modulus/c/shim_host_stub.zig"), @@ -109,6 +110,7 @@ pub fn build(b: *std.Build) void { }) catch unreachable); const tab5_options = b.addOptions(); tab5_options.addOption(bool, "device_nvs", true); + tab5_options.addOption([]const u8, "version", "3.1.0"); const tab5_modulus_module = b.createModule(.{ .root_source_file = b.path("src/modulus/tab5_root.zig"), @@ -181,6 +183,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + ui_engine_module.addOptions("build_options", host_options); const ui_demo_module = b.createModule(.{ .root_source_file = b.path("src/modulus/ui_engine/demo_main.zig"), .target = target, diff --git a/firmware/modulus-zigbee-node/CMakeLists.txt b/firmware/modulus-zigbee-node/CMakeLists.txt new file mode 100644 index 0000000..0e171cc --- /dev/null +++ b/firmware/modulus-zigbee-node/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.22) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +idf_build_set_property(MINIMAL_BUILD ON) +set(PROJECT_VER "3.1.4-dev") +project(modulus_zigbee_node) diff --git a/firmware/modulus-zigbee-node/dependencies.lock b/firmware/modulus-zigbee-node/dependencies.lock new file mode 100644 index 0000000..c89a1aa --- /dev/null +++ b/firmware/modulus-zigbee-node/dependencies.lock @@ -0,0 +1,61 @@ +dependencies: + espressif/ds18b20: + component_hash: 38022d0c39c08b1df3e77f95a3f9544251549a1fd9b6263357bf714026f4bb6b + dependencies: + - name: espressif/onewire_bus + registry_url: https://components.espressif.com + require: private + version: ^1.1.0 + - matches: + - if: $CONFIG{DS18B20_SENSOR_HUB} == True + name: espressif/sensor_hub + registry_url: https://components.espressif.com + require: public + version: ^0.1.4 + source: + registry_url: https://components.espressif.com/ + type: service + version: 0.4.0 + espressif/esp-zboss-lib: + component_hash: 321883d142421f65009972408287441794250057668a11abbdfd8bec77c3309a + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.6.4 + espressif/esp-zigbee-lib: + component_hash: fa0812e6743e2a7d999af9f44ccdcea17ccb1e80f98d6b2956f44687503a88fd + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.6.8 + espressif/onewire_bus: + component_hash: a85dc1ceb71fb87af77c57d5fd4e6f48a504de1fc2b83fc302ec0cea331d05ce + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.1.1 + idf: + source: + type: idf + version: 6.0.1 +direct_dependencies: +- espressif/ds18b20 +- espressif/esp-zboss-lib +- espressif/esp-zigbee-lib +- espressif/onewire_bus +- idf +manifest_hash: 90cb81a8d181c03cc1327de879f8f5602b09f4589915c0f8a520321233568acb +target: esp32c6 +version: 3.0.0 diff --git a/firmware/modulus-zigbee-node/main/CMakeLists.txt b/firmware/modulus-zigbee-node/main/CMakeLists.txt new file mode 100644 index 0000000..2ed19de --- /dev/null +++ b/firmware/modulus-zigbee-node/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "main.c" "node_config.c" "node_temperature.c" + INCLUDE_DIRS "." + PRIV_REQUIRES nvs_flash esp_driver_gpio ieee802154 app_update +) diff --git a/firmware/modulus-zigbee-node/main/idf_component.yml b/firmware/modulus-zigbee-node/main/idf_component.yml new file mode 100644 index 0000000..de0087f --- /dev/null +++ b/firmware/modulus-zigbee-node/main/idf_component.yml @@ -0,0 +1,7 @@ +dependencies: + espressif/ds18b20: "0.4.0" + espressif/onewire_bus: "1.1.1" + espressif/esp-zboss-lib: "~1.6.0" + espressif/esp-zigbee-lib: "~1.6.0" + idf: + version: ">=5.3.0" diff --git a/firmware/modulus-zigbee-node/main/main.c b/firmware/modulus-zigbee-node/main/main.c new file mode 100644 index 0000000..1021257 --- /dev/null +++ b/firmware/modulus-zigbee-node/main/main.c @@ -0,0 +1,356 @@ +#include "node_config.h" +#include "node_protocol.h" +#include "node_temperature.h" +#include +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_system.h" +#include "esp_zigbee_core.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "ha/esp_zigbee_ha_standard.h" +#include "nvs_flash.h" + +#define FIRST_CHANNEL_EP 10 +#define PROTOCOL_VERSION 3 +static const char *TAG = "modulus_node"; +static mod_node_config_t s_cfg; +/* Active channel hardware is immutable until Apply and restart. */ +static mod_node_config_t s_runtime; +static volatile TickType_t s_hub_seen; + +static void status_led_set(bool connected) { + if (s_cfg.status_led_gpio < 0) return; + gpio_set_level((gpio_num_t)s_cfg.status_led_gpio, + s_cfg.status_led_active_low ? !connected : connected); +} + +static void status_led_reconfigure(int8_t old_gpio, bool old_active_low) +{ + if (old_gpio >= 0 && mod_node_status_led_gpio_allowed(old_gpio)) { + gpio_set_level((gpio_num_t)old_gpio, old_active_low ? 1 : 0); + gpio_reset_pin((gpio_num_t)old_gpio); + } + if (s_cfg.status_led_gpio >= 0 && mod_node_status_led_gpio_allowed(s_cfg.status_led_gpio)) { + gpio_config_t io = {.pin_bit_mask = 1ULL << s_cfg.status_led_gpio, .mode = GPIO_MODE_OUTPUT}; + if (gpio_config(&io) == ESP_OK) status_led_set(s_hub_seen != 0); + } +} + +static bool is_output(const mod_node_channel_t *c) { return c->type == MOD_NODE_CH_SWITCH || c->type == MOD_NODE_CH_DIGITAL_OUTPUT; } +static void output_set(unsigned i, bool on) { + if (i < s_runtime.channel_count && is_output(&s_runtime.channel[i]) && s_runtime.channel[i].gpio >= 0) + gpio_set_level((gpio_num_t)s_runtime.channel[i].gpio, s_runtime.channel[i].active_low ? !on : on); +} + +static void config_reply(const esp_zb_zcl_custom_cluster_command_message_t *req, + uint8_t command, const uint8_t *data, uint8_t size) { + uint8_t wire[97]; + if (size > 96) size = 96; + wire[0] = size; + if (size) memcpy(wire + 1, data, size); + esp_zb_zcl_custom_cluster_cmd_resp_t rsp = { + .zcl_basic_cmd = {.dst_addr_u.addr_short = req->info.src_address.u.short_addr, + .dst_endpoint = req->info.src_endpoint, .src_endpoint = MOD_NODE_CONFIG_EP}, + .address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, + .profile_id = ESP_ZB_AF_HA_PROFILE_ID, .cluster_id = MOD_NODE_CONFIG_CLUSTER, + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI, .dis_default_resp = 1, + .custom_cmd_id = command, + .data = {.type = ESP_ZB_ZCL_ATTR_TYPE_OCTET_STRING, .size = size + 1, .value = wire}, + }; + uint8_t tsn = esp_zb_zcl_custom_cluster_cmd_resp(&rsp); + ESP_LOGI(TAG, "Config response cmd=0x%02x bytes=%u tsn=%u", command, size, tsn); +} +static void status_reply(const esp_zb_zcl_custom_cluster_command_message_t *req, uint8_t cmd, uint8_t status) { + uint8_t data[] = {cmd, status}; + config_reply(req, MOD_NODE_RSP_STATUS, data, sizeof(data)); +} + +static void config_command(const esp_zb_zcl_custom_cluster_command_message_t *req) { + if (req->info.status != ESP_ZB_ZCL_STATUS_SUCCESS || req->info.cluster != MOD_NODE_CONFIG_CLUSTER || + req->info.src_address.addr_type != ESP_ZB_ZCL_ADDR_TYPE_SHORT) return; + s_hub_seen = xTaskGetTickCount(); + status_led_set(true); + ESP_LOGI(TAG, "Config request cmd=0x%02x bytes=%u from=0x%04x ep=%u", + (unsigned)req->info.command.id, (unsigned)req->data.size, + req->info.src_address.u.short_addr, req->info.src_endpoint); + const uint8_t *p = req->data.value; + size_t n = req->data.size; + if (p && n && p[0] == n - 1) { p++; n--; } + switch (req->info.command.id) { + case 0x00: break; /* Nano coordinator heartbeat; no reply required. */ + case MOD_NODE_CMD_GET_INFO: { + uint8_t len = (uint8_t)strnlen(s_cfg.device_name, MOD_NODE_NAME_MAX - 1); + uint8_t data[6 + MOD_NODE_NAME_MAX] = {PROTOCOL_VERSION, s_cfg.channel_count, MOD_NODE_CHANNEL_MAX, len, + (uint8_t)s_cfg.status_led_gpio, s_cfg.status_led_active_low ? 1 : 0}; + memcpy(data + 6, s_cfg.device_name, len); + config_reply(req, MOD_NODE_RSP_INFO, data, 6 + len); + break; + } + case MOD_NODE_CMD_GET_CHANNEL: { + if (!p || n < 1 || p[0] >= s_cfg.channel_count) { status_reply(req, req->info.command.id, 2); break; } + uint8_t i = p[0], len = (uint8_t)strnlen(s_cfg.channel[i].name, MOD_NODE_CHANNEL_NAME_MAX - 1); + const mod_node_channel_t *c = &s_cfg.channel[i]; + uint8_t data[8 + MOD_NODE_CHANNEL_NAME_MAX] = {i, c->type, (uint8_t)c->gpio, + (uint8_t)((c->active_low ? 1 : 0) | (c->pull_up ? 2 : 0) | (c->boot_on ? 4 : 0)), len}; + memcpy(data + 5, c->name, len); + data[5 + len] = (uint8_t)(s_cfg.poll_interval_s[i] >> 8); + data[6 + len] = (uint8_t)s_cfg.poll_interval_s[i]; + data[7 + len] = i >= s_runtime.channel_count || + memcmp(c, &s_runtime.channel[i], sizeof(*c)) != 0 || + s_cfg.poll_interval_s[i] != s_runtime.poll_interval_s[i]; + config_reply(req, MOD_NODE_RSP_CHANNEL, data, 8 + len); + break; + } + case MOD_NODE_CMD_SET_NAME: { + if (!p || n < 2 || p[0] == 0 || p[0] >= MOD_NODE_NAME_MAX || n < (size_t)p[0] + 1) { status_reply(req, req->info.command.id, 2); break; } + mod_node_config_t next = s_cfg; + memcpy(next.device_name, p + 1, p[0]); next.device_name[p[0]] = 0; + if (!mod_node_config_save(&next)) { status_reply(req, req->info.command.id, 3); break; } + s_cfg = next; status_reply(req, req->info.command.id, 0); break; + } + case MOD_NODE_CMD_SET_CHANNEL: { + if (!p || n < 5 || p[0] >= MOD_NODE_CHANNEL_MAX || p[1] > MOD_NODE_CH_DS18B20 || + p[4] >= MOD_NODE_CHANNEL_NAME_MAX || n < (size_t)p[4] + 5) { status_reply(req, req->info.command.id, 2); break; } + mod_node_config_t next = s_cfg; uint8_t i = p[0]; + if (next.channel_count <= i) next.channel_count = i + 1; + mod_node_channel_t *c = &next.channel[i]; + c->type = p[1]; c->gpio = (int8_t)p[2]; c->active_low = (p[3] & 1) != 0; + c->pull_up = (p[3] & 2) != 0; c->boot_on = (p[3] & 4) != 0; + memcpy(c->name, p + 5, p[4]); c->name[p[4]] = 0; + if (!mod_node_config_save(&next)) { status_reply(req, req->info.command.id, 3); break; } + s_cfg = next; status_reply(req, req->info.command.id, 0); break; + } + case MOD_NODE_CMD_SET_POLL: { /* channel, seconds big-endian */ + if (!p || n != 3 || p[0] >= s_cfg.channel_count) { status_reply(req, 0x15, 2); break; } + mod_node_config_t next = s_cfg; + next.poll_interval_s[p[0]] = ((uint16_t)p[1] << 8) | p[2]; + if (!mod_node_config_save(&next)) { status_reply(req, 0x15, 2); break; } + s_cfg = next; status_reply(req, 0x15, 0); break; + } + case MOD_NODE_CMD_APPLY_REBOOT: + status_reply(req, req->info.command.id, 0); + esp_zb_scheduler_alarm((esp_zb_callback_t)esp_restart, 0, 300); + break; + case MOD_NODE_CMD_SET_STATUS_LED: { + if (!p || n < 2) { status_reply(req, req->info.command.id, 2); break; } + mod_node_config_t next = s_cfg; + next.status_led_gpio = (int8_t)p[0]; next.status_led_active_low = (p[1] & 1) != 0; + bool active_conflict = false; + for (unsigned i=0; i= 0 && + s_runtime.channel[i].gpio == next.status_led_gpio) active_conflict = true; + if (active_conflict || !mod_node_config_save(&next)) { status_reply(req, req->info.command.id, 3); break; } + int8_t old_gpio = s_cfg.status_led_gpio; bool old_active_low = s_cfg.status_led_active_low; + s_cfg = next; status_led_reconfigure(old_gpio, old_active_low); + status_reply(req, req->info.command.id, 0); break; + } + default: status_reply(req, req->info.command.id, 1); break; + } +} + +static esp_err_t action_handler(esp_zb_core_action_callback_id_t id, const void *message) { + if (!message) return ESP_OK; + ESP_LOGD(TAG, "Zigbee callback 0x%x", (unsigned)id); + if (id == ESP_ZB_CORE_CMD_CUSTOM_CLUSTER_REQ_CB_ID) { config_command(message); return ESP_OK; } + if (id != ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID) return ESP_OK; + const esp_zb_zcl_set_attr_value_message_t *m = message; + if (m->info.status == ESP_ZB_ZCL_STATUS_SUCCESS && m->info.cluster == ESP_ZB_ZCL_CLUSTER_ID_ON_OFF && + m->attribute.id == ESP_ZB_ZCL_ATTR_ON_OFF_ON_OFF_ID && m->attribute.data.value && m->info.dst_endpoint >= FIRST_CHANNEL_EP) { + unsigned i = m->info.dst_endpoint - FIRST_CHANNEL_EP; + if (i < s_cfg.channel_count) output_set(i, *(const bool *)m->attribute.data.value); + } + return ESP_OK; +} +static void retry_steering(uint8_t mode) { ESP_ERROR_CHECK(esp_zb_bdb_start_top_level_commissioning(mode)); } +void esp_zb_app_signal_handler(esp_zb_app_signal_t *s) { + esp_zb_app_signal_type_t t = *(s->p_app_signal); esp_err_t e = s->esp_err_status; + switch (t) { + case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_ERROR_CHECK(esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION)); break; + case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START: case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT: + status_led_set(false); + if (e == ESP_OK && esp_zb_bdb_is_factory_new()) { + ESP_ERROR_CHECK(esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING)); + } + break; + case ESP_ZB_BDB_SIGNAL_STEERING: + if (e == ESP_OK) { + status_led_set(false); + ESP_LOGI(TAG, "Joined PAN 0x%04x channel %u", esp_zb_get_pan_id(), esp_zb_get_current_channel()); + } else { + status_led_set(false); + esp_zb_scheduler_alarm((esp_zb_callback_t)retry_steering, ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000); + } + break; + default: break; + } +} + +static void status_task(void *arg) { + (void)arg; + for (;;) { + const TickType_t now = xTaskGetTickCount(); + if (s_hub_seen == 0 || now - s_hub_seen > pdMS_TO_TICKS(6000)) status_led_set(false); + vTaskDelay(pdMS_TO_TICKS(500)); + } +} + +static void report_digital_input(unsigned i, bool value) +{ + esp_zb_lock_acquire(portMAX_DELAY); + esp_zb_zcl_set_attribute_val(FIRST_CHANNEL_EP + i, + ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE, + ESP_ZB_ZCL_ATTR_BINARY_INPUT_PRESENT_VALUE_ID, &value, false); + if (esp_zb_bdb_dev_joined()) { + esp_zb_zcl_report_attr_cmd_t report = { + .zcl_basic_cmd = {.dst_addr_u.addr_short = 0, .dst_endpoint = 1, + .src_endpoint = FIRST_CHANNEL_EP + i}, + .address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, + .clusterID = ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI, + .dis_default_resp = 1, + .attributeID = ESP_ZB_ZCL_ATTR_BINARY_INPUT_PRESENT_VALUE_ID, + }; + esp_err_t err = esp_zb_zcl_report_attr_cmd_req(&report); + if (err != ESP_OK) ESP_LOGW(TAG, "Digital input report: %s", esp_err_to_name(err)); + } + esp_zb_lock_release(); +} + +static void digital_input_task(void *arg) +{ + (void)arg; + bool candidate[MOD_NODE_CHANNEL_MAX] = {0}; + bool stable[MOD_NODE_CHANNEL_MAX] = {0}; + uint8_t consecutive[MOD_NODE_CHANNEL_MAX] = {0}; + bool initialized[MOD_NODE_CHANNEL_MAX] = {0}; + TickType_t last_report[MOD_NODE_CHANNEL_MAX] = {0}; + for (;;) { + for (unsigned i = 0; i < s_runtime.channel_count; ++i) { + const mod_node_channel_t *c = &s_runtime.channel[i]; + if (c->type != MOD_NODE_CH_DIGITAL_INPUT) continue; + bool value = gpio_get_level((gpio_num_t)c->gpio) != 0; + if (c->active_low) value = !value; + if (value != candidate[i]) { + candidate[i] = value; + consecutive[i] = 1; + } else if (consecutive[i] < 3) { + consecutive[i]++; + } + /* Three equal 20 ms samples suppress contact bounce and noise. + * The first report is deliberately delayed by the same 60 ms. */ + const TickType_t now = xTaskGetTickCount(); + if (consecutive[i] == 3 && (!initialized[i] || stable[i] != candidate[i] || + now - last_report[i] >= pdMS_TO_TICKS(10000))) { + initialized[i] = true; + stable[i] = candidate[i]; + last_report[i] = now; + report_digital_input(i, stable[i]); + } + } + vTaskDelay(pdMS_TO_TICKS(20)); + } +} + +static void temperature_task(void *arg) { + (void)arg; + TickType_t last[MOD_NODE_CHANNEL_MAX] = {0}; + bool sampled[MOD_NODE_CHANNEL_MAX] = {false}; + for (;;) { + for (unsigned i=0; i= 0 && mod_node_status_led_gpio_allowed(s_cfg.status_led_gpio)) { + gpio_config_t status_led = {.pin_bit_mask = 1ULL << s_cfg.status_led_gpio, .mode = GPIO_MODE_OUTPUT}; + ESP_ERROR_CHECK(gpio_config(&status_led)); + } + status_led_set(false); + xTaskCreate(status_task, "node_status", 2048, NULL, 4, NULL); + for (unsigned i = 0; i < s_cfg.channel_count; i++) { mod_node_channel_t *c = &s_cfg.channel[i]; if (!is_output(c) || c->gpio < 0 || !mod_node_gpio_allowed(c->gpio)) continue; + gpio_config_t io = {.pin_bit_mask = 1ULL << c->gpio, .mode = GPIO_MODE_OUTPUT}; output_set(i, c->boot_on); ESP_ERROR_CHECK(gpio_config(&io)); } + for (unsigned i = 0; i < s_runtime.channel_count; ++i) { + mod_node_channel_t *c = &s_runtime.channel[i]; + if (c->type != MOD_NODE_CH_DIGITAL_INPUT || c->gpio < 0) continue; + gpio_config_t io = {.pin_bit_mask = 1ULL << c->gpio, .mode = GPIO_MODE_INPUT, + .pull_up_en = c->pull_up ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE}; + ESP_ERROR_CHECK(gpio_config(&io)); + } + esp_zb_platform_config_t p = {.radio_config = {.radio_mode = ZB_RADIO_MODE_NATIVE}, .host_config = {.host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE}}; + ESP_ERROR_CHECK(esp_zb_platform_config(&p)); ESP_LOGI(TAG, "%s: %u channels", s_cfg.device_name, s_cfg.channel_count); xTaskCreate(zigbee_task, "zigbee", 7168, NULL, 5, NULL); +} diff --git a/firmware/modulus-zigbee-node/main/node_config.c b/firmware/modulus-zigbee-node/main/node_config.c new file mode 100644 index 0000000..0116ce5 --- /dev/null +++ b/firmware/modulus-zigbee-node/main/node_config.c @@ -0,0 +1,75 @@ +#include "node_config.h" +#include "nvs.h" +#include +#include +#define CFG_SCHEMA 4 +#define CFG_NS "modnode" +typedef struct { uint16_t schema_version; char device_name[MOD_NODE_NAME_MAX]; uint8_t channel_count; + mod_node_channel_t channel[MOD_NODE_CHANNEL_MAX]; } mod_node_config_v2_t; +typedef struct { uint16_t schema_version; char device_name[MOD_NODE_NAME_MAX]; uint8_t channel_count; + int8_t status_led_gpio; bool status_led_active_low; + mod_node_channel_t channel[MOD_NODE_CHANNEL_MAX]; } mod_node_config_v3_t; +bool mod_node_gpio_allowed(int gpio) { + /* XIAO ESP32-C6 D0-D10 headers. Keep RF-switch GPIO3/14, + * boot/strapping, USB, flash and the status LED away from channels. */ + switch (gpio) { + case 0: case 1: case 2: case 16: case 17: case 18: + case 19: case 20: case 21: case 22: case 23: return true; + default: return false; + } +} +bool mod_node_status_led_gpio_allowed(int gpio) { + return gpio == 15 || mod_node_gpio_allowed(gpio); +} +static void defaults(mod_node_config_t *cfg) { + memset(cfg,0,sizeof(*cfg)); cfg->schema_version=CFG_SCHEMA; strcpy(cfg->device_name,"Modulus Zigbee Node"); cfg->channel_count=4; cfg->status_led_gpio=15; cfg->status_led_active_low=true; + for(int i=0;ipoll_interval_s[i]=MOD_NODE_POLL_DEFAULT_S; + for(int i=0;i<4;i++){ cfg->channel[i].type=MOD_NODE_CH_SWITCH; cfg->channel[i].gpio=-1; snprintf(cfg->channel[i].name,sizeof(cfg->channel[i].name),"Switch %d",i+1); } +} +void mod_node_config_load(mod_node_config_t *cfg) { + defaults(cfg); nvs_handle_t h; if(nvs_open(CFG_NS,NVS_READONLY,&h)!=ESP_OK)return; size_t n=0; + if(nvs_get_blob(h,"config",NULL,&n)==ESP_OK && n==sizeof(mod_node_config_v2_t)) { + mod_node_config_v2_t old; + if(nvs_get_blob(h,"config",&old,&n)==ESP_OK && old.schema_version==2 && old.channel_count<=MOD_NODE_CHANNEL_MAX) { + memcpy(cfg->device_name,old.device_name,sizeof(cfg->device_name)); cfg->channel_count=old.channel_count; + memcpy(cfg->channel,old.channel,sizeof(cfg->channel)); + } + if (!mod_node_config_valid(cfg)) defaults(cfg); + nvs_close(h); return; + } + if(n==sizeof(mod_node_config_v3_t)) { + mod_node_config_v3_t old; + if(nvs_get_blob(h,"config",&old,&n)==ESP_OK && old.schema_version==3) { + memcpy(cfg->device_name,old.device_name,sizeof(cfg->device_name)); + cfg->channel_count=old.channel_count; + cfg->status_led_gpio=old.status_led_gpio; + cfg->status_led_active_low=old.status_led_active_low; + memcpy(cfg->channel,old.channel,sizeof(cfg->channel)); + } + if (!mod_node_config_valid(cfg)) defaults(cfg); + nvs_close(h); return; + } + n=sizeof(*cfg); mod_node_config_t v; + if(nvs_get_blob(h,"config",&v,&n)==ESP_OK&&n==sizeof(v)&&v.schema_version==CFG_SCHEMA&&mod_node_config_valid(&v)) { + *cfg=v; + } + nvs_close(h); +} +bool mod_node_config_valid(const mod_node_config_t *cfg) { + if(!cfg||!cfg->device_name[0]||cfg->channel_count>MOD_NODE_CHANNEL_MAX) return false; + if (!memchr(cfg->device_name,0,sizeof(cfg->device_name))) return false; + for (int i=0;ichannel_count;i++) { + if (cfg->channel[i].type>MOD_NODE_CH_DS18B20 || cfg->channel[i].gpio < -1 || + !memchr(cfg->channel[i].name,0,sizeof(cfg->channel[i].name)) || + cfg->poll_interval_s[i]poll_interval_s[i]>MOD_NODE_POLL_MAX_S) return false; + } + if (cfg->status_led_gpio < -1) return false; + bool used[31]={0}; + if(cfg->status_led_gpio >= 0) { if(!mod_node_status_led_gpio_allowed(cfg->status_led_gpio)) return false; used[cfg->status_led_gpio]=true; } + for(int i=0;ichannel_count;i++){int g=cfg->channel[i].gpio;if(cfg->channel[i].type==0||g<0)continue;if(!mod_node_gpio_allowed(g)||used[g])return false;used[g]=true;} + return true; +} +bool mod_node_config_save(const mod_node_config_t *cfg) { + if (!mod_node_config_valid(cfg)) return false; + nvs_handle_t h;if(nvs_open(CFG_NS,NVS_READWRITE,&h)!=ESP_OK)return false;esp_err_t e=nvs_set_blob(h,"config",cfg,sizeof(*cfg));if(e==ESP_OK)e=nvs_commit(h);nvs_close(h);return e==ESP_OK; +} diff --git a/firmware/modulus-zigbee-node/main/node_config.h b/firmware/modulus-zigbee-node/main/node_config.h new file mode 100644 index 0000000..9888497 --- /dev/null +++ b/firmware/modulus-zigbee-node/main/node_config.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#define MOD_NODE_CHANNEL_MAX 12 +#define MOD_NODE_CHANNEL_INITIAL 4 +#define MOD_NODE_NAME_MAX 32 +#define MOD_NODE_CHANNEL_NAME_MAX 24 +#define MOD_NODE_POLL_DEFAULT_S 15 +#define MOD_NODE_POLL_MIN_S 15 +#define MOD_NODE_POLL_MAX_S 3600 +typedef enum { MOD_NODE_CH_DISABLED=0, MOD_NODE_CH_SWITCH=1, MOD_NODE_CH_DIGITAL_OUTPUT=2, + MOD_NODE_CH_DIGITAL_INPUT=3, MOD_NODE_CH_DS18B20=4 } mod_node_channel_type_t; +typedef struct { uint8_t type; int8_t gpio; bool active_low; bool pull_up; bool boot_on; + char name[MOD_NODE_CHANNEL_NAME_MAX]; } mod_node_channel_t; +typedef struct { uint16_t schema_version; char device_name[MOD_NODE_NAME_MAX]; uint8_t channel_count; + int8_t status_led_gpio; bool status_led_active_low; + mod_node_channel_t channel[MOD_NODE_CHANNEL_MAX]; + uint16_t poll_interval_s[MOD_NODE_CHANNEL_MAX]; } mod_node_config_t; +void mod_node_config_load(mod_node_config_t *cfg); +bool mod_node_config_save(const mod_node_config_t *cfg); +bool mod_node_config_valid(const mod_node_config_t *cfg); +bool mod_node_gpio_allowed(int gpio); +bool mod_node_status_led_gpio_allowed(int gpio); diff --git a/firmware/modulus-zigbee-node/main/node_protocol.h b/firmware/modulus-zigbee-node/main/node_protocol.h new file mode 100644 index 0000000..bda6c64 --- /dev/null +++ b/firmware/modulus-zigbee-node/main/node_protocol.h @@ -0,0 +1,14 @@ +#pragma once +#define MOD_NODE_CONFIG_CLUSTER 0xFC10 +#define MOD_NODE_CONFIG_EP 1 +#define MOD_NODE_CMD_GET_INFO 0x01 +#define MOD_NODE_CMD_GET_CHANNEL 0x02 +#define MOD_NODE_CMD_SET_NAME 0x10 +#define MOD_NODE_CMD_SET_CHANNEL 0x11 +#define MOD_NODE_CMD_APPLY_REBOOT 0x12 +#define MOD_NODE_CMD_FACTORY_RESET 0x13 +#define MOD_NODE_CMD_SET_STATUS_LED 0x14 +#define MOD_NODE_CMD_SET_POLL 0x15 /* [channel][seconds:2BE], protocol >= 3 */ +#define MOD_NODE_RSP_INFO 0x81 +#define MOD_NODE_RSP_CHANNEL 0x82 +#define MOD_NODE_RSP_STATUS 0x90 diff --git a/firmware/modulus-zigbee-node/main/node_temperature.c b/firmware/modulus-zigbee-node/main/node_temperature.c new file mode 100644 index 0000000..4035dde --- /dev/null +++ b/firmware/modulus-zigbee-node/main/node_temperature.c @@ -0,0 +1,55 @@ +#include "node_temperature.h" +#include "node_temperature_decode.h" +#include "node_config.h" +#include "onewire_bus.h" +#include "onewire_device.h" +#include "ds18b20.h" +#include "driver/gpio.h" +#include + +esp_err_t mod_node_temperature_read(int gpio, int16_t *centi_c) +{ + *centi_c = MOD_NODE_TEMP_INVALID; + if (!mod_node_gpio_allowed(gpio)) return ESP_ERR_INVALID_ARG; + onewire_bus_handle_t bus = NULL; + onewire_device_iter_handle_t iter = NULL; + ds18b20_device_handle_t sensor = NULL; + onewire_bus_config_t cfg = {.bus_gpio_num = gpio}; + onewire_bus_rmt_config_t rmt = {.max_rx_bytes = 10}; + esp_err_t err = onewire_new_bus_rmt(&cfg, &rmt, &bus); + if (err != ESP_OK) return err; + /* Release the RMT pair after every sample: twelve configured channels + * must not require twelve simultaneously allocated RMT peripherals. */ + if ((err = onewire_new_device_iter(bus, &iter)) != ESP_OK) goto done; + onewire_device_t dev, extra; + if ((err = onewire_device_iter_get_next(iter, &dev)) != ESP_OK) goto done; + uint8_t rom[8]; + memcpy(rom, &dev.address, sizeof(rom)); + if (rom[0] != 0x28 || mod_node_crc8(rom, 7) != rom[7]) { + err = ESP_ERR_INVALID_RESPONSE; goto done; + } + err = onewire_device_iter_get_next(iter, &extra); + if (err == ESP_OK) { err = ESP_ERR_NOT_SUPPORTED; goto done; } + if (err != ESP_ERR_NOT_FOUND) goto done; + ds18b20_config_t ds_cfg = {}; + if ((err = ds18b20_new_device_from_enumeration(&dev, &ds_cfg, &sensor)) != ESP_OK) goto done; + if ((err = ds18b20_set_resolution(sensor, DS18B20_RESOLUTION_12B)) != ESP_OK) goto done; + if ((err = ds18b20_trigger_temperature_conversion(sensor)) != ESP_OK) goto done; + /* Require a completed conversion, not just a successful write. */ + uint8_t ready = 0; + if ((err = onewire_bus_read_bit(bus, &ready)) != ESP_OK) goto done; + if (!ready) { err = ESP_ERR_TIMEOUT; goto done; } + if ((err = onewire_bus_reset(bus)) != ESP_OK) goto done; + uint8_t command[10] = {0x55}; /* MATCH ROM + READ SCRATCHPAD */ + memcpy(command + 1, rom, 8); command[9] = 0xbe; + if ((err = onewire_bus_write_bytes(bus, command, sizeof(command))) != ESP_OK) goto done; + uint8_t scratch[9]; + if ((err = onewire_bus_read_bytes(bus, scratch, sizeof(scratch))) != ESP_OK) goto done; + if (!mod_node_temperature_decode(scratch, centi_c)) err = ESP_ERR_INVALID_RESPONSE; +done: + if (sensor) ds18b20_del_device(sensor); + if (iter) onewire_del_device_iter(iter); + onewire_bus_del(bus); + gpio_reset_pin((gpio_num_t)gpio); + return err; +} diff --git a/firmware/modulus-zigbee-node/main/node_temperature.h b/firmware/modulus-zigbee-node/main/node_temperature.h new file mode 100644 index 0000000..a33db5f --- /dev/null +++ b/firmware/modulus-zigbee-node/main/node_temperature.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include "esp_err.h" +#define MOD_NODE_TEMP_INVALID INT16_MIN +/* Exactly one externally powered DS18B20 per GPIO; retries on the next poll. */ +esp_err_t mod_node_temperature_read(int gpio, int16_t *centi_c); diff --git a/firmware/modulus-zigbee-node/main/node_temperature_decode.h b/firmware/modulus-zigbee-node/main/node_temperature_decode.h new file mode 100644 index 0000000..f1d9bce --- /dev/null +++ b/firmware/modulus-zigbee-node/main/node_temperature_decode.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include + +static inline uint8_t mod_node_crc8(const uint8_t *data, unsigned count) +{ + uint8_t crc = 0; + while (count--) { + uint8_t b = *data++; + for (unsigned j = 0; j < 8; ++j) { + uint8_t mix = (crc ^ b) & 1; + crc >>= 1; + if (mix) crc ^= 0x8c; + b >>= 1; + } + } + return crc; +} + +static inline bool mod_node_temperature_decode(const uint8_t s[9], int16_t *centi_c) +{ + *centi_c = INT16_MIN; + if (mod_node_crc8(s, 8) != s[8]) return false; + /* We explicitly request 12-bit operation. Reject all-zero/bad scratchpads. */ + if (s[4] != 0x7f || s[5] != 0xff || s[7] != 0x10) return false; + if (s[0] == 0x50 && s[1] == 0x05 && s[6] == 0x0c) return false; + int32_t raw = (int16_t)((uint16_t)s[0] | ((uint16_t)s[1] << 8)); + if (raw < -55 * 16 || raw > 125 * 16) return false; + const int32_t scaled = raw * 100; + *centi_c = (int16_t)((scaled + (scaled < 0 ? -8 : 8)) / 16); + return true; +} diff --git a/firmware/modulus-zigbee-node/partitions.csv b/firmware/modulus-zigbee-node/partitions.csv new file mode 100644 index 0000000..355b82b --- /dev/null +++ b/firmware/modulus-zigbee-node/partitions.csv @@ -0,0 +1,8 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +otadata, data, ota, 0xf000, 0x2000 +phy_init, data, phy, 0x11000, 0x1000 +ota_0, app, ota_0, 0x20000, 0x170000 +ota_1, app, ota_1, 0x190000,0x170000 +zb_storage, data, fat, 0x300000,0x4000 +zb_fct, data, fat, 0x304000,0x400 diff --git a/firmware/modulus-zigbee-node/sdkconfig.defaults b/firmware/modulus-zigbee-node/sdkconfig.defaults new file mode 100644 index 0000000..5fdb503 --- /dev/null +++ b/firmware/modulus-zigbee-node/sdkconfig.defaults @@ -0,0 +1,7 @@ +CONFIG_ZB_ENABLED=y +CONFIG_ZB_ZCZR=y +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" diff --git a/firmware/modulus-zigbee-node/tests/nvs.h b/firmware/modulus-zigbee-node/tests/nvs.h new file mode 100644 index 0000000..491d327 --- /dev/null +++ b/firmware/modulus-zigbee-node/tests/nvs.h @@ -0,0 +1,12 @@ +#pragma once +#include +typedef int nvs_handle_t; +typedef int esp_err_t; +#define ESP_OK 0 +#define NVS_READONLY 0 +#define NVS_READWRITE 1 +int nvs_open(const char *, int, nvs_handle_t *); +int nvs_get_blob(nvs_handle_t, const char *, void *, size_t *); +int nvs_set_blob(nvs_handle_t, const char *, const void *, size_t); +int nvs_commit(nvs_handle_t); +void nvs_close(nvs_handle_t); diff --git a/firmware/modulus-zigbee-node/tests/test_node.c b/firmware/modulus-zigbee-node/tests/test_node.c new file mode 100644 index 0000000..00e4a24 --- /dev/null +++ b/firmware/modulus-zigbee-node/tests/test_node.c @@ -0,0 +1,81 @@ +#include +#include +#include +#include "../main/node_config.c" +#include "../main/node_temperature_decode.h" + +static unsigned char blob[1024]; +static size_t blob_size; +int nvs_open(const char *n, int m, nvs_handle_t *h) { (void)n; (void)m; *h=1; return 0; } +int nvs_get_blob(nvs_handle_t h, const char *k, void *p, size_t *n) { + (void)h; (void)k; + if (!p) { *n=blob_size; return 0; } + if (*n>8),0,0,0x7f,0xff,0,0x10,0}; + s[8]=mod_node_crc8(s,8); + int16_t value; + assert(mod_node_temperature_decode(s,&value)); assert(value==expected); + s[0]^=1; + assert(!mod_node_temperature_decode(s,&value)); assert(value==INT16_MIN); +} +int main(void) { + sample(0,0); sample(1,6); sample(-1,-6); sample(401,2506); + sample(-161,-1006); sample(-880,-5500); sample(2000,12500); + sample(1360,8500); /* A completed real 85 C sample is valid. */ + uint8_t bad[9]={0}; int16_t value; + assert(!mod_node_temperature_decode(bad,&value)); + uint8_t startup[9]={0x50,0x05,0,0,0x7f,0xff,0x0c,0x10,0}; + startup[8]=mod_node_crc8(startup,8); + assert(!mod_node_temperature_decode(startup,&value)); + startup[0]=0xd1; startup[1]=7; startup[6]=0; startup[8]=mod_node_crc8(startup,8); + assert(!mod_node_temperature_decode(startup,&value)); /* 125.0625 C */ + + mod_node_config_t cfg, loaded; + mod_node_config_load(&cfg); assert(cfg.poll_interval_s[0]==15); + cfg.channel[0].type=MOD_NODE_CH_DS18B20; cfg.channel[0].gpio=22; + cfg.channel[1].type=MOD_NODE_CH_DS18B20; cfg.channel[1].gpio=23; + cfg.poll_interval_s[0]=60; cfg.poll_interval_s[1]=3600; + assert(mod_node_config_save(&cfg)); mod_node_config_load(&loaded); + assert(loaded.poll_interval_s[0]==60 && loaded.poll_interval_s[1]==3600); + cfg.poll_interval_s[0]=14; assert(!mod_node_config_save(&cfg)); + cfg.poll_interval_s[0]=3601; assert(!mod_node_config_save(&cfg)); + cfg.poll_interval_s[0]=15; + cfg.channel[1].gpio=22; assert(!mod_node_config_save(&cfg)); + cfg.channel[1].type=MOD_NODE_CH_SWITCH; assert(!mod_node_config_save(&cfg)); + cfg.channel[1].gpio=15; assert(!mod_node_config_save(&cfg)); + cfg.channel[1].gpio=12; assert(!mod_node_config_save(&cfg)); + cfg.channel[1].gpio=3; assert(!mod_node_config_save(&cfg)); + cfg.channel[1].gpio=14; assert(!mod_node_config_save(&cfg)); + cfg.channel[1].gpio=23; assert(mod_node_config_valid(&cfg)); + cfg.channel[1].type=5; assert(!mod_node_config_valid(&cfg)); + cfg.channel[1].type=MOD_NODE_CH_DIGITAL_INPUT; + cfg.channel[1].pull_up=true; cfg.channel[1].active_low=true; + assert(mod_node_config_valid(&cfg)); + + mod_node_config_v3_t old={0}; + old.schema_version=3; strcpy(old.device_name,"Existing node"); old.channel_count=4; + old.status_led_gpio=15; old.status_led_active_low=true; + memcpy(old.channel,cfg.channel,sizeof(old.channel)); + nvs_set_blob(1,"config",&old,sizeof(old)); mod_node_config_load(&loaded); + assert(!strcmp(loaded.device_name,"Existing node")); assert(loaded.channel[0].gpio==22); + assert(loaded.poll_interval_s[0]==15 && loaded.status_led_active_low); + assert(loaded.schema_version==4); + mod_node_config_v2_t older={0}; older.schema_version=2; older.channel_count=4; + strcpy(older.device_name,"Older node"); memcpy(older.channel,cfg.channel,sizeof(older.channel)); + nvs_set_blob(1,"config",&older,sizeof(older)); mod_node_config_load(&loaded); + assert(!strcmp(loaded.device_name,"Older node")); assert(loaded.poll_interval_s[3]==15); + assert(loaded.status_led_gpio==15); + older.channel[1].gpio=22; + nvs_set_blob(1,"config",&older,sizeof(older)); mod_node_config_load(&loaded); + assert(loaded.channel[0].gpio==-1); /* Bad persisted conflicts fall back safely. */ + puts("PASS: signed temperatures, CRC, startup/range errors, poll bounds, GPIO conflicts, persistence and v2/v3 migration"); +} diff --git a/firmware/nanoh2/CMakeLists.txt b/firmware/nanoh2/CMakeLists.txt index 049377c..fa27097 100644 --- a/firmware/nanoh2/CMakeLists.txt +++ b/firmware/nanoh2/CMakeLists.txt @@ -3,4 +3,5 @@ cmake_minimum_required(VERSION 3.16) include($ENV{IDF_PATH}/tools/cmake/project.cmake) +set(PROJECT_VER "3.1.4-dev") project(modulus_nanoh2) diff --git a/firmware/nanoh2/dependencies.lock b/firmware/nanoh2/dependencies.lock index ac0b2ea..6c48309 100644 --- a/firmware/nanoh2/dependencies.lock +++ b/firmware/nanoh2/dependencies.lock @@ -22,7 +22,7 @@ dependencies: idf: source: type: idf - version: 6.2.0 + version: 6.0.1 direct_dependencies: - espressif/esp-zboss-lib - espressif/esp-zigbee-lib diff --git a/firmware/nanoh2/main/CMakeLists.txt b/firmware/nanoh2/main/CMakeLists.txt index 2c8a2ef..98401ac 100644 --- a/firmware/nanoh2/main/CMakeLists.txt +++ b/firmware/nanoh2/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "main.c" "zb_uart_link.c" "zigbee_hub.c" + SRCS "main.c" "zb_uart_link.c" "zigbee_hub.c" "nano_ota.c" INCLUDE_DIRS "." - REQUIRES esp_driver_gpio esp_driver_uart esp_timer nvs_flash + REQUIRES app_update esp_driver_gpio esp_driver_uart esp_timer nvs_flash ) diff --git a/firmware/nanoh2/main/main.c b/firmware/nanoh2/main/main.c index 6dd65a3..8966439 100644 --- a/firmware/nanoh2/main/main.c +++ b/firmware/nanoh2/main/main.c @@ -7,6 +7,7 @@ * double-blink = formed but host link silent (P4 down or unwired). */ #include "nanoh2_hw.h" +#include "nano_ota.h" #include "zb_uart_link.h" #include "zigbee_hub.h" @@ -52,6 +53,7 @@ void app_main(void) ESP_ERROR_CHECK(nvs_flash_erase()); ESP_ERROR_CHECK(nvs_flash_init()); } + nano_ota_confirm_running(); zb_uart_link_init(zigbee_hub_process_cmd); diff --git a/firmware/nanoh2/main/nano_ota.c b/firmware/nanoh2/main/nano_ota.c new file mode 100644 index 0000000..89e47af --- /dev/null +++ b/firmware/nanoh2/main/nano_ota.c @@ -0,0 +1,131 @@ +#include "nano_ota.h" +#include "zb_proto.h" + +#include "esp_app_format.h" +#include "esp_crc.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "nano_ota"; +static esp_ota_handle_t s_handle; +static const esp_partition_t *s_partition; +static uint32_t s_size; +static uint32_t s_written; +static uint32_t s_last_offset; +static uint32_t s_last_crc; +static uint16_t s_last_len; +static bool s_active; +static bool s_reboot_armed; + +static uint32_t be32(const uint8_t *p) +{ + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | p[3]; +} + +static void reset_session(bool abort_write) +{ + if (abort_write && s_active) (void)esp_ota_abort(s_handle); + s_handle = 0; + s_partition = NULL; + s_size = 0; + s_written = 0; + s_last_offset = 0; + s_last_crc = 0; + s_last_len = 0; + s_active = false; + s_reboot_armed = false; +} + +static void reboot_task(void *unused) +{ + (void)unused; + vTaskDelay(pdMS_TO_TICKS(400)); + esp_restart(); +} + +void nano_ota_confirm_running(void) +{ + esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); + if (err != ESP_OK && err != ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, "Confirm running image: %s", esp_err_to_name(err)); + } +} + +bool nano_ota_handle(uint8_t cmd, const uint8_t *args, uint16_t len, uint8_t *reason) +{ + if (cmd < ZIGBEE_CMD_OTA_BEGIN || cmd > ZIGBEE_CMD_OTA_REBOOT) return false; + if (!reason) return true; + *reason = 0; + + if (cmd == ZIGBEE_CMD_OTA_BEGIN) { + if (len != 4) { *reason = 1; return true; } + reset_session(true); + s_size = be32(args); + s_partition = esp_ota_get_next_update_partition(NULL); + if (!s_partition || !s_size || s_size > s_partition->size) { + reset_session(false); *reason = 2; return true; + } + esp_err_t err = esp_ota_begin(s_partition, s_size, &s_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Begin: %s", esp_err_to_name(err)); + reset_session(false); *reason = 3; return true; + } + s_active = true; + ESP_LOGW(TAG, "Started: %lu bytes -> %s", (unsigned long)s_size, s_partition->label); + return true; + } + + if (cmd == ZIGBEE_CMD_OTA_DATA) { + if (!s_active || len <= 8) { *reason = 4; return true; } + uint32_t offset = be32(args); + uint32_t crc = be32(args + 4); + const uint8_t *data = args + 8; + uint16_t data_len = len - 8; + /* The P4 retries a command with a fresh link sequence if the ACK was + * lost. Acknowledge the exact preceding block without writing twice. */ + if (s_written && offset == s_last_offset && data_len == s_last_len && crc == s_last_crc) { + return true; + } + if (offset != s_written || s_written + data_len > s_size) { *reason = 5; return true; } + if (esp_crc32_le(UINT32_MAX, data, data_len) != crc) { *reason = 6; return true; } + if (offset == 0) { + if (data_len < sizeof(esp_image_header_t)) { *reason = 7; return true; } + const esp_image_header_t *header = (const esp_image_header_t *)data; + if (header->magic != ESP_IMAGE_HEADER_MAGIC || header->chip_id != ESP_CHIP_ID_ESP32H2) { + *reason = 7; return true; + } + } + esp_err_t err = esp_ota_write(s_handle, data, data_len); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Write: %s", esp_err_to_name(err)); + reset_session(true); *reason = 3; return true; + } + s_written += data_len; + s_last_offset = offset; + s_last_crc = crc; + s_last_len = data_len; + return true; + } + + if (cmd == ZIGBEE_CMD_OTA_END) { + if (len || !s_active || s_written != s_size) { *reason = 4; return true; } + esp_err_t err = esp_ota_end(s_handle); + s_active = false; + if (err == ESP_OK) err = esp_ota_set_boot_partition(s_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Validate/activate: %s", esp_err_to_name(err)); + reset_session(false); *reason = 7; return true; + } + s_reboot_armed = true; + ESP_LOGW(TAG, "Image verified; reboot armed"); + return true; + } + + if (len || !s_reboot_armed) { *reason = 4; return true; } + if (xTaskCreate(reboot_task, "nano_ota_reboot", 2048, NULL, 4, NULL) != pdPASS) *reason = 3; + return true; +} diff --git a/firmware/nanoh2/main/nano_ota.h b/firmware/nanoh2/main/nano_ota.h new file mode 100644 index 0000000..0a06daa --- /dev/null +++ b/firmware/nanoh2/main/nano_ota.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +/* Handle Modulus UART OTA commands. Returns true when cmd is an OTA command. + * reason is zero for ACK or a stable non-zero NAK reason. */ +bool nano_ota_handle(uint8_t cmd, const uint8_t *args, uint16_t len, uint8_t *reason); + +/* Confirm a pending app after it has booted far enough to initialize NVS. */ +void nano_ota_confirm_running(void); diff --git a/firmware/nanoh2/main/zb_proto.h b/firmware/nanoh2/main/zb_proto.h index 087f7fa..33078a0 100644 --- a/firmware/nanoh2/main/zb_proto.h +++ b/firmware/nanoh2/main/zb_proto.h @@ -29,6 +29,12 @@ #define ZIGBEE_CMD_COLOR 0x20 /* [short:2BE][ep][mode][a:2BE][b:2BE] * mode 0: a=color temp (mireds), b=trans ds * mode 1: a=hue 0-254, b=saturation 0-254 */ +#define ZIGBEE_CMD_NODE_CONFIG 0x21 /* [short:2BE][node_cmd][payload...] */ +#define ZIGBEE_CMD_HUB_RESET 0x22 /* erase coordinator network and reboot */ +#define ZIGBEE_CMD_OTA_BEGIN 0x30 /* [image_size:4BE] */ +#define ZIGBEE_CMD_OTA_DATA 0x31 /* [offset:4BE][crc32:4BE][data...] */ +#define ZIGBEE_CMD_OTA_END 0x32 /* validate and select the new slot */ +#define ZIGBEE_CMD_OTA_REBOOT 0x33 /* accepted only after successful END */ /* Events to host (P4) */ #define ZIGBEE_EVT_OK 0x81 @@ -49,6 +55,7 @@ #define ZIGBEE_EVT_ACK 0x94 /* [seq:1] cmd accepted */ #define ZIGBEE_EVT_NAK 0x95 /* [seq:1][reason:1] cmd rejected */ #define ZIGBEE_EVT_DEV_INFO 0x96 /* [short:2BE][mfr\0model\0] (Basic 0x0004/0x0005) */ +#define ZIGBEE_EVT_NODE_CONFIG 0x97 /* [short:2BE][node_rsp][payload...] */ /* * Host→hub wire format (sequenced): @@ -66,3 +73,6 @@ #define ZIGBEE_CAP_POWER 0x20 #define ZIGBEE_CAP_METER 0x40 #define ZIGBEE_CAP_COLOR 0x80 /* Color Control (0x0300) */ + +#define ZIGBEE_EVT_TEMPERATURE 0x98 /* [short:2BE][endpoint][centiC:s16BE], 0x8000 invalid */ +#define ZIGBEE_EVT_DIGITAL_INPUT 0x99 /* [short:2BE][endpoint][logical state] */ diff --git a/firmware/nanoh2/main/zb_uart_link.c b/firmware/nanoh2/main/zb_uart_link.c index 53a6f13..35d0a06 100644 --- a/firmware/nanoh2/main/zb_uart_link.c +++ b/firmware/nanoh2/main/zb_uart_link.c @@ -72,7 +72,13 @@ static void rx_task(void *arg) uint8_t chunk[64]; for (;;) { - const int n = uart_read_bytes(LINK_UART, chunk, sizeof(chunk), pdMS_TO_TICKS(250)); + int n = uart_read_bytes(LINK_UART, chunk, 1, portMAX_DELAY); + size_t waiting = 0; + if (n == 1 && uart_get_buffered_data_len(LINK_UART, &waiting) == ESP_OK && waiting) { + const size_t take = waiting < sizeof(chunk) - 1 ? waiting : sizeof(chunk) - 1; + const int extra = uart_read_bytes(LINK_UART, chunk + 1, take, 0); + if (extra > 0) n += extra; + } for (int i = 0; i < n; i++) { const uint8_t b = chunk[i]; switch (st) { diff --git a/firmware/nanoh2/main/zigbee_hub.c b/firmware/nanoh2/main/zigbee_hub.c index 28ab0dc..126fcd7 100644 --- a/firmware/nanoh2/main/zigbee_hub.c +++ b/firmware/nanoh2/main/zigbee_hub.c @@ -5,10 +5,12 @@ #include "zigbee_hub.h" #include "zb_proto.h" #include "zb_uart_link.h" +#include "nano_ota.h" #include "esp_check.h" #include "esp_log.h" #include "esp_zigbee_core.h" +#include "nvs.h" #include "ha/esp_zigbee_ha_standard.h" #include "nwk/esp_zigbee_nwk.h" #include "zcl/esp_zigbee_zcl_common.h" @@ -49,6 +51,47 @@ static bool s_form_after_scan; static uint8_t s_permit_s; static uint8_t s_reply_seq; /* non-zero while handling a sequenced host cmd */ static hub_dev_t s_devs[HUB_DEV_MAX]; +static bool s_devs_loaded; + +#define HUB_DEV_STORE_VERSION 1 + +static void dev_store_save(void) +{ + nvs_handle_t h; + if (nvs_open("zb_hub", NVS_READWRITE, &h) != ESP_OK) return; + (void)nvs_set_u8(h, "dev_ver", HUB_DEV_STORE_VERSION); + (void)nvs_set_blob(h, "devices", s_devs, sizeof(s_devs)); + (void)nvs_commit(h); + nvs_close(h); +} + +static void dev_store_load(void) +{ + if (s_devs_loaded) return; + s_devs_loaded = true; + nvs_handle_t h; + if (nvs_open("zb_hub", NVS_READONLY, &h) != ESP_OK) return; + uint8_t version = 0; + size_t size = sizeof(s_devs); + if (nvs_get_u8(h, "dev_ver", &version) != ESP_OK || + version != HUB_DEV_STORE_VERSION || + nvs_get_blob(h, "devices", s_devs, &size) != ESP_OK || size != sizeof(s_devs)) { + memset(s_devs, 0, sizeof(s_devs)); + } + nvs_close(h); +} + +static void dev_store_clear(void) +{ + memset(s_devs, 0, sizeof(s_devs)); + s_devs_loaded = true; + nvs_handle_t h; + if (nvs_open("zb_hub", NVS_READWRITE, &h) != ESP_OK) return; + (void)nvs_erase_key(h, "devices"); + (void)nvs_erase_key(h, "dev_ver"); + (void)nvs_commit(h); + nvs_close(h); +} #define HUB_HEARTBEAT_TICKS 20 /* 20 * 100 ms = 2 s (was 5 s) */ @@ -160,29 +203,43 @@ static void dev_upsert_joined(uint16_t short_addr, const uint8_t ieee_msb_in[8]) } d->short_addr = short_addr; memcpy(d->ieee_msb, ieee_msb_in, 8); + dev_store_save(); } static void dev_set_caps(uint16_t short_addr, uint8_t ep, uint8_t caps, uint16_t device_id) { - hub_dev_t *d = dev_find_short(short_addr); + hub_dev_t *d = NULL; + hub_dev_t *base = NULL; + for (int i = 0; i < HUB_DEV_MAX; i++) { + if (!s_devs[i].used || s_devs[i].short_addr != short_addr) continue; + if (!base) base = &s_devs[i]; + if (s_devs[i].ep == ep) { d = &s_devs[i]; break; } + } + if (!d && base && base->ep == 0) d = base; if (!d) { d = dev_alloc(); if (!d) { return; } d->short_addr = short_addr; + if (base) memcpy(d->ieee_msb, base->ieee_msb, sizeof(d->ieee_msb)); } d->ep = ep; d->caps = caps; d->device_id = device_id; + dev_store_save(); } static void dev_remove_ieee(const uint8_t ieee_msb_in[8]) { - hub_dev_t *d = dev_find_ieee(ieee_msb_in); - if (d) { - d->used = false; + bool changed = false; + for (int i = 0; i < HUB_DEV_MAX; i++) { + if (s_devs[i].used && memcmp(s_devs[i].ieee_msb, ieee_msb_in, 8) == 0) { + s_devs[i].used = false; + changed = true; + } } + if (changed) dev_store_save(); } static void hub_bdb_commission(uint8_t mode) @@ -190,6 +247,14 @@ static void hub_bdb_commission(uint8_t mode) (void)esp_zb_bdb_start_top_level_commissioning(mode); } +static bool hub_pan_valid(uint16_t pan) +{ + /* 0xFFFF is the Zigbee broadcast / "no PAN" sentinel. ZBOSS can retain + * a non-factory-new flag after an interrupted erase while still returning + * this value. Never expose that broken state to the P4 as joined. */ + return pan != 0xFFFF; +} + static void hub_send_state(void) { uint8_t st[5]; @@ -201,7 +266,7 @@ static void hub_send_state(void) ch = esp_zb_get_current_channel(); esp_zb_lock_release(); } - st[0] = s_formed ? 1 : 0; + st[0] = (s_formed && hub_pan_valid(pan)) ? 1 : 0; st[1] = ch; st[2] = (uint8_t)(pan >> 8); st[3] = (uint8_t)pan; @@ -309,8 +374,10 @@ static uint8_t caps_from_simple_desc(const esp_zb_af_simple_desc_1_1_t *sd) switch (sd->app_cluster_list[i]) { case 0x0006: caps |= ZIGBEE_CAP_ONOFF; break; case 0x0008: caps |= ZIGBEE_CAP_LEVEL; break; + case 0x000F: caps |= ZIGBEE_CAP_SENSOR; break; case 0x0102: caps |= ZIGBEE_CAP_COVER; break; case 0x0201: caps |= ZIGBEE_CAP_THERMOSTAT; break; + case 0x0402: caps |= ZIGBEE_CAP_SENSOR; break; case 0x0500: caps |= ZIGBEE_CAP_SENSOR; break; case 0x0B04: caps |= ZIGBEE_CAP_POWER; break; case 0x0702: caps |= ZIGBEE_CAP_METER; break; @@ -374,11 +441,15 @@ static void hub_active_ep_cb(esp_zb_zdp_status_t status, uint8_t ep_count, ESP_LOGW(TAG, "Active EP 0x%04x failed (%d)", short_addr, status); return; } - esp_zb_zdo_simple_desc_req_param_t req = { - .addr_of_interest = short_addr, - .endpoint = ep_id_list[0], - }; - esp_zb_zdo_simple_desc_req(&req, hub_simple_desc_cb, (void *)(uintptr_t)short_addr); + for (uint8_t i = 0; i < ep_count; i++) { + /* Endpoint 0 is ZDO and has no application simple descriptor. */ + if (ep_id_list[i] == 0) continue; + esp_zb_zdo_simple_desc_req_param_t req = { + .addr_of_interest = short_addr, + .endpoint = ep_id_list[i], + }; + esp_zb_zdo_simple_desc_req(&req, hub_simple_desc_cb, (void *)(uintptr_t)short_addr); + } } static void hub_discover_caps(uint16_t short_addr) @@ -484,6 +555,53 @@ static void hub_refresh_lqi(void) static void hub_dump_devices(void) { + /* ZBOSS keeps joined children in its persistent NWK neighbor table, while + * s_devs is rebuilt from DEVICE_ANNCE signals after every Nano reboot. + * Reconcile both here so a Tab5 scan can find devices which are already + * joined and therefore do not announce themselves again. */ + typedef struct { + uint16_t short_addr; + uint8_t ieee_msb[8]; + uint8_t lqi; + int8_t rssi; + uint8_t age; + } known_neighbor_t; + known_neighbor_t known[HUB_DEV_MAX]; + uint8_t known_n = 0; + + if (s_stack_ready && s_formed) { + esp_zb_nwk_info_iterator_t it = ESP_ZB_NWK_INFO_ITERATOR_INIT; + esp_zb_nwk_neighbor_info_t nbr; + esp_zb_lock_acquire(portMAX_DELAY); + while (known_n < HUB_DEV_MAX && + esp_zb_nwk_get_next_neighbor(&it, &nbr) == ESP_OK) { + known[known_n].short_addr = nbr.short_addr; + ieee_msb(nbr.ieee_addr, known[known_n].ieee_msb); + known[known_n].lqi = nbr.lqi; + known[known_n].rssi = nbr.rssi; + known[known_n].age = nbr.age; + known_n++; + } + esp_zb_lock_release(); + } + + for (uint8_t i = 0; i < known_n; i++) { + hub_dev_t *d = dev_find_ieee(known[i].ieee_msb); + const bool rediscovered = d == NULL; + dev_upsert_joined(known[i].short_addr, known[i].ieee_msb); + d = dev_find_ieee(known[i].ieee_msb); + if (d) { + d->short_addr = known[i].short_addr; + d->lqi = known[i].lqi; + d->rssi = known[i].rssi; + d->age = known[i].age; + } + if (rediscovered) { + ESP_LOGI(TAG, "Rediscovered neighbor short=0x%04x", known[i].short_addr); + hub_discover_caps(known[i].short_addr); + } + } + uint8_t n = 0; for (int i = 0; i < HUB_DEV_MAX; i++) { const hub_dev_t *d = &s_devs[i]; @@ -500,6 +618,19 @@ static void hub_dump_devices(void) ev[13] = (uint8_t)d->device_id; ev[14] = d->lqi; ev[15] = (uint8_t)d->rssi; + uint8_t joined[11]; + joined[0] = ev[0]; + joined[1] = ev[1]; + memcpy(&joined[2], d->ieee_msb, 8); + joined[10] = 0; + send_evt(ZIGBEE_EVT_DEV_JOINED, joined, sizeof(joined)); + if (d->ep) { + uint8_t caps[6] = { + ev[0], ev[1], d->ep, d->caps, + (uint8_t)(d->device_id >> 8), (uint8_t)d->device_id, + }; + send_evt(ZIGBEE_EVT_DEV_CAPS, caps, sizeof(caps)); + } send_evt(ZIGBEE_EVT_DEV_ENTRY, ev, sizeof(ev)); n++; } @@ -596,10 +727,22 @@ void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) hub_energy_scan(true); } } else { + const uint16_t pan = esp_zb_get_pan_id(); + if (!hub_pan_valid(pan)) { + /* A persisted "joined" flag with PAN 0xFFFF cannot carry + * devices or permit joins. Clear only Zigbee storage and + * reboot; the next start forms a fresh coordinator PAN. */ + s_formed = false; + ESP_LOGE(TAG, "Invalid persisted Zigbee PAN 0xFFFF — factory reset"); + hub_send_state(); + dev_store_clear(); + esp_zb_factory_reset(); + break; + } s_formed = true; s_req_channel = esp_zb_get_current_channel(); ESP_LOGI(TAG, "Rebooted into existing network (pan 0x%04x ch %u)", - esp_zb_get_pan_id(), (unsigned)s_req_channel); + pan, (unsigned)s_req_channel); hub_send_state(); } } else { @@ -716,6 +859,24 @@ static void emit_dev_state(uint16_t short_addr, uint16_t cluster, uint16_t val) send_evt(ZIGBEE_EVT_DEV_STATE, evt, sizeof(evt)); } +static void emit_temperature(uint16_t addr, uint8_t ep, const esp_zb_zcl_attribute_t *attr) +{ + if (attr->id != 0 || attr->data.type != ESP_ZB_ZCL_ATTR_TYPE_S16 || attr->data.size != 2 || !attr->data.value) return; + const uint8_t *p = attr->data.value; + uint8_t evt[] = {(uint8_t)(addr >> 8), (uint8_t)addr, ep, p[1], p[0]}; + send_evt(ZIGBEE_EVT_TEMPERATURE, evt, sizeof(evt)); +} + +static void emit_digital_input(uint16_t addr, uint8_t ep, const esp_zb_zcl_attribute_t *attr) +{ + if (attr->id != ESP_ZB_ZCL_ATTR_BINARY_INPUT_PRESENT_VALUE_ID || + attr->data.type != ESP_ZB_ZCL_ATTR_TYPE_BOOL || + attr->data.size != 1 || !attr->data.value) return; + uint8_t evt[] = {(uint8_t)(addr >> 8), (uint8_t)addr, ep, + *(const uint8_t *)attr->data.value != 0}; + send_evt(ZIGBEE_EVT_DIGITAL_INPUT, evt, sizeof(evt)); +} + static esp_err_t hub_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) { @@ -729,6 +890,43 @@ static esp_err_t hub_action_handler(esp_zb_core_action_callback_id_t callback_id } return ESP_OK; } + if (callback_id == ESP_ZB_CORE_CMD_CUSTOM_CLUSTER_RESP_CB_ID || + callback_id == ESP_ZB_CORE_CMD_CUSTOM_CLUSTER_REQ_CB_ID) { + const esp_zb_zcl_custom_cluster_command_message_t *rsp = message; + if (rsp->info.status == ESP_ZB_ZCL_STATUS_SUCCESS && + rsp->info.cluster == 0xFC10 && + rsp->info.command.direction == ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI && + rsp->info.src_address.addr_type == ESP_ZB_ZCL_ADDR_TYPE_SHORT) { + const uint8_t *p = rsp->data.value; + uint16_t n = rsp->data.size; + if (p && n && p[0] == n - 1) { p++; n--; } + if (n > 96) n = 96; + uint8_t evt[3 + 96]; + const uint16_t short_addr = rsp->info.src_address.u.short_addr; + evt[0] = (uint8_t)(short_addr >> 8); + evt[1] = (uint8_t)short_addr; + evt[2] = (uint8_t)rsp->info.command.id; + if (n) memcpy(evt + 3, p, n); + send_evt(ZIGBEE_EVT_NODE_CONFIG, evt, 3 + n); + if (rsp->info.command.id == 0x82 && n >= 5 && p[0] < 12 && p[1] == 4) { + static uint16_t measured_value = 0; + esp_zb_zcl_read_attr_cmd_t read = { + .zcl_basic_cmd={.dst_addr_u.addr_short=short_addr, .dst_endpoint=10+p[0], .src_endpoint=HUB_EP}, + .address_mode=ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, .clusterID=0x0402, + .attr_number=1, .attr_field=&measured_value}; + esp_zb_zcl_read_attr_cmd_req(&read); + } else if (rsp->info.command.id == 0x82 && n >= 5 && p[0] < 12 && p[1] == 3) { + static uint16_t on_off_value = ESP_ZB_ZCL_ATTR_ON_OFF_ON_OFF_ID; + esp_zb_zcl_read_attr_cmd_t read = { + .zcl_basic_cmd={.dst_addr_u.addr_short=short_addr, .dst_endpoint=10+p[0], .src_endpoint=HUB_EP}, + .address_mode=ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, + .clusterID=ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, + .attr_number=1, .attr_field=&on_off_value}; + esp_zb_zcl_read_attr_cmd_req(&read); + } + } + return ESP_OK; + } if (callback_id == ESP_ZB_CORE_CMD_READ_ATTR_RESP_CB_ID) { const esp_zb_zcl_cmd_read_attr_resp_message_t *rr = message; if (rr->info.status != ESP_ZB_ZCL_STATUS_SUCCESS || @@ -736,6 +934,18 @@ static esp_err_t hub_action_handler(esp_zb_core_action_callback_id_t callback_id return ESP_OK; } const uint16_t cl = rr->info.cluster; + if (cl == 0x0402) { + for (esp_zb_zcl_read_attr_resp_variable_t *v=rr->variables; v; v=v->next) + if (v->status == ESP_ZB_ZCL_STATUS_SUCCESS) + emit_temperature(rr->info.src_address.u.short_addr, rr->info.src_endpoint, &v->attribute); + return ESP_OK; + } + if (cl == ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT) { + for (esp_zb_zcl_read_attr_resp_variable_t *v=rr->variables; v; v=v->next) + if (v->status == ESP_ZB_ZCL_STATUS_SUCCESS) + emit_digital_input(rr->info.src_address.u.short_addr, rr->info.src_endpoint, &v->attribute); + return ESP_OK; + } if (cl == 0x0000) { /* Interview response: ZCL char strings [len][bytes], not * NUL-terminated. Cap 16 chars each; emit as mfr\0model\0. */ @@ -812,7 +1022,13 @@ static esp_err_t hub_action_handler(esp_zb_core_action_callback_id_t callback_id const uint16_t attr = msg->attribute.id; uint16_t val = 0; - if (cl == 0x0006 && attr == 0x0000) { + if (cl == 0x0402) { + emit_temperature(short_addr, msg->src_endpoint, &msg->attribute); + } else if (cl == ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT) { + emit_digital_input(short_addr, msg->src_endpoint, &msg->attribute); + val = *(const uint8_t *)msg->attribute.data.value; + emit_dev_state(short_addr, cl, val); + } else if (cl == 0x0006 && attr == 0x0000) { val = *(const uint8_t *)msg->attribute.data.value; emit_dev_state(short_addr, cl, val); } else if (cl == 0x0008 && attr == 0x0000) { @@ -899,7 +1115,20 @@ static void hub_zb_task(void *arg) } esp_zb_on_off_switch_cfg_t sw_cfg = ESP_ZB_DEFAULT_ON_OFF_SWITCH_CONFIG(); - esp_zb_device_register(esp_zb_on_off_switch_ep_create(HUB_EP, &sw_cfg)); + esp_zb_cluster_list_t *hub_clusters = esp_zb_on_off_switch_clusters_create(&sw_cfg); + esp_zb_attribute_list_t *node_config_client = esp_zb_zcl_attr_list_create(0xFC10); + ESP_ERROR_CHECK(esp_zb_cluster_list_add_custom_cluster(hub_clusters, node_config_client, + ESP_ZB_ZCL_CLUSTER_CLIENT_ROLE)); + ESP_ERROR_CHECK(esp_zb_cluster_list_add_temperature_meas_cluster(hub_clusters, + esp_zb_zcl_attr_list_create(0x0402), ESP_ZB_ZCL_CLUSTER_CLIENT_ROLE)); + ESP_ERROR_CHECK(esp_zb_cluster_list_add_binary_input_cluster(hub_clusters, + esp_zb_zcl_attr_list_create(0x000F), ESP_ZB_ZCL_CLUSTER_CLIENT_ROLE)); + esp_zb_ep_list_t *hub_eps = esp_zb_ep_list_create(); + esp_zb_endpoint_config_t hub_ep = {.endpoint = HUB_EP, + .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, + .app_device_id = ESP_ZB_HA_ON_OFF_SWITCH_DEVICE_ID, .app_device_version = 0}; + ESP_ERROR_CHECK(esp_zb_ep_list_add_ep(hub_eps, hub_clusters, hub_ep)); + ESP_ERROR_CHECK(esp_zb_device_register(hub_eps)); esp_zb_core_action_handler_register(hub_action_handler); /* Max TX power (~+20 dBm on H2): dedicated hub, no coex neighbor to * protect — buy the extra range/link margin for free. */ @@ -925,6 +1154,7 @@ static void hub_zb_task(void *arg) void zigbee_hub_start(uint8_t channel) { + dev_store_load(); if (channel >= 11 && channel <= 26) { s_req_channel = channel; s_channel_forced = true; @@ -1225,6 +1455,74 @@ static void hub_color(const uint8_t *a, uint16_t n) send_ok(); } +/* Modulus Node configuration tunnel. The P4 supplies the node short address, + * Modulus command id, and command-specific bytes. Zigbee delivers replies + * asynchronously as ZIGBEE_EVT_NODE_CONFIG. */ +static void hub_node_config(const uint8_t *a, uint16_t n) +{ + if (!s_stack_ready || !s_formed || n < 3 || n > 98) { + send_fail(0x22); + return; + } + uint8_t wire[96]; + const uint8_t payload_len = (uint8_t)(n - 3); + wire[0] = payload_len; + if (payload_len) memcpy(wire + 1, a + 3, payload_len); + esp_zb_zcl_custom_cluster_cmd_req_t cmd = { + .zcl_basic_cmd = { + .dst_addr_u.addr_short = (uint16_t)(((uint16_t)a[0] << 8) | a[1]), + .dst_endpoint = 1, + .src_endpoint = HUB_EP, + }, + .address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, + .profile_id = ESP_ZB_AF_HA_PROFILE_ID, + .cluster_id = 0xFC10, + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV, + .dis_default_resp = 1, + .custom_cmd_id = a[2], + .data = {.type = ESP_ZB_ZCL_ATTR_TYPE_OCTET_STRING, + .size = payload_len + 1, .value = wire}, + }; + esp_zb_lock_acquire(portMAX_DELAY); + esp_zb_zcl_custom_cluster_cmd_req(&cmd); + esp_zb_lock_release(); + send_ok(); +} + +static void hub_ping_modulus_nodes(void) +{ + for (unsigned i = 0; i < HUB_DEV_MAX; ++i) { + if (!s_devs[i].used || s_devs[i].device_id != 0xFFF0) continue; + /* A router that powers up after the coordinator may receive a fresh + * network address. Resolve the persisted IEEE address before every + * heartbeat instead of continuing to use the stale short address. */ + esp_zb_ieee_addr_t ieee_lsb; + ieee_msb_to_lsb(s_devs[i].ieee_msb, ieee_lsb); + const uint16_t current_short = esp_zb_address_short_by_ieee(ieee_lsb); + if (current_short != 0xFFFF && current_short != 0xFFFE && current_short != 0) { + if (s_devs[i].short_addr != current_short) { + s_devs[i].short_addr = current_short; + dev_store_save(); + } + } + esp_zb_zcl_custom_cluster_cmd_req_t cmd = { + .zcl_basic_cmd = {.dst_addr_u.addr_short = s_devs[i].short_addr, + .dst_endpoint = 1, .src_endpoint = HUB_EP}, + .address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, + .profile_id = ESP_ZB_AF_HA_PROFILE_ID, + .cluster_id = 0xFC10, + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV, + .dis_default_resp = 1, + .custom_cmd_id = 0x00, + .data = {.type = ESP_ZB_ZCL_ATTR_TYPE_NULL, .size = 0, .value = NULL}, + }; + esp_zb_lock_acquire(portMAX_DELAY); + esp_zb_zcl_custom_cluster_cmd_req(&cmd); + esp_zb_lock_release(); + break; + } +} + void zigbee_hub_process_cmd(const uint8_t *payload, uint16_t len) { if (!payload || len < 2) { @@ -1238,6 +1536,21 @@ void zigbee_hub_process_cmd(const uint8_t *payload, uint16_t len) const uint8_t cmd = payload[1]; const uint8_t *args = payload + 2; const uint16_t args_len = len - 2; + /* OTA_DATA arrives thousands of times per image. Logging every block to the + * USB console throttles later updates after this image has booted. Keep the + * useful session/control diagnostics without putting console I/O in the + * transfer's hot path. */ + if (cmd != ZIGBEE_CMD_OTA_DATA) { + ESP_LOGI(TAG, "UART cmd 0x%02x seq=%u args=%u", cmd, + (unsigned)s_reply_seq, (unsigned)args_len); + } + + uint8_t ota_reason = 0; + if (nano_ota_handle(cmd, args, args_len, &ota_reason)) { + if (ota_reason) send_fail(ota_reason); else send_ok(); + s_reply_seq = 0; + return; + } switch (cmd) { case ZIGBEE_CMD_HUB_START: @@ -1295,6 +1608,19 @@ void zigbee_hub_process_cmd(const uint8_t *payload, uint16_t len) case ZIGBEE_CMD_COLOR: hub_color(args, args_len); break; + case ZIGBEE_CMD_NODE_CONFIG: + hub_node_config(args, args_len); + break; + case ZIGBEE_CMD_HUB_RESET: + /* The NanoH2 is normally enclosed. Let the Tab5 perform the same + * recovery as the physical 3-second button gesture. */ + send_ok(); + s_reply_seq = 0; + s_formed = false; + dev_store_clear(); + vTaskDelay(pdMS_TO_TICKS(100)); /* allow the ACK to leave the UART */ + esp_zb_factory_reset(); + return; case ZIGBEE_CMD_DISABLE: hub_permit_join(0); break; @@ -1321,4 +1647,5 @@ void zigbee_hub_poll(void) } ticks = 0; hub_send_state(); + hub_ping_modulus_nodes(); } diff --git a/firmware/nanoh2/partitions.csv b/firmware/nanoh2/partitions.csv index 53d6dff..f6f4b68 100644 --- a/firmware/nanoh2/partitions.csv +++ b/firmware/nanoh2/partitions.csv @@ -1,8 +1,10 @@ -# Modulus NanoH2 — 4 MB flash, single app + ZBOSS NVRAM. +# Modulus NanoH2 — 4 MB flash, dual OTA app slots + ZBOSS NVRAM. # zb_storage/zb_fct REQUIRED or esp_zb_start fails and the network never forms. # Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x6000 -phy_init, data, phy, 0xf000, 0x1000 -factory, app, factory, 0x10000, 0x2E0000 -zb_storage, data, fat, 0x2F0000, 0x4000 -zb_fct, data, fat, 0x2F4000, 0x400 +otadata, data, ota, 0xf000, 0x2000 +phy_init, data, phy, 0x11000, 0x1000 +ota_0, app, ota_0, 0x20000, 0x1D0000 +ota_1, app, ota_1, 0x1F0000, 0x1D0000 +zb_storage, data, fat, 0x3C0000, 0x4000 +zb_fct, data, fat, 0x3C4000, 0x400 diff --git a/firmware/s3-bridge/CMakeLists.txt b/firmware/s3-bridge/CMakeLists.txt index ffe5df9..f30801c 100644 --- a/firmware/s3-bridge/CMakeLists.txt +++ b/firmware/s3-bridge/CMakeLists.txt @@ -1,4 +1,5 @@ cmake_minimum_required(VERSION 3.16) set(IDF_TARGET "esp32s3") include($ENV{IDF_PATH}/tools/cmake/project.cmake) +set(PROJECT_VER "3.1.5") project(s3_espnow_uart_bridge) diff --git a/firmware/s3-bridge/dependencies.lock b/firmware/s3-bridge/dependencies.lock index e4fcf66..6f7d016 100644 --- a/firmware/s3-bridge/dependencies.lock +++ b/firmware/s3-bridge/dependencies.lock @@ -15,6 +15,6 @@ dependencies: version: 6.0.1 direct_dependencies: - espressif/led_strip -manifest_hash: 1d4779e8d9defe238755eaaea5cb6b390c73be3df4ab03c7c4fd9c297447c4ad +manifest_hash: 91ac665e8720813424d29309184e279e16534b460280679f62653d4c7ec84daa target: esp32s3 version: 3.0.0 diff --git a/firmware/s3-bridge/main/CMakeLists.txt b/firmware/s3-bridge/main/CMakeLists.txt index 4fa9492..ff9ce58 100644 --- a/firmware/s3-bridge/main/CMakeLists.txt +++ b/firmware/s3-bridge/main/CMakeLists.txt @@ -2,9 +2,11 @@ idf_component_register( SRCS "main.cpp" "espnow_link.cpp" + "s3_ota.cpp" "uart_bridge.cpp" "halt_gpio.cpp" "bridge_board.cpp" + "status_rgb.cpp" INCLUDE_DIRS "." REQUIRES nvs_flash @@ -13,7 +15,11 @@ idf_component_register( esp_netif esp_timer esp_driver_uart + esp_driver_usb_serial_jtag esp_driver_gpio + app_update + espcoredump freertos log + led_strip ) diff --git a/firmware/s3-bridge/main/Kconfig.projbuild b/firmware/s3-bridge/main/Kconfig.projbuild index 4123546..87b7137 100644 --- a/firmware/s3-bridge/main/Kconfig.projbuild +++ b/firmware/s3-bridge/main/Kconfig.projbuild @@ -17,6 +17,13 @@ menu "Modulus S3 bridge board" active-low. Shell on USB Serial/JTAG. Do not drive GPIO33-37 (octal PSRAM on ESP32-S3R8). + config S3_BRIDGE_BOARD_GENERIC + bool "Generic ESP32-S3 Super Mini" + help + UART1 on GPIO43/44, HALT on GPIO1 and onboard WS2812 on + GPIO48. This is the safe generic default; it does not bind + UART RX to GPIO9. + endchoice endmenu diff --git a/firmware/s3-bridge/main/bridge_board.cpp b/firmware/s3-bridge/main/bridge_board.cpp index 0bad4a3..d0f7fde 100644 --- a/firmware/s3-bridge/main/bridge_board.cpp +++ b/firmware/s3-bridge/main/bridge_board.cpp @@ -15,17 +15,17 @@ static const char *TAG = "board"; /* Pin order: UART TX, RX, LED TX, LED RX, LED active-high, HALT. * UART1 to grblHAL. Shell is USB Serial/JTAG. Avoid GPIO33-37 on octal-PSRAM S3. */ static const bridge_board_t kBoards[] = { - { "mini1", "Modulus", "ESP32-S3-MINI-1 handwheel", 8, 9, 45, 46, 1, 37 }, - { "ws-s3-zero", "Waveshare", "ESP32-S3-Zero (SKU 25081 / 33879)", 43, 44, -1, -1, 1, 1 }, - { "s3-supermini", "Generic", "ESP32-S3 Super Mini", 43, 44, -1, -1, 1, 1 }, - { "qtpy-s3", "Adafruit", "QT Py ESP32-S3 (no PSRAM)", 5, 16, -1, -1, 1, 8 }, - { "feather-s3", "Adafruit", "Feather ESP32-S3 (2MB PSRAM)", 39, 38, 13, 13, 1, 5 }, - { "feather-s3-np", "Adafruit", "Feather ESP32-S3 (no PSRAM)", 39, 38, 13, 13, 1, 5 }, - { "xiao", "Seeed", "XIAO ESP32-S3", 43, 44, 21, 21, 0, 1 }, - { "xiao-plus", "Seeed", "XIAO ESP32-S3 Plus", 43, 44, 1, 1, 1, 2 }, - { "um-feathers3", "Unexpected Maker", "FeatherS3", 43, 44, 13, 13, 1, 1 }, - { "um-tinys3", "Unexpected Maker", "TinyS3", 43, 44, -1, -1, 1, 1 }, - { "s3-devkitm1", "Espressif", "ESP32-S3-DevKitM-1", 17, 18, -1, -1, 1, 1 }, + { "mini1", "Modulus", "ESP32-S3-MINI-1 handwheel", 8, 9, 45, 46, 1, 37, 48 }, + { "ws-s3-zero", "Waveshare", "ESP32-S3-Zero (SKU 25081 / 33879)", 43, 44, -1, -1, 1, 1, -1 }, + { "s3-supermini", "Generic", "ESP32-S3 Super Mini", 43, 44, -1, -1, 1, 1, 48 }, + { "qtpy-s3", "Adafruit", "QT Py ESP32-S3 (no PSRAM)", 5, 16, -1, -1, 1, 8, -1 }, + { "feather-s3", "Adafruit", "Feather ESP32-S3 (2MB PSRAM)", 39, 38, 13, 13, 1, 5, -1 }, + { "feather-s3-np", "Adafruit", "Feather ESP32-S3 (no PSRAM)", 39, 38, 13, 13, 1, 5, -1 }, + { "xiao", "Seeed", "XIAO ESP32-S3", 43, 44, 21, 21, 0, 1, -1 }, + { "xiao-plus", "Seeed", "XIAO ESP32-S3 Plus", 43, 44, 1, 1, 1, 2, -1 }, + { "um-feathers3", "Unexpected Maker", "FeatherS3", 43, 44, 13, 13, 1, 1, -1 }, + { "um-tinys3", "Unexpected Maker", "TinyS3", 43, 44, -1, -1, 1, 1, -1 }, + { "s3-devkitm1", "Espressif", "ESP32-S3-DevKitM-1", 17, 18, -1, -1, 1, 1, 48 }, }; static int s_idx; @@ -49,6 +49,9 @@ static int default_idx(void) #if CONFIG_S3_BRIDGE_BOARD_XIAO int i = find_s3("xiao"); return i >= 0 ? i : 0; +#elif CONFIG_S3_BRIDGE_BOARD_GENERIC + int i = find_s3("s3-supermini"); + return i >= 0 ? i : 0; #else int i = find_s3("mini1"); return i >= 0 ? i : 0; @@ -106,6 +109,7 @@ void bridge_board_print_list(void) } else { printf("LED=%d/%d\r\n", b->led_tx, b->led_rx); } + if (b->rgb_gpio >= 0) printf(" RGB status=GPIO%d\r\n", b->rgb_gpio); } printf(" Current: %s\r\n\r\n", kBoards[s_idx].id); } diff --git a/firmware/s3-bridge/main/bridge_board.h b/firmware/s3-bridge/main/bridge_board.h index c95c30b..074a9b3 100644 --- a/firmware/s3-bridge/main/bridge_board.h +++ b/firmware/s3-bridge/main/bridge_board.h @@ -14,6 +14,7 @@ typedef struct { int led_rx; int led_on; /* 1 = active high */ int halt; + int rgb_gpio; /* onboard WS2812, -1 if absent */ } bridge_board_t; void bridge_board_init(void); diff --git a/firmware/s3-bridge/main/bridge_config.h b/firmware/s3-bridge/main/bridge_config.h index 6f795d7..d65b504 100644 --- a/firmware/s3-bridge/main/bridge_config.h +++ b/firmware/s3-bridge/main/bridge_config.h @@ -21,7 +21,7 @@ #define ESPNOW_TX_WAIT_MS 200 #define UART_PORT_NUM UART_NUM_1 -#define UART_DEFAULT_BAUD 921600 +#define UART_DEFAULT_BAUD 115200 #define UART_RTS_GPIO (-1) #define UART_CTS_GPIO (-1) #define UART_RX_BUF_SIZE (16384) diff --git a/firmware/s3-bridge/main/bridge_protocol.h b/firmware/s3-bridge/main/bridge_protocol.h index ba7d919..c6330e5 100644 --- a/firmware/s3-bridge/main/bridge_protocol.h +++ b/firmware/s3-bridge/main/bridge_protocol.h @@ -4,8 +4,13 @@ /* Tab5 ↔ S3 side-band — must match espnow_transport_shim.c / espnow_stack.h */ #define BRIDGE_MOD_PROBE "MOD_PROBE" #define BRIDGE_MOD_ACK "MOD_ACK" +#define BRIDGE_MOD_HEARTBEAT "MOD_HB1" #define BRIDGE_MOD_HALT_ON "MOD_HALT1" #define BRIDGE_MOD_HALT_OFF "MOD_HALT0" +#define BRIDGE_MOD_CHANNEL "MOD_CH" +#define BRIDGE_MOD_CHANNEL_ACK "MOD_CA" #define BRIDGE_MOD_PROBE_LEN 9 #define BRIDGE_MOD_ACK_LEN 7 +#define BRIDGE_MOD_HEARTBEAT_LEN 7 #define BRIDGE_MOD_HALT_LEN 9 +#define BRIDGE_MOD_CHANNEL_LEN 7 diff --git a/firmware/s3-bridge/main/espnow_link.cpp b/firmware/s3-bridge/main/espnow_link.cpp index 5e26358..8b7ae95 100644 --- a/firmware/s3-bridge/main/espnow_link.cpp +++ b/firmware/s3-bridge/main/espnow_link.cpp @@ -10,25 +10,30 @@ #include "bridge_config.h" #include "bridge_nvs.h" #include "bridge_protocol.h" +#include "s3_ota.h" #include #include #include +#include #include #include #include #include +#include #include #include #include #include +#include static const char* TAG = "espnow"; /* 2.4 GHz channels 1-13 (Tab5 probe set); ch 14 is JP-only and unused here. */ static constexpr uint8_t kChannelMin = 1; static constexpr uint8_t kChannelMax = 13; +static constexpr uint8_t kBroadcastMac[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; typedef struct { uint16_t len; @@ -52,10 +57,42 @@ static QueueHandle_t s_inbound_q = nullptr; static QueueHandle_t s_outbound_q = nullptr; static bool s_inbound_worker_started = false; static bool s_outbound_worker_started = false; +static bool s_heartbeat_task_started = false; +static std::atomic s_channel_hunting{false}; +static std::atomic s_channel_latch_pending{false}; +static std::atomic s_last_link_ok_tick{0}; +static std::atomic s_last_rx_tick{0}; +static std::atomic s_last_tx_ok_tick{0}; +static std::atomic s_last_tx_fail_tick{0}; +static std::atomic s_air_rx_count{0}; +static std::atomic s_hunt_hold_until_tick{0}; +static std::atomic s_trace_enabled{false}; +static bool s_trace_task_started = false; +static constexpr size_t kLinkLogDepth = 60; +/* Once a unicast MAC ACK or an inbound frame proves the Tab5 channel, keep + * the radio there long enough for both 2 s heartbeats and the Tab5 liveness + * probe to reinforce the lock. The former 5 s timeout made both ends resume + * independent channel hopping after a single missed heartbeat. */ +static constexpr uint32_t kConfirmedChannelHoldMs = 30000; +typedef struct { + uint32_t ms, link_age, rx_age, tx_age; + uint32_t air_rx, tx_ok, fail; + uint8_t configured_channel, radio_channel; + bool hunting; +} link_log_entry_t; +static link_log_entry_t s_link_log[kLinkLogDepth] = {}; +static size_t s_link_log_head = 0; +static size_t s_link_log_count = 0; +static portMUX_TYPE s_link_log_spin = portMUX_INITIALIZER_UNLOCKED; /* MOD_ACK / peer learn must not block inside esp_now recv cb. */ static portMUX_TYPE s_ack_spin = portMUX_INITIALIZER_UNLOCKED; static bool s_ack_pending = false; static uint8_t s_ack_mac[6] = {}; +static bool s_channel_cmd_pending = false; +static uint8_t s_channel_cmd_mac[6] = {}; +static uint8_t s_channel_cmd_target = 0; +static void request_channel_change(const uint8_t* dest_mac, uint8_t channel); +static void flush_pending_channel_change(void); static portMUX_TYPE s_learn_spin = portMUX_INITIALIZER_UNLOCKED; static bool s_learn_pending = false; static uint8_t s_learn_mac[6] = {}; @@ -125,14 +162,17 @@ static void register_tab5_peer_locked(const uint8_t* mac) esp_now_peer_info_t pi = {}; memcpy(pi.peer_addr, mac, 6); - pi.channel = s_channel; + /* Channel 0 follows the STA interface's current channel. The recovery + * task deliberately hops 1..13; pinning the peer to the startup channel + * makes every outbound hunt packet fail after the first hop. */ + pi.channel = 0; pi.ifidx = WIFI_IF_STA; pi.encrypt = false; esp_err_t err = esp_now_add_peer(&pi); if (err == ESP_OK) { apply_peer_rate_11m(mac); - ESP_LOGI(TAG, "Tab5 peer registered: %s ch%d rate11M", s_mac_str, s_channel); + ESP_LOGI(TAG, "Tab5 peer registered: %s current-channel rate11M", s_mac_str); } else { ESP_LOGW(TAG, "add_peer failed: %s", esp_err_to_name(err)); } @@ -143,7 +183,8 @@ static void learn_tab5_mac(const uint8_t* mac) if (!s_peer_mu) return; xSemaphoreTake(s_peer_mu, portMAX_DELAY); - if (s_tab5_known && memcmp(s_tab5_mac, mac, 6) == 0) { + const bool latch_channel = s_channel_latch_pending.exchange(false, std::memory_order_acq_rel); + if (s_tab5_known && memcmp(s_tab5_mac, mac, 6) == 0 && !latch_channel) { xSemaphoreGive(s_peer_mu); return; } @@ -156,6 +197,10 @@ static void learn_tab5_mac(const uint8_t* mac) xSemaphoreGive(s_peer_mu); save_tab5_mac(); + if (latch_channel) { + (void)save_channel_nvs(s_channel); + ESP_LOGW(TAG, "ESP-NOW reacquired Tab5 on channel %u", (unsigned)s_channel); + } ESP_LOGI(TAG, "%s Tab5 MAC: %s", first ? "Learned" : "Updated", s_mac_str); } @@ -272,6 +317,7 @@ static void inbound_worker(void*) for (;;) { apply_deferred_learn(); flush_pending_mod_ack(); + flush_pending_channel_change(); if (xQueueReceive(s_inbound_q, &pkt, pdMS_TO_TICKS(20)) == pdTRUE) { uart_bridge_send(pkt.data, pkt.len); } @@ -284,6 +330,7 @@ static void outbound_worker(void*) for (;;) { apply_deferred_learn(); flush_pending_mod_ack(); + flush_pending_channel_change(); if (xQueueReceive(s_outbound_q, &pkt, pdMS_TO_TICKS(20)) == pdTRUE) { (void)espnow_send_to_tab5(pkt.data, pkt.len); } @@ -297,6 +344,21 @@ static void on_recv(const esp_now_recv_info_t* info, return; } + const uint32_t rx_tick = (uint32_t)xTaskGetTickCount(); + s_air_rx_count.fetch_add(1, std::memory_order_relaxed); + s_last_rx_tick.store(rx_tick, std::memory_order_release); + s_last_link_ok_tick.store(rx_tick, std::memory_order_release); + s_hunt_hold_until_tick.store(rx_tick + pdMS_TO_TICKS(kConfirmedChannelHoldMs), + std::memory_order_release); + if (s_channel_hunting.exchange(false, std::memory_order_acq_rel)) { + s_channel_latch_pending.store(true, std::memory_order_release); + } + + if (s3_ota_try_handle(info->src_addr, data, static_cast(len))) { + defer_learn_mac(info->src_addr); + return; + } + if (len == BRIDGE_MOD_PROBE_LEN && memcmp(data, BRIDGE_MOD_PROBE, BRIDGE_MOD_PROBE_LEN) == 0) { defer_learn_mac(info->src_addr); @@ -318,6 +380,15 @@ static void on_recv(const esp_now_recv_info_t* info, return; } + if (len == BRIDGE_MOD_CHANNEL_LEN && + memcmp(data, BRIDGE_MOD_CHANNEL, BRIDGE_MOD_CHANNEL_LEN - 1) == 0 && + data[BRIDGE_MOD_CHANNEL_LEN - 1] >= kChannelMin && + data[BRIDGE_MOD_CHANNEL_LEN - 1] <= kChannelMax) { + defer_learn_mac(info->src_addr); + request_channel_change(info->src_addr, data[BRIDGE_MOD_CHANNEL_LEN - 1]); + return; + } + defer_learn_mac(info->src_addr); s_rx_count.fetch_add(1, std::memory_order_relaxed); @@ -330,10 +401,32 @@ static void on_recv(const esp_now_recv_info_t* info, static void on_sent(const esp_now_send_info_t* info, esp_now_send_status_t status) { + const bool broadcast = info && memcmp(info->des_addr, kBroadcastMac, 6) == 0; if (status == ESP_NOW_SEND_SUCCESS) { - s_tx_count.fetch_add(1, std::memory_order_relaxed); - } else { + /* A broadcast has no receiver ACK. Its send callback only means that + * the frame left our radio, so it must not be reported as a healthy + * Tab5 transmission. */ + if (!broadcast) { + s_tx_count.fetch_add(1, std::memory_order_relaxed); + } + const bool tab5_unicast = info && s_tab5_known && + memcmp(info->des_addr, s_tab5_mac, 6) == 0; + if (tab5_unicast) { + const uint32_t tx_tick = (uint32_t)xTaskGetTickCount(); + s_last_tx_ok_tick.store(tx_tick, std::memory_order_release); + s_last_link_ok_tick.store(tx_tick, std::memory_order_release); + s_hunt_hold_until_tick.store(tx_tick + pdMS_TO_TICKS(kConfirmedChannelHoldMs), + std::memory_order_release); + } + if (tab5_unicast && s_channel_hunting.exchange(false, std::memory_order_acq_rel)) { + /* Persist a channel found by an outbound hunt as well as one found + * by an inbound Tab5 probe. NVS work remains outside the callback. */ + s_channel_latch_pending.store(true, std::memory_order_release); + defer_learn_mac(info->des_addr); + } + } else if (!broadcast) { s_fail_count.fetch_add(1, std::memory_order_relaxed); + s_last_tx_fail_tick.store((uint32_t)xTaskGetTickCount(), std::memory_order_release); if (info) { ESP_LOGD(TAG, "TX failed to %02X:%02X:%02X:%02X:%02X:%02X", info->des_addr[0], info->des_addr[1], info->des_addr[2], @@ -421,12 +514,9 @@ void espnow_init() uint8_t my_mac[6]; esp_read_mac(my_mac, ESP_MAC_WIFI_STA); - printf("\r\n=====================================================\r\n"); - printf(" S3 UART Bridge MAC: %02X:%02X:%02X:%02X:%02X:%02X\r\n", - my_mac[0], my_mac[1], my_mac[2], my_mac[3], my_mac[4], my_mac[5]); - printf(" -> Tab5 Settings > Wireless > ESP-NOW peer\r\n"); - printf(" ESP-NOW channel : %d (must match Tab5)\r\n", s_channel); - printf("=====================================================\r\n\r\n"); + ESP_LOGI(TAG, "S3 MAC %02X:%02X:%02X:%02X:%02X:%02X channel %u", + my_mac[0], my_mac[1], my_mac[2], my_mac[3], my_mac[4], my_mac[5], + (unsigned)s_channel); s_tx_lock = xSemaphoreCreateMutex(); s_tx_done = xSemaphoreCreateBinary(); @@ -442,6 +532,14 @@ void espnow_init() ESP_ERROR_CHECK(esp_now_register_recv_cb(on_recv)); ESP_ERROR_CHECK(esp_now_register_send_cb(on_sent)); + /* Broadcast discovery follows the channel selected by the recovery hunt. */ + esp_now_peer_info_t broadcast = {}; + memcpy(broadcast.peer_addr, kBroadcastMac, sizeof(kBroadcastMac)); + broadcast.channel = 0; + broadcast.ifidx = WIFI_IF_STA; + broadcast.encrypt = false; + ESP_ERROR_CHECK(esp_now_add_peer(&broadcast)); + if (s_tab5_known) { xSemaphoreTake(s_peer_mu, portMAX_DELAY); register_tab5_peer_locked(s_tab5_mac); @@ -449,6 +547,7 @@ void espnow_init() } s_espnow_ready = true; + s3_ota_init(espnow_send_chunk); ESP_LOGI(TAG, "ESP-NOW initialised (channel %d)", s_channel); } @@ -458,6 +557,107 @@ void espnow_flush_pending_ack() flush_pending_mod_ack(); } +static void heartbeat_task(void*) +{ + static const uint8_t heartbeat[] = BRIDGE_MOD_HEARTBEAT; + for (;;) { + /* Side-band liveness only: never enters either UART queue. The normal + * ESP-NOW TX mutex serializes it with CNC traffic and OTA packets. */ + (void)espnow_send_to_tab5(heartbeat, BRIDGE_MOD_HEARTBEAT_LEN); + vTaskDelay(pdMS_TO_TICKS(2000)); + } +} + +static void request_channel_change(const uint8_t* dest_mac, uint8_t channel) +{ + taskENTER_CRITICAL(&s_ack_spin); + memcpy(s_channel_cmd_mac, dest_mac, 6); + s_channel_cmd_target = channel; + s_channel_cmd_pending = true; + taskEXIT_CRITICAL(&s_ack_spin); +} + +static void flush_pending_channel_change(void) +{ + uint8_t mac[6]; + uint8_t channel = 0; + bool pending = false; + taskENTER_CRITICAL(&s_ack_spin); + if (s_channel_cmd_pending) { + memcpy(mac, s_channel_cmd_mac, 6); + channel = s_channel_cmd_target; + s_channel_cmd_pending = false; + pending = true; + } + taskEXIT_CRITICAL(&s_ack_spin); + if (!pending) return; + + uint8_t ack[BRIDGE_MOD_CHANNEL_LEN] = {'M','O','D','_','C','A',0}; + ack[BRIDGE_MOD_CHANNEL_LEN - 1] = channel; + if (!espnow_send_chunk(mac, ack, sizeof(ack))) { + ESP_LOGW(TAG, "channel %u ACK failed; staying on channel %u", + (unsigned)channel, (unsigned)s_channel); + return; + } + + if (espnow_set_channel(channel)) { + ESP_LOGI(TAG, "Tab5 commanded channel %u", (unsigned)channel); + } else { + ESP_LOGW(TAG, "channel command apply failed: %u", (unsigned)channel); + } +} + +static uint32_t age_ms(std::atomic& tick) +{ + const uint32_t last = tick.load(std::memory_order_acquire); + if (last == 0) return UINT32_MAX; + return (uint32_t)(xTaskGetTickCount() - last) * portTICK_PERIOD_MS; +} + +static void link_console_printf(const char* fmt, ...); + +static void trace_task(void*) +{ + uint32_t prev_rx = 0, prev_tx = 0, prev_fail = 0; + for (;;) { + uint8_t actual = 0; + wifi_second_chan_t second = WIFI_SECOND_CHAN_NONE; + (void)esp_wifi_get_channel(&actual, &second); + const uint32_t rx = s_air_rx_count.load(std::memory_order_relaxed); + const uint32_t tx = s_tx_count.load(std::memory_order_relaxed); + const uint32_t fail = s_fail_count.load(std::memory_order_relaxed); + link_log_entry_t entry = { + .ms = (uint32_t)(esp_timer_get_time() / 1000), + .link_age = age_ms(s_last_link_ok_tick), + .rx_age = age_ms(s_last_rx_tick), + .tx_age = age_ms(s_last_tx_ok_tick), + .air_rx = rx, .tx_ok = tx, .fail = fail, + .configured_channel = s_channel, .radio_channel = actual, + .hunting = s_channel_hunting.load(std::memory_order_acquire), + }; + taskENTER_CRITICAL(&s_link_log_spin); + s_link_log[s_link_log_head] = entry; + s_link_log_head = (s_link_log_head + 1) % kLinkLogDepth; + if (s_link_log_count < kLinkLogDepth) ++s_link_log_count; + taskEXIT_CRITICAL(&s_link_log_spin); + + if (s_trace_enabled.load(std::memory_order_acquire)) { + link_console_printf("\r\n[LINK %10llu ms] cfg=%u radio=%u hunt=%s link_age=%lu rx_age=%lu txok_age=%lu air_rx=%lu(+%lu) tx_ok=%lu(+%lu) fail=%lu(+%lu)\r\n> ", + (unsigned long long)(esp_timer_get_time() / 1000), + (unsigned)s_channel, (unsigned)actual, + s_channel_hunting.load(std::memory_order_acquire) ? "yes" : "no", + (unsigned long)age_ms(s_last_link_ok_tick), + (unsigned long)age_ms(s_last_rx_tick), + (unsigned long)age_ms(s_last_tx_ok_tick), + (unsigned long)rx, (unsigned long)(rx - prev_rx), + (unsigned long)tx, (unsigned long)(tx - prev_tx), + (unsigned long)fail, (unsigned long)(fail - prev_fail)); + prev_rx = rx; prev_tx = tx; prev_fail = fail; + } + vTaskDelay(pdMS_TO_TICKS(2000)); + } +} + void espnow_start_inbound_worker() { if (s_inbound_q && !s_inbound_worker_started) { @@ -470,6 +670,23 @@ void espnow_start_inbound_worker() 4096, nullptr, 6, nullptr, 1); s_outbound_worker_started = true; } + if (!s_heartbeat_task_started) { + if (xTaskCreatePinnedToCore(heartbeat_task, "espnow_hb", + 3072, nullptr, 4, nullptr, 1) == pdPASS) { + s_heartbeat_task_started = true; + } else { + ESP_LOGW(TAG, "heartbeat task create failed"); + } + } + /* Tab5 owns the radio channel. The S3 remains on its persisted channel + * until a MOD_CH command arrives and never hunts autonomously. */ + s_channel_hunting.store(false, std::memory_order_release); + if (!s_trace_task_started) { + if (xTaskCreatePinnedToCore(trace_task, "espnow_trace", + 3072, nullptr, 2, nullptr, 1) == pdPASS) { + s_trace_task_started = true; + } + } } bool espnow_set_channel(uint8_t ch) @@ -482,6 +699,14 @@ bool espnow_set_channel(uint8_t ch) } s_channel = ch; + /* A manual channel change invalidates the previous green/link state. Give + * the selected channel five seconds to receive a fresh ACK or RX before + * automatic hunting resumes. */ + s_last_link_ok_tick.store(0, std::memory_order_release); + s_last_tx_ok_tick.store(0, std::memory_order_release); + s_channel_hunting.store(false, std::memory_order_release); + s_hunt_hold_until_tick.store((uint32_t)xTaskGetTickCount() + pdMS_TO_TICKS(5000), + std::memory_order_release); if (!save_channel_nvs(ch)) { ESP_LOGW(TAG, "channel NVS save failed (live ch=%d)", ch); } @@ -563,3 +788,71 @@ uint32_t espnow_outbound_pending() { return s_outbound_q ? uxQueueMessagesWaiting(s_outbound_q) : 0; } + +bool espnow_channel_hunting() +{ + return s_channel_hunting.load(std::memory_order_acquire); +} + +uint32_t espnow_last_link_age_ms() +{ + return age_ms(s_last_link_ok_tick); +} + +uint32_t espnow_last_rx_age_ms() { return age_ms(s_last_rx_tick); } +uint32_t espnow_last_tx_ok_age_ms() { return age_ms(s_last_tx_ok_tick); } +uint32_t espnow_air_rx_count() { return s_air_rx_count.load(std::memory_order_relaxed); } +void espnow_trace_set(bool enabled) { s_trace_enabled.store(enabled, std::memory_order_release); } +bool espnow_trace_enabled() { return s_trace_enabled.load(std::memory_order_acquire); } + +void espnow_log_clear() +{ + taskENTER_CRITICAL(&s_link_log_spin); + s_link_log_head = 0; + s_link_log_count = 0; + taskEXIT_CRITICAL(&s_link_log_spin); +} + +static void link_console_printf(const char* fmt, ...) +{ + char line[192]; + va_list args; + va_start(args, fmt); + const int n = vsnprintf(line, sizeof(line), fmt, args); + va_end(args); + if (n <= 0) return; + const size_t total = (size_t)n < sizeof(line) ? (size_t)n : sizeof(line) - 1; + size_t sent = 0; + while (sent < total) { + const int wrote = usb_serial_jtag_write_bytes( + line + sent, total - sent, pdMS_TO_TICKS(500)); + if (wrote <= 0) break; + sent += (size_t)wrote; + } +} + +void espnow_log_print() +{ + size_t count = 0; + size_t start = 0; + taskENTER_CRITICAL(&s_link_log_spin); + count = s_link_log_count; + start = (s_link_log_head + kLinkLogDepth - count) % kLinkLogDepth; + taskEXIT_CRITICAL(&s_link_log_spin); + + link_console_printf("\r\n=== S3 LINK LOG (2 s samples, oldest first) ===\r\n"); + link_console_printf(" ms cfg radio hunt linkAge rxAge txAge airRX txOK fail\r\n"); + for (size_t i = 0; i < count; ++i) { + link_log_entry_t e; + taskENTER_CRITICAL(&s_link_log_spin); + e = s_link_log[(start + i) % kLinkLogDepth]; + taskEXIT_CRITICAL(&s_link_log_spin); + link_console_printf(" %8lu %3u %5u %4s %7lu %5lu %5lu %5lu %4lu %4lu\r\n", + (unsigned long)e.ms, (unsigned)e.configured_channel, + (unsigned)e.radio_channel, e.hunting ? "yes" : "no", + (unsigned long)e.link_age, (unsigned long)e.rx_age, + (unsigned long)e.tx_age, (unsigned long)e.air_rx, + (unsigned long)e.tx_ok, (unsigned long)e.fail); + } + link_console_printf("=== END S3 LINK LOG (%u samples) ===\r\n", (unsigned)count); +} diff --git a/firmware/s3-bridge/main/espnow_link.h b/firmware/s3-bridge/main/espnow_link.h index fd628ef..1c9e469 100644 --- a/firmware/s3-bridge/main/espnow_link.h +++ b/firmware/s3-bridge/main/espnow_link.h @@ -36,6 +36,15 @@ uint32_t espnow_inbound_drops(); uint32_t espnow_inbound_pending(); uint32_t espnow_outbound_drops(); uint32_t espnow_outbound_pending(); +bool espnow_channel_hunting(); +uint32_t espnow_last_link_age_ms(); +uint32_t espnow_last_rx_age_ms(); +uint32_t espnow_last_tx_ok_age_ms(); +uint32_t espnow_air_rx_count(); +void espnow_trace_set(bool enabled); +bool espnow_trace_enabled(); +void espnow_log_print(); +void espnow_log_clear(); #ifdef __cplusplus } diff --git a/firmware/s3-bridge/main/idf_component.yml b/firmware/s3-bridge/main/idf_component.yml new file mode 100644 index 0000000..b8132e5 --- /dev/null +++ b/firmware/s3-bridge/main/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + espressif/led_strip: "3.0.3" diff --git a/firmware/s3-bridge/main/main.cpp b/firmware/s3-bridge/main/main.cpp index 48b2b71..8753fe5 100644 --- a/firmware/s3-bridge/main/main.cpp +++ b/firmware/s3-bridge/main/main.cpp @@ -7,31 +7,51 @@ #include "espnow_link.h" #include "uart_bridge.h" #include "halt_gpio.h" +#include "status_rgb.h" #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include -static void print_boot_self_check() +static int shell_printf(const char* format, ...) +{ + char buf[768]; + va_list args; + va_start(args, format); + const int needed = vsnprintf(buf, sizeof(buf), format, args); + va_end(args); + if (needed <= 0) return needed; + const size_t count = static_cast(needed) < sizeof(buf) + ? static_cast(needed) + : sizeof(buf) - 1; + return usb_serial_jtag_write_bytes(buf, count, pdMS_TO_TICKS(100)); +} + +#define printf shell_printf + +static bool print_boot_self_check() { bool espnow_ok = espnow_self_check(); bool uart_ok = uart_bridge_self_check(); bool all_ok = espnow_ok && uart_ok; - - printf("--- Boot self-check ---\r\n"); - printf(" ESP-NOW stack : %s (ch %d, queue depth %d)\r\n", - espnow_ok ? "PASS" : "FAIL", - espnow_get_channel(), ESPNOW_QUEUE_DEPTH); - printf(" UART bridge : %s (TX=GPIO%d RX=GPIO%d)\r\n", - uart_ok ? "PASS" : "FAIL", - uart_bridge_tx_gpio(), uart_bridge_rx_gpio()); - printf(" Overall : %s\r\n\r\n", all_ok ? "PASS" : "FAIL"); + ESP_LOGI("main", "self-check ESP-NOW=%s UART=%s", + espnow_ok ? "PASS" : "FAIL", uart_ok ? "PASS" : "FAIL"); + return all_ok; } static void print_link_health() @@ -131,9 +151,56 @@ static void print_help() printf(" led on|off - activity LED pulses\r\n"); printf(" stats reset - clear traffic / fail counters\r\n"); printf(" status - config, counters, link health\r\n"); + printf(" diag - copyable build/crash/link diagnostics\r\n"); + printf(" trace on|off - live 2 s ESP-NOW channel/link log\r\n"); + printf(" log - print saved ~2 min link history\r\n"); + printf(" log clear - clear saved link history\r\n"); + printf(" coredump clear - erase saved crash image after collection\r\n"); printf(" help - this list\r\n\r\n"); } +static void print_diag() +{ + const esp_app_desc_t *app = esp_app_get_description(); + uint8_t primary = 0; + wifi_second_chan_t secondary = WIFI_SECOND_CHAN_NONE; + (void)esp_wifi_get_channel(&primary, &secondary); + printf("\r\n=== MODULUS S3 DIAGNOSTICS ===\r\n"); + printf(" App version : %s\r\n", app ? app->version : "unknown"); + printf(" ELF SHA256 : "); + if (app) for (unsigned i = 0; i < sizeof(app->app_elf_sha256); ++i) printf("%02x", app->app_elf_sha256[i]); + printf("\r\n Board : %s\r\n", bridge_board_get()->id); + printf(" Reset reason : %d\r\n", (int)esp_reset_reason()); + size_t core_addr = 0; + size_t core_size = 0; + const esp_err_t core_rc = esp_core_dump_image_get(&core_addr, &core_size); + if (core_rc == ESP_OK) { + printf(" Saved coredump : yes, address 0x%08x, %u bytes\r\n", + (unsigned)core_addr, (unsigned)core_size); + } else { + printf(" Saved coredump : no\r\n"); + } + printf(" Uptime : %llu ms\r\n", (unsigned long long)(esp_timer_get_time() / 1000)); + printf(" Heap free/min : %u / %u bytes\r\n", (unsigned)esp_get_free_heap_size(), + (unsigned)esp_get_minimum_free_heap_size()); + printf(" Radio channel : %u\r\n", (unsigned)primary); + printf(" Channel hunting : %s\r\n", espnow_channel_hunting() ? "yes" : "no"); + printf(" Last link age : %lu ms\r\n", (unsigned long)espnow_last_link_age_ms()); + printf(" Last RX/TX-ok age : %lu / %lu ms\r\n", (unsigned long)espnow_last_rx_age_ms(), + (unsigned long)espnow_last_tx_ok_age_ms()); + printf(" Tab5 MAC : %s\r\n", espnow_tab5_mac_str()); + printf(" RX/TX/fail : %lu / %lu / %lu\r\n", (unsigned long)espnow_rx_count(), + (unsigned long)espnow_tx_count(), (unsigned long)espnow_fail_count()); + printf(" All radio RX : %lu (includes probes/control)\r\n", + (unsigned long)espnow_air_rx_count()); + printf(" Queue in/out/drop : %lu / %lu / %lu+%lu\r\n", + (unsigned long)espnow_inbound_pending(), (unsigned long)espnow_outbound_pending(), + (unsigned long)espnow_inbound_drops(), (unsigned long)espnow_outbound_drops()); + printf(" UART baud TX/RX : %lu GPIO%d/GPIO%d\r\n", (unsigned long)uart_bridge_baud(), + uart_bridge_tx_gpio(), uart_bridge_rx_gpio()); + printf("=== END DIAGNOSTICS ===\r\n"); +} + static void handle_command(const char* line) { char cmd[32] = {}; @@ -143,6 +210,18 @@ static void handle_command(const char* line) if (strcmp(cmd, "status") == 0) { print_status(); + } else if (strcmp(cmd, "diag") == 0) { + print_diag(); + + } else if (strcmp(cmd, "coredump") == 0) { + if (strcmp(arg, "clear") == 0) { + const esp_err_t rc = esp_core_dump_image_erase(); + printf(" Saved coredump erase: %s\r\n", esp_err_to_name(rc)); + } else { + printf(" Use: coredump clear\r\n"); + return; + } + } else if (strcmp(cmd, "help") == 0) { print_help(); @@ -153,11 +232,31 @@ static void handle_command(const char* line) return; } if (espnow_set_channel((uint8_t)ch)) { - printf(" Channel set to %d (live - Tab5 must match)\r\n", ch); + printf(" Channel set to %d; old link proof cleared, 5 s verify window\r\n", ch); } else { printf(" Error: failed to apply channel %d\r\n", ch); } + } else if (strcmp(cmd, "trace") == 0) { + if (strcmp(arg, "on") == 0) { + espnow_trace_set(true); + printf(" Live link trace on (one line every 2 s)\r\n"); + } else if (strcmp(arg, "off") == 0) { + espnow_trace_set(false); + printf(" Live link trace off\r\n"); + } else { + printf(" Use: trace on|off (currently %s)\r\n", + espnow_trace_enabled() ? "on" : "off"); + } + + } else if (strcmp(cmd, "log") == 0) { + if (strcmp(arg, "clear") == 0) { + espnow_log_clear(); + printf(" Link history cleared\r\n"); + } else { + espnow_log_print(); + } + } else if (strcmp(cmd, "baud") == 0) { uint32_t baud = (uint32_t)atol(arg); if (baud != 115200 && baud != 230400 && baud != 460800 && baud != 921600) { @@ -258,18 +357,23 @@ static void shell_task(void* arg) char line[64]; int pos = 0; - printf("\r\nS3 ESP-NOW <-> UART Bridge board=%s (type 'board' or 'help')\r\n", + printf("\r\nS3 bridge ready (%s). Type 'diag' or 'help'.\r\n", bridge_board_get()->id); - print_status(); printf("> "); fflush(stdout); while (1) { - int ch = fgetc(stdin); - if (ch == EOF) { vTaskDelay(pdMS_TO_TICKS(10)); continue; } + uint8_t byte = 0; + if (usb_serial_jtag_read_bytes(&byte, 1, pdMS_TO_TICKS(20)) != 1) continue; + const int ch = byte; if (ch == '\r' || ch == '\n') { - if (pos == 0) { printf("> "); fflush(stdout); continue; } + if (pos == 0) { + /* Most terminals send CRLF. The CR already executed the + * command; silently ignore the following LF instead of + * flooding the console with a second help/menu block. */ + continue; + } line[pos] = '\0'; pos = 0; printf("\r\n"); @@ -281,7 +385,8 @@ static void shell_task(void* arg) fflush(stdout); } else if (ch >= 0x20 && pos < (int)(sizeof(line) - 1)) { line[pos++] = (char)ch; - fputc(ch, stdout); + const uint8_t echo = (uint8_t)ch; + (void)usb_serial_jtag_write_bytes(&echo, 1, pdMS_TO_TICKS(20)); fflush(stdout); } } @@ -290,6 +395,18 @@ static void shell_task(void* arg) // ── app_main ───────────────────────────────────────────────────────────────── extern "C" void app_main() { + if (!usb_serial_jtag_is_driver_installed()) { + usb_serial_jtag_driver_config_t usb_cfg = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(); + usb_cfg.tx_buffer_size = 2048; + usb_cfg.rx_buffer_size = 512; + ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&usb_cfg)); + } + + /* This CH343 board's application UART can stop draining while Wi-Fi is + * starting. Keep background logging off so diagnostics can be requested + * explicitly without blocking radio/task startup. */ + esp_log_level_set("*", ESP_LOG_NONE); + /* NVS - must initialise before any component reads settings */ esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || @@ -309,12 +426,22 @@ extern "C" void app_main() espnow_init(); bridge_board_init(); + status_rgb_start(); halt_gpio_init(); /* Initialise UART bridge (sets up driver + RX task) */ uart_bridge_init(); + status_rgb_mark_runtime_ready(); + + const bool boot_self_check_ok = print_boot_self_check(); - print_boot_self_check(); + /* An OTA image becomes permanent only after its radio/UART self-check has + * completed. A crash before this point lets the bootloader roll it back. */ + if (boot_self_check_ok) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ota_mark_app_valid_cancel_rollback()); + } else { + ESP_LOGE("main", "Boot self-check failed; OTA image remains pending for rollback"); + } /* Launch interactive shell on UART0 / USB-CDC */ xTaskCreatePinnedToCore(shell_task, "shell", 4096, NULL, 3, NULL, 1); diff --git a/firmware/s3-bridge/main/s3_ota.cpp b/firmware/s3-bridge/main/s3_ota.cpp new file mode 100644 index 0000000..dc61d5f --- /dev/null +++ b/firmware/s3-bridge/main/s3_ota.cpp @@ -0,0 +1,261 @@ +#include "s3_ota.h" +#include "s3_ota_protocol.h" +#include "bridge_board.h" +#include "uart_bridge.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *TAG = "s3_ota"; + +typedef struct { + uint8_t mac[6]; + uint16_t len; + uint8_t bytes[MOD_S3_OTA_HEADER_SIZE + MOD_S3_OTA_DATA_MAX]; +} ota_queue_item_t; + +static QueueHandle_t s_queue; +static s3_ota_send_fn s_send; +static esp_ota_handle_t s_handle; +static const esp_partition_t *s_partition; +static uint32_t s_session; +static uint32_t s_expected_seq; +static uint32_t s_image_size; +static uint32_t s_written; +static uint32_t s_last_payload_crc; +static uint16_t s_last_payload_len; +static bool s_active; + +static bool baud_valid(uint32_t baud) +{ + return baud == 115200 || baud == 230400 || baud == 460800 || baud == 921600; +} + +static bool pin_reserved(int gpio) +{ + if (gpio == 0 || gpio == 19 || gpio == 20 || (gpio >= 26 && gpio <= 37) || + gpio == 45 || gpio == 46) return true; + const bridge_board_t *board = bridge_board_get(); + return board && (gpio == board->led_tx || gpio == board->led_rx || + gpio == board->halt || gpio == board->rgb_gpio); +} + +static bool uart_config_valid(const mod_s3_uart_config_t *cfg) +{ + if (!cfg || cfg->tx_gpio == cfg->rx_gpio || !baud_valid(cfg->baud)) return false; + if (!GPIO_IS_VALID_OUTPUT_GPIO(cfg->tx_gpio) || !GPIO_IS_VALID_GPIO(cfg->rx_gpio)) return false; + return !pin_reserved(cfg->tx_gpio) && !pin_reserved(cfg->rx_gpio); +} + +static void reset_session(bool abort_flash) +{ + if (abort_flash && s_active) (void)esp_ota_abort(s_handle); + s_handle = 0; + s_partition = nullptr; + s_session = 0; + s_expected_seq = 0; + s_image_size = 0; + s_written = 0; + s_last_payload_crc = 0; + s_last_payload_len = 0; + s_active = false; +} + +static void send_reply(const uint8_t mac[6], const mod_s3_ota_packet_t *request, + mod_s3_ota_status_t status, mod_s3_uart_test_result_t test_result) +{ + if (!s_send || !request) return; + mod_s3_ota_packet_t packet = {}; + packet.magic = MOD_S3_OTA_MAGIC; + packet.version = MOD_S3_OTA_VERSION; + packet.type = MOD_S3_OTA_REPLY; + packet.session = request->session; + packet.sequence = request->sequence; + packet.value = s_written; + mod_s3_ota_reply_t reply = {}; + reply.command = request->type; + reply.status = status; + const esp_app_desc_t *desc = esp_app_get_description(); + if (desc) snprintf(reply.app_version, sizeof(reply.app_version), "%s", desc->version); + reply.capabilities = MOD_S3_CAP_UART_CONFIG | MOD_S3_CAP_UART_TEST; + reply.uart_tx_gpio = (int8_t)uart_bridge_tx_gpio(); + reply.uart_rx_gpio = (int8_t)uart_bridge_rx_gpio(); + reply.uart_baud = uart_bridge_baud(); + reply.uart_test_result = test_result; + packet.payload_len = sizeof(reply); + memcpy(packet.payload, &reply, sizeof(reply)); + (void)s_send(mac, reinterpret_cast(&packet), + MOD_S3_OTA_HEADER_SIZE + packet.payload_len); +} + +static mod_s3_ota_status_t begin_update(const mod_s3_ota_packet_t *packet) +{ + if (s_active) reset_session(true); + if (packet->value == 0) return MOD_S3_OTA_BAD_IMAGE; + s_partition = esp_ota_get_next_update_partition(nullptr); + if (!s_partition || packet->value > s_partition->size) return MOD_S3_OTA_BAD_IMAGE; + const esp_err_t err = esp_ota_begin(s_partition, packet->value, &s_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_begin: %s", esp_err_to_name(err)); + reset_session(false); + return MOD_S3_OTA_FLASH_ERROR; + } + s_active = true; + s_session = packet->session; + s_image_size = packet->value; + s_expected_seq = 0; + s_written = 0; + ESP_LOGW(TAG, "OTA session %lu started (%lu bytes -> %s)", + (unsigned long)s_session, (unsigned long)s_image_size, s_partition->label); + return MOD_S3_OTA_OK; +} + +static mod_s3_ota_status_t write_data(const mod_s3_ota_packet_t *packet) +{ + if (!s_active || packet->session != s_session) + return MOD_S3_OTA_BAD_STATE; + if (packet->payload_len == 0 || packet->payload_len > MOD_S3_OTA_DATA_MAX) + return MOD_S3_OTA_BAD_PACKET; + const uint32_t crc = esp_crc32_le(UINT32_MAX, packet->payload, packet->payload_len); + if (crc != packet->payload_crc) return MOD_S3_OTA_BAD_PACKET; + /* The host retries when an ESP-NOW reply is lost. Acknowledge the exact + * previous block again without writing it twice. */ + if (s_expected_seq > 0 && packet->sequence == s_expected_seq - 1 && + packet->payload_len == s_last_payload_len && packet->payload_crc == s_last_payload_crc) + return MOD_S3_OTA_OK; + if (packet->sequence != s_expected_seq) return MOD_S3_OTA_BAD_STATE; + if (s_written + packet->payload_len > s_image_size) return MOD_S3_OTA_BAD_PACKET; + if (s_written == 0) { + if (packet->payload_len < sizeof(esp_image_header_t)) return MOD_S3_OTA_BAD_IMAGE; + const esp_image_header_t *header = reinterpret_cast(packet->payload); + if (header->magic != ESP_IMAGE_HEADER_MAGIC || header->chip_id != ESP_CHIP_ID_ESP32S3) + return MOD_S3_OTA_BAD_IMAGE; + } + const esp_err_t err = esp_ota_write(s_handle, packet->payload, packet->payload_len); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_write: %s", esp_err_to_name(err)); + reset_session(true); + return MOD_S3_OTA_FLASH_ERROR; + } + s_written += packet->payload_len; + s_last_payload_crc = packet->payload_crc; + s_last_payload_len = packet->payload_len; + s_expected_seq++; + return MOD_S3_OTA_OK; +} + +static mod_s3_ota_status_t finish_update(const mod_s3_ota_packet_t *packet) +{ + if (!s_active || packet->session != s_session || s_written != s_image_size) + return MOD_S3_OTA_BAD_STATE; + esp_err_t err = esp_ota_end(s_handle); /* validates the complete ESP image */ + s_active = false; + if (err == ESP_OK) err = esp_ota_set_boot_partition(s_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "OTA validation/activation: %s", esp_err_to_name(err)); + reset_session(false); + return MOD_S3_OTA_BAD_IMAGE; + } + ESP_LOGW(TAG, "OTA image verified; restart command required to boot %s", s_partition->label); + return MOD_S3_OTA_OK; +} + +static void reboot_task(void *) +{ + vTaskDelay(pdMS_TO_TICKS(350)); + esp_restart(); +} + +static void ota_worker(void *) +{ + ota_queue_item_t item; + for (;;) { + if (xQueueReceive(s_queue, &item, portMAX_DELAY) != pdTRUE) continue; + const mod_s3_ota_packet_t *packet = reinterpret_cast(item.bytes); + mod_s3_ota_status_t status = MOD_S3_OTA_OK; + mod_s3_uart_test_result_t test_result = MOD_S3_UART_TEST_NOT_RUN; + switch (packet->type) { + case MOD_S3_OTA_PROBE: + break; + case MOD_S3_OTA_BEGIN: + status = begin_update(packet); + break; + case MOD_S3_OTA_DATA: + status = write_data(packet); + break; + case MOD_S3_OTA_END: + status = finish_update(packet); + break; + case MOD_S3_OTA_ABORT: + reset_session(true); + break; + case MOD_S3_OTA_REBOOT: + send_reply(item.mac, packet, MOD_S3_OTA_OK, MOD_S3_UART_TEST_NOT_RUN); + xTaskCreate(reboot_task, "s3_reboot", 2048, nullptr, 4, nullptr); + continue; + case MOD_S3_CTRL_GET_UART: + break; + case MOD_S3_CTRL_SET_UART: { + if (packet->payload_len != sizeof(mod_s3_uart_config_t)) { + status = MOD_S3_OTA_BAD_PACKET; + break; + } + mod_s3_uart_config_t cfg = {}; + memcpy(&cfg, packet->payload, sizeof(cfg)); + if (!uart_config_valid(&cfg)) { + status = MOD_S3_OTA_BAD_CONFIG; + break; + } + if (!uart_bridge_reinit(cfg.baud, cfg.tx_gpio, cfg.rx_gpio)) + status = MOD_S3_OTA_NVS_ERROR; + break; + } + case MOD_S3_CTRL_TEST_UART: + test_result = static_cast(uart_bridge_test_grbl(1500)); + break; + default: + status = MOD_S3_OTA_BAD_PACKET; + break; + } + send_reply(item.mac, packet, status, test_result); + } +} + +void s3_ota_init(s3_ota_send_fn send_fn) +{ + s_send = send_fn; + s_queue = xQueueCreate(4, sizeof(ota_queue_item_t)); + ESP_ERROR_CHECK(s_queue ? ESP_OK : ESP_ERR_NO_MEM); + xTaskCreatePinnedToCore(ota_worker, "s3_ota", 6144, nullptr, 8, nullptr, 1); + ESP_LOGI(TAG, "OTA receiver ready"); +} + +bool s3_ota_try_handle(const uint8_t src_mac[6], const uint8_t *data, size_t len) +{ + if (!src_mac || !data || len < MOD_S3_OTA_HEADER_SIZE || !s_queue) return false; + const mod_s3_ota_packet_t *packet = reinterpret_cast(data); + if (packet->magic != MOD_S3_OTA_MAGIC) return false; + if (packet->version != MOD_S3_OTA_VERSION || packet->payload_len > MOD_S3_OTA_DATA_MAX || + len != MOD_S3_OTA_HEADER_SIZE + packet->payload_len) { + return true; /* OTA magic: consume malformed control traffic, never forward to CNC UART. */ + } + ota_queue_item_t item = {}; + memcpy(item.mac, src_mac, 6); + item.len = static_cast(len); + memcpy(item.bytes, data, len); + if (xQueueSend(s_queue, &item, 0) != pdTRUE) { + ESP_LOGW(TAG, "OTA queue full"); + } + return true; +} diff --git a/firmware/s3-bridge/main/s3_ota.h b/firmware/s3-bridge/main/s3_ota.h new file mode 100644 index 0000000..8d8ee59 --- /dev/null +++ b/firmware/s3-bridge/main/s3_ota.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include +#include + +typedef bool (*s3_ota_send_fn)(const uint8_t *mac, const uint8_t *data, size_t len); + +void s3_ota_init(s3_ota_send_fn send_fn); +bool s3_ota_try_handle(const uint8_t src_mac[6], const uint8_t *data, size_t len); + diff --git a/firmware/s3-bridge/main/s3_ota_protocol.h b/firmware/s3-bridge/main/s3_ota_protocol.h new file mode 100644 index 0000000..efa4d36 --- /dev/null +++ b/firmware/s3-bridge/main/s3_ota_protocol.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +/* Private Modulus S3 OTA control plane carried inside ESP-NOW payloads. + * The magic keeps firmware traffic separate from transparent CNC UART data. */ +#define MOD_S3_OTA_MAGIC 0x53334f54U /* "S3OT" */ +#define MOD_S3_OTA_VERSION 1U +#define MOD_S3_OTA_DATA_MAX 1024U + +typedef enum { + MOD_S3_OTA_PROBE = 1, + MOD_S3_OTA_BEGIN = 2, + MOD_S3_OTA_DATA = 3, + MOD_S3_OTA_END = 4, + MOD_S3_OTA_ABORT = 5, + MOD_S3_OTA_REBOOT = 6, + MOD_S3_CTRL_GET_UART = 7, + MOD_S3_CTRL_SET_UART = 8, + MOD_S3_CTRL_TEST_UART = 9, + MOD_S3_OTA_REPLY = 0x80, +} mod_s3_ota_type_t; + +typedef enum { + MOD_S3_OTA_OK = 0, + MOD_S3_OTA_BAD_PACKET, + MOD_S3_OTA_BAD_STATE, + MOD_S3_OTA_BAD_IMAGE, + MOD_S3_OTA_FLASH_ERROR, + MOD_S3_OTA_BUSY, + MOD_S3_OTA_BAD_CONFIG, + MOD_S3_OTA_NVS_ERROR, +} mod_s3_ota_status_t; + +#pragma pack(push, 1) +typedef struct { + uint32_t magic; + uint8_t version; + uint8_t type; + uint16_t payload_len; + uint32_t session; + uint32_t sequence; + uint32_t value; /* BEGIN: image size; REPLY: bytes written */ + uint32_t payload_crc; /* DATA payload CRC32, zero otherwise */ + uint8_t payload[MOD_S3_OTA_DATA_MAX]; +} mod_s3_ota_packet_t; + +typedef struct { + uint8_t command; + uint8_t status; + uint16_t reserved; + char app_version[32]; + uint8_t capabilities; + int8_t uart_tx_gpio; + int8_t uart_rx_gpio; + uint8_t reserved2; + uint32_t uart_baud; + uint8_t uart_test_result; +} mod_s3_ota_reply_t; + +typedef struct { + int8_t tx_gpio; + int8_t rx_gpio; + uint16_t reserved; + uint32_t baud; +} mod_s3_uart_config_t; +#pragma pack(pop) + +#define MOD_S3_CAP_UART_CONFIG 0x01U +#define MOD_S3_CAP_UART_TEST 0x02U + +typedef enum { + MOD_S3_UART_TEST_NOT_RUN = 0, + MOD_S3_UART_TEST_GRBL_OK, + MOD_S3_UART_TEST_DATA_UNRECOGNIZED, + MOD_S3_UART_TEST_NO_RESPONSE, + MOD_S3_UART_TEST_TX_ERROR, + MOD_S3_UART_TEST_BUSY, +} mod_s3_uart_test_result_t; + +#define MOD_S3_OTA_HEADER_SIZE ((uint16_t)offsetof(mod_s3_ota_packet_t, payload)) diff --git a/firmware/s3-bridge/main/status_rgb.cpp b/firmware/s3-bridge/main/status_rgb.cpp new file mode 100644 index 0000000..92dd670 --- /dev/null +++ b/firmware/s3-bridge/main/status_rgb.cpp @@ -0,0 +1,105 @@ +#include "status_rgb.h" +#include "bridge_board.h" +#include "espnow_link.h" +#include "uart_bridge.h" + +#include +#include +#include +#include +#include + +static const char *TAG = "status_rgb"; +static led_strip_handle_t s_strip; +static int s_gpio = -1; +static std::atomic s_runtime_ready{false}; + +static void set_rgb(uint8_t red, uint8_t green, uint8_t blue) +{ + if (!s_strip) return; + (void)led_strip_set_pixel(s_strip, 0, red, green, blue); + (void)led_strip_refresh(s_strip); +} + +static void bind_board_led() +{ + const int gpio = bridge_board_get()->rgb_gpio; + if (gpio == s_gpio) return; + if (s_strip) { + (void)led_strip_clear(s_strip); + (void)led_strip_del(s_strip); + s_strip = nullptr; + } + s_gpio = gpio; + if (gpio < 0) return; + + led_strip_config_t strip = {}; + strip.strip_gpio_num = gpio; + strip.max_leds = 1; + strip.led_model = LED_MODEL_WS2812; + strip.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB; + led_strip_rmt_config_t rmt = {}; + rmt.clk_src = RMT_CLK_SRC_DEFAULT; + rmt.resolution_hz = 10000000; + if (led_strip_new_rmt_device(&strip, &rmt, &s_strip) != ESP_OK) { + s_strip = nullptr; + ESP_LOGW(TAG, "WS2812 init failed on GPIO%d", gpio); + return; + } + set_rgb(4, 1, 0); /* boot: very dim amber */ + ESP_LOGI(TAG, "WS2812 status LED on GPIO%d", gpio); +} + +static void status_task(void *) +{ + uint32_t last_bytes = 0; + uint32_t last_fails = 0; + TickType_t activity_until = 0; + TickType_t error_until = 0; + for (;;) { + bind_board_led(); + const TickType_t now = xTaskGetTickCount(); + const uint32_t bytes = uart_bridge_bytes_tx() + uart_bridge_bytes_rx(); + const uint32_t fails = espnow_fail_count(); + if (bytes != last_bytes) activity_until = now + pdMS_TO_TICKS(140); + if (fails != last_fails && espnow_last_link_age_ms() < 5000) { + error_until = now + pdMS_TO_TICKS(500); + } + last_bytes = bytes; + last_fails = fails; + + if (!s_runtime_ready.load(std::memory_order_acquire)) { + set_rgb(4, 1, 0); /* startup incomplete: amber */ + } else if (now < activity_until) { + set_rgb(0, 3, 5); /* CNC traffic: cyan */ + } else if (now < error_until) { + set_rgb(6, 1, 0); /* link error: orange */ + } else if (!espnow_channel_hunting() && espnow_last_link_age_ms() < 5000) { + set_rgb(0, 5, 0); /* connected: green */ + } else if (espnow_channel_hunting() && + ((now / pdMS_TO_TICKS(300)) & 1U) == 0) { + set_rgb(4, 0, 5); /* active channel hunt: violet */ + } else if (espnow_channel_hunting()) { + set_rgb(0, 0, 0); + } else if (((now / pdMS_TO_TICKS(300)) & 1U) == 0) { + set_rgb(0, 1, 5); /* searching: blue blink */ + } else { + set_rgb(0, 0, 0); + } + vTaskDelay(pdMS_TO_TICKS(70)); + } +} + +void status_rgb_mark_runtime_ready() +{ + s_runtime_ready.store(true, std::memory_order_release); +} + +void status_rgb_start() +{ + bind_board_led(); + /* Keep the task alive even when the boot profile has no RGB LED: a later + * `board ...` command can select one without requiring a reboot. */ + (void)xTaskCreatePinnedToCore(status_task, "status_rgb", 3072, + nullptr, 3, nullptr, 1); +} diff --git a/firmware/s3-bridge/main/status_rgb.h b/firmware/s3-bridge/main/status_rgb.h new file mode 100644 index 0000000..c7413f6 --- /dev/null +++ b/firmware/s3-bridge/main/status_rgb.h @@ -0,0 +1,4 @@ +#pragma once + +void status_rgb_start(); +void status_rgb_mark_runtime_ready(); diff --git a/firmware/s3-bridge/main/uart_bridge.cpp b/firmware/s3-bridge/main/uart_bridge.cpp index 09088d0..f6d8d47 100644 --- a/firmware/s3-bridge/main/uart_bridge.cpp +++ b/firmware/s3-bridge/main/uart_bridge.cpp @@ -42,6 +42,10 @@ static std::atomic s_bytes_tx{0}; static std::atomic s_bytes_rx{0}; static std::atomic s_uart_tx_fails{0}; static std::atomic s_rx_overruns{0}; +static std::atomic s_probe_active{false}; +static std::atomic s_probe_any_data{false}; +static std::atomic s_probe_open{false}; +static std::atomic s_probe_valid{false}; static QueueHandle_t s_uart_evt = nullptr; @@ -150,7 +154,7 @@ static void activity_led_pulse_rx() led_pulse(static_cast(s_led_rx), s_led_rx_timer); } -static void save_config(); +static bool save_config(); static void load_config() { @@ -172,12 +176,12 @@ static void load_config() int32_t gpio = 0; const int def_tx = s_tx_gpio; const int def_rx = s_rx_gpio; - if (have_board && nvs_get_i32(h, NVS_KEY_TX_GPIO, &gpio) == ESP_OK && gpio >= 0) { - if (gpio == 17) { s_tx_gpio = def_tx; migrated = true; } + if (nvs_get_i32(h, NVS_KEY_TX_GPIO, &gpio) == ESP_OK && gpio >= 0) { + if (have_board && gpio == 17) { s_tx_gpio = def_tx; migrated = true; } else s_tx_gpio = static_cast(gpio); } - if (have_board && nvs_get_i32(h, NVS_KEY_RX_GPIO, &gpio) == ESP_OK && gpio >= 0) { - if (gpio == 18) { s_rx_gpio = def_rx; migrated = true; } + if (nvs_get_i32(h, NVS_KEY_RX_GPIO, &gpio) == ESP_OK && gpio >= 0) { + if (have_board && gpio == 18) { s_rx_gpio = def_rx; migrated = true; } else s_rx_gpio = static_cast(gpio); } @@ -199,10 +203,10 @@ static void load_config() } } -static void save_config() +static bool save_config() { bridge_nvs_t nvs = bridge_nvs_open(NVS_READWRITE); - if (!nvs.ok) return; + if (!nvs.ok) return false; esp_err_t err = nvs_set_u32(nvs.h, NVS_KEY_BAUD, s_baud); if (err == ESP_OK) err = nvs_set_i32(nvs.h, NVS_KEY_TX_GPIO, static_cast(s_tx_gpio)); if (err == ESP_OK) err = nvs_set_i32(nvs.h, NVS_KEY_RX_GPIO, static_cast(s_rx_gpio)); @@ -217,6 +221,7 @@ static void save_config() if (err != ESP_OK) { ESP_LOGW(TAG, "save_config: %s", esp_err_to_name(err)); } + return err == ESP_OK; } static void uart_install(uint32_t baud, int tx, int rx) @@ -322,8 +327,22 @@ static void uart_rx_task(void* arg) if (n <= 0) continue; s_bytes_rx.fetch_add(static_cast(n), std::memory_order_relaxed); + if (s_probe_active.load(std::memory_order_acquire)) { + s_probe_any_data.store(true, std::memory_order_release); + bool open = s_probe_open.load(std::memory_order_relaxed); + for (int i = 0; i < n; ++i) { + if (buf[i] == '<') open = true; + else if (open && buf[i] == '>') s_probe_valid.store(true, std::memory_order_release); + } + s_probe_open.store(open, std::memory_order_relaxed); + } activity_led_pulse_rx(); (void)espnow_queue_to_tab5(buf, static_cast(n)); + + /* A noisy/floating RX pin can keep UART_DATA ready continuously. This + * task runs above the idle task, so yield for one tick after every + * drained batch to keep the Core 1 watchdog fed under an RX storm. */ + vTaskDelay(1); } } @@ -370,20 +389,56 @@ void uart_bridge_send(const uint8_t* data, size_t len) activity_led_pulse_tx(); } -void uart_bridge_reinit(uint32_t baud, int tx_gpio, int rx_gpio) +bool uart_bridge_reinit(uint32_t baud, int tx_gpio, int rx_gpio) { s_rx_paused.store(true, std::memory_order_release); vTaskDelay(pdMS_TO_TICKS(kReinitPauseMs)); + const uint32_t old_baud = s_baud; + const int old_tx_gpio = s_tx_gpio; + const int old_rx_gpio = s_rx_gpio; s_baud = baud; s_tx_gpio = tx_gpio; s_rx_gpio = rx_gpio; - save_config(); + if (!save_config()) { + s_baud = old_baud; + s_tx_gpio = old_tx_gpio; + s_rx_gpio = old_rx_gpio; + s_rx_paused.store(false, std::memory_order_release); + return false; + } uart_install(s_baud, s_tx_gpio, s_rx_gpio); s_rx_paused.store(false, std::memory_order_release); ESP_LOGI(TAG, "UART reconfigured: %lu baud TX=%d RX=%d", static_cast(s_baud), s_tx_gpio, s_rx_gpio); + return true; +} + +uart_bridge_test_result_t uart_bridge_test_grbl(uint32_t timeout_ms) +{ + bool expected = false; + if (!s_probe_active.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) + return UART_BRIDGE_TEST_BUSY; + s_probe_any_data.store(false, std::memory_order_relaxed); + s_probe_open.store(false, std::memory_order_relaxed); + s_probe_valid.store(false, std::memory_order_relaxed); + const uint32_t failures_before = s_uart_tx_fails.load(std::memory_order_relaxed); + static const uint8_t status_query = '?'; + uart_bridge_send(&status_query, 1); + if (s_uart_tx_fails.load(std::memory_order_relaxed) != failures_before) { + s_probe_active.store(false, std::memory_order_release); + return UART_BRIDGE_TEST_TX_ERROR; + } + const int64_t deadline = esp_timer_get_time() + (int64_t)timeout_ms * 1000; + while (esp_timer_get_time() < deadline && !s_probe_valid.load(std::memory_order_acquire)) + vTaskDelay(pdMS_TO_TICKS(20)); + const bool valid = s_probe_valid.load(std::memory_order_acquire); + const bool any_data = s_probe_any_data.load(std::memory_order_acquire); + s_probe_active.store(false, std::memory_order_release); + if (valid) return UART_BRIDGE_TEST_GRBL_OK; + if (any_data) return UART_BRIDGE_TEST_DATA_UNRECOGNIZED; + return UART_BRIDGE_TEST_NO_RESPONSE; } void uart_bridge_mpg_activate() @@ -410,12 +465,9 @@ void uart_bridge_init() espnow_start_inbound_worker(); xTaskCreatePinnedToCore(uart_rx_task, "uart_rx", 4096, NULL, 8, NULL, 1); - printf(" UART%d %lu baud TX=GPIO%d RX=GPIO%d\r\n", - UART_PORT_NUM, static_cast(s_baud), s_tx_gpio, s_rx_gpio); - printf(" Match grblHAL USART to this baud\r\n"); - printf(" UART batch trigger: %lu ms LEDs: %s\r\n\r\n", - static_cast(s_batch_ms.load(std::memory_order_relaxed)), - s_led_en.load(std::memory_order_relaxed) ? "on" : "off"); + ESP_LOGI(TAG, "UART%d ready %lu baud GPIO%d/%d batch %lu ms", + UART_PORT_NUM, static_cast(s_baud), s_tx_gpio, s_rx_gpio, + static_cast(s_batch_ms.load(std::memory_order_relaxed))); } uint32_t uart_bridge_baud() { return s_baud; } diff --git a/firmware/s3-bridge/main/uart_bridge.h b/firmware/s3-bridge/main/uart_bridge.h index 0f08012..83ff995 100644 --- a/firmware/s3-bridge/main/uart_bridge.h +++ b/firmware/s3-bridge/main/uart_bridge.h @@ -11,7 +11,7 @@ extern "C" { void uart_bridge_init(); /* Re-initialise UART after a baud/GPIO change (from shell) */ -void uart_bridge_reinit(uint32_t baud, int tx_gpio, int rx_gpio); +bool uart_bridge_reinit(uint32_t baud, int tx_gpio, int rx_gpio); /* (Re-)send 0x8B to activate MPG mode on grblHAL Flexi-HAL */ void uart_bridge_mpg_activate(); @@ -19,6 +19,18 @@ void uart_bridge_mpg_activate(); /* Queue bytes for UART TX → grblHAL (ESP-NOW worker or shell) */ void uart_bridge_send(const uint8_t* data, size_t len); +typedef enum { + UART_BRIDGE_TEST_NOT_RUN = 0, + UART_BRIDGE_TEST_GRBL_OK, + UART_BRIDGE_TEST_DATA_UNRECOGNIZED, + UART_BRIDGE_TEST_NO_RESPONSE, + UART_BRIDGE_TEST_TX_ERROR, + UART_BRIDGE_TEST_BUSY, +} uart_bridge_test_result_t; + +/* Send one harmless grbl realtime status query and classify the response. */ +uart_bridge_test_result_t uart_bridge_test_grbl(uint32_t timeout_ms); + /* Runtime tunables (persisted in NVS) */ uint32_t uart_bridge_batch_ms(); void uart_bridge_set_batch_ms(uint32_t ms); diff --git a/firmware/s3-bridge/partitions_ota.csv b/firmware/s3-bridge/partitions_ota.csv new file mode 100644 index 0000000..4dbfe5c --- /dev/null +++ b/firmware/s3-bridge/partitions_ota.csv @@ -0,0 +1,8 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +otadata, data, ota, 0xf000, 0x2000, +phy_init, data, phy, 0x11000, 0x1000, +ota_0, app, ota_0, 0x20000, 0x300000, +ota_1, app, ota_1, 0x320000, 0x300000, +coredump, data, coredump,0x620000, 0x10000, +storage, data, spiffs, 0x630000, 0x1d0000, diff --git a/firmware/s3-bridge/sdkconfig.defaults b/firmware/s3-bridge/sdkconfig.defaults index a854fd5..6bd9115 100644 --- a/firmware/s3-bridge/sdkconfig.defaults +++ b/firmware/s3-bridge/sdkconfig.defaults @@ -11,6 +11,9 @@ CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y CONFIG_ESPTOOLPY_FLASHFREQ_80M=y CONFIG_ESPTOOLPY_OCT_FLASH=n +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_ota.csv" +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y # Disable brownout detector — WiFi/ESP-NOW init briefly spikes current CONFIG_ESP32S3_BROWNOUT_DET=n @@ -34,9 +37,18 @@ CONFIG_FREERTOS_HZ=1000 # NVS for persistent settings CONFIG_NVS_ENCRYPTION=n -# Shell on native USB (XIAO USB-C / S3 USB Serial/JTAG) so UART1 can use GPIO43/44 -CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y -CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y +# Keep a post-mortem crash image in the existing coredump partition. `diag` +# reports its flash address/size so a field unit can be diagnosed afterwards. +CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y +CONFIG_ESP_COREDUMP_MAX_TASKS_NUM=16 +CONFIG_ESP_COREDUMP_LOGS=n +CONFIG_ESP_COREDUMP_STACK_SIZE=1792 + +# The Modulus/DevKit profile exposes UART0 through its USB-UART bridge. This +# keeps diagnostics available on boards whose USB socket is not native USB. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y # Default board when NVS has no `board` key. Runtime: USB shell `board mini1|xiao` CONFIG_S3_BRIDGE_BOARD_MINI1=y diff --git a/firmware/s3-bridge/sdkconfig.defaults.generic b/firmware/s3-bridge/sdkconfig.defaults.generic new file mode 100644 index 0000000..fda4bfb --- /dev/null +++ b/firmware/s3-bridge/sdkconfig.defaults.generic @@ -0,0 +1,7 @@ +# Generic ESP32-S3 Super Mini overlay: +# .\scripts\build_s3_bridge.ps1 -Board generic -Action fullclean +# .\scripts\build_s3_bridge.ps1 -Board generic + +CONFIG_S3_BRIDGE_BOARD_MINI1=n +CONFIG_S3_BRIDGE_BOARD_XIAO=n +CONFIG_S3_BRIDGE_BOARD_GENERIC=y diff --git a/firmware/s3-bridge/sdkconfig.defaults.xiao b/firmware/s3-bridge/sdkconfig.defaults.xiao index 227941f..4c900df 100644 --- a/firmware/s3-bridge/sdkconfig.defaults.xiao +++ b/firmware/s3-bridge/sdkconfig.defaults.xiao @@ -7,5 +7,6 @@ CONFIG_S3_BRIDGE_BOARD_MINI1=n CONFIG_S3_BRIDGE_BOARD_XIAO=y # USB-C Serial/JTAG shell — GPIO43/44 stay free for UART1 → grblHAL +CONFIG_ESP_CONSOLE_UART_DEFAULT=n CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y diff --git a/firmware/tab5/CMakeLists.txt b/firmware/tab5/CMakeLists.txt index 9c69397..f2c9354 100644 --- a/firmware/tab5/CMakeLists.txt +++ b/firmware/tab5/CMakeLists.txt @@ -8,6 +8,7 @@ set(CMAKE_C_USE_RESPONSE_FILE_FOR_OBJECTS 1) set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_OBJECTS 1) include($ENV{IDF_PATH}/tools/cmake/project.cmake) +set(PROJECT_VER "3.1.5") project(modulus_tab5) # Zig UI engine owns the panel: no LVGL display, indev, port task or draw diff --git a/firmware/tab5/components/espressif__esp_wifi_remote/CMakeLists.txt b/firmware/tab5/components/espressif__esp_wifi_remote/CMakeLists.txt deleted file mode 100644 index c080c1f..0000000 --- a/firmware/tab5/components/espressif__esp_wifi_remote/CMakeLists.txt +++ /dev/null @@ -1,12 +0,0 @@ -# ESP-IDF 6.0.1+ ships esp_wifi/remote in-tree (weak API + injected headers). -# esp_hosted provides the strong RPC implementations. Managed registry -# esp_wifi_remote expects idf_v6.x weak sources that are not laid out the way -# that package expects — keep this no-op name placeholder instead. -# (BufferRoot/Modulus-Firmware#2 — reporter used 1.6.4; that fights in-tree remote here.) -if(EXISTS "$ENV{IDF_PATH}/components/esp_wifi/remote/Kconfig.wifi.in") - idf_component_register() - return() -endif() - -message(WARNING "esp_wifi/remote missing from IDF — wifi_remote override is empty; add espressif/esp_wifi_remote from the registry") -idf_component_register() diff --git a/firmware/tab5/components/espressif__esp_wifi_remote/idf_component.yml b/firmware/tab5/components/espressif__esp_wifi_remote/idf_component.yml deleted file mode 100644 index 04f4f2d..0000000 --- a/firmware/tab5/components/espressif__esp_wifi_remote/idf_component.yml +++ /dev/null @@ -1,2 +0,0 @@ -description: No-op stub when ESP-IDF ships in-tree esp_wifi/remote (IDF 6+) -version: "0.0.1" diff --git a/firmware/tab5/components/modulus_zig/CMakeLists.txt b/firmware/tab5/components/modulus_zig/CMakeLists.txt index 6e707ff..9109e65 100644 --- a/firmware/tab5/components/modulus_zig/CMakeLists.txt +++ b/firmware/tab5/components/modulus_zig/CMakeLists.txt @@ -14,6 +14,9 @@ set(MODULUS_CORE_SRCS "i2c_transport_shim.c" "canbus_shim.c" "c6_sdio_host.c" + "c6_ota_shim.c" + "s3_ota_shim.c" + "nano_ota_shim.c" "zb_uart_host.c" "zb_devdb.c" "zb_devdb_data.c" @@ -150,6 +153,7 @@ idf_component_register( REQUIRES nvs_flash esp_driver_uart esp_driver_gpio esp_driver_i2c esp_driver_tsens esp_driver_twai esp_driver_ppa esp_mm esp_timer mbedtls esp-tls bt + esp_app_format bootloader_support espressif__m5stack_tab5 espressif__bmi270 espressif__esp-dsp espressif__esp_lcd_touch ${MODULUS_LVGL_REQ} esp_lcd espressif__usb espressif__usb_host_msc esp_wifi esp_netif esp_event espressif__esp_hosted @@ -169,6 +173,7 @@ set(ESP_HOSTED_DIR "${MODULUS_REPO_ROOT}/firmware/tab5/managed_components/espres target_include_directories(${COMPONENT_LIB} PRIVATE "${ESP_HOSTED_DIR}/common" "${ESP_HOSTED_DIR}/common/transport" + "${ESP_HOSTED_DIR}/common/mempool/include" "${ESP_HOSTED_DIR}/host" "${ESP_HOSTED_DIR}/host/api/include" "${ESP_HOSTED_DIR}/host/drivers/transport" @@ -225,9 +230,16 @@ target_link_options(${COMPONENT_LIB} INTERFACE set(BMI270_SENSOR_ROOT "${MODULUS_REPO_ROOT}/firmware/tab5/managed_components/espressif__bmi270_sensor") set(BMI270_SENSOR_LIB "${BMI270_SENSOR_ROOT}/${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR}/${IDF_TARGET}/libbmi270_sensor.a") +if(IDF_TARGET STREQUAL "esp32p4") + set(BMI270_SENSOR_LIB "${BMI270_SENSOR_ROOT}/${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR}/${IDF_TARGET}/rev3/libbmi270_sensor.a") +endif() if(NOT EXISTS "${BMI270_SENSOR_LIB}") foreach(_bmi_ver 5.5 5.4 5.3) - set(_bmi_try "${BMI270_SENSOR_ROOT}/${_bmi_ver}/${IDF_TARGET}/libbmi270_sensor.a") + if(IDF_TARGET STREQUAL "esp32p4") + set(_bmi_try "${BMI270_SENSOR_ROOT}/${_bmi_ver}/${IDF_TARGET}/rev3/libbmi270_sensor.a") + else() + set(_bmi_try "${BMI270_SENSOR_ROOT}/${_bmi_ver}/${IDF_TARGET}/libbmi270_sensor.a") + endif() if(EXISTS "${_bmi_try}") set(BMI270_SENSOR_LIB "${_bmi_try}") break() @@ -240,6 +252,9 @@ if(EXISTS "${BMI270_SENSOR_LIB}") "${BMI270_SENSOR_ROOT}/include" "${BMI270_SENSOR_ROOT}/firmware") target_link_libraries(${COMPONENT_LIB} PRIVATE bmi270_sensor_api) + target_include_directories(${COMPONENT_LIB} PRIVATE + "${BMI270_SENSOR_ROOT}/include" + "${BMI270_SENSOR_ROOT}/firmware") else() message(WARNING "bmi270_sensor prebuilt missing — PMIC any-motion wake disabled") endif() diff --git a/firmware/tab5/components/modulus_zig/c6_ota_shim.c b/firmware/tab5/components/modulus_zig/c6_ota_shim.c new file mode 100644 index 0000000..991609f --- /dev/null +++ b/firmware/tab5/components/modulus_zig/c6_ota_shim.c @@ -0,0 +1,329 @@ +#include "c6_ota_shim.h" +#include "storage_shim.h" + +#include +#include +#include +#include +#include +#include + +#include "esp_app_desc.h" +#include "esp_app_format.h" +#include "esp_err.h" +#include "esp_hosted.h" +#include "esp_hosted_api_types.h" +#include "esp_hosted_ota.h" +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" +#include "freertos/task.h" + +#define OTA_USB_ROOT "/usb" +#define OTA_CHUNK_SIZE 4096 +/* 1.4.1 has a 4096-byte RPC buffer including protobuf/TLV overhead. */ +#define OTA_LEGACY_CHUNK_SIZE 1024 +#define OTA_MAX_IMAGE_SIZE (4U * 1024U * 1024U) +#define OTA_HOST_RESTART_DELAY_MS 3000 +/* Pre-2.6 slaves schedule their own reboot 5 s after OTA end. */ +#define OTA_LEGACY_HOST_RESTART_DELAY_MS 8000 + +static const char *TAG = "c6_ota"; +static modulus_c6_ota_snapshot_t s_state = { + .phase = MODULUS_C6_OTA_IDLE, + .status = "Open this page to scan a USB drive. Nothing is flashed automatically.", +}; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static bool s_worker_running; +static char s_armed_path[192]; +static size_t s_armed_size; +static char s_file_paths[MODULUS_C6_OTA_MAX_FILES][192]; +static char s_file_versions[MODULUS_C6_OTA_MAX_FILES][32]; + +static void state_status(modulus_c6_ota_phase_t phase, const char *text) +{ + taskENTER_CRITICAL(&s_lock); + s_state.phase = phase; + snprintf(s_state.status, sizeof(s_state.status), "%s", text); + taskEXIT_CRITICAL(&s_lock); +} + +static bool safe_bin_name(const char *name) +{ + if (!name || !name[0] || strlen(name) >= MODULUS_C6_OTA_NAME_LEN || + strstr(name, "..") || strchr(name, '/') || strchr(name, '\\')) return false; + const size_t n = strlen(name); + return n > 4 && strcasecmp(name + n - 4, ".bin") == 0; +} + +static bool image_path(char *out, size_t out_len, const char *root, const char *name) +{ + const size_t root_len = root ? strlen(root) : 0; + const size_t name_len = name ? strlen(name) : 0; + if (!name || root_len + 1U + name_len + 1U > out_len) return false; + memcpy(out, root, root_len); + out[root_len] = '/'; + memcpy(out + root_len + 1U, name, name_len + 1U); + return true; +} + +static esp_err_t inspect_image(const char *path, size_t *size_out, esp_app_desc_t *desc_out); + +static bool scan_root(modulus_c6_ota_snapshot_t *next, + char paths[MODULUS_C6_OTA_MAX_FILES][192], + char versions[MODULUS_C6_OTA_MAX_FILES][32], + const char *root, const char *label) +{ + DIR *dir = opendir(root); + if (!dir) return false; + struct dirent *entry; + while ((entry = readdir(dir)) != NULL && next->file_count < MODULUS_C6_OTA_MAX_FILES) { + if (!safe_bin_name(entry->d_name)) continue; + char path[192]; + size_t image_size = 0; + esp_app_desc_t image_desc = {0}; + if (!image_path(path, sizeof(path), root, entry->d_name)) continue; + if (inspect_image(path, &image_size, &image_desc) != ESP_OK) continue; + const uint8_t index = next->file_count; + snprintf(next->files[index], sizeof(next->files[index]), "%.3s: %.90s", label, entry->d_name); + snprintf(paths[index], sizeof(paths[index]), "%s", path); + snprintf(versions[index], sizeof(versions[index]), "%s", image_desc.version); + next->file_count++; + } + closedir(dir); + return true; +} + +static esp_err_t inspect_image(const char *path, size_t *size_out, esp_app_desc_t *desc_out) +{ + struct stat st = {0}; + if (stat(path, &st) != 0 || st.st_size <= 0 || (uint64_t)st.st_size > OTA_MAX_IMAGE_SIZE) { + return ESP_ERR_INVALID_SIZE; + } + FILE *f = fopen(path, "rb"); + if (!f) return ESP_ERR_NOT_FOUND; + esp_image_header_t hdr = {0}; + esp_image_segment_header_t seg = {0}; + esp_app_desc_t desc = {0}; + const bool read_ok = fread(&hdr, 1, sizeof(hdr), f) == sizeof(hdr) && + fread(&seg, 1, sizeof(seg), f) == sizeof(seg) && + fread(&desc, 1, sizeof(desc), f) == sizeof(desc); + fclose(f); + if (!read_ok || hdr.magic != ESP_IMAGE_HEADER_MAGIC || + hdr.chip_id != ESP_CHIP_ID_ESP32C6 || desc.magic_word != ESP_APP_DESC_MAGIC_WORD) { + return ESP_ERR_INVALID_ARG; + } + *size_out = (size_t)st.st_size; + *desc_out = desc; + return ESP_OK; +} + +void modulus_c6_ota_get_snapshot(modulus_c6_ota_snapshot_t *out) +{ + if (!out) return; + taskENTER_CRITICAL(&s_lock); + *out = s_state; + taskEXIT_CRITICAL(&s_lock); +} + +void modulus_c6_ota_refresh(void) +{ + if (s_worker_running) return; + modulus_c6_ota_snapshot_t next = { .phase = MODULUS_C6_OTA_READY }; + const bool usb_mounted = modulus_storage_usb_volume_mounted(); + esp_hosted_coprocessor_fwver_t ver = {0}; + if (esp_hosted_get_coprocessor_fwversion(&ver) == ESP_OK) { + next.c6_connected = true; + snprintf(next.c6_version, sizeof(next.c6_version), "%lu.%lu.%lu", + (unsigned long)ver.major1, (unsigned long)ver.minor1, (unsigned long)ver.patch1); + } + char paths[MODULUS_C6_OTA_MAX_FILES][192] = {{0}}; + char versions[MODULUS_C6_OTA_MAX_FILES][32] = {{0}}; + const bool usb_open = usb_mounted && scan_root(&next, paths, versions, OTA_USB_ROOT, "USB"); + if (next.file_count > 0) snprintf(next.image_version, sizeof(next.image_version), "%s", versions[0]); + if (!next.c6_connected) { + next.phase = MODULUS_C6_OTA_ERROR; + snprintf(next.status, sizeof(next.status), "C6 is not responding over ESP-Hosted/SDIO."); + } else if (!usb_open) { + next.phase = MODULUS_C6_OTA_ERROR; + snprintf(next.status, sizeof(next.status), "Insert a FAT USB drive, wait for it to mount, then refresh."); + } else if (next.file_count == 0) { + snprintf(next.status, sizeof(next.status), "No valid ESP32-C6 app images found in the USB drive root."); + } else { + snprintf(next.status, sizeof(next.status), "%u C6 firmware image(s) found. Select one and check it.", next.file_count); + } + taskENTER_CRITICAL(&s_lock); + s_state = next; + memcpy(s_file_paths, paths, sizeof(s_file_paths)); + memcpy(s_file_versions, versions, sizeof(s_file_versions)); + s_armed_path[0] = 0; + s_armed_size = 0; + taskEXIT_CRITICAL(&s_lock); +} + +void modulus_c6_ota_select(uint8_t index) +{ + taskENTER_CRITICAL(&s_lock); + if (!s_worker_running && index < s_state.file_count) { + s_state.selected = index; + s_state.phase = MODULUS_C6_OTA_READY; + snprintf(s_state.image_version, sizeof(s_state.image_version), "%s", s_file_versions[index]); + snprintf(s_state.status, sizeof(s_state.status), "Selected %s. Check the image before flashing.", s_state.files[index]); + s_armed_path[0] = 0; + s_armed_size = 0; + } + taskEXIT_CRITICAL(&s_lock); +} + +void modulus_c6_ota_arm_selected(void) +{ + modulus_c6_ota_snapshot_t snap; + modulus_c6_ota_get_snapshot(&snap); + if (s_worker_running || !snap.c6_connected || snap.selected >= snap.file_count) return; + char path[sizeof(s_armed_path)]; + taskENTER_CRITICAL(&s_lock); + snprintf(path, sizeof(path), "%s", s_file_paths[snap.selected]); + taskEXIT_CRITICAL(&s_lock); + size_t size = 0; + esp_app_desc_t desc = {0}; + const esp_err_t err = inspect_image(path, &size, &desc); + if (err != ESP_OK) { + state_status(MODULUS_C6_OTA_ERROR, "Rejected: not a valid ESP32-C6 application image."); + return; + } + taskENTER_CRITICAL(&s_lock); + snprintf(s_armed_path, sizeof(s_armed_path), "%s", path); + s_armed_size = size; + s_state.phase = MODULUS_C6_OTA_ARMED; + s_state.progress = 0; + snprintf(s_state.status, sizeof(s_state.status), "Checked: %.48s, version %.24s, %u bytes. Flash is now armed.", + snap.files[snap.selected], desc.version, (unsigned)size); + taskEXIT_CRITICAL(&s_lock); + ESP_LOGW(TAG, "C6 OTA armed only: %s (%u bytes)", path, (unsigned)size); +} + +static void ota_worker(void *arg) +{ + (void)arg; + char path[sizeof(s_armed_path)]; + size_t image_size; + taskENTER_CRITICAL(&s_lock); + snprintf(path, sizeof(path), "%s", s_armed_path); + image_size = s_armed_size; + s_state.phase = MODULUS_C6_OTA_FLASHING; + s_state.progress = 0; + snprintf(s_state.status, sizeof(s_state.status), "Flashing C6. Do not remove power or the source drive."); + taskEXIT_CRITICAL(&s_lock); + + /* Query the running slave, not the image version or a cached UI snapshot. + * Before 2.6.0 OTAEnd selects the boot partition and schedules reboot; + * newer slaves require OTAActivate. Never probe activation after OTAEnd. + */ + esp_hosted_coprocessor_fwver_t ver = {0}; + esp_err_t err = esp_hosted_get_coprocessor_fwversion(&ver); + if (err != ESP_OK || ver.major1 == 0) { + taskENTER_CRITICAL(&s_lock); + s_state.c6_connected = false; + s_state.c6_version[0] = 0; + taskEXIT_CRITICAL(&s_lock); + state_status(MODULUS_C6_OTA_ERROR, + "Cannot read running C6 firmware version. Nothing written. Refresh and retry; use C6 USB recovery if unreachable."); + s_worker_running = false; + vTaskDelete(NULL); + return; + } + const bool explicit_activate = ver.major1 > 2 || (ver.major1 == 2 && ver.minor1 >= 6); + const size_t chunk_size = explicit_activate ? OTA_CHUNK_SIZE : OTA_LEGACY_CHUNK_SIZE; + const unsigned restart_delay_ms = explicit_activate ? OTA_HOST_RESTART_DELAY_MS : OTA_LEGACY_HOST_RESTART_DELAY_MS; + taskENTER_CRITICAL(&s_lock); + s_state.c6_connected = true; + snprintf(s_state.c6_version, sizeof(s_state.c6_version), "%lu.%lu.%lu", + (unsigned long)ver.major1, (unsigned long)ver.minor1, (unsigned long)ver.patch1); + snprintf(s_state.status, sizeof(s_state.status), "Flashing C6 (%s). Keep power and USB connected.", + explicit_activate ? "explicit activation" : "legacy auto-restart"); + taskEXIT_CRITICAL(&s_lock); + ESP_LOGI(TAG, "Running C6 %lu.%lu.%lu: %s OTA completion", + (unsigned long)ver.major1, (unsigned long)ver.minor1, (unsigned long)ver.patch1, + explicit_activate ? "explicit activation" : "legacy auto-restart"); + + const char *stage = "open image"; + bool activation_uncertain = false; + FILE *f = fopen(path, "rb"); + uint8_t *buf = NULL; + err = f ? ESP_OK : ESP_ERR_NOT_FOUND; + if (err == ESP_OK) { + stage = "allocate buffer"; + buf = malloc(chunk_size); + if (!buf) err = ESP_ERR_NO_MEM; + } + if (err == ESP_OK) { + stage = "begin"; + err = esp_hosted_slave_ota_begin(); + } + size_t sent = 0; + while (err == ESP_OK && sent < image_size) { + size_t want = image_size - sent; + if (want > chunk_size) want = chunk_size; + stage = "read image"; + if (fread(buf, 1, want, f) != want) { err = ESP_FAIL; break; } + stage = "transfer"; + err = esp_hosted_slave_ota_write(buf, (uint32_t)want); + if (err != ESP_OK) break; + sent += want; + taskENTER_CRITICAL(&s_lock); + s_state.progress = (uint8_t)((sent * 100U) / image_size); + taskEXIT_CRITICAL(&s_lock); + } + /* OTAEnd can activate on legacy firmware, so never use it as cleanup + * after an incomplete transfer. A failed transfer must be retried/recovered. + */ + if (err == ESP_OK && sent == image_size) { + stage = "finalize"; + activation_uncertain = !explicit_activate; + err = esp_hosted_slave_ota_end(); + } + if (err == ESP_OK && sent == image_size && explicit_activate) { + stage = "activate"; + activation_uncertain = true; + err = esp_hosted_slave_ota_activate(); + } + if (f) fclose(f); + free(buf); + + if (err == ESP_OK && sent == image_size) { + taskENTER_CRITICAL(&s_lock); + s_state.phase = MODULUS_C6_OTA_SUCCESS; + s_state.progress = 100; + s_state.c6_connected = false; + snprintf(s_state.status, sizeof(s_state.status), "C6 update accepted. Waiting for C6 restart; Modulus restarts in %u seconds.", + restart_delay_ms / 1000U); + taskEXIT_CRITICAL(&s_lock); + ESP_LOGW(TAG, "C6 OTA completion accepted; restarting P4 in %u ms to resync ESP-Hosted/SDIO", + restart_delay_ms); + vTaskDelay(pdMS_TO_TICKS(restart_delay_ms)); + esp_restart(); + } else { + char msg[MODULUS_C6_OTA_STATUS_LEN]; + snprintf(msg, sizeof(msg), "C6 OTA %s failed at %u/%u: %s. %s", + stage, (unsigned)sent, (unsigned)image_size, esp_err_to_name(err), + activation_uncertain ? "Activation unconfirmed; wait, then check C6 version." + : "Activation not requested. Retry or use C6 USB recovery."); + ESP_LOGE(TAG, "%s", msg); + state_status(MODULUS_C6_OTA_ERROR, msg); + } + s_worker_running = false; + vTaskDelete(NULL); +} + +void modulus_c6_ota_start(void) +{ + modulus_c6_ota_snapshot_t snap; + modulus_c6_ota_get_snapshot(&snap); + if (s_worker_running || snap.phase != MODULUS_C6_OTA_ARMED || !s_armed_path[0]) return; + s_worker_running = true; + if (xTaskCreate(ota_worker, "c6_ota", 8192, NULL, 4, NULL) != pdPASS) { + s_worker_running = false; + state_status(MODULUS_C6_OTA_ERROR, "Could not start the C6 update worker."); + } +} diff --git a/firmware/tab5/components/modulus_zig/espnow_transport_shim.c b/firmware/tab5/components/modulus_zig/espnow_transport_shim.c index b84925e..d3fc6b4 100644 --- a/firmware/tab5/components/modulus_zig/espnow_transport_shim.c +++ b/firmware/tab5/components/modulus_zig/espnow_transport_shim.c @@ -6,6 +6,7 @@ #include "cnc_cmd_exports.h" #include "espnow_debug.h" #include "espnow_stack.h" +#include "s3_ota_protocol.h" #include "tab5_pi4ioe.h" #include "transport_shim.h" #include "wireless_shim.h" @@ -31,6 +32,8 @@ static uint8_t s_channel = 1; static bool s_encrypt; static modulus_espnow_stack_evt_fn s_evt_hook; static void *s_evt_ctx; +static modulus_espnow_stack_evt_fn s_aux_evt_hook; +static void *s_aux_evt_ctx; static SemaphoreHandle_t s_send_sem; static SemaphoreHandle_t s_send_lock; /* serializes senders (Core 1 CNC vs Core 0 HALT/probe) */ static bool s_send_ok; @@ -336,6 +339,9 @@ static void espnow_rx(const uint8_t *payload, uint16_t len, void *ctx) if (s_evt_hook) { s_evt_hook(evt, body, body_len, s_evt_ctx); } + if (s_aux_evt_hook) { + s_aux_evt_hook(evt, body, body_len, s_aux_evt_ctx); + } if (modulus_espnow_log_level() >= MODULUS_ESPNOW_LOG_VERBOSE) { ESP_LOGD(TAG, "rx evt 0x%02x body=%u", (unsigned)evt, (unsigned)body_len); @@ -398,6 +404,15 @@ static void espnow_rx(const uint8_t *payload, uint16_t len, void *ctx) } break; case ESPNOW_EVT_RECV: + /* S3 liveness is consumed locally and must never reach grblHAL. */ + if (body_len == 6 + 7 && memcmp(body + 6, "MOD_HB1", 7) == 0) { + break; + } + if (body_len >= 6 + sizeof(uint32_t)) { + uint32_t magic = 0; + memcpy(&magic, body + 6, sizeof(magic)); + if (magic == MOD_S3_OTA_MAGIC) break; + } if (len > 7 && s_open && body_len >= 6 && memcmp(body, s_peer, 6) == 0) { modulus_wireless_espnow_rx_inc(); @@ -428,6 +443,28 @@ void modulus_espnow_stack_set_evt_hook(modulus_espnow_stack_evt_fn fn, void *ctx s_evt_ctx = ctx; } +void modulus_espnow_stack_set_aux_evt_hook(modulus_espnow_stack_evt_fn fn, void *ctx) +{ + s_aux_evt_hook = fn; + s_aux_evt_ctx = ctx; +} + +bool modulus_espnow_stack_send_configured_peer(const uint8_t *data, size_t len) +{ + if (!data || len == 0 || len > ESPNOW_MAX_PAYLOAD || !modulus_wireless_ready()) { + return false; + } + char mac_str[20]; + uint8_t peer[6]; + modulus_wireless_espnow_peer_mac_str(mac_str, sizeof(mac_str)); + if (!parse_mac(mac_str, peer)) return false; + if (!modulus_espnow_stack_inited() && + !modulus_espnow_stack_ensure_inited(MODULUS_ESPNOW_INIT_WAIT_MS)) return false; + const uint8_t channel = modulus_wireless_espnow_channel(); + if (!modulus_espnow_stack_add_peer(peer, channel, false)) return false; + return modulus_espnow_stack_send(peer, data, len); +} + bool modulus_espnow_stack_init(void) { modulus_espnow_stack_register(); @@ -636,6 +673,12 @@ bool modulus_espnow_stack_probe(uint8_t channel) channel = 1; } + /* Reassert before every probe. The hosted C6 may still be finishing an + * old STA operation, which can move the PHY after the broadcast peer was + * added and would otherwise make esp_now_send fail with CHAN (0x306d). */ + if (!modulus_espnow_stack_lock_channel(channel)) { + return false; + } if (!espnow_peer_cache_hit(k_bcast, channel, false)) { (void)modulus_espnow_stack_del_peer(k_bcast); if (!modulus_espnow_stack_add_peer(k_bcast, channel, false)) { @@ -717,11 +760,12 @@ bool modulus_espnow_transport_start(const char *mac_str, uint8_t channel, bool e return false; } } - modulus_wireless_espnow_apply_bridge_peer(); if (channel > 0) { modulus_wireless_espnow_set_channel(channel); (void)modulus_espnow_stack_lock_channel(channel); } + /* Apply/verify only after the C6 radio is on the requested channel. */ + modulus_wireless_espnow_apply_bridge_peer(); /* S3 UART bridge uses plain ESP-NOW — PMK encryption breaks CNC transport. */ modulus_nvs_set_u8("en_enc", 0); diff --git a/firmware/tab5/components/modulus_zig/include/c6_ota_shim.h b/firmware/tab5/components/modulus_zig/include/c6_ota_shim.h new file mode 100644 index 0000000..faeed52 --- /dev/null +++ b/firmware/tab5/components/modulus_zig/include/c6_ota_shim.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MODULUS_C6_OTA_MAX_FILES 8 +#define MODULUS_C6_OTA_NAME_LEN 96 +#define MODULUS_C6_OTA_STATUS_LEN 160 + +typedef enum { + MODULUS_C6_OTA_IDLE = 0, + MODULUS_C6_OTA_READY, + MODULUS_C6_OTA_ARMED, + MODULUS_C6_OTA_FLASHING, + MODULUS_C6_OTA_SUCCESS, + MODULUS_C6_OTA_ERROR, +} modulus_c6_ota_phase_t; + +typedef struct { + modulus_c6_ota_phase_t phase; + uint8_t file_count; + uint8_t selected; + uint8_t progress; + bool c6_connected; + char c6_version[24]; + char image_version[32]; + char files[MODULUS_C6_OTA_MAX_FILES][MODULUS_C6_OTA_NAME_LEN]; + char status[MODULUS_C6_OTA_STATUS_LEN]; +} modulus_c6_ota_snapshot_t; + +void modulus_c6_ota_refresh(void); +void modulus_c6_ota_select(uint8_t index); +void modulus_c6_ota_arm_selected(void); +void modulus_c6_ota_start(void); +void modulus_c6_ota_get_snapshot(modulus_c6_ota_snapshot_t *out); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/tab5/components/modulus_zig/include/espnow_stack.h b/firmware/tab5/components/modulus_zig/include/espnow_stack.h index b98b803..3d0325b 100644 --- a/firmware/tab5/components/modulus_zig/include/espnow_stack.h +++ b/firmware/tab5/components/modulus_zig/include/espnow_stack.h @@ -48,6 +48,11 @@ typedef void (*modulus_espnow_stack_evt_fn)(uint8_t evt, const uint8_t *payload, void *ctx); void modulus_espnow_stack_set_evt_hook(modulus_espnow_stack_evt_fn fn, void *ctx); +/** Independent control-plane listener used by S3 OTA. */ +void modulus_espnow_stack_set_aux_evt_hook(modulus_espnow_stack_evt_fn fn, void *ctx); + +/** Send to the S3 bridge MAC configured in Wireless settings. */ +bool modulus_espnow_stack_send_configured_peer(const uint8_t *data, size_t len); /** Re-apply bridge peer after NVS/channel change while transport open. */ void modulus_espnow_transport_reapply_peer(void); diff --git a/firmware/tab5/components/modulus_zig/include/modulus_shims_bundle.h b/firmware/tab5/components/modulus_zig/include/modulus_shims_bundle.h index 707aa76..010d972 100644 --- a/firmware/tab5/components/modulus_zig/include/modulus_shims_bundle.h +++ b/firmware/tab5/components/modulus_zig/include/modulus_shims_bundle.h @@ -23,5 +23,8 @@ #include "transport_shim.h" #include "ui_shim.h" #include "wireless_shim.h" +#include "wireless_rpc.h" #include "c6_sdio_host.h" -#include "wireless_shim.h" +#include "c6_ota_shim.h" +#include "s3_ota_shim.h" +#include "nano_ota_shim.h" diff --git a/firmware/tab5/components/modulus_zig/include/nano_ota_shim.h b/firmware/tab5/components/modulus_zig/include/nano_ota_shim.h new file mode 100644 index 0000000..1fd9398 --- /dev/null +++ b/firmware/tab5/components/modulus_zig/include/nano_ota_shim.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include + +#define MODULUS_NANO_OTA_MAX_FILES 8 +#define MODULUS_NANO_OTA_NAME_LEN 96 +#define MODULUS_NANO_OTA_STATUS_LEN 160 + +typedef enum { MODULUS_NANO_OTA_IDLE, MODULUS_NANO_OTA_READY, + MODULUS_NANO_OTA_ARMED, MODULUS_NANO_OTA_FLASHING, + MODULUS_NANO_OTA_SUCCESS, MODULUS_NANO_OTA_ERROR } modulus_nano_ota_phase_t; + +typedef struct { + modulus_nano_ota_phase_t phase; + uint8_t file_count, selected, progress; + bool nano_connected; + char nano_version[24], image_version[32]; + char files[MODULUS_NANO_OTA_MAX_FILES][MODULUS_NANO_OTA_NAME_LEN]; + char status[MODULUS_NANO_OTA_STATUS_LEN]; +} modulus_nano_ota_snapshot_t; + +void modulus_nano_ota_refresh(void); +void modulus_nano_ota_select(uint8_t index); +void modulus_nano_ota_arm_selected(void); +void modulus_nano_ota_start(void); +void modulus_nano_ota_restart(void); +void modulus_nano_ota_get_snapshot(modulus_nano_ota_snapshot_t *out); diff --git a/firmware/tab5/components/modulus_zig/include/s3_ota_protocol.h b/firmware/tab5/components/modulus_zig/include/s3_ota_protocol.h new file mode 100644 index 0000000..e81c85d --- /dev/null +++ b/firmware/tab5/components/modulus_zig/include/s3_ota_protocol.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include + +#define MOD_S3_OTA_MAGIC 0x53334f54U +#define MOD_S3_OTA_VERSION 1U +#define MOD_S3_OTA_DATA_MAX 1024U + +typedef enum { + MOD_S3_OTA_PROBE = 1, + MOD_S3_OTA_BEGIN = 2, + MOD_S3_OTA_DATA = 3, + MOD_S3_OTA_END = 4, + MOD_S3_OTA_ABORT = 5, + MOD_S3_OTA_REBOOT = 6, + MOD_S3_CTRL_GET_UART = 7, + MOD_S3_CTRL_SET_UART = 8, + MOD_S3_CTRL_TEST_UART = 9, + MOD_S3_OTA_REPLY = 0x80, +} mod_s3_ota_type_t; + +typedef enum { + MOD_S3_OTA_OK = 0, + MOD_S3_OTA_BAD_PACKET, + MOD_S3_OTA_BAD_STATE, + MOD_S3_OTA_BAD_IMAGE, + MOD_S3_OTA_FLASH_ERROR, + MOD_S3_OTA_BUSY, + MOD_S3_OTA_BAD_CONFIG, + MOD_S3_OTA_NVS_ERROR, +} mod_s3_ota_status_t; + +#pragma pack(push, 1) +typedef struct { + uint32_t magic; + uint8_t version; + uint8_t type; + uint16_t payload_len; + uint32_t session; + uint32_t sequence; + uint32_t value; + uint32_t payload_crc; + uint8_t payload[MOD_S3_OTA_DATA_MAX]; +} mod_s3_ota_packet_t; + +typedef struct { + uint8_t command; + uint8_t status; + uint16_t reserved; + char app_version[32]; + uint8_t capabilities; + int8_t uart_tx_gpio; + int8_t uart_rx_gpio; + uint8_t reserved2; + uint32_t uart_baud; + uint8_t uart_test_result; +} mod_s3_ota_reply_t; + +typedef struct { + int8_t tx_gpio; + int8_t rx_gpio; + uint16_t reserved; + uint32_t baud; +} mod_s3_uart_config_t; +#pragma pack(pop) + +#define MOD_S3_CAP_UART_CONFIG 0x01U +#define MOD_S3_CAP_UART_TEST 0x02U + +typedef enum { + MOD_S3_UART_TEST_NOT_RUN = 0, + MOD_S3_UART_TEST_GRBL_OK, + MOD_S3_UART_TEST_DATA_UNRECOGNIZED, + MOD_S3_UART_TEST_NO_RESPONSE, + MOD_S3_UART_TEST_TX_ERROR, + MOD_S3_UART_TEST_BUSY, +} mod_s3_uart_test_result_t; + +#define MOD_S3_OTA_HEADER_SIZE ((uint16_t)offsetof(mod_s3_ota_packet_t, payload)) + diff --git a/firmware/tab5/components/modulus_zig/include/s3_ota_shim.h b/firmware/tab5/components/modulus_zig/include/s3_ota_shim.h new file mode 100644 index 0000000..a944683 --- /dev/null +++ b/firmware/tab5/components/modulus_zig/include/s3_ota_shim.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MODULUS_S3_OTA_MAX_FILES 8 +#define MODULUS_S3_OTA_NAME_LEN 96 +#define MODULUS_S3_OTA_STATUS_LEN 160 + +typedef enum { + MODULUS_S3_OTA_IDLE = 0, + MODULUS_S3_OTA_READY, + MODULUS_S3_OTA_ARMED, + MODULUS_S3_OTA_FLASHING, + MODULUS_S3_OTA_SUCCESS, + MODULUS_S3_OTA_ERROR, +} modulus_s3_ota_phase_t; + +typedef struct { + modulus_s3_ota_phase_t phase; + uint8_t file_count; + uint8_t selected; + uint8_t progress; + bool s3_connected; + bool uart_config_supported; + bool uart_test_supported; + bool uart_config_busy; + int8_t uart_tx_gpio; + int8_t uart_rx_gpio; + uint32_t uart_baud; + char s3_version[32]; + char image_version[32]; + char files[MODULUS_S3_OTA_MAX_FILES][MODULUS_S3_OTA_NAME_LEN]; + char status[MODULUS_S3_OTA_STATUS_LEN]; +} modulus_s3_ota_snapshot_t; + +void modulus_s3_ota_refresh(void); +void modulus_s3_ota_select(uint8_t index); +void modulus_s3_ota_arm_selected(void); +void modulus_s3_ota_start(void); +void modulus_s3_ota_restart(void); +void modulus_s3_ota_get_snapshot(modulus_s3_ota_snapshot_t *out); +void modulus_s3_uart_config_refresh(void); +void modulus_s3_uart_config_apply(int8_t tx_gpio, int8_t rx_gpio, uint32_t baud); +void modulus_s3_uart_test(void); + +#ifdef __cplusplus +} +#endif + diff --git a/firmware/tab5/components/modulus_zig/include/tab5_hw.h b/firmware/tab5/components/modulus_zig/include/tab5_hw.h index 2ce4cb0..d0b298c 100644 --- a/firmware/tab5/components/modulus_zig/include/tab5_hw.h +++ b/firmware/tab5/components/modulus_zig/include/tab5_hw.h @@ -50,12 +50,12 @@ #define TAB5_RS485_RX_GPIO 21 #define TAB5_RS485_DE_GPIO 34 -/* NanoH2 Zigbee hub link — UART2 on M5BUS pins 16/15 (silkscreen TXD2/RXD2). +/* NanoH2 Zigbee hub link — UART2 on Tab5 M5BUS pins 16/15 (PC_TX/PC_RX). * GPIO6 is shared with the COM.X STAMP pad net (xMOD_RX_GPIO6) — fine while * no STAMP module is fitted. UART0 = console, UART1 = RS-485. */ #define TAB5_ZB_UART 2 -#define TAB5_ZB_UART_TX_GPIO 6 /* M5BUS pin 16 G14/TXD2 -> H2 Grove G2 (RX) */ -#define TAB5_ZB_UART_RX_GPIO 7 /* M5BUS pin 15 G13/RXD2 <- H2 Grove G1 (TX) */ +#define TAB5_ZB_UART_TX_GPIO 6 /* M5BUS pin 16 G6/PC_TX -> H2 Grove G2 (RX) */ +#define TAB5_ZB_UART_RX_GPIO 7 /* M5BUS pin 15 G7/PC_RX <- H2 Grove G1 (TX) */ /* Port A alternate — TWAI/CAN transport shares G53/G54 with I2C1 (mutually exclusive) */ #define TAB5_PORT_A_CAN_TX_GPIO TAB5_EXT_I2C_SCL_GPIO diff --git a/firmware/tab5/components/modulus_zig/include/ui_status_bar_priv.h b/firmware/tab5/components/modulus_zig/include/ui_status_bar_priv.h index 7651e35..606213f 100644 --- a/firmware/tab5/components/modulus_zig/include/ui_status_bar_priv.h +++ b/firmware/tab5/components/modulus_zig/include/ui_status_bar_priv.h @@ -60,7 +60,7 @@ lv_color_t bar_wireless_wifi_color(uint8_t st); /** BLE: 0=hidden, 1=idle, 2=connecting, 3=connected. */ uint8_t bar_wireless_ble_state(void); lv_color_t bar_wireless_ble_color(uint8_t st); -/** ESP-NOW: 0=hidden, 1=enabled, 2=transport active. */ +/** ESP-NOW: 0=hidden, 1=enabled, 2=live bridge, 3=open but bridge stale. */ uint8_t bar_wireless_espnow_state(void); lv_color_t bar_wireless_espnow_color(uint8_t st); void bar_update_wireless(status_bar_t *bar, uint8_t *wifi_st, uint8_t *ble_st, uint8_t *en_st, diff --git a/firmware/tab5/components/modulus_zig/include/wireless_rpc.h b/firmware/tab5/components/modulus_zig/include/wireless_rpc.h index 5e05aca..60f4a9d 100644 --- a/firmware/tab5/components/modulus_zig/include/wireless_rpc.h +++ b/firmware/tab5/components/modulus_zig/include/wireless_rpc.h @@ -18,6 +18,8 @@ void modulus_wireless_rpc_poll_state(bool zigbee_on, bool thread_on); bool modulus_wireless_zb_join(void); bool modulus_wireless_zb_leave(void); bool modulus_wireless_zb_permit_join(uint8_t seconds); +bool modulus_wireless_zb_get_state(void); +bool modulus_wireless_zb_get_devices(void); bool modulus_wireless_zb_set_level(const modulus_zb_device_t *dev, uint8_t level); bool modulus_wireless_zb_cover(const modulus_zb_device_t *dev, uint8_t op); bool modulus_wireless_zb_thermo_sp(const modulus_zb_device_t *dev, uint16_t sp_c_x10); @@ -30,6 +32,43 @@ bool modulus_wireless_zb_group_set(const modulus_zb_device_t *dev, bool modulus_wireless_zb_group_onoff(uint16_t group_id, uint8_t op); bool modulus_wireless_zb_dev_leave(const char *ieee_hex); bool modulus_wireless_zb_energy_scan(void); +typedef struct { + uint16_t short_addr; + uint8_t protocol_version; + uint8_t channel_count; + uint8_t max_channels; + int8_t status_led_gpio; + uint8_t status_led_flags; + char name[32]; + uint32_t generation; + uint8_t last_command; + uint8_t last_status; +} modulus_zb_node_info_t; +typedef struct { + bool apply_pending; + uint16_t poll_interval_s; + int16_t temperature_centi_c; + uint8_t temperature_state; /* 0 waiting, 1 valid, 2 sensor error, 3 stale, 4 apply pending */ + bool digital_value; + uint8_t digital_state; /* 0 waiting, 1 valid, 2 stale, 3 apply pending */ + uint8_t index; + uint8_t type; + int8_t gpio; + uint8_t flags; + char name[24]; + bool valid; +} modulus_zb_node_channel_t; +bool modulus_wireless_zb_node_request(uint16_t short_addr); +bool modulus_wireless_zb_node_get_info(modulus_zb_node_info_t *out); +bool modulus_wireless_zb_node_get_channel(uint8_t index, modulus_zb_node_channel_t *out); +bool modulus_wireless_zb_node_set_name(uint16_t short_addr, const char *name); +bool modulus_wireless_zb_node_set_channel(uint16_t short_addr, uint8_t index, + uint8_t type, int8_t gpio, uint8_t flags, + const char *name); +bool modulus_wireless_zb_node_set_poll(uint16_t short_addr, uint8_t index, uint16_t seconds); +bool modulus_wireless_zb_node_set_output(uint16_t short_addr, uint8_t index, bool on); +bool modulus_wireless_zb_node_apply(uint16_t short_addr); +bool modulus_wireless_zb_node_set_status_led(uint16_t short_addr, int8_t gpio, uint8_t flags); /* Color Control (0x0300): mode 0 = CCT (a=mireds, b=trans ds), mode 1 = hue/sat. */ bool modulus_wireless_zb_color(const modulus_zb_device_t *dev, uint8_t mode, uint16_t a, uint16_t b); diff --git a/firmware/tab5/components/modulus_zig/include/wireless_shim.h b/firmware/tab5/components/modulus_zig/include/wireless_shim.h index 4d6cde8..64136d4 100644 --- a/firmware/tab5/components/modulus_zig/include/wireless_shim.h +++ b/firmware/tab5/components/modulus_zig/include/wireless_shim.h @@ -48,8 +48,8 @@ void modulus_wireless_poll(void); bool modulus_wireless_radio_op_try_take(uint32_t timeout_ms); void modulus_wireless_radio_op_give(void); -/** Warn when the joined AP's channel differs from the ESP-NOW bridge channel. - * Never rewrites en_chan — the S3 bridge cannot follow a channel change. */ +/** Follow the joined AP's channel and persist it for ESP-NOW. The S3 bridge + * hunts channels after link loss and re-latches on the Tab5 there. */ void modulus_wireless_espnow_check_channel_conflict(void); /* ── Wi-Fi ─────────────────────────────────────────────────────── */ @@ -106,6 +106,8 @@ void modulus_wireless_espnow_format_mac(const uint8_t mac[6], char *buf, size_t uint8_t modulus_wireless_espnow_channel(void); /** Persist bridge channel (1–13) to RAM + NVS — safe from SDIO RX context. */ void modulus_wireless_espnow_set_channel(uint8_t channel); +/** Tell the paired S3 to move first, then persist and lock the C6 channel. */ +bool modulus_wireless_espnow_move_bridge_channel(uint8_t channel); /** Reload channel cache after external NVS writes (settings dropdown). */ void modulus_wireless_espnow_channel_reload(void); diff --git a/firmware/tab5/components/modulus_zig/include/zb_link_proto.h b/firmware/tab5/components/modulus_zig/include/zb_link_proto.h index 79f074a..2701c7f 100644 --- a/firmware/tab5/components/modulus_zig/include/zb_link_proto.h +++ b/firmware/tab5/components/modulus_zig/include/zb_link_proto.h @@ -28,6 +28,12 @@ #define ZIGBEE_CMD_GROUP_ONOFF 0x1F /* [group:2BE][op: 0=off 1=on 2=toggle] */ #define ZIGBEE_CMD_COLOR 0x20 /* [short:2BE][ep][mode][a:2BE][b:2BE] * mode 0: a=mireds, b=trans ds; mode 1: a=hue, b=sat */ +#define ZIGBEE_CMD_NODE_CONFIG 0x21 /* [short:2BE][node_cmd][payload...] */ +#define ZIGBEE_CMD_HUB_RESET 0x22 /* erase coordinator network and reboot */ +#define ZIGBEE_CMD_OTA_BEGIN 0x30 /* [image_size:4BE] */ +#define ZIGBEE_CMD_OTA_DATA 0x31 /* [offset:4BE][crc32:4BE][data...] */ +#define ZIGBEE_CMD_OTA_END 0x32 /* validate and select the new slot */ +#define ZIGBEE_CMD_OTA_REBOOT 0x33 /* accepted only after successful END */ /* Events to host (P4) */ #define ZIGBEE_EVT_OK 0x81 @@ -48,6 +54,7 @@ #define ZIGBEE_EVT_ACK 0x94 /* [seq:1] */ #define ZIGBEE_EVT_NAK 0x95 /* [seq:1][reason:1] */ #define ZIGBEE_EVT_DEV_INFO 0x96 /* [short:2BE][mfr\0model\0] — zigbee2mqtt DB key */ +#define ZIGBEE_EVT_NODE_CONFIG 0x97 /* [short:2BE][node_rsp][payload...] */ /* * Host→hub wire format (sequenced): @@ -66,3 +73,6 @@ #define ZIGBEE_CAP_METER 0x40 #define ZIGBEE_CAP_COLOR 0x80 + +#define ZIGBEE_EVT_TEMPERATURE 0x98 /* [short:2BE][endpoint][centiC:s16BE], 0x8000 invalid */ +#define ZIGBEE_EVT_DIGITAL_INPUT 0x99 /* [short:2BE][endpoint][logical state] */ diff --git a/firmware/tab5/components/modulus_zig/include/zb_uart_host.h b/firmware/tab5/components/modulus_zig/include/zb_uart_host.h index f281ce9..36302c1 100644 --- a/firmware/tab5/components/modulus_zig/include/zb_uart_host.h +++ b/firmware/tab5/components/modulus_zig/include/zb_uart_host.h @@ -8,8 +8,8 @@ * ESP_ZIGBEE_IF channel; the byte protocol (zb_link_proto.h) is unchanged. * * Wiring (Tab5 M5BUS <-> NanoH2 Grove): - * M5BUS pin 16 G14/TXD2 (P4 GPIO6) -> Grove G2 yellow (H2 RX) - * M5BUS pin 15 G13/RXD2 (P4 GPIO7) <- Grove G1 white (H2 TX) + * M5BUS pin 16 G6/PC_TX (P4 GPIO6) -> Grove G2 yellow (H2 RX) + * M5BUS pin 15 G7/PC_RX (P4 GPIO7) <- Grove G1 white (H2 TX) * M5BUS GND (1/3/5) -> Grove GND; M5BUS pin 28 SYS_EXT5VO -> Grove 5V * * Frame: [0xA5][len_lo][len_hi][payload:len][crc8(payload), poly 0x07] @@ -38,6 +38,10 @@ bool modulus_zb_uart_send_cmd(const uint8_t *cmd_payload, uint16_t len); */ bool modulus_zb_uart_send_cmd_sync(const uint8_t *cmd_payload, uint16_t len); +/* Reserve the command channel for a bulk OTA transfer. While enabled, + * fire-and-forget Zigbee traffic is rejected and sync OTA jobs take priority. */ +void modulus_zb_uart_set_ota_mode(bool enabled); + /* RX path: feed ACK/NAK from zigbee_rx so send_cmd can unblock. */ void modulus_zb_uart_note_ack(uint8_t seq, bool nak, uint8_t reason); diff --git a/firmware/tab5/components/modulus_zig/nano_ota_shim.c b/firmware/tab5/components/modulus_zig/nano_ota_shim.c new file mode 100644 index 0000000..4968152 --- /dev/null +++ b/firmware/tab5/components/modulus_zig/nano_ota_shim.c @@ -0,0 +1,92 @@ +#include "nano_ota_shim.h" +#include "storage_shim.h" +#include "zb_link_proto.h" +#include "zb_uart_host.h" + +#include +#include +#include +#include +#include +#include +#include "esp_app_desc.h" +#include "esp_app_format.h" +#include "esp_crc.h" +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" +#include "freertos/task.h" + +#define USB_ROOT "/usb" +#define DATA_SIZE 240 +#define IMAGE_MAX (1856U * 1024U) +static modulus_nano_ota_snapshot_t s = {.status="Open this page to scan USB. Nothing flashes automatically."}; +static portMUX_TYPE lock = portMUX_INITIALIZER_UNLOCKED; +static bool running; +static char paths[MODULUS_NANO_OTA_MAX_FILES][192]; +static char versions[MODULUS_NANO_OTA_MAX_FILES][32]; +static char armed[192]; +static size_t armed_size; + +static void put32(uint8_t *p, uint32_t v) { p[0]=v>>24; p[1]=v>>16; p[2]=v>>8; p[3]=v; } +static void set_status(modulus_nano_ota_phase_t p, const char *text) { + taskENTER_CRITICAL(&lock); s.phase=p; snprintf(s.status,sizeof(s.status),"%s",text); taskEXIT_CRITICAL(&lock); +} +static bool safe_name(const char *n) { + if (!n || !*n || strlen(n)>=MODULUS_NANO_OTA_NAME_LEN || strstr(n,"..") || strchr(n,'/') || strchr(n,'\\')) return false; + size_t z=strlen(n); return z>4 && strcasecmp(n+z-4,".bin")==0; +} +static bool inspect(const char *path, size_t *size, esp_app_desc_t *desc) { + struct stat st={0}; if (stat(path,&st) || st.st_size<=0 || (uint64_t)st.st_size>IMAGE_MAX) return false; + FILE *f=fopen(path,"rb"); if (!f) return false; + esp_image_header_t h={0}; esp_image_segment_header_t seg={0}; esp_app_desc_t d={0}; + bool ok=fread(&h,1,sizeof(h),f)==sizeof(h) && fread(&seg,1,sizeof(seg),f)==sizeof(seg) && fread(&d,1,sizeof(d),f)==sizeof(d); + fclose(f); if (!ok || h.magic!=ESP_IMAGE_HEADER_MAGIC || h.chip_id!=ESP_CHIP_ID_ESP32H2 || d.magic_word!=ESP_APP_DESC_MAGIC_WORD) return false; + *size=st.st_size; *desc=d; return true; +} +void modulus_nano_ota_get_snapshot(modulus_nano_ota_snapshot_t *out) { + if (!out) return; + taskENTER_CRITICAL(&lock); + *out = s; + taskEXIT_CRITICAL(&lock); + /* Link readiness changes independently of USB scans and UI actions. */ + out->nano_connected = modulus_zb_uart_ready(); +} +void modulus_nano_ota_refresh(void) { + if (running) return; + modulus_nano_ota_snapshot_t n={.phase=MODULUS_NANO_OTA_READY}; + /* Refresh is an explicit user action: actively prove both UART directions + * with a harmless sequenced command instead of trusting passive traffic. */ + const uint8_t probe[] = {ZIGBEE_CMD_GET_STATE}; + n.nano_connected=modulus_zb_uart_send_cmd_sync(probe,sizeof(probe)); + snprintf(n.nano_version,sizeof(n.nano_version),"UART OTA v1"); + memset(paths,0,sizeof(paths)); memset(versions,0,sizeof(versions)); + DIR *d=modulus_storage_usb_volume_mounted()?opendir(USB_ROOT):NULL; + if(d){struct dirent *e;while((e=readdir(d)) && n.file_countd_name)) continue; + char p[192]; + const size_t name_len = strlen(e->d_name); + if (sizeof(USB_ROOT) + name_len >= sizeof(p)) continue; + memcpy(p, USB_ROOT "/", sizeof(USB_ROOT)); + memcpy(p + sizeof(USB_ROOT), e->d_name, name_len + 1); + size_t z; + esp_app_desc_t a={0}; + if(!inspect(p,&z,&a)) continue; + uint8_t i=n.file_count++;snprintf(n.files[i],sizeof(n.files[i]),"USB: %.90s",e->d_name);snprintf(paths[i],sizeof(paths[i]),"%s",p);snprintf(versions[i],sizeof(versions[i]),"%s",a.version); + }closedir(d);} + if(n.file_count)snprintf(n.image_version,sizeof(n.image_version),"%s",versions[0]); + if(!n.nano_connected){n.phase=MODULUS_NANO_OTA_ERROR;snprintf(n.status,sizeof(n.status),"NanoH2 is not responding over UART.");} + else if(!d){n.phase=MODULUS_NANO_OTA_ERROR;snprintf(n.status,sizeof(n.status),"Insert a FAT USB drive, wait for mount, then refresh.");} + else if(!n.file_count)snprintf(n.status,sizeof(n.status),"No valid ESP32-H2 app images found in USB root."); + else snprintf(n.status,sizeof(n.status),"%u NanoH2 image(s) found. Select and check one.",n.file_count); + taskENTER_CRITICAL(&lock);s=n;armed[0]=0;armed_size=0;taskEXIT_CRITICAL(&lock); +} +void modulus_nano_ota_select(uint8_t i){taskENTER_CRITICAL(&lock);if(!running&&i=n.file_count)return;size_t z;esp_app_desc_t d={0};if(!inspect(paths[n.selected],&z,&d)){set_status(MODULUS_NANO_OTA_ERROR,"Rejected: not a valid ESP32-H2 app image.");return;}taskENTER_CRITICAL(&lock);snprintf(armed,sizeof(armed),"%s",paths[n.selected]);armed_size=z;s.phase=MODULUS_NANO_OTA_ARMED;s.progress=0;snprintf(s.status,sizeof(s.status),"Checked %.48s, version %.24s, %u bytes. Flash armed.",n.files[n.selected],d.version,(unsigned)z);taskEXIT_CRITICAL(&lock);} +static void worker(void *arg){(void)arg;char path[192];size_t total;taskENTER_CRITICAL(&lock);snprintf(path,sizeof(path),"%s",armed);total=armed_size;s.phase=MODULUS_NANO_OTA_FLASHING;s.progress=0;taskEXIT_CRITICAL(&lock);modulus_zb_uart_set_ota_mode(true); + FILE *f=fopen(path,"rb");uint8_t cmd[1+8+DATA_SIZE];bool ok=f!=NULL;size_t sent=0;if(ok){cmd[0]=ZIGBEE_CMD_OTA_BEGIN;put32(cmd+1,total);ok=modulus_zb_uart_send_cmd_sync(cmd,5);} + while(ok&&sentDATA_SIZE)z=DATA_SIZE;if(fread(cmd+9,1,z,f)!=z){ok=false;break;}cmd[0]=ZIGBEE_CMD_OTA_DATA;put32(cmd+1,sent);put32(cmd+5,esp_crc32_le(UINT32_MAX,cmd+9,z));ok=modulus_zb_uart_send_cmd_sync(cmd,9+z);if(ok){sent+=z;taskENTER_CRITICAL(&lock);s.progress=(uint8_t)(sent*100U/total);taskEXIT_CRITICAL(&lock);}} + if(ok){cmd[0]=ZIGBEE_CMD_OTA_END;ok=modulus_zb_uart_send_cmd_sync(cmd,1);}if(f)fclose(f); + if(ok){cmd[0]=ZIGBEE_CMD_OTA_REBOOT;ok=modulus_zb_uart_send_cmd_sync(cmd,1);} + if(ok){taskENTER_CRITICAL(&lock);s.phase=MODULUS_NANO_OTA_SUCCESS;s.progress=100;s.nano_connected=false;snprintf(s.status,sizeof(s.status),"NanoH2 image verified. Restarting into the new OTA slot.");taskEXIT_CRITICAL(&lock);}else{char msg[160];snprintf(msg,sizeof(msg),"NanoH2 OTA failed at %u/%u bytes; current slot remains active.",(unsigned)sent,(unsigned)total);set_status(MODULUS_NANO_OTA_ERROR,msg);}modulus_zb_uart_set_ota_mode(false);running=false;vTaskDelete(NULL);} +void modulus_nano_ota_start(void){modulus_nano_ota_snapshot_t n;modulus_nano_ota_get_snapshot(&n);if(running||n.phase!=MODULUS_NANO_OTA_ARMED||!armed[0])return;running=true;if(xTaskCreate(worker,"nano_ota_host",8192,NULL,4,NULL)!=pdPASS){running=false;set_status(MODULUS_NANO_OTA_ERROR,"Could not start NanoH2 update worker.");}} +void modulus_nano_ota_restart(void){uint8_t cmd=ZIGBEE_CMD_OTA_REBOOT;if(s.phase==MODULUS_NANO_OTA_SUCCESS)(void)modulus_zb_uart_send_cmd(&cmd,1);} diff --git a/firmware/tab5/components/modulus_zig/power_shim.c b/firmware/tab5/components/modulus_zig/power_shim.c index 67062fc..07fc11b 100644 --- a/firmware/tab5/components/modulus_zig/power_shim.c +++ b/firmware/tab5/components/modulus_zig/power_shim.c @@ -158,6 +158,12 @@ void modulus_power_apply_rails(void) void modulus_power_init(void) { + /* Port A hosts the MPG handwheel. Always restore its 5 V rail at boot so + * a stale persisted power-toggle cannot silently disable all jog input. + * The toggle remains useful for live diagnostics, but it is intentionally + * not carried across a restart. */ + modulus_nvs_set_u8("ext5v", 1); + s_sleep_mode = modulus_nvs_get_u8("pwr_mode", 0); s_deep_sleep_to = modulus_nvs_get_u16("pwr_dsto", 120); s_wake_sources = modulus_nvs_get_u8("pwr_wake", 0x01); diff --git a/firmware/tab5/components/modulus_zig/s3_ota_shim.c b/firmware/tab5/components/modulus_zig/s3_ota_shim.c new file mode 100644 index 0000000..0d28144 --- /dev/null +++ b/firmware/tab5/components/modulus_zig/s3_ota_shim.c @@ -0,0 +1,536 @@ +#include "s3_ota_shim.h" +#include "s3_ota_protocol.h" +#include "espnow_stack.h" +#include "c6_espnow_proto.h" +#include "storage_shim.h" + +#include +#include +#include +#include +#include +#include + +#include "esp_app_desc.h" +#include "esp_app_format.h" +#include "esp_crc.h" +#include "esp_log.h" +#include "esp_random.h" +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#define OTA_USB_ROOT "/usb" +#define OTA_MAX_IMAGE_SIZE (3U * 1024U * 1024U) +#define OTA_REPLY_TIMEOUT_MS 2500U +#define OTA_DATA_RETRIES 3 + +static modulus_s3_ota_snapshot_t s_state = { + .phase = MODULUS_S3_OTA_IDLE, + .status = "Open this page to scan for ESP32-S3 app images. Nothing flashes automatically.", +}; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static SemaphoreHandle_t s_reply_sem; +static mod_s3_ota_packet_t s_reply_packet; +static uint8_t s_wait_command; +static uint32_t s_wait_session; +static uint32_t s_wait_sequence; +static uint16_t s_reply_payload_len; +static bool s_worker_running; +static bool s_registered; +static char s_armed_path[192]; +static size_t s_armed_size; +static char s_file_paths[MODULUS_S3_OTA_MAX_FILES][192]; +static char s_file_versions[MODULUS_S3_OTA_MAX_FILES][32]; + +static void state_status(modulus_s3_ota_phase_t phase, const char *text) +{ + taskENTER_CRITICAL(&s_lock); + s_state.phase = phase; + snprintf(s_state.status, sizeof(s_state.status), "%s", text); + taskEXIT_CRITICAL(&s_lock); +} + +static bool safe_bin_name(const char *name) +{ + if (!name || !name[0] || strlen(name) >= MODULUS_S3_OTA_NAME_LEN || + strstr(name, "..") || strchr(name, '/') || strchr(name, '\\')) return false; + const size_t n = strlen(name); + return n > 4 && strcasecmp(name + n - 4, ".bin") == 0; +} + +static bool image_path(char *out, size_t out_len, const char *root, const char *name) +{ + const size_t root_len = root ? strlen(root) : 0; + const size_t name_len = name ? strlen(name) : 0; + if (!name || root_len + 1U + name_len + 1U > out_len) return false; + memcpy(out, root, root_len); + out[root_len] = '/'; + memcpy(out + root_len + 1U, name, name_len + 1U); + return true; +} + +static esp_err_t inspect_s3_image(const char *path, size_t *size_out, esp_app_desc_t *desc_out); + +static bool scan_root(modulus_s3_ota_snapshot_t *next, + char paths[MODULUS_S3_OTA_MAX_FILES][192], + char versions[MODULUS_S3_OTA_MAX_FILES][32], + const char *root, const char *label) +{ + DIR *dir = opendir(root); + if (!dir) return false; + struct dirent *entry; + while ((entry = readdir(dir)) != NULL && next->file_count < MODULUS_S3_OTA_MAX_FILES) { + if (!safe_bin_name(entry->d_name)) continue; + char path[192]; + size_t size = 0; + esp_app_desc_t desc = {0}; + if (!image_path(path, sizeof(path), root, entry->d_name)) continue; + if (inspect_s3_image(path, &size, &desc) != ESP_OK) continue; + const uint8_t index = next->file_count; + snprintf(next->files[index], sizeof(next->files[index]), "%.3s: %.90s", label, entry->d_name); + snprintf(paths[index], sizeof(paths[index]), "%s", path); + snprintf(versions[index], sizeof(versions[index]), "%s", desc.version); + next->file_count++; + } + closedir(dir); + return true; +} + +static esp_err_t inspect_s3_image(const char *path, size_t *size_out, esp_app_desc_t *desc_out) +{ + struct stat st = {0}; + if (stat(path, &st) != 0 || st.st_size <= 0 || (uint64_t)st.st_size > OTA_MAX_IMAGE_SIZE) + return ESP_ERR_INVALID_SIZE; + FILE *f = fopen(path, "rb"); + if (!f) return ESP_ERR_NOT_FOUND; + esp_image_header_t hdr = {0}; + esp_image_segment_header_t seg = {0}; + esp_app_desc_t desc = {0}; + const bool ok = fread(&hdr, 1, sizeof(hdr), f) == sizeof(hdr) && + fread(&seg, 1, sizeof(seg), f) == sizeof(seg) && + fread(&desc, 1, sizeof(desc), f) == sizeof(desc); + fclose(f); + if (!ok || hdr.magic != ESP_IMAGE_HEADER_MAGIC || hdr.chip_id != ESP_CHIP_ID_ESP32S3 || + desc.magic_word != ESP_APP_DESC_MAGIC_WORD) return ESP_ERR_INVALID_ARG; + *size_out = (size_t)st.st_size; + *desc_out = desc; + return ESP_OK; +} + +static void ota_event(uint8_t evt, const uint8_t *body, uint16_t len, void *ctx) +{ + (void)ctx; + if (evt != ESPNOW_EVT_RECV || !body || len < 6 + MOD_S3_OTA_HEADER_SIZE) return; + const mod_s3_ota_packet_t *packet = (const mod_s3_ota_packet_t *)(body + 6); + if (packet->magic != MOD_S3_OTA_MAGIC || packet->version != MOD_S3_OTA_VERSION || + packet->type != MOD_S3_OTA_REPLY || + packet->payload_len < offsetof(mod_s3_ota_reply_t, uart_test_result) || + len != 6 + MOD_S3_OTA_HEADER_SIZE + packet->payload_len) return; + const mod_s3_ota_reply_t *reply = (const mod_s3_ota_reply_t *)packet->payload; + if (reply->command != s_wait_command || packet->session != s_wait_session || + packet->sequence != s_wait_sequence) return; + memcpy(&s_reply_packet, packet, MOD_S3_OTA_HEADER_SIZE + packet->payload_len); + s_reply_payload_len = packet->payload_len; + if (s_reply_sem) xSemaphoreGive(s_reply_sem); +} + +static void ensure_registered(void) +{ + if (!s_reply_sem) s_reply_sem = xSemaphoreCreateBinary(); + if (!s_registered) { + modulus_espnow_stack_register(); + modulus_espnow_stack_set_aux_evt_hook(ota_event, NULL); + s_registered = true; + } +} + +static bool send_wait(mod_s3_ota_packet_t *packet, mod_s3_ota_status_t *status_out) +{ + ensure_registered(); + if (!s_reply_sem) return false; + (void)xSemaphoreTake(s_reply_sem, 0); + s_wait_command = packet->type; + s_wait_session = packet->session; + s_wait_sequence = packet->sequence; + if (!modulus_espnow_stack_send_configured_peer((const uint8_t *)packet, + MOD_S3_OTA_HEADER_SIZE + packet->payload_len)) return false; + if (xSemaphoreTake(s_reply_sem, pdMS_TO_TICKS(OTA_REPLY_TIMEOUT_MS)) != pdTRUE) return false; + const mod_s3_ota_reply_t *reply = (const mod_s3_ota_reply_t *)s_reply_packet.payload; + if (status_out) *status_out = (mod_s3_ota_status_t)reply->status; + return true; +} + +static bool probe_s3(char *version, size_t version_len) +{ + mod_s3_ota_packet_t packet = {0}; + packet.magic = MOD_S3_OTA_MAGIC; + packet.version = MOD_S3_OTA_VERSION; + packet.type = MOD_S3_OTA_PROBE; + packet.session = esp_random(); + mod_s3_ota_status_t status = MOD_S3_OTA_BUSY; + if (!send_wait(&packet, &status) || status != MOD_S3_OTA_OK) return false; + const mod_s3_ota_reply_t *reply = (const mod_s3_ota_reply_t *)s_reply_packet.payload; + snprintf(version, version_len, "%s", reply->app_version); + return true; +} + +static bool reply_has_uart_config(const mod_s3_ota_reply_t *reply) +{ + return reply && s_reply_payload_len >= offsetof(mod_s3_ota_reply_t, uart_test_result) && + (reply->capabilities & MOD_S3_CAP_UART_CONFIG) != 0; +} + +static bool reply_has_uart_test(const mod_s3_ota_reply_t *reply) +{ + return reply && s_reply_payload_len >= sizeof(mod_s3_ota_reply_t) && + (reply->capabilities & MOD_S3_CAP_UART_TEST) != 0; +} + +static bool read_uart_config(int8_t *tx, int8_t *rx, uint32_t *baud) +{ + mod_s3_ota_packet_t packet = {0}; + packet.magic = MOD_S3_OTA_MAGIC; + packet.version = MOD_S3_OTA_VERSION; + packet.type = MOD_S3_CTRL_GET_UART; + packet.session = esp_random(); + mod_s3_ota_status_t status = MOD_S3_OTA_BUSY; + if (!send_wait(&packet, &status) || status != MOD_S3_OTA_OK) return false; + const mod_s3_ota_reply_t *reply = (const mod_s3_ota_reply_t *)s_reply_packet.payload; + if (!reply_has_uart_config(reply)) return false; + *tx = reply->uart_tx_gpio; + *rx = reply->uart_rx_gpio; + *baud = reply->uart_baud; + return true; +} + +void modulus_s3_ota_get_snapshot(modulus_s3_ota_snapshot_t *out) +{ + if (!out) return; + taskENTER_CRITICAL(&s_lock); + *out = s_state; + taskEXIT_CRITICAL(&s_lock); +} + +void modulus_s3_ota_refresh(void) +{ + if (s_worker_running) return; + modulus_s3_ota_snapshot_t next = { .phase = MODULUS_S3_OTA_READY }; + const bool usb_mounted = modulus_storage_usb_volume_mounted(); + next.s3_connected = probe_s3(next.s3_version, sizeof(next.s3_version)); + if (next.s3_connected) { + next.uart_config_supported = read_uart_config( + &next.uart_tx_gpio, &next.uart_rx_gpio, &next.uart_baud); + if (next.uart_config_supported) { + const mod_s3_ota_reply_t *reply = (const mod_s3_ota_reply_t *)s_reply_packet.payload; + next.uart_test_supported = reply_has_uart_test(reply); + } + } + char paths[MODULUS_S3_OTA_MAX_FILES][192] = {{0}}; + char versions[MODULUS_S3_OTA_MAX_FILES][32] = {{0}}; + const bool usb_open = usb_mounted && scan_root(&next, paths, versions, OTA_USB_ROOT, "USB"); + if (next.file_count > 0) snprintf(next.image_version, sizeof(next.image_version), "%s", versions[0]); + if (!next.s3_connected) { + next.phase = MODULUS_S3_OTA_ERROR; + snprintf(next.status, sizeof(next.status), "S3 bridge not responding. Check peer MAC/channel and first-flash firmware."); + } else if (!usb_open) { + next.phase = MODULUS_S3_OTA_ERROR; + snprintf(next.status, sizeof(next.status), "Insert a FAT USB drive, wait for it to mount, then refresh."); + } else if (next.file_count == 0) { + snprintf(next.status, sizeof(next.status), "No valid ESP32-S3 app images found in the USB drive root."); + } else { + snprintf(next.status, sizeof(next.status), "%u verified S3 image(s) found. Select one and check it.", next.file_count); + } + taskENTER_CRITICAL(&s_lock); + s_state = next; + memcpy(s_file_paths, paths, sizeof(s_file_paths)); + memcpy(s_file_versions, versions, sizeof(s_file_versions)); + s_armed_path[0] = 0; + s_armed_size = 0; + taskEXIT_CRITICAL(&s_lock); +} + +void modulus_s3_uart_config_refresh(void) +{ + int8_t tx = -1, rx = -1; + uint32_t baud = 0; + taskENTER_CRITICAL(&s_lock); + s_state.uart_config_busy = true; + taskEXIT_CRITICAL(&s_lock); + const bool ok = read_uart_config(&tx, &rx, &baud); + taskENTER_CRITICAL(&s_lock); + s_state.uart_config_busy = false; + s_state.s3_connected = ok || s_state.s3_connected; + s_state.uart_config_supported = ok; + if (ok) { + const mod_s3_ota_reply_t *reply = (const mod_s3_ota_reply_t *)s_reply_packet.payload; + s_state.uart_test_supported = reply_has_uart_test(reply); + } + if (ok) { + s_state.uart_tx_gpio = tx; + s_state.uart_rx_gpio = rx; + s_state.uart_baud = baud; + snprintf(s_state.status, sizeof(s_state.status), "S3 UART settings loaded."); + } else { + snprintf(s_state.status, sizeof(s_state.status), + "Remote settings unavailable. Update the S3 firmware or check ESP-NOW."); + } + taskEXIT_CRITICAL(&s_lock); +} + +void modulus_s3_uart_config_apply(int8_t tx_gpio, int8_t rx_gpio, uint32_t baud) +{ + mod_s3_ota_packet_t packet = {0}; + packet.magic = MOD_S3_OTA_MAGIC; + packet.version = MOD_S3_OTA_VERSION; + packet.type = MOD_S3_CTRL_SET_UART; + packet.session = esp_random(); + mod_s3_uart_config_t cfg = { + .tx_gpio = tx_gpio, .rx_gpio = rx_gpio, .baud = baud, + }; + packet.payload_len = sizeof(cfg); + memcpy(packet.payload, &cfg, sizeof(cfg)); + taskENTER_CRITICAL(&s_lock); + s_state.uart_config_busy = true; + taskEXIT_CRITICAL(&s_lock); + mod_s3_ota_status_t status = MOD_S3_OTA_BUSY; + const bool replied = send_wait(&packet, &status); + taskENTER_CRITICAL(&s_lock); + s_state.uart_config_busy = false; + if (replied && status == MOD_S3_OTA_OK) { + const mod_s3_ota_reply_t *reply = (const mod_s3_ota_reply_t *)s_reply_packet.payload; + s_state.uart_config_supported = reply_has_uart_config(reply); + s_state.uart_test_supported = reply_has_uart_test(reply); + s_state.uart_tx_gpio = reply->uart_tx_gpio; + s_state.uart_rx_gpio = reply->uart_rx_gpio; + s_state.uart_baud = reply->uart_baud; + snprintf(s_state.status, sizeof(s_state.status), "S3 UART settings saved and activated."); + } else if (replied && status == MOD_S3_OTA_BAD_CONFIG) { + snprintf(s_state.status, sizeof(s_state.status), "Rejected: GPIO combination or baud rate is not safe."); + } else if (replied && status == MOD_S3_OTA_NVS_ERROR) { + snprintf(s_state.status, sizeof(s_state.status), "S3 could not save the settings to NVS."); + } else { + snprintf(s_state.status, sizeof(s_state.status), "S3 did not confirm the UART settings; nothing changed."); + } + taskEXIT_CRITICAL(&s_lock); +} + +static void uart_test_worker(void *arg) +{ + (void)arg; + mod_s3_ota_packet_t packet = {0}; + packet.magic = MOD_S3_OTA_MAGIC; + packet.version = MOD_S3_OTA_VERSION; + packet.type = MOD_S3_CTRL_TEST_UART; + packet.session = esp_random(); + mod_s3_ota_status_t status = MOD_S3_OTA_BUSY; + const bool replied = send_wait(&packet, &status); + taskENTER_CRITICAL(&s_lock); + s_state.uart_config_busy = false; + if (!replied || status != MOD_S3_OTA_OK) { + snprintf(s_state.status, sizeof(s_state.status), + "S3 did not complete the CNC connection test."); + } else if (reply_has_uart_test((const mod_s3_ota_reply_t *)s_reply_packet.payload)) { + const mod_s3_ota_reply_t *reply = (const mod_s3_ota_reply_t *)s_reply_packet.payload; + switch ((mod_s3_uart_test_result_t)reply->uart_test_result) { + case MOD_S3_UART_TEST_GRBL_OK: + snprintf(s_state.status, sizeof(s_state.status), + "CNC detected: valid grblHAL status response received."); + break; + case MOD_S3_UART_TEST_DATA_UNRECOGNIZED: + snprintf(s_state.status, sizeof(s_state.status), + "UART data received, but not recognized as grblHAL. Check baud rate."); + break; + case MOD_S3_UART_TEST_NO_RESPONSE: + snprintf(s_state.status, sizeof(s_state.status), + "No CNC response. Check power, GND, TX/RX pins and baud rate."); + break; + case MOD_S3_UART_TEST_TX_ERROR: + snprintf(s_state.status, sizeof(s_state.status), + "S3 could not transmit the grblHAL status query."); + break; + default: + snprintf(s_state.status, sizeof(s_state.status), + "CNC test is busy or returned an unknown result."); + break; + } + } else { + snprintf(s_state.status, sizeof(s_state.status), + "Update the S3 firmware to enable the CNC connection test."); + } + taskEXIT_CRITICAL(&s_lock); + vTaskDelete(NULL); +} + +void modulus_s3_uart_test(void) +{ + taskENTER_CRITICAL(&s_lock); + if (s_state.uart_config_busy || !s_state.uart_test_supported) { + taskEXIT_CRITICAL(&s_lock); + return; + } + s_state.uart_config_busy = true; + snprintf(s_state.status, sizeof(s_state.status), + "Testing CNC UART with a harmless grblHAL status query..."); + taskEXIT_CRITICAL(&s_lock); + if (xTaskCreate(uart_test_worker, "s3_uart_test", 4096, NULL, 4, NULL) != pdPASS) { + taskENTER_CRITICAL(&s_lock); + s_state.uart_config_busy = false; + snprintf(s_state.status, sizeof(s_state.status), "Could not start the CNC connection test."); + taskEXIT_CRITICAL(&s_lock); + } +} + +void modulus_s3_ota_select(uint8_t index) +{ + taskENTER_CRITICAL(&s_lock); + if (!s_worker_running && index < s_state.file_count) { + s_state.selected = index; + s_state.phase = MODULUS_S3_OTA_READY; + snprintf(s_state.image_version, sizeof(s_state.image_version), "%s", s_file_versions[index]); + snprintf(s_state.status, sizeof(s_state.status), "Selected %s. Check the image before flashing.", s_state.files[index]); + s_armed_path[0] = 0; + s_armed_size = 0; + } + taskEXIT_CRITICAL(&s_lock); +} + +void modulus_s3_ota_arm_selected(void) +{ + modulus_s3_ota_snapshot_t snap; + modulus_s3_ota_get_snapshot(&snap); + if (s_worker_running || !snap.s3_connected || snap.selected >= snap.file_count) return; + char path[sizeof(s_armed_path)]; + taskENTER_CRITICAL(&s_lock); + snprintf(path, sizeof(path), "%s", s_file_paths[snap.selected]); + taskEXIT_CRITICAL(&s_lock); + size_t size = 0; + esp_app_desc_t desc = {0}; + if (inspect_s3_image(path, &size, &desc) != ESP_OK) { + state_status(MODULUS_S3_OTA_ERROR, "Rejected: not a valid ESP32-S3 application image."); + return; + } + taskENTER_CRITICAL(&s_lock); + snprintf(s_armed_path, sizeof(s_armed_path), "%s", path); + s_armed_size = size; + s_state.phase = MODULUS_S3_OTA_ARMED; + s_state.progress = 0; + snprintf(s_state.status, sizeof(s_state.status), "Checked ESP32-S3 image: %.48s, version %.24s, %u bytes. Flash is armed.", + snap.files[snap.selected], desc.version, (unsigned)size); + taskEXIT_CRITICAL(&s_lock); +} + +static void send_abort(uint32_t session) +{ + mod_s3_ota_packet_t packet = { .magic = MOD_S3_OTA_MAGIC, .version = MOD_S3_OTA_VERSION, + .type = MOD_S3_OTA_ABORT, .session = session }; + mod_s3_ota_status_t ignored; + (void)send_wait(&packet, &ignored); +} + +static void ota_worker(void *arg) +{ + (void)arg; + char path[sizeof(s_armed_path)]; + size_t image_size; + taskENTER_CRITICAL(&s_lock); + snprintf(path, sizeof(path), "%s", s_armed_path); + image_size = s_armed_size; + s_state.phase = MODULUS_S3_OTA_FLASHING; + s_state.progress = 0; + snprintf(s_state.status, sizeof(s_state.status), "Flashing S3 over ESP-NOW. Keep the CNC idle; do not remove power or USB."); + taskEXIT_CRITICAL(&s_lock); + + uint32_t session = esp_random(); + if (session == 0) session = 1; + mod_s3_ota_packet_t *packet = calloc(1, sizeof(*packet)); + FILE *f = fopen(path, "rb"); + bool ok = packet && f; + mod_s3_ota_status_t remote = MOD_S3_OTA_BUSY; + if (ok) { + packet->magic = MOD_S3_OTA_MAGIC; + packet->version = MOD_S3_OTA_VERSION; + packet->type = MOD_S3_OTA_BEGIN; + packet->session = session; + packet->value = (uint32_t)image_size; + ok = send_wait(packet, &remote) && remote == MOD_S3_OTA_OK; + } + size_t sent = 0; + uint32_t seq = 0; + while (ok && sent < image_size) { + size_t want = image_size - sent; + if (want > MOD_S3_OTA_DATA_MAX) want = MOD_S3_OTA_DATA_MAX; + memset(packet, 0, MOD_S3_OTA_HEADER_SIZE); + if (fread(packet->payload, 1, want, f) != want) { ok = false; break; } + packet->magic = MOD_S3_OTA_MAGIC; + packet->version = MOD_S3_OTA_VERSION; + packet->type = MOD_S3_OTA_DATA; + packet->payload_len = (uint16_t)want; + packet->session = session; + packet->sequence = seq; + packet->payload_crc = esp_crc32_le(UINT32_MAX, packet->payload, want); + bool acked = false; + for (int retry = 0; retry < OTA_DATA_RETRIES && !acked; retry++) + acked = send_wait(packet, &remote) && remote == MOD_S3_OTA_OK; + if (!acked) { ok = false; break; } + sent += want; + seq++; + taskENTER_CRITICAL(&s_lock); + s_state.progress = (uint8_t)((sent * 100U) / image_size); + taskEXIT_CRITICAL(&s_lock); + } + if (ok) { + memset(packet, 0, MOD_S3_OTA_HEADER_SIZE); + packet->magic = MOD_S3_OTA_MAGIC; + packet->version = MOD_S3_OTA_VERSION; + packet->type = MOD_S3_OTA_END; + packet->session = session; + packet->sequence = seq; + ok = send_wait(packet, &remote) && remote == MOD_S3_OTA_OK; + } + if (!ok) send_abort(session); + if (f) fclose(f); + free(packet); + + if (ok && sent == image_size) { + taskENTER_CRITICAL(&s_lock); + s_state.phase = MODULUS_S3_OTA_SUCCESS; + s_state.progress = 100; + snprintf(s_state.status, sizeof(s_state.status), "S3 image verified and selected. Press Restart S3 to boot it."); + taskEXIT_CRITICAL(&s_lock); + } else { + char msg[MODULUS_S3_OTA_STATUS_LEN]; + snprintf(msg, sizeof(msg), "S3 update failed at %u/%u bytes (remote status %u). Old firmware remains bootable.", + (unsigned)sent, (unsigned)image_size, (unsigned)remote); + state_status(MODULUS_S3_OTA_ERROR, msg); + } + s_worker_running = false; + vTaskDelete(NULL); +} + +void modulus_s3_ota_start(void) +{ + modulus_s3_ota_snapshot_t snap; + modulus_s3_ota_get_snapshot(&snap); + if (s_worker_running || snap.phase != MODULUS_S3_OTA_ARMED || !s_armed_path[0]) return; + s_worker_running = true; + if (xTaskCreate(ota_worker, "s3_ota_host", 8192, NULL, 4, NULL) != pdPASS) { + s_worker_running = false; + state_status(MODULUS_S3_OTA_ERROR, "Could not start the S3 update worker."); + } +} + +void modulus_s3_ota_restart(void) +{ + modulus_s3_ota_snapshot_t snap; + modulus_s3_ota_get_snapshot(&snap); + if (snap.phase != MODULUS_S3_OTA_SUCCESS) return; + mod_s3_ota_packet_t packet = { .magic = MOD_S3_OTA_MAGIC, .version = MOD_S3_OTA_VERSION, + .type = MOD_S3_OTA_REBOOT, .session = esp_random() }; + mod_s3_ota_status_t status; + if (send_wait(&packet, &status) && status == MOD_S3_OTA_OK) + state_status(MODULUS_S3_OTA_READY, "Restart command accepted. Wait for the S3 bridge to reconnect."); + else + state_status(MODULUS_S3_OTA_ERROR, "S3 restart command was not acknowledged."); +} diff --git a/firmware/tab5/components/modulus_zig/storage_shim.c b/firmware/tab5/components/modulus_zig/storage_shim.c index 2c407ae..cafbe98 100644 --- a/firmware/tab5/components/modulus_zig/storage_shim.c +++ b/firmware/tab5/components/modulus_zig/storage_shim.c @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include #include #include @@ -38,6 +40,76 @@ static const char *const k_loglvl_names[] = { static bool s_sd_mounted = false; static char s_mount_point[] = "/sdcard"; +static sdmmc_card_t *s_sd_card = NULL; +static sd_pwr_ctrl_handle_t s_sd_pwr = NULL; + +/* ESP-IDF 6 permits the P4 SDMMC controller to be created only once. + * ESP-Hosted owns that controller for C6 SDIO on slot 1; the physical microSD + * card must therefore add only slot 0. This is the same workaround used by + * Espressif's esp_hosted host_sdcard_with_hosted example. */ +static esp_err_t shared_sdmmc_host_init(void) +{ + return ESP_OK; +} + +static esp_err_t shared_sdmmc_host_deinit(void) +{ + return ESP_OK; +} + +static esp_err_t shared_sdcard_mount(bool format_if_mount_failed) +{ + esp_vfs_fat_sdmmc_mount_config_t mount_cfg = { + .format_if_mount_failed = format_if_mount_failed, + .max_files = 5, + .allocation_unit_size = 16 * 1024, + }; + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + host.slot = SDMMC_HOST_SLOT_0; + host.max_freq_khz = SDMMC_FREQ_HIGHSPEED; + host.init = shared_sdmmc_host_init; + host.deinit = shared_sdmmc_host_deinit; + + sdmmc_slot_config_t slot = {0}; + bsp_sdcard_sdmmc_get_slot(SDMMC_HOST_SLOT_0, &slot); + + sd_pwr_ctrl_ldo_config_t ldo_cfg = { + .ldo_chan_id = 4, + }; + esp_err_t ret = sd_pwr_ctrl_new_on_chip_ldo(&ldo_cfg, &s_sd_pwr); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "SD LDO acquire failed: %s", esp_err_to_name(ret)); + s_sd_pwr = NULL; + return ret; + } + host.pwr_ctrl_handle = s_sd_pwr; + + ret = esp_vfs_fat_sdmmc_mount(s_mount_point, &host, &slot, &mount_cfg, &s_sd_card); + if (ret != ESP_OK) { + s_sd_card = NULL; + const esp_err_t pwr_ret = sd_pwr_ctrl_del_on_chip_ldo(s_sd_pwr); + if (pwr_ret != ESP_OK) { + ESP_LOGW(TAG, "SD LDO cleanup failed: %s", esp_err_to_name(pwr_ret)); + } + s_sd_pwr = NULL; + } + return ret; +} + +static esp_err_t shared_sdcard_unmount(void) +{ + esp_err_t ret = ESP_OK; + if (s_sd_card) { + ret = esp_vfs_fat_sdcard_unmount(s_mount_point, s_sd_card); + s_sd_card = NULL; + } + if (s_sd_pwr) { + const esp_err_t pwr_ret = sd_pwr_ctrl_del_on_chip_ldo(s_sd_pwr); + if (ret == ESP_OK) ret = pwr_ret; + s_sd_pwr = NULL; + } + return ret; +} /* USB Type-A host: Power tab owns VBUS (usb5v / PI4IOE E2.P3); BSP host stack * enumerates devices when that rail is on. Same pin as BSP_FEATURE_USB. */ @@ -351,7 +423,7 @@ bool modulus_storage_mount(void) } (void)s_mount_point; - const esp_err_t ret = bsp_sdcard_mount(); + const esp_err_t ret = shared_sdcard_mount(false); if (ret == ESP_OK) { s_sd_mounted = true; ESP_LOGI(TAG, "SD mounted at %s", s_mount_point); @@ -368,7 +440,7 @@ void modulus_storage_unmount(void) if (!s_sd_mounted) { return; } - const esp_err_t ret = bsp_sdcard_unmount(); + const esp_err_t ret = shared_sdcard_unmount(); if (ret != ESP_OK) { ESP_LOGW(TAG, "SD unmount failed: %s", esp_err_to_name(ret)); } @@ -378,17 +450,10 @@ void modulus_storage_unmount(void) static bool sd_remount_with_format(void) { - (void)bsp_sdcard_unmount(); + (void)shared_sdcard_unmount(); s_sd_mounted = false; - static const esp_vfs_fat_sdmmc_mount_config_t mount_cfg = { - .format_if_mount_failed = true, - .max_files = 5, - .allocation_unit_size = 16 * 1024, - }; - bsp_sdcard_cfg_t cfg = {0}; - cfg.mount = &mount_cfg; - const esp_err_t ret = bsp_sdcard_sdmmc_mount(&cfg); + const esp_err_t ret = shared_sdcard_mount(true); if (ret == ESP_OK) { s_sd_mounted = true; ESP_LOGI(TAG, "SD mounted with format-if-needed at %s", s_mount_point); @@ -1201,7 +1266,7 @@ bool modulus_storage_format_sd(void) return false; } - sdmmc_card_t *card = bsp_sdcard_get_handle(); + sdmmc_card_t *card = s_sd_card; if (!card) { ESP_LOGW(TAG, "SD format: no card handle"); return false; @@ -1213,7 +1278,7 @@ bool modulus_storage_format_sd(void) if (!sd_remount_with_format()) { return false; } - card = bsp_sdcard_get_handle(); + card = s_sd_card; if (!card) { return false; } diff --git a/firmware/tab5/components/modulus_zig/ui_boot.c b/firmware/tab5/components/modulus_zig/ui_boot.c index 534c7f3..63c3e76 100644 --- a/firmware/tab5/components/modulus_zig/ui_boot.c +++ b/firmware/tab5/components/modulus_zig/ui_boot.c @@ -31,7 +31,7 @@ void modulus_ui_boot_arm_transition(void) if (s_boot_tmr) { return; } - s_boot_tmr = lv_timer_create(boot_timeout_cb, 3000, NULL); + s_boot_tmr = lv_timer_create(boot_timeout_cb, 5000, NULL); lv_timer_set_repeat_count(s_boot_tmr, 1); } @@ -62,6 +62,12 @@ void modulus_ui_boot_create(void) lv_obj_set_style_text_letter_space(title, 3, 0); lv_obj_set_style_opa(title, LV_OPA_COVER, 0); + lv_obj_t *ota_credit = lv_label_create(col); + lv_label_set_text(ota_credit, "OTA VERSION BY FELLPOWER"); + lv_obj_set_style_text_color(ota_credit, modulus_ui_color_primary(), 0); + lv_obj_set_style_text_font(ota_credit, MOD_UI_FONT_TITLE_M, 0); + lv_obj_set_style_text_letter_space(ota_credit, 1, 0); + lv_obj_t *creator = lv_label_create(col); lv_label_set_text(creator, "Driven by M5Stack | Powered by Zig | Built on ESP-IDF"); lv_obj_set_style_text_color(creator, modulus_ui_color_on_surface_variant(), 0); diff --git a/firmware/tab5/components/modulus_zig/ui_settings_wireless_espnow.c b/firmware/tab5/components/modulus_zig/ui_settings_wireless_espnow.c index cf24331..04b8e85 100644 --- a/firmware/tab5/components/modulus_zig/ui_settings_wireless_espnow.c +++ b/firmware/tab5/components/modulus_zig/ui_settings_wireless_espnow.c @@ -49,9 +49,15 @@ void espnow_toggle_cb(lv_event_t *e) void dd_u8_cb(lv_event_t *e) { const char *key = lv_event_get_user_data(e); - modulus_nvs_set_u8(key, (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e))); if (strcmp(key, "en_chan") == 0) { - modulus_wireless_espnow_channel_reload(); + const uint8_t channel = (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e)) + 1; + if (!modulus_wireless_espnow_move_bridge_channel(channel)) { + modulus_audio_play_ui(MODULUS_UI_SOUND_DROP); + wl_rebuild(); + return; + } + } else { + modulus_nvs_set_u8(key, (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e))); } if (strcmp(key, "en_chan") == 0 || strcmp(key, "en_rate") == 0) { wl_maybe_reinit_espnow_transport(); diff --git a/firmware/tab5/components/modulus_zig/ui_status_bar_data.c b/firmware/tab5/components/modulus_zig/ui_status_bar_data.c index e635cf2..ffc68cf 100644 --- a/firmware/tab5/components/modulus_zig/ui_status_bar_data.c +++ b/firmware/tab5/components/modulus_zig/ui_status_bar_data.c @@ -200,7 +200,7 @@ uint8_t bar_wireless_espnow_state(void) return 0; } if (modulus_wireless_espnow_transport_active()) { - return 2; + return modulus_wireless_espnow_bridge_ready() ? 2 : 3; } if (modulus_wireless_espnow_is_enabled()) { return 1; @@ -213,6 +213,8 @@ lv_color_t bar_wireless_espnow_color(uint8_t st) switch (st) { case 2: return modulus_ui_color_success(); + case 3: + return modulus_ui_color_warning(); case 1: return modulus_ui_color_icon_chrome(); default: diff --git a/firmware/tab5/components/modulus_zig/wireless_rpc.c b/firmware/tab5/components/modulus_zig/wireless_rpc.c index 117719d..ff1f153 100644 --- a/firmware/tab5/components/modulus_zig/wireless_rpc.c +++ b/firmware/tab5/components/modulus_zig/wireless_rpc.c @@ -15,6 +15,9 @@ #include "esp_hosted_interface.h" #include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include #include @@ -26,6 +29,11 @@ static bool s_zb_hub_fw; /* true once any EVT_HUB_STATE arrives */ static uint8_t s_zb_ch; static uint16_t s_zb_pan; static uint8_t s_zb_permit_s; +static modulus_zb_node_info_t s_node_info; +static modulus_zb_node_channel_t s_node_channels[12]; +static portMUX_TYPE s_temperature_mux = portMUX_INITIALIZER_UNLOCKED; +static struct { int16_t value; int64_t received_us; } s_node_temperature[12]; +static struct { bool value; int64_t received_us; } s_node_digital[12]; static bool s_th_attached; static uint8_t s_th_role; @@ -212,6 +220,68 @@ static void zigbee_rx(const uint8_t *payload, uint16_t len, void *ctx) } } break; + case ZIGBEE_EVT_TEMPERATURE: + if (args_len == 5 && (((uint16_t)args[0]<<8)|args[1]) == s_node_info.short_addr && args[2]>=10 && args[2]<22) { + unsigned i=args[2]-10; + taskENTER_CRITICAL(&s_temperature_mux); + s_node_temperature[i].value=(int16_t)(((uint16_t)args[3]<<8)|args[4]); + s_node_temperature[i].received_us=esp_timer_get_time(); + taskEXIT_CRITICAL(&s_temperature_mux); + } + break; + case ZIGBEE_EVT_DIGITAL_INPUT: + if (args_len == 4 && (((uint16_t)args[0]<<8)|args[1]) == s_node_info.short_addr && args[2]>=10 && args[2]<22) { + unsigned i=args[2]-10; + taskENTER_CRITICAL(&s_temperature_mux); + s_node_digital[i].value=args[3] != 0; + s_node_digital[i].received_us=esp_timer_get_time(); + taskEXIT_CRITICAL(&s_temperature_mux); + } + break; + case ZIGBEE_EVT_NODE_CONFIG: + if (args_len >= 3) { + const uint16_t short_addr = ((uint16_t)args[0] << 8) | args[1]; + if (short_addr != s_node_info.short_addr) break; + const uint8_t response = args[2]; + const uint8_t *p = args + 3; + const size_t n = args_len - 3; + if (response == 0x81 && n >= 4 && n >= (size_t)p[3] + (p[0] >= 2 ? 6 : 4)) { + memset(&s_node_info, 0, sizeof(s_node_info)); + memset(s_node_channels, 0, sizeof(s_node_channels)); + s_node_info.short_addr = short_addr; + s_node_info.protocol_version = p[0]; + s_node_info.channel_count = p[1] > 12 ? 12 : p[1]; + s_node_info.max_channels = p[2]; + s_node_info.status_led_gpio = p[0] >= 2 ? (int8_t)p[4] : 15; + s_node_info.status_led_flags = p[0] >= 2 ? p[5] : 1; + const size_t name_len = p[3] < sizeof(s_node_info.name) - 1 ? p[3] : sizeof(s_node_info.name) - 1; + memcpy(s_node_info.name, p + (p[0] >= 2 ? 6 : 4), name_len); + s_node_info.generation++; + for (uint8_t i = 0; i < s_node_info.channel_count; i++) { + uint8_t cmd[] = {ZIGBEE_CMD_NODE_CONFIG, (uint8_t)(short_addr >> 8), + (uint8_t)short_addr, 0x02, i}; + (void)modulus_zb_uart_send_cmd(cmd, sizeof(cmd)); + } + } else if (response == 0x82 && n >= 5 && p[0] < 12 && n >= (size_t)p[4] + 5) { + modulus_zb_node_channel_t *channel = &s_node_channels[p[0]]; + memset(channel, 0, sizeof(*channel)); + channel->poll_interval_s = (s_node_info.protocol_version >= 3 && n >= (size_t)p[4]+7) ? ((uint16_t)p[5+p[4]]<<8)|p[6+p[4]] : 15; + channel->apply_pending = s_node_info.protocol_version >= 3 && n >= (size_t)p[4]+8 && p[7+p[4]]; + channel->index = p[0]; channel->type = p[1]; channel->gpio = (int8_t)p[2]; channel->flags = p[3]; + const size_t name_len = p[4] < sizeof(channel->name) - 1 ? p[4] : sizeof(channel->name) - 1; + memcpy(channel->name, p + 5, name_len); channel->valid = true; + s_node_info.generation++; + } else if (response == 0x90 && n >= 2) { + s_node_info.last_command = p[0]; s_node_info.last_status = p[1]; + s_node_info.generation++; + if ((p[0] == 0x14 || p[0] == 0x11 || p[0] == 0x15) && p[1] != 0) { + uint8_t refresh[] = {ZIGBEE_CMD_NODE_CONFIG, + (uint8_t)(short_addr >> 8), (uint8_t)short_addr, 0x01}; + (void)modulus_zb_uart_send_cmd(refresh, sizeof(refresh)); + } + } + } + break; case ZIGBEE_EVT_FAIL: /* Reason map (zigbee_handler.c): 10 init-signal 11 task-create * 12 not-ready 13 onoff 14 level 15 raw-op-refused 16 cover @@ -309,6 +379,116 @@ bool modulus_wireless_zb_permit_join(uint8_t seconds) return modulus_zb_uart_send_cmd(cmd, sizeof(cmd)); } +bool modulus_wireless_zb_get_state(void) +{ + uint8_t cmd[] = {ZIGBEE_CMD_GET_STATE}; + return modulus_zb_uart_send_cmd(cmd, sizeof(cmd)); +} + +bool modulus_wireless_zb_get_devices(void) +{ + uint8_t cmd[] = {ZIGBEE_CMD_GET_DEVICES}; + return modulus_zb_uart_send_cmd(cmd, sizeof(cmd)); +} + +static bool node_send(uint16_t short_addr, uint8_t node_command, + const uint8_t *payload, size_t payload_len) +{ + if (payload_len > 92) return false; + uint8_t cmd[4 + 92] = {ZIGBEE_CMD_NODE_CONFIG, (uint8_t)(short_addr >> 8), + (uint8_t)short_addr, node_command}; + if (payload_len) memcpy(cmd + 4, payload, payload_len); + return modulus_zb_uart_send_cmd(cmd, 4 + payload_len); +} + +bool modulus_wireless_zb_node_request(uint16_t short_addr) +{ + memset(&s_node_info, 0, sizeof(s_node_info)); + memset(s_node_channels, 0, sizeof(s_node_channels)); + taskENTER_CRITICAL(&s_temperature_mux); + memset(s_node_temperature, 0, sizeof(s_node_temperature)); + memset(s_node_digital, 0, sizeof(s_node_digital)); + taskEXIT_CRITICAL(&s_temperature_mux); + s_node_info.short_addr = short_addr; + return node_send(short_addr, 0x01, NULL, 0); +} +bool modulus_wireless_zb_node_get_info(modulus_zb_node_info_t *out) +{ + if (!out || !s_node_info.short_addr || !s_node_info.protocol_version) return false; + *out = s_node_info; return true; +} +bool modulus_wireless_zb_node_get_channel(uint8_t index, modulus_zb_node_channel_t *out) +{ + if (!out || index >= 12 || !s_node_channels[index].valid) return false; + *out = s_node_channels[index]; + taskENTER_CRITICAL(&s_temperature_mux); + out->temperature_centi_c=s_node_temperature[index].value; + int64_t received=s_node_temperature[index].received_us; + out->digital_value=s_node_digital[index].value; + int64_t digital_received=s_node_digital[index].received_us; + taskEXIT_CRITICAL(&s_temperature_mux); + out->temperature_state = out->apply_pending ? 4 : !received ? 0 : + (!modulus_wireless_zb_link_up() || esp_timer_get_time()-received > ((int64_t)out->poll_interval_s*3+15)*1000000) ? 3 : + out->temperature_centi_c == INT16_MIN ? 2 : 1; + out->digital_state = out->apply_pending ? 3 : !digital_received ? 0 : + (!modulus_wireless_zb_link_up() || esp_timer_get_time()-digital_received > 30000000) ? 2 : 1; + return true; +} +bool modulus_wireless_zb_node_set_name(uint16_t short_addr, const char *name) +{ + if (!name) return false; + size_t n = strnlen(name, 32); + if (!n || n >= 32) return false; + uint8_t data[32] = {(uint8_t)n}; + memcpy(data + 1, name, n); + memset(s_node_info.name, 0, sizeof(s_node_info.name)); + memcpy(s_node_info.name, name, n); + s_node_info.generation++; + return node_send(short_addr, 0x10, data, n + 1); +} +bool modulus_wireless_zb_node_set_channel(uint16_t short_addr, uint8_t index, + uint8_t type, int8_t gpio, uint8_t flags, + const char *name) +{ + if (!name || index >= 12 || type > 7) return false; + size_t n = strnlen(name, 24); + if (n >= 24) return false; + uint8_t data[5 + 23] = {index, type, (uint8_t)gpio, flags, (uint8_t)n}; + memcpy(data + 5, name, n); + modulus_zb_node_channel_t *channel = &s_node_channels[index]; + channel->apply_pending = true; + if (!channel->poll_interval_s) channel->poll_interval_s=15; + channel->index = index; + channel->type = type; + channel->gpio = gpio; + channel->flags = flags; + memset(channel->name, 0, sizeof(channel->name)); + memcpy(channel->name, name, n); + channel->valid = true; + if (s_node_info.channel_count <= index) s_node_info.channel_count=index+1; + s_node_info.generation++; + return node_send(short_addr, 0x11, data, n + 5); +} +bool modulus_wireless_zb_node_set_poll(uint16_t short_addr, uint8_t index, uint16_t seconds) +{ + if (index>=12 || seconds<15 || seconds>3600 || s_node_info.protocol_version<3) return false; + uint8_t data[]={index,(uint8_t)(seconds>>8),(uint8_t)seconds}; + if (!node_send(short_addr,0x15,data,sizeof(data))) return false; + s_node_channels[index].apply_pending=true; + s_node_channels[index].poll_interval_s=seconds; + s_node_info.generation++; + return true; +} +bool modulus_wireless_zb_node_apply(uint16_t short_addr) { return node_send(short_addr, 0x12, NULL, 0); } +bool modulus_wireless_zb_node_set_status_led(uint16_t short_addr, int8_t gpio, uint8_t flags) +{ + uint8_t payload[] = {(uint8_t)gpio, flags}; + if (s_node_info.short_addr == short_addr) { + s_node_info.status_led_gpio = gpio; s_node_info.status_led_flags = flags; s_node_info.generation++; + } + return node_send(short_addr, 0x14, payload, sizeof(payload)); +} + uint8_t modulus_wireless_zb_permit_remaining(void) { return s_zb_permit_s; @@ -321,7 +501,7 @@ bool modulus_wireless_zb_hub_fw(void) bool modulus_wireless_zb_leave(void) { - uint8_t cmd[] = {ZIGBEE_CMD_DISABLE}; + uint8_t cmd[] = {ZIGBEE_CMD_HUB_RESET}; if (!modulus_zb_uart_send_cmd(cmd, sizeof(cmd))) { return false; } @@ -382,6 +562,14 @@ bool modulus_wireless_zb_set_onoff(const modulus_zb_device_t *dev, bool on) return true; } +bool modulus_wireless_zb_node_set_output(uint16_t short_addr, uint8_t index, bool on) +{ + if (short_addr == 0 || index >= 12) return false; + uint8_t cmd[] = {ZIGBEE_CMD_ONOFF, (uint8_t)(short_addr >> 8), + (uint8_t)short_addr, (uint8_t)(10 + index), on ? 1 : 0}; + return modulus_zb_uart_send_cmd(cmd, sizeof(cmd)); +} + bool modulus_wireless_zb_set_level(const modulus_zb_device_t *dev, uint8_t level) { if (!dev || !dev->id[0] || dev->short_addr == 0) { @@ -403,6 +591,14 @@ bool modulus_wireless_zb_set_level(const modulus_zb_device_t *dev, uint8_t level return true; } +bool modulus_wireless_zb_node_set_output(uint16_t short_addr, uint8_t index, bool on) +{ + if (short_addr == 0 || index >= 12) return false; + uint8_t cmd[] = {ZIGBEE_CMD_ONOFF, (uint8_t)(short_addr >> 8), + (uint8_t)short_addr, (uint8_t)(10 + index), on ? 1 : 0}; + return modulus_zb_uart_send_cmd(cmd, sizeof(cmd)); +} + bool modulus_wireless_zb_cover(const modulus_zb_device_t *dev, uint8_t op) { if (!dev || dev->short_addr == 0 || op > 2) { diff --git a/firmware/tab5/components/modulus_zig/wireless_shim.c b/firmware/tab5/components/modulus_zig/wireless_shim.c index 611ccda..f4f46dc 100644 --- a/firmware/tab5/components/modulus_zig/wireless_shim.c +++ b/firmware/tab5/components/modulus_zig/wireless_shim.c @@ -105,6 +105,8 @@ void modulus_wireless_radio_op_give(void) static portMUX_TYPE s_wifi_mux = portMUX_INITIALIZER_UNLOCKED; static bool s_wifi_enabled = false; +/* User intent is distinct from the STA interface required by ESP-NOW. */ +static bool s_wifi_requested = false; static bool s_wifi_connected = false; static bool s_wifi_connecting = false; static bool s_user_disconnect = false; @@ -275,8 +277,17 @@ static void wireless_prime_sta_config(void) { char ssid[33] = {}; char pass[65] = {}; - if (!modulus_nvs_get_str("wf_ssid", ssid, sizeof(ssid)) || !ssid[0]) { - /* No saved STA — skip RPC set_config during boot (ESP-NOW / idle). */ + if (modulus_nvs_get_u8("wifi", 0) == 0 || + !modulus_nvs_get_str("wf_ssid", ssid, sizeof(ssid)) || !ssid[0]) { + /* P4 full-flash/NVS erase does not erase the separate C6 flash. Clear + * any stale C6 STA credentials explicitly, otherwise esp_wifi_start() + * can keep pulling the shared PHY back to an old AP channel while the + * Tab5 UI correctly says Wi-Fi is off. */ + wifi_config_t empty = {}; + esp_err_t err = esp_wifi_set_config(WIFI_IF_STA, &empty); + if (err != ESP_OK) { + ESP_LOGW(TAG, "clear stale C6 STA config: %s", esp_err_to_name(err)); + } return; } wifi_config_t cfg = {}; @@ -461,12 +472,24 @@ static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t event_i case WIFI_EVENT_STA_CONNECTED: { wifi_event_sta_connected_t *ev = event_data; taskENTER_CRITICAL(&s_wifi_mux); - s_wifi_enabled = true; - s_wifi_connecting = true; - if (ev && ev->ssid[0]) { - strncpy(s_wifi_ssid, (const char *)ev->ssid, sizeof(s_wifi_ssid) - 1); + const bool requested = s_wifi_requested; + if (requested) { + s_wifi_enabled = true; + s_wifi_connecting = true; + if (ev && ev->ssid[0]) { + strncpy(s_wifi_ssid, (const char *)ev->ssid, sizeof(s_wifi_ssid) - 1); + } } taskEXIT_CRITICAL(&s_wifi_mux); + if (!requested) { + ESP_LOGW(TAG, "Rejecting unsolicited AP connection while Wi-Fi radio is disabled"); + s_user_disconnect = true; + (void)esp_wifi_disconnect(); + break; + } + if (ev && ev->channel >= 1 && ev->channel <= 13) { + modulus_nvs_set_u8("wf_chan", ev->channel); + } modulus_wireless_espnow_check_channel_conflict(); break; } @@ -650,6 +673,13 @@ static bool wireless_host_init(bool aggressive) return false; } + /* Earlier builds could leave the C-side antenna preference out of sync + * with the Settings UI. Migrate once to the safe/default PCB antenna; + * later explicit menu changes keep persisting through ant_ext. */ + if (modulus_nvs_get_u8("ant_pcb1", 0) == 0) { + modulus_nvs_set_u8("ant_ext", 0); + modulus_nvs_set_u8("ant_pcb1", 1); + } s_ext_antenna = modulus_nvs_get_u8("ant_ext", 0) != 0; tab5_pi4ioe_set_ext_antenna_enable(s_ext_antenna); ESP_LOGI(TAG, "Antenna: %s (PI4IOE1 P0)", s_ext_antenna ? "external MMCX" : "internal PCB"); @@ -713,6 +743,11 @@ void modulus_wireless_restore_settings(void) if (!s_ready) { return; } + /* Zigbee lives on the NanoH2 UART and must be restored even while the + * separate SDIO transport is still starting (or unavailable). */ + if (modulus_nvs_get_u8("zigbee", 0) != 0) { + modulus_wireless_zigbee_enable(); + } if (!wireless_transport_ready() && !wireless_ensure_wifi_stack_started()) { ESP_LOGW(TAG, "Skip NVS radio restore — SDIO transport down"); return; @@ -730,12 +765,12 @@ void modulus_wireless_restore_settings(void) } } if (espnow_on) { - modulus_wireless_espnow_enable(); + /* Fixed-channel architecture: restoring ESP-NOW must never start + * an autonomous sweep. Discovery is an explicit user action; all + * operational channel changes are commanded by Tab5 to the S3. */ + (void)modulus_wireless_espnow_enable(); } } - if (modulus_nvs_get_u8("zigbee", 0) != 0) { - modulus_wireless_zigbee_enable(); - } if (modulus_nvs_get_u8("thread", 0) != 0 && modulus_wireless_thread_supported()) { modulus_wireless_thread_enable(); } @@ -881,6 +916,7 @@ static void wifi_enable_worker(void *arg) return; } taskENTER_CRITICAL(&s_wifi_mux); + s_wifi_requested = true; s_wifi_enabled = true; taskEXIT_CRITICAL(&s_wifi_mux); modulus_nvs_set_u8("wifi", 1); @@ -901,6 +937,9 @@ bool modulus_wireless_wifi_enable(void) if (!s_ready) { return false; } + taskENTER_CRITICAL(&s_wifi_mux); + s_wifi_requested = true; + taskEXIT_CRITICAL(&s_wifi_mux); if (s_wifi_enable_busy) { return true; /* already starting */ } @@ -916,6 +955,13 @@ bool modulus_wireless_wifi_enable(void) void modulus_wireless_wifi_disable(void) { + if (modulus_wireless_espnow_is_enabled() && + !modulus_wireless_espnow_move_bridge_channel(1)) { + ESP_LOGW(TAG, "S3 channel-1 command failed before Wi-Fi off"); + } + taskENTER_CRITICAL(&s_wifi_mux); + s_wifi_requested = false; + taskEXIT_CRITICAL(&s_wifi_mux); s_user_disconnect = true; esp_wifi_disconnect(); modulus_wireless_espnow_on_wifi_stop(); @@ -987,6 +1033,7 @@ static void wifi_scan_worker(void *arg) if (!s_wifi_enabled && !s_wifi_enable_busy) { taskENTER_CRITICAL(&s_wifi_mux); + s_wifi_requested = true; s_wifi_enabled = true; taskEXIT_CRITICAL(&s_wifi_mux); modulus_nvs_set_u8("wifi", 1); @@ -1138,8 +1185,32 @@ bool modulus_wireless_wifi_connect(const char *ssid, const char *pass) if (!wireless_ensure_wifi_stack_started()) { return false; } + + /* ESP-NOW shares the C6 PHY with Wi-Fi. Move the bridge while both ends + * are still reachable on the old channel, before association moves C6 to + * the AP channel. Prefer the fresh scan result; saved auto-connect uses + * the AP channel learned during the preceding successful connection. */ + uint8_t target_channel = 0; + taskENTER_CRITICAL(&s_wifi_mux); + for (int i = 0; i < s_scan_n; i++) { + if (strcmp(s_scan_buf[i].ssid, ssid) == 0) { + target_channel = s_scan_buf[i].channel; + break; + } + } + taskEXIT_CRITICAL(&s_wifi_mux); + if (target_channel < 1 || target_channel > 13) { + target_channel = modulus_nvs_get_u8("wf_chan", 0); + } + if (modulus_wireless_espnow_is_enabled() && target_channel >= 1 && target_channel <= 13 && + !modulus_wireless_espnow_move_bridge_channel(target_channel)) { + ESP_LOGE(TAG, "Wi-Fi connect blocked: S3 did not accept channel %u", + (unsigned)target_channel); + return false; + } s_user_disconnect = false; taskENTER_CRITICAL(&s_wifi_mux); + s_wifi_requested = true; s_wifi_enabled = true; s_wifi_connecting = true; s_wifi_disc_reason = 0; @@ -1185,6 +1256,11 @@ bool modulus_wireless_wifi_connect_saved(void) bool modulus_wireless_wifi_disconnect(void) { + if (modulus_wireless_espnow_is_enabled() && + !modulus_wireless_espnow_move_bridge_channel(1)) { + ESP_LOGW(TAG, "S3 channel-1 command failed before Wi-Fi disconnect"); + return false; + } s_user_disconnect = true; taskENTER_CRITICAL(&s_wifi_mux); s_wifi_connected = false; @@ -1617,7 +1693,6 @@ static void wireless_zigbee_stop_hub(void) (void)modulus_wireless_zb_leave(); } s_zb_on = false; - modulus_nvs_set_u8("zigbee", 0); } void modulus_wireless_zigbee_disable(void) @@ -1626,6 +1701,7 @@ void modulus_wireless_zigbee_disable(void) * auto-rejoin intent so it stays off across reboots. (Sleep uses * wireless_zigbee_stop_hub, which preserves zb_auto for wake.) */ wireless_zigbee_stop_hub(); + modulus_nvs_set_u8("zigbee", 0); modulus_nvs_set_u8("zb_auto", 0); } diff --git a/firmware/tab5/components/modulus_zig/wireless_shim_802154.c b/firmware/tab5/components/modulus_zig/wireless_shim_802154.c index c622d96..e51f9b4 100644 --- a/firmware/tab5/components/modulus_zig/wireless_shim_802154.c +++ b/firmware/tab5/components/modulus_zig/wireless_shim_802154.c @@ -146,10 +146,44 @@ static void zb_load_nvs(void) } w++; } - if (w != s_zb_dev_n) { - s_zb_dev_n = w; - zb_save_nvs(); + bool registry_changed = w != s_zb_dev_n; + s_zb_dev_n = w; + + /* Repair Modulus-node endpoint rows saved by older builds. The presence + * of endpoint 242 plus one of our channel endpoints (10..13) on the same + * IEEE is the signature of one node, not several independent devices. */ + for (int base = 0; base < s_zb_dev_n; ++base) { + bool has_gp = false; + bool has_channel = false; + for (int i = base; i < s_zb_dev_n; ++i) { + if (strcmp(s_zb_devs[i].id, s_zb_devs[base].id) != 0) continue; + has_gp |= s_zb_devs[i].endpoint == 242; + has_channel |= s_zb_devs[i].endpoint >= 10 && s_zb_devs[i].endpoint <= 13; + } + if (!has_gp || !has_channel) continue; + + modulus_zb_device_t merged = s_zb_devs[base]; + for (int i = base; i < s_zb_dev_n; ++i) { + if (strcmp(s_zb_devs[i].id, merged.id) != 0) continue; + merged.caps |= s_zb_devs[i].caps; + if (merged.endpoint == 1 || merged.endpoint == 242) { + if (s_zb_devs[i].endpoint >= 10 && s_zb_devs[i].endpoint <= 13) + merged.endpoint = s_zb_devs[i].endpoint; + } + if (strncmp(merged.name, "Endpoint ", 9) == 0 && + strncmp(s_zb_devs[i].name, "Endpoint ", 9) != 0) + strncpy(merged.name, s_zb_devs[i].name, sizeof(merged.name) - 1); + } + s_zb_devs[base] = merged; + for (int i = s_zb_dev_n - 1; i > base; --i) { + if (strcmp(s_zb_devs[i].id, merged.id) != 0) continue; + for (int j = i; j + 1 < s_zb_dev_n; ++j) s_zb_devs[j] = s_zb_devs[j + 1]; + --s_zb_dev_n; + } + registry_changed = true; + ESP_LOGI(TAG, "Merged Modulus node %s endpoint rows", merged.id); } + if (registry_changed) zb_save_nvs(); } static void zb_save_nvs(void) @@ -223,6 +257,9 @@ static void zb_scan_note(const char *id, int8_t rssi) if (rssi > s_zb_scan[i].rssi) { s_zb_scan[i].rssi = rssi; } + if (s_zb_scan_active) { + s_zb_scan_deadline = xTaskGetTickCount() + pdMS_TO_TICKS(1000); + } taskEXIT_CRITICAL(&s_mux); return; } @@ -234,6 +271,11 @@ static void zb_scan_note(const char *id, int8_t rssi) snprintf(d->name, sizeof(d->name), "Dev %.8s", id); d->endpoint = 1; d->rssi = rssi; + if (s_zb_scan_active) { + /* Leave one second for interview/capability events, then close the + * permit window instead of making the user wait the full minute. */ + s_zb_scan_deadline = xTaskGetTickCount() + pdMS_TO_TICKS(1000); + } } taskEXIT_CRITICAL(&s_mux); } @@ -304,6 +346,7 @@ static void th_scan_finish(void) void modulus_wireless_802154_poll(void) { static bool loaded; + static TickType_t last_empty_refresh; if (!loaded) { zb_load_nvs(); th_load_nvs(); @@ -312,6 +355,14 @@ void modulus_wireless_802154_poll(void) modulus_zb_auto_poll(); /* also driven by always-on zb_auto task */ const TickType_t now = xTaskGetTickCount(); + /* Keep the P4 registry synchronized after either side boots. A stale local + * row can otherwise suppress the empty-list retry even though the Nano has + * newer addressing/capability data for the same IEEE device. */ + if (modulus_wireless_zb_joined() && + now - last_empty_refresh >= pdMS_TO_TICKS(5000)) { + last_empty_refresh = now; + (void)modulus_wireless_zb_get_devices(); + } if (s_zb_scan_active && now >= s_zb_scan_deadline) { zb_scan_finish(); } @@ -370,14 +421,57 @@ void modulus_wireless_zb_note_device_joined(uint16_t short_addr, const uint8_t i void modulus_wireless_zb_note_device_caps(uint16_t short_addr, uint8_t endpoint, uint8_t caps, uint16_t device_id) { - (void)device_id; + static uint16_t modulus_node_short; bool save = false; taskENTER_CRITICAL(&s_mux); + const uint8_t ep = endpoint ? endpoint : 1; + int found = -1; + int base = -1; for (int i = 0; i < s_zb_dev_n; i++) { - if (s_zb_devs[i].short_addr != short_addr || short_addr == 0) { - continue; + if (s_zb_devs[i].short_addr != short_addr || short_addr == 0) continue; + if (base < 0) base = i; + if (s_zb_devs[i].endpoint == ep) { found = i; break; } + } + + /* A Modulus node exposes one configuration endpoint, one endpoint per + * channel and Zigbee's reserved Green Power endpoint 242. They all have + * the same IEEE address and are one physical device. Keep one registry + * row so the M panel does not turn every channel into a separate device. */ + if (device_id == 0xFFF0 && base >= 0) { + modulus_node_short = short_addr; + found = base; + for (int i = s_zb_dev_n - 1; i >= 0; --i) { + if (i == base || s_zb_devs[i].short_addr != short_addr) continue; + for (int j = i; j + 1 < s_zb_dev_n; ++j) s_zb_devs[j] = s_zb_devs[j + 1]; + --s_zb_dev_n; + if (i < base) --base; + } + found = base; + s_zb_devs[found].endpoint = 1; + save = true; + } else if (modulus_node_short == short_addr && base >= 0) { + found = base; + if (ep != 242) { + s_zb_devs[found].caps |= caps; + if (s_zb_devs[found].endpoint == 1 && caps != 0) s_zb_devs[found].endpoint = ep; + save = true; } - s_zb_devs[i].endpoint = endpoint ? endpoint : 1; + taskEXIT_CRITICAL(&s_mux); + if (save) zb_save_nvs(); + return; + } + if (found < 0 && base >= 0 && s_zb_devs[base].endpoint == 1 && + s_zb_devs[base].caps == 0) { + found = base; + } else if (found < 0 && base >= 0 && s_zb_dev_n < MODULUS_ZB_MAX_DEVICES) { + found = s_zb_dev_n++; + s_zb_devs[found] = s_zb_devs[base]; + snprintf(s_zb_devs[found].name, sizeof(s_zb_devs[found].name), + "Endpoint %u", (unsigned)ep); + } + if (found >= 0) { + int i = found; + s_zb_devs[i].endpoint = ep; s_zb_devs[i].caps = caps; if (strncmp(s_zb_devs[i].name, "Dev ", 4) == 0) { const char *kind = (caps & ZIGBEE_CAP_LEVEL) ? "Light" @@ -392,7 +486,6 @@ void modulus_wireless_zb_note_device_caps(uint16_t short_addr, uint8_t endpoint, snprintf(s_zb_devs[i].name, sizeof(s_zb_devs[i].name), "%s %.6s", kind, id_copy); } save = true; - break; } taskEXIT_CRITICAL(&s_mux); if (save) { @@ -628,6 +721,10 @@ bool modulus_wireless_zigbee_scan_start(void) s_zb_scan_done = true; return false; } + /* Also enumerate the Nano's persistent neighbor table. Already-paired + * devices do not emit another DEVICE_ANNCE merely because permit-join was + * opened, so waiting for join events alone produces an empty scan. */ + (void)modulus_wireless_zb_get_devices(); return true; } diff --git a/firmware/tab5/components/modulus_zig/wireless_shim_espnow.c b/firmware/tab5/components/modulus_zig/wireless_shim_espnow.c index 085374d..3127240 100644 --- a/firmware/tab5/components/modulus_zig/wireless_shim_espnow.c +++ b/firmware/tab5/components/modulus_zig/wireless_shim_espnow.c @@ -27,6 +27,9 @@ static bool s_espnow_on; static uint32_t s_en_tx; static uint32_t s_en_rx; static bool s_bridge_ok; +static volatile TickType_t s_last_bridge_rx_tick; +static uint8_t s_configured_bridge_mac[6]; +static bool s_configured_bridge_mac_valid; static portMUX_TYPE s_en_mux = portMUX_INITIALIZER_UNLOCKED; static bool s_scan_active; @@ -40,14 +43,21 @@ static int s_scan_n; * announce where it parked, so probing only {en_chan,1,6,11} leaves a bridge on * ch3/4/5/... permanently undiscoverable. 13 x 250 ms fits inside the window. */ #define MODULUS_ESPNOW_CHANNEL_MAX 13 -#define ESPNOW_SCAN_WINDOW_MS 5000 +#define ESPNOW_SCAN_WINDOW_MS 8000 #define ESPNOW_SCAN_DWELL_MS 250 +#define ESPNOW_SCAN_SWEEPS 2 +#define BRIDGE_MOD_CHANNEL_LEN 7 /* Channel the sweep is probing right now (0 = use configured en_chan). */ static uint8_t s_scan_probe_ch; /* RAM mirror of en_chan — never read NVS from SDIO RX / espnow_stack_evt (lock abort). */ static uint8_t s_bridge_ch_cached; +static bool s_applied_valid; + +static bool mac_is_broadcast(const uint8_t mac[6]); +static bool espnow_add_peer_wait(const uint8_t mac[6], uint8_t ch, bool encrypt, + uint32_t timeout_ms); static uint8_t espnow_channel_from_nvs(void) { @@ -72,13 +82,64 @@ void modulus_wireless_espnow_set_channel(uint8_t channel) modulus_nvs_set_u8("en_chan", (uint8_t)(channel - 1)); } +bool modulus_wireless_espnow_move_bridge_channel(uint8_t channel) +{ + if (channel < 1 || channel > MODULUS_ESPNOW_CHANNEL_MAX) { + return false; + } + const uint8_t old_channel = modulus_wireless_espnow_channel(); + if (channel == old_channel) { + if (s_espnow_on && modulus_espnow_stack_inited()) { + (void)modulus_espnow_stack_lock_channel(channel); + } + return true; + } + + uint8_t peer[6]; + char mac[20]; + modulus_wireless_espnow_peer_mac_str(mac, sizeof(mac)); + const bool have_peer = modulus_wireless_espnow_parse_mac(mac, peer) && + !mac_is_broadcast(peer); + if (s_espnow_on && have_peer) { + /* The command must leave on the old shared channel. S3 replies there + * before it applies the new channel. A MAC ACK proves the command + * reached the bridge; the short delay lets its application ACK and + * deferred NVS update complete before C6 moves. */ + if (!modulus_espnow_stack_lock_channel(old_channel) || + !espnow_add_peer_wait(peer, old_channel, false, 600)) { + modulus_espnow_debug_event("chan", "cannot prepare S3 move ch%u->ch%u", + (unsigned)old_channel, (unsigned)channel); + return false; + } + uint8_t command[BRIDGE_MOD_CHANNEL_LEN] = {'M','O','D','_','C','H', channel}; + if (!modulus_espnow_stack_send_discovery(peer, command, sizeof(command))) { + modulus_espnow_debug_event("chan", "S3 move ch%u->ch%u not acknowledged", + (unsigned)old_channel, (unsigned)channel); + return false; + } + vTaskDelay(pdMS_TO_TICKS(250)); + } + + modulus_wireless_espnow_set_channel(channel); + if (s_espnow_on && modulus_espnow_stack_inited()) { + if (!modulus_espnow_stack_lock_channel(channel)) { + return false; + } + if (have_peer) { + (void)espnow_add_peer_wait(peer, channel, false, 600); + } + } + s_applied_valid = false; + s_bridge_ok = false; + modulus_espnow_debug_event("chan", "Tab5 master channel %u", (unsigned)channel); + return true; +} + static SemaphoreHandle_t s_peer_sem; static volatile bool s_peer_wait_ok; static volatile bool s_peer_wait_armed; static bool espnow_add_peer_wait(const uint8_t mac[6], uint8_t ch, bool encrypt, uint32_t timeout_ms); -static bool espnow_ping_locate_peer(const uint8_t peer[6], uint8_t *found_ch); - static bool espnow_apply_bridge_peer_ex(bool locate_if_dark); /** Configured channel only — safe for boot / SDIO (no channel sweep). */ static bool espnow_apply_bridge_peer(void); @@ -239,7 +300,6 @@ static void espnow_scan_finish_radio(void) static uint8_t s_applied_mac[6]; static uint8_t s_applied_ch; -static bool s_applied_valid; void modulus_wireless_espnow_check_channel_conflict(void) { @@ -254,24 +314,17 @@ void modulus_wireless_espnow_check_channel_conflict(void) if (ap.primary == bridge_ch) { return; } - /* This used to overwrite en_chan with the AP channel. That is unrecoverable: - * the S3 bridge takes a channel only from its own USB console ("channel N") - * and has no over-the-air handoff, so it can never follow us — and en_chan - * is the only record of where the bridge actually lives. Persisting the AP - * channel destroyed it for good (NVS survives reboot) and every unicast - * then died with reason=0x01. One radio cannot serve two channels; say so - * and leave the bridge channel alone. */ - static uint8_t s_warned_ap_ch; - if (s_warned_ap_ch != ap.primary) { - s_warned_ap_ch = ap.primary; - ESP_LOGW(TAG, "Wi-Fi AP is ch%u but ESP-NOW bridge is ch%u — one radio " - "cannot serve both. Set the S3 to ch%u (console 'channel %u') " - "or join an AP on ch%u.", - (unsigned)ap.primary, (unsigned)bridge_ch, - (unsigned)ap.primary, (unsigned)ap.primary, (unsigned)bridge_ch); - } - modulus_espnow_debug_event("chan", "AP ch%u != bridge ch%u", + /* A connected STA owns the radio channel. Normally the master handoff ran + * before association. Reaching this branch means the AP channel changed or + * was unknown; persist reality and report the missed handoff. The S3 never + * hunts, so the UI must not claim that it will recover by itself. */ + modulus_wireless_espnow_set_channel(ap.primary); + s_applied_valid = false; + s_bridge_ok = false; + modulus_espnow_debug_event("chan", "AP moved to ch%u; S3 remained ch%u", (unsigned)ap.primary, (unsigned)bridge_ch); + ESP_LOGW(TAG, "Wi-Fi AP is channel %u but S3 handoff from channel %u was missed", + (unsigned)ap.primary, (unsigned)bridge_ch); } uint8_t modulus_wireless_espnow_channel(void) @@ -322,6 +375,16 @@ static int espnow_channel_candidates(uint8_t *out, int cap) static bool espnow_verify_peer_air(const uint8_t mac[6], uint8_t ch) { static const uint8_t k_probe[] = "MOD_PROBE"; + /* A peer's configured channel does not retune the C6 radio. Explicitly + * lock the radio before probing, otherwise a locally accepted peer can be + * tested on whatever channel a prior scan left behind. A connected STA + * owns the PHY channel; check_channel_conflict updates en_chan to it. */ + modulus_wireless_espnow_check_channel_conflict(); + ch = modulus_wireless_espnow_channel(); + if (!modulus_wireless_wifi_is_connected() && + !modulus_espnow_stack_lock_channel(ch)) { + return false; + } if (!espnow_add_peer_wait(mac, ch, false, 600)) { return false; } @@ -374,14 +437,21 @@ static void espnow_stack_evt(uint8_t evt, const uint8_t *payload, uint16_t len, case ESPNOW_EVT_RECV: if (payload && len >= 6) { scan_note_peer(payload, 0); + if (s_configured_bridge_mac_valid && + memcmp(payload, s_configured_bridge_mac, 6) == 0) { + s_last_bridge_rx_tick = xTaskGetTickCount(); + s_bridge_ok = true; + } } break; case ESPNOW_EVT_PEER_OK: if (payload && len >= 6) { - s_bridge_ok = true; char mac[20]; modulus_wireless_espnow_format_mac(payload, mac, sizeof(mac)); - modulus_espnow_debug_event("bridge", "peer ok %s", mac); + /* PEER_OK only confirms that the C6 accepted the peer in its local + * ESP-NOW table. It says nothing about whether the S3 is powered + * or reachable, so it must never drive the UI's live state. */ + modulus_espnow_debug_event("bridge", "peer configured %s", mac); if (s_peer_wait_armed) { s_peer_wait_ok = true; if (s_peer_sem) { @@ -494,9 +564,12 @@ static bool espnow_apply_bridge_peer_ex(bool locate_if_dark) modulus_wireless_espnow_peer_mac_str(mac_str, sizeof(mac_str)); if (!modulus_wireless_espnow_parse_mac(mac_str, mac) || mac_is_broadcast(mac)) { s_bridge_ok = false; + s_configured_bridge_mac_valid = false; s_applied_valid = false; return false; } + memcpy(s_configured_bridge_mac, mac, sizeof(s_configured_bridge_mac)); + s_configured_bridge_mac_valid = true; modulus_espnow_stack_register(); if (!modulus_espnow_stack_inited()) { @@ -519,23 +592,25 @@ static bool espnow_apply_bridge_peer_ex(bool locate_if_dark) modulus_espnow_debug_event("bridge", "apply peer %s ch%u", mac_str, (unsigned)ch); s_bridge_ok = false; - uint8_t live_ch = ch; - if (!locate_if_dark) { - /* LVGL hal_wireless::espnow::enable — add_peer on configured channel only. */ + /* Adding a peer only configures the C6. Require a real MAC-layer ACK + * before advertising the S3 as live in the status bar. */ if (!espnow_add_peer_wait(mac, ch, encrypt, 600)) { return false; } - ESP_LOGI(TAG, "Bridge peer %s ch%u (added)", mac_str, (unsigned)ch); - } else if (!espnow_verify_peer_air(mac, ch) && - !espnow_ping_locate_peer(mac, &live_ch)) { - modulus_espnow_debug_event("bridge", "peer dark on all channels"); - ESP_LOGW(TAG, "Bridge %s not reachable (check S3 power + channel)", mac_str); + if (!modulus_espnow_stack_send_discovery( + mac, (const uint8_t *)"MOD_PROBE", sizeof("MOD_PROBE") - 1)) { + modulus_espnow_debug_event("bridge", "configured peer did not answer"); + ESP_LOGW(TAG, "Bridge peer %s ch%u configured but not reachable", + mac_str, (unsigned)ch); + return false; + } + ESP_LOGI(TAG, "Bridge peer %s ch%u (verified)", mac_str, (unsigned)ch); + } else if (!espnow_verify_peer_air(mac, ch)) { + modulus_espnow_debug_event("bridge", "peer dark on fixed ch%u", (unsigned)ch); + ESP_LOGW(TAG, "Bridge %s not reachable on fixed channel %u", mac_str, + (unsigned)ch); return false; - } else if (live_ch != ch && live_ch >= 1 && live_ch <= 13) { - ch = live_ch; - modulus_wireless_espnow_set_channel(ch); - ESP_LOGI(TAG, "Bridge peer %s ch%u (located)", mac_str, (unsigned)ch); } else { ESP_LOGI(TAG, "Bridge peer %s ch%u", mac_str, (unsigned)ch); } @@ -640,7 +715,12 @@ bool modulus_wireless_espnow_is_enabled(void) bool modulus_wireless_espnow_bridge_ready(void) { - return s_espnow_on && s_bridge_ok; + if (!s_espnow_on || !s_bridge_ok || s_last_bridge_rx_tick == 0) { + return false; + } + /* S3 emits a heartbeat every 2 s. Three missed heartbeats are enough to + * remove the green UI state; an open transport alone is not connectivity. */ + return (xTaskGetTickCount() - s_last_bridge_rx_tick) <= pdMS_TO_TICKS(6000); } void modulus_wireless_espnow_peer_mac_str(char *buf, size_t len) @@ -828,7 +908,7 @@ const char *modulus_wireless_espnow_bridge_text(void) if (!modulus_wireless_espnow_parse_mac(mac, raw) || mac_is_broadcast(raw)) { return "No bridge peer"; } - if (s_bridge_ok) { + if (modulus_wireless_espnow_bridge_ready()) { if (espnow_cnc_transport_selected() && modulus_espnow_transport_is_open()) { snprintf(buf, sizeof(buf), "%.17s CNC live", mac); } else if (espnow_cnc_transport_selected()) { @@ -924,6 +1004,13 @@ static void espnow_scan_worker(void *arg) tab5_pi4ioe_wait_c6_sdio_ready(); + /* A disabled Wi-Fi radio can still inherit an unfinished/saved STA connect + * from the hosted C6 startup. Stop it before hopping channels for ESP-NOW. */ + if (modulus_nvs_get_u8("wifi", 0) == 0) { + (void)esp_wifi_disconnect(); + vTaskDelay(pdMS_TO_TICKS(150)); + } + if (ch < 1 || ch > 13) { taskENTER_CRITICAL(&s_en_mux); strncpy(s_scan_err, "Invalid channel", sizeof(s_scan_err) - 1); @@ -966,15 +1053,26 @@ static void espnow_scan_worker(void *arg) sweep_n = espnow_channel_candidates(sweep, (int)sizeof(sweep)); } - for (int i = 0; i < sweep_n; i++) { - s_scan_probe_ch = sweep[i]; - modulus_espnow_debug_event("scan", "probe ch%u (%d/%d)", (unsigned)sweep[i], i + 1, - sweep_n); - /* Probe kick is best-effort — window stays open until poll_scan deadline. */ - if (!modulus_espnow_stack_probe(sweep[i])) { - modulus_espnow_debug_event("scan", "probe ch%u send fail", (unsigned)sweep[i]); + const int probe_n = modulus_wireless_wifi_is_connected() + ? (ESPNOW_SCAN_WINDOW_MS / ESPNOW_SCAN_DWELL_MS) + : (sweep_n * ESPNOW_SCAN_SWEEPS); + for (int i = 0; i < probe_n; i++) { + const int si = i % sweep_n; + s_scan_probe_ch = sweep[si]; + modulus_espnow_debug_event("scan", "probe ch%u (%d/%d)", (unsigned)sweep[si], i + 1, + probe_n); + /* The S3 hops independently. One packet immediately after changing + * channel can always land between two of its dwell windows, so keep + * this channel active and emit three probes across the whole dwell. */ + bool sent = false; + for (int attempt = 0; attempt < 3; attempt++) { + sent = modulus_espnow_stack_probe(sweep[si]) || sent; + vTaskDelay(pdMS_TO_TICKS(75)); + } + if (!sent) { + modulus_espnow_debug_event("scan", "probe ch%u send fail", (unsigned)sweep[si]); } - vTaskDelay(pdMS_TO_TICKS(ESPNOW_SCAN_DWELL_MS)); + vTaskDelay(pdMS_TO_TICKS(ESPNOW_SCAN_DWELL_MS - 225)); } s_scan_probe_ch = 0; @@ -1044,7 +1142,9 @@ bool modulus_wireless_espnow_scan_start(void) s_scan_done = false; s_scan_n = 0; s_scan_err[0] = '\0'; - /* Must outlast the full 13-channel sweep (13 x ESPNOW_SCAN_DWELL_MS). */ + /* Covers two complete sweeps. The S3 hops independently at a different + * cadence, so a second pass prevents an unlucky phase from missing every + * probe during the only pass. */ s_scan_deadline = xTaskGetTickCount() + pdMS_TO_TICKS(ESPNOW_SCAN_WINDOW_MS); taskEXIT_CRITICAL(&s_en_mux); @@ -1116,10 +1216,11 @@ bool modulus_wireless_espnow_select_scan_peer(int idx) * saved MAC is unreachable. A STA link owns the PHY channel, so skip then. */ if (peer.channel >= 1 && peer.channel <= 13 && !modulus_wireless_wifi_is_connected()) { if (modulus_wireless_espnow_channel() != peer.channel) { - modulus_wireless_espnow_set_channel(peer.channel); + if (!modulus_wireless_espnow_move_bridge_channel(peer.channel)) { + return false; + } s_applied_valid = false; s_bridge_ok = false; - (void)modulus_espnow_stack_lock_channel(peer.channel); ESP_LOGI(TAG, "ESP-NOW channel follows discovered peer ch%u", (unsigned)peer.channel); } @@ -1249,52 +1350,6 @@ static bool boot_reconnect_wanted(void) return modulus_nvs_get_u8("cnc_conn", 4) == 0; } -/* en_chan drifts (manual change, bridge reconfigured) and a saved peer only - * answers on its own channel, so a failed probe means "wrong channel" at least - * as often as it means "absent". Retry across the channels before declaring the - * bridge dark. Returns the channel that answered. */ -static bool espnow_ping_locate_peer(const uint8_t peer[6], uint8_t *found_ch) -{ - /* MOD_PROBE, not a private "MOD_PING": the S3 only special-cases PROBE / - * HALT and forwards every other payload straight to grblHAL, so a bespoke - * liveness word gets injected into the CNC serial stream as garbage. PROBE - * also makes the bridge re-learn our MAC, which the S3->Tab5 path needs. */ - static const uint8_t k_probe[] = "MOD_PROBE"; - uint8_t sweep[MODULUS_ESPNOW_CHANNEL_MAX]; - int n = 1; - sweep[0] = modulus_wireless_espnow_channel(); - if (!modulus_wireless_wifi_is_connected()) { - n = espnow_channel_candidates(sweep, (int)sizeof(sweep)); - /* ponytail: 13-ch sweep when S3 is dark hammers SDIO → 0x107 / Core abort */ - if (n > 2) { - n = 2; - } - } - int dark_streak = 0; - for (int i = 0; i < n; i++) { - if (!modulus_c6_sdio_ready() || !modulus_wireless_transport_up()) { - ESP_LOGW(TAG, "SDIO down — abort ESP-NOW channel locate"); - return false; - } - if (!espnow_add_peer_wait(peer, sweep[i], false, 600)) { - continue; - } - if (modulus_espnow_stack_send_discovery(peer, k_probe, sizeof(k_probe) - 1)) { - *found_ch = sweep[i]; - return true; - } - if (modulus_espnow_stack_last_send_fail_reason() == 0x01) { - if (++dark_streak >= 2) { - return false; - } - } else { - dark_streak = 0; - } - vTaskDelay(pdMS_TO_TICKS(200)); - } - return false; -} - static bool boot_reconnect_once(const char *phase) { if (!boot_reconnect_wanted()) { @@ -1311,26 +1366,84 @@ static bool boot_reconnect_once(const char *phase) return false; } } - /* Radio + C6 ESP-NOW INIT only — peer add / transport open on user Connect. */ - modulus_espnow_debug_event("boot", "reconnect (%s): radio up, peer deferred", phase); + if (modulus_espnow_transport_is_open()) { + return true; + } + + /* The S3 and Tab5 commonly start from the same 24 V rail. The C6 may be + * ready several seconds before the S3 bridge, so an added peer is not proof + * of a live bridge. Verify it over the air (and locate its channel if the + * saved channel drifted) before opening the CNC transport. */ + if (!espnow_apply_bridge_peer_ex(true)) { + modulus_espnow_debug_event("boot", "reconnect (%s): bridge not ready", phase); + return false; + } + + char mac[20]; + modulus_wireless_espnow_peer_mac_str(mac, sizeof(mac)); + const uint8_t ch = modulus_wireless_espnow_channel(); + modulus_espnow_debug_event("boot", "reconnect (%s): light start ch%u", phase, + (unsigned)ch); + if (!modulus_espnow_transport_start(mac, ch, false)) { + modulus_espnow_debug_event("boot", "reconnect (%s): transport start failed", phase); + return false; + } + modulus_zig_transport_espnow_attach(); + modulus_espnow_debug_event("boot", "reconnect (%s): CNC transport open", phase); return true; } -/* Slow cadence — probe is enough; avoid thrashing reinit. */ +/* Slow cadence avoids SDIO/radio thrashing while still covering a bridge that + * boots late, loses power independently, or restarts after an OTA update. */ + +static volatile bool s_boot_reconnect_task_started; static void deferred_boot_reconnect_task(void *arg) { (void)arg; - static const uint16_t k_delays_ms[] = {3000, 8000, 15000}; - for (int attempt = 0; attempt < (int)(sizeof(k_delays_ms) / sizeof(k_delays_ms[0])); - attempt++) { - vTaskDelay(pdMS_TO_TICKS(k_delays_ms[attempt])); - modulus_espnow_debug_event("boot", "reconnect retry %d/%d", attempt + 1, - (int)(sizeof(k_delays_ms) / sizeof(k_delays_ms[0]))); - if (boot_reconnect_once("retry")) { - break; + uint32_t attempt = 0; + uint8_t liveness_misses = 0; + vTaskDelay(pdMS_TO_TICKS(3000)); + while (boot_reconnect_wanted()) { + if (modulus_espnow_transport_is_open()) { + uint8_t peer[6]; + char mac[20]; + modulus_wireless_espnow_peer_mac_str(mac, sizeof(mac)); + const bool parsed = modulus_wireless_espnow_parse_mac(mac, peer) && + !mac_is_broadcast(peer); + const bool alive = parsed && espnow_radio_usable() && + espnow_verify_peer_air( + peer, modulus_wireless_espnow_channel()); + const TickType_t now = xTaskGetTickCount(); + const bool recent_rx = s_last_bridge_rx_tick != 0 && + (now - s_last_bridge_rx_tick) <= pdMS_TO_TICKS(30000); + if (alive || recent_rx) { + liveness_misses = 0; + s_bridge_ok = true; + } else if (++liveness_misses >= 2) { + /* Background MAC-layer ACK misses are not proof that the CNC + * session is dead. Closing the transport here caused an idle + * pendant to disconnect until the next operator action. Keep + * the transparent CNC transport open; real traffic still + * closes it through the normal TX-failure path. */ + modulus_espnow_debug_event("boot", "S3 liveness not confirmed - keep transport open"); + ESP_LOGW(TAG, "S3 bridge did not answer two liveness probes; keeping CNC transport open"); + liveness_misses = 0; + s_bridge_ok = false; + s_applied_valid = false; + } + } else { + liveness_misses = 0; + attempt++; + modulus_espnow_debug_event("boot", "automatic reconnect attempt %lu", + (unsigned long)attempt); + (void)boot_reconnect_once("background"); } + /* Keep watching after success so an independently rebooted S3 returns + * without requiring Settings > Apply & connect. */ + vTaskDelay(pdMS_TO_TICKS(modulus_espnow_transport_is_open() ? 15000 : 10000)); } + s_boot_reconnect_task_started = false; vTaskDelete(NULL); } @@ -1341,13 +1454,14 @@ static void schedule_bridge_reapply(void) void modulus_wireless_espnow_boot_reconnect(void) { - if (boot_reconnect_once("boot")) { + (void)boot_reconnect_once("boot"); + if (!boot_reconnect_wanted() || s_boot_reconnect_task_started) { return; } - static bool started; - if (started) { - return; + s_boot_reconnect_task_started = true; + if (xTaskCreate(deferred_boot_reconnect_task, "espnow_boot", 4096, NULL, 3, NULL) != + pdPASS) { + s_boot_reconnect_task_started = false; + modulus_espnow_debug_event("boot", "automatic reconnect task create failed"); } - started = true; - xTaskCreate(deferred_boot_reconnect_task, "espnow_boot", 4096, NULL, 3, NULL); } diff --git a/firmware/tab5/components/modulus_zig/zb_uart_host.c b/firmware/tab5/components/modulus_zig/zb_uart_host.c index 08ff21c..a4e67c9 100644 --- a/firmware/tab5/components/modulus_zig/zb_uart_host.c +++ b/firmware/tab5/components/modulus_zig/zb_uart_host.c @@ -26,7 +26,7 @@ static const char *TAG = "zb_uart"; #define LINK_RX_BUF 2048 #define LINK_SUPERV_US (70LL * 1000 * 1000) #define LINK_OFFLINE_US (3LL * LINK_SUPERV_US) -#define CMD_ACK_TIMEOUT_MS 250 +#define CMD_ACK_TIMEOUT_MS 1500 #define CMD_MAX_RETRIES 3 #define CMD_QUEUE_DEPTH 16 @@ -55,6 +55,7 @@ static volatile uint8_t s_wait_seq; static volatile uint8_t s_ack_seq; static volatile bool s_ack_nak; static volatile uint8_t s_ack_reason; +static volatile bool s_ota_mode; static uint8_t crc8(const uint8_t *p, uint16_t n) { @@ -163,6 +164,10 @@ static bool enqueue_cmd(const uint8_t *cmd_payload, uint16_t len, bool wait_resu return false; } + if (s_ota_mode && !wait_result) { + return false; + } + zb_cmd_job_t job = {}; job.len = len; memcpy(job.payload, cmd_payload, len); @@ -182,7 +187,10 @@ static bool enqueue_cmd(const uint8_t *cmd_payload, uint16_t len, bool wait_resu job.token = s_sync_token; } - if (xQueueSend(s_cmd_q, &job, pdMS_TO_TICKS(50)) != pdTRUE) { + const BaseType_t queued = wait_result + ? xQueueSendToFront(s_cmd_q, &job, pdMS_TO_TICKS(50)) + : xQueueSendToBack(s_cmd_q, &job, pdMS_TO_TICKS(50)); + if (queued != pdTRUE) { ESP_LOGW(TAG, "cmd queue full (drop 0x%02x)", cmd_payload[0]); if (wait_result) { s_sync_busy = false; @@ -218,6 +226,11 @@ bool modulus_zb_uart_send_cmd_sync(const uint8_t *cmd_payload, uint16_t len) return enqueue_cmd(cmd_payload, len, true); } +void modulus_zb_uart_set_ota_mode(bool enabled) +{ + s_ota_mode = enabled; +} + bool modulus_zb_uart_ready(void) { return s_inited && s_last_rx_us >= 0 && @@ -239,7 +252,13 @@ static void rx_task(void *arg) uint8_t chunk[64]; for (;;) { - const int n = uart_read_bytes(LINK_UART, chunk, sizeof(chunk), pdMS_TO_TICKS(250)); + int n = uart_read_bytes(LINK_UART, chunk, 1, portMAX_DELAY); + size_t waiting = 0; + if (n == 1 && uart_get_buffered_data_len(LINK_UART, &waiting) == ESP_OK && waiting) { + const size_t take = waiting < sizeof(chunk) - 1 ? waiting : sizeof(chunk) - 1; + const int extra = uart_read_bytes(LINK_UART, chunk + 1, take, 0); + if (extra > 0) n += extra; + } for (int i = 0; i < n; i++) { const uint8_t b = chunk[i]; switch (st) { diff --git a/firmware/tab5/dependencies.lock b/firmware/tab5/dependencies.lock index d91227f..3218fd2 100644 --- a/firmware/tab5/dependencies.lock +++ b/firmware/tab5/dependencies.lock @@ -48,7 +48,7 @@ dependencies: type: service version: 1.1.0 espressif/bmi270_sensor: - component_hash: fac23a5ecd2effdac0fb0705988d853745b8ffb99a384f87a307b3b25d15d432 + component_hash: 10f18ccc44bc71b09c0680c003c24cfb273905b079314efe1095e8ea9d714147 dependencies: - name: espressif/cmake_utilities registry_url: https://components.espressif.com @@ -64,7 +64,7 @@ dependencies: source: registry_url: https://components.espressif.com/ type: service - version: 0.1.1 + version: 0.1.2 espressif/cmake_utilities: component_hash: 351350613ceafba240b761b4ea991e0f231ac7a9f59a9ee901f751bddc0bb18f dependencies: @@ -75,6 +75,20 @@ dependencies: registry_url: https://components.espressif.com type: service version: 0.5.3 + espressif/eppp_link: + component_hash: c1369b37f2b0b427b198ae14e07e7547d3da92b95fe4dc7f949e5de560b35f21 + dependencies: + - name: espressif/esp_serial_slave_link + registry_url: https://components.espressif.com + require: private + version: ^1.1.0 + - name: idf + require: private + version: '>=5.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.1.6 espressif/esp-dsp: component_hash: ed9ac175b14e0414399a2b33c525579414b5bad19a2a67b12ea212def96f9a98 dependencies: @@ -274,6 +288,16 @@ dependencies: registry_url: https://components.espressif.com type: service version: 0.0.9 + espressif/esp_serial_slave_link: + component_hash: ac1776806de0a6e371c84e87898bb983e19ce62aa7f1e2e5c4a3b0234a575d2c + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com + type: service + version: 1.1.2 espressif/esp_video: component_hash: ba8f4aa63ad9bda3cc18a3afe1b9ba5b39c7359a6ad79392a94e42ba8cf9e5f8 dependencies: @@ -318,11 +342,25 @@ dependencies: - esp32c61 version: 2.0.1 espressif/esp_wifi_remote: - dependencies: [] + component_hash: 50d3beaf95c0bc47d0fcf56752beaf3cbaae981fd8c3b74d6890b45d8be689fe + dependencies: + - name: espressif/esp_hosted + registry_url: https://components.espressif.com + require: private + rules: + - if: target in [esp32h2, esp32p4] + version: '>=2.11' + - name: espressif/wifi_remote_over_eppp + registry_url: https://components.espressif.com + require: private + version: '>=0.1' + - name: idf + require: private + version: '>=5.3' source: - path: components/espressif__esp_wifi_remote - type: local - version: 0.0.1 + registry_url: https://components.espressif.com/ + type: service + version: 1.6.4 espressif/i2c_bus: component_hash: 483112c1619c54c38d0b734fb25f1678733267858a77b7af2f48cc7db6dbbd84 dependencies: @@ -338,8 +376,16 @@ dependencies: type: service version: 1.5.2 espressif/m5stack_tab5: - component_hash: 215ca8dc3b7886fbb87b1b5502abc9b1bd055aa5191308ce85788102ab85de67 + component_hash: 7ff4f8bde01fa57ac317d22adc922ac4a0c29474e1485ebae17b68870fc7d86d dependencies: + - name: espressif/esp_codec_dev + registry_url: https://components.espressif.com + require: public + version: ~1.5 + - name: espressif/esp_video + registry_url: https://components.espressif.com + require: public + version: ~2.0 - name: espressif/bmi270 registry_url: https://components.espressif.com require: public @@ -356,14 +402,6 @@ dependencies: registry_url: https://components.espressif.com require: private version: '*' - - name: espressif/esp_codec_dev - registry_url: https://components.espressif.com - require: public - version: ~1.5 - - name: espressif/esp_video - registry_url: https://components.espressif.com - require: public - version: ~2.0 - name: espressif/esp_lcd_st7123 registry_url: https://components.espressif.com require: private @@ -392,7 +430,7 @@ dependencies: type: service targets: - esp32p4 - version: 1.2.0~1 + version: 1.3.0 espressif/sensor_hub: component_hash: ea30db551c34aee2862482abf8b3164dd3855ecb4b4659b1e6878455c9cb9e22 dependencies: @@ -475,10 +513,24 @@ dependencies: - esp32h4 - linux version: 2.4.2 + espressif/wifi_remote_over_eppp: + component_hash: 1fd6968af20f0b19e421f6b519b0a8caf81d689afc3783b564c28559e45f66ef + dependencies: + - name: espressif/eppp_link + registry_url: https://components.espressif.com + require: private + version: '>=0.1' + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com + type: service + version: 0.3.3 idf: source: type: idf - version: 6.2.0 + version: 6.0.1 lvgl/lvgl: component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae dependencies: [] @@ -497,6 +549,6 @@ direct_dependencies: - espressif/m5stack_tab5 - espressif/usb_host_msc - idf -manifest_hash: ba1e927513468756378eb2a0ffaeb83a38b4470a2301dec0cbe0083272b9b5b2 +manifest_hash: 54aa72736d909eb9c889ccf8f8143a83424749765ca0dc648e8dfcad64076a0e target: esp32p4 version: 3.0.0 diff --git a/firmware/tab5/main/idf_component.yml b/firmware/tab5/main/idf_component.yml index a90b81d..a90df8a 100644 --- a/firmware/tab5/main/idf_component.yml +++ b/firmware/tab5/main/idf_component.yml @@ -7,13 +7,12 @@ dependencies: idf: '>=6.0,<7.0' espressif/m5stack_tab5: '*' + # Keep the P4 host and C6 slave on the field-proven Modulus baseline. This + # version already provides the complete slave OTA API. espressif/esp_hosted: 2.11.4 - # IDF 6.0.1 ships esp_wifi/remote in-tree (weak stubs; esp_hosted supplies - # strong RPC). Local override is a no-op that satisfies the component name - # without pulling managed esp_wifi_remote (which expects missing idf_v6.x - # weak sources). Do not replace with registry 1.6.4 on this IDF. - espressif/esp_wifi_remote: - override_path: ../components/espressif__esp_wifi_remote + # Resolve ESP-Hosted's remote Wi-Fi dependency from the component registry; + # the IDF 6 compatibility patch script handles its generated weak sources. + espressif/esp_wifi_remote: 1.6.4 # 1.0.7: GCC 15 -Wignored-qualifiers on fn-ptr casts; patch_tab5_idf6_deps.ps1 # adds -Wno-error=ignored-qualifiers. Hold version until upstream fixes. chmorgan/esp-audio-player: 1.0.7 diff --git a/firmware/tab5/sdkconfig.defaults b/firmware/tab5/sdkconfig.defaults index 9cca3f9..97e4fb4 100644 --- a/firmware/tab5/sdkconfig.defaults +++ b/firmware/tab5/sdkconfig.defaults @@ -103,6 +103,8 @@ CONFIG_MODULUS_C6_BOOT_DELAY_MS=7000 # ESP-Hosted 2.11.4 — Tab5 SDIO2 pins CLK12 CMD13 D0=11 D1=10 D2=9 D3=8 RST=15 CONFIG_ESP_HOSTED_ENABLED=y +CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6=y +# CONFIG_ESP_HOSTED_CP_TARGET_ESP32H2 is not set CONFIG_ESP_HOSTED_P4_DEV_BOARD_NONE=y CONFIG_ESP_HOSTED_SDIO_HOST_INTERFACE=y CONFIG_ESP_HOSTED_IDF_SLAVE_TARGET="esp32c6" @@ -115,7 +117,8 @@ CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE=15 CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS=y CONFIG_ESP_HOSTED_SDIO_BUS_WIDTH=4 CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=40000 -# ESP-NOW scan bursts (DISCOVER events) need deeper host RX queues than default 20 +# ESP-NOW scan bursts (DISCOVER events) need deeper host RX queues than default 20. +# This exact configuration is field-proven with the pinned esp-hosted 2.11.4. CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE=48 CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE=32 CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1=13 diff --git a/modulus.ps1 b/modulus.ps1 new file mode 100644 index 0000000..a8a4c55 --- /dev/null +++ b/modulus.ps1 @@ -0,0 +1,461 @@ +$ErrorActionPreference = "Stop" + +$RepoRoot = $PSScriptRoot + +function Select-Menu { + param( + [string]$Title, + [string[]]$Items + ) + + Write-Host "" + Write-Host "==============================" + Write-Host $Title + Write-Host "==============================" + Write-Host "" + + for ($i = 0; $i -lt $Items.Count; $i++) { + Write-Host "[$($i + 1)] $($Items[$i])" + } + + Write-Host "[0] Back / Exit" + Write-Host "" + + while ($true) { + $choice = Read-Host "Select" + $number = 0 + + if ([int]::TryParse($choice, [ref]$number)) { + if ($number -ge 0 -and $number -le $Items.Count) { + return $number + } + } + + Write-Host "Invalid selection." + } +} + +function Get-SystemComPortNames { + [System.IO.Ports.SerialPort]::GetPortNames() +} + +function Get-ModulusComPorts { + # Win32_SerialPort alone misses some USB serial drivers (including CH340). + # Prefer present Plug-and-Play devices, then merge the other Windows sources. + $byPort = @{} + function Add-Port { + param([string]$Port, [string]$Name, [string]$DeviceId) + if ($Port -notmatch '^COM\d+$') { return } + $Port = $Port.ToUpperInvariant() + if (-not $byPort.ContainsKey($Port)) { + $byPort[$Port] = [PSCustomObject]@{ + Port = $Port + Name = $(if ($Name) { $Name } else { 'Serial port' }) + PNPDeviceID = $DeviceId + IsEspressif = $DeviceId -match 'VID_303A' + } + } elseif ($DeviceId -match 'VID_303A') { + $byPort[$Port].IsEspressif = $true + } + } + + try { + $devices = @(Get-PnpDevice -PresentOnly -Class Ports -ErrorAction Stop) + foreach ($device in $devices) { + if ($device.FriendlyName -match '\((COM\d+)\)\s*$') { + Add-Port $Matches[1] $device.FriendlyName $device.InstanceId + } + } + } catch { + Write-Verbose "Plug-and-Play port query unavailable: $($_.Exception.Message)" + # Also works when the PnpDevice PowerShell module is not installed. + try { + $devices = @(Get-CimInstance -ClassName Win32_PnPEntity -Filter "PNPClass = 'Ports' AND Present = TRUE" -ErrorAction Stop) + foreach ($device in $devices) { + if ($device.Name -match '\((COM\d+)\)\s*$') { + Add-Port $Matches[1] $device.Name $device.PNPDeviceID + } + } + } catch { + Write-Verbose "CIM Plug-and-Play query unavailable: $($_.Exception.Message)" + } + } + + try { + foreach ($device in @(Get-CimInstance -ClassName Win32_SerialPort -ErrorAction Stop)) { + Add-Port $device.DeviceID $device.Name $device.PNPDeviceID + } + } catch { + Write-Verbose "Legacy serial port query unavailable: $($_.Exception.Message)" + } + + try { + foreach ($portName in @(Get-SystemComPortNames)) { + Add-Port $portName '' '' + } + } catch { + Write-Verbose "System serial port query unavailable: $($_.Exception.Message)" + } + + $byPort.Values | Sort-Object { [int]($_.Port.Substring(3)) } +} + +function Select-ComPort { + + $ports = @(Get-ModulusComPorts) + + if ($ports.Count -eq 0) { + throw "No COM ports found." + } + + Write-Host "" + Write-Host "Available COM ports:" + Write-Host "" + + for ($i = 0; $i -lt $ports.Count; $i++) { + + $tag = "" + + if ($ports[$i].IsEspressif) { + $tag = " [ESPRESSIF]" + } + + Write-Host "[$($i + 1)] $($ports[$i].Port) $($ports[$i].Name)$tag" + } + + Write-Host "" + + while ($true) { + $choice = Read-Host "Select COM port" + $number = 0 + + if ([int]::TryParse($choice, [ref]$number)) { + if ($number -ge 1 -and $number -le $ports.Count) { + return $ports[$number - 1].Port + } + } + + Write-Host "Invalid selection." + } +} + +function Ensure-Idf { + + if (-not $env:IDF_PATH) { + $env:IDF_PATH = "C:\Espressif\v6.0.1\esp-idf" + } + + $export = Join-Path $env:IDF_PATH "export.ps1" + + if (-not (Test-Path $export)) { + throw "ESP-IDF not found: $export" + } + + & $export | Out-Null +} + +function Flash-ExistingBuild { + param( + [string]$ProjectPath, + [string]$Chip + ) + + $build = Join-Path $ProjectPath "build" + $flashArgs = Join-Path $build "flash_args" + + if (-not (Test-Path $build)) { + throw "No build directory found: $build" + } + + if (-not (Test-Path $flashArgs)) { + throw "flash_args not found. Build the project first." + } + + $port = Select-ComPort + + Ensure-Idf + + Write-Host "" + Write-Host "==============================" + Write-Host "FLASH" + Write-Host "==============================" + Write-Host "Chip : $Chip" + Write-Host "Port : $port" + Write-Host "Build: $build" + Write-Host "" + + Push-Location $build + + try { + + python -m esptool ` + --chip $Chip ` + -p $port ` + -b 460800 ` + --before default-reset ` + --after hard-reset ` + write-flash ` + "@flash_args" + + if ($LASTEXITCODE -ne 0) { + throw "Flash failed." + } + } + finally { + Pop-Location + } +} + +function Build-S3 { + + Write-Host "" + Write-Host "==> Building S3 / XIAO" + + & "$RepoRoot\scripts\build_s3_bridge.ps1" -Board xiao +} + +function Build-Tab5 { + + Write-Host "" + Write-Host "==> Building Tab5 / P4" + + & "$RepoRoot\scripts\build_tab5.ps1" +} + +function Build-H2 { + + Write-Host "" + Write-Host "==> Building NanoH2" + + Ensure-Idf + + $project = "$RepoRoot\firmware\nanoh2" + + Push-Location $project + + try { + + if (-not (Test-Path "sdkconfig")) { + idf.py set-target esp32h2 + } + + idf.py build + } + finally { + Pop-Location + } +} + +function Build-C6 { + + Write-Host "" + Write-Host "==> Building Tab5 C6" + + & "$RepoRoot\scripts\build_tab5_c6_modulus.ps1" +} + +function Build-ZigbeeNode { + + Write-Host "" + Write-Host "==> Building Modulus Zigbee Node" + + Ensure-Idf + $project = "$RepoRoot\firmware\modulus-zigbee-node" + + Push-Location $project + try { + if (-not (Test-Path "build-node")) { + idf.py -B build-node set-target esp32c6 + } + idf.py -B build-node build + } + finally { + Pop-Location + } +} + +function Clean-Project { + param( + [string]$ProjectPath + ) + + Ensure-Idf + + Push-Location $ProjectPath + + try { + idf.py fullclean + } + finally { + Pop-Location + } +} + +function Monitor-Project { + param( + [string]$ProjectPath + ) + + $port = Select-ComPort + + Ensure-Idf + + Push-Location $ProjectPath + + try { + idf.py -p $port monitor + } + finally { + Pop-Location + } +} + +while ($true) { + + $action = Select-Menu ` + "Modulus Firmware Tool" ` + @( + "Build", + "Flash", + "Monitor", + "Clean" + ) + + if ($action -eq 0) { + break + } + + $device = Select-Menu ` + "Select device" ` + @( + "S3 / XIAO", + "Tab5 / P4", + "NanoH2", + "Tab5 C6", + "Modulus Zigbee Node / C6" + ) + + if ($device -eq 0) { + continue + } + + try { + + switch ($action) { + + 1 { + switch ($device) { + 1 { Build-S3 } + 2 { Build-Tab5 } + 3 { Build-H2 } + 4 { Build-C6 } + 5 { Build-ZigbeeNode } + } + } + + 2 { + switch ($device) { + + 1 { + Flash-ExistingBuild ` + "$RepoRoot\firmware\s3-bridge" ` + "esp32s3" + } + + 2 { + Flash-ExistingBuild ` + "$RepoRoot\firmware\tab5" ` + "esp32p4" + } + + 3 { + Flash-ExistingBuild ` + "$RepoRoot\firmware\nanoh2" ` + "esp32h2" + } + + 4 { + $port = Select-ComPort + & "$RepoRoot\scripts\build_tab5_c6_modulus.ps1" -Action flash -Port $port + } + + 5 { + $port = Select-ComPort + Ensure-Idf + Push-Location "$RepoRoot\firmware\modulus-zigbee-node" + try { idf.py -B build-node -p $port flash } + finally { Pop-Location } + } + } + } + + 3 { + switch ($device) { + + 1 { + Monitor-Project "$RepoRoot\firmware\s3-bridge" + } + + 2 { + Monitor-Project "$RepoRoot\firmware\tab5" + } + + 3 { + Monitor-Project "$RepoRoot\firmware\nanoh2" + } + + 4 { + $port = Select-ComPort + & "$RepoRoot\scripts\build_tab5_c6_modulus.ps1" -Action monitor -Port $port + } + + 5 { + $port = Select-ComPort + Ensure-Idf + Push-Location "$RepoRoot\firmware\modulus-zigbee-node" + try { idf.py -B build-node -p $port monitor } + finally { Pop-Location } + } + } + } + + 4 { + switch ($device) { + + 1 { + Clean-Project "$RepoRoot\firmware\s3-bridge" + } + + 2 { + Clean-Project "$RepoRoot\firmware\tab5" + } + + 3 { + Clean-Project "$RepoRoot\firmware\nanoh2" + } + + 4 { + & "$RepoRoot\scripts\build_tab5_c6_modulus.ps1" -Action fullclean + } + + 5 { + Ensure-Idf + Push-Location "$RepoRoot\firmware\modulus-zigbee-node" + try { idf.py -B build-node fullclean } + finally { Pop-Location } + } + } + } + } + + Write-Host "" + Write-Host "==> Done." + } + catch { + Write-Host "" + Write-Host "ERROR: $($_.Exception.Message)" + } + + Write-Host "" + Read-Host "Press Enter to continue" +} diff --git a/scripts/build_s3_bridge.ps1 b/scripts/build_s3_bridge.ps1 index ceb8ce3..48093d2 100644 --- a/scripts/build_s3_bridge.ps1 +++ b/scripts/build_s3_bridge.ps1 @@ -6,7 +6,7 @@ param( [ValidateSet("build", "flash", "monitor", "fullclean", "set-target", "flash-monitor")] [string]$Action = "build", - [ValidateSet("mini1", "xiao")] + [ValidateSet("mini1", "xiao", "generic")] [string]$Board = "mini1", [string]$Port = "COM8", [string]$IdfPath = "" @@ -20,6 +20,9 @@ function Get-IdfDefaultsArgs { if ($Board -eq "xiao") { return @("-D", "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.defaults.xiao") } + if ($Board -eq "generic") { + return @("-D", "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.defaults.generic") + } return @() } @@ -63,6 +66,11 @@ try { } "fullclean" { idf.py fullclean + # sdkconfig is generated state. Remove it so the next build really + # applies the selected board overlay (console and pin profile). + if (Test-Path -LiteralPath "sdkconfig") { + Remove-Item -LiteralPath "sdkconfig" -Force + } } "build" { if (-not (Test-Path "sdkconfig")) { @@ -73,6 +81,11 @@ try { if ($cfg -notmatch "CONFIG_S3_BRIDGE_BOARD_XIAO=y") { Write-Error "sdkconfig is not XIAO. Run: .\scripts\build_s3_bridge.ps1 -Board xiao -Action fullclean" } + } elseif ($Board -eq "generic") { + $cfg = Get-Content "sdkconfig" -Raw + if ($cfg -notmatch "CONFIG_S3_BRIDGE_BOARD_GENERIC=y") { + Write-Error "sdkconfig is not Generic. Run: .\scripts\build_s3_bridge.ps1 -Board generic -Action fullclean" + } } idf.py @IdfDefaults build $bin = Join-Path $S3Dir "build\s3_espnow_uart_bridge.bin" diff --git a/scripts/build_tab5.ps1 b/scripts/build_tab5.ps1 index 509a98b..662d636 100644 --- a/scripts/build_tab5.ps1 +++ b/scripts/build_tab5.ps1 @@ -94,6 +94,11 @@ try { } else { Write-Host "==> skip set-target (esp32p4 sdkconfig present)" } + # A completely fresh checkout has no managed_components before set-target. + # Run the idempotent compatibility patch again after component resolution + # so first builds and subsequent builds follow the same path. + Write-Host "==> patch resolved ESP-IDF managed components" + & "$PSScriptRoot\patch_tab5_idf6_deps.ps1" & "$PSScriptRoot\write_flash_walltime.ps1" -RepoRoot $RepoRoot Write-Host "==> idf.py build" idf.py build diff --git a/scripts/build_tab5_c6_modulus.ps1 b/scripts/build_tab5_c6_modulus.ps1 index fcec335..077d686 100644 --- a/scripts/build_tab5_c6_modulus.ps1 +++ b/scripts/build_tab5_c6_modulus.ps1 @@ -159,7 +159,7 @@ function Inject-ModulusIntoHostedMirror { $mainCmake2 = Join-Path $SlaveDir "main\CMakeLists.txt" $mc2 = Get-Content $mainCmake2 -Raw if ($mc2 -notmatch 'espnow_handler\.c') { - $mc2 = $mc2 -replace '(set\(COMPONENT_SRCS\r?\n\t"slave_control\.c")', "`$1`r`n`t`"espnow_handler.c`"" + $mc2 = $mc2 -replace '(register_component\s*\(\))', "list(APPEND COMPONENT_SRCS `"espnow_handler.c`")`r`n`r`n`$1" Set-Content -Path $mainCmake2 -Value $mc2 -NoNewline Write-Host "Patched slave main CMakeLists.txt: added espnow_handler.c" } @@ -195,7 +195,7 @@ function Inject-ModulusIntoHostedMirror { $mainCmake3 = Join-Path $SlaveDir "main\CMakeLists.txt" $mc3 = Get-Content $mainCmake3 -Raw if ($mc3 -notmatch 'zigbee_handler\.c') { - $mc3 = $mc3 -replace '(\t"espnow_handler\.c")', "`$1`r`n`t`"zigbee_handler.c`"`r`n`t`"thread_handler.c`"" + $mc3 = $mc3 -replace '(register_component\s*\(\))', "list(APPEND COMPONENT_SRCS `"zigbee_handler.c`" `"thread_handler.c`")`r`n`r`n`$1" Set-Content -Path $mainCmake3 -Value $mc3 -NoNewline Write-Host "Patched slave main CMakeLists.txt: added zigbee_handler.c + thread_handler.c" } diff --git a/scripts/package_flash_images.ps1 b/scripts/package_flash_images.ps1 index f89cf9e..5d39339 100644 --- a/scripts/package_flash_images.ps1 +++ b/scripts/package_flash_images.ps1 @@ -1,131 +1,80 @@ -# Package prebuilt flash images from local IDF build dirs into dist/flash-images/. -# Run after a successful full build. Does not rebuild. +# Merge existing release files only. Never invokes a firmware build or downloads files. +[CmdletBinding()] param( - [string]$Version = "3.1.0", - [string]$OutRoot = "" + [string]$Version = '3.1.3-ota', + [string]$SourceRoot = '', + [string]$OutRoot = '', + [string]$Python = 'python' ) - -$ErrorActionPreference = "Stop" +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest $RepoRoot = (Resolve-Path (Split-Path -Parent $PSScriptRoot)).Path -if (-not $OutRoot) { $OutRoot = Join-Path $RepoRoot "dist\flash-images\v$Version" } - -function Copy-FlashSet { - param( - [string]$Name, - [string]$BuildDir, - [string]$Chip, - [hashtable]$Files, # destName -> relative path under BuildDir - [string]$FlashCmd - ) - if (-not (Test-Path -LiteralPath $BuildDir)) { - Write-Warning "Skip $Name — missing build dir: $BuildDir" - return $null - } - $dest = Join-Path $OutRoot $Name - New-Item -ItemType Directory -Force -Path $dest | Out-Null - foreach ($kv in $Files.GetEnumerator()) { - $src = Join-Path $BuildDir $kv.Value - if (-not (Test-Path -LiteralPath $src)) { - throw "Missing $src for $Name" - } - Copy-Item -LiteralPath $src -Destination (Join-Path $dest $kv.Key) -Force +$Tag = 'v' + ($Version -replace '^v', '') +if (-not $SourceRoot) { $SourceRoot = Join-Path $RepoRoot "dist/flash-images/$Tag-source" } +if (-not $OutRoot) { $OutRoot = Join-Path $RepoRoot "dist/flash-images/$Tag-full" } +$SourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path +$OutRoot = [IO.Path]::GetFullPath($OutRoot) +$manifest = Get-Content -LiteralPath (Join-Path $SourceRoot 'MANIFEST.json') -Raw | ConvertFrom-Json +if ($manifest.release -ne $Tag) { throw 'Release manifest does not match Version.' } +$targets = @( + @{ Name = 'tab5-p4'; Chip = 'esp32p4'; Output = 'tab5-p4' }, + @{ Name = 'tab5-c6'; Chip = 'esp32c6'; Output = 'tab5-c6' }, + @{ Name = 's3-xiao'; Chip = 'esp32s3'; Output = 'xiao-s3' } +) +# Validate every input before producing any output; never silently skip a target. +$plans = foreach ($target in $targets) { + $configs = @(Get-ChildItem -LiteralPath $SourceRoot -Recurse -Filter flasher_args.json | + Where-Object { $_.Directory.Name -eq $target.Name }) + if ($configs.Count -ne 1) { throw "Expected exactly one flasher_args.json for $($target.Name)." } + $dir = $configs[0].Directory.FullName + $config = Get-Content -LiteralPath $configs[0].FullName -Raw | ConvertFrom-Json + if ($config.extra_esptool_args.chip -ne $target.Chip) { throw "Wrong chip for $($target.Name)." } + $records = @($manifest.targets | Where-Object name -eq $target.Name) + if ($records.Count -ne 1) { throw "Missing or duplicate manifest target $($target.Name)." } + $entries = @($config.flash_files.PSObject.Properties) + if ($entries.Count -ne @($records[0].flash_files).Count) { throw 'Flash file count differs from manifest.' } + $end = 0L + $mergeArgs = @() + foreach ($entry in ($entries | Sort-Object { [Convert]::ToInt64($_.Name, 16) })) { + $path = [IO.Path]::GetFullPath((Join-Path $dir $entry.Value)) + if (-not $path.StartsWith($dir + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'Input path escapes target folder.' } + $record = @($records[0].flash_files | Where-Object file -eq $entry.Value) + if ($record.Count -ne 1) { throw "File missing or duplicated in manifest: $path" } + $file = Get-Item -LiteralPath $path + if ($file.Length -ne $record[0].bytes -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $record[0].sha256) { throw "Input checksum/size mismatch: $path" } + $offset = [Convert]::ToInt64($entry.Name, 16) + if ($offset -lt $end) { throw "Overlapping flash regions: $path" } + $end = $offset + $file.Length + $mergeArgs += @($entry.Name, $path) } - Copy-Item -LiteralPath (Join-Path $BuildDir "flasher_args.json") -Destination (Join-Path $dest "flasher_args.json") -Force -ErrorAction SilentlyContinue - - $readme = @" -# Modulus $Name — flash package v$Version - -Chip: **$Chip** - -## esptool (from this folder) - -``````text -$FlashCmd -`````` - -Requires esptool or ESP-IDF ``idf.py -p COMx flash`` from the matching ``firmware/`` tree after build. - -SHA256 of each ``.bin`` is listed in ``../SHA256SUMS.txt``. -"@ - Set-Content -Path (Join-Path $dest "FLASH.md") -Value $readme -Encoding utf8 - return $dest -} - -New-Item -ItemType Directory -Force -Path $OutRoot | Out-Null -Write-Host "==> Packaging -> $OutRoot" - -$c6Build = "C:\modulus_tab5_c6_build\slave\build" -if (-not (Test-Path -LiteralPath (Join-Path $c6Build "network_adapter.bin"))) { - $c6Build = Join-Path $RepoRoot "firmware\tab5-c6\build" -} - -$sets = @() -$sets += Copy-FlashSet -Name "tab5-p4" -BuildDir (Join-Path $RepoRoot "firmware\tab5\build") -Chip "esp32p4" -Files @{ - "bootloader.bin" = "bootloader\bootloader.bin" - "partition-table.bin" = "partition_table\partition-table.bin" - "modulus_tab5.bin" = "modulus_tab5.bin" -} -FlashCmd @' -esptool.py --chip esp32p4 -p COM5 --before default-reset --after hard-reset write_flash --flash-mode dio --flash-freq 40m --flash-size 16MB ^ - 0x2000 bootloader.bin ^ - 0x8000 partition-table.bin ^ - 0x10000 modulus_tab5.bin -'@ - -$c6Files = @{ - "bootloader.bin" = "bootloader\bootloader.bin" - "partition-table.bin" = "partition_table\partition-table.bin" - "network_adapter.bin" = "network_adapter.bin" -} -if (Test-Path -LiteralPath (Join-Path $c6Build "ota_data_initial.bin")) { - $c6Files["ota_data_initial.bin"] = "ota_data_initial.bin" -} -$sets += Copy-FlashSet -Name "tab5-c6" -BuildDir $c6Build -Chip "esp32c6" -Files $c6Files -FlashCmd @' -esptool.py --chip esp32c6 -p COM6 --before default-reset --after hard-reset write_flash --flash-mode dio --flash-freq 80m --flash-size 4MB ^ - 0x0 bootloader.bin ^ - 0x8000 partition-table.bin ^ - 0xd000 ota_data_initial.bin ^ - 0x10000 network_adapter.bin -'@ - -$sets += Copy-FlashSet -Name "nanoh2" -BuildDir (Join-Path $RepoRoot "firmware\nanoh2\build") -Chip "esp32h2" -Files @{ - "bootloader.bin" = "bootloader\bootloader.bin" - "partition-table.bin" = "partition_table\partition-table.bin" - "modulus_nanoh2.bin" = "modulus_nanoh2.bin" -} -FlashCmd @' -esptool.py --chip esp32h2 -p COM7 --before default-reset --after hard-reset write_flash --flash-mode dio --flash-freq 48m --flash-size 4MB ^ - 0x0 bootloader.bin ^ - 0x8000 partition-table.bin ^ - 0x10000 modulus_nanoh2.bin -'@ - -$sets += Copy-FlashSet -Name "s3-bridge" -BuildDir (Join-Path $RepoRoot "firmware\s3-bridge\build") -Chip "esp32s3" -Files @{ - "bootloader.bin" = "bootloader\bootloader.bin" - "partition-table.bin" = "partition_table\partition-table.bin" - "s3_espnow_uart_bridge.bin" = "s3_espnow_uart_bridge.bin" -} -FlashCmd @' -esptool.py --chip esp32s3 -p COM8 --before default-reset --after hard-reset write_flash --flash-mode dio --flash-freq 80m --flash-size 8MB ^ - 0x0 bootloader.bin ^ - 0x8000 partition-table.bin ^ - 0x10000 s3_espnow_uart_bridge.bin -'@ - -$sums = Join-Path $OutRoot "SHA256SUMS.txt" -Remove-Item -LiteralPath $sums -ErrorAction SilentlyContinue -Get-ChildItem -Path $OutRoot -Recurse -Filter "*.bin" | Sort-Object FullName | ForEach-Object { - $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLower() - $rel = $_.FullName.Substring($OutRoot.Length).TrimStart('\','/') -replace '\\','/' - Add-Content -Path $sums -Value "$hash $rel" + if ($config.flash_settings.flash_size -notmatch '^(\d+)MB$') { throw 'Unsupported flash size.' } + if ($end -gt ([long]$Matches[1] * 1MB)) { throw 'Image exceeds flash capacity.' } + $output = Join-Path $OutRoot "modulus-$($target.Output)-full-$Tag.bin" + if (Test-Path -LiteralPath $output) { throw "Output already exists: $output. Choose a fresh OutRoot." } + @{ Target = $target; Config = $config; Args = $mergeArgs; Output = $output } } - -$zips = @() -Get-ChildItem -Path $OutRoot -Directory | ForEach-Object { - $zip = Join-Path $OutRoot ("modulus-{0}-v{1}.zip" -f $_.Name, $Version) - if (Test-Path $zip) { Remove-Item $zip -Force } - Compress-Archive -Path (Join-Path $_.FullName '*') -DestinationPath $zip -Force - $zips += $zip - Write-Host "==> $zip" +New-Item -ItemType Directory -Path $OutRoot -Force | Out-Null +$sums = @() +$commands = @() +foreach ($plan in $plans) { + # No flash-header overrides: retain the released bytes and fill gaps with 0xFF. + $arguments = @('-m', 'esptool', '--chip', $plan.Target.Chip, 'merge-bin', '--format', 'raw', '--target-offset', '0x0', '-o', $plan.Output) + $plan.Args + & $Python @arguments + if ($LASTEXITCODE -ne 0) { throw "esptool merge failed for $($plan.Target.Name)." } + $name = Split-Path -Leaf $plan.Output + $hash = (Get-FileHash -LiteralPath $plan.Output -Algorithm SHA256).Hash.ToLowerInvariant() + $sums += "$hash $name" + $settings = $plan.Config.flash_settings + $commands += "python -m esptool --chip $($plan.Target.Chip) -p PORT write-flash --flash-mode $($settings.flash_mode) --flash-freq $($settings.flash_freq) --flash-size $($settings.flash_size) 0x0 $name" } - -Write-Host "==> Done. Zips:" -$zips | ForEach-Object { Write-Host " $_" } -Write-Host "==> SHA256SUMS: $sums" +$sums | Set-Content -LiteralPath (Join-Path $OutRoot 'SHA256SUMS-full.txt') -Encoding utf8 +@( + "# Full images: $Tag", '', + "Source commit: $($manifest.source_commit). Inputs verified against MANIFEST.json. No rebuild.", '', + 'USB installation/recovery only. Replace PORT with the target serial port.', + 'Write exactly one BIN at 0x0. Never select a full image in a C6/S3 OTA menu.', + 'Gaps contain 0xFF; flashing can reset settings/NVS and OTA selection within the image range.', '', + '```text' +) + $commands + @('```', '', 'Checksums: SHA256SUMS-full.txt') | + Set-Content -LiteralPath (Join-Path $OutRoot 'FLASH-full.md') -Encoding utf8 +Write-Host "Full images and checksums: $OutRoot" diff --git a/scripts/patch_tab5_idf6_deps.ps1 b/scripts/patch_tab5_idf6_deps.ps1 index fd65db2..e1b60ba 100644 --- a/scripts/patch_tab5_idf6_deps.ps1 +++ b/scripts/patch_tab5_idf6_deps.ps1 @@ -448,14 +448,6 @@ static int modulus_sdio_counter_resync(void) $sd = $sd2 Write-Host "Patched host sdio_drv.c: RX counter resync v5 -> v6" } - if ($sd -notmatch 'MODULUS_SDIO_GPIO_FLUSH_MIN') { - $sd2 = $sd -replace '(#define MODULUS_SDIO_DRAIN_CHUNK \(16384\)[^\r\n]*)', - "`$1`r`n#define MODULUS_SDIO_GPIO_FLUSH_MIN (4096) /* below: align counters only, no GPIO15 */" - if ($sd -ne $sd2) { - $sd = $sd2 - Write-Host "Patched host sdio_drv.c: MODULUS_SDIO_GPIO_FLUSH_MIN" - } - } # v6 poll_slave_regs + counter_resync body $sd2 = $sd -replace 'slave PACKET_LEN not clear after', 'slave PACKET_LEN unreadable after' if ($sd -ne $sd2) { $sd = $sd2 } @@ -479,6 +471,29 @@ static int modulus_sdio_counter_resync(void) } } + # Every resync installation path uses this threshold. Keep the define + # outside the v5->v6 migration branch so a clean component checkout gets + # it as well. Use a platform-neutral newline and fail early if the anchor + # ever changes upstream. + if ($sd -match 'MODULUS_SDIO_GPIO_FLUSH_MIN' -and + $sd -notmatch '#define\s+MODULUS_SDIO_GPIO_FLUSH_MIN') { + $newline = if ($sd.Contains("`r`n")) { "`r`n" } else { "`n" } + $sd2 = [regex]::Replace( + $sd, + '(#define\s+MODULUS_SDIO_DRAIN_CHUNK\s+\(16384\)[^\r\n]*)', + "`$1${newline}#define MODULUS_SDIO_GPIO_FLUSH_MIN (4096) /* below: align counters only, no GPIO15 */", + 1) + if ($sd -eq $sd2) { + throw "Could not add MODULUS_SDIO_GPIO_FLUSH_MIN: DRAIN_CHUNK anchor missing in $sdioDrv" + } + $sd = $sd2 + Write-Host "Patched host sdio_drv.c: MODULUS_SDIO_GPIO_FLUSH_MIN" + } + if ($sd -match 'MODULUS_SDIO_GPIO_FLUSH_MIN' -and + $sd -notmatch '#define\s+MODULUS_SDIO_GPIO_FLUSH_MIN') { + throw "Incomplete SDIO patch: MODULUS_SDIO_GPIO_FLUSH_MIN is used but not defined in $sdioDrv" + } + # Modulus: back off when register reads fail (bus down / slave re-enumerating). # The ~10ms spin floods the log and contends the CMD lines against concurrent # card re-init during transport reconfigure, making 0x107 permanent. @@ -672,6 +687,10 @@ if (Test-Path $AudioCmake) { if ($a -match 'REQUIRES driver') { $a = $a -replace 'REQUIRES driver', 'REQUIRES esp_driver_i2s' } + if ($a -notmatch '\besp_driver_i2s\b') { + $audioRequires = 'list(APPEND requires "esp_driver_i2s")' + "`r`n`r`n" + '$1' + $a = $a -replace '(?m)^(idf_component_register\()', $audioRequires + } # GCC 15: reinterpret_cast(fn_ptr) → -Wignored-qualifiers; component # treats warnings as errors (BufferRoot/Modulus-Firmware#2). if ($a -notmatch 'Wno-error=ignored-qualifiers') { diff --git a/scripts/test_c6_ota.py b/scripts/test_c6_ota.py new file mode 100644 index 0000000..ab11105 --- /dev/null +++ b/scripts/test_c6_ota.py @@ -0,0 +1,148 @@ +"""Host regression tests for the real C6 OTA shim; no firmware build or hardware. +Run: python scripts/test_c6_ota.py (requires zig on PATH). +ESP-Hosted and FreeRTOS are mocked; file reads and the worker are real. +""" +from pathlib import Path +import os +import subprocess + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'dist' / 'tests' / 'c6-ota' +OUT.mkdir(parents=True, exist_ok=True) +STUB = r''' +#pragma once +#include +#include +#include +typedef int esp_err_t; +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_NO_MEM 1 +#define ESP_ERR_NOT_FOUND 2 +#define ESP_ERR_INVALID_SIZE 3 +#define ESP_ERR_INVALID_ARG 4 +#define ESP_IMAGE_HEADER_MAGIC 0xe9 +#define ESP_CHIP_ID_ESP32C6 13 +#define ESP_APP_DESC_MAGIC_WORD 0xabcd5432 +/* Only image scanner fields are needed; worker tests use a pre-armed file. */ +typedef struct { unsigned magic, chip_id; } esp_image_header_t; +typedef struct { unsigned size; } esp_image_segment_header_t; +typedef struct { unsigned magic_word; char version[32]; } esp_app_desc_t; +typedef struct { uint32_t major1, minor1, patch1; } esp_hosted_coprocessor_fwver_t; +typedef int portMUX_TYPE; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define taskENTER_CRITICAL(x) ((void)(x)) +#define taskEXIT_CRITICAL(x) ((void)(x)) +#define pdMS_TO_TICKS(x) (x) +#define pdPASS 1 +#define ESP_LOGI(...) ((void)0) +#define ESP_LOGE(...) ((void)0) +#define ESP_LOGW(...) ((void)0) +int esp_hosted_get_coprocessor_fwversion(esp_hosted_coprocessor_fwver_t *); +int esp_hosted_slave_ota_begin(void); +int esp_hosted_slave_ota_write(uint8_t *, uint32_t); +int esp_hosted_slave_ota_end(void); +int esp_hosted_slave_ota_activate(void); +const char *esp_err_to_name(int); +void esp_restart(void); +void vTaskDelay(unsigned); +void vTaskDelete(void *); +int xTaskCreate(void (*)(void *), const char *, unsigned, void *, unsigned, void *); +''' +(OUT / 'stub.h').write_text(STUB) +for header in ['esp_app_desc.h', 'esp_app_format.h', 'esp_err.h', 'esp_hosted.h', + 'esp_hosted_api_types.h', 'esp_hosted_ota.h', 'esp_log.h', 'esp_system.h', + 'freertos/FreeRTOS.h', 'freertos/portmacro.h', 'freertos/task.h']: + p = OUT / header + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text('#include "stub.h"\n') +HARNESS = r''' +#include +#include +#include +#include "SOURCE" +static esp_hosted_coprocessor_fwver_t running; +static char calls[100]; +static int fault, writes, restarted; +static unsigned delay_ms; +static void record(char c) { size_t n = strlen(calls); calls[n] = c; calls[n+1] = 0; } +int esp_hosted_get_coprocessor_fwversion(esp_hosted_coprocessor_fwver_t *v) { + record('V'); *v = running; return fault == 1 ? ESP_FAIL : ESP_OK; +} +int esp_hosted_slave_ota_begin(void) { record('B'); return fault == 2 ? ESP_FAIL : ESP_OK; } +int esp_hosted_slave_ota_write(uint8_t *b, uint32_t n) { + record('W'); writes++; + const bool modern = running.major1 > 2 || (running.major1 == 2 && running.minor1 >= 6); + assert(n == (modern ? (writes == 1 ? 4096 : 904) : (writes < 5 ? 1024 : 904))); + if (!modern) assert(n + 64 < 4096); /* RPC envelope must fit old receiver. */ + for (uint32_t i=0; i 2 || (running.major1 == 2 && running.minor1 >= 6)); + return fault == 5 ? ESP_FAIL : ESP_OK; +} +const char *esp_err_to_name(int e) { (void)e; return "mock error"; } +void esp_restart(void) { record('R'); restarted++; } +void vTaskDelay(unsigned ms) { record('D'); delay_ms = ms; } +void vTaskDelete(void *p) { (void)p; record('X'); } +int xTaskCreate(void (*fn)(void *), const char *name, unsigned stack, void *arg, unsigned pri, void *handle) { + (void)name; (void)stack; (void)pri; (void)handle; fn(arg); return pdPASS; +} +bool modulus_storage_usb_volume_mounted(void) { return true; } +static void run(unsigned major, unsigned minor, unsigned patch, int failure, const char *expected) { + running = (esp_hosted_coprocessor_fwver_t){major, minor, patch}; + calls[0] = 0; writes = 0; restarted = 0; delay_ms = 0; fault = failure; + s_state = (modulus_c6_ota_snapshot_t){.phase = MODULUS_C6_OTA_ARMED, .c6_connected = true}; + /* Stale UI deliberately claims a modern slave. Worker must query again. */ + strcpy(s_state.c6_version, "9.9.9"); + strcpy(s_armed_path, failure == 6 ? "missing.bin" : "input.bin"); + s_armed_size = failure == 7 ? 6000 : 5000; + s_worker_running = false; + modulus_c6_ota_start(); + assert(strcmp(calls, expected) == 0); + assert(!s_worker_running); + if (!failure && major) { + assert(s_state.phase == MODULUS_C6_OTA_SUCCESS); + assert(s_state.progress == 100 && restarted == 1); + assert(delay_ms == (major > 2 || (major == 2 && minor >= 6) ? 3000 : 8000)); + } else { + assert(s_state.phase == MODULUS_C6_OTA_ERROR); + assert(restarted == 0 && delay_ms == 0); + } +} +int main(void) { + FILE *f = fopen("input.bin", "wb"); assert(f); + for (int i=0; i<5000; i++) fputc(0xa5, f); + fclose(f); + run(1,4,1,0,"VBWWWWWEDRX"); + run(2,5,99,0,"VBWWWWWEDRX"); + run(2,6,0,0,"VBWWEADRX"); + run(2,11,4,0,"VBWWEADRX"); + run(3,0,0,0,"VBWWEADRX"); + run(2,11,4,1,"VX"); assert(strstr(s_state.status,"Nothing written")); + run(0,0,0,0,"VX"); + run(1,4,1,2,"VBX"); assert(strstr(s_state.status,"begin failed")); + run(1,4,1,3,"VBWX"); assert(strstr(s_state.status,"transfer failed")); + run(1,4,1,4,"VBWWWWWEX"); assert(strstr(s_state.status,"Activation unconfirmed")); + run(2,6,0,4,"VBWWEX"); assert(strstr(s_state.status,"Activation not requested")); + run(2,6,0,5,"VBWWEAX"); assert(strstr(s_state.status,"Activation unconfirmed")); + run(2,6,0,6,"VX"); assert(strstr(s_state.status,"open image failed")); + run(1,4,1,7,"VBWWWWX"); assert(strstr(s_state.status,"read image failed")); + puts("PASS: 14 C6 OTA scenarios (versions, order, delays, failures, partial input)"); + return 0; +} +''' +source = ROOT / 'firmware/tab5/components/modulus_zig/c6_ota_shim.c' +(OUT / 'test.c').write_text(HARNESS.replace('SOURCE', source.as_posix())) +env = os.environ.copy() +env['ZIG_LOCAL_CACHE_DIR'] = str(OUT / 'zig-local-cache') +env['ZIG_GLOBAL_CACHE_DIR'] = str(OUT / 'zig-global-cache') +exe = OUT / ('test.exe' if os.name == 'nt' else 'test') +subprocess.run(['zig', 'cc', '-std=c11', '-Wall', '-Wextra', '-Werror', + '-Wno-unused-variable', '-I', str(OUT), '-I', str(source.parent / 'include'), + str(OUT / 'test.c'), '-o', str(exe)], check=True, cwd=OUT, env=env) +subprocess.run([str(exe)], check=True, cwd=OUT) diff --git a/scripts/test_modulus_ports.ps1 b/scripts/test_modulus_ports.ps1 new file mode 100644 index 0000000..a408c3b --- /dev/null +++ b/scripts/test_modulus_ports.ps1 @@ -0,0 +1,62 @@ +# Read only the port-related function definitions; never start the menu/flash. +$ErrorActionPreference = 'Stop' +$scriptPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'modulus.ps1' +$parseErrors = $null +$tokens = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw ($parseErrors | Out-String) } +foreach ($name in @('Get-SystemComPortNames', 'Get-ModulusComPorts', 'Select-ComPort')) { + $definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true) + if (-not $definition) { throw "Missing function: $name" } + Invoke-Expression $definition.Extent.Text +} + +function Get-PnpDevice { + param([switch]$PresentOnly, $Class, $ErrorAction) + if (-not $PresentOnly -or $Class -ne 'Ports') { throw 'Expected present Ports devices only' } + if ($script:failPnp) { throw 'PnpDevice unavailable' } + $script:pnpRows +} +function Get-CimInstance { + param($ClassName, $Filter, $ErrorAction) + if ($script:failCim) { throw 'CIM unavailable' } + if ($ClassName -eq 'Win32_SerialPort') { $script:serialRows } + elseif ($ClassName -eq 'Win32_PnPEntity') { + if ($Filter -notmatch 'Present = TRUE') { throw 'Missing presence filter' } + $script:cimPnpRows + } else { throw "Unexpected CIM class: $ClassName" } +} +function Get-SystemComPortNames { $script:systemPorts } +function Assert-Ports([string]$Expected) { + $actual = @((Get-ModulusComPorts).Port) -join ',' + if ($actual -ne $Expected) { throw "Expected '$Expected', got '$actual'" } +} +$script:failPnp = $false +$script:failCim = $false +$script:serialRows = @([PSCustomObject]@{ DeviceID='COM1'; Name='Communications Port (COM1)'; PNPDeviceID='ACPI\PNP0501' }) +$script:pnpRows = @( + [PSCustomObject]@{ FriendlyName='USB-SERIAL CH340 (COM5)'; InstanceId='USB\VID_1A86&PID_7523' }, + [PSCustomObject]@{ FriendlyName='USB JTAG/serial (COM14)'; InstanceId='USB\VID_303A&PID_1001' }, + [PSCustomObject]@{ FriendlyName='Printer (LPT1)'; InstanceId='ACPI\PRINTER' } +) +$script:cimPnpRows = @([PSCustomObject]@{ Name='USB-SERIAL CH340 (COM5)'; PNPDeviceID='USB\VID_1A86&PID_7523' }) +$script:systemPorts = @('COM14','COM5','COM1','COM5') +Assert-Ports 'COM1,COM5,COM14' +$ports = @(Get-ModulusComPorts) +if ($ports[1].Name -ne 'USB-SERIAL CH340 (COM5)' -or $ports[1].IsEspressif -or -not $ports[2].IsEspressif) { throw 'Adapter names/vendor tags incorrect' } +function Read-Host { '2' } +if ((Select-ComPort) -ne 'COM5') { throw 'Menu did not select automatically discovered CH340' } +Write-Host 'PASS: CH340 missing from Win32_SerialPort is discovered and selectable; numeric order, deduplication and vendor tags.' +$script:failPnp = $true +$script:systemPorts = @() +Assert-Ports 'COM1,COM5' +Write-Host 'PASS: CIM PnP fallback without PnpDevice module.' +$script:failCim = $true +$script:systemPorts = @('COM5') +Assert-Ports 'COM5' +Write-Host 'PASS: system port fallback when both Windows queries fail.' +$script:systemPorts = @() +Assert-Ports '' +try { Select-ComPort; throw 'Expected no-ports error' } +catch { if ($_.Exception.Message -ne 'No COM ports found.') { throw } } +Write-Host 'PASS: no devices, no accidental flash.' diff --git a/scripts/update_all_firmware_bundle.py b/scripts/update_all_firmware_bundle.py new file mode 100644 index 0000000..baafa01 --- /dev/null +++ b/scripts/update_all_firmware_bundle.py @@ -0,0 +1,124 @@ +"""Add verified full BINs to an existing release bundle without rebuilding firmware. + +The source directory must contain the original all-firmware ZIP, MANIFEST.json, +FLASH-INSTRUCTIONS-EN-DE.md and release SHA256SUMS.txt. Output uses a fresh folder. +""" +import argparse +import hashlib +import json +from pathlib import Path +import zipfile + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def parse_sums(data): + return {name: digest for digest, name in + (line.split(None, 1) for line in data.decode('utf-8-sig').splitlines() if line.strip())} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, required=True) + parser.add_argument('--full-images', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + manifest = json.loads((args.source / 'MANIFEST.json').read_text(encoding='utf-8-sig')) + tag = manifest['release'] + zip_name = f'modulus-all-firmware-{tag}.zip' + sums = parse_sums((args.source / 'SHA256SUMS.txt').read_bytes()) + for name in [zip_name, 'MANIFEST.json', 'FLASH-INSTRUCTIONS-EN-DE.md']: + if sha((args.source / name).read_bytes()) != sums[name]: + raise ValueError(f'Source checksum mismatch: {name}') + with zipfile.ZipFile(args.source / zip_name) as z: + if len(z.namelist()) != len(set(z.namelist())): + raise ValueError('Duplicate ZIP entries') + files = {item.filename: z.read(item) for item in z.infolist() if not item.is_dir()} + original = dict(files) + for name, digest in parse_sums(files['SHA256SUMS.txt']).items(): + if sha(files[name]) != digest: + raise ValueError(f'Original bundle checksum mismatch: {name}') + if json.loads(files['MANIFEST.json']) != manifest: + raise ValueError('Internal/external manifests differ') + + full_sums = parse_sums((args.full_images / 'SHA256SUMS-full.txt').read_bytes()) + entries = [] + for target, output in [('tab5-p4', 'tab5-p4'), ('tab5-c6', 'tab5-c6'), ('s3-xiao', 'xiao-s3')]: + name = f'modulus-{output}-full-{tag}.bin' + data = (args.full_images / name).read_bytes() + if sha(data) != full_sums[name]: + raise ValueError(f'Full image checksum mismatch: {name}') + config = json.loads(files[f'{target}/flasher_args.json']) + end = 0 + for address, rel in sorted(config['flash_files'].items(), key=lambda x: int(x[0], 16)): + offset = int(address, 16) + part = files[f'{target}/{rel}'] + if offset < end or data[end:offset] != b'\xff' * (offset-end) or data[offset:offset+len(part)] != part: + raise ValueError(f'Full image differs from original bundle: {target}/{rel}') + end = offset + len(part) + if len(data) != end: + raise ValueError(f'Unexpected image size: {name}') + files[name] = data + entries.append({'file': name, 'bytes': len(data), 'sha256': sha(data), + 'flash_offset': '0x0', 'chip': config['extra_esptool_args']['chip']}) + manifest['full_images'] = entries + manifest['packaging_note'] = 'Full images merged from original release files; no firmware rebuilt.' + files['MANIFEST.json'] = (json.dumps(manifest, indent=2, ensure_ascii=False)+'\n').encode() + files['FLASH-full.md'] = (args.full_images / 'FLASH-full.md').read_bytes() + files['SHA256SUMS-full.txt'] = (args.full_images / 'SHA256SUMS-full.txt').read_bytes() + note = f'''# Single-BIN full images / Full-Images als einzelne BIN + +The three `*-full-{tag}.bin` files are now included **in the ZIP root**. +Flash exactly one matching BIN at `0x0`; see `FLASH-full.md` for commands. +The original per-target files, OTA apps and licenses remain included. +Full images are for wired installation/recovery, never the OTA menus. +The Tab5 C6 uses its internal programming connector and a USB-TTL programmer; +the normal Tab5 USB-C port programs the P4. + +Die drei `*-full-{tag}.bin` liegen jetzt **direkt im ZIP-Hauptordner**. +Pro Chip genau eine passende BIN bei `0x0` flashen; Befehle in `FLASH-full.md`. +Einzeldateien, OTA-Apps und Lizenzen bleiben enthalten. Full-BINs niemals im +OTA-Menü auswählen. Der C6 wird über seinen internen Programmieranschluss mit +USB-TTL-Downloader geflasht; die normale Tab5-USB-C-Buchse flasht den P4. +**No firmware rebuild / Kein Firmware-Neubau:** This package still contains the +original {tag} firmware. The later C6 legacy OTA fix (commit 69ca844) is NOT +in these BINs and requires a newly built P4 image for testing. +Das Paket enthält weiterhin die ursprüngliche {tag}-Firmware. Der spätere +C6-Legacy-OTA-Fix (Commit 69ca844) ist NICHT in diesen BINs enthalten. + +--- + +'''.encode() + instructions = note + (args.source / 'FLASH-INSTRUCTIONS-EN-DE.md').read_bytes() + files['README.md'] = instructions + files['SHA256SUMS.txt'] = ''.join(f'{sha(data)} {name}\n' for name, data in sorted(files.items()) + if name != 'SHA256SUMS.txt').encode() + args.output.mkdir(parents=True, exist_ok=True) + if any(args.output.iterdir()): + raise ValueError('Output must be empty; original files are never overwritten locally') + with zipfile.ZipFile(args.output / zip_name, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as z: + for name, data in sorted(files.items()): + z.writestr(name, data) + # Reopen and verify every original firmware/license plus all internal checksums. + with zipfile.ZipFile(args.output / zip_name) as z: + assert z.testzip() is None + for name, data in original.items(): + if name not in ('MANIFEST.json', 'README.md', 'SHA256SUMS.txt'): + assert z.read(name) == data, name + for name, digest in parse_sums(z.read('SHA256SUMS.txt')).items(): + assert sha(z.read(name)) == digest, name + (args.output / 'MANIFEST.json').write_bytes(files['MANIFEST.json']) + (args.output / 'FLASH-INSTRUCTIONS-EN-DE.md').write_bytes(instructions) + for name in [zip_name, 'MANIFEST.json', 'FLASH-INSTRUCTIONS-EN-DE.md']: + sums[name] = sha((args.output / name).read_bytes()) + for item in entries: + sums[item['file']] = item['sha256'] + (args.output / 'SHA256SUMS.txt').write_text(''.join(f'{digest} {name}\n' for name, digest in sorted(sums.items())), encoding='utf-8') + print(f'Verified {len(files)} bundle files; preserved all original firmware and licenses.') + print(f'{zip_name}: {sums[zip_name]}') + + +if __name__ == '__main__': + main() diff --git a/src/modulus/c/shim_bundle_manifest.zig b/src/modulus/c/shim_bundle_manifest.zig index 54dd47e..ce40c77 100644 --- a/src/modulus/c/shim_bundle_manifest.zig +++ b/src/modulus/c/shim_bundle_manifest.zig @@ -18,12 +18,16 @@ pub const headers = [_][]const u8{ "security_shim.h", "serial_shim.h", "storage_shim.h", + "c6_ota_shim.h", + "s3_ota_shim.h", + "nano_ota_shim.h", "touch_shim.h", "transport_shim.h", "ui_shim.h", "wireless_shim.h", + "wireless_rpc.h", }; test "shim: bundle header count" { - try @import("std").testing.expectEqual(@as(usize, 21), headers.len); + try @import("std").testing.expectEqual(@as(usize, 22), headers.len); } diff --git a/src/modulus/cnc/driver_commands.zig b/src/modulus/cnc/driver_commands.zig index 162aa16..2f27e53 100644 --- a/src/modulus/cnc/driver_commands.zig +++ b/src/modulus/cnc/driver_commands.zig @@ -9,7 +9,7 @@ const driver_ops = @import("driver_ops.zig"); const gating = @import("driver_gating.zig"); pub fn cmdCycleStart(drv: anytype) void { - if (gating.isReady(drv)) { + if (gating.canSendCommands(drv)) { drv.engine.sendCycleStart(); } else { gating.lockSnapshot(drv); @@ -19,7 +19,7 @@ pub fn cmdCycleStart(drv: anytype) void { } pub fn cmdFeedHold(drv: anytype) void { - if (gating.isReady(drv)) { + if (gating.canSendCommands(drv)) { drv.engine.sendFeedHold(); } else { gating.lockSnapshot(drv); @@ -68,7 +68,7 @@ pub fn cycleWcs(drv: anytype) void { drv.snapshot.wcs = wcs; gating.unlockSnapshot(drv); if (drv.store) |s| s.persistU8(settings_keys.cnc_wcs, @intCast(next_idx)); - if (gating.isReady(drv)) driver_ops.sendGcodeClamped(drv, cnc_state.wcsStr(wcs)); + if (gating.canSendCommands(drv)) driver_ops.sendGcodeClamped(drv, cnc_state.wcsStr(wcs)); } pub fn setActiveAxis(drv: anytype, axis_idx: u8) void { @@ -340,7 +340,7 @@ pub fn cmdEstop(drv: anytype) void { } pub fn cmdRapidOverride(drv: anytype, pct: u8) void { - if (gating.isReady(drv)) { + if (gating.canSendCommands(drv)) { drv.engine.sendRapidOverride(pct); } else { gating.lockSnapshot(drv); @@ -376,7 +376,7 @@ pub fn cmdSpindleOverride(drv: anytype, delta: i8) void { } pub fn cmdSpindleToggle(drv: anytype) void { - if (gating.isReady(drv)) drv.engine.sendSpindleStopToggle(); + if (gating.canSendCommands(drv)) drv.engine.sendSpindleStopToggle(); } fn spindleCmdRpm(drv: anytype) u32 { @@ -396,7 +396,7 @@ fn spindleCmdRpm(drv: anytype) u32 { } pub fn cmdSpindleCw(drv: anytype) void { - if (!gating.isReady(drv)) return; + if (!gating.canSendCommands(drv)) return; var buf: [32]u8 = undefined; const rpm = spindleCmdRpm(drv); const line = std.fmt.bufPrint(&buf, "M3 S{d}", .{rpm}) catch return; @@ -404,7 +404,7 @@ pub fn cmdSpindleCw(drv: anytype) void { } pub fn cmdSpindleCcw(drv: anytype) void { - if (!gating.isReady(drv)) return; + if (!gating.canSendCommands(drv)) return; if (drv.store) |s| { if (!s.getBool(settings_keys.cnc_spcw, true)) return; } @@ -415,7 +415,7 @@ pub fn cmdSpindleCcw(drv: anytype) void { } pub fn cmdRunMacro(drv: anytype) void { - if (!gating.isReady(drv)) return; + if (!gating.canSendCommands(drv)) return; var buf: [settings_keys.cnc_macro_max_len + 1]u8 = undefined; @memset(&buf, 0); const line: []const u8 = blk: { @@ -431,19 +431,19 @@ pub fn cmdRunMacro(drv: anytype) void { } pub fn cmdCoolantToggle(drv: anytype) void { - if (gating.isReady(drv)) drv.engine.sendCoolantFloodToggle(); + if (gating.canSendCommands(drv)) drv.engine.sendCoolantFloodToggle(); } pub fn cmdMistToggle(drv: anytype) void { - if (gating.isReady(drv)) drv.engine.sendCoolantMistToggle(); + if (gating.canSendCommands(drv)) drv.engine.sendCoolantMistToggle(); } pub fn cmdFanToggle(drv: anytype) void { - if (gating.isReady(drv)) drv.engine.sendFanToggle(); + if (gating.canSendCommands(drv)) drv.engine.sendFanToggle(); } pub fn cmdSingleStep(drv: anytype) void { - if (gating.isReady(drv)) drv.engine.sendSingleStepToggle(); + if (gating.canSendCommands(drv)) drv.engine.sendSingleStepToggle(); } pub fn cmdMpgToggle(drv: anytype) void { @@ -471,7 +471,7 @@ pub fn setUnitsMm(drv: anytype, mm: bool) void { drv.snapshot.units_mm = mm; gating.unlockSnapshot(drv); if (drv.store) |s| s.persistBool(settings_keys.cnc_unit, mm); - if (!gating.isReady(drv)) return; + if (!gating.canSendCommands(drv)) return; if (drv.store) |s| { const idx = s.getU8(settings_keys.cnc_proto, cnc_config.k_default_cnc_proto); const proto: cnc_config.Protocol = if (idx < @intFromEnum(cnc_config.Protocol._count)) @@ -495,5 +495,5 @@ pub fn setWcs(drv: anytype, w: cnc_state.WCS) void { gating.lockSnapshot(drv); drv.snapshot.wcs = wcs; gating.unlockSnapshot(drv); - if (gating.isReady(drv)) driver_ops.sendGcodeClamped(drv, cnc_state.wcsStr(wcs)); + if (gating.canSendCommands(drv)) driver_ops.sendGcodeClamped(drv, cnc_state.wcsStr(wcs)); } diff --git a/src/modulus/cnc/driver_ops.zig b/src/modulus/cnc/driver_ops.zig index 7aed798..2dc40b7 100644 --- a/src/modulus/cnc/driver_ops.zig +++ b/src/modulus/cnc/driver_ops.zig @@ -35,7 +35,10 @@ pub fn applyOverridePct(eng: *Engine, feed: bool, target_pct: u8) void { pub fn applyOverrideDelta(drv: anytype, feed: bool, delta: i8) void { drv.reloadLimits(); - if (drv.isReady()) { + // Realtime overrides remain valid while grblHAL reports its remote MPG + // input active. `isReady()` excludes `.mpg_blocked`, which made the UI + // step optimistically and then snap back on the next Ov status report. + if (drv.canSendCommands()) { drv.lockSnapshot(); // FS/RPM in status are usually *actual* (already × override). Recover programmed base // so envelope clamp does not pin override at 100% while cutting near max feed. diff --git a/src/modulus/cnc/driver_tests.zig b/src/modulus/cnc/driver_tests.zig index 689fd3b..0eb046b 100644 --- a/src/modulus/cnc/driver_tests.zig +++ b/src/modulus/cnc/driver_tests.zig @@ -337,6 +337,64 @@ test "cnc: cmdZeroAxis still sends under mpg_blocked" { try testing.expectEqualStrings("G10 L20 P1 X0\n", sent.items); } +test "cnc: realtime overrides still send under mpg_blocked" { + var drv = Driver.init(.{}); + var sent = std.ArrayListUnmanaged(u8).empty; + defer sent.deinit(testing.allocator); + drv.setSendFn(test_util.appendSend(&sent, testing.allocator)); + test_util.connectToReady(&drv, 0); + drv.feed("ok\n"); + drv.poll(0); + drv.feed("\n"); + drv.poll(0); + try testing.expectEqual( + @import("grblhal/session.zig").SessionState.mpg_blocked, + drv.engine.session(), + ); + + sent.clearRetainingCapacity(); + drv.cmdFeedOverride(0); + try testing.expectEqualSlices(u8, &.{0x90}, sent.items); + + sent.clearRetainingCapacity(); + drv.cmdSpindleOverride(0); + try testing.expectEqualSlices(u8, &.{0x99}, sent.items); +} + +test "cnc: dashboard realtime controls still send under mpg_blocked" { + var drv = Driver.init(.{}); + var sent = std.ArrayListUnmanaged(u8).empty; + defer sent.deinit(testing.allocator); + drv.setSendFn(test_util.appendSend(&sent, testing.allocator)); + test_util.connectToReady(&drv, 0); + drv.feed("ok\n"); + drv.poll(0); + drv.feed("\n"); + drv.poll(0); + + inline for (.{ + "cycle_start", + "feed_hold", + "rapid_override", + "spindle_toggle", + "coolant", + "mist", + "fan", + "single_step", + }) |command| { + sent.clearRetainingCapacity(); + if (std.mem.eql(u8, command, "cycle_start")) drv.cmdCycleStart(); + if (std.mem.eql(u8, command, "feed_hold")) drv.cmdFeedHold(); + if (std.mem.eql(u8, command, "rapid_override")) drv.cmdRapidOverride(50); + if (std.mem.eql(u8, command, "spindle_toggle")) drv.cmdSpindleToggle(); + if (std.mem.eql(u8, command, "coolant")) drv.cmdCoolantToggle(); + if (std.mem.eql(u8, command, "mist")) drv.cmdMistToggle(); + if (std.mem.eql(u8, command, "fan")) drv.cmdFanToggle(); + if (std.mem.eql(u8, command, "single_step")) drv.cmdSingleStep(); + try testing.expect(sent.items.len > 0); + } +} + test "cnc: cmdZeroAxis gated when disconnected" { var drv = Driver.init(.{}); var tx_len: usize = 0; diff --git a/src/modulus/firmware/device_ui_bridge.zig b/src/modulus/firmware/device_ui_bridge.zig index c7e110a..bd68f98 100644 --- a/src/modulus/firmware/device_ui_bridge.zig +++ b/src/modulus/firmware/device_ui_bridge.zig @@ -58,6 +58,7 @@ var g_prev_bt_on: bool = false; var g_last_espnow: u8 = 255; var g_last_zb: u8 = 255; var g_last_th: u8 = 255; +var g_last_ant_ext: u8 = 255; fn applyTone(p: *const settings_prefs.Prefs) void { if (p.audio.tone_prof == g_last_tone) return; @@ -82,6 +83,13 @@ fn applyWirelessRadios(p: *settings_prefs.Prefs) void { const en: u8 = @intFromBool(p.wireless.espnow); const zb: u8 = @intFromBool(p.wireless.zigbee); const th: u8 = @intFromBool(p.wireless.thread); + const ant_ext: u8 = @intFromBool(p.wireless.ant_ext); + if (ant_ext != g_last_ant_ext) { + g_last_ant_ext = ant_ext; + // The setting shown in the Zig UI is authoritative. This also repairs + // older installs whose C-side mirror said MMCX while the UI said PCB. + c.modulus_wireless_set_antenna_external(p.wireless.ant_ext); + } if (wi != g_last_wifi) { g_last_wifi = wi; if (p.wireless.wifi) { @@ -238,6 +246,45 @@ fn syncZbDevices(w: *settings_prefs.WirelessPrefs) void { w.zb_dev_n = w.live_zb_n; } +fn syncZbNode(eng: *Engine) void { + var w = &eng.prefs.wireless; + if (!w.zb_node_open) return; + var info: c.modulus_zb_node_info_t = undefined; + if (!c.modulus_wireless_zb_node_get_info(&info)) return; + const became_ready = !w.zb_node_ready; + var changed = info.generation != w.zb_node_generation; + w.zb_node_generation = info.generation; + w.zb_node_ready = true; + w.zb_node_short = info.short_addr; + w.zb_node_channel_count = @min(info.channel_count, 12); + w.zb_node_led_gpio = info.status_led_gpio; + w.zb_node_led_flags = info.status_led_flags; + copySliceTo(&w.zb_node_name, std.mem.sliceTo(&info.name, 0)); + if (changed and info.last_status != 0) { + if (info.last_status == 3) eng.showSnackbarError("Pin conflict or reserved GPIO") + else eng.showSnackbarError("Node rejected setting"); + } + var i: u8 = 0; + while (i < w.zb_node_channel_count) : (i += 1) { + var channel: c.modulus_zb_node_channel_t = undefined; + if (!c.modulus_wireless_zb_node_get_channel(i, &channel)) continue; + var dst = &w.zb_node_channels[i]; + if (dst.temperature_centi_c != channel.temperature_centi_c or dst.temperature_state != channel.temperature_state or + dst.digital_value != channel.digital_value or dst.digital_state != channel.digital_state) changed = true; + dst.poll_interval_s = channel.poll_interval_s; + dst.temperature_centi_c = channel.temperature_centi_c; + dst.temperature_state = channel.temperature_state; + dst.digital_value = channel.digital_value; + dst.digital_state = channel.digital_state; + dst.typ = channel.type; + dst.gpio = channel.gpio; + dst.flags = channel.flags; + dst.valid = channel.valid; + copySliceTo(&dst.name, std.mem.sliceTo(&channel.name, 0)); + } + if (became_ready or changed) eng.requestSettingsRepaint(); +} + fn syncThDevices(w: *settings_prefs.WirelessPrefs) void { const n = c.modulus_wireless_thread_device_count(); w.live_th_n = 0; @@ -302,7 +349,7 @@ pub fn wirelessPoll(eng: *Engine) void { if (was_bt_on and !w.bt and c.modulus_wireless_ble_enable_failed()) { if (!(c.modulus_c6_sdio_ready() and c.modulus_wireless_transport_up())) { - eng.showSnackbarError("Bluetooth failed — C6 offline (dual-flash C6 UART COM18)"); + eng.showSnackbarError("Bluetooth failed — Internal C6 offline"); } else { eng.showSnackbarError("Bluetooth failed — retry or reboot Tab5"); } @@ -342,6 +389,9 @@ pub fn wirelessPoll(eng: *Engine) void { w.en_tx = c.modulus_wireless_espnow_tx_count(); w.en_rx = c.modulus_wireless_espnow_rx_count(); + // The C6 may follow the joined Wi-Fi AP to a different channel. Mirror + // that live value so the settings page shows where ESP-NOW really is. + if (!g_prefs_dirty) w.en_chan = c.modulus_wireless_espnow_channel(); syncEspnowSaved(eng); const was_zb_pending = w.zb_join_pending; @@ -368,6 +418,7 @@ pub fn wirelessPoll(eng: *Engine) void { } copyCStr(w.zb_network[0..], c.modulus_wireless_zigbee_network_text()); syncZbDevices(w); + syncZbNode(eng); syncThDevices(w); if (w.scan_phase == 1 and c.modulus_wireless_wifi_scan_done()) { @@ -387,7 +438,7 @@ pub fn wirelessPoll(eng: *Engine) void { w.wifi_scan_hw = false; w.scan_c6_down = w.scan_n == 0 and !(c.modulus_c6_sdio_ready() and c.modulus_wireless_transport_up()); if (w.scan_c6_down) { - eng.showSnackbarError("C6 offline — dual-flash C6 UART (COM18)"); + eng.showSnackbarError("Internal C6 offline"); } eng.requestSettingsRepaint(); } @@ -415,7 +466,7 @@ pub fn wirelessPoll(eng: *Engine) void { w.bt_scan_hw = false; if (w.bt_scan_n == 0) { if (!(c.modulus_c6_sdio_ready() and c.modulus_wireless_transport_up())) { - eng.showSnackbarError("No BLE devices — C6 offline (dual-flash COM18)"); + eng.showSnackbarError("No BLE devices — Internal C6 offline"); } else if (!c.modulus_wireless_ble_is_enabled()) { eng.showSnackbarError("No BLE devices — radio off"); } @@ -492,6 +543,7 @@ pub fn wirelessPoll(eng: *Engine) void { eng.cnc.wifi_on = w.wifi; eng.cnc.bt_on = w.bt; eng.cnc.espnow_on = w.espnow; + eng.cnc.espnow_bridge_ready = w.espnow and c.modulus_wireless_espnow_bridge_ready(); } fn wirelessScanStart(eng: *Engine) void { @@ -513,7 +565,7 @@ fn wirelessScanStart(eng: *Engine) void { w.scan_n = 0; w.scan_c6_down = !(c.modulus_c6_sdio_ready() and c.modulus_wireless_transport_up()); eng.showSnackbarError(if (w.scan_c6_down) - "C6 offline — dual-flash C6 UART (COM18)" + "Internal C6 offline" else "Wi-Fi scan failed"); } @@ -532,7 +584,7 @@ fn wirelessScanStart(eng: *Engine) void { w.bt_scan_n = 0; const c6_down = !(c.modulus_c6_sdio_ready() and c.modulus_wireless_transport_up()); eng.showSnackbarError(if (c6_down) - "BLE scan failed — C6 offline (dual-flash COM18)" + "BLE scan failed — Internal C6 offline" else "BLE scan failed — enable radio / wait for Ready"); } @@ -592,10 +644,13 @@ pub fn wirelessCmd(eng: *Engine, cmd: ui_engine.engine.WirelessUiCmd) void { var pbuf: [64]u8 = undefined; const ssid = zTerm(&sbuf, p.ssid); const pass = zTerm(&pbuf, p.pass); - copySliceTo(eng.prefs.wireless.ssid[0..], p.ssid); + // `p.ssid` can already be a slice of the destination prefs buffer. + // Copy from our detached stack buffer so copySliceTo's leading + // memset cannot overlap (and trip Zig's @memcpy alias safety). + copySliceTo(eng.prefs.wireless.ssid[0..], ssid); if (!c.modulus_wireless_wifi_connect(ssid.ptr, pass.ptr)) { eng.prefs.wireless.wifi_connecting = false; - eng.showSnackbarError("Wi-Fi connect failed — C6 / SDIO"); + eng.showSnackbarError("Wi-Fi connect failed — Internal C6"); } else { eng.prefs.wireless.wifi_connecting = true; } @@ -690,6 +745,39 @@ pub fn wirelessCmd(eng: *Engine, cmd: ui_engine.engine.WirelessUiCmd) void { }, .zb_toggle => |idx| _ = c.modulus_wireless_zigbee_device_toggle(@intCast(idx)), .zb_identify => |idx| _ = c.modulus_wireless_zigbee_device_identify(@intCast(idx)), + .zb_rename => |p| { + var name_buf: [32]u8 = undefined; + _ = c.modulus_wireless_zigbee_device_rename(@intCast(p.idx), zTerm(&name_buf, p.name).ptr); + syncZbDevices(&eng.prefs.wireless); + }, + .zb_node_open => |idx| { + var dev: c.modulus_zb_device_t = undefined; + if (c.modulus_wireless_zigbee_device_get(@intCast(idx), &dev)) { + eng.prefs.wireless.zb_node_short = dev.short_addr; + eng.prefs.wireless.zb_node_ready = false; + if (!c.modulus_wireless_zb_node_request(dev.short_addr)) eng.showSnackbarError("Node did not respond"); + } + }, + .zb_node_name => |name| { + var buf: [32]u8 = undefined; + _ = c.modulus_wireless_zb_node_set_name(eng.prefs.wireless.zb_node_short, zTerm(&buf, name).ptr); + }, + .zb_node_channel => |p| { + var buf: [24]u8 = undefined; + _ = c.modulus_wireless_zb_node_set_channel(eng.prefs.wireless.zb_node_short, + p.index, p.typ, p.gpio, p.flags, zTerm(&buf, p.name).ptr); + }, + .zb_node_poll => |p| { + if (!c.modulus_wireless_zb_node_set_poll(eng.prefs.wireless.zb_node_short, p.index, p.seconds)) + eng.showSnackbarError("Could not save interval: check Node firmware/link"); + }, + .zb_node_output => |p| { + if (!c.modulus_wireless_zb_node_set_output(eng.prefs.wireless.zb_node_short, p.index, p.on)) + eng.showSnackbarError("Output command failed"); + }, + .zb_node_status_led => |p| _ = c.modulus_wireless_zb_node_set_status_led( + eng.prefs.wireless.zb_node_short, p.gpio, p.flags), + .zb_node_apply => _ = c.modulus_wireless_zb_node_apply(eng.prefs.wireless.zb_node_short), .zb_remove => |idx| { _ = c.modulus_wireless_zigbee_device_leave(@intCast(idx)); syncZbDevices(&eng.prefs.wireless); @@ -730,6 +818,8 @@ pub fn wirelessCmd(eng: *Engine, cmd: ui_engine.engine.WirelessUiCmd) void { if (p.idx < eng.prefs.wireless.live_zb_n) eng.prefs.wireless.live_zb_snap[p.idx].child_lock = p.on; }, .zb_refresh => { + _ = c.modulus_wireless_zb_get_state(); + _ = c.modulus_wireless_zb_get_devices(); syncZbDevices(&eng.prefs.wireless); }, .zb_clear => { @@ -1294,6 +1384,127 @@ pub fn transportReinit() void { device_runtime.transportReinit(); } +fn copyC6Text(dst: []u8, src: anytype) u8 { + var n: usize = 0; + while (n < dst.len and n < src.len and src[n] != 0) : (n += 1) dst[n] = @intCast(src[n]); + return @intCast(n); +} + +fn c6OtaPoll(eng: *Engine) void { + var snap: c.modulus_c6_ota_snapshot_t = undefined; + c.modulus_c6_ota_get_snapshot(&snap); + // Preserve UI-only navigation state while refreshing the hardware snapshot. + // Reinitialising State here would force the C6 detail page back to its + // default dashboard view on every poll. + var next = eng.m_panel_c6_ota_state; + next.phase = @enumFromInt(@min(@as(u8, @intCast(snap.phase)), @intFromEnum(ui_engine.m_panel_c6_ota.Phase.failed))); + next.file_count = @min(@as(u8, @intCast(snap.file_count)), ui_engine.m_panel_c6_ota.max_files); + next.selected = @min(@as(u8, @intCast(snap.selected)), if (next.file_count > 0) next.file_count - 1 else 0); + next.progress = @min(@as(u8, @intCast(snap.progress)), 100); + next.c6_connected = snap.c6_connected; + next.version_len = copyC6Text(next.version[0..], snap.c6_version); + next.image_version_len = copyC6Text(next.image_version[0..], snap.image_version); + next.status_len = copyC6Text(next.status[0..], snap.status); + var i: usize = 0; + while (i < next.file_count) : (i += 1) next.file_lens[i] = copyC6Text(next.files[i][0..], snap.files[i]); + if (!std.mem.eql(u8, std.mem.asBytes(&eng.m_panel_c6_ota_state), std.mem.asBytes(&next))) { + eng.m_panel_c6_ota_state = next; + if (eng.m_panel_tool == @intFromEnum(ui_engine.m_panel.ToolId.c6_update)) eng.requestFull(); + } +} + +fn c6OtaCmd(eng: *Engine, action: ui_engine.m_panel_c6_ota.Action, index: u8) void { + switch (action) { + .refresh => c.modulus_c6_ota_refresh(), + .select => c.modulus_c6_ota_select(index), + .check => c.modulus_c6_ota_arm_selected(), + .flash => c.modulus_c6_ota_start(), + } + c6OtaPoll(eng); +} + +fn s3OtaPoll(eng: *Engine) void { + var snap: c.modulus_s3_ota_snapshot_t = undefined; + c.modulus_s3_ota_get_snapshot(&snap); + var next = eng.m_panel_s3_ota_state; + next.phase = @enumFromInt(@min(@as(u8, @intCast(snap.phase)), @intFromEnum(ui_engine.m_panel_s3_ota.Phase.failed))); + next.file_count = @min(@as(u8, @intCast(snap.file_count)), ui_engine.m_panel_s3_ota.max_files); + next.selected = @min(@as(u8, @intCast(snap.selected)), if (next.file_count > 0) next.file_count - 1 else 0); + next.progress = @min(@as(u8, @intCast(snap.progress)), 100); + next.s3_connected = snap.s3_connected; + next.config_supported = snap.uart_config_supported; + next.test_supported = snap.uart_test_supported; + next.config_busy = snap.uart_config_busy; + next.uart_tx = snap.uart_tx_gpio; + next.uart_rx = snap.uart_rx_gpio; + next.uart_baud = snap.uart_baud; + if (snap.uart_config_supported and !next.config_loaded) { + next.draft_tx = snap.uart_tx_gpio; + next.draft_rx = snap.uart_rx_gpio; + next.draft_baud = snap.uart_baud; + next.config_loaded = true; + } else if (!snap.uart_config_supported) { + next.config_loaded = false; + } + next.version_len = copyC6Text(next.version[0..], snap.s3_version); + next.image_version_len = copyC6Text(next.image_version[0..], snap.image_version); + next.status_len = copyC6Text(next.status[0..], snap.status); + var i: usize = 0; + while (i < next.file_count) : (i += 1) next.file_lens[i] = copyC6Text(next.files[i][0..], snap.files[i]); + if (!std.mem.eql(u8, std.mem.asBytes(&eng.m_panel_s3_ota_state), std.mem.asBytes(&next))) { + eng.m_panel_s3_ota_state = next; + if (eng.m_panel_tool == @intFromEnum(ui_engine.m_panel.ToolId.s3_update)) eng.requestFull(); + } +} + +fn nanoOtaPoll(eng: *Engine) void { + var snap: c.modulus_nano_ota_snapshot_t = undefined; + c.modulus_nano_ota_get_snapshot(&snap); + var next = eng.m_panel_nano_ota_state; + next.phase = @enumFromInt(@min(@as(u8, @intCast(snap.phase)), @intFromEnum(ui_engine.m_panel_nano_ota.Phase.failed))); + next.file_count = @min(@as(u8, @intCast(snap.file_count)), ui_engine.m_panel_nano_ota.max_files); + next.selected = @min(@as(u8, @intCast(snap.selected)), if (next.file_count > 0) next.file_count - 1 else 0); + next.progress = @min(@as(u8, @intCast(snap.progress)), 100); + next.nano_connected = snap.nano_connected; + next.version_len = copyC6Text(next.version[0..], snap.nano_version); + next.image_version_len = copyC6Text(next.image_version[0..], snap.image_version); + next.status_len = copyC6Text(next.status[0..], snap.status); + var i: usize = 0; + while (i < next.file_count) : (i += 1) next.file_lens[i] = copyC6Text(next.files[i][0..], snap.files[i]); + if (!std.mem.eql(u8, std.mem.asBytes(&eng.m_panel_nano_ota_state), std.mem.asBytes(&next))) { + eng.m_panel_nano_ota_state = next; + if (eng.m_panel_tool == @intFromEnum(ui_engine.m_panel.ToolId.nano_update)) eng.requestFull(); + } +} + +fn nanoOtaCmd(eng: *Engine, action: ui_engine.m_panel_nano_ota.Action, index: u8) void { + switch (action) { + .refresh => c.modulus_nano_ota_refresh(), + .select => c.modulus_nano_ota_select(index), + .check => c.modulus_nano_ota_arm_selected(), + .flash => c.modulus_nano_ota_start(), + } + nanoOtaPoll(eng); +} + +fn s3OtaCmd(eng: *Engine, action: ui_engine.m_panel_s3_ota.Action, index: u8) void { + switch (action) { + .refresh => c.modulus_s3_ota_refresh(), + .select => c.modulus_s3_ota_select(index), + .check => c.modulus_s3_ota_arm_selected(), + .flash => c.modulus_s3_ota_start(), + .restart => c.modulus_s3_ota_restart(), + .config_refresh => c.modulus_s3_uart_config_refresh(), + .config_apply => c.modulus_s3_uart_config_apply( + eng.m_panel_s3_ota_state.draft_tx, + eng.m_panel_s3_ota_state.draft_rx, + eng.m_panel_s3_ota_state.draft_baud, + ), + .config_test => c.modulus_s3_uart_test(), + } + s3OtaPoll(eng); +} + pub fn dumpBegin() void { device_runtime.settingsDumpBegin(); } @@ -1369,6 +1580,12 @@ pub fn install(eng: *Engine) void { eng.wifi_connect_sink = wifiConnect; eng.wifi_disconnect_sink = wifiDisconnect; eng.stor_sys_sink = storSysCmd; + eng.c6_ota_cmd_sink = c6OtaCmd; + eng.c6_ota_poll_sink = c6OtaPoll; + eng.s3_ota_cmd_sink = s3OtaCmd; + eng.s3_ota_poll_sink = s3OtaPoll; + eng.nano_ota_cmd_sink = nanoOtaCmd; + eng.nano_ota_poll_sink = nanoOtaPoll; // storage / i2c / wireless deferred to installLate — after boot i2c_coex. c.modulus_display_resume_activity_monitor(); diff --git a/src/modulus/firmware/device_ui_runtime.zig b/src/modulus/firmware/device_ui_runtime.zig index da5d38b..7a0a58c 100644 --- a/src/modulus/firmware/device_ui_runtime.zig +++ b/src/modulus/firmware/device_ui_runtime.zig @@ -124,7 +124,7 @@ const PsramAllocator = struct { /// The vtable ignores `ptr`, but leaving it `undefined` puts an indeterminate /// value in a struct that gets copied around. Point it at the vtable itself. const psram_allocator: std.mem.Allocator = .{ - .ptr = @constCast(@ptrCast(&PsramAllocator.vtable)), + .ptr = @ptrCast(@constCast(&PsramAllocator.vtable)), .vtable = &PsramAllocator.vtable, }; @@ -502,6 +502,9 @@ export fn modulus_zig_ui_frame() void { device_ui_bridge.wirelessPoll(eng); device_ui_bridge.pollPinLock(eng); device_ui_bridge.mirrorC6Sdio(eng); + if (eng.c6_ota_poll_sink) |poll| poll(eng); + if (eng.s3_ota_poll_sink) |poll| poll(eng); + if (eng.nano_ota_poll_sink) |poll| poll(eng); if (eng.probe_pin_sink) |pin| { eng.qs_probe_trig = pin(); } diff --git a/src/modulus/firmware/prefs_nvs.zig b/src/modulus/firmware/prefs_nvs.zig index 21d43e0..268df65 100644 --- a/src/modulus/firmware/prefs_nvs.zig +++ b/src/modulus/firmware/prefs_nvs.zig @@ -225,13 +225,32 @@ pub fn loadPrefs(p: *settings_prefs.Prefs) void { p.wireless.espnow = getU8(keys.espnow, @intFromBool(p.wireless.espnow)) != 0; p.wireless.zigbee = getU8(keys.zigbee, @intFromBool(p.wireless.zigbee)) != 0; p.wireless.thread = getU8(keys.thread, @intFromBool(p.wireless.thread)) != 0; - p.wireless.ant_ext = getU8(keys.ant_ext, @intFromBool(p.wireless.ant_ext)) != 0; + // Older builds could leave the UI preference and the antenna hardware + // selection out of sync. Migrate once to the safe/default PCB antenna. + // Subsequent explicit menu changes continue to persist through ant_ext. + if (getU8("ant_ui1", 0) == 0) { + p.wireless.ant_ext = false; + setU8(keys.ant_ext, 0); + setU8("ant_ui1", 1); + } else { + p.wireless.ant_ext = getU8(keys.ant_ext, @intFromBool(p.wireless.ant_ext)) != 0; + } p.wireless.wf_auto = getU8(keys.wf_auto, @intFromBool(p.wireless.wf_auto)) != 0; p.wireless.wf_arecon = getU8(keys.wf_arecon, @intFromBool(p.wireless.wf_arecon)) != 0; p.wireless.wf_dhcp = getU8(keys.wf_dhcp, @intFromBool(p.wireless.wf_dhcp)) != 0; // NVS en_chan is 0-based; prefs use 1..13. p.wireless.en_chan = getU8(keys.en_chan, p.wireless.en_chan -| 1) + 1; p.wireless.en_rate = getU8(keys.en_rate, p.wireless.en_rate); + for (p.wireless.zb_node_channels[0..8], 0..) |*ch, i| { + var key: [16]u8 = undefined; + ch.icon = getU8(std.fmt.bufPrint(&key, "zbc{d}_icon", .{i}) catch continue, ch.icon); + ch.favorite = @min(getU8(std.fmt.bufPrint(&key, "zbc{d}_fav", .{i}) catch continue, ch.favorite), 3); + ch.start_mode = @min(getU8(std.fmt.bufPrint(&key, "zbc{d}_start", .{i}) catch continue, ch.start_mode), 2); + ch.temp_alarm_enabled = getU8(std.fmt.bufPrint(&key, "zbc{d}_alen", .{i}) catch continue, @intFromBool(ch.temp_alarm_enabled)) != 0; + ch.temp_alarm_high_centi_c = @bitCast(getU16(std.fmt.bufPrint(&key, "zbc{d}_ahi", .{i}) catch continue, @bitCast(ch.temp_alarm_high_centi_c))); + ch.temp_alarm_low_centi_c = @bitCast(getU16(std.fmt.bufPrint(&key, "zbc{d}_alo", .{i}) catch continue, @bitCast(ch.temp_alarm_low_centi_c))); + ch.temp_alarm_hysteresis_centi_c = getU16(std.fmt.bufPrint(&key, "zbc{d}_ahys", .{i}) catch continue, ch.temp_alarm_hysteresis_centi_c); + } p.audio.tone_prof = getU8(keys.tone_prof, p.audio.tone_prof); p.audio.tsound = getU8(keys.tsound, @intFromBool(p.audio.tsound)) != 0; @@ -241,7 +260,12 @@ pub fn loadPrefs(p: *settings_prefs.Prefs) void { p.power.setDimFromSec(getU16(keys.dim_to, p.power.dimSec())); p.power.setScrFromSec(getU16(keys.scr_to, p.power.scrSec())); - p.power.ext5v = getU8(keys.ext5v, @intFromBool(p.power.ext5v)) != 0; + // Port A powers the MPG handwheel. A persisted off state used to survive + // firmware updates and made jogging appear broken even though CNC and + // ESP-NOW were connected. EXT5V is therefore a boot-on rail: it may still + // be switched off temporarily for diagnostics, but never starts off. + p.power.ext5v = true; + setU8(keys.ext5v, 1); p.power.usb5v = getU8(keys.usb5v, @intFromBool(p.power.usb5v)) != 0; p.power.pwr_mode = getU8(keys.pwr_mode, p.power.pwr_mode); p.power.setDstoFromSec(getU16(keys.pwr_dsto, p.power.dstoSec())); @@ -451,6 +475,16 @@ pub fn savePrefs(p: *const settings_prefs.Prefs) void { setU8(keys.wf_dhcp, @intFromBool(p.wireless.wf_dhcp)); setU8(keys.en_chan, p.wireless.en_chan -| 1); setU8(keys.en_rate, p.wireless.en_rate); + for (p.wireless.zb_node_channels[0..8], 0..) |ch, i| { + var key: [16]u8 = undefined; + setU8(std.fmt.bufPrint(&key, "zbc{d}_icon", .{i}) catch continue, ch.icon); + setU8(std.fmt.bufPrint(&key, "zbc{d}_fav", .{i}) catch continue, ch.favorite); + setU8(std.fmt.bufPrint(&key, "zbc{d}_start", .{i}) catch continue, ch.start_mode); + setU8(std.fmt.bufPrint(&key, "zbc{d}_alen", .{i}) catch continue, @intFromBool(ch.temp_alarm_enabled)); + setU16(std.fmt.bufPrint(&key, "zbc{d}_ahi", .{i}) catch continue, @bitCast(ch.temp_alarm_high_centi_c)); + setU16(std.fmt.bufPrint(&key, "zbc{d}_alo", .{i}) catch continue, @bitCast(ch.temp_alarm_low_centi_c)); + setU16(std.fmt.bufPrint(&key, "zbc{d}_ahys", .{i}) catch continue, ch.temp_alarm_hysteresis_centi_c); + } setU8(keys.tone_prof, p.audio.tone_prof); setU8(keys.tsound, @intFromBool(p.audio.tsound)); diff --git a/src/modulus/ui_engine/dashboard.zig b/src/modulus/ui_engine/dashboard.zig index 6bbd1a8..4b63e94 100644 --- a/src/modulus/ui_engine/dashboard.zig +++ b/src/modulus/ui_engine/dashboard.zig @@ -58,6 +58,8 @@ pub const CncView = struct { wifi_on: bool = false, bt_on: bool = false, espnow_on: bool = false, + /// Device: the configured ESP-NOW S3 bridge answered the latest air probe. + espnow_bridge_ready: bool = false, /// CNC transport index — fallback when no wireless radio is on. conn: u8 = 4, unit_mm: bool = true, @@ -494,7 +496,10 @@ fn paintStatusBar(logical: *fb.LogicalFb, theme: tokens.Theme, cnc: CncView) voi if (any_radio) { if (cnc.espnow_on) { rx -= ico_24; - icons_phosphor.drawCenteredScaled(logical, rx + @divTrunc(ico_24, 2), mid_y, .broadcast, theme.primary, ico_24); + // Enabled is not the same as connected: orange while the saved S3 + // is being found/reconnected, green only after a real air reply. + const espnow_fg = if (cnc.espnow_bridge_ready) theme.cycle else theme.hold; + icons_phosphor.drawCenteredScaled(logical, rx + @divTrunc(ico_24, 2), mid_y, .broadcast, espnow_fg, ico_24); rx -= tokens.Space.xs; } if (cnc.bt_on) { diff --git a/src/modulus/ui_engine/demo_main.zig b/src/modulus/ui_engine/demo_main.zig index e157bfc..dfc31fe 100644 --- a/src/modulus/ui_engine/demo_main.zig +++ b/src/modulus/ui_engine/demo_main.zig @@ -50,6 +50,17 @@ fn paceFrame(frame_start_ms: u64) void { fn runWindow(gpa: std.mem.Allocator, io: std.Io) !void { var eng = try ui_engine.engine.Engine.create(gpa); defer eng.destroy(gpa); + eng.prefs.wireless.zb_node_channel_count = 8; + for (eng.prefs.wireless.zb_node_channels[0..8], 0..) |*ch, i| { + ch.typ = if (i == 3) 4 else 2; + ch.gpio = @intCast(i + 1); + ch.digital_value = i < 2; + ch.temperature_state = if (i == 3) 1 else 0; + ch.temperature_centi_c = if (i == 3) 2475 else 0; + ch.favorite = if (i < 3) @intCast(i + 1) else 0; + const names = [_][]const u8{ "Dust extractor", "Work light", "Air valve", "Coolant water", "Relay 5", "Relay 6", "Relay 7", "Relay 8" }; + @memcpy(ch.name[0..names[i].len], names[i]); + } var view = try host_win32.View.open("Modulus UI Engine"); defer view.close(); @@ -96,7 +107,7 @@ fn runWindow(gpa: std.mem.Allocator, io: std.Io) !void { if (input.key_theme and !eng.searchFocused()) eng.toggleTheme(); if (input.key_dialog and !eng.searchFocused()) eng.openDialog(); if (input.key_pin and !eng.searchFocused()) eng.openPin(); - if (input.key_catalog and !eng.searchFocused()) eng.openCatalog(); + if (input.key_catalog and !eng.searchFocused()) eng.openMPanelPreview(); if (input.key_tab) eng.handleFocusTab(false); if (input.key_shift_tab) eng.handleFocusTab(true); if (input.key_enter) eng.activateFocus(); diff --git a/src/modulus/ui_engine/engine.zig b/src/modulus/ui_engine/engine.zig index 3d48bea..127b499 100644 --- a/src/modulus/ui_engine/engine.zig +++ b/src/modulus/ui_engine/engine.zig @@ -36,6 +36,12 @@ const m_panel_usb = @import("m_panel_usb.zig"); const m_panel_probe = @import("m_panel_probe.zig"); const m_panel_sd = @import("m_panel_sd.zig"); const m_panel_zigbee = @import("m_panel_zigbee.zig"); +const m_panel_zigbee_add = @import("m_panel_zigbee_add.zig"); +const m_panel_controls = @import("m_panel_controls.zig"); +const m_panel_update_hub = @import("m_panel_update_hub.zig"); +const m_panel_c6_ota = @import("m_panel_c6_ota.zig"); +const m_panel_nano_ota = @import("m_panel_nano_ota.zig"); +const m_panel_s3_ota = @import("m_panel_s3_ota.zig"); const zb_exposes = @import("zb_exposes.zig"); const sd_volume = @import("sd_volume.zig"); const usb_volume = @import("usb_volume.zig"); @@ -82,11 +88,12 @@ const settings_header_h: i32 = settings_form.title_h; const cat_item_h: i32 = settings_form.cat_item_h; /// Splash length in wall-clock seconds — host demo and Tab5 tick at different /// rates, so counting frames made the splash scale with the loop period. -const boot_seconds: f32 = 3.0; +const boot_seconds: f32 = 5.0; /// Host product line (System & about + splash). pub const ui_engine_product: []const u8 = "ZIG UI Engine"; pub const ui_engine_version: []const u8 = "V1.0"; const boot_credit: []const u8 = "Driven by M5Stack | Powered by Zig | Built on ESP-IDF"; +const boot_ota_credit: []const u8 = "OTA VERSION BY FELLPOWER"; const FocusKind = enum { none, gear, power, search, close }; @@ -174,6 +181,12 @@ pub const Engine = struct { wifi_disconnect_sink: ?*const fn () void = null, wireless_cmd_sink: ?*const fn (*Engine, WirelessUiCmd) void = null, stor_sys_sink: ?*const fn (*Engine, StorSysUiCmd) void = null, + c6_ota_cmd_sink: ?*const fn (*Engine, m_panel_c6_ota.Action, u8) void = null, + c6_ota_poll_sink: ?*const fn (*Engine) void = null, + s3_ota_cmd_sink: ?*const fn (*Engine, m_panel_s3_ota.Action, u8) void = null, + s3_ota_poll_sink: ?*const fn (*Engine) void = null, + nano_ota_cmd_sink: ?*const fn (*Engine, m_panel_nano_ota.Action, u8) void = null, + nano_ota_poll_sink: ?*const fn (*Engine) void = null, /// A USB G-code file is armed as the pending job. Set by the Load confirm, /// cleared by the bridge when the streamer goes terminal. While set, Cycle /// Start starts the pendant stream instead of a plain controller resume. @@ -289,6 +302,9 @@ pub const Engine = struct { cnc_prof_layout: settings_cnc_modals.ProfLayout = .{}, cnc_dump: settings_cnc_modals.DumpState = .{}, cnc_rename_slot: u8 = 0, + zb_rename_idx: u8 = 0, + zb_node_channel_idx: u8 = 0, + zb_temp_alarm_idx: u8 = 0xff, cnc_overlay_fx: spring.Spring = spring.Spring.effects(0), dash_overlay: settings_dashboard_modals.Kind = .none, dash_wcs_layout: settings_dashboard_modals.WcsLayout = .{}, @@ -370,12 +386,26 @@ pub const Engine = struct { m_panel_sd_import_path: [sd_volume.path_len]u8 = .{0} ** sd_volume.path_len, m_panel_sd_footer_overflow_seen: bool = false, m_panel_zb_layout: m_panel_zigbee.Layout = .{}, + m_panel_zb_add_layout: m_panel_zigbee_add.Layout = .{}, + m_panel_zb_add_state: m_panel_zigbee_add.State = .{}, + m_panel_zb_add_open: bool = false, + m_panel_controls_layout: m_panel_controls.Layout = .{}, + m_panel_controls_page: u8 = 0, + /// 0..2 while choosing a quick-control slot, 0xff in normal control mode. + m_panel_quick_slot: u8 = 0xff, m_panel_zb_scroll: i32 = 0, m_panel_zb_level_drag: bool = false, m_panel_zb_menu_dev: u8 = 0xff, m_panel_zb_menu_field: u8 = 0, m_panel_zb_menu_rect: geom.Rect = .{}, m_panel_zb_menu_scroll: usize = 0, + m_panel_update_hub_layout: m_panel_update_hub.Layout = .{}, + m_panel_c6_ota_layout: m_panel_c6_ota.Layout = .{}, + m_panel_c6_ota_state: m_panel_c6_ota.State = .{}, + m_panel_s3_ota_layout: m_panel_s3_ota.Layout = .{}, + m_panel_s3_ota_state: m_panel_s3_ota.State = .{}, + m_panel_nano_ota_layout: m_panel_nano_ota.Layout = .{}, + m_panel_nano_ota_state: m_panel_nano_ota.State = .{}, m_panel_tool: u8 = 0xff, needs_full_repaint: bool = true, /// After full paint on settings: present window AABB only (margins unchanged). @@ -560,13 +590,18 @@ pub const Engine = struct { const gap = tokens.Space.xl; const title_h = tokens.TypeRole.display_l.lineHeight(); const cap_h = tokens.TypeRole.label_l.lineHeight(); - const block_h = title_h + gap + cap_h + gap + cap_h; + const ota_h = tokens.TypeRole.title_m.lineHeight(); + const block_h = title_h + tokens.Space.md + ota_h + gap + cap_h + gap + cap_h; var y: i32 = @divTrunc(@as(i32, tokens.Logical.height) - block_h, 2); const tw = font.textWidthStr(title, .display_l) + title_track * @as(i32, @intCast(title.len -| 1)); const tx = @divTrunc(@as(i32, tokens.Logical.width) - tw, 2); font.drawTextRoleTracked(&self.logical, tx, y, title, self.theme.primary, .display_l, title_track, false); - y += title_h + gap; + y += title_h + tokens.Space.md; + + const ow = font.textWidthStr(boot_ota_credit, .title_m); + font.drawTextRole(&self.logical, @divTrunc(@as(i32, tokens.Logical.width) - ow, 2), y, boot_ota_credit, self.theme.primary, .title_m); + y += ota_h + gap; const cw = font.textWidthStr(boot_credit, .label_l); font.drawTextRole(&self.logical, @divTrunc(@as(i32, tokens.Logical.width) - cw, 2), y, boot_credit, self.theme.on_surface_variant, .label_l); @@ -679,19 +714,24 @@ pub const Engine = struct { self.m_panel_sd_footer_overflow_seen = false; } } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.zigbee)) { - self.m_panel_zb_layout = m_panel_zigbee.paint( - &self.logical, - self.theme, - .{ - .wireless = &self.prefs.wireless, - .scroll_px = self.m_panel_zb_scroll, - .anim_t = @as(f32, @floatFromInt(self.frame_n % 120)) / 120.0, - .menu_dev = self.m_panel_zb_menu_dev, - .menu_field = @enumFromInt(self.m_panel_zb_menu_field), - }, - self.m_panel_fx.value, - ); - if (self.m_panel_zb_menu_dev != 0xff) { + if (self.m_panel_zb_add_open) { + self.driveZbAddWizard(); + self.m_panel_zb_add_layout = m_panel_zigbee_add.paint(&self.logical, self.theme, self.m_panel_zb_add_state, self.prefs.wireless.zigbee, self.prefs.wireless.zb_joined, self.prefs.wireless.live_zb_n, self.m_panel_fx.value); + } else { + self.m_panel_zb_layout = m_panel_zigbee.paint( + &self.logical, + self.theme, + .{ + .wireless = &self.prefs.wireless, + .scroll_px = self.m_panel_zb_scroll, + .anim_t = @as(f32, @floatFromInt(self.frame_n % 120)) / 120.0, + .menu_dev = self.m_panel_zb_menu_dev, + .menu_field = @enumFromInt(self.m_panel_zb_menu_field), + }, + self.m_panel_fx.value, + ); + } + if (!self.m_panel_zb_add_open and self.m_panel_zb_menu_dev != 0xff) { const field: zb_exposes.Field = @enumFromInt(self.m_panel_zb_menu_field); const snap = self.prefs.wireless.zbSnap(self.m_panel_zb_menu_dev); const sel: usize = switch (field) { @@ -709,6 +749,43 @@ pub const Engine = struct { self.m_panel_zb_menu_scroll, ); } + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.controls)) { + self.m_panel_controls_layout = m_panel_controls.paint(&self.logical, self.theme, &self.prefs.wireless.zb_node_channels, self.prefs.wireless.zb_node_channel_count, self.m_panel_controls_page, self.m_panel_quick_slot, self.m_panel_fx.value); + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.firmware_update)) { + self.m_panel_update_hub_layout = m_panel_update_hub.paint( + &self.logical, + self.theme, + self.m_panel_c6_ota_state.c6_connected, + self.m_panel_s3_ota_state.s3_connected, + self.m_panel_nano_ota_state.nano_connected, + self.prefs.wireless.zb_node_ready, + self.m_panel_c6_ota_state.versionText(), + self.m_panel_s3_ota_state.versionText(), + self.m_panel_nano_ota_state.versionText(), + self.prefs.storage.usb_host, + self.m_panel_fx.value, + ); + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.c6_update)) { + self.m_panel_c6_ota_layout = m_panel_c6_ota.paint( + &self.logical, + self.theme, + &self.m_panel_c6_ota_state, + self.m_panel_fx.value, + ); + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.s3_update)) { + self.m_panel_s3_ota_layout = m_panel_s3_ota.paint( + &self.logical, + self.theme, + &self.m_panel_s3_ota_state, + self.m_panel_fx.value, + ); + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.nano_update)) { + self.m_panel_nano_ota_layout = m_panel_nano_ota.paint( + &self.logical, + self.theme, + &self.m_panel_nano_ota_state, + self.m_panel_fx.value, + ); } else { self.m_panel_tool_layout = m_panel.paintTool( &self.logical, @@ -724,6 +801,10 @@ pub const Engine = struct { self.m_panel_scroll, self.m_panel_fx.value, self.prefs.storage.usb_host, + &self.prefs.wireless.zb_node_channels, + self.prefs.wireless.zb_node_channel_count, + self.prefs.cnc.session_up or self.m_panel_s3_ota_state.s3_connected, + self.prefs.wireless.live_zb_n, ); } @@ -842,6 +923,7 @@ pub const Engine = struct { (@as(u32, @intFromBool(cnc.wifi_on)) << 12) | (@as(u32, @intFromBool(cnc.bt_on)) << 11) | (@as(u32, @intFromBool(cnc.espnow_on)) << 10) | + (@as(u32, @intFromBool(cnc.espnow_bridge_ready)) << 9) | (@as(u32, cnc.alarm_code) << 5) | (@as(u32, cnc.wcs_i) & 0x1f); } @@ -1661,13 +1743,7 @@ pub const Engine = struct { /// Modal chrome that must not be punched through by content-pane refresh. fn settingsHasBlockingModal(self: *const Engine) bool { - return self.settings_confirm != .none - or self.pad.open - or self.cnc_overlay != .none - or self.dash_overlay != .none - or self.pin_overlay != .none - or self.mach_str_overlay != .none - or self.extra_overlay != .none; + return self.settings_confirm != .none or self.pad.open or self.cnc_overlay != .none or self.dash_overlay != .none or self.pin_overlay != .none or self.mach_str_overlay != .none or self.extra_overlay != .none; } /// True when settings shell paint can present window AABB only (PSRAM BW). @@ -3808,6 +3884,78 @@ pub const Engine = struct { }, .wl_pass => settings_prefs.WirelessPrefs.setDraftField(&self.prefs.wireless.draft_pass, txt), .wl_zb_code => settings_prefs.WirelessPrefs.setDraftField(&self.prefs.wireless.zb_install, txt), + .wl_zb_name => { + if (txt.len == 0) { + self.showSnackbarError("Name required"); + return; + } + if (self.emitWireless(.{ .zb_rename = .{ .idx = self.zb_rename_idx, .name = txt } })) { + self.showSnackbar("Device renamed"); + } + }, + .wl_zb_node_name => { + if (txt.len == 0) { self.showSnackbarError("Name required"); return; } + _ = self.emitWireless(.{ .zb_node_name = txt }); + }, + .wl_zb_node_channel_name => { + const i = self.zb_node_channel_idx; + if (i < self.prefs.wireless.zb_node_channels.len and txt.len != 0) { + const ch = &self.prefs.wireless.zb_node_channels[i]; + @memset(&ch.name, 0); + @memcpy(ch.name[0..@min(txt.len, ch.name.len - 1)], txt[0..@min(txt.len, ch.name.len - 1)]); + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + } + }, + .wl_zb_node_poll => { + const seconds = std.fmt.parseInt(u16, txt, 10) catch 0; + if (seconds < 15 or seconds > 3600) { self.showSnackbarError("Poll interval: 15-3600 seconds"); return; } + const i = self.zb_node_channel_idx; + if (i < self.prefs.wireless.zb_node_channels.len) { + self.prefs.wireless.zb_node_channels[i].poll_interval_s = seconds; + _ = self.emitWireless(.{ .zb_node_poll = .{ .index=i, .seconds=seconds } }); + } + }, + .wl_zb_node_alarm_high, .wl_zb_node_alarm_low, .wl_zb_node_alarm_hysteresis => { + const degrees = std.fmt.parseInt(i16, txt, 10) catch { + self.showSnackbarError("Enter whole degrees Celsius"); + return; + }; + const i = self.zb_node_channel_idx; + if (i >= self.prefs.wireless.zb_node_channels.len) return; + const ch = &self.prefs.wireless.zb_node_channels[i]; + switch (self.pad.target) { + .wl_zb_node_alarm_high => ch.temp_alarm_high_centi_c = std.math.clamp(degrees, -55, 150) * 100, + .wl_zb_node_alarm_low => ch.temp_alarm_low_centi_c = std.math.clamp(degrees, -55, 150) * 100, + .wl_zb_node_alarm_hysteresis => ch.temp_alarm_hysteresis_centi_c = @intCast(std.math.clamp(degrees, 0, 20) * 100), + else => unreachable, + } + if (ch.temp_alarm_low_centi_c >= ch.temp_alarm_high_centi_c) { + self.showSnackbarError("Low alarm must be below high alarm"); + return; + } + }, + .wl_zb_node_gpio => if (txt.len == 0) { + const i = self.zb_node_channel_idx; + if (i == 0xff) { + self.prefs.wireless.zb_node_led_gpio = -1; + _ = self.emitWireless(.{ .zb_node_status_led = .{ .gpio = -1, .flags = self.prefs.wireless.zb_node_led_flags } }); + } else if (i < self.prefs.wireless.zb_node_channels.len) { + self.prefs.wireless.zb_node_channels[i].gpio = -1; + const ch = &self.prefs.wireless.zb_node_channels[i]; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + } + } else if (self.pad.parseU32()) |v| { + if (v > 30) { self.showSnackbarError("GPIO must be 0 to 30"); return; } + const i = self.zb_node_channel_idx; + if (i == 0xff) { + self.prefs.wireless.zb_node_led_gpio = @intCast(v); + _ = self.emitWireless(.{ .zb_node_status_led = .{ .gpio = @intCast(v), .flags = self.prefs.wireless.zb_node_led_flags } }); + } else if (i < self.prefs.wireless.zb_node_channels.len) { + self.prefs.wireless.zb_node_channels[i].gpio = @intCast(v); + const ch = &self.prefs.wireless.zb_node_channels[i]; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + } + }, .wl_th_node => settings_prefs.WirelessPrefs.setDraftField(&self.prefs.wireless.th_node, txt), .wl_bt_passkey => settings_prefs.WirelessPrefs.setDraftField(&self.prefs.wireless.bt_passkey, txt), .wl_en_mac => { @@ -4181,7 +4329,7 @@ pub const Engine = struct { } if (self.screen == .power) { if (self.power_confirm != .none) { - if (self.power_layout.confirm_ok.contains(x, y)) { + if (self.power_layout.confirm_ok.contains(x, y)) { if (self.power_confirm == .shutdown) { if (self.power_shutdown_sink) |s| s() else self.showSnackbar("Shutdown (stub)"); } else { @@ -4758,6 +4906,53 @@ pub const Engine = struct { self.requestFull(); return; } + if (r.hit == .wl_zb_rename) { + self.zb_rename_idx = r.aux; + self.openTextPad(.wl_zb_name, "Device name", self.prefs.wireless.zbDevLabel(r.aux)); + return; + } + if (r.hit == .wl_zb_node) { + self.prefs.wireless.zb_node_open = true; + self.prefs.wireless.zb_node_ready = false; + self.prefs.wireless.zb_node_idx = r.aux; + _ = self.emitWireless(.{ .zb_node_open = r.aux }); + self.requestFull(); return; + } + if (r.hit == .wl_zb_node_close) { + self.prefs.wireless.zb_node_open = false; self.requestFull(); return; + } + if (r.hit == .wl_zb_node_name) { + self.openTextPad(.wl_zb_node_name, "Node name", std.mem.sliceTo(&self.prefs.wireless.zb_node_name, 0)); return; + } + if (r.hit == .wl_zb_node_type) { + const i = r.aux; if (i < self.prefs.wireless.zb_node_channels.len) { + const ch = &self.prefs.wireless.zb_node_channels[i]; ch.typ = (ch.typ + 1) % 5; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + } + self.requestFull(); return; + } + if (r.hit == .wl_zb_node_gpio) { + self.zb_node_channel_idx = r.aux; + const gpio = self.prefs.wireless.zb_node_channels[r.aux].gpio; + self.openNumberForTarget(.wl_zb_node_gpio, "GPIO 0-30 (empty = None)", if (gpio < 0) 0 else @intCast(gpio)); return; + } + if (r.hit == .wl_zb_node_polarity) { + const i = r.aux; if (i < self.prefs.wireless.zb_node_channels.len) { + const ch = &self.prefs.wireless.zb_node_channels[i]; ch.flags ^= 1; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + } + self.requestFull(); return; + } + if (r.hit == .wl_zb_node_pullup) { + const i = r.aux; if (i < self.prefs.wireless.zb_node_channels.len) { + const ch = &self.prefs.wireless.zb_node_channels[i]; ch.flags ^= 2; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + } + self.requestFull(); return; + } + if (r.hit == .wl_zb_node_apply) { + _ = self.emitWireless(.zb_node_apply); self.showSnackbar("Node restarting..."); self.requestFull(); return; + } if (r.hit == .wl_zb_remove) { if (!self.emitWireless(.{ .zb_remove = r.aux })) { if (r.aux < self.prefs.wireless.live_zb_n) { @@ -5313,6 +5508,7 @@ pub const Engine = struct { self.m_panel_term_scroll = 0; self.m_panel_usb_scroll = 0; self.m_panel_sd_scroll = 0; + self.m_panel_quick_slot = 0xff; self.m_panel_zb_scroll = 0; self.m_panel_zb_level_drag = false; self.m_panel_sd_import_len = 0; @@ -5584,6 +5780,11 @@ pub const Engine = struct { switch (h.kind) { .none => {}, .back, .scrim => { + if (self.prefs.wireless.zb_node_open) { + self.prefs.wireless.zb_node_open = false; + self.requestFull(); + return; + } self.closeZbMenu(); self.returnToMPanelFromTool(); }, @@ -5613,6 +5814,11 @@ pub const Engine = struct { self.applyPrefs(); self.requestFull(); }, + .add_device => { + self.m_panel_zb_add_open = true; + self.m_panel_zb_add_state = .{}; + self.requestFull(); + }, .toggle => { if (!self.emitWireless(.{ .zb_toggle = h.dev })) { if (h.dev < self.prefs.wireless.zb_dev_on.len) { @@ -5699,6 +5905,126 @@ pub const Engine = struct { } self.requestFull(); }, + .configure => { + self.prefs.wireless.zb_node_open = true; + self.prefs.wireless.zb_node_ready = false; + self.prefs.wireless.zb_node_idx = h.dev; + self.prefs.wireless.zb_node_page = 0; + self.m_panel_zb_scroll = 0; + _ = self.emitWireless(.{ .zb_node_open = h.dev }); + self.requestFull(); + }, + .node_name => self.openTextPad(.wl_zb_node_name, "Node name", std.mem.sliceTo(&self.prefs.wireless.zb_node_name, 0)), + .node_channel_name => { + self.zb_node_channel_idx = h.dev; + self.openTextPad(.wl_zb_node_channel_name, "Channel name", std.mem.sliceTo(&self.prefs.wireless.zb_node_channels[h.dev].name, 0)); + }, + .node_led_gpio => { + self.zb_node_channel_idx = 0xff; + const gpio = self.prefs.wireless.zb_node_led_gpio; + self.openNumberForTarget(.wl_zb_node_gpio, "LED GPIO 0-30 (empty = None)", if (gpio < 0) 0 else @intCast(gpio)); + }, + .node_led_polarity => { + self.prefs.wireless.zb_node_led_flags ^= 1; + _ = self.emitWireless(.{ .zb_node_status_led = .{ .gpio = self.prefs.wireless.zb_node_led_gpio, .flags = self.prefs.wireless.zb_node_led_flags } }); + self.requestFull(); + }, + .node_type => { + const i = h.dev; + const ch = &self.prefs.wireless.zb_node_channels[i]; + ch.typ = (ch.typ + 1) % 5; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + self.requestFull(); + }, + .node_gpio => { + self.zb_node_channel_idx = h.dev; + const gpio = self.prefs.wireless.zb_node_channels[h.dev].gpio; + self.openNumberForTarget(.wl_zb_node_gpio, "GPIO 0-30 (empty = None)", if (gpio < 0) 0 else @intCast(gpio)); + }, + .node_poll => { + self.zb_node_channel_idx = h.dev; + self.openNumberForTarget(.wl_zb_node_poll, "Poll interval: 15-3600 seconds", self.prefs.wireless.zb_node_channels[h.dev].poll_interval_s); + }, + .node_favorite => { + const ch = &self.prefs.wireless.zb_node_channels[h.dev]; + const next: u8 = (ch.favorite + 1) % 4; + if (next != 0) for (&self.prefs.wireless.zb_node_channels) |*other| { + if (other.favorite == next) other.favorite = 0; + }; + ch.favorite = next; + self.applyPrefs(); + self.requestFull(); + }, + .node_start => { + const ch = &self.prefs.wireless.zb_node_channels[h.dev]; + ch.start_mode = if (ch.start_mode == 0) 1 else 0; + if (ch.start_mode == 1) ch.flags |= 4 else ch.flags &= ~@as(u8, 4); + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = h.dev, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + self.applyPrefs(); + self.requestFull(); + }, + .node_alarm_enabled => { + const ch = &self.prefs.wireless.zb_node_channels[h.dev]; + ch.temp_alarm_enabled = !ch.temp_alarm_enabled; + if (!ch.temp_alarm_enabled) ch.temp_alarm_active = false; + self.applyPrefs(); + self.requestFull(); + }, + .node_alarm_high => { + self.zb_node_channel_idx = h.dev; + const v = @divTrunc(self.prefs.wireless.zb_node_channels[h.dev].temp_alarm_high_centi_c, 100); + self.openNumberForTarget(.wl_zb_node_alarm_high, "High alarm in degrees Celsius", @intCast(@max(0, v))); + }, + .node_alarm_low => { + self.zb_node_channel_idx = h.dev; + const v = @divTrunc(self.prefs.wireless.zb_node_channels[h.dev].temp_alarm_low_centi_c, 100); + self.openNumberForTarget(.wl_zb_node_alarm_low, "Low alarm in degrees Celsius", @intCast(@max(0, v))); + }, + .node_alarm_hysteresis => { + self.zb_node_channel_idx = h.dev; + self.openNumberForTarget(.wl_zb_node_alarm_hysteresis, "Hysteresis in whole degrees Celsius", self.prefs.wireless.zb_node_channels[h.dev].temp_alarm_hysteresis_centi_c / 100); + }, + .node_polarity => { + const i = h.dev; + const ch = &self.prefs.wireless.zb_node_channels[i]; + ch.flags ^= 1; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + self.requestFull(); + }, + .node_pullup => { + const i = h.dev; + const ch = &self.prefs.wireless.zb_node_channels[i]; + ch.flags ^= 2; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index = i, .typ = ch.typ, .gpio = ch.gpio, .flags = ch.flags, .name = std.mem.sliceTo(&ch.name, 0) } }); + self.requestFull(); + }, + .node_apply => { + _ = self.emitWireless(.zb_node_apply); + self.prefs.wireless.zb_node_open = false; + self.showSnackbar("Node restarting..."); + self.requestFull(); + }, + .node_close => { + self.prefs.wireless.zb_node_open = false; + self.requestFull(); + }, + .node_prev => { + self.prefs.wireless.zb_node_page -|= 1; + self.requestFull(); + }, + .node_next => { + const pages = self.prefs.wireless.zb_node_channel_count + 1; + if (self.prefs.wireless.zb_node_page + 1 < pages) { + self.prefs.wireless.zb_node_page += 1; + } else if (self.prefs.wireless.zb_node_channel_count < self.prefs.wireless.zb_node_channels.len) { + const i = self.prefs.wireless.zb_node_channel_count; + self.prefs.wireless.zb_node_channels[i] = .{}; + self.prefs.wireless.zb_node_channel_count += 1; + self.prefs.wireless.zb_node_page = i + 1; + _ = self.emitWireless(.{ .zb_node_channel = .{ .index=i, .typ=0, .gpio=-1, .flags=0, .name="Channel" } }); + } + self.requestFull(); + }, .exposes => { self.showSnackbar("Exposes - see Settings > Wireless"); }, @@ -5733,6 +6059,7 @@ pub const Engine = struct { self.m_panel_term_scroll = 0; self.m_panel_usb_scroll = 0; self.m_panel_sd_scroll = 0; + if (index == @intFromEnum(m_panel.ToolId.controls)) self.m_panel_quick_slot = 0xff; if (index == @intFromEnum(m_panel.ToolId.usb)) { self.m_panel_usb_catalog.refresh(self.prefs.storage.usb_host); } @@ -5746,6 +6073,24 @@ pub const Engine = struct { if (!self.prefs.wireless.zigbee) self.prefs.wireless.zigbee = true; _ = self.emitWireless(.zb_refresh); } + if (index == @intFromEnum(m_panel.ToolId.firmware_update)) { + if (self.c6_ota_cmd_sink) |sink| sink(self, .refresh, 0); + if (self.s3_ota_cmd_sink) |sink| sink(self, .refresh, 0); + if (self.nano_ota_cmd_sink) |sink| sink(self, .refresh, 0); + } + if (index == @intFromEnum(m_panel.ToolId.c6_update)) { + self.m_panel_c6_ota_state.view = .dashboard; + if (self.c6_ota_cmd_sink) |sink| sink(self, .refresh, 0); + } + if (index == @intFromEnum(m_panel.ToolId.s3_update)) { + self.m_panel_s3_ota_state.view = .dashboard; + if (self.s3_ota_cmd_sink) |sink| sink(self, .refresh, 0); + if (self.s3_ota_cmd_sink) |sink| sink(self, .config_refresh, 0); + } + if (index == @intFromEnum(m_panel.ToolId.nano_update)) { + self.m_panel_nano_ota_state.view = .dashboard; + if (self.nano_ota_cmd_sink) |sink| sink(self, .refresh, 0); + } if (index == @intFromEnum(m_panel.ToolId.terminal) and self.m_panel_term_auto_scroll) { self.terminalFollowTail(); } @@ -5766,6 +6111,105 @@ pub const Engine = struct { self.requestFull(); } + fn openFirmwareTarget(self: *Engine, target: m_panel_update_hub.Target) void { + if (target == .node) { + self.showSnackbar("Zigbee Node update requires USB"); + self.requestFull(); + return; + } + self.m_panel_tool = switch (target) { + .c6 => @intFromEnum(m_panel.ToolId.c6_update), + .s3 => @intFromEnum(m_panel.ToolId.s3_update), + .nano => @intFromEnum(m_panel.ToolId.nano_update), + .node => unreachable, + }; + switch (target) { + .c6 => { + self.m_panel_c6_ota_state.view = .firmware; + if (self.c6_ota_cmd_sink) |sink| sink(self, .refresh, 0); + }, + .s3 => { + self.m_panel_s3_ota_state.view = .firmware; + if (self.s3_ota_cmd_sink) |sink| sink(self, .refresh, 0); + }, + .nano => { + self.m_panel_nano_ota_state.view = .firmware; + if (self.nano_ota_cmd_sink) |sink| sink(self, .refresh, 0); + }, + .node => unreachable, + } + snapEnter(&self.m_panel_fx); + self.requestFull(); + } + + /// Host preview entry point used to review the M-Panel before device flash. + pub fn openMPanelPreview(self: *Engine) void { + self.openMPanel(); + } + + fn beginZbAdd(self: *Engine, device_type: m_panel_zigbee_add.DeviceType) void { + self.m_panel_zb_add_state = .{ .stage = .connect, .device_type = device_type, .devices_before = self.prefs.wireless.live_zb_n }; + if (!self.prefs.wireless.zigbee) { + self.prefs.wireless.zigbee = true; + self.applyPrefs(); + } + self.driveZbAddWizard(); + self.requestFull(); + } + + fn driveZbAddWizard(self: *Engine) void { + if (!self.m_panel_zb_add_open or self.m_panel_zb_add_state.stage != .connect) return; + if (self.prefs.wireless.live_zb_n > self.m_panel_zb_add_state.devices_before) { + self.m_panel_zb_add_state.stage = .found; + return; + } + if (!self.prefs.wireless.zb_joined) { + if (!self.prefs.wireless.zb_join_pending) { + if (!self.emitWireless(.zb_join)) self.prefs.wireless.joinZigbee() else self.prefs.wireless.zb_join_pending = true; + } + return; + } + if (!self.m_panel_zb_add_state.pairing_started) { + self.m_panel_zb_add_state.pairing_started = true; + self.startZbPermitJoin(); + } + } + + fn handleZbAddClick(self: *Engine, x: i32, y: i32) void { + const h = m_panel_zigbee_add.hit(self.m_panel_zb_add_layout, x, y); + switch (h.kind) { + .none => {}, + .exit => self.closeToolToDashboard(), + .back => if (self.m_panel_zb_add_state.stage == .choose) { + self.m_panel_zb_add_open = false; + self.requestFull(); + } else { + self.m_panel_zb_add_state = .{}; + self.requestFull(); + }, + .node => self.beginZbAdd(.node), + .other => self.beginZbAdd(.other), + .retry => { + self.m_panel_zb_add_state.pairing_started = false; + self.driveZbAddWizard(); + }, + .finish => { + self.m_panel_zb_add_open = false; + if (self.prefs.wireless.live_zb_n > 0) { + self.zb_rename_idx = self.prefs.wireless.live_zb_n - 1; + self.openTextPad(.wl_zb_name, "Device name", self.prefs.wireless.zbDevLabel(self.zb_rename_idx)); + } + self.requestFull(); + }, + } + } + + fn returnToFirmwareHub(self: *Engine) void { + self.m_panel_tool = @intFromEnum(m_panel.ToolId.firmware_update); + snapEnter(&self.m_panel_fx); + self.requestFull(); + } + fn handleMPanelClick(self: *Engine, x: i32, y: i32) void { if (self.m_panel_tool != 0xff) { if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.terminal)) { @@ -5777,7 +6221,168 @@ pub const Engine = struct { } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.sd)) { self.handleSdClick(x, y); } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.zigbee)) { - self.handleZigbeeClick(x, y); + if (self.m_panel_zb_add_open) self.handleZbAddClick(x, y) else self.handleZigbeeClick(x, y); + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.controls)) { + const h = m_panel_controls.hit(self.m_panel_controls_layout, self.m_panel_controls_page, x, y); + switch (h.kind) { + .none => {}, + .back => { + self.m_panel_quick_slot = 0xff; + self.returnToMPanelFromTool(); + }, + .exit => self.closeToolToDashboard(), + .prev => { self.m_panel_controls_page -|= 1; self.requestFull(); }, + .next => { self.m_panel_controls_page = @min(1, self.m_panel_controls_page + 1); self.requestFull(); }, + .toggle => { + if (h.channel < self.prefs.wireless.zb_node_channel_count) { + if (self.m_panel_quick_slot < 3) { + self.assignQuickControl(h.channel); + return; + } + const ch = &self.prefs.wireless.zb_node_channels[h.channel]; + ch.digital_value = !ch.digital_value; + _ = self.emitWireless(.{ .zb_node_output = .{ .index = h.channel, .on = ch.digital_value } }); + self.requestFull(); + } + }, + .configure => { + if (h.channel < self.prefs.wireless.zb_node_channel_count and self.m_panel_quick_slot < 3) { + self.assignQuickControl(h.channel); + } else if (h.channel < 8) { + self.prefs.wireless.zb_node_open = true; + self.prefs.wireless.zb_node_page = h.channel + 1; + self.m_panel_tool = @intFromEnum(m_panel.ToolId.zigbee); + self.requestFull(); + } + }, + } + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.firmware_update)) { + const h = m_panel_update_hub.hit(self.m_panel_update_hub_layout, x, y); + switch (h.kind) { + .none => {}, + .back => self.returnToMPanelFromTool(), + .exit => self.closeToolToDashboard(), + .target => self.openFirmwareTarget(h.target), + .refresh => { + if (self.c6_ota_cmd_sink) |sink| sink(self, .refresh, 0); + if (self.s3_ota_cmd_sink) |sink| sink(self, .refresh, 0); + if (self.nano_ota_cmd_sink) |sink| sink(self, .refresh, 0); + self.showSnackbar("USB drive rescanned"); + self.requestFull(); + }, + } + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.c6_update)) { + if (self.m_panel_c6_ota_state.phase == .flashing or self.m_panel_c6_ota_state.phase == .success) { + self.requestFull(); + return; + } + const h = m_panel_c6_ota.hit(self.m_panel_c6_ota_layout, x, y); + switch (h.kind) { + .none => {}, + .scrim, .back => self.returnToFirmwareHub(), + .exit => self.closeToolToDashboard(), + .detail_back => self.m_panel_c6_ota_state.view = .dashboard, + .firmware => self.m_panel_c6_ota_state.view = .firmware, + .refresh => if (self.c6_ota_cmd_sink) |sink| sink(self, .refresh, 0), + .row => if (self.c6_ota_cmd_sink) |sink| sink(self, .select, h.index), + .check => if (self.m_panel_c6_ota_state.file_count > 0 and self.m_panel_c6_ota_state.phase != .flashing) { + if (self.c6_ota_cmd_sink) |sink| sink(self, .check, 0); + }, + .flash => if (self.m_panel_c6_ota_state.phase == .armed) { + if (self.c6_ota_cmd_sink) |sink| sink(self, .flash, 0); + }, + } + self.requestFull(); + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.s3_update)) { + const h = m_panel_s3_ota.hit(self.m_panel_s3_ota_layout, x, y); + if (self.m_panel_s3_ota_state.view == .review) { + switch (h.kind) { + .cancel => self.m_panel_s3_ota_state.view = .settings, + .apply => if (!self.m_panel_s3_ota_state.config_busy) { + self.m_panel_s3_ota_state.view = .settings; + if (self.s3_ota_cmd_sink) |sink| sink(self, .config_apply, 0); + }, + .exit => self.closeToolToDashboard(), + else => {}, + } + self.requestFull(); + return; + } + switch (h.kind) { + .none => {}, + .scrim, .back => self.returnToFirmwareHub(), + .exit => self.closeToolToDashboard(), + .detail_back => self.m_panel_s3_ota_state.view = .dashboard, + .refresh => if (self.s3_ota_cmd_sink) |sink| { + if (self.m_panel_s3_ota_state.view == .settings) sink(self, .config_refresh, 0) else sink(self, .refresh, 0); + }, + .row => if (self.s3_ota_cmd_sink) |sink| sink(self, .select, h.index), + .check => if (self.m_panel_s3_ota_state.file_count > 0 and self.m_panel_s3_ota_state.phase != .flashing) { + if (self.s3_ota_cmd_sink) |sink| sink(self, .check, 0); + }, + .flash => if (self.m_panel_s3_ota_state.phase == .armed) { + if (self.s3_ota_cmd_sink) |sink| sink(self, .flash, 0); + }, + .restart => if (self.m_panel_s3_ota_state.phase == .success) { + if (self.s3_ota_cmd_sink) |sink| sink(self, .restart, 0); + }, + .firmware => self.m_panel_s3_ota_state.view = .firmware, + .settings => { + self.m_panel_s3_ota_state.view = .settings; + self.m_panel_s3_ota_state.config_loaded = false; + if (self.s3_ota_cmd_sink) |sink| sink(self, .config_refresh, 0); + }, + .tx_minus => if (self.m_panel_s3_ota_state.config_supported and self.m_panel_s3_ota_state.draft_tx > 0) { + self.m_panel_s3_ota_state.draft_tx -= 1; + }, + .tx_plus => if (self.m_panel_s3_ota_state.config_supported and self.m_panel_s3_ota_state.draft_tx < 48) { + self.m_panel_s3_ota_state.draft_tx += 1; + }, + .rx_minus => if (self.m_panel_s3_ota_state.config_supported and self.m_panel_s3_ota_state.draft_rx > 0) { + self.m_panel_s3_ota_state.draft_rx -= 1; + }, + .rx_plus => if (self.m_panel_s3_ota_state.config_supported and self.m_panel_s3_ota_state.draft_rx < 48) { + self.m_panel_s3_ota_state.draft_rx += 1; + }, + .baud => if (self.m_panel_s3_ota_state.config_supported) { + self.m_panel_s3_ota_state.draft_baud = switch (self.m_panel_s3_ota_state.draft_baud) { + 115200 => 230400, + 230400 => 460800, + 460800 => 921600, + else => 115200, + }; + }, + .test_cnc => if (self.m_panel_s3_ota_state.test_supported and !self.m_panel_s3_ota_state.config_busy) { + if (self.s3_ota_cmd_sink) |sink| sink(self, .config_test, 0); + }, + .review => if (self.m_panel_s3_ota_state.config_supported and !self.m_panel_s3_ota_state.config_busy) { + self.m_panel_s3_ota_state.view = .review; + }, + .cancel, .apply => {}, + } + self.requestFull(); + } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.nano_update)) { + if (self.m_panel_nano_ota_state.phase == .flashing) { + self.requestFull(); + return; + } + const h = m_panel_nano_ota.hit(self.m_panel_nano_ota_layout, x, y); + switch (h.kind) { + .none => {}, + .scrim, .back => self.returnToFirmwareHub(), + .exit => self.closeToolToDashboard(), + .detail_back => self.m_panel_nano_ota_state.view = .dashboard, + .firmware => self.m_panel_nano_ota_state.view = .firmware, + .refresh => if (self.nano_ota_cmd_sink) |sink| sink(self, .refresh, 0), + .row => if (self.nano_ota_cmd_sink) |sink| sink(self, .select, h.index), + .check => if (self.m_panel_nano_ota_state.file_count > 0) { + if (self.nano_ota_cmd_sink) |sink| sink(self, .check, 0); + }, + .flash => if (self.m_panel_nano_ota_state.phase == .armed) { + if (self.nano_ota_cmd_sink) |sink| sink(self, .flash, 0); + }, + } + self.requestFull(); } else if (m_panel.hitTool(self.m_panel_tool_layout, x, y)) { self.returnToMPanelFromTool(); } @@ -5788,6 +6393,26 @@ pub const Engine = struct { .none => {}, .scrim, .close => self.closeMPanel(), .tile => self.openMPanelTool(h.index), + .favorite => { + if (h.channel < self.prefs.wireless.zb_node_channel_count) { + const ch = &self.prefs.wireless.zb_node_channels[h.channel]; + if (ch.typ == 1 or ch.typ == 2) { + ch.digital_value = !ch.digital_value; + _ = self.emitWireless(.{ .zb_node_output = .{ .index = h.channel, .on = ch.digital_value } }); + self.requestFull(); + } else { + self.m_panel_tool = @intFromEnum(m_panel.ToolId.controls); + self.m_panel_controls_page = h.channel / 4; + snapEnter(&self.m_panel_fx); + self.requestFull(); + } + } else { + self.m_panel_tool = @intFromEnum(m_panel.ToolId.controls); + self.m_panel_quick_slot = h.index; + snapEnter(&self.m_panel_fx); + self.requestFull(); + } + }, } } @@ -6087,10 +6712,57 @@ pub const Engine = struct { return sink(); } + fn assignQuickControl(self: *Engine, channel: u8) void { + if (self.m_panel_quick_slot >= 3 or channel >= self.prefs.wireless.zb_node_channel_count) return; + const position = self.m_panel_quick_slot + 1; + for (&self.prefs.wireless.zb_node_channels) |*other| { + if (other.favorite == position) other.favorite = 0; + } + self.prefs.wireless.zb_node_channels[channel].favorite = position; + self.m_panel_quick_slot = 0xff; + self.applyPrefs(); + self.returnToMPanelFromTool(); + self.showSnackbar("Quick control added"); + } + + fn updateZbTemperatureAlarms(self: *Engine) void { + var active: u8 = 0xff; + for (self.prefs.wireless.zb_node_channels[0..8], 0..) |*ch, i| { + if (!ch.temp_alarm_enabled or ch.typ != 4 or ch.temperature_state != 1) { + ch.temp_alarm_active = false; + continue; + } + const t = ch.temperature_centi_c; + const hyst: i32 = ch.temp_alarm_hysteresis_centi_c; + if (!ch.temp_alarm_active) { + ch.temp_alarm_active = t >= ch.temp_alarm_high_centi_c or t <= ch.temp_alarm_low_centi_c; + } else { + ch.temp_alarm_active = @as(i32, t) >= @as(i32, ch.temp_alarm_high_centi_c) - hyst or + @as(i32, t) <= @as(i32, ch.temp_alarm_low_centi_c) + hyst; + } + if (ch.temp_alarm_active and active == 0xff) active = @intCast(i); + } + if (active != 0xff and (self.zb_temp_alarm_idx != active or self.snack_frames == 0)) { + self.zb_temp_alarm_idx = active; + const ch = &self.prefs.wireless.zb_node_channels[active]; + var msg: [96]u8 = undefined; + const name = std.mem.sliceTo(&ch.name, 0); + const text = std.fmt.bufPrint(&msg, "Temperature alarm: {s} {d:.1} C", .{ if (name.len == 0) "Channel" else name, @as(f32, @floatFromInt(ch.temperature_centi_c)) / 100.0 }) catch "Temperature alarm"; + self.showSnackbarError(text); + self.snack_frames = std.math.maxInt(u32); + } else if (active == 0xff and self.zb_temp_alarm_idx != 0xff) { + self.zb_temp_alarm_idx = 0xff; + self.snack_frames = 0; + self.snack_len = 0; + self.requestFull(); + } + } + pub fn tick(self: *Engine, dt_sec: f32) FrameMetrics { const t_tick_start = self.nowUs(); self.dirty.clear(); self.frame_n +%= 1; + if (@rem(self.frame_n, 30) == 0) self.updateZbTemperatureAlarms(); if (self.qs_blank_armed and (self.frame_n -% self.qs_blank_arm_frame) > 12) { self.qs_blank_armed = false; } @@ -6828,8 +7500,8 @@ test "boot hold does not drain until armed" { eng.armBootHold(); _ = eng.tick(0.05); try std.testing.expect(eng.screen == .boot); - try std.testing.expect(eng.boot_left_sec < 3.0); - try std.testing.expect(eng.boot_left_sec > 2.9); + try std.testing.expect(eng.boot_left_sec < boot_seconds); + try std.testing.expect(eng.boot_left_sec > boot_seconds - 0.1); } test "boot splash primary follows accent and dark mode" { @@ -6868,7 +7540,7 @@ test "dashboard screen after boot" { var eng = try Engine.create(gpa); defer eng.destroy(gpa); try std.testing.expect(eng.screen == .boot); - try std.testing.expectEqual(@as(f32, 3.0), eng.boot_left_sec); + try std.testing.expectEqual(boot_seconds, eng.boot_left_sec); eng.handleClick(100, 100); // LVGL: splash ignores tap try std.testing.expect(eng.screen == .boot); eng.skipBoot(); diff --git a/src/modulus/ui_engine/input_pad.zig b/src/modulus/ui_engine/input_pad.zig index 802fec6..7edbcd3 100644 --- a/src/modulus/ui_engine/input_pad.zig +++ b/src/modulus/ui_engine/input_pad.zig @@ -62,6 +62,14 @@ pub const Target = enum { wl_pass, wl_en_mac, wl_zb_code, + wl_zb_name, + wl_zb_node_name, + wl_zb_node_channel_name, + wl_zb_node_alarm_high, + wl_zb_node_alarm_low, + wl_zb_node_alarm_hysteresis, + wl_zb_node_gpio, + wl_zb_node_poll, wl_th_node, wl_bt_passkey, cnc_masso_ip, @@ -104,6 +112,8 @@ pub const State = struct { caps: bool = false, /// LVGL `1#` page — digits/symbols instead of QWERTY. digits_page: bool = false, + /// First key on a seeded number field replaces the displayed value. + input_fresh: bool = false, /// MD3 date/time picker civil values (24h). year: u16 = 2026, @@ -138,6 +148,7 @@ pub const State = struct { self.shift_on = false; self.caps = false; self.digits_page = false; + self.input_fresh = mode == .number; const n = @min(seed.len, self.buf.len); @memcpy(self.buf[0..n], seed[0..n]); self.len = n; @@ -183,12 +194,23 @@ pub const State = struct { } pub fn pushChar(self: *State, ch: u8) void { + if (self.input_fresh) { + @memset(&self.buf, 0); + self.len = 0; + self.input_fresh = false; + } if (self.len >= self.buf.len) return; self.buf[self.len] = ch; self.len += 1; } pub fn backspace(self: *State) void { + if (self.input_fresh) { + @memset(&self.buf, 0); + self.len = 0; + self.input_fresh = false; + return; + } if (self.len == 0) return; self.len -= 1; self.buf[self.len] = 0; @@ -378,6 +400,11 @@ pub fn allowsDot(t: Target) bool { /// carry their own length rules. pub fn numberValid(t: Target, txt: []const u8) bool { if (isPinTarget(t)) return true; + if (t == .wl_zb_node_gpio and txt.len == 0) return true; + if (t == .wl_zb_node_poll) { + const seconds = std.fmt.parseInt(u16, txt, 10) catch return false; + return seconds >= 15 and seconds <= 3600; + } if (txt.len == 0) return false; switch (numberKind(t)) { .integer => _ = std.fmt.parseInt(u32, txt, 10) catch return false, @@ -633,7 +660,7 @@ fn paintPinGrid(logical: *fb.LogicalFb, theme: tokens.Theme, g: Grid) void { var lab: [1]u8 = .{digits[i]}; paintKey(logical, cell(g, @intCast(i % 3), @intCast(i / 3)), lab[0..], theme.surface_container, theme.on_surface); } - paintKey(logical, cell(g, 0, 3), "Bk", theme.secondary_container, theme.on_secondary_container); + paintKey(logical, cell(g, 0, 3), "DEL", theme.secondary_container, theme.on_secondary_container); paintKey(logical, cell(g, 1, 3), "0", theme.surface_container, theme.on_surface); paintKey(logical, cell(g, 2, 3), "OK", theme.primary, theme.on_primary); } @@ -645,7 +672,7 @@ fn paintNumGrid(logical: *fb.LogicalFb, theme: tokens.Theme, g: Grid, dot: bool) var lab: [1]u8 = .{digits[i]}; paintKey(logical, cell(g, @intCast(i % 3), @intCast(i / 3)), lab[0..], theme.surface_container, theme.on_surface); } - paintKey(logical, cell(g, 3, 0), "Bk", theme.secondary_container, theme.on_secondary_container); + paintKey(logical, cell(g, 3, 0), "DEL", theme.secondary_container, theme.on_secondary_container); if (dot) paintKey(logical, cell(g, 3, 2), ".", theme.surface_container, theme.on_surface); paintKey(logical, cell(g, 3, 3), "X", theme.error_container, theme.on_error_container); const ok: geom.Rect = .{ @@ -946,7 +973,7 @@ test "number pad parses" { st.openPad(.number, .disp_bright, "Brightness", "42"); try std.testing.expectEqual(@as(u32, 42), st.parseU32().?); st.pushChar('0'); - try std.testing.expectEqual(@as(u32, 420), st.parseU32().?); + try std.testing.expectEqual(@as(u32, 0), st.parseU32().?); } test "pin mask and dock" { @@ -1038,3 +1065,10 @@ test "date mode seeds YYYY-MM-DD" { try std.testing.expectEqual(@as(u8, 5), st.day); try std.testing.expect(panelRect(&st).w == 500); } + +test "node poll interval accepts bounded integer seconds" { + try std.testing.expect(numberValid(.wl_zb_node_poll, "15")); + try std.testing.expect(numberValid(.wl_zb_node_poll, "3600")); + for ([_][]const u8{ "", "0", "14", "3601", "15.5", "-15" }) |value| + try std.testing.expect(!numberValid(.wl_zb_node_poll, value)); +} diff --git a/src/modulus/ui_engine/m_panel.zig b/src/modulus/ui_engine/m_panel.zig index 7da12e1..ece7ade 100644 --- a/src/modulus/ui_engine/m_panel.zig +++ b/src/modulus/ui_engine/m_panel.zig @@ -9,6 +9,12 @@ const font = @import("font.zig"); const widgets = @import("widgets.zig"); const icons_phosphor = @import("icons_phosphor.zig"); const color = @import("color.zig"); +const panel_bg = color.Rgb565.fromHex(0x001426); +const panel_card = color.Rgb565.fromHex(0x03233F); +const panel_card_hi = color.Rgb565.fromHex(0x07345A); +const panel_cyan = color.Rgb565.fromHex(0x18C8FF); +const panel_text = color.Rgb565.fromHex(0xF4FAFF); +const panel_muted = color.Rgb565.fromHex(0xA9C7E5); pub const ToolId = enum(u8) { terminal = 0, @@ -16,6 +22,11 @@ pub const ToolId = enum(u8) { probe = 2, sd = 3, zigbee = 4, + controls = 5, + firmware_update = 6, + c6_update = 7, + s3_update = 8, + nano_update = 9, }; pub const Tool = struct { @@ -31,6 +42,8 @@ pub const tools = [_]Tool{ .{ .label = "Probe", .icon = .arrow_down }, .{ .label = "SD Card", .icon = .hard_drives }, .{ .label = "Zigbee", .icon = .broadcast }, + .{ .label = "All controls", .icon = .plugs }, + .{ .label = "Firmware Update", .icon = .arrow_down }, }; pub fn toolEnabled(index: u8, usb_host: bool) bool { @@ -38,27 +51,29 @@ pub fn toolEnabled(index: u8, usb_host: bool) bool { return !tools[index].requires_usb or usb_host; } -pub const cols: i32 = 5; +pub const cols: i32 = 3; pub const visible_rows: i32 = 2; const icon_px: i32 = 32; -const tile_h: i32 = 88; +const tile_h: i32 = 132; const gap: i32 = tokens.Space.sm; const close_sz: i32 = tokens.Logical.touch_min; -const card_w: i32 = 1000; -const card_h: i32 = 400; -const title_h: i32 = tokens.Space.lg + 32; +const card_w: i32 = tokens.Logical.width - 32; +const card_h: i32 = tokens.Logical.height - 28; +const title_h: i32 = 84; pub const Hit = enum { none, scrim, close, tile, + favorite, }; pub const HitInfo = struct { kind: Hit = .none, /// Tool index when `kind == .tile`. index: u8 = 0, + channel: u8 = 0, }; pub const Layout = struct { @@ -66,7 +81,10 @@ pub const Layout = struct { close: geom.Rect = .{}, view: geom.Rect = .{}, tiles: [32]geom.Rect = [_]geom.Rect{.{}} ** 32, + tool_indices: [32]u8 = [_]u8{0xff} ** 32, + quick: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, tile_n: u8 = 0, + favorite_channels: [3]u8 = .{ 0xff, 0xff, 0xff }, scroll_max: i32 = 0, }; @@ -94,7 +112,7 @@ fn tileW(card: geom.Rect) i32 { } fn rowCount() i32 { - const n = tools.len; + const n = tools.len - 1; if (n == 0) return visible_rows; return @divTrunc(n + @as(usize, @intCast(cols)) - 1, @as(usize, @intCast(cols))); } @@ -104,7 +122,7 @@ pub fn contentH() i32 { } pub fn viewH(card: geom.Rect) i32 { - return card.h - title_h - tokens.Space.md; + return card.h - 418; } pub fn scrollMax(card: geom.Rect) i32 { @@ -114,7 +132,7 @@ pub fn scrollMax(card: geom.Rect) i32 { fn tileRect(card: geom.Rect, col: i32, row: i32, scroll: i32) geom.Rect { const pad = tokens.Space.lg; const tw = tileW(card); - const y0 = card.y + title_h; + const y0 = card.y + 418; return .{ .x = card.x + pad + col * (tw + gap), .y = y0 + row * (tile_h + gap) - scroll, @@ -133,6 +151,14 @@ fn paintClose(logical: *fb.LogicalFb, theme: tokens.Theme, r: geom.Rect) void { widgets.drawTonalCloseButton(logical, r, theme); } +fn paintLargeSwitch(logical: *fb.LogicalFb, r: geom.Rect, on: bool) void { + widgets.fillRoundRect(logical, r, @divTrunc(r.h, 2), if (on) color.Rgb565.fromHex(0x079DFF) else color.Rgb565.fromHex(0x173B60)); + widgets.strokeRoundRect(logical, r, @divTrunc(r.h, 2), panel_cyan, 1); + const d = r.h - 10; + const x = if (on) r.x + r.w - d - 5 else r.x + 5; + widgets.fillRoundRect(logical, .{ .x = x, .y = r.y + 5, .w = d, .h = d }, @divTrunc(d, 2), panel_text); +} + fn paintTile( logical: *fb.LogicalFb, r: geom.Rect, @@ -142,13 +168,13 @@ fn paintTile( theme: tokens.Theme, enabled: bool, ) void { - widgets.fillRoundRect(logical, r, tokens.Shape.lg, if (enabled) fill else theme.surface_container_low); - if (!enabled) widgets.strokeRoundRect(logical, r, tokens.Shape.lg, theme.outline_variant, 1); - const ink = if (enabled) theme.on_surface else theme.on_surface_variant; - icons_phosphor.draw(logical, r.x + @divTrunc(r.w - icon_px, 2), r.y + 14, icon, ink); + _ = fill; + widgets.fillRoundRect(logical, r, tokens.Shape.lg, if (enabled) panel_card else theme.surface_container_low); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, if (enabled) panel_cyan else theme.outline_variant, 2); + const ink = if (enabled) panel_text else theme.on_surface_variant; + icons_phosphor.draw(logical, r.x + 30, r.y + @divTrunc(r.h - icon_px, 2), icon, ink); if (label.len != 0) { - const tw = font.textWidthStr(label, .label_m); - font.drawTextRole(logical, r.x + @divTrunc(r.w - tw, 2), r.y + 52, label, ink, .label_m); + font.drawTextRole(logical, r.x + 86, r.y + @divTrunc(r.h - font.faceHeight(font.faceForRole(.title_m)), 2), label, ink, .title_m); } } @@ -163,14 +189,24 @@ pub fn paint( scroll_px: i32, enter_t: f32, usb_host: bool, + channels: *const [12]@import("settings_prefs.zig").WirelessPrefs.ZbNodeChannel, + channel_count: u8, + espnow_connected: bool, + zigbee_online: u8, ) Layout { - widgets.fillScrim(logical, theme); + logical.fillRect(.{ .x = 0, .y = 0, .w = tokens.Logical.width, .h = tokens.Logical.height }, panel_bg); const card = cardGeom(enter_t); - widgets.fillRoundRect(logical, card, tokens.Shape.dialog, theme.elev(3)); + widgets.fillRoundRect(logical, card, tokens.Shape.dialog, panel_bg); + widgets.strokeRoundRect(logical, card, tokens.Shape.dialog, panel_cyan, 1); var lay: Layout = .{ .card = card }; const title_y = card.y + tokens.Space.md; - font.drawTextRole(logical, card.x + tokens.Space.lg, title_y, "M-Panel", theme.on_surface, .title_l); + font.drawTextRole(logical, card.x + tokens.Space.lg, title_y, "Modulus | M-Panel", panel_text, .title_l); + font.drawTextRole(logical, card.x + 430, title_y, "Ready", color.Rgb565.fromHex(0x00EE88), .title_m); + font.drawTextRole(logical, card.x + 620, title_y, if (espnow_connected) "ESP-NOW Connected" else "ESP-NOW Offline", if (espnow_connected) panel_cyan else panel_muted, .title_m); + var zb_buf: [24]u8 = undefined; + const zb_text = std.fmt.bufPrint(&zb_buf, "Zigbee {d} online", .{zigbee_online}) catch "Zigbee"; + font.drawTextRole(logical, card.x + 930, title_y, zb_text, if (zigbee_online > 0) panel_cyan else panel_muted, .title_m); const th = font.faceHeight(font.faceForRole(.title_l)); lay.close = .{ .x = card.x + card.w - close_sz - tokens.Space.md, @@ -180,17 +216,17 @@ pub fn paint( }; paintClose(logical, theme, lay.close); - const y0 = card.y + title_h; + const y0 = card.y + 418; lay.view = .{ .x = card.x + tokens.Space.lg, .y = y0, .w = card.w - tokens.Space.lg * 2, - .h = viewH(card), + .h = card.y + card.h - y0 - tokens.Space.md, }; lay.scroll_max = scrollMax(card); const scroll = std.math.clamp(scroll_px, 0, lay.scroll_max); - logical.setClip(lay.view); + logical.setClip(card); defer logical.setClip(null); if (tools.len == 0) { @@ -210,12 +246,65 @@ pub fn paint( return lay; } + font.drawTextRole(logical, card.x + tokens.Space.lg, card.y + 82, "Quick controls", panel_text, .title_l); + const quick_y = card.y + 128; + const quick_h: i32 = 238; + const quick_gap: i32 = 16; + const quick_w = @divTrunc(card.w - tokens.Space.lg * 2 - quick_gap * 3, 4); + var pos: usize = 0; + while (pos < 4) : (pos += 1) { + const r: geom.Rect = .{ .x = card.x + tokens.Space.lg + @as(i32, @intCast(pos)) * (quick_w + quick_gap), .y = quick_y, .w = quick_w, .h = quick_h }; + lay.quick[pos] = r; + if (pos == 3) { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, panel_card_hi); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, panel_cyan, 2); + icons_phosphor.draw(logical, r.x + 32, r.y + 36, .cards_three, panel_cyan); + font.drawTextRole(logical, r.x + 92, r.y + 30, "All controls", panel_text, .title_l); + font.drawTextRole(logical, r.x + 92, r.y + 86, "8 channels | 2 pages", panel_muted, .body_m); + widgets.drawFilledButton(logical, .{ .x = r.x + 32, .y = r.y + 150, .w = r.w - 64, .h = 64 }, "Open controls", theme); + continue; + } + var found: u8 = 0xff; + var ci: u8 = 0; + while (ci < @min(channel_count, 8)) : (ci += 1) if (channels[ci].favorite == pos + 1) { found = ci; break; }; + lay.favorite_channels[pos] = found; + if (found == 0xff) { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, panel_card); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, panel_cyan, 2); + const plus: geom.Rect = .{ .x = r.x + @divTrunc(r.w - 88, 2), .y = r.y + 24, .w = 88, .h = 88 }; + widgets.fillRoundRect(logical, plus, 44, color.Rgb565.fromHex(0x079DFF)); + logical.fillRect(.{ .x = plus.x + 20, .y = plus.y + 40, .w = 48, .h = 8 }, panel_text); + logical.fillRect(.{ .x = plus.x + 40, .y = plus.y + 20, .w = 8, .h = 48 }, panel_text); + const add = "Add quick control"; + font.drawTextRole(logical, r.x + @divTrunc(r.w - font.textWidthStr(add, .title_m), 2), r.y + 126, add, panel_text, .title_m); + const hint = "Tap to choose"; + font.drawTextRole(logical, r.x + @divTrunc(r.w - font.textWidthStr(hint, .body_m), 2), r.y + 176, hint, panel_muted, .body_m); + } else { + const ch = channels[found]; + var fallback: [20]u8 = undefined; + const saved = std.mem.sliceTo(&ch.name, 0); + const label = if (saved.len != 0) saved else std.fmt.bufPrint(&fallback, "Channel {d}", .{found + 1}) catch "Channel"; + widgets.fillRoundRect(logical, r, tokens.Shape.lg, panel_card); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, if (ch.temp_alarm_active) theme.err else panel_cyan, 2); + icons_phosphor.draw(logical, r.x + 32, r.y + 36, if (ch.typ == 4) .thermometer_simple else .plugs, if (ch.temp_alarm_active) theme.err else panel_cyan); + font.drawTextRole(logical, r.x + 92, r.y + 30, label, panel_text, .title_l); + var state_buf: [24]u8 = undefined; + const state = if (ch.typ == 4 and ch.temperature_state == 1) std.fmt.bufPrint(&state_buf, "{d:.1} C", .{@as(f32, @floatFromInt(ch.temperature_centi_c)) / 100.0}) catch "" else if (ch.digital_value) "ON" else "OFF"; + font.drawTextRole(logical, r.x + 92, r.y + 86, state, if (ch.temp_alarm_active) theme.err else theme.primary, .title_m); + if (ch.typ == 1 or ch.typ == 2) paintLargeSwitch(logical, .{ .x = r.x + 32, .y = r.y + 150, .w = r.w - 64, .h = 64 }, ch.digital_value); + } + } + + font.drawTextRole(logical, card.x + tokens.Space.lg, card.y + 378, "Tools", panel_text, .title_l); var i: usize = 0; while (i < tools.len and lay.tile_n < lay.tiles.len) : (i += 1) { - const row: i32 = @intCast(@divTrunc(i, @as(usize, @intCast(cols)))); - const col: i32 = @intCast(@rem(i, @as(usize, @intCast(cols)))); + if (i == @intFromEnum(ToolId.controls)) continue; + const visual = lay.tile_n; + const row: i32 = @intCast(@divTrunc(visual, @as(usize, @intCast(cols)))); + const col: i32 = @intCast(@rem(visual, @as(usize, @intCast(cols)))); const r = tileRect(card, col, row, scroll); lay.tiles[lay.tile_n] = r; + lay.tool_indices[lay.tile_n] = @intCast(i); lay.tile_n += 1; const tool = tools[i]; const enabled = toolEnabled(@intCast(i), usb_host); @@ -227,12 +316,17 @@ pub fn paint( pub fn hit(layout: Layout, x: i32, y: i32, usb_host: bool) HitInfo { if (layout.close.contains(x, y)) return .{ .kind = .close }; if (!layout.card.contains(x, y)) return .{ .kind = .scrim }; + for (layout.quick, 0..) |r, qi| if (r.contains(x, y)) { + if (qi == 3) return .{ .kind = .tile, .index = @intFromEnum(ToolId.controls) }; + return .{ .kind = .favorite, .index = @intCast(qi), .channel = layout.favorite_channels[qi] }; + }; if (!layout.view.contains(x, y)) return .{ .kind = .none }; var i: u8 = 0; while (i < layout.tile_n) : (i += 1) { if (layout.tiles[i].contains(x, y)) { - if (!toolEnabled(i, usb_host)) return .{ .kind = .none }; - return .{ .kind = .tile, .index = i }; + const tool_index = layout.tool_indices[i]; + if (!toolEnabled(tool_index, usb_host)) return .{ .kind = .none }; + return .{ .kind = .tile, .index = tool_index }; } } return .{ .kind = .none }; @@ -304,7 +398,7 @@ test "m-panel grid is 5 columns" { try std.testing.expectEqual(r1.x - r0.x, tw + gap); } -test "scroll max grows with tools" { +test "launcher tools fit without scrolling" { const card = cardGeom(1); try std.testing.expectEqual(@as(i32, 0), scrollMax(card)); } @@ -314,11 +408,13 @@ test "hit tile index" { var logical = try fb.LogicalFb.alloc(gpa); defer logical.deinit(gpa); const theme = tokens.Theme.industrialTealDark(); - const lay = paint(&logical, theme, 0, 1, true); - if (lay.tile_n > 0) { - const r = lay.tiles[0]; + const prefs = @import("settings_prefs.zig"); + var channels = [_]prefs.WirelessPrefs.ZbNodeChannel{.{}} ** 12; + const lay = paint(&logical, theme, 0, 1, true, &channels, 0, false, 0); + if (lay.tile_n > 3) { + const r = lay.tiles[3]; const h = hit(lay, r.x + @divTrunc(r.w, 2), r.y + @divTrunc(r.h, 2), true); try std.testing.expect(h.kind == .tile); - try std.testing.expectEqual(@as(u8, 0), h.index); + try std.testing.expectEqual(@as(u8, 3), h.index); } } diff --git a/src/modulus/ui_engine/m_panel_c6_ota.zig b/src/modulus/ui_engine/m_panel_c6_ota.zig new file mode 100644 index 0000000..008abce --- /dev/null +++ b/src/modulus/ui_engine/m_panel_c6_ota.zig @@ -0,0 +1,238 @@ +//! M-Panel C6 Update — guarded ESP-Hosted slave OTA from USB. + +const std = @import("std"); +const geom = @import("geom.zig"); +const tokens = @import("tokens.zig"); +const fb = @import("fb.zig"); +const font = @import("font.zig"); +const widgets = @import("widgets.zig"); +const tool_chrome = @import("m_panel_tool.zig"); +const icons = @import("icons_phosphor.zig"); + +pub const max_files = 8; +pub const name_len = 96; + +pub const Phase = enum(u8) { idle, ready, armed, flashing, success, failed }; +pub const Action = enum(u8) { refresh, select, check, flash }; +pub const View = enum(u8) { dashboard, firmware }; + +pub const State = struct { + phase: Phase = .idle, + file_count: u8 = 0, + selected: u8 = 0, + progress: u8 = 0, + c6_connected: bool = false, + version: [24]u8 = .{0} ** 24, + version_len: u8 = 0, + image_version: [32]u8 = .{0} ** 32, + image_version_len: u8 = 0, + files: [max_files][name_len]u8 = [_][name_len]u8{.{0} ** name_len} ** max_files, + file_lens: [max_files]u8 = .{0} ** max_files, + status: [160]u8 = .{0} ** 160, + status_len: u8 = 0, + view: View = .dashboard, + + pub fn statusText(self: *const State) []const u8 { + return self.status[0..self.status_len]; + } + pub fn versionText(self: *const State) []const u8 { + return self.version[0..self.version_len]; + } + pub fn imageVersionText(self: *const State) []const u8 { + return self.image_version[0..self.image_version_len]; + } + pub fn fileText(self: *const State, i: usize) []const u8 { + if (i >= self.file_count) return ""; + return self.files[i][0..self.file_lens[i]]; + } +}; + +pub const Hit = enum { none, scrim, back, exit, detail_back, firmware, refresh, row, check, flash }; +pub const HitInfo = struct { kind: Hit = .none, index: u8 = 0 }; +pub const Layout = struct { + header: tool_chrome.Header = .{}, + detail_back: geom.Rect = .{}, + firmware: geom.Rect = .{}, + refresh: geom.Rect = .{}, + rows: [max_files]geom.Rect = [_]geom.Rect{.{}} ** max_files, + row_n: u8 = 0, + check: geom.Rect = .{}, + flash: geom.Rect = .{}, +}; + +fn cardGeom(t0: f32) geom.Rect { + const t = std.math.clamp(t0, 0, 1); + const w: i32 = @intFromFloat(1220.0 * (0.92 + 0.08 * t)); + const h: i32 = @intFromFloat(650.0 * (0.92 + 0.08 * t)); + return .{ .x = @divTrunc(tokens.Logical.width - w, 2), .y = @divTrunc(tokens.Logical.height - h, 2), .w = w, .h = h }; +} + +fn drawDisabled(logical: *fb.LogicalFb, r: geom.Rect, label: []const u8, theme: tokens.Theme) void { + widgets.drawButton(logical, r, label, .filled, .disabled, theme); +} + +fn drawFittedText(logical: *fb.LogicalFb, x: i32, y: i32, max_w: i32, text: []const u8, ink: @TypeOf(tokens.Theme.industrialTealDark().on_surface), role: tokens.TypeRole) void { + if (max_w <= 0 or text.len == 0) return; + if (font.textWidthStr(text, role) <= max_w) { + font.drawTextRole(logical, x, y, text, ink, role); + return; + } + const suffix = "..."; + var n = text.len; + while (n > 0 and font.textWidthStr(text[0..n], role) + font.textWidthStr(suffix, role) > max_w) : (n -= 1) {} + var buf: [164]u8 = undefined; + const keep = @min(n, buf.len - suffix.len); + @memcpy(buf[0..keep], text[0..keep]); + @memcpy(buf[keep .. keep + suffix.len], suffix); + font.drawTextRole(logical, x, y, buf[0 .. keep + suffix.len], ink, role); +} + +fn drawStatusCard(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, r: geom.Rect) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, theme.surface_container_low); + const dot: geom.Rect = .{ .x = r.x + tokens.Space.lg, .y = r.y + 24, .w = 18, .h = 18 }; + widgets.fillRoundRect(logical, dot, 9, if (state.c6_connected) theme.primary else theme.tertiary); + font.drawTextRole(logical, dot.x + 34, r.y + 16, "Internal C6 Controller", theme.on_surface, .title_m); + font.drawTextRole(logical, dot.x + 34, r.y + 51, if (state.c6_connected) "ESP-Hosted / SDIO connected" else "Internal C6 not connected", theme.on_surface_variant, .body_m); + if (state.version_len != 0) { + var version: [48]u8 = undefined; + const version_text = std.fmt.bufPrint(&version, "Firmware: {s}", .{state.versionText()}) catch "Firmware version unavailable"; + drawFittedText(logical, r.x + r.w - 330, r.y + 18, 306, version_text, theme.primary, .body_m); + } +} + +fn drawDashboardCard(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, r: geom.Rect) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, theme.surface_container); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, theme.outline_variant, 1); + icons.draw(logical, r.x + tokens.Space.lg, r.y + tokens.Space.lg, .usb, theme.primary); + font.drawTextRole(logical, r.x + 76, r.y + 25, "Firmware Update", theme.on_surface, .title_m); + var installed: [64]u8 = undefined; + var selected: [64]u8 = undefined; + const installed_text = std.fmt.bufPrint(&installed, "Installed: {s}", .{if (state.version_len != 0) state.versionText() else "unknown"}) catch "Installed: unknown"; + const selected_text = std.fmt.bufPrint(&selected, "On USB: {s}", .{if (state.image_version_len != 0) state.imageVersionText() else "no verified image"}) catch "On USB: unavailable"; + drawFittedText(logical, r.x + tokens.Space.lg, r.y + 92, r.w - tokens.Space.lg * 2, installed_text, theme.on_surface_variant, .body_l); + drawFittedText(logical, r.x + tokens.Space.lg, r.y + 132, r.w - tokens.Space.lg * 2, selected_text, theme.on_surface_variant, .body_l); + if (state.version_len != 0 and state.image_version_len != 0 and std.mem.eql(u8, state.versionText(), state.imageVersionText())) { + font.drawTextRole(logical, r.x + tokens.Space.lg, r.y + 178, "Already installed", theme.primary, .label_m); + } + font.drawTextRole(logical, r.x + tokens.Space.lg, r.y + r.h - 50, "Open", theme.primary, .label_l); + icons.draw(logical, r.x + r.w - 52, r.y + r.h - 54, .caret_right, theme.primary); +} + +pub fn paint(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, enter_t: f32) Layout { + widgets.fillScrim(logical, theme); + const card = cardGeom(enter_t); + widgets.fillRoundRect(logical, card, tokens.Shape.dialog, theme.elev(3)); + var lay: Layout = .{}; + lay.header = tool_chrome.headerChrome(card); + tool_chrome.paintBackToPanel(logical, theme, lay.header.back); + tool_chrome.paintTitle(logical, theme, lay.header.back.x + lay.header.back.w + tokens.Space.sm, lay.header.back.y, "Internal C6 Controller"); + tool_chrome.paintExit(logical, theme, lay.header.exit); + + const x = card.x + tokens.Space.lg; + const right = card.x + card.w - tokens.Space.lg; + if (state.view == .dashboard) { + const status_card: geom.Rect = .{ .x = x, .y = lay.header.back.y + lay.header.back.h + tokens.Space.md, .w = right - x, .h = 104 }; + drawStatusCard(logical, theme, state, status_card); + lay.firmware = .{ .x = x, .y = status_card.y + status_card.h + tokens.Space.lg, .w = right - x, .h = 300 }; + drawDashboardCard(logical, theme, state, lay.firmware); + return lay; + } + + var y = lay.header.back.y + lay.header.back.h + tokens.Space.sm; + lay.detail_back = .{ .x = x, .y = y, .w = 64, .h = 56 }; + widgets.drawTonalButton(logical, lay.detail_back, "<", theme); + font.drawTextRole(logical, x + 84, y + 13, "Firmware Update", theme.on_surface, .title_m); + y += 72; + const link = if (state.c6_connected) "ESP-Hosted/SDIO connected" else "Internal C6 not connected"; + font.drawTextRole(logical, x, y, link, if (state.c6_connected) theme.primary else theme.on_error_container, .body_m); + if (state.version_len != 0) { + var version: [48]u8 = undefined; + const version_text = std.fmt.bufPrint(&version, "Firmware: {s}", .{state.versionText()}) catch "Firmware version unavailable"; + drawFittedText(logical, x + 330, y, 390, version_text, theme.primary, .body_m); + } + lay.refresh = .{ .x = right - 190, .y = y - 10, .w = 190, .h = 60 }; + const locked = state.phase == .flashing or state.phase == .success; + if (locked) drawDisabled(logical, lay.refresh, "Refresh USB", theme) else widgets.drawTonalButton(logical, lay.refresh, "Refresh USB", theme); + y += 34; + var installed: [64]u8 = undefined; + var selected_version: [64]u8 = undefined; + const installed_text = std.fmt.bufPrint(&installed, "Installed: {s}", .{if (state.version_len != 0) state.versionText() else "unknown"}) catch "Installed: unknown"; + const selected_text = std.fmt.bufPrint(&selected_version, "Selected image: {s}", .{if (state.image_version_len != 0) state.imageVersionText() else "none"}) catch "Selected image: none"; + font.drawTextRole(logical, x, y, installed_text, theme.on_surface_variant, .body_m); + font.drawTextRole(logical, x + 310, y, selected_text, theme.on_surface_variant, .body_m); + if (state.version_len != 0 and state.image_version_len != 0 and std.mem.eql(u8, state.versionText(), state.imageVersionText())) { + font.drawTextRole(logical, x + 690, y, "Already installed", theme.primary, .label_m); + } + y += 42; + + const row_h: i32 = tokens.Logical.touch_min; + const row_gap: i32 = tokens.Space.xs; + lay.row_n = @min(state.file_count, 5); + var i: u8 = 0; + while (i < lay.row_n) : (i += 1) { + const r: geom.Rect = .{ .x = x, .y = y, .w = right - x, .h = row_h }; + lay.rows[i] = r; + const selected = i == state.selected; + widgets.fillRoundRect(logical, r, tokens.Shape.md, if (selected) theme.secondary_container else theme.surface_container_low); + if (!selected) widgets.strokeRoundRect(logical, r, tokens.Shape.md, theme.outline_variant, 1); + drawFittedText(logical, r.x + tokens.Space.md, r.y + 13, r.w - tokens.Space.md * 2, state.fileText(i), if (selected) theme.on_secondary_container else theme.on_surface, .body_m); + y += row_h + row_gap; + } + if (lay.row_n == 0) font.drawTextRole(logical, x, y + 8, "No verified ESP32-C6 app images found on USB.", theme.on_surface_variant, .body_m); + + const status_y = card.y + card.h - 150; + drawFittedText(logical, x, status_y, right - x, state.statusText(), if (state.phase == .failed) theme.on_error_container else theme.on_surface_variant, .body_m); + const bar: geom.Rect = .{ .x = x, .y = status_y + 27, .w = right - x, .h = 10 }; + widgets.fillRoundRect(logical, bar, 5, theme.surface_container_high); + if (state.progress > 0) { + const fill: geom.Rect = .{ .x = bar.x, .y = bar.y, .w = @divTrunc(bar.w * state.progress, 100), .h = bar.h }; + widgets.fillRoundRect(logical, fill, 5, theme.primary); + } + + const action_h: i32 = 64; + const action_w: i32 = 230; + const by = card.y + card.h - tokens.Space.lg - action_h; + lay.check = .{ .x = x, .y = by, .w = action_w, .h = action_h }; + lay.flash = .{ .x = x + action_w + tokens.Space.md, .y = by, .w = action_w, .h = action_h }; + const can_check = state.file_count > 0 and !locked; + if (can_check) widgets.drawFilledButton(logical, lay.check, "1. Check image", theme) else drawDisabled(logical, lay.check, "1. Check image", theme); + if (state.phase == .armed) widgets.drawDangerButton(logical, lay.flash, "2. Update Internal C6", theme) else drawDisabled(logical, lay.flash, "2. Update Internal C6", theme); + return lay; +} + +pub fn hit(layout: Layout, x: i32, y: i32) HitInfo { + if (tool_chrome.hitBack(layout.header, x, y)) return .{ .kind = .back }; + if (tool_chrome.hitExit(layout.header, x, y)) return .{ .kind = .exit }; + if (tool_chrome.hitScrim(layout.header, x, y)) return .{ .kind = .scrim }; + if (layout.detail_back.contains(x, y)) return .{ .kind = .detail_back }; + if (layout.firmware.contains(x, y)) return .{ .kind = .firmware }; + if (layout.refresh.contains(x, y)) return .{ .kind = .refresh }; + var i: u8 = 0; + while (i < layout.row_n) : (i += 1) if (layout.rows[i].contains(x, y)) return .{ .kind = .row, .index = i }; + if (layout.check.contains(x, y)) return .{ .kind = .check }; + if (layout.flash.contains(x, y)) return .{ .kind = .flash }; + return .{}; +} + +test "C6 OTA buttons meet minimum touch size" { + const gpa = std.testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + var state: State = .{ .view = .firmware }; + state.file_count = 1; + const lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expect(lay.check.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.flash.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.refresh.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.rows[0].h >= tokens.Logical.touch_min); +} + +test "C6 dashboard exposes one large firmware card" { + const gpa = std.testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + const state: State = .{ .view = .dashboard, .c6_connected = true }; + const lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expect(lay.firmware.h >= tokens.Logical.touch_min); + try std.testing.expectEqual(Hit.firmware, hit(lay, lay.firmware.x + 4, lay.firmware.y + 4).kind); +} diff --git a/src/modulus/ui_engine/m_panel_controls.zig b/src/modulus/ui_engine/m_panel_controls.zig new file mode 100644 index 0000000..5e53d5c --- /dev/null +++ b/src/modulus/ui_engine/m_panel_controls.zig @@ -0,0 +1,118 @@ +//! Eight Node controls, four large cards per page. +const std = @import("std"); +const geom = @import("geom.zig"); +const tokens = @import("tokens.zig"); +const fb = @import("fb.zig"); +const font = @import("font.zig"); +const widgets = @import("widgets.zig"); +const icons = @import("icons_phosphor.zig"); +const prefs = @import("settings_prefs.zig"); +const chrome = @import("m_panel_tool.zig"); +const color = @import("color.zig"); +const panel_bg = color.Rgb565.fromHex(0x001426); +const panel_card = color.Rgb565.fromHex(0x03233F); +const panel_cyan = color.Rgb565.fromHex(0x18C8FF); +const panel_text = color.Rgb565.fromHex(0xF4FAFF); +const panel_muted = color.Rgb565.fromHex(0xA9C7E5); + +pub const Layout = struct { header: chrome.Header = .{}, cards: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, toggles: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, prev: geom.Rect = .{}, next: geom.Rect = .{} }; +pub const Kind = enum { none, back, exit, configure, toggle, prev, next }; +pub const Hit = struct { kind: Kind = .none, channel: u8 = 0 }; + +fn iconFor(ch: prefs.WirelessPrefs.ZbNodeChannel) icons.Id { + if (ch.icon != 0 and ch.icon <= @intFromEnum(icons.Id.hand_withdraw)) return @enumFromInt(ch.icon); + return switch (ch.typ) { + 3 => .hand_withdraw, + 4 => .thermometer_simple, + 1, 2 => .plugs, + else => .gear, + }; +} + +fn channelName(ch: *const prefs.WirelessPrefs.ZbNodeChannel, index: u8, buf: *[24]u8) []const u8 { + const saved = std.mem.sliceTo(&ch.name, 0); + if (saved.len != 0) return saved; + return std.fmt.bufPrint(buf, "Channel {d}", .{index + 1}) catch "Channel"; +} + +fn paintLargeSwitch(logical: *fb.LogicalFb, r: geom.Rect, on: bool) void { + widgets.fillRoundRect(logical, r, @divTrunc(r.h, 2), if (on) color.Rgb565.fromHex(0x079DFF) else color.Rgb565.fromHex(0x173B60)); + widgets.strokeRoundRect(logical, r, @divTrunc(r.h, 2), panel_cyan, 1); + const d = r.h - 10; + const x = if (on) r.x + r.w - d - 5 else r.x + 5; + widgets.fillRoundRect(logical, .{ .x = x, .y = r.y + 5, .w = d, .h = d }, @divTrunc(d, 2), panel_text); +} + +pub fn paint(logical: *fb.LogicalFb, theme: tokens.Theme, channels: *const [12]prefs.WirelessPrefs.ZbNodeChannel, count: u8, page: u8, quick_slot: u8, enter_t: f32) Layout { + _ = enter_t; + logical.fillRect(.{ .x = 0, .y = 0, .w = tokens.Logical.width, .h = tokens.Logical.height }, panel_bg); + const shell: geom.Rect = .{ .x = 20, .y = 20, .w = tokens.Logical.width - 40, .h = tokens.Logical.height - 40 }; + widgets.fillRoundRect(logical, shell, tokens.Shape.dialog, panel_bg); + widgets.strokeRoundRect(logical, shell, tokens.Shape.dialog, panel_cyan, 1); + var lay: Layout = .{}; + lay.header = chrome.headerChrome(shell); + chrome.paintBackToPanel(logical, theme, lay.header.back); + chrome.paintTitle(logical, theme, lay.header.back.x + lay.header.back.w + tokens.Space.sm, lay.header.back.y, if (quick_slot < 3) "Choose quick control" else "All controls"); + chrome.paintExit(logical, theme, lay.header.exit); + if (quick_slot < 3) { + var prompt_buf: [96]u8 = undefined; + const prompt = std.fmt.bufPrint(&prompt_buf, "Tap a channel to place it in quick control {d}.", .{quick_slot + 1}) catch "Tap a channel to add it."; + font.drawTextRole(logical, shell.x + 28, shell.y + 73, prompt, panel_muted, .body_m); + } + const gap: i32 = 18; + const x0 = shell.x + 28; + const y0 = shell.y + 102; + const cw = @divTrunc(shell.w - 56 - gap, 2); + const chh: i32 = 238; + const first: u8 = @as(u8, @min(page, 1)) * 4; + for (0..4) |slot| { + const col: i32 = @intCast(slot % 2); + const row: i32 = @intCast(slot / 2); + const r: geom.Rect = .{ .x = x0 + col * (cw + gap), .y = y0 + row * (chh + gap), .w = cw, .h = chh }; + lay.cards[slot] = r; + widgets.fillRoundRect(logical, r, tokens.Shape.lg, panel_card); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, panel_cyan, 2); + const idx: u8 = first + @as(u8, @intCast(slot)); + if (idx >= @min(count, 8)) { + font.drawTextRole(logical, r.x + 28, r.y + 92, "Not configured", panel_muted, .title_m); + continue; + } + const c = channels[idx]; + icons.draw(logical, r.x + 30, r.y + 30, iconFor(c), if (c.temp_alarm_active) theme.err else theme.primary); + var name_buf: [24]u8 = undefined; + font.drawTextRole(logical, r.x + 92, r.y + 28, channelName(&c, idx, &name_buf), panel_text, .title_l); + var value_buf: [32]u8 = undefined; + const value: []const u8 = if (c.typ == 4 and c.temperature_state == 1) + std.fmt.bufPrint(&value_buf, "{d:.1} C", .{@as(f32, @floatFromInt(c.temperature_centi_c)) / 100.0}) catch "Temperature" + else if (c.typ == 3 and c.digital_state == 1) (if (c.digital_value) "HIGH" else "LOW") else if (c.typ == 1 or c.typ == 2) (if (c.digital_value) "ON" else "OFF") else "Unavailable"; + font.drawTextRole(logical, r.x + 92, r.y + 82, value, if (c.temp_alarm_active) theme.err else panel_cyan, .title_m); + if (c.typ == 1 or c.typ == 2) { + lay.toggles[slot] = .{ .x = r.x + r.w - 190, .y = r.y + 138, .w = 150, .h = 72 }; + paintLargeSwitch(logical, lay.toggles[slot], c.digital_value); + } else { + font.drawTextRole(logical, r.x + 92, r.y + 154, "Tap card to configure", panel_muted, .body_m); + } + } + lay.prev = .{ .x = shell.x + 28, .y = shell.y + shell.h - 66, .w = 180, .h = 52 }; + lay.next = .{ .x = shell.x + shell.w - 208, .y = lay.prev.y, .w = 180, .h = 52 }; + widgets.drawTonalButton(logical, lay.prev, "Previous", theme); + widgets.drawTonalButton(logical, lay.next, "Next", theme); + var pbuf: [20]u8 = undefined; + const ptxt = std.fmt.bufPrint(&pbuf, "Page {d} of 2", .{@min(page, 1) + 1}) catch "Page"; + const tw = font.textWidthStr(ptxt, .body_m); + font.drawTextRole(logical, shell.x + @divTrunc(shell.w - tw, 2), lay.prev.y + 14, ptxt, panel_muted, .body_m); + return lay; +} + +pub fn hit(lay: Layout, page: u8, x: i32, y: i32) Hit { + if (chrome.hitBack(lay.header, x, y)) return .{ .kind = .back }; + if (chrome.hitExit(lay.header, x, y)) return .{ .kind = .exit }; + if (lay.prev.contains(x, y)) return .{ .kind = .prev }; + if (lay.next.contains(x, y)) return .{ .kind = .next }; + for (0..4) |i| { + const channel: u8 = @as(u8, @min(page, 1)) * 4 + @as(u8, @intCast(i)); + if (lay.toggles[i].contains(x, y)) return .{ .kind = .toggle, .channel = channel }; + if (lay.cards[i].contains(x, y)) return .{ .kind = .configure, .channel = channel }; + } + return .{}; +} diff --git a/src/modulus/ui_engine/m_panel_nano_ota.zig b/src/modulus/ui_engine/m_panel_nano_ota.zig new file mode 100644 index 0000000..0a16df1 --- /dev/null +++ b/src/modulus/ui_engine/m_panel_nano_ota.zig @@ -0,0 +1,238 @@ +//! M-Panel NanoH2 Update — guarded ESP-Hosted slave OTA from USB. + +const std = @import("std"); +const geom = @import("geom.zig"); +const tokens = @import("tokens.zig"); +const fb = @import("fb.zig"); +const font = @import("font.zig"); +const widgets = @import("widgets.zig"); +const tool_chrome = @import("m_panel_tool.zig"); +const icons = @import("icons_phosphor.zig"); + +pub const max_files = 8; +pub const name_len = 96; + +pub const Phase = enum(u8) { idle, ready, armed, flashing, success, failed }; +pub const Action = enum(u8) { refresh, select, check, flash }; +pub const View = enum(u8) { dashboard, firmware }; + +pub const State = struct { + phase: Phase = .idle, + file_count: u8 = 0, + selected: u8 = 0, + progress: u8 = 0, + nano_connected: bool = false, + version: [24]u8 = .{0} ** 24, + version_len: u8 = 0, + image_version: [32]u8 = .{0} ** 32, + image_version_len: u8 = 0, + files: [max_files][name_len]u8 = [_][name_len]u8{.{0} ** name_len} ** max_files, + file_lens: [max_files]u8 = .{0} ** max_files, + status: [160]u8 = .{0} ** 160, + status_len: u8 = 0, + view: View = .dashboard, + + pub fn statusText(self: *const State) []const u8 { + return self.status[0..self.status_len]; + } + pub fn versionText(self: *const State) []const u8 { + return self.version[0..self.version_len]; + } + pub fn imageVersionText(self: *const State) []const u8 { + return self.image_version[0..self.image_version_len]; + } + pub fn fileText(self: *const State, i: usize) []const u8 { + if (i >= self.file_count) return ""; + return self.files[i][0..self.file_lens[i]]; + } +}; + +pub const Hit = enum { none, scrim, back, exit, detail_back, firmware, refresh, row, check, flash }; +pub const HitInfo = struct { kind: Hit = .none, index: u8 = 0 }; +pub const Layout = struct { + header: tool_chrome.Header = .{}, + detail_back: geom.Rect = .{}, + firmware: geom.Rect = .{}, + refresh: geom.Rect = .{}, + rows: [max_files]geom.Rect = [_]geom.Rect{.{}} ** max_files, + row_n: u8 = 0, + check: geom.Rect = .{}, + flash: geom.Rect = .{}, +}; + +fn cardGeom(t0: f32) geom.Rect { + const t = std.math.clamp(t0, 0, 1); + const w: i32 = @intFromFloat(1220.0 * (0.92 + 0.08 * t)); + const h: i32 = @intFromFloat(650.0 * (0.92 + 0.08 * t)); + return .{ .x = @divTrunc(tokens.Logical.width - w, 2), .y = @divTrunc(tokens.Logical.height - h, 2), .w = w, .h = h }; +} + +fn drawDisabled(logical: *fb.LogicalFb, r: geom.Rect, label: []const u8, theme: tokens.Theme) void { + widgets.drawButton(logical, r, label, .filled, .disabled, theme); +} + +fn drawFittedText(logical: *fb.LogicalFb, x: i32, y: i32, max_w: i32, text: []const u8, ink: @TypeOf(tokens.Theme.industrialTealDark().on_surface), role: tokens.TypeRole) void { + if (max_w <= 0 or text.len == 0) return; + if (font.textWidthStr(text, role) <= max_w) { + font.drawTextRole(logical, x, y, text, ink, role); + return; + } + const suffix = "..."; + var n = text.len; + while (n > 0 and font.textWidthStr(text[0..n], role) + font.textWidthStr(suffix, role) > max_w) : (n -= 1) {} + var buf: [164]u8 = undefined; + const keep = @min(n, buf.len - suffix.len); + @memcpy(buf[0..keep], text[0..keep]); + @memcpy(buf[keep .. keep + suffix.len], suffix); + font.drawTextRole(logical, x, y, buf[0 .. keep + suffix.len], ink, role); +} + +fn drawStatusCard(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, r: geom.Rect) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, theme.surface_container_low); + const dot: geom.Rect = .{ .x = r.x + tokens.Space.lg, .y = r.y + 24, .w = 18, .h = 18 }; + widgets.fillRoundRect(logical, dot, 9, if (state.nano_connected) theme.primary else theme.tertiary); + font.drawTextRole(logical, dot.x + 34, r.y + 16, "NanoH2 Zigbee Coordinator", theme.on_surface, .title_m); + font.drawTextRole(logical, dot.x + 34, r.y + 51, if (state.nano_connected) "UART connected" else "NanoH2 not connected", theme.on_surface_variant, .body_m); + if (state.version_len != 0) { + var version: [48]u8 = undefined; + const version_text = std.fmt.bufPrint(&version, "Firmware: {s}", .{state.versionText()}) catch "Firmware version unavailable"; + drawFittedText(logical, r.x + r.w - 330, r.y + 18, 306, version_text, theme.primary, .body_m); + } +} + +fn drawDashboardCard(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, r: geom.Rect) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, theme.surface_container); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, theme.outline_variant, 1); + icons.draw(logical, r.x + tokens.Space.lg, r.y + tokens.Space.lg, .usb, theme.primary); + font.drawTextRole(logical, r.x + 76, r.y + 25, "Firmware Update", theme.on_surface, .title_m); + var installed: [64]u8 = undefined; + var selected: [64]u8 = undefined; + const installed_text = std.fmt.bufPrint(&installed, "Installed: {s}", .{if (state.version_len != 0) state.versionText() else "unknown"}) catch "Installed: unknown"; + const selected_text = std.fmt.bufPrint(&selected, "On USB: {s}", .{if (state.image_version_len != 0) state.imageVersionText() else "no verified image"}) catch "On USB: unavailable"; + drawFittedText(logical, r.x + tokens.Space.lg, r.y + 92, r.w - tokens.Space.lg * 2, installed_text, theme.on_surface_variant, .body_l); + drawFittedText(logical, r.x + tokens.Space.lg, r.y + 132, r.w - tokens.Space.lg * 2, selected_text, theme.on_surface_variant, .body_l); + if (state.version_len != 0 and state.image_version_len != 0 and std.mem.eql(u8, state.versionText(), state.imageVersionText())) { + font.drawTextRole(logical, r.x + tokens.Space.lg, r.y + 178, "Already installed", theme.primary, .label_m); + } + font.drawTextRole(logical, r.x + tokens.Space.lg, r.y + r.h - 50, "Open", theme.primary, .label_l); + icons.draw(logical, r.x + r.w - 52, r.y + r.h - 54, .caret_right, theme.primary); +} + +pub fn paint(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, enter_t: f32) Layout { + widgets.fillScrim(logical, theme); + const card = cardGeom(enter_t); + widgets.fillRoundRect(logical, card, tokens.Shape.dialog, theme.elev(3)); + var lay: Layout = .{}; + lay.header = tool_chrome.headerChrome(card); + tool_chrome.paintBackToPanel(logical, theme, lay.header.back); + tool_chrome.paintTitle(logical, theme, lay.header.back.x + lay.header.back.w + tokens.Space.sm, lay.header.back.y, "NanoH2"); + tool_chrome.paintExit(logical, theme, lay.header.exit); + + const x = card.x + tokens.Space.lg; + const right = card.x + card.w - tokens.Space.lg; + if (state.view == .dashboard) { + const status_card: geom.Rect = .{ .x = x, .y = lay.header.back.y + lay.header.back.h + tokens.Space.md, .w = right - x, .h = 104 }; + drawStatusCard(logical, theme, state, status_card); + lay.firmware = .{ .x = x, .y = status_card.y + status_card.h + tokens.Space.lg, .w = right - x, .h = 300 }; + drawDashboardCard(logical, theme, state, lay.firmware); + return lay; + } + + var y = lay.header.back.y + lay.header.back.h + tokens.Space.sm; + lay.detail_back = .{ .x = x, .y = y, .w = 64, .h = 56 }; + widgets.drawTonalButton(logical, lay.detail_back, "<", theme); + font.drawTextRole(logical, x + 84, y + 13, "Firmware Update", theme.on_surface, .title_m); + y += 72; + const link = if (state.nano_connected) "UART connected" else "NanoH2 not connected"; + font.drawTextRole(logical, x, y, link, if (state.nano_connected) theme.primary else theme.on_error_container, .body_m); + if (state.version_len != 0) { + var version: [48]u8 = undefined; + const version_text = std.fmt.bufPrint(&version, "Firmware: {s}", .{state.versionText()}) catch "Firmware version unavailable"; + drawFittedText(logical, x + 330, y, 390, version_text, theme.primary, .body_m); + } + lay.refresh = .{ .x = right - 190, .y = y - 10, .w = 190, .h = 60 }; + const locked = state.phase == .flashing; + if (locked) drawDisabled(logical, lay.refresh, "Refresh USB", theme) else widgets.drawTonalButton(logical, lay.refresh, "Refresh USB", theme); + y += 34; + var installed: [64]u8 = undefined; + var selected_version: [64]u8 = undefined; + const installed_text = std.fmt.bufPrint(&installed, "Installed: {s}", .{if (state.version_len != 0) state.versionText() else "unknown"}) catch "Installed: unknown"; + const selected_text = std.fmt.bufPrint(&selected_version, "Selected image: {s}", .{if (state.image_version_len != 0) state.imageVersionText() else "none"}) catch "Selected image: none"; + font.drawTextRole(logical, x, y, installed_text, theme.on_surface_variant, .body_m); + font.drawTextRole(logical, x + 310, y, selected_text, theme.on_surface_variant, .body_m); + if (state.version_len != 0 and state.image_version_len != 0 and std.mem.eql(u8, state.versionText(), state.imageVersionText())) { + font.drawTextRole(logical, x + 690, y, "Already installed", theme.primary, .label_m); + } + y += 42; + + const row_h: i32 = tokens.Logical.touch_min; + const row_gap: i32 = tokens.Space.xs; + lay.row_n = @min(state.file_count, 5); + var i: u8 = 0; + while (i < lay.row_n) : (i += 1) { + const r: geom.Rect = .{ .x = x, .y = y, .w = right - x, .h = row_h }; + lay.rows[i] = r; + const selected = i == state.selected; + widgets.fillRoundRect(logical, r, tokens.Shape.md, if (selected) theme.secondary_container else theme.surface_container_low); + if (!selected) widgets.strokeRoundRect(logical, r, tokens.Shape.md, theme.outline_variant, 1); + drawFittedText(logical, r.x + tokens.Space.md, r.y + 13, r.w - tokens.Space.md * 2, state.fileText(i), if (selected) theme.on_secondary_container else theme.on_surface, .body_m); + y += row_h + row_gap; + } + if (lay.row_n == 0) font.drawTextRole(logical, x, y + 8, "No verified ESP32-NanoH2 app images found on USB.", theme.on_surface_variant, .body_m); + + const status_y = card.y + card.h - 150; + drawFittedText(logical, x, status_y, right - x, state.statusText(), if (state.phase == .failed) theme.on_error_container else theme.on_surface_variant, .body_m); + const bar: geom.Rect = .{ .x = x, .y = status_y + 27, .w = right - x, .h = 10 }; + widgets.fillRoundRect(logical, bar, 5, theme.surface_container_high); + if (state.progress > 0) { + const fill: geom.Rect = .{ .x = bar.x, .y = bar.y, .w = @divTrunc(bar.w * state.progress, 100), .h = bar.h }; + widgets.fillRoundRect(logical, fill, 5, theme.primary); + } + + const action_h: i32 = 64; + const action_w: i32 = 230; + const by = card.y + card.h - tokens.Space.lg - action_h; + lay.check = .{ .x = x, .y = by, .w = action_w, .h = action_h }; + lay.flash = .{ .x = x + action_w + tokens.Space.md, .y = by, .w = action_w, .h = action_h }; + const can_check = state.file_count > 0 and !locked; + if (can_check) widgets.drawFilledButton(logical, lay.check, "1. Check image", theme) else drawDisabled(logical, lay.check, "1. Check image", theme); + if (state.phase == .armed) widgets.drawDangerButton(logical, lay.flash, "2. Flash NanoH2", theme) else drawDisabled(logical, lay.flash, "2. Flash NanoH2", theme); + return lay; +} + +pub fn hit(layout: Layout, x: i32, y: i32) HitInfo { + if (tool_chrome.hitBack(layout.header, x, y)) return .{ .kind = .back }; + if (tool_chrome.hitExit(layout.header, x, y)) return .{ .kind = .exit }; + if (tool_chrome.hitScrim(layout.header, x, y)) return .{ .kind = .scrim }; + if (layout.detail_back.contains(x, y)) return .{ .kind = .detail_back }; + if (layout.firmware.contains(x, y)) return .{ .kind = .firmware }; + if (layout.refresh.contains(x, y)) return .{ .kind = .refresh }; + var i: u8 = 0; + while (i < layout.row_n) : (i += 1) if (layout.rows[i].contains(x, y)) return .{ .kind = .row, .index = i }; + if (layout.check.contains(x, y)) return .{ .kind = .check }; + if (layout.flash.contains(x, y)) return .{ .kind = .flash }; + return .{}; +} + +test "NanoH2 OTA buttons meet minimum touch size" { + const gpa = std.testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + var state: State = .{ .view = .firmware }; + state.file_count = 1; + const lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expect(lay.check.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.flash.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.refresh.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.rows[0].h >= tokens.Logical.touch_min); +} + +test "NanoH2 dashboard exposes one large firmware card" { + const gpa = std.testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + const state: State = .{ .view = .dashboard, .nano_connected = true }; + const lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expect(lay.firmware.h >= tokens.Logical.touch_min); + try std.testing.expectEqual(Hit.firmware, hit(lay, lay.firmware.x + 4, lay.firmware.y + 4).kind); +} diff --git a/src/modulus/ui_engine/m_panel_s3_ota.zig b/src/modulus/ui_engine/m_panel_s3_ota.zig new file mode 100644 index 0000000..5e497a1 --- /dev/null +++ b/src/modulus/ui_engine/m_panel_s3_ota.zig @@ -0,0 +1,443 @@ +//! M-Panel S3 Update — guarded ESP-NOW OTA from USB. + +const std = @import("std"); +const geom = @import("geom.zig"); +const tokens = @import("tokens.zig"); +const fb = @import("fb.zig"); +const font = @import("font.zig"); +const widgets = @import("widgets.zig"); +const tool_chrome = @import("m_panel_tool.zig"); +const icons = @import("icons_phosphor.zig"); + +pub const max_files = 8; +pub const name_len = 96; +pub const Phase = enum(u8) { idle, ready, armed, flashing, success, failed }; +pub const Action = enum(u8) { refresh, select, check, flash, restart, config_refresh, config_apply, config_test }; +pub const View = enum(u8) { dashboard, firmware, settings, review }; + +pub const State = struct { + phase: Phase = .idle, + file_count: u8 = 0, + selected: u8 = 0, + progress: u8 = 0, + s3_connected: bool = false, + version: [32]u8 = .{0} ** 32, + version_len: u8 = 0, + image_version: [32]u8 = .{0} ** 32, + image_version_len: u8 = 0, + files: [max_files][name_len]u8 = [_][name_len]u8{.{0} ** name_len} ** max_files, + file_lens: [max_files]u8 = .{0} ** max_files, + status: [160]u8 = .{0} ** 160, + status_len: u8 = 0, + view: View = .dashboard, + config_supported: bool = false, + test_supported: bool = false, + config_busy: bool = false, + config_loaded: bool = false, + uart_tx: i8 = -1, + uart_rx: i8 = -1, + uart_baud: u32 = 115200, + draft_tx: i8 = -1, + draft_rx: i8 = -1, + draft_baud: u32 = 115200, + + pub fn statusText(self: *const State) []const u8 { + return self.status[0..self.status_len]; + } + pub fn versionText(self: *const State) []const u8 { + return self.version[0..self.version_len]; + } + pub fn imageVersionText(self: *const State) []const u8 { + return self.image_version[0..self.image_version_len]; + } + pub fn fileText(self: *const State, i: usize) []const u8 { + if (i >= self.file_count) return ""; + return self.files[i][0..self.file_lens[i]]; + } +}; + +pub const Hit = enum { none, scrim, back, exit, detail_back, firmware, settings, refresh, row, check, flash, restart, tx_minus, tx_plus, rx_minus, rx_plus, baud, test_cnc, review, cancel, apply }; +pub const HitInfo = struct { kind: Hit = .none, index: u8 = 0 }; +pub const Layout = struct { + header: tool_chrome.Header = .{}, + refresh: geom.Rect = .{}, + rows: [max_files]geom.Rect = [_]geom.Rect{.{}} ** max_files, + row_n: u8 = 0, + check: geom.Rect = .{}, + flash: geom.Rect = .{}, + restart: geom.Rect = .{}, + firmware: geom.Rect = .{}, + settings: geom.Rect = .{}, + detail_back: geom.Rect = .{}, + tx_minus: geom.Rect = .{}, + tx_plus: geom.Rect = .{}, + rx_minus: geom.Rect = .{}, + rx_plus: geom.Rect = .{}, + baud: geom.Rect = .{}, + test_cnc: geom.Rect = .{}, + review: geom.Rect = .{}, + cancel: geom.Rect = .{}, + apply: geom.Rect = .{}, + file_area: geom.Rect = .{}, + status_area: geom.Rect = .{}, +}; + +fn drawValueRow(logical: *fb.LogicalFb, theme: tokens.Theme, area: geom.Rect, label: []const u8, value: i32, minus: *geom.Rect, plus: *geom.Rect) void { + widgets.fillRoundRect(logical, area, tokens.Shape.lg, theme.surface_container_low); + font.drawTextRole(logical, area.x + tokens.Space.lg, area.y + 12, label, theme.on_surface_variant, .label_m); + const control_y = area.y + 43; + minus.* = .{ .x = area.x + tokens.Space.lg, .y = control_y, .w = 72, .h = 58 }; + plus.* = .{ .x = area.x + area.w - tokens.Space.lg - 72, .y = control_y, .w = 72, .h = 58 }; + widgets.drawTonalButton(logical, minus.*, "-", theme); + widgets.drawTonalButton(logical, plus.*, "+", theme); + var buf: [16]u8 = undefined; + const txt = std.fmt.bufPrint(&buf, "GPIO {d}", .{value}) catch "GPIO ?"; + const tw = font.textWidthStr(txt, .title_m); + font.drawTextRole(logical, area.x + @divTrunc(area.w - tw, 2), control_y + 14, txt, theme.primary, .title_m); +} + +fn cardGeom(t0: f32) geom.Rect { + const t = std.math.clamp(t0, 0, 1); + const w: i32 = @intFromFloat(1220.0 * (0.92 + 0.08 * t)); + const h: i32 = @intFromFloat(650.0 * (0.92 + 0.08 * t)); + return .{ .x = @divTrunc(tokens.Logical.width - w, 2), .y = @divTrunc(tokens.Logical.height - h, 2), .w = w, .h = h }; +} + +fn disabled(logical: *fb.LogicalFb, r: geom.Rect, label: []const u8, theme: tokens.Theme) void { + widgets.drawButton(logical, r, label, .filled, .disabled, theme); +} + +fn drawFittedText(logical: *fb.LogicalFb, x: i32, y: i32, max_w: i32, text: []const u8, ink: @TypeOf(tokens.Theme.industrialTealDark().on_surface), role: tokens.TypeRole) void { + if (max_w <= 0 or text.len == 0) return; + if (font.textWidthStr(text, role) <= max_w) { + font.drawTextRole(logical, x, y, text, ink, role); + return; + } + const suffix = "..."; + var n = text.len; + while (n > 0 and font.textWidthStr(text[0..n], role) + font.textWidthStr(suffix, role) > max_w) : (n -= 1) {} + var buf: [164]u8 = undefined; + const keep = @min(n, buf.len - suffix.len); + @memcpy(buf[0..keep], text[0..keep]); + @memcpy(buf[keep .. keep + suffix.len], suffix); + font.drawTextRole(logical, x, y, buf[0 .. keep + suffix.len], ink, role); +} + +fn displayFileName(text: []const u8) []const u8 { + return if (std.mem.startsWith(u8, text, "USB: ")) text[5..] else text; +} + +fn shortVersion(text: []const u8) []const u8 { + var start: usize = 0; + while (start < text.len) : (start += 1) { + if (!std.ascii.isDigit(text[start])) continue; + var end = start; + var dots: u8 = 0; + while (end < text.len and (std.ascii.isDigit(text[end]) or text[end] == '.')) : (end += 1) { + if (text[end] == '.') dots += 1; + } + if (dots >= 2 and end > start and text[end - 1] != '.') { + return text[if (start > 0 and text[start - 1] == 'v') start - 1 else start..end]; + } + } + return text; +} + +fn drawStatusCard(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, r: geom.Rect) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, theme.surface_container_low); + const dot: geom.Rect = .{ .x = r.x + tokens.Space.lg, .y = r.y + 24, .w = 18, .h = 18 }; + widgets.fillRoundRect(logical, dot, 9, if (state.s3_connected) theme.primary else theme.tertiary); + font.drawTextRole(logical, dot.x + 34, r.y + 16, "S3 Bridge", theme.on_surface, .title_m); + font.drawTextRole(logical, dot.x + 34, r.y + 51, if (state.s3_connected) "ESP-NOW connected" else "Waiting for ESP-NOW", theme.on_surface_variant, .body_m); + if (state.version_len != 0) drawFittedText(logical, r.x + r.w - 300, r.y + 18, 276, shortVersion(state.versionText()), theme.on_surface_variant, .body_m); + var uart: [64]u8 = undefined; + const uart_text = if (state.config_loaded) + (std.fmt.bufPrint(&uart, "TX {d} | RX {d} | {d} baud", .{ state.uart_tx, state.uart_rx, state.uart_baud }) catch "UART configuration unavailable") + else + "UART configuration loading..."; + drawFittedText(logical, r.x + 520, r.y + 55, r.w - 520 - tokens.Space.lg, uart_text, theme.on_surface, .body_l); +} + +fn drawDashboardCard(logical: *fb.LogicalFb, theme: tokens.Theme, r: geom.Rect, icon: icons.Id, title: []const u8, detail: []const u8) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, theme.surface_container); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, theme.outline_variant, 1); + icons.draw(logical, r.x + tokens.Space.lg, r.y + tokens.Space.lg, icon, theme.primary); + font.drawTextRole(logical, r.x + 76, r.y + 25, title, theme.on_surface, .title_m); + font.drawTextRole(logical, r.x + tokens.Space.lg, r.y + 92, detail, theme.on_surface_variant, .body_l); + font.drawTextRole(logical, r.x + tokens.Space.lg, r.y + r.h - 50, "Open", theme.primary, .label_l); + icons.draw(logical, r.x + r.w - 52, r.y + r.h - 54, .caret_right, theme.primary); +} + +fn drawFirmwareCard(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, r: geom.Rect) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, theme.surface_container); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, theme.outline_variant, 1); + icons.draw(logical, r.x + tokens.Space.lg, r.y + tokens.Space.lg, .usb, theme.primary); + font.drawTextRole(logical, r.x + 76, r.y + 25, "Firmware Update", theme.on_surface, .title_m); + var installed: [64]u8 = undefined; + var selected: [64]u8 = undefined; + const installed_text = std.fmt.bufPrint(&installed, "Installed: {s}", .{if (state.version_len != 0) shortVersion(state.versionText()) else "unknown"}) catch "Installed: unknown"; + const selected_text = std.fmt.bufPrint(&selected, "On USB: {s}", .{if (state.image_version_len != 0) shortVersion(state.imageVersionText()) else "no verified image"}) catch "On USB: unavailable"; + drawFittedText(logical, r.x + tokens.Space.lg, r.y + 92, r.w - tokens.Space.lg * 2, installed_text, theme.on_surface_variant, .body_l); + drawFittedText(logical, r.x + tokens.Space.lg, r.y + 132, r.w - tokens.Space.lg * 2, selected_text, theme.on_surface_variant, .body_l); + if (state.version_len != 0 and state.image_version_len != 0 and std.mem.eql(u8, state.versionText(), state.imageVersionText())) { + font.drawTextRole(logical, r.x + tokens.Space.lg, r.y + 178, "Already installed", theme.primary, .label_m); + } + font.drawTextRole(logical, r.x + tokens.Space.lg, r.y + r.h - 50, "Open", theme.primary, .label_l); + icons.draw(logical, r.x + r.w - 52, r.y + r.h - 54, .caret_right, theme.primary); +} + +fn paintDetailHeading(logical: *fb.LogicalFb, theme: tokens.Theme, lay: *Layout, card: geom.Rect, title: []const u8) i32 { + const x = card.x + tokens.Space.lg; + const y = lay.header.back.y + lay.header.back.h + tokens.Space.sm; + lay.detail_back = .{ .x = x, .y = y, .w = 64, .h = 56 }; + widgets.drawTonalButton(logical, lay.detail_back, "<", theme); + font.drawTextRole(logical, x + 84, y + 13, title, theme.on_surface, .title_m); + return y + 72; +} + +pub fn paint(logical: *fb.LogicalFb, theme: tokens.Theme, state: *const State, enter_t: f32) Layout { + widgets.fillScrim(logical, theme); + const card = cardGeom(enter_t); + widgets.fillRoundRect(logical, card, tokens.Shape.dialog, theme.elev(3)); + var lay: Layout = .{}; + lay.header = tool_chrome.headerChrome(card); + tool_chrome.paintBackToPanel(logical, theme, lay.header.back); + tool_chrome.paintTitle(logical, theme, lay.header.back.x + lay.header.back.w + tokens.Space.sm, lay.header.back.y, "S3"); + tool_chrome.paintExit(logical, theme, lay.header.exit); + + const x = card.x + tokens.Space.lg; + const right = card.x + card.w - tokens.Space.lg; + + if (state.view == .dashboard) { + const status_card: geom.Rect = .{ .x = x, .y = lay.header.back.y + lay.header.back.h + tokens.Space.md, .w = right - x, .h = 104 }; + drawStatusCard(logical, theme, state, status_card); + const gap = tokens.Space.lg; + const tile_y = status_card.y + status_card.h + gap; + const tile_w = @divTrunc(status_card.w - gap, 2); + lay.firmware = .{ .x = x, .y = tile_y, .w = tile_w, .h = 300 }; + lay.settings = .{ .x = x + tile_w + gap, .y = tile_y, .w = tile_w, .h = 300 }; + drawFirmwareCard(logical, theme, state, lay.firmware); + var cfg_detail: [80]u8 = undefined; + const cfg_text = if (state.config_loaded) + (std.fmt.bufPrint(&cfg_detail, "TX GPIO {d} / RX GPIO {d} / {d}", .{ state.uart_tx, state.uart_rx, state.uart_baud }) catch "Remote UART configuration") + else if (state.config_supported) "Read UART settings from the S3" else "Update S3 firmware to enable settings"; + drawDashboardCard(logical, theme, lay.settings, .plugs, "UART Settings", cfg_text); + return lay; + } + + var y = paintDetailHeading(logical, theme, &lay, card, if (state.view == .firmware) "Firmware Update" else "UART Settings"); + + if (state.view != .firmware) { + const link = if (state.s3_connected) "Connected via ESP-NOW" else "S3 not connected"; + font.drawTextRole(logical, x, y, link, if (state.s3_connected) theme.primary else theme.on_error_container, .body_m); + y += 42; + if (!state.config_supported) { + font.drawTextRole(logical, x, y, "Update the S3 firmware to enable remote UART settings.", theme.on_error_container, .body_l); + lay.refresh = .{ .x = x, .y = y + 70, .w = 260, .h = 64 }; + widgets.drawTonalButton(logical, lay.refresh, "Retry connection", theme); + return lay; + } + const gap = tokens.Space.md; + const col_w = @divTrunc((right - x) - gap, 2); + const tx_card: geom.Rect = .{ .x = x, .y = y, .w = col_w, .h = 116 }; + const rx_card: geom.Rect = .{ .x = x + col_w + gap, .y = y, .w = col_w, .h = 116 }; + drawValueRow(logical, theme, tx_card, "UART transmit pin", state.draft_tx, &lay.tx_minus, &lay.tx_plus); + drawValueRow(logical, theme, rx_card, "UART receive pin", state.draft_rx, &lay.rx_minus, &lay.rx_plus); + y += 132; + const baud_card: geom.Rect = .{ .x = x, .y = y, .w = right - x, .h = 104 }; + widgets.fillRoundRect(logical, baud_card, tokens.Shape.lg, theme.surface_container_low); + font.drawTextRole(logical, baud_card.x + tokens.Space.lg, baud_card.y + 13, "Baud rate", theme.on_surface_variant, .label_m); + lay.baud = .{ .x = baud_card.x + tokens.Space.lg, .y = baud_card.y + 39, .w = baud_card.w - tokens.Space.lg * 2, .h = 54 }; + var baud_buf: [24]u8 = undefined; + const baud_txt = std.fmt.bufPrint(&baud_buf, "{d}", .{state.draft_baud}) catch "115200"; + widgets.drawTonalButton(logical, lay.baud, baud_txt, theme); + y += 120; + font.drawTextRole(logical, x, y, state.statusText(), theme.on_surface_variant, .body_m); + const action_y = card.y + card.h - 90; + const action_w: i32 = 270; + lay.refresh = .{ .x = x, .y = action_y, .w = action_w, .h = 64 }; + lay.test_cnc = .{ .x = x + @divTrunc((right - x) - action_w, 2), .y = action_y, .w = action_w, .h = 64 }; + lay.review = .{ .x = right - action_w, .y = action_y, .w = action_w, .h = 64 }; + if (!state.config_busy) widgets.drawTonalButton(logical, lay.refresh, "Refresh from S3", theme) else disabled(logical, lay.refresh, "Refreshing...", theme); + if (state.test_supported and !state.config_busy) + widgets.drawTonalButton(logical, lay.test_cnc, "Test CNC connection", theme) + else if (state.config_busy) + disabled(logical, lay.test_cnc, "Testing...", theme) + else + disabled(logical, lay.test_cnc, "Update S3 to test", theme); + if (!state.config_busy) widgets.drawFilledButton(logical, lay.review, "Review & Apply", theme) else disabled(logical, lay.review, "Applying...", theme); + if (state.view == .review) { + const box: geom.Rect = .{ .x = card.x + 270, .y = card.y + 125, .w = 680, .h = 430 }; + widgets.fillRoundRect(logical, box, tokens.Shape.dialog, theme.elev(5)); + font.drawTextRole(logical, box.x + 32, box.y + 28, "Apply S3 UART settings?", theme.on_surface, .title_m); + const labels = [_][]const u8{ "Transmit GPIO", "Receive GPIO", "Baud rate" }; + const old = [_]i64{ state.uart_tx, state.uart_rx, state.uart_baud }; + const new = [_]i64{ state.draft_tx, state.draft_rx, state.draft_baud }; + for (labels, 0..) |label, i| { + const ry = box.y + 92 + @as(i32, @intCast(i)) * 58; + font.drawTextRole(logical, box.x + 32, ry, label, theme.on_surface_variant, .body_m); + var before: [24]u8 = undefined; + var after: [24]u8 = undefined; + const before_txt = std.fmt.bufPrint(&before, "{d}", .{old[i]}) catch "?"; + const after_txt = std.fmt.bufPrint(&after, "{d}", .{new[i]}) catch "?"; + font.drawTextRole(logical, box.x + 290, ry, before_txt, theme.on_surface_variant, .body_l); + font.drawTextRole(logical, box.x + 405, ry, "->", theme.on_surface_variant, .body_l); + font.drawTextRole(logical, box.x + 485, ry, after_txt, theme.primary, .body_l); + } + font.drawTextRole(logical, box.x + 32, box.y + 285, "UART pauses briefly; ESP-NOW remains connected.", theme.on_surface_variant, .body_m); + lay.cancel = .{ .x = box.x + 32, .y = box.y + box.h - 88, .w = 220, .h = 60 }; + lay.apply = .{ .x = box.x + box.w - 252, .y = box.y + box.h - 88, .w = 220, .h = 60 }; + widgets.drawTonalButton(logical, lay.cancel, "Cancel", theme); + widgets.drawFilledButton(logical, lay.apply, "Apply", theme); + } + return lay; + } + const link = if (state.s3_connected) "S3 connected via ESP-NOW" else "S3 bridge not connected"; + font.drawTextRole(logical, x, y, link, if (state.s3_connected) theme.primary else theme.on_error_container, .body_m); + if (state.version_len != 0) { + var version: [48]u8 = undefined; + const version_text = std.fmt.bufPrint(&version, "Firmware: {s}", .{shortVersion(state.versionText())}) catch "Firmware version unavailable"; + drawFittedText(logical, x + 330, y, 390, version_text, theme.primary, .body_m); + } + lay.refresh = .{ .x = right - 190, .y = y - 10, .w = 190, .h = 60 }; + widgets.drawTonalButton(logical, lay.refresh, "Refresh USB", theme); + y += 34; + var installed: [64]u8 = undefined; + var selected_version: [64]u8 = undefined; + const installed_text = std.fmt.bufPrint(&installed, "Installed: {s}", .{if (state.version_len != 0) shortVersion(state.versionText()) else "unknown"}) catch "Installed: unknown"; + const selected_text = std.fmt.bufPrint(&selected_version, "Selected image: {s}", .{if (state.image_version_len != 0) shortVersion(state.imageVersionText()) else "none"}) catch "Selected image: none"; + drawFittedText(logical, x, y, 280, installed_text, theme.on_surface_variant, .body_m); + drawFittedText(logical, x + 310, y, 350, selected_text, theme.on_surface_variant, .body_m); + if (state.version_len != 0 and state.image_version_len != 0 and std.mem.eql(u8, state.versionText(), state.imageVersionText())) { + font.drawTextRole(logical, x + 690, y, "Already installed", theme.primary, .label_m); + } + y += 42; + + const row_h: i32 = tokens.Logical.touch_min; + const row_gap: i32 = tokens.Space.xs; + lay.file_area = .{ .x = x, .y = y, .w = right - x, .h = 5 * row_h + 4 * row_gap }; + lay.row_n = @min(state.file_count, 5); + var i: u8 = 0; + while (i < lay.row_n) : (i += 1) { + const r: geom.Rect = .{ .x = x, .y = y, .w = right - x, .h = row_h }; + lay.rows[i] = r; + const selected = i == state.selected; + widgets.fillRoundRect(logical, r, tokens.Shape.md, theme.surface_bright); + widgets.strokeRoundRect(logical, r, tokens.Shape.md, if (selected) theme.primary else theme.outline_variant, if (selected) 2 else 1); + drawFittedText(logical, r.x + tokens.Space.md, r.y + 13, r.w - tokens.Space.md * 2, displayFileName(state.fileText(i)), theme.on_surface, .body_m); + y += row_h + row_gap; + } + if (lay.row_n == 0) font.drawTextRole(logical, x + tokens.Space.md, y + 14, "No verified ESP32-S3 app images found on USB.", theme.on_surface_variant, .body_m); + + const status_y = card.y + card.h - 150; + lay.status_area = .{ .x = x, .y = status_y, .w = right - x, .h = 37 }; + drawFittedText(logical, x, status_y, right - x, state.statusText(), if (state.phase == .failed) theme.on_error_container else theme.on_surface_variant, .body_m); + const bar: geom.Rect = .{ .x = x, .y = status_y + 27, .w = right - x, .h = 10 }; + widgets.fillRoundRect(logical, bar, 5, theme.surface_container_high); + if (state.progress > 0) { + const fill: geom.Rect = .{ .x = bar.x, .y = bar.y, .w = @divTrunc(bar.w * state.progress, 100), .h = bar.h }; + widgets.fillRoundRect(logical, fill, 5, theme.primary); + } + + const action_h: i32 = 64; + const action_w: i32 = 250; + const by = card.y + card.h - tokens.Space.lg - action_h; + lay.check = .{ .x = x, .y = by, .w = action_w, .h = action_h }; + lay.flash = .{ .x = x + action_w + tokens.Space.md, .y = by, .w = action_w, .h = action_h }; + lay.restart = .{ .x = right - action_w, .y = by, .w = action_w, .h = action_h }; + if (state.file_count > 0 and state.phase != .flashing) widgets.drawFilledButton(logical, lay.check, "1. Check S3 image", theme) else disabled(logical, lay.check, "1. Check S3 image", theme); + if (state.phase == .armed) widgets.drawDangerButton(logical, lay.flash, "2. Flash S3", theme) else disabled(logical, lay.flash, "2. Flash S3", theme); + if (state.phase == .success) widgets.drawFilledButton(logical, lay.restart, "3. Restart S3", theme) else disabled(logical, lay.restart, "3. Restart S3", theme); + return lay; +} + +pub fn hit(layout: Layout, x: i32, y: i32) HitInfo { + if (tool_chrome.hitBack(layout.header, x, y)) return .{ .kind = .back }; + if (tool_chrome.hitExit(layout.header, x, y)) return .{ .kind = .exit }; + if (tool_chrome.hitScrim(layout.header, x, y)) return .{ .kind = .scrim }; + if (layout.detail_back.contains(x, y)) return .{ .kind = .detail_back }; + if (layout.refresh.contains(x, y)) return .{ .kind = .refresh }; + if (layout.firmware.contains(x, y)) return .{ .kind = .firmware }; + if (layout.settings.contains(x, y)) return .{ .kind = .settings }; + if (layout.tx_minus.contains(x, y)) return .{ .kind = .tx_minus }; + if (layout.tx_plus.contains(x, y)) return .{ .kind = .tx_plus }; + if (layout.rx_minus.contains(x, y)) return .{ .kind = .rx_minus }; + if (layout.rx_plus.contains(x, y)) return .{ .kind = .rx_plus }; + if (layout.baud.contains(x, y)) return .{ .kind = .baud }; + if (layout.test_cnc.contains(x, y)) return .{ .kind = .test_cnc }; + if (layout.review.contains(x, y)) return .{ .kind = .review }; + if (layout.cancel.contains(x, y)) return .{ .kind = .cancel }; + if (layout.apply.contains(x, y)) return .{ .kind = .apply }; + var i: u8 = 0; + while (i < layout.row_n) : (i += 1) if (layout.rows[i].contains(x, y)) return .{ .kind = .row, .index = i }; + if (layout.check.contains(x, y)) return .{ .kind = .check }; + if (layout.flash.contains(x, y)) return .{ .kind = .flash }; + if (layout.restart.contains(x, y)) return .{ .kind = .restart }; + return .{}; +} + +test "S3 OTA controls meet minimum touch size" { + const gpa = std.testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + var state: State = .{ .view = .firmware }; + state.file_count = 1; + const lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expect(lay.check.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.flash.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.restart.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.file_area.y + lay.file_area.h <= lay.status_area.y); +} + +test "S3 firmware view matches the C6 five-row list layout" { + const gpa = std.testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + var state: State = .{ .view = .firmware, .file_count = max_files }; + const lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expectEqual(@as(u8, 5), lay.row_n); + try std.testing.expectEqual(lay.rows[0].x, lay.rows[4].x); + try std.testing.expectEqual(lay.rows[0].w, lay.rows[4].w); + try std.testing.expect(lay.rows[4].y > lay.rows[0].y); + try std.testing.expect(lay.rows[4].y + lay.rows[4].h <= lay.status_area.y); +} + +test "S3 versions are shortened before rendering" { + try std.testing.expectEqualStrings("v3.1.2", shortVersion("v3.1.2-ota-2-g41f23eb-dirty")); + try std.testing.expectEqualStrings("v3.1.0", shortVersion("v3.1.0-10-g8166aac-dirty")); +} + +test "S3 dashboard has balanced firmware and settings cards" { + const gpa = std.testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + const state: State = .{ .view = .dashboard, .s3_connected = true, .config_loaded = true, .uart_tx = 8, .uart_rx = 7 }; + const lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expectEqual(lay.firmware.w, lay.settings.w); + try std.testing.expectEqual(lay.firmware.h, lay.settings.h); + try std.testing.expect(lay.firmware.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.settings.h >= tokens.Logical.touch_min); + try std.testing.expectEqual(Hit.firmware, hit(lay, lay.firmware.x + 4, lay.firmware.y + 4).kind); + try std.testing.expectEqual(Hit.settings, hit(lay, lay.settings.x + 4, lay.settings.y + 4).kind); +} + +test "S3 settings and confirmation controls meet minimum touch size" { + const gpa = std.testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + var state: State = .{ .view = .settings, .s3_connected = true, .config_supported = true, .config_loaded = true, .draft_tx = 3, .draft_rx = 4 }; + var lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expect(lay.tx_minus.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.tx_plus.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.rx_minus.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.rx_plus.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.baud.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.test_cnc.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.review.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.refresh.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.detail_back.h >= tokens.Logical.touch_min); + state.view = .review; + lay = paint(&logical, tokens.Theme.industrialTealDark(), &state, 1); + try std.testing.expect(lay.cancel.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.apply.h >= tokens.Logical.touch_min); +} diff --git a/src/modulus/ui_engine/m_panel_update_hub.zig b/src/modulus/ui_engine/m_panel_update_hub.zig new file mode 100644 index 0000000..3f7f145 --- /dev/null +++ b/src/modulus/ui_engine/m_panel_update_hub.zig @@ -0,0 +1,152 @@ +const geom = @import("geom.zig"); +const tokens = @import("tokens.zig"); +const fb = @import("fb.zig"); +const font = @import("font.zig"); +const widgets = @import("widgets.zig"); +const icons = @import("icons_phosphor.zig"); +const tool_chrome = @import("m_panel_tool.zig"); +const color = @import("color.zig"); +const bg = color.Rgb565.fromHex(0x001426); +const card_bg = color.Rgb565.fromHex(0x03233F); +const cyan = color.Rgb565.fromHex(0x18C8FF); +const white = color.Rgb565.fromHex(0xF4FAFF); +const muted = color.Rgb565.fromHex(0xA9C7E5); +const green = color.Rgb565.fromHex(0x00EE88); +const action_blue = color.Rgb565.fromHex(0x079BFF); + +pub const Target = enum(u8) { c6, s3, nano, node }; +pub const Hit = enum { none, back, exit, target, refresh }; +pub const HitInfo = struct { kind: Hit = .none, target: Target = .c6 }; +pub const Layout = struct { + header: tool_chrome.Header = .{}, + cards: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + targets: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + refresh: geom.Rect = .{}, +}; + +fn cardGeom(enter_t: f32) geom.Rect { + _ = enter_t; + return .{ .x = 20, .y = 20, .w = tokens.Logical.width - 40, .h = tokens.Logical.height - 40 }; +} + +fn chipBadge(logical: *fb.LogicalFb, cx: i32, cy: i32, label: []const u8) void { + const chip: geom.Rect = .{ .x = cx - 44, .y = cy - 44, .w = 88, .h = 88 }; + widgets.fillRoundRect(logical, chip, 10, bg); + widgets.strokeRoundRect(logical, chip, 10, color.Rgb565.fromHex(0x7EF3FF), 6); + var p: i32 = -32; + while (p <= 32) : (p += 16) { + logical.fillRect(.{ .x = chip.x - 10, .y = cy + p - 3, .w = 12, .h = 6 }, color.Rgb565.fromHex(0x7EF3FF)); + logical.fillRect(.{ .x = chip.x + chip.w - 2, .y = cy + p - 3, .w = 12, .h = 6 }, color.Rgb565.fromHex(0x7EF3FF)); + logical.fillRect(.{ .x = cx + p - 3, .y = chip.y - 10, .w = 6, .h = 12 }, color.Rgb565.fromHex(0x7EF3FF)); + logical.fillRect(.{ .x = cx + p - 3, .y = chip.y + chip.h - 2, .w = 6, .h = 12 }, color.Rgb565.fromHex(0x7EF3FF)); + } + font.drawTextRole(logical, cx - @divTrunc(font.textWidthStr(label, .headline_m), 2), cy - 17, label, white, .headline_m); +} + +fn actionButton(logical: *fb.LogicalFb, r: geom.Rect, label: []const u8) void { + widgets.fillRoundRect(logical, .{ .x = r.x - 3, .y = r.y - 3, .w = r.w + 6, .h = r.h + 6 }, 16, color.Rgb565.fromHex(0x03598E)); + widgets.fillRoundRect(logical, r, 14, action_blue); + widgets.strokeRoundRect(logical, r, 14, cyan, 2); + font.drawTextRole(logical, r.x + @divTrunc(r.w - font.textWidthStr(label, .title_l), 2), r.y + 9, label, white, .title_l); +} + +fn glowCard(logical: *fb.LogicalFb, r: geom.Rect) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, color.Rgb565.fromHex(0x021B33)); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, color.Rgb565.fromHex(0x075D91), 5); + widgets.strokeRoundRect(logical, .{ .x = r.x + 3, .y = r.y + 3, .w = r.w - 6, .h = r.h - 6 }, tokens.Shape.lg - 2, cyan, 2); +} + +fn targetCard(logical: *fb.LogicalFb, theme: tokens.Theme, r: geom.Rect, button: geom.Rect, badge: []const u8, title: []const u8, detail: []const u8, link: []const u8, version: []const u8, online: bool) void { + glowCard(logical, r); + chipBadge(logical, r.x + @divTrunc(r.w, 2), r.y + 70, badge); + font.drawTextRole(logical, r.x + @divTrunc(r.w - font.textWidthStr(title, .headline_m), 2), r.y + 126, title, white, .headline_m); + font.drawTextRole(logical, r.x + @divTrunc(r.w - font.textWidthStr(detail, .body_l), 2), r.y + 168, detail, muted, .body_l); + const status_w = font.textWidthStr(link, .body_l); + const sx = r.x + @divTrunc(r.w - status_w - 26, 2); + widgets.fillRoundRect(logical, .{ .x = sx, .y = r.y + 211, .w = 18, .h = 18 }, 9, if (online) green else theme.error_container); + font.drawTextRole(logical, sx + 28, r.y + 202, link, if (online) green else theme.on_error_container, .body_l); + const ver = if (version.len > 0) version else "Unknown"; + const prefix = "Version "; + const vw = font.textWidthStr(prefix, .body_l) + font.textWidthStr(ver, .body_l); + const vx = r.x + @divTrunc(r.w - vw, 2); + font.drawTextRole(logical, vx, r.y + 238, prefix, muted, .body_l); + font.drawTextRole(logical, vx + font.textWidthStr(prefix, .body_l), r.y + 238, ver, white, .body_l); + actionButton(logical, button, "Open update"); +} + +fn nodeCard(logical: *fb.LogicalFb, theme: tokens.Theme, r: geom.Rect, button: geom.Rect, online: bool) void { + glowCard(logical, r); + icons.drawCenteredScaled(logical, r.x + 62, r.y + @divTrunc(r.h, 2), .plugs, cyan, 48); + font.drawTextRole(logical, r.x + 116, r.y + 15, "Zigbee Node", white, .title_l); + font.drawTextRole(logical, r.x + 116, r.y + 54, "GPIO, relay and sensor node", muted, .body_l); + font.drawTextRole(logical, r.x + r.w - 450, r.y + 34, if (online) "Connected via Zigbee" else "Not connected", if (online) green else theme.on_error_container, .body_l); + actionButton(logical, button, "USB update"); +} + +pub fn paint(logical: *fb.LogicalFb, theme: tokens.Theme, c6_online: bool, s3_online: bool, nano_online: bool, node_online: bool, c6_version: []const u8, s3_version: []const u8, nano_version: []const u8, usb_ready: bool, enter_t: f32) Layout { + logical.fillRect(.{ .x = 0, .y = 0, .w = tokens.Logical.width, .h = tokens.Logical.height }, bg); + const card = cardGeom(enter_t); + widgets.fillRoundRect(logical, card, tokens.Shape.dialog, bg); + widgets.strokeRoundRect(logical, card, tokens.Shape.dialog, cyan, 1); + var lay: Layout = .{}; + const header: geom.Rect = .{ .x = 8, .y = 8, .w = 1264, .h = 60 }; + widgets.fillRoundRect(logical, header, 12, card_bg); + widgets.strokeRoundRect(logical, header, 12, cyan, 2); + lay.header = .{ .card = card, .back = .{ .x = 12, .y = 12, .w = 56, .h = 52 }, .exit = .{ .x = 1204, .y = 12, .w = 56, .h = 52 } }; + widgets.fillRoundRect(logical, lay.header.back, 10, color.Rgb565.fromHex(0x052E52)); + font.drawTextRole(logical, 28, 18, "<", white, .headline_l); + font.drawTextRole(logical, 94, 17, "M", cyan, .display_s); + font.drawTextRole(logical, 126, 19, "Modulus", white, .headline_m); + logical.fillRect(.{ .x = 260, .y = 18, .w = 2, .h = 40 }, cyan); + font.drawTextRole(logical, 288, 16, "Firmware Update", white, .headline_l); + icons.drawCenteredScaled(logical, 928, 38, .usb, cyan, 38); + font.drawTextRole(logical, 958, 15, if (usb_ready) "USB connected" else "USB not connected", white, .title_l); + font.drawTextRole(logical, 958, 40, if (usb_ready) "FAT32 drive detected" else "Insert FAT32 drive", muted, .body_m); + widgets.fillRoundRect(logical, lay.header.exit, 10, color.Rgb565.fromHex(0x052E52)); + font.drawTextRole(logical, 1220, 18, "X", white, .headline_l); + font.drawTextRole(logical, 14, 72, "Select a device", white, .headline_l); + const gap: i32 = 10; + const x: i32 = 8; + const y: i32 = 108; + const w: i32 = 414; + const h: i32 = 372; + lay.cards[0] = .{ .x = x, .y = y, .w = w, .h = h }; + lay.cards[1] = .{ .x = x + w + gap, .y = y, .w = w, .h = h }; + lay.cards[2] = .{ .x = x + (w + gap) * 2, .y = y, .w = w, .h = h }; + for (0..3) |i| lay.targets[i] = .{ .x = lay.cards[i].x + 16, .y = lay.cards[i].y + h - 68, .w = w - 32, .h = 56 }; + lay.cards[3] = .{ .x = x, .y = 490, .w = 1264, .h = 92 }; + lay.targets[3] = .{ .x = 1020, .y = 506, .w = 224, .h = 60 }; + targetCard(logical, theme, lay.cards[0], lay.targets[0], "C6", "Internal C6", "Tab5 wireless controller", if (c6_online) "Connected" else "Not connected", c6_version, c6_online); + targetCard(logical, theme, lay.cards[1], lay.targets[1], "S3", "S3 Bridge", "CNC wireless bridge", if (s3_online) "Connected via ESP-NOW" else "Not connected", s3_version, s3_online); + targetCard(logical, theme, lay.cards[2], lay.targets[2], "Z", "NanoH2", "Zigbee coordinator", if (nano_online) "Connected via UART" else "Not connected", nano_version, nano_online); + nodeCard(logical, theme, lay.cards[3], lay.targets[3], node_online); + const info: geom.Rect = .{ .x = x, .y = 592, .w = 1264, .h = 54 }; + widgets.fillRoundRect(logical, info, tokens.Shape.md, card_bg); + widgets.strokeRoundRect(logical, info, tokens.Shape.md, cyan, 1); + icons.drawCenteredScaled(logical, info.x + 46, info.y + 29, .usb, cyan, 38); + font.drawTextRole(logical, info.x + 86, info.y + 7, "Insert a FAT32 USB drive containing the matching OTA file.", white, .body_l); + font.drawTextRole(logical, info.x + 86, info.y + 30, "The image is checked before flashing.", muted, .body_m); + lay.refresh = .{ .x = x, .y = 654, .w = 1264, .h = 54 }; + widgets.fillRoundRect(logical, lay.refresh, 12, card_bg); + widgets.strokeRoundRect(logical, lay.refresh, 12, cyan, 2); + icons.drawCenteredScaled(logical, 535, 681, .broadcast, cyan, 34); + font.drawTextRole(logical, 566, 664, "Rescan USB drive", white, .title_l); + return lay; +} + +pub fn hit(lay: Layout, x: i32, y: i32) HitInfo { + if (tool_chrome.hitBack(lay.header, x, y)) return .{ .kind = .back }; + if (tool_chrome.hitExit(lay.header, x, y)) return .{ .kind = .exit }; + if (lay.refresh.contains(x, y)) return .{ .kind = .refresh }; + for (lay.targets, 0..) |r, i| if (r.contains(x, y)) + return .{ .kind = .target, .target = @enumFromInt(i) }; + return .{}; +} + +test "firmware update touch targets are glove friendly" { + const gpa = @import("std").testing.allocator; + var logical = try fb.LogicalFb.alloc(gpa); + defer logical.deinit(gpa); + const lay = paint(&logical, tokens.Theme.industrialTealDark(), true, true, true, true, "2.11.4", "3.1.4", "3.1.4", true, 1); + for (lay.targets) |r| try @import("std").testing.expect(r.h >= tokens.Logical.touch_min); +} diff --git a/src/modulus/ui_engine/m_panel_zigbee.zig b/src/modulus/ui_engine/m_panel_zigbee.zig index 6be1e5d..ba71597 100644 --- a/src/modulus/ui_engine/m_panel_zigbee.zig +++ b/src/modulus/ui_engine/m_panel_zigbee.zig @@ -46,6 +46,7 @@ pub const CardLayout = struct { exposes_link: geom.Rect = .{}, identify: geom.Rect = .{}, remove: geom.Rect = .{}, + configure: geom.Rect = .{}, }; pub const Hit = struct { @@ -63,6 +64,7 @@ pub const Kind = enum { permit_join, refresh, join_hub, + add_device, toggle, slider, child_lock, @@ -71,6 +73,26 @@ pub const Kind = enum { cover, identify, remove, + configure, + node_name, + node_channel_name, + node_type, + node_gpio, + node_polarity, + node_pullup, + node_poll, + node_favorite, + node_start, + node_alarm_enabled, + node_alarm_high, + node_alarm_low, + node_alarm_hysteresis, + node_led_gpio, + node_led_polarity, + node_apply, + node_close, + node_prev, + node_next, exposes, }; @@ -79,12 +101,33 @@ pub const Layout = struct { permit: geom.Rect = .{}, refresh: geom.Rect = .{}, join: geom.Rect = .{}, + add: geom.Rect = .{}, view: geom.Rect = .{}, cards: [max_devices]CardLayout = [_]CardLayout{.{}} ** max_devices, card_n: u8 = 0, scroll_max: i32 = 0, grid_cols: u8 = 2, menu_anchor: geom.Rect = .{}, + node_name: geom.Rect = .{}, + node_led_gpio: geom.Rect = .{}, + node_led_polarity: geom.Rect = .{}, + node_type: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_channel_name: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_gpio: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_poll: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_favorite: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_start: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_alarm_enabled: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_alarm_high: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_alarm_low: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_alarm_hysteresis: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_pullup: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_polarity: [4]geom.Rect = [_]geom.Rect{.{}} ** 4, + node_apply: geom.Rect = .{}, + node_close: geom.Rect = .{}, + node_prev: geom.Rect = .{}, + node_next: geom.Rect = .{}, + node_page_start: u8 = 0, }; fn gridCols(view_w: i32) i32 { @@ -360,10 +403,13 @@ fn paintDevCard( font.drawTextRole(logical, bx + icons_phosphor.size + tokens.Space.xs, foot_mid - @divTrunc(foot_lh, 2), bat, theme.on_surface_variant, .label_m); } + const cfg_w = btnWidth("Configure"); const id_w = btnWidth("Identify"); const del_w = btnWidth("Del"); lay.remove = .{ .x = outer.x + outer.w - pad - del_w, .y = foot_y, .w = del_w, .h = row_h }; lay.identify = .{ .x = lay.remove.x - tokens.Space.sm - id_w, .y = foot_y, .w = id_w, .h = row_h }; + lay.configure = .{ .x = lay.identify.x - tokens.Space.sm - cfg_w, .y = foot_y, .w = cfg_w, .h = row_h }; + widgets.drawFilledButton(logical, lay.configure, "Configure", theme); widgets.drawTonalButton(logical, lay.identify, "Identify", theme); widgets.drawDangerTonalButton(logical, lay.remove, "Del", theme); } @@ -393,53 +439,144 @@ pub fn paint(logical: *fb.LogicalFb, theme: tokens.Theme, ctx: Ctx, enter_t: f32 bar_y = status_y + body_lh * 2 + tokens.Space.md; } - const permit_w = btnWidth(if (w.zb_scan_phase == 1) "Joining..." else "Permit join"); - lay.permit = .{ .x = shell.x + pad, .y = bar_y, .w = permit_w, .h = btn_size.height() }; - const ref_w = btnWidth("Refresh"); - lay.refresh = .{ .x = lay.permit.x + permit_w + tokens.Space.sm, .y = bar_y, .w = ref_w, .h = btn_size.height() }; - if (!w.zb_joined) { - const jw = btnWidth("Join hub"); - lay.join = .{ .x = lay.refresh.x + ref_w + tokens.Space.sm, .y = bar_y, .w = jw, .h = btn_size.height() }; - } - - const permit_on = w.zigbee and w.zb_joined and w.zb_scan_phase == 1; - if (w.zigbee and w.zb_joined) { - if (permit_on) { - widgets.drawFilledButton(logical, lay.permit, "Joining...", theme); - } else { - widgets.drawFilledButton(logical, lay.permit, "Permit join", theme); - } - } else { - widgets.drawButton(logical, lay.permit, "Permit join", .filled, .disabled, theme); - } - if (w.zigbee) { - widgets.drawTonalButton(logical, lay.refresh, "Refresh", theme); - } else { - widgets.drawButton(logical, lay.refresh, "Refresh", .tonal, .disabled, theme); - } - if (!w.zb_joined) { - widgets.drawTonalButton(logical, lay.join, "Join hub", theme); - } - - if (permit_on) { - const prog_w = lay.refresh.x + lay.refresh.w - lay.permit.x; - const prog: geom.Rect = .{ - .x = lay.permit.x, - .y = lay.permit.y + lay.permit.h + tokens.Space.xs, - .w = prog_w, - .h = tokens.Space.xs, - }; - expr.drawLoadingIndicator(logical, prog, ctx.anim_t * 8.0, theme); - } + lay.add = .{ .x = shell.x + pad, .y = bar_y, .w = 300, .h = 64 }; + widgets.drawFilledButton(logical, lay.add, "Add Zigbee device", theme); - const footer_h = btn_size.height() + tokens.Space.md; - const body_top = bar_y + btn_size.height() + tokens.Space.md + if (permit_on) tokens.Space.sm + tokens.Space.xs else 0; + const footer_h = if (w.zb_node_open) 0 else btn_size.height() + tokens.Space.md; + const body_top = if (w.zb_node_open) + lay.header.back.y + lay.header.back.h + tokens.Space.sm + else + bar_y + lay.add.h + tokens.Space.md; lay.view = .{ .x = shell.x + pad, .y = body_top, .w = shell.w - pad * 2, .h = shell.y + shell.h - pad - footer_h - body_top, }; + if (w.zb_node_open) { + // Configuration owns the whole body. Cover the network toolbar drawn + // during the transition so all four fixed channels fit without a + // laggy scrolling surface. + logical.fillRect(lay.view, theme.elev(3)); + const node_row: i32 = 52; + const node_gap = tokens.Space.sm; + lay.scroll_max = 0; + const node_scroll: i32 = 0; + logical.setClip(lay.view); + var y = lay.view.y - node_scroll; + const full_w = lay.view.w; + if (!w.zb_node_ready) { + font.drawTextRole(logical, lay.view.x, y, "Reading Node configuration...", theme.on_surface_variant, .title_m); + } else { + const row = node_row; + const count: usize = @min(@as(usize, w.zb_node_channel_count), w.zb_node_channels.len); + const pages: usize = count + 1; + const page: usize = @min(@as(usize, w.zb_node_page), pages - 1); + const first: usize = page -| 1; + const last: usize = @min(first + 1, count); + lay.node_page_start = @intCast(first); + font.drawTextRole(logical, lay.view.x, y, "Modulus Zigbee Node", theme.on_surface, .title_l); + y += 52; + var page_buf: [40]u8 = undefined; + const page_text = if (page == 0) "STATUS LED SETTINGS" else std.fmt.bufPrint(&page_buf, "CHANNEL {d} SETTINGS", .{first + 1}) catch "CHANNEL SETTINGS"; + const section: geom.Rect = .{ .x = lay.view.x, .y = y, .w = full_w, .h = 58 }; + widgets.fillRoundRect(logical, section, tokens.Shape.md, theme.primary_container); + const page_w = font.textWidthStr(page_text, .title_m); + const page_h = font.faceHeight(font.faceForRole(.title_m)); + font.drawTextRole(logical, section.x + @divTrunc(section.w - page_w, 2), section.y + @divTrunc(section.h - page_h, 2), page_text, theme.on_primary_container, .title_m); + y += section.h + node_gap; + const types = [_][]const u8{ "Disabled", "Switch", "Digital output", "Digital input", "DS18B20" }; + if (page == 0) { + lay.node_name = .{ .x = lay.view.x, .y = y, .w = full_w, .h = row }; + paintDropdownRow(logical, theme, lay.node_name, "Device name", std.mem.sliceTo(&w.zb_node_name, 0)); + y += row + node_gap; + var led_gpio: [12]u8 = undefined; + lay.node_led_gpio = .{ .x = lay.view.x, .y = y, .w = full_w, .h = row }; + const led_text = if (w.zb_node_led_gpio < 0) "None" else std.fmt.bufPrint(&led_gpio, "GPIO {d}", .{w.zb_node_led_gpio}) catch "?"; + paintDropdownRow(logical, theme, lay.node_led_gpio, "Status LED pin", led_text); + y += row + node_gap; + lay.node_led_polarity = .{ .x = lay.view.x, .y = y, .w = full_w, .h = row }; + paintToggleRow(logical, theme, lay.node_led_polarity, "LED active low", (w.zb_node_led_flags & 1) != 0); + y += row + node_gap; + } else { + var i: usize = 0; + while (i < last - first) : (i += 1) { + const channel_idx = first + i; + var gpio: [12]u8 = undefined; + const ch = &w.zb_node_channels[channel_idx]; + const half = @divTrunc(full_w - node_gap, 2); + lay.node_channel_name[i] = .{ .x = lay.view.x, .y = y, .w = half, .h = row }; + paintDropdownRow(logical, theme, lay.node_channel_name[i], "Name", std.mem.sliceTo(&ch.name, 0)); + lay.node_type[i] = .{ .x = lay.view.x + half + node_gap, .y = y, .w = half, .h = row }; + paintDropdownRow(logical, theme, lay.node_type[i], "Function", types[@min(@as(usize, ch.typ), types.len - 1)]); + y += row + node_gap; + lay.node_gpio[i] = .{ .x = lay.view.x, .y = y, .w = half, .h = row }; + const gpio_text = if (ch.gpio < 0) "None" else std.fmt.bufPrint(&gpio, "GPIO {d}", .{ch.gpio}) catch "?"; + paintDropdownRow(logical, theme, lay.node_gpio[i], "Pin", gpio_text); + lay.node_favorite[i] = .{ .x = lay.view.x + half + node_gap, .y = y, .w = half, .h = row }; + const favorites = [_][]const u8{ "Not shown", "Position 1", "Position 2", "Position 3" }; + paintDropdownRow(logical, theme, lay.node_favorite[i], "Quick access", favorites[@min(@as(usize, ch.favorite), 3)]); + y += row + node_gap; + if (ch.typ == 4) { + var period: [24]u8 = undefined; + lay.node_poll[i] = .{ .x = lay.view.x, .y = y, .w = half, .h = row }; + paintDropdownRow(logical, theme, lay.node_poll[i], "Poll interval", std.fmt.bufPrint(&period, "{d} s", .{ch.poll_interval_s}) catch "?"); + lay.node_alarm_enabled[i] = .{ .x = lay.view.x + half + node_gap, .y = y, .w = half, .h = row }; + paintToggleRow(logical, theme, lay.node_alarm_enabled[i], "Temperature alarm", ch.temp_alarm_enabled); + y += row + node_gap; + var low: [24]u8 = undefined; + var high: [24]u8 = undefined; + lay.node_alarm_low[i] = .{ .x = lay.view.x, .y = y, .w = half, .h = row }; + paintDropdownRow(logical, theme, lay.node_alarm_low[i], "Low alarm", std.fmt.bufPrint(&low, "{d:.1} C", .{@as(f32, @floatFromInt(ch.temp_alarm_low_centi_c)) / 100.0}) catch "?"); + lay.node_alarm_high[i] = .{ .x = lay.view.x + half + node_gap, .y = y, .w = half, .h = row }; + paintDropdownRow(logical, theme, lay.node_alarm_high[i], "High alarm", std.fmt.bufPrint(&high, "{d:.1} C", .{@as(f32, @floatFromInt(ch.temp_alarm_high_centi_c)) / 100.0}) catch "?"); + y += row + node_gap; + var hyst: [24]u8 = undefined; + lay.node_alarm_hysteresis[i] = .{ .x = lay.view.x, .y = y, .w = half, .h = row }; + paintDropdownRow(logical, theme, lay.node_alarm_hysteresis[i], "Hysteresis", std.fmt.bufPrint(&hyst, "{d:.1} C", .{@as(f32, @floatFromInt(ch.temp_alarm_hysteresis_centi_c)) / 100.0}) catch "?"); + var reading: [32]u8 = undefined; + const value = if (ch.gpio < 0) "Select a GPIO" else if (ch.temperature_state == 1) std.fmt.bufPrint(&reading, "{d:.2} C", .{@as(f32, @floatFromInt(ch.temperature_centi_c)) / 100.0}) catch "?" else "Waiting / unavailable"; + paintReadonly(logical, theme, .{ .x = lay.view.x + half + node_gap, .y = y, .w = half, .h = row }, "Temperature", value, if (ch.temp_alarm_active) theme.err else theme.on_surface_variant); + y += row + node_gap; + } else if (ch.typ == 3) { + lay.node_polarity[i] = .{ .x = lay.view.x, .y = y, .w = half, .h = row }; + paintToggleRow(logical, theme, lay.node_polarity[i], "Active low", (ch.flags & 1) != 0); + lay.node_pullup[i] = .{ .x = lay.view.x + half + node_gap, .y = y, .w = half, .h = row }; + paintToggleRow(logical, theme, lay.node_pullup[i], "Internal pull-up", (ch.flags & 2) != 0); + y += row + node_gap; + const state = if (ch.gpio < 0) "Select a GPIO" else switch (ch.digital_state) { + 1 => if (ch.digital_value) "ON" else "OFF", + 2 => "No recent state", + 3 => "Apply and restart required", + else => "Waiting for state", + }; + paintReadonly(logical, theme, .{ .x = lay.view.x, .y = y, .w = full_w, .h = row }, "Input", state, theme.on_surface_variant); + y += row + node_gap; + } else if (ch.typ == 1 or ch.typ == 2) { + lay.node_polarity[i] = .{ .x = lay.view.x, .y = y, .w = half, .h = row }; + paintToggleRow(logical, theme, lay.node_polarity[i], "Active low", (ch.flags & 1) != 0); + lay.node_start[i] = .{ .x = lay.view.x + half + node_gap, .y = y, .w = half, .h = row }; + paintDropdownRow(logical, theme, lay.node_start[i], "After restart", if (ch.start_mode == 1) "On" else "Off"); + y += row + node_gap; + } + } + } + lay.node_apply = .{ .x = lay.view.x, .y = y + tokens.Space.sm, .w = 300, .h = btn_size.height() }; + lay.node_close = .{ .x = lay.node_apply.x + lay.node_apply.w + tokens.Space.sm, .y = lay.node_apply.y, .w = 160, .h = btn_size.height() }; + if (count > 1) { + lay.node_next = .{ .x = lay.view.x + full_w - 140, .y = lay.node_apply.y, .w = 140, .h = btn_size.height() }; + lay.node_prev = .{ .x = lay.node_next.x - 140 - tokens.Space.sm, .y = lay.node_apply.y, .w = 140, .h = btn_size.height() }; + } + widgets.drawFilledButton(logical, lay.node_apply, "Apply and restart", theme); + widgets.drawTonalButton(logical, lay.node_close, "Close", theme); + if (count > 1) { + if (page > 0) widgets.drawTonalButton(logical, lay.node_prev, "Previous", theme) else widgets.drawButton(logical, lay.node_prev, "Previous", .tonal, .disabled, theme); + if (page + 1 < pages) widgets.drawFilledButton(logical, lay.node_next, "Next", theme) else if (count < w.zb_node_channels.len) widgets.drawFilledButton(logical, lay.node_next, "Add channel", theme) else widgets.drawButton(logical, lay.node_next, "Next", .filled, .disabled, theme); + } + } + logical.setClip(null); + return lay; + } lay.card_n = devCount(w); const cols = gridCols(lay.view.w); lay.grid_cols = @intCast(cols); @@ -452,14 +589,17 @@ pub fn paint(logical: *fb.LogicalFb, theme: tokens.Theme, ctx: Ctx, enter_t: f32 if (!w.zigbee) { font.drawTextRole(logical, lay.view.x, lay.view.y, "Enable Zigbee radio in Settings > Wireless.", theme.on_surface_variant, .body_m); + logical.setClip(null); return lay; } if (!w.zb_joined) { font.drawTextRole(logical, lay.view.x, lay.view.y, "Join the Zigbee hub, then Permit join to pair devices.", theme.on_surface_variant, .body_m); + logical.setClip(null); return lay; } if (lay.card_n == 0) { font.drawTextRole(logical, lay.view.x, lay.view.y, "No devices - tap Permit join and pair a device.", theme.on_surface_variant, .body_m); + logical.setClip(null); return lay; } @@ -525,6 +665,7 @@ fn cardHitAt(c: CardLayout, dev: u8, x: i32, y: i32) Hit { if (c.exposes_link.contains(x, y)) return .{ .kind = .exposes, .dev = dev }; if (c.identify.contains(x, y)) return .{ .kind = .identify, .dev = dev }; if (c.remove.contains(x, y)) return .{ .kind = .remove, .dev = dev }; + if (c.configure.contains(x, y)) return .{ .kind = .configure, .dev = dev }; var si: u8 = 0; while (si < c.slot_n) : (si += 1) { const slot = c.slots[si]; @@ -603,6 +744,29 @@ pub fn hit(layout: Layout, x: i32, y: i32) Hit { if (layout.permit.contains(x, y)) return .{ .kind = .permit_join }; if (layout.refresh.contains(x, y)) return .{ .kind = .refresh }; if (!layout.join.isEmpty() and layout.join.contains(x, y)) return .{ .kind = .join_hub }; + if (layout.add.contains(x, y)) return .{ .kind = .add_device }; + if (layout.node_name.contains(x, y)) return .{ .kind = .node_name }; + if (layout.node_led_gpio.contains(x, y)) return .{ .kind = .node_led_gpio }; + if (layout.node_led_polarity.contains(x, y)) return .{ .kind = .node_led_polarity }; + if (layout.node_apply.contains(x, y)) return .{ .kind = .node_apply }; + if (layout.node_close.contains(x, y)) return .{ .kind = .node_close }; + if (layout.node_prev.contains(x, y)) return .{ .kind = .node_prev }; + if (layout.node_next.contains(x, y)) return .{ .kind = .node_next }; + for (0..4) |i| { + const channel: u8 = layout.node_page_start + @as(u8, @intCast(i)); + if (layout.node_type[i].contains(x, y)) return .{ .kind = .node_type, .dev = channel }; + if (layout.node_channel_name[i].contains(x, y)) return .{ .kind = .node_channel_name, .dev = channel }; + if (layout.node_gpio[i].contains(x, y)) return .{ .kind = .node_gpio, .dev = channel }; + if (layout.node_poll[i].contains(x, y)) return .{ .kind = .node_poll, .dev = channel }; + if (layout.node_favorite[i].contains(x, y)) return .{ .kind = .node_favorite, .dev = channel }; + if (layout.node_start[i].contains(x, y)) return .{ .kind = .node_start, .dev = channel }; + if (layout.node_alarm_enabled[i].contains(x, y)) return .{ .kind = .node_alarm_enabled, .dev = channel }; + if (layout.node_alarm_high[i].contains(x, y)) return .{ .kind = .node_alarm_high, .dev = channel }; + if (layout.node_alarm_low[i].contains(x, y)) return .{ .kind = .node_alarm_low, .dev = channel }; + if (layout.node_alarm_hysteresis[i].contains(x, y)) return .{ .kind = .node_alarm_hysteresis, .dev = channel }; + if (layout.node_pullup[i].contains(x, y)) return .{ .kind = .node_pullup, .dev = channel }; + if (layout.node_polarity[i].contains(x, y)) return .{ .kind = .node_polarity, .dev = channel }; + } var i: u8 = 0; while (i < layout.card_n) : (i += 1) { const h = cardHitAt(layout.cards[i], i, x, y); @@ -621,7 +785,7 @@ test "zigbee panel chrome meets touch_min" { wl.joinZigbee(); const lay = paint(&logical, tokens.Theme.industrialTealDark(), .{ .wireless = &wl }, 1); try std.testing.expect(lay.header.exit.w >= tokens.Logical.touch_min); - try std.testing.expect(lay.permit.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.add.h >= tokens.Logical.touch_min); try std.testing.expect(lay.card_n >= 3); try std.testing.expectEqual(@as(u8, 3), lay.grid_cols); if (lay.card_n > 0) { @@ -630,3 +794,25 @@ test "zigbee panel chrome meets touch_min" { try std.testing.expect(lay.cards[0].exposes_link.h >= tokens.Logical.touch_min); } } + +test "DS18B20 page fits and poll hit selects the correct channel" { + var logical = try fb.LogicalFb.alloc(std.testing.allocator); + defer logical.deinit(std.testing.allocator); + var w: settings_prefs.WirelessPrefs = .{}; + w.zb_node_open = true; + w.zb_node_ready = true; + w.zb_node_channel_count = 12; + w.zb_node_page = 12; + w.zb_node_channels[11].typ = 4; + w.zb_node_channels[11].gpio = 22; + w.zb_node_channels[11].temperature_state = 1; + w.zb_node_channels[11].temperature_centi_c = -125; + const layout = paint(&logical, tokens.Theme.industrialTealDark(), .{ .wireless = &w }, 1.0); + try std.testing.expect(layout.node_apply.y + layout.node_apply.h <= layout.view.y + layout.view.h); + const r = layout.node_poll[0]; + try std.testing.expect(r.h >= 48); + const selected = hit(layout, r.x + 10, r.y + 10); + try std.testing.expectEqual(Kind.node_poll, selected.kind); + try std.testing.expectEqual(@as(u8, 11), selected.dev); + try std.testing.expect(layout.node_polarity[0].isEmpty()); +} diff --git a/src/modulus/ui_engine/m_panel_zigbee_add.zig b/src/modulus/ui_engine/m_panel_zigbee_add.zig new file mode 100644 index 0000000..188c6dc --- /dev/null +++ b/src/modulus/ui_engine/m_panel_zigbee_add.zig @@ -0,0 +1,112 @@ +//! Guided Zigbee device onboarding for the M panel. + +const std = @import("std"); +const geom = @import("geom.zig"); +const tokens = @import("tokens.zig"); +const fb = @import("fb.zig"); +const font = @import("font.zig"); +const widgets = @import("widgets.zig"); +const tool_chrome = @import("m_panel_tool.zig"); + +pub const Stage = enum { choose, connect, found }; +pub const DeviceType = enum { node, other }; + +pub const State = struct { + stage: Stage = .choose, + device_type: DeviceType = .node, + devices_before: u8 = 0, + pairing_started: bool = false, +}; + +pub const Kind = enum { none, back, exit, node, other, retry, finish }; +pub const Hit = struct { kind: Kind = .none }; +pub const Layout = struct { + header: tool_chrome.Header = .{}, + node: geom.Rect = .{}, + other: geom.Rect = .{}, + retry: geom.Rect = .{}, + finish: geom.Rect = .{}, +}; + +fn centeredText(logical: *fb.LogicalFb, r: geom.Rect, text: []const u8, role: tokens.TypeRole, c: @import("color.zig").Rgb565) void { + const tw = font.textWidthStr(text, role); + const th = font.faceHeight(font.faceForRole(role)); + font.drawTextRole(logical, r.x + @divTrunc(r.w - tw, 2), r.y + @divTrunc(r.h - th, 2), text, c, role); +} + +fn card(logical: *fb.LogicalFb, theme: tokens.Theme, r: geom.Rect, accent: @import("color.zig").Rgb565, title: []const u8, line1: []const u8, line2: []const u8, button: geom.Rect, button_text: []const u8) void { + widgets.fillRoundRect(logical, r, tokens.Shape.lg, theme.elev(2)); + widgets.strokeRoundRect(logical, r, tokens.Shape.lg, accent, 3); + centeredText(logical, .{ .x = r.x + 20, .y = r.y + 32, .w = r.w - 40, .h = 52 }, title, .title_l, theme.on_surface); + centeredText(logical, .{ .x = r.x + 24, .y = r.y + 112, .w = r.w - 48, .h = 34 }, line1, .body_m, theme.on_surface_variant); + centeredText(logical, .{ .x = r.x + 24, .y = r.y + 150, .w = r.w - 48, .h = 34 }, line2, .body_m, theme.on_surface_variant); + widgets.drawFilledButton(logical, button, button_text, theme); +} + +pub fn paint(logical: *fb.LogicalFb, theme: tokens.Theme, state: State, hub_ready: bool, joined: bool, device_count: u8, enter_t: f32) Layout { + widgets.fillScrim(logical, theme); + _ = enter_t; + const shell: geom.Rect = .{ .x = 20, .y = 20, .w = tokens.Logical.width - 40, .h = tokens.Logical.height - 40 }; + widgets.fillRoundRect(logical, shell, tokens.Shape.dialog, theme.elev(3)); + widgets.strokeRoundRect(logical, shell, tokens.Shape.dialog, theme.outline_variant, 1); + var lay: Layout = .{}; + lay.header = tool_chrome.headerChrome(shell); + tool_chrome.paintBackToPanel(logical, theme, lay.header.back); + tool_chrome.paintTitle(logical, theme, lay.header.back.x + lay.header.back.w + tokens.Space.sm, lay.header.back.y, "Add Zigbee device"); + tool_chrome.paintExit(logical, theme, lay.header.exit); + + const x = shell.x + 32; + const y = lay.header.back.y + lay.header.back.h + 28; + const width = shell.w - 64; + if (state.stage == .choose) { + centeredText(logical, .{ .x = x, .y = y, .w = width, .h = 54 }, "What would you like to add?", .headline_s, theme.on_surface); + centeredText(logical, .{ .x = x, .y = y + 54, .w = width, .h = 34 }, "Tab5 guides you through the next steps.", .body_m, theme.on_surface_variant); + const gap: i32 = 24; + const cw = @divTrunc(width - gap, 2); + const cy = y + 108; + const ch: i32 = 430; + const left: geom.Rect = .{ .x = x, .y = cy, .w = cw, .h = ch }; + const right: geom.Rect = .{ .x = x + cw + gap, .y = cy, .w = cw, .h = ch }; + lay.node = .{ .x = left.x + 24, .y = left.y + left.h - 92, .w = left.w - 48, .h = 68 }; + lay.other = .{ .x = right.x + 24, .y = right.y + right.h - 92, .w = right.w - 48, .h = 68 }; + card(logical, theme, left, theme.primary, "Modulus Node", "Just switch it on.", "Tab5 finds and connects it automatically.", lay.node, "Add Node"); + card(logical, theme, right, theme.tertiary, "Other Zigbee device", "Smart plugs, lights and sensors.", "Tab5 helps you start pairing.", lay.other, "Add Zigbee device"); + } else if (state.stage == .connect) { + const node = state.device_type == .node; + centeredText(logical, .{ .x = x, .y = y + 12, .w = width, .h = 58 }, if (node) "Switch on the Modulus Node" else "Put the device in pairing mode", .headline_s, theme.on_surface); + centeredText(logical, .{ .x = x + 80, .y = y + 82, .w = width - 160, .h = 40 }, if (node) "No button is needed. Tab5 handles the connection automatically." else "Use the pairing instructions supplied with your Zigbee device.", .body_m, theme.on_surface_variant); + const status: geom.Rect = .{ .x = x + 100, .y = y + 160, .w = width - 200, .h = 230 }; + widgets.fillRoundRect(logical, status, tokens.Shape.lg, theme.elev(2)); + centeredText(logical, .{ .x = status.x + 20, .y = status.y + 28, .w = status.w - 40, .h = 48 }, if (!hub_ready) "Connecting to NanoH2 hub..." else if (!joined) "Creating Zigbee network..." else "Searching for Zigbee devices...", .title_l, theme.on_surface); + centeredText(logical, .{ .x = status.x + 20, .y = status.y + 94, .w = status.w - 40, .h = 40 }, "Hub, network and device list are checked automatically.", .body_m, theme.on_surface_variant); + var count_buf: [48]u8 = undefined; + const count = std.fmt.bufPrint(&count_buf, "Devices found: {d}", .{device_count -| state.devices_before}) catch "Searching..."; + centeredText(logical, .{ .x = status.x + 20, .y = status.y + 150, .w = status.w - 40, .h = 42 }, count, .title_m, theme.primary); + lay.retry = .{ .x = x + @divTrunc(width - 420, 2), .y = y + 430, .w = 420, .h = 72 }; + widgets.drawTonalButton(logical, lay.retry, "Search again", theme); + } else { + centeredText(logical, .{ .x = x, .y = y + 70, .w = width, .h = 64 }, "Device found", .headline_s, theme.on_surface); + centeredText(logical, .{ .x = x, .y = y + 154, .w = width, .h = 42 }, "The Zigbee device is connected and ready to set up.", .title_m, theme.on_surface_variant); + lay.finish = .{ .x = x + @divTrunc(width - 520, 2), .y = y + 280, .w = 520, .h = 78 }; + widgets.drawFilledButton(logical, lay.finish, "Set up device", theme); + } + return lay; +} + +pub fn hit(layout: Layout, x: i32, y: i32) Hit { + if (tool_chrome.hitBack(layout.header, x, y)) return .{ .kind = .back }; + if (tool_chrome.hitExit(layout.header, x, y)) return .{ .kind = .exit }; + if (layout.node.contains(x, y)) return .{ .kind = .node }; + if (layout.other.contains(x, y)) return .{ .kind = .other }; + if (layout.retry.contains(x, y)) return .{ .kind = .retry }; + if (layout.finish.contains(x, y)) return .{ .kind = .finish }; + return .{}; +} + +test "wizard actions are glove friendly" { + var logical = try fb.LogicalFb.alloc(std.testing.allocator); + defer logical.deinit(std.testing.allocator); + const lay = paint(&logical, tokens.Theme.industrialTealDark(), .{}, false, false, 0, 1); + try std.testing.expect(lay.node.h >= tokens.Logical.touch_min); + try std.testing.expect(lay.other.h >= tokens.Logical.touch_min); +} diff --git a/src/modulus/ui_engine/root.zig b/src/modulus/ui_engine/root.zig index 34a778b..afda258 100644 --- a/src/modulus/ui_engine/root.zig +++ b/src/modulus/ui_engine/root.zig @@ -28,6 +28,7 @@ pub const settings_extra_modals = @import("settings_extra_modals.zig"); pub const settings_pin_modal = @import("settings_pin_modal.zig"); pub const settings_mach_string_modal = @import("settings_mach_string_modal.zig"); pub const settings_menu = @import("settings_menu.zig"); +pub const m_panel_update_hub = @import("m_panel_update_hub.zig"); pub const widgets_expressive = @import("widgets_expressive.zig"); pub const hct = @import("hct.zig"); pub const palette = @import("palette.zig"); @@ -46,6 +47,9 @@ pub const m_panel_usb = @import("m_panel_usb.zig"); pub const m_panel_probe = @import("m_panel_probe.zig"); pub const m_panel_sd = @import("m_panel_sd.zig"); pub const m_panel_zigbee = @import("m_panel_zigbee.zig"); +pub const m_panel_c6_ota = @import("m_panel_c6_ota.zig"); +pub const m_panel_s3_ota = @import("m_panel_s3_ota.zig"); +pub const m_panel_nano_ota = @import("m_panel_nano_ota.zig"); pub const m_panel_tool = @import("m_panel_tool.zig"); pub const sd_volume = @import("sd_volume.zig"); pub const usb_volume = @import("usb_volume.zig"); @@ -116,6 +120,7 @@ test { _ = settings_pin_modal; _ = settings_mach_string_modal; _ = settings_menu; + _ = m_panel_update_hub; _ = widgets_expressive; _ = hct; _ = palette; @@ -135,10 +140,10 @@ test { _ = m_panel_sd; _ = zb_exposes; _ = m_panel_zigbee; + _ = @import("m_panel_zigbee_add.zig"); + _ = @import("m_panel_controls.zig"); _ = sd_volume; _ = zb_purpose; _ = ui_lint; _ = host_win32; } - - diff --git a/src/modulus/ui_engine/settings_other_tabs.zig b/src/modulus/ui_engine/settings_other_tabs.zig index bdc1a47..70cb848 100644 --- a/src/modulus/ui_engine/settings_other_tabs.zig +++ b/src/modulus/ui_engine/settings_other_tabs.zig @@ -7,7 +7,7 @@ const tokens = @import("tokens.zig"); const fb = @import("fb.zig"); const form = @import("settings_form.zig"); const prefs_mod = @import("settings_prefs.zig"); -const modulus = @import("../core/modulus.zig"); +const build_options = @import("build_options"); pub const max_slots = 72; @@ -95,6 +95,15 @@ pub const Hit = enum(u16) { wl_zb_dev1, wl_zb_dev, wl_zb_identify, + wl_zb_rename, + wl_zb_node, + wl_zb_node_name, + wl_zb_node_type, + wl_zb_node_gpio, + wl_zb_node_polarity, + wl_zb_node_pullup, + wl_zb_node_apply, + wl_zb_node_close, wl_zb_remove, wl_zb_refresh, wl_zb_sensors, @@ -410,7 +419,10 @@ pub fn applyHit(prefs: *prefs_mod.Prefs, hit: Hit, seg: ?usize, x: i32, y: i32, prefs.wireless.zb_dev_on[idx] = !prefs.wireless.zb_dev_on[idx]; } }, - .wl_zb_identify, .wl_zb_remove, .wl_zb_refresh, .wl_zb_sensors => {}, + .wl_zb_identify, .wl_zb_rename, .wl_zb_node, .wl_zb_node_name, + .wl_zb_node_type, .wl_zb_node_gpio, .wl_zb_node_polarity, .wl_zb_node_pullup, + .wl_zb_node_apply, .wl_zb_node_close, + .wl_zb_remove, .wl_zb_refresh, .wl_zb_sensors => {}, .wl_th_dev0 => if (prefs.wireless.th_dev_n > 0) { prefs.wireless.th_dev_on[0] = !prefs.wireless.th_dev_on[0]; }, @@ -1000,7 +1012,7 @@ fn paintWireless(logical: *fb.LogicalFb, theme: tokens.Theme, w: prefs_mod.Wirel if (w.zigbee) { form.paintSection(logical, theme, &cur, scroll, "Network control"); lay.push(.wl_zb_join, if (w.zb_joined) form.paintActionAccent(logical, theme, &cur, scroll, "Join network", "Joined") else if (w.zb_join_pending) form.paintAction(logical, theme, &cur, scroll, "Join network", "Joining...") else form.paintAction(logical, theme, &cur, scroll, "Join network", ""), .action); - lay.push(.wl_zb_leave, form.paintAction(logical, theme, &cur, scroll, "Leave network", ""), .action); + lay.push(.wl_zb_leave, form.paintAction(logical, theme, &cur, scroll, "Reset Zigbee network", ""), .action); if (!w.zb_joined) { form.paintNote(logical, theme, &cur, scroll, "Join the network first, then pair devices."); } @@ -1028,6 +1040,8 @@ fn paintWireless(logical: *fb.LogicalFb, theme: tokens.Theme, w: prefs_mod.Wirel while (di < show_n) : (di += 1) { const dev_on = if (di < w.zb_dev_on.len) w.zb_dev_on[di] else false; lay.pushAux(.wl_zb_dev, form.paintToggle(logical, theme, &cur, scroll, w.zbDevLabel(di), dev_on), .toggle, @intCast(di)); + lay.pushAux(.wl_zb_rename, form.paintAction(logical, theme, &cur, scroll, "Name", w.zbDevLabel(di)), .action, @intCast(di)); + lay.pushAux(.wl_zb_node, form.paintAction(logical, theme, &cur, scroll, "Configure Node", ""), .action, @intCast(di)); lay.pushAux(.wl_zb_identify, form.paintAction(logical, theme, &cur, scroll, "Identify", "blink 5s"), .action, @intCast(di)); lay.pushAux(.wl_zb_remove, form.paintDestructiveAction(logical, theme, &cur, scroll, "Remove device", ""), .action, @intCast(di)); if (form.isAdvanced()) { @@ -1035,6 +1049,32 @@ fn paintWireless(logical: *fb.LogicalFb, theme: tokens.Theme, w: prefs_mod.Wirel } } } + if (w.zb_node_open) { + form.paintSection(logical, theme, &cur, scroll, "Modulus Node configuration"); + if (!w.zb_node_ready) { + form.paintDetailStatus(logical, theme, &cur, scroll, "Node", "Waiting for response...", .warn); + lay.push(.wl_zb_node_close, form.paintAction(logical, theme, &cur, scroll, "Close", ""), .action); + } else { + lay.push(.wl_zb_node_name, form.paintAction(logical, theme, &cur, scroll, "Device name", std.mem.sliceTo(&w.zb_node_name, 0)), .action); + const type_names = [_][]const u8{ "Disabled", "Switch", "Digital output", "Digital input", "DS18B20" }; + const channel_n = @min(@as(usize, w.zb_node_channel_count), @as(usize, 4)); + var ci: usize = 0; + while (ci < channel_n) : (ci += 1) { + const ch = &w.zb_node_channels[ci]; + var title: [32]u8 = undefined; + var gpio_text: [12]u8 = undefined; + const channel_title = std.fmt.bufPrint(&title, "Channel {d} function", .{ci + 1}) catch "Channel"; + lay.pushAux(.wl_zb_node_type, form.paintAction(logical, theme, &cur, scroll, channel_title, type_names[@min(@as(usize, ch.typ), type_names.len - 1)]), .action, @intCast(ci)); + const gpio = if (ch.gpio < 0) "Not assigned" else std.fmt.bufPrint(&gpio_text, "GPIO {d}", .{ch.gpio}) catch "?"; + lay.pushAux(.wl_zb_node_gpio, form.paintAction(logical, theme, &cur, scroll, "GPIO", gpio), .action, @intCast(ci)); + lay.pushAux(.wl_zb_node_polarity, form.paintToggle(logical, theme, &cur, scroll, "Active low", (ch.flags & 1) != 0), .toggle, @intCast(ci)); + if (ch.typ == 3) lay.pushAux(.wl_zb_node_pullup, form.paintToggle(logical, theme, &cur, scroll, "Internal pull-up", (ch.flags & 2) != 0), .toggle, @intCast(ci)); + } + form.paintNote(logical, theme, &cur, scroll, "Changes are saved on the node. Apply restarts it with the new endpoints."); + lay.push(.wl_zb_node_apply, form.paintActionAccent(logical, theme, &cur, scroll, "Apply and restart node", ""), .action); + lay.push(.wl_zb_node_close, form.paintAction(logical, theme, &cur, scroll, "Close", ""), .action); + } + } lay.push(.wl_zb_refresh, form.paintAction(logical, theme, &cur, scroll, "Refresh device list", "Ask hub"), .action); if (w.zb_adv_exp) { form.paintSection(logical, theme, &cur, scroll, "Radio"); @@ -1581,8 +1621,8 @@ fn paintSystem(logical: *fb.LogicalFb, theme: tokens.Theme, prefs: *const prefs_ form.paintSection(logical, theme, &cur, scroll, "Device"); var ver_card_buf: [12]u8 = undefined; var fw_buf: [24]u8 = undefined; - const card_ver = std.fmt.bufPrint(&ver_card_buf, "v{s}", .{modulus.version}) catch "v3.1.0"; - const fw_line = std.fmt.bufPrint(&fw_buf, "Modulus v{s}", .{modulus.version}) catch "Modulus v3.1.0"; + const card_ver = std.fmt.bufPrint(&ver_card_buf, "v{s}", .{build_options.version}) catch "v3.1.0"; + const fw_line = std.fmt.bufPrint(&fw_buf, "Modulus v{s}", .{build_options.version}) catch "Modulus v3.1.0"; form.paintDeviceCard(logical, theme, &cur, scroll, card_ver); form.paintDetail(logical, theme, &cur, scroll, "Firmware", fw_line); form.paintDetail(logical, theme, &cur, scroll, "ESP-IDF", if (comptime @import("builtin").os.tag == .freestanding) "ESP-IDF 6.0" else "n/a (host)"); diff --git a/src/modulus/ui_engine/settings_prefs.zig b/src/modulus/ui_engine/settings_prefs.zig index fc9fd03..20ef7ce 100644 --- a/src/modulus/ui_engine/settings_prefs.zig +++ b/src/modulus/ui_engine/settings_prefs.zig @@ -770,6 +770,29 @@ pub const ZbDevSnap = struct { }; pub const WirelessPrefs = struct { + pub const ZbNodeChannel = struct { + typ: u8 = 0, + gpio: i8 = -1, + flags: u8 = 0, + name: [24]u8 = .{0} ** 24, + valid: bool = false, + poll_interval_s: u16 = 15, + temperature_centi_c: i16 = 0, + temperature_state: u8 = 0, + digital_value: bool = false, + digital_state: u8 = 0, + /// 0=automatic, otherwise a UI icon id chosen by the operator. + icon: u8 = 0, + /// 0=not shown, 1..3=favorite position in the M-Panel. + favorite: u8 = 0, + /// 0=off, 1=on, 2=restore last state. + start_mode: u8 = 0, + temp_alarm_enabled: bool = false, + temp_alarm_high_centi_c: i16 = 5000, + temp_alarm_low_centi_c: i16 = -5500, + temp_alarm_hysteresis_centi_c: u16 = 200, + temp_alarm_active: bool = false, + }; /// 0=hub 1=wifi 2=bt 3=espnow 4=zigbee 5=thread 6=wifi_saved 7=wifi_adv page: u8 = 0, wifi: bool = false, @@ -867,6 +890,17 @@ pub const WirelessPrefs = struct { /// Device: live NanoH2 status / network lines from wireless shim. zb_status: [48]u8 = .{0} ** 48, zb_network: [40]u8 = .{0} ** 40, + zb_node_open: bool = false, + zb_node_ready: bool = false, + zb_node_idx: u8 = 0, + zb_node_page: u8 = 0, + zb_node_short: u16 = 0, + zb_node_name: [32]u8 = .{0} ** 32, + zb_node_led_gpio: i8 = 15, + zb_node_led_flags: u8 = 1, + zb_node_generation: u32 = 0, + zb_node_channel_count: u8 = 0, + zb_node_channels: [12]ZbNodeChannel = [_]ZbNodeChannel{.{}} ** 12, /// Device: live Wi-Fi / BLE status from C6 shim. wifi_status: [48]u8 = .{0} ** 48, bt_status: [48]u8 = .{0} ** 48, @@ -2563,4 +2597,4 @@ test "applyAllOpts preserve_machine_live keeps overrides and wcs" { prefs.applyAllOpts(&cnc, &theme, .{}); try std.testing.expectEqual(@as(u8, 100), cnc.feed_pct); try std.testing.expectEqual(@as(u8, 0), cnc.wcs_i); -} \ No newline at end of file +} diff --git a/src/modulus/ui_engine/ui_cmds.zig b/src/modulus/ui_engine/ui_cmds.zig index 9a3cb11..8b39701 100644 --- a/src/modulus/ui_engine/ui_cmds.zig +++ b/src/modulus/ui_engine/ui_cmds.zig @@ -19,6 +19,14 @@ pub const WirelessUiCmd = union(enum) { zb_leave, zb_toggle: u8, zb_identify: u8, + zb_rename: struct { idx: u8, name: []const u8 }, + zb_node_open: u8, + zb_node_name: []const u8, + zb_node_channel: struct { index: u8, typ: u8, gpio: i8, flags: u8, name: []const u8 }, + zb_node_poll: struct { index: u8, seconds: u16 }, + zb_node_output: struct { index: u8, on: bool }, + zb_node_status_led: struct { gpio: i8, flags: u8 }, + zb_node_apply, zb_remove: u8, zb_sensors: u8, zb_cover: struct { idx: u8, op: u8 },