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/README.de.md b/README.de.md
new file mode 100644
index 0000000..bb08362
--- /dev/null
+++ b/README.de.md
@@ -0,0 +1,152 @@
+
+
+
+
+
+ English · Deutsch
+
+
+# Modulus Firmware – C6-OTA für Tab5
+
+**Version:** 3.1.0
+**Autor:** D. McLean / BufferRoot
+**Feature-Branch:** `feature/tab5-c6-ota`
+**Plattform:** M5Stack Tab5 (ESP32-P4 + ESP32-C6)
+**Lizenz:** [MIT](LICENSE)
+
+Modulus ist eine CNC-Pendant-Firmware für das M5Stack Tab5. Der ESP32-P4 führt
+Oberfläche und Steuerungslogik aus; der ESP32-C6 stellt WLAN, BLE und ESP-NOW
+über ESP-Hosted/SDIO bereit. Das Pendant ersetzt nicht die Maschinensteuerung
+und nicht den hardwareseitigen Not-Aus.
+
+## Warum es diesen Feature-Branch gibt
+
+Für ein C6-Update war bisher eine separate USB-/Bootloader-Verbindung zum C6
+nötig. Dieser Branch ergänzt im P4-Menü die Seite **C6 Update**. Damit lässt sich
+eine kompatible C6-Anwendungsdatei von der SD-Karte auswählen und über die
+bereits vorhandene ESP-Hosted-SDIO-Verbindung übertragen.
+
+Der vorgesehene Ablauf lautet deshalb **zuerst P4, danach C6**: Zunächst wird
+die P4-Firmware mit dem neuen Updater installiert. Anschließend startet Modulus
+normal und aktualisiert den C6 über **M Panel → C6 Update**. Das Flashen beginnt
+niemals automatisch. Image-Prüfung, ausdrückliche Bestätigung, Fortschrittsbalken
+und Neustart-Schaltfläche sichern den Ablauf ab.
+
+## Architektur in Kurzform
+
+| Ziel | Aufgabe |
+|------|---------|
+| **ESP32-P4** | Tab5-Oberfläche, MPG und Steuerung; enthält den C6-Updater |
+| **ESP32-C6** | WLAN, BLE und ESP-NOW über ESP-Hosted/SDIO |
+| **ESP32-S3** | ESP-NOW-Bridge im Schaltschrank zur CNC-UART |
+| **NanoH2** | Optionaler Zigbee-Koordinator |
+
+## Voraussetzungen
+
+- ESP-IDF 6.0.1 und esptool
+- USB-Verbindung zum Tab5-P4 (Beispiel: `COM5`)
+- FAT32-formatierte SD-Karte für das C6-Update
+- Passende Builds desselben Firmwarestands für P4 und C6
+
+Die COM-Portnummer kann auf deinem Rechner abweichen.
+
+## Empfohlene Flash-Reihenfolge
+
+### 1. ESP32-P4 zuerst flashen
+
+Dieser Feature-Branch muss auf dem P4 laufen, bevor der C6 per SDIO aktualisiert
+werden kann. Baue ihn mit:
+
+```powershell
+.\scripts\build_tab5.ps1
+```
+
+> [!IMPORTANT]
+> Für den Tab5-P4 immer das Build-Skript verwenden. Ein direktes
+> `idf.py build` wendet die nötigen ESP-IDF-6-/ESP-Hosted-Patches nicht an.
+
+Flashe danach den vollständigen P4-Satz aus dem erzeugten Paket:
+
+```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
+```
+
+Tab5 vollständig aus- und wieder einschalten. Warte auf das normale Dashboard
+und prüfe, ob **M Panel → C6 Update** vorhanden ist.
+
+### 2. C6-Anwendungsdatei erzeugen
+
+Baue die C6-Firmware mit:
+
+```powershell
+.\scripts\build_tab5_c6_modulus.ps1
+```
+
+Die für OTA benötigte Datei ist:
+
+```text
+firmware/tab5-c6/build/network_adapter.bin
+```
+
+Sie darf umbenannt werden, zum Beispiel in
+`modulus-c6-ota-2.12.12.bin`. Entscheidend ist ihr Inhalt, nicht der Dateiname.
+
+### 3. ESP32-C6 über das Tab5 aktualisieren
+
+1. SD-Karte als FAT32 formatieren.
+2. **Nur** `network_adapter.bin` beziehungsweise die umbenannte OTA-Datei in
+ das Stammverzeichnis der SD-Karte kopieren.
+3. SD-Karte in das laufende Tab5 einsetzen.
+4. **M Panel → C6 Update** öffnen.
+5. **Refresh SD** drücken, Datei auswählen und **Check image** drücken.
+6. Prüfen, dass ein ESP32-C6-Application-Image erkannt und akzeptiert wird.
+7. **Flash C6** drücken und die Sicherheitsabfrage bestätigen.
+8. Während des Fortschrittsbalkens weder Strom noch SD-Karte entfernen.
+9. Nach erfolgreicher Aktivierung **Restart Modulus** drücken.
+10. Prüfen, ob Dashboard und C6-/ESP-NOW-Verbindung wieder verfügbar sind.
+
+> [!WARNING]
+> Kein Full-/Merged-Image, Release-ZIP, `bootloader.bin`,
+> `partition-table.bin` oder `ota_data_initial.bin` im OTA-Menü auswählen.
+> Diese Dateien sind für feste Flash-Adressen bestimmt und keine gültigen
+> OTA-Anwendungsimages.
+
+## C6-Wiederherstellung per USB
+
+Dieser Weg ist nur nötig, wenn der C6 nicht mehr weit genug startet, um
+ESP-Hosted/SDIO-OTA anzubieten. Verbinde den C6-Bootloader per USB und flashe
+den vollständigen Satz an seine festen Adressen:
+
+```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
+```
+
+Falls kein Port erscheint, beim Anschließen des C6-USB die **BOOT**-Taste
+gedrückt halten. Danach das Tab5 vollständig neu starten.
+
+## Weitere Firmwareziele
+
+- **S3-Bridge:** verbindet ESP-NOW mit der UART der CNC-Steuerung.
+- **NanoH2:** optionaler Zigbee-Hub; Zigbee-Only-Firmware niemals auf den C6
+ des Tab5 flashen.
+
+Die vollständige Architektur-, Build- und Entwicklerdokumentation befindet
+sich in der [englischen README](README.md).
+
+## Sicherheit
+
+Der Pendant-E-Stop an GPIO16 ist nur eine zusätzliche Softwarefunktion. Er ist
+kein sicherheitsgerichteter Abschaltkreis und kann bei ausgefallener
+Funkverbindung die Maschine nicht stoppen. Der echte Maschinen-Not-Aus bleibt
+immer die primäre Sicherheitseinrichtung.
diff --git a/README.md b/README.md
index b72d360..2bcd504 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,10 @@
+
+ English · Deutsch
+
+
# Modulus Firmware
**Version:** 3.1.0
@@ -13,6 +17,20 @@
**One Device, One Software. Real control for any machine — no lag, no brand lock-in, no compromise.**
+## Why this feature branch exists
+
+The ESP32-C6 is the Tab5's wireless coprocessor, but updating it traditionally
+requires a separate USB/bootloader connection. This branch adds a guarded
+**C6 Update** page to the P4 user interface so a compatible C6 application can
+be selected from the Tab5 SD card and transferred through the existing
+ESP-Hosted SDIO link.
+
+The intended maintenance sequence is therefore **P4 first, C6 second**: install
+the P4 firmware that contains the updater, boot Modulus normally, then use
+**M Panel → C6 Update** to update the C6. An image check, an explicit
+confirmation step, a progress display, and a post-update restart button keep
+the operation deliberate; C6 flashing never starts automatically.
+
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.
@@ -165,40 +183,77 @@ Download **v3.1.0** assets (bootloader + partition table + app per target):
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.
+**Recommended order for this feature branch:** P4 → C6 through the on-device
+updater → S3 bridge → NanoH2 (optional). The separate C6 USB procedure is a
+recovery path, not the normal update method. Keep the machine E-Stop in reach.
COM ports on your PC may differ — change `-p COMx` to match Device Manager.
-### 1. Tab5 C6 (wireless slave)
+### 1. Tab5 P4 (main pendant and C6 updater)
-Unzip `modulus-tab5-c6-v3.1.0.zip`, then:
+The P4 must contain this feature branch before it can update the C6. Build this
+branch with `scripts/build_tab5.ps1`, or use a P4 package produced from this
+branch. Then flash its complete P4 set:
```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 `
+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 `
- 0xd000 ota_data_initial.bin `
- 0x10000 network_adapter.bin
+ 0x10000 modulus_tab5.bin
+```
+
+Power-cycle the Tab5 and wait for the normal Modulus dashboard. Open **M Panel →
+C6 Update** and confirm that the updater page is present before continuing.
+
+### 2. Tab5 C6 through SDIO OTA (normal method)
+
+Build the C6 application with `scripts/build_tab5_c6_modulus.ps1`. The file to
+copy is:
+
+```text
+firmware/tab5-c6/build/network_adapter.bin
```
-If the port never appears: hold **BOOT** on the C6 while plugging USB, then run the command.
+It may be renamed to a descriptive name such as
+`modulus-c6-ota-2.12.12.bin`; renaming does not change its contents.
+
+1. Format an SD card as FAT32.
+2. Copy **only the C6 application image** (`network_adapter.bin`, or its renamed
+ equivalent) to the SD-card root.
+3. Insert the card into the running Tab5.
+4. Open **M Panel → C6 Update**.
+5. Select **Refresh SD**, choose the file, and select **Check image**.
+6. Verify that the screen identifies an ESP32-C6 application and accepts its
+ compatibility check.
+7. Select **Flash C6** and confirm the warning. Do not remove power or the SD
+ card while the progress bar is moving.
+8. After activation succeeds, select **Restart Modulus**. Confirm that the
+ dashboard returns and the C6/ESP-NOW connection is available.
-### 2. Tab5 P4 (main pendant)
+> [!WARNING]
+> Do **not** put a merged/full-flash image, release ZIP, `bootloader.bin`,
+> `partition-table.bin`, or `ota_data_initial.bin` into the OTA updater. Those
+> files belong to fixed flash offsets and are not valid OTA application images.
-Unzip `modulus-tab5-p4-v3.1.0.zip`, then:
+### C6 USB recovery (only if SDIO OTA cannot start)
+
+Use this only if the C6 no longer boots far enough for ESP-Hosted/SDIO OTA.
+Unzip the complete C6 flash package, connect the C6 USB bootloader, then run:
```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 `
+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 `
- 0x10000 modulus_tab5.bin
+ 0xd000 ota_data_initial.bin `
+ 0x10000 network_adapter.bin
```
-Power-cycle the Tab5. Cold boot should show wireless / SDIO ready (not `0x107`).
+If the port never appears, hold **BOOT** on the C6 while connecting USB. After
+recovery, power-cycle the Tab5.
### 3. ESP32-S3 bridge (cabinet)
@@ -279,6 +334,30 @@ zig build tab5-lib # freestanding Zig library
Re-pack local builds into release zips: `.\scripts\package_flash_images.ps1`.
+### Updating the Tab5 C6 from Modulus
+
+The Tab5 **M Panel → C6 Update** page updates the ESP32-C6 over the internal
+ESP-Hosted SDIO connection. Copy an ESP32-C6 **application image** (`.bin`) to
+the root of a FAT32-formatted SD card, insert it, and open the page. For builds
+from this repository, that application is
+`firmware/tab5-c6/build/network_adapter.bin`. Use
+**Refresh SD**, select the file, then **Check image**. Modulus verifies the ESP
+image header and ESP32-C6 chip ID before enabling **Flash C6**.
+
+Flashing never starts automatically. Keep power and the SD card connected while
+the progress bar is active. After successful activation, **Restart Modulus** is
+enabled so the P4 can reboot and reconnect to the updated C6 firmware.
+
+Use an ESP-Hosted slave application image compatible with the host component
+version pinned in `firmware/tab5/dependencies.lock`. Do not use a full-flash,
+merged, bootloader, or partition-table image.
+
+Recovery: if the C6 update is interrupted and SDIO no longer starts, restore a
+complete C6 flash set through the C6 USB bootloader (`bootloader.bin`,
+`partition-table.bin`, `ota_data_initial.bin`, and `network_adapter.bin` at the
+documented offsets). The P4 OTA page cannot repair a C6 that no longer boots far
+enough to provide ESP-Hosted OTA.
+
### Pinout (Tab5)
| Signal | Pin | Note |
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/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..e1cfb12 100644
--- a/firmware/tab5/components/modulus_zig/CMakeLists.txt
+++ b/firmware/tab5/components/modulus_zig/CMakeLists.txt
@@ -14,6 +14,7 @@ set(MODULUS_CORE_SRCS
"i2c_transport_shim.c"
"canbus_shim.c"
"c6_sdio_host.c"
+ "c6_ota_shim.c"
"zb_uart_host.c"
"zb_devdb.c"
"zb_devdb_data.c"
@@ -150,6 +151,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 +171,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 +228,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 +250,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..e12ef0e
--- /dev/null
+++ b/firmware/tab5/components/modulus_zig/c6_ota_shim.c
@@ -0,0 +1,238 @@
+#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_ROOT "/sdcard"
+#define OTA_CHUNK_SIZE 4096
+#define OTA_MAX_IMAGE_SIZE (4U * 1024U * 1024U)
+
+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 the SD card. 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 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 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 };
+ /* Support cards inserted after boot. Mount is explicit here, not from the
+ * periodic storage poll, so a user-requested Eject remains respected. */
+ if (!modulus_storage_is_mounted()) {
+ (void)modulus_storage_mount();
+ }
+ 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);
+ }
+ DIR *dir = opendir(OTA_ROOT);
+ if (dir) {
+ struct dirent *entry;
+ while ((entry = readdir(dir)) != NULL && next.file_count < MODULUS_C6_OTA_MAX_FILES) {
+ if (!safe_bin_name(entry->d_name)) continue;
+ memcpy(next.files[next.file_count], entry->d_name, strlen(entry->d_name) + 1U);
+ next.file_count++;
+ }
+ closedir(dir);
+ }
+ 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 (!dir) {
+ next.phase = MODULUS_C6_OTA_ERROR;
+ snprintf(next.status, sizeof(next.status), "SD card is not mounted.");
+ } else if (next.file_count == 0) {
+ snprintf(next.status, sizeof(next.status), "No .bin files found in the SD card 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;
+ 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.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)];
+ snprintf(path, sizeof(path), OTA_ROOT "/%s", snap.files[snap.selected]);
+ 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 SD card.");
+ taskEXIT_CRITICAL(&s_lock);
+
+ FILE *f = fopen(path, "rb");
+ uint8_t *buf = malloc(OTA_CHUNK_SIZE);
+ bool ota_started = false;
+ esp_err_t err = (f && buf) ? esp_hosted_slave_ota_begin() : ESP_ERR_NO_MEM;
+ if (err == ESP_OK) ota_started = true;
+ size_t sent = 0;
+ while (err == ESP_OK && sent < image_size) {
+ size_t want = image_size - sent;
+ if (want > OTA_CHUNK_SIZE) want = OTA_CHUNK_SIZE;
+ if (fread(buf, 1, want, f) != want) { err = ESP_FAIL; break; }
+ 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);
+ }
+ if (ota_started) {
+ const esp_err_t end_err = esp_hosted_slave_ota_end();
+ if (err == ESP_OK) err = end_err;
+ }
+ if (err == ESP_OK && sent == image_size) 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 firmware activated successfully. Restart Modulus to reconnect.");
+ taskEXIT_CRITICAL(&s_lock);
+ } else {
+ char msg[MODULUS_C6_OTA_STATUS_LEN];
+ snprintf(msg, sizeof(msg), "C6 update failed at %u/%u bytes: %s. The image was not activated.",
+ (unsigned)sent, (unsigned)image_size, esp_err_to_name(err));
+ 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.");
+ }
+}
+
+void modulus_c6_ota_restart(void)
+{
+ modulus_c6_ota_snapshot_t snap;
+ modulus_c6_ota_get_snapshot(&snap);
+ if (snap.phase == MODULUS_C6_OTA_SUCCESS) esp_restart();
+}
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..6e82ad1
--- /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 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_restart(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/modulus_shims_bundle.h b/firmware/tab5/components/modulus_zig/include/modulus_shims_bundle.h
index 707aa76..8af627e 100644
--- a/firmware/tab5/components/modulus_zig/include/modulus_shims_bundle.h
+++ b/firmware/tab5/components/modulus_zig/include/modulus_shims_bundle.h
@@ -24,4 +24,5 @@
#include "ui_shim.h"
#include "wireless_shim.h"
#include "c6_sdio_host.h"
+#include "c6_ota_shim.h"
#include "wireless_shim.h"
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/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/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/patch_tab5_idf6_deps.ps1 b/scripts/patch_tab5_idf6_deps.ps1
index fd65db2..e9152e7 100644
--- a/scripts/patch_tab5_idf6_deps.ps1
+++ b/scripts/patch_tab5_idf6_deps.ps1
@@ -448,7 +448,7 @@ 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') {
+ if ($sd -notmatch '#define\s+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) {
@@ -672,6 +672,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/src/modulus/c/shim_bundle_manifest.zig b/src/modulus/c/shim_bundle_manifest.zig
index 54dd47e..8dc656e 100644
--- a/src/modulus/c/shim_bundle_manifest.zig
+++ b/src/modulus/c/shim_bundle_manifest.zig
@@ -18,6 +18,7 @@ pub const headers = [_][]const u8{
"security_shim.h",
"serial_shim.h",
"storage_shim.h",
+ "c6_ota_shim.h",
"touch_shim.h",
"transport_shim.h",
"ui_shim.h",
diff --git a/src/modulus/firmware/device_ui_bridge.zig b/src/modulus/firmware/device_ui_bridge.zig
index c7e110a..91b56be 100644
--- a/src/modulus/firmware/device_ui_bridge.zig
+++ b/src/modulus/firmware/device_ui_bridge.zig
@@ -1294,6 +1294,42 @@ 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);
+ var next: ui_engine.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.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(),
+ .restart => c.modulus_c6_ota_restart(),
+ }
+ c6OtaPoll(eng);
+}
+
pub fn dumpBegin() void {
device_runtime.settingsDumpBegin();
}
@@ -1369,6 +1405,8 @@ 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;
// 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..5a4f03c 100644
--- a/src/modulus/firmware/device_ui_runtime.zig
+++ b/src/modulus/firmware/device_ui_runtime.zig
@@ -502,6 +502,7 @@ 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.probe_pin_sink) |pin| {
eng.qs_probe_trig = pin();
}
diff --git a/src/modulus/ui_engine/engine.zig b/src/modulus/ui_engine/engine.zig
index 3d48bea..0820964 100644
--- a/src/modulus/ui_engine/engine.zig
+++ b/src/modulus/ui_engine/engine.zig
@@ -36,6 +36,7 @@ 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_c6_ota = @import("m_panel_c6_ota.zig");
const zb_exposes = @import("zb_exposes.zig");
const sd_volume = @import("sd_volume.zig");
const usb_volume = @import("usb_volume.zig");
@@ -174,6 +175,8 @@ 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,
/// 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.
@@ -376,6 +379,8 @@ pub const Engine = struct {
m_panel_zb_menu_field: u8 = 0,
m_panel_zb_menu_rect: geom.Rect = .{},
m_panel_zb_menu_scroll: usize = 0,
+ m_panel_c6_ota_layout: m_panel_c6_ota.Layout = .{},
+ m_panel_c6_ota_state: m_panel_c6_ota.State = .{},
m_panel_tool: u8 = 0xff,
needs_full_repaint: bool = true,
/// After full paint on settings: present window AABB only (margins unchanged).
@@ -709,6 +714,13 @@ pub const Engine = struct {
self.m_panel_zb_menu_scroll,
);
}
+ } 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 {
self.m_panel_tool_layout = m_panel.paintTool(
&self.logical,
@@ -5746,6 +5758,9 @@ pub const Engine = struct {
if (!self.prefs.wireless.zigbee) self.prefs.wireless.zigbee = true;
_ = self.emitWireless(.zb_refresh);
}
+ if (index == @intFromEnum(m_panel.ToolId.c6_update)) {
+ if (self.c6_ota_cmd_sink) |sink| sink(self, .refresh, 0);
+ }
if (index == @intFromEnum(m_panel.ToolId.terminal) and self.m_panel_term_auto_scroll) {
self.terminalFollowTail();
}
@@ -5778,6 +5793,25 @@ pub const Engine = struct {
self.handleSdClick(x, y);
} else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.zigbee)) {
self.handleZigbeeClick(x, y);
+ } else if (self.m_panel_tool == @intFromEnum(m_panel.ToolId.c6_update)) {
+ const h = m_panel_c6_ota.hit(self.m_panel_c6_ota_layout, x, y);
+ switch (h.kind) {
+ .none => {},
+ .scrim, .back => self.returnToMPanelFromTool(),
+ .exit => self.closeToolToDashboard(),
+ .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);
+ },
+ .restart => if (self.m_panel_c6_ota_state.phase == .success) {
+ if (self.c6_ota_cmd_sink) |sink| sink(self, .restart, 0);
+ },
+ }
+ self.requestFull();
} else if (m_panel.hitTool(self.m_panel_tool_layout, x, y)) {
self.returnToMPanelFromTool();
}
diff --git a/src/modulus/ui_engine/m_panel.zig b/src/modulus/ui_engine/m_panel.zig
index 7da12e1..3899fb9 100644
--- a/src/modulus/ui_engine/m_panel.zig
+++ b/src/modulus/ui_engine/m_panel.zig
@@ -16,6 +16,7 @@ pub const ToolId = enum(u8) {
probe = 2,
sd = 3,
zigbee = 4,
+ c6_update = 5,
};
pub const Tool = struct {
@@ -31,6 +32,7 @@ pub const tools = [_]Tool{
.{ .label = "Probe", .icon = .arrow_down },
.{ .label = "SD Card", .icon = .hard_drives },
.{ .label = "Zigbee", .icon = .broadcast },
+ .{ .label = "C6 Update", .icon = .cpu },
};
pub fn toolEnabled(index: u8, usb_host: bool) bool {
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..4921db8
--- /dev/null
+++ b/src/modulus/ui_engine/m_panel_c6_ota.zig
@@ -0,0 +1,143 @@
+//! M-Panel C6 Update — guarded ESP-Hosted slave OTA from the SD-card root.
+
+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 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 };
+
+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,
+ 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,
+
+ 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 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, refresh, row, check, flash, restart };
+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 = .{},
+};
+
+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);
+}
+
+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, "C6 Firmware Update");
+ tool_chrome.paintExit(logical, theme, lay.header.exit);
+
+ const x = card.x + tokens.Space.lg;
+ const right = card.x + card.w - tokens.Space.lg;
+ var y = lay.header.back.y + lay.header.back.h + tokens.Space.md;
+ const link = if (state.c6_connected) "ESP-Hosted/SDIO connected" else "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) font.drawTextRole(logical, x + 270, y, state.versionText(), theme.on_surface_variant, .body_m);
+ lay.refresh = .{ .x = right - 190, .y = y - 10, .w = 190, .h = 60 };
+ widgets.drawTonalButton(logical, lay.refresh, "Refresh SD", theme);
+ y += tokens.Logical.touch_min + tokens.Space.sm;
+
+ const row_h: i32 = 54;
+ const row_gap: i32 = 6;
+ lay.row_n = @min(state.file_count, 6);
+ 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);
+ font.drawTextRole(logical, r.x + tokens.Space.md, r.y + 16, 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 C6 .bin files available in the SD card root.", theme.on_surface_variant, .body_m);
+
+ const status_y = card.y + card.h - 150;
+ font.drawTextRole(logical, x, status_y, 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 };
+ lay.restart = .{ .x = right - action_w, .y = by, .w = action_w, .h = action_h };
+ const can_check = state.file_count > 0 and state.phase != .flashing;
+ 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 C6", theme) else drawDisabled(logical, lay.flash, "2. Flash C6", theme);
+ if (state.phase == .success) widgets.drawFilledButton(logical, lay.restart, "3. Restart", theme) else drawDisabled(logical, lay.restart, "3. Restart", 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.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 };
+ if (layout.restart.contains(x, y)) return .{ .kind = .restart };
+ 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 = .{};
+ 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.refresh.h >= tokens.Logical.touch_min);
+ try std.testing.expect(lay.rows[0].h >= tokens.Logical.touch_min);
+}
diff --git a/src/modulus/ui_engine/root.zig b/src/modulus/ui_engine/root.zig
index 34a778b..042e57f 100644
--- a/src/modulus/ui_engine/root.zig
+++ b/src/modulus/ui_engine/root.zig
@@ -46,6 +46,7 @@ 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_tool = @import("m_panel_tool.zig");
pub const sd_volume = @import("sd_volume.zig");
pub const usb_volume = @import("usb_volume.zig");
diff --git a/src/modulus/ui_engine/settings_other_tabs.zig b/src/modulus/ui_engine/settings_other_tabs.zig
index bdc1a47..b83f39b 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;
@@ -1581,8 +1581,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)");