diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 580965a..559a080 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,8 +7,8 @@ on: branches: ["main"] # Cada run compila y sube los binarios como artefacto (Actions -> run -> Artifacts). -# En push a main ademas crea la Release v con esos binarios, -# salvo que ya exista (hay que subir FIRMWARE_VERSION para publicar otra). +# En push a main ademas crea la Release v con esos binarios, +# salvo que ya exista (hay que subir FW_VERSION para publicar otra). # # Se compila con secrets.h.template: un equipo nuevo arranca en el portal de # Setup y ahi se configura; un equipo ya configurado conserva su NVS al actualizar. @@ -55,7 +55,7 @@ jobs: - name: Package binaries id: pkg run: | - VERSION=$(grep -oP 'const String FIRMWARE_VERSION = "\K[^"]+' src/proxy_operativo.hpp) + VERSION=$(grep -oP '#define FW_VERSION "\K[^"]+' src/proxy_operativo.hpp) BUILD=.pio/build/esp32_usb DIST=dist NAME=modbusproxy-$VERSION @@ -95,7 +95,7 @@ jobs: run: | TAG="v$VERSION" if gh release view "$TAG" >/dev/null 2>&1; then - echo "::notice::La release $TAG ya existe. Sube FIRMWARE_VERSION para publicar una nueva." + echo "::notice::La release $TAG ya existe. Sube FW_VERSION para publicar una nueva." exit 0 fi @@ -109,6 +109,8 @@ jobs: | \`$NAME-bootloader.bin\` / \`$NAME-partitions.bin\` | Flasheo por partes (\`0x1000\` / \`0x8000\`) | | \`$NAME-sha256.txt\` | Checksums | + **Actualizar un equipo que ya funciona:** abre \`http:///firmware\`, elige \`$NAME-firmware.bin\` y pulsa *Actualizar*. El fichero se verifica antes de instalarse y, si el firmware nuevo no arranca bien, el equipo vuelve solo a la versión anterior. También vale \`pio run -e PROD_ota -t upload\` (OTA). Las versiones anteriores a la 6.2-0 no tienen la pestaña Firmware: la primera actualización a 6.2-0 hay que hacerla por OTA o USB. + Flasheo de un equipo nuevo: \`\`\`bash diff --git a/README.en.md b/README.en.md index aad6e69..c21f6c1 100644 --- a/README.en.md +++ b/README.en.md @@ -1,4 +1,4 @@ -# 🔋 ESP32 Modbus TCP Proxy & Multiplexer for Huawei EMMA & SUN2000 (v6.1-0) +# 🔋 ESP32 Modbus TCP Proxy & Multiplexer for Huawei EMMA & SUN2000 (v6.2-0) This repository contains industrial-grade firmware developed for **ESP32** (optimized for wired connection via the native Ethernet stack of the LAN8720 chip on WT32-ETH01 boards, or via its internal WiFi antenna). The device acts as a **network shield, transparent on-demand proxy, and Modbus TCP channel multiplexer**. @@ -150,7 +150,15 @@ SERVIDORES = { ## 🚀 Version History and Changelog -### 🟢 Version 6.1-0 (Current) +### 🟢 Version 6.2-0 (Current) + +* **Web firmware update (issue #16):** New **Firmware** tab showing the installed version, running partition, validation state and available space. Upload the `modbusproxy--firmware.bin` from the [releases](https://github.com/shosoin/modbusproxy/releases) without PlatformIO or a USB cable. +* **Verification before and during upload:** The browser checks that the file is an ESP32 application image (and warns if `factory.bin` was picked by mistake), that it carries the project marker `MBPXY-FW:;`, and compares versions (newer / same / downgrade). Accepting a disclaimer is mandatory. The ESP32 searches for the marker again while receiving and discards any foreign firmware without touching the boot partition; `Update` validates integrity (SHA-256). +* **Automatic rollback:** A freshly installed firmware (web or OTA) boots *pending validation* and is only confirmed after 30 s running with network. If it reboots before that, the bootloader goes back to the previous version by itself. +* **Automatic releases:** For every new version on `main`, CI publishes a Release with `firmware.bin` (updates), `factory.bin` (new devices), bootloader, partitions and checksums. +* **Single version source:** The version lives in `#define FW_VERSION` (read by the firmware, CI and `pre_upload.py`). `/api/status` adds `firmware_version`. + +### 🔵 Version 6.1-0 * **Configurable Test Target (issue #12):** The boot Modbus check is no longer hard-wired to the EMMA. In `/config` and in the Setup portal, inside "Modbus TCP target", you choose the device type (`EMMA`, `SUN2000` or `Custom`), enable or disable the check and set the 4 request fields: Unit ID, function, start register and number of registers. With Custom all of them are free and the web warns that they are the user's responsibility. * **Persistence and migration:** Values are stored in NVS. When upgrading from 6.0-0 they are initialised with the EMMA ones (`0 / FC03 / 30000 / 15`, check enabled), so behaviour is unchanged until they are modified. @@ -199,7 +207,7 @@ The complete behavior of the board and network stack is managed via parameteriza * **Persistence:** stored in NVS, surviving reboots and flashing (USB or OTA). When upgrading from an older firmware, missing values are initialised with the EMMA ones. * `/api/status` exposes `target_profile`, `startup_check`, `test_unit_id`, `test_function`, `test_register` and `test_reg_count`. * `RECONNECT_DELAY` (`const uint32_t`): Wait time in milliseconds (`5000` ms) the proxy applies before retrying a connection to Huawei's port 502 if the socket breaks, preventing bans from burst infinite retries (Anti-DDoS). -* `FIRMWARE_VERSION` (`const String`): Stores the semantic version of the active program, rendered centered in the local menu. Also used as the suffix of the WiFi AP name in Setup Mode (`modbusproxy-`). +* `FW_VERSION` (`#define`) / `FIRMWARE_VERSION` (`const String`): Semantic version of the active program; change it only in `FW_VERSION`. Also used by CI (Release name), `pre_upload.py` and the `FW_MARKER` checked by the Firmware tab. It is, rendered centered in the local menu. Also used as the suffix of the WiFi AP name in Setup Mode (`modbusproxy-`). --- @@ -234,6 +242,35 @@ The device runs a web server on standard port `80` that auto-refreshes every 5 s --- +## ⬆️ Web Firmware Update (Firmware tab) + +For devices that already run the firmware. A brand new ESP32 is flashed once over USB with `modbusproxy--factory.bin`: + +```bash +esptool.py --chip esp32 write_flash 0x0 modbusproxy--factory.bin +``` + +**Steps:** + +1. Download `modbusproxy--firmware.bin` from [GitHub Releases](https://github.com/shosoin/modbusproxy/releases). +2. Open `http:///firmware`. It shows the installed version, the running partition (`app0`/`app1`), the state and the available space. +3. Choose the file. Before uploading anything, the browser checks: + * that it is an ESP32 application image (`0xE9` header, ESP32 chip). If you pick `factory.bin`, it tells you to use `firmware.bin`; + * that it carries the project marker `MBPXY-FW:;` (firmware from other projects is rejected); + * the version: *current → file*, telling whether it is newer, the same or a downgrade; + * that the file fits in the partition. +4. Accept the disclaimer and press **Update**. The proxy stops serving clients, the OLED shows the progress (`UPDATE WEB`) and the device reboots when done. The page waits for it to come back and shows the new version. + +**Process safety:** + +* The ESP32 writes to the **free** OTA partition: the running version is untouched until the new one is complete and verified. +* The ESP32 searches for the project marker again in the received stream. If missing, the upload is discarded without changing the boot partition, even if someone bypasses the browser check. `Update` also validates the image integrity (SHA-256). +* **Rollback:** the new firmware boots *pending validation* and is confirmed after 30 s running with network (or when reaching the Setup portal). If it reboots before (crash, reset), the bootloader goes back to the previous version. This also covers ArduinoOTA updates. A firmware that hangs **without** rebooting is not rolled back: it would need USB recovery. +* The configuration (NVS) is kept. +* **Limits:** web/OTA updates cannot change the partition table or the bootloader; that requires USB. The tab has no password, just like `/config`: the proxy is meant for trusted local networks. + +--- + ## 📊 Remote Monitoring via REST JSON API To enable native integrations with external systems or create dedicated diagnostic sensors in Home Assistant, the firmware exposes a read-only optimized JSON endpoint at: @@ -243,6 +280,7 @@ The response is generated by concatenating memory buffers to eliminate heavy thi ```json { + "firmware_version": "6.2-0", "uptime_seconds": 3245, "backend_state": "CONNECTED", "device_model": "SmartHEMS", @@ -371,7 +409,7 @@ When NVS is empty (freshly flashed device or erased flash), `runSetup` does not When entering Setup Mode, the ESP32: -1. Creates an **open WiFi access point** named `modbusproxy-6.1-0` (no password). +1. Creates an **open WiFi access point** named `modbusproxy-6.2-0` (no password). 2. Assigns IP `192.168.1.1` to its own interface. 3. Any device connecting to that WiFi and opening a browser is **automatically redirected** to the configuration page (captive portal — works just like hotel WiFi). 4. The wizard lets you configure: @@ -398,7 +436,7 @@ If you still have access to the proxy's web interface: 1. Open `http:///config` in your browser. 2. Scroll down to the **Danger Zone** section. 3. Press **Factory Reset (Enter Setup Mode)** and confirm. -4. The device restarts and brings up the `modbusproxy-6.1-0` AP. +4. The device restarts and brings up the `modbusproxy-6.2-0` AP. --- diff --git a/README.md b/README.md index 5cc54f4..7c5d4e4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🔋 ESP32 Modbus TCP Proxy & Multiplexer for Huawei EMMA & SUN2000 (v6.1-0) +# 🔋 ESP32 Modbus TCP Proxy & Multiplexer for Huawei EMMA & SUN2000 (v6.2-0) Este repositorio contiene el firmware de grado industrial desarrollado para **ESP32** (optimizado para conexión por cable físico mediante la pila Ethernet nativa del chip LAN8720 en placas WT32-ETH01, o mediante su antena WiFi interna). El dispositivo actúa como un **escudo de red, proxy transparente bajo demanda (On-Demand) y multiplexor de canales Modbus TCP**. @@ -153,7 +153,15 @@ SERVIDORES = { ## 🚀 Historial de Versiones y Novedades -### 🟢 Versión 6.1-0 (Actual) +### 🟢 Versión 6.2-0 (Actual) + +* **Actualización de firmware desde la web (issue #16):** Nueva pestaña **Firmware** con la versión instalada, la partición en uso, el estado de validación y el espacio disponible. Permite subir el `modbusproxy--firmware.bin` de las [releases](https://github.com/shosoin/modbusproxy/releases) sin PlatformIO ni cable USB. +* **Verificación antes y durante la subida:** El navegador comprueba que el fichero es una imagen de aplicación ESP32 (y avisa si se elige por error la `factory.bin`), que lleva la marca del proyecto `MBPXY-FW:;` y compara versiones (más nueva / igual / downgrade). Es obligatorio aceptar un aviso de responsabilidad. El ESP32 vuelve a buscar la marca mientras recibe el fichero y descarta cualquier firmware ajeno sin tocar el arranque; `Update` valida la integridad (SHA-256). +* **Rollback automático:** Un firmware recién instalado (por web u OTA) arranca *pendiente de validar* y solo se confirma tras 30 s funcionando con red. Si se reinicia antes, el bootloader vuelve solo a la versión anterior. +* **Releases automáticas:** El CI publica en cada versión nueva de `main` una Release con `firmware.bin` (actualizaciones), `factory.bin` (equipos nuevos), bootloader, particiones y checksums. +* **Versión única:** La versión se define en `#define FW_VERSION` (la leen el firmware, el CI y `pre_upload.py`). `/api/status` añade `firmware_version`. + +### 🔵 Versión 6.1-0 * **Test Target configurable (issue #12):** El test Modbus de arranque deja de estar fijado a la EMMA. En `/config` y en el portal de Setup, dentro de "Destino Modbus TCP", se elige el tipo de dispositivo (`EMMA`, `SUN2000` o `Personalizado`), se activa o desactiva el test y se definen los 4 datos de la petición: Unit ID, función, registro inicial y número de registros. En Personalizado todos son libres y la web avisa de que van bajo responsabilidad del usuario. * **Persistencia y migración:** Los valores se guardan en NVS. Al actualizar desde 6.0-0 se inicializan con los de la EMMA (`0 / FC03 / 30000 / 15`, test activado), por lo que el comportamiento no cambia hasta que se modifiquen. @@ -202,7 +210,7 @@ El comportamiento completo de la placa y la pila de red se administra mediante v * **Persistencia:** se guardan en NVS y sobreviven a reinicios y flasheos (USB u OTA). Al actualizar desde un firmware anterior, si no existen, se inicializan con los valores de la EMMA. * `/api/status` expone `target_profile`, `startup_check`, `test_unit_id`, `test_function`, `test_register` y `test_reg_count`. * `RECONNECT_DELAY` (`const uint32_t`): Tiempo de espera en milisegundos (`5000` ms) que aplica el proxy antes de reintentar una conexión contra el puerto 502 de Huawei si el socket se rompe, evitando baneos por reintentos infinitos en ráfaga (Anti-DDoS). -* `FIRMWARE_VERSION` (`const String`): Almacena la versión semántica del programa activo que se renderizará de forma centrada en el menú local. También se usa como sufijo del nombre del AP WiFi en Modo Setup (`modbusproxy-`). +* `FW_VERSION` (`#define`) / `FIRMWARE_VERSION` (`const String`): Versión semántica del programa activo; se cambia solo en `FW_VERSION`. La usan también el CI (nombre de la Release), `pre_upload.py` y la marca `FW_MARKER` que verifica la pestaña Firmware. Se que se renderizará de forma centrada en el menú local. También se usa como sufijo del nombre del AP WiFi en Modo Setup (`modbusproxy-`). --- @@ -237,6 +245,35 @@ El dispositivo levanta un servidor web en el puerto estándar `80` que se refres --- +## ⬆️ Actualización de Firmware desde la Web (pestaña Firmware) + +Para equipos que ya tienen el firmware funcionando. Un ESP32 nuevo se flashea una única vez por USB con `modbusproxy--factory.bin`: + +```bash +esptool.py --chip esp32 write_flash 0x0 modbusproxy--factory.bin +``` + +**Pasos:** + +1. Descarga `modbusproxy--firmware.bin` de [GitHub Releases](https://github.com/shosoin/modbusproxy/releases). +2. Abre `http:///firmware`. Verás la versión instalada, la partición en uso (`app0`/`app1`), el estado y el espacio disponible. +3. Elige el fichero. Antes de subir nada, el navegador comprueba: + * que es una imagen de aplicación ESP32 (cabecera `0xE9`, chip ESP32). Si eliges `factory.bin`, avisa de que uses `firmware.bin`; + * que lleva la marca del proyecto `MBPXY-FW:;` (no deja instalar firmwares de otros proyectos); + * la versión: *actual → fichero*, indicando si es más nueva, la misma o un downgrade; + * que el fichero cabe en la partición. +4. Acepta el aviso de responsabilidad y pulsa **Actualizar**. El proxy deja de atender clientes, el OLED muestra el progreso (`UPDATE WEB`) y al terminar el equipo se reinicia. La página espera a que vuelva y muestra la versión nueva. + +**Seguridad del proceso:** + +* El ESP32 escribe en la partición OTA **libre**: la versión en uso no se toca hasta que la nueva está completa y verificada. +* El ESP32 vuelve a buscar la marca del proyecto en el flujo recibido. Si no está, descarta la subida sin cambiar el arranque, aunque alguien se salte la verificación del navegador. `Update` valida además la integridad de la imagen (SHA-256). +* **Rollback:** el firmware nuevo arranca *pendiente de validar* y se confirma tras 30 s funcionando con red (o al llegar al portal de Setup). Si se reinicia antes (crash, reset), el bootloader vuelve solo a la versión anterior. Vale también para las actualizaciones por ArduinoOTA. Un firmware que se cuelgue **sin** reiniciarse no se revierte: habría que recuperarlo por USB. +* La configuración (NVS) se conserva. +* **Límites:** por web u OTA no se pueden cambiar la tabla de particiones ni el bootloader; eso requiere USB. La pestaña no tiene contraseña, igual que `/config`: el proxy está pensado para redes locales de confianza. + +--- + ## 📊 Monitorización remota vía API REST JSON Para permitir integraciones nativas con sistemas externos o crear sensores de diagnóstico dedicados en Home Assistant, el firmware expone un endpoint JSON optimizado de solo lectura en la ruta: @@ -246,6 +283,7 @@ La respuesta se genera concatenando buffers en memoria para eliminar el uso de l ```json { + "firmware_version": "6.2-0", "uptime_seconds": 3245, "backend_state": "CONNECTED", "device_model": "SmartHEMS", @@ -376,7 +414,7 @@ Cuando la NVS está vacía (dispositivo recién flasheado o flash borrada), `run Al entrar en Modo Setup el ESP32: -1. Levanta un **punto de acceso WiFi abierto** con el nombre `modbusproxy-6.1-0` (sin contraseña). +1. Levanta un **punto de acceso WiFi abierto** con el nombre `modbusproxy-6.2-0` (sin contraseña). 2. Asigna la IP `192.168.1.1` a su propia interfaz. 3. Cualquier dispositivo que se conecte a esa WiFi y abra un navegador es **redirigido automáticamente** a la página de configuración (portal cautivo — funciona igual que los WiFi de hoteles). 4. El asistente permite configurar: @@ -403,7 +441,7 @@ Si todavía tienes acceso a la interfaz web del proxy: 1. Abre `http:///config` en el navegador. 2. Baja hasta la sección **Zona de Peligro**. 3. Pulsa **Factory Reset (Entrar en Modo Setup)** y confirma. -4. El dispositivo reinicia y levanta el AP `modbusproxy-6.1-0`. +4. El dispositivo reinicia y levanta el AP `modbusproxy-6.2-0`. --- diff --git a/pre_upload.py b/pre_upload.py index 36209b0..871c46c 100644 --- a/pre_upload.py +++ b/pre_upload.py @@ -156,7 +156,7 @@ def find_file(name, dirs): # ----------------------------------------------------------------------- fw_version = extract_first( proxy_content, - r'const\s+String\s+FIRMWARE_VERSION\s*=\s*"([^"]+)"' + r'#define\s+FW_VERSION\s+"([^"]+)"' ) diff --git a/src/i18n/de.hpp b/src/i18n/de.hpp index db1d518..c549d3f 100644 --- a/src/i18n/de.hpp +++ b/src/i18n/de.hpp @@ -177,4 +177,39 @@ static const LangStrings LANG_DE = { .tgt_func_note = "Frei. EMMA/SUN2000 verwenden 03 (Holding-Register lesen); 04 = Input-Register. Vorsicht: Schreibfunktionen (05, 06, 15, 16...) würden das Gerät bei jedem Testversuch verändern.", .tgt_register = "Startregister", .tgt_reg_count = "Anzahl der Register", + // --- Pestaña Firmware --- + .nav_firmware = "Firmware", + .fw_title = "Firmware", + .fw_h1 = "Firmware", + .fw_h3_current = "Installierte Version", + .fw_version = "Version", + .fw_partition = "Aktive Partition", + .fw_state = "Status", + .fw_space = "Platz für Firmware", + .fw_state_valid = "Validiert", + .fw_state_pending = "Validierung ausstehend (bei Fehler Rückkehr zur vorherigen Version)", + .fw_state_usb = "Per USB geflasht", + .fw_h3_update = "Firmware aktualisieren", + .fw_disclaimer = "⚠ Haftungshinweis: Das Aktualisieren der Firmware erfolgt auf eigene Verantwortung. Verwenden Sie nur modbusproxy-<Version>-firmware.bin-Dateien aus den offiziellen Releases. Während des Uploads bedient der Proxy keine Clients und startet danach neu. Nicht vom Strom trennen. Startet die neue Firmware nicht korrekt, kehrt das Gerät selbst zur vorherigen Version zurück; ist es dennoch nicht erreichbar, muss es per USB wiederhergestellt werden.", + .fw_hint = "Wählen Sie die von GitHub Releases heruntergeladene Datei firmware.bin. Sie wird vor dem Upload geprüft. Das Image factory.bin ist nur für neue Geräte per USB.", + .fw_accept = "Ich habe den Hinweis gelesen und aktualisiere auf eigene Verantwortung", + .fw_btn = "Aktualisieren", + .fw_chk_magic = "Kein ESP32-Firmware-Image", + .fw_chk_factory = "Das ist das factory.bin-Image (für neue Geräte per USB): verwenden Sie firmware.bin", + .fw_chk_chip = "Image für einen anderen Chip, nicht ESP32", + .fw_chk_img = "Gültiges ESP32-Firmware-Image", + .fw_chk_no_marker = "Keine modbusproxy-Firmware (Projektmarkierung fehlt)", + .fw_chk_marker = "modbusproxy-Firmware, Version", + .fw_chk_size = "Die Datei passt nicht in die Firmware-Partition", + .fw_chk_newer = "Neuere Version:", + .fw_chk_same = "Gleiche Version wie installiert:", + .fw_chk_older = "Ältere Version (Downgrade):", + .fw_chk_unknown = "Unbekanntes Versionsformat:", + .fw_uploading = "Firmware wird hochgeladen... Seite nicht schließen.", + .fw_rebooting = "Firmware installiert. Gerät startet neu...", + .fw_done = "Gerät wieder online. Version:", + .fw_not_back = "Das Gerät antwortet nicht. Prüfen Sie das OLED oder laden Sie die Seite später neu.", + .fw_error = "Fehler:", + .fw_err_no_marker = "keine modbusproxy-Firmware (Projektmarkierung fehlt); nicht installiert", + .fw_err_aborted = "Upload abgebrochen; nicht installiert", }; diff --git a/src/i18n/en.hpp b/src/i18n/en.hpp index 0bf134b..3ebf46d 100644 --- a/src/i18n/en.hpp +++ b/src/i18n/en.hpp @@ -177,4 +177,39 @@ static const LangStrings LANG_EN = { .tgt_func_note = "Free. EMMA/SUN2000 use 03 (read holding registers); 04 = input registers. Careful: write functions (05, 06, 15, 16...) would modify the device on every check attempt.", .tgt_register = "Start register", .tgt_reg_count = "Number of registers", + // --- Pestaña Firmware --- + .nav_firmware = "Firmware", + .fw_title = "Firmware", + .fw_h1 = "Firmware", + .fw_h3_current = "Installed version", + .fw_version = "Version", + .fw_partition = "Running partition", + .fw_state = "State", + .fw_space = "Firmware space", + .fw_state_valid = "Validated", + .fw_state_pending = "Pending validation (if it fails, it rolls back to the previous version)", + .fw_state_usb = "Flashed via USB", + .fw_h3_update = "Update firmware", + .fw_disclaimer = "⚠ Disclaimer: updating the firmware is at your own risk. Only use modbusproxy-<version>-firmware.bin files from the official releases. During the upload the proxy stops serving clients and it reboots when done. Do not power it off. If the new firmware does not boot properly, the device rolls back to the previous version by itself; if it still becomes unreachable, it will need USB recovery.", + .fw_hint = "Choose the firmware.bin file downloaded from GitHub Releases. It is verified before uploading. The factory.bin image is only for new devices over USB.", + .fw_accept = "I have read the disclaimer and update at my own risk", + .fw_btn = "Update", + .fw_chk_magic = "Not an ESP32 firmware image", + .fw_chk_factory = "This is the factory.bin image (for new devices over USB): use the firmware.bin file", + .fw_chk_chip = "Image for another chip, not ESP32", + .fw_chk_img = "Valid ESP32 firmware image", + .fw_chk_no_marker = "Not a modbusproxy firmware (project marker missing)", + .fw_chk_marker = "modbusproxy firmware, version", + .fw_chk_size = "The file does not fit in the firmware partition", + .fw_chk_newer = "Newer version:", + .fw_chk_same = "Same version as installed:", + .fw_chk_older = "Older version (downgrade):", + .fw_chk_unknown = "Unknown version format:", + .fw_uploading = "Uploading firmware... do not close this page.", + .fw_rebooting = "Firmware installed. Rebooting the device...", + .fw_done = "Device back online. Version:", + .fw_not_back = "The device has not come back. Check the OLED or reload the page later.", + .fw_error = "Error:", + .fw_err_no_marker = "not a modbusproxy firmware (project marker missing); not installed", + .fw_err_aborted = "upload interrupted; not installed", }; diff --git a/src/i18n/es.hpp b/src/i18n/es.hpp index c552d9c..8ff887d 100644 --- a/src/i18n/es.hpp +++ b/src/i18n/es.hpp @@ -177,4 +177,39 @@ static const LangStrings LANG_ES = { .tgt_func_note = "Libre. EMMA/SUN2000 usan 03 (lectura de holding registers); 04 = input registers. Cuidado: funciones de escritura (05, 06, 15, 16...) modificarían el equipo en cada intento del test.", .tgt_register = "Registro inicial", .tgt_reg_count = "Número de registros", + // --- Pestaña Firmware --- + .nav_firmware = "Firmware", + .fw_title = "Firmware", + .fw_h1 = "Firmware", + .fw_h3_current = "Versión instalada", + .fw_version = "Versión", + .fw_partition = "Partición en uso", + .fw_state = "Estado", + .fw_space = "Espacio para firmware", + .fw_state_valid = "Validado", + .fw_state_pending = "Pendiente de validar (si falla, vuelve a la versión anterior)", + .fw_state_usb = "Flasheado por USB", + .fw_h3_update = "Actualizar firmware", + .fw_disclaimer = "⚠ Aviso de responsabilidad: actualizar el firmware se hace bajo tu responsabilidad. Usa solo ficheros modbusproxy-<versión>-firmware.bin de las releases oficiales. Durante la subida el proxy deja de atender clientes y al terminar se reinicia. No cortes la alimentación. Si el firmware nuevo no arranca bien, el equipo vuelve solo a la versión anterior; si aun así queda inaccesible, habrá que recuperarlo por USB.", + .fw_hint = "Elige el fichero firmware.bin descargado de GitHub Releases. Se verifica antes de subirlo. La imagen factory.bin es solo para equipos nuevos por USB.", + .fw_accept = "He leído el aviso y actualizo bajo mi responsabilidad", + .fw_btn = "Actualizar", + .fw_chk_magic = "No es una imagen de firmware ESP32", + .fw_chk_factory = "Es la imagen factory.bin (para equipos nuevos por USB): usa el fichero firmware.bin", + .fw_chk_chip = "Imagen para otro chip, no para ESP32", + .fw_chk_img = "Imagen de firmware ESP32 válida", + .fw_chk_no_marker = "No es un firmware de modbusproxy (falta la marca del proyecto)", + .fw_chk_marker = "Firmware de modbusproxy, versión", + .fw_chk_size = "El fichero no cabe en la partición de firmware", + .fw_chk_newer = "Versión más nueva:", + .fw_chk_same = "Es la misma versión que la instalada:", + .fw_chk_older = "Versión más antigua (downgrade):", + .fw_chk_unknown = "Formato de versión desconocido:", + .fw_uploading = "Subiendo firmware... no cierres esta página.", + .fw_rebooting = "Firmware instalado. Reiniciando el equipo...", + .fw_done = "Equipo de nuevo en marcha. Versión:", + .fw_not_back = "El equipo no ha vuelto a responder. Revisa el OLED o recarga la página en un rato.", + .fw_error = "Error:", + .fw_err_no_marker = "no es un firmware de modbusproxy (falta la marca del proyecto); no se ha instalado", + .fw_err_aborted = "subida interrumpida; no se ha instalado", }; diff --git a/src/i18n/strings.hpp b/src/i18n/strings.hpp index cc30cbc..3db7fcb 100644 --- a/src/i18n/strings.hpp +++ b/src/i18n/strings.hpp @@ -183,4 +183,40 @@ struct LangStrings { const char* tgt_func_note; const char* tgt_register; const char* tgt_reg_count; + + // --- Pestaña Firmware (actualización web) --- + const char* nav_firmware; + const char* fw_title; + const char* fw_h1; + const char* fw_h3_current; + const char* fw_version; + const char* fw_partition; + const char* fw_state; + const char* fw_space; + const char* fw_state_valid; + const char* fw_state_pending; + const char* fw_state_usb; + const char* fw_h3_update; + const char* fw_disclaimer; + const char* fw_hint; + const char* fw_accept; + const char* fw_btn; + const char* fw_chk_magic; + const char* fw_chk_factory; + const char* fw_chk_chip; + const char* fw_chk_img; + const char* fw_chk_no_marker; + const char* fw_chk_marker; + const char* fw_chk_size; + const char* fw_chk_newer; + const char* fw_chk_same; + const char* fw_chk_older; + const char* fw_chk_unknown; + const char* fw_uploading; + const char* fw_rebooting; + const char* fw_done; + const char* fw_not_back; + const char* fw_error; + const char* fw_err_no_marker; + const char* fw_err_aborted; }; diff --git a/src/proxy_operativo.hpp b/src/proxy_operativo.hpp index 061a5f0..aa4a18f 100644 --- a/src/proxy_operativo.hpp +++ b/src/proxy_operativo.hpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "secrets.h" // Valores default del primer arranque — EXCLUIDO DE GIT #include #include @@ -236,7 +238,16 @@ const int BOTON_MENOS = 39; // Ver bloque "DISPOSITIVO DESTINO Y TEST DE CONEXIÓN DE ARRANQUE". const uint32_t RECONNECT_DELAY = 100; // ms mínimos entre reconexiones al backend -const String FIRMWARE_VERSION = "6.1-0"; +// Versión del firmware: cambiar SOLO aquí. La leen también el CI (nombre de +// la Release) y pre_upload.py. +#define FW_VERSION "6.2-0" +const String FIRMWARE_VERSION = FW_VERSION; + +// Marca del proyecto incrustada en el binario. La pestaña Firmware la busca +// en el .bin que se sube (navegador y ESP32) para comprobar que es un +// firmware de este proyecto y leer su versión. Formato: MBPXY-FW:; +// Se imprime por Serial al arrancar para que el linker no la elimine. +const char FW_MARKER[] = "MBPXY-FW:" FW_VERSION ";"; // ==================================================================== // Máquina de estados extendida @@ -370,6 +381,9 @@ void handleWebConfigSave(); void handleFactoryReset(); void handleReboot(); void handleWifiScan(); +void handleWebFirmware(); +void handleFirmwareUpload(); +void handleFirmwareUploadDone(); String buildNavBar(const String &activePage); // ==================================================================== @@ -503,27 +517,16 @@ void readTargetConfigArgs(WebServer& srv) { // Requiere que display ya este declarado (Adafruit_SH1106G) y FIRMWARE_VERSION definido #include "setup_mode.hpp" +// Pestaña Firmware: actualización desde la web + validación/rollback +#include "web_update.hpp" + void setupOTA() { ArduinoOTA.setPort(3232); ArduinoOTA.setHostname("Proxy-Huawei"); ArduinoOTA.setPassword(cfg.otaPassword); ArduinoOTA.onStart([]() { - otaInProgress = true; - if (backendClient.connected()) backendClient.stop(); - if (proxyServer.hasClient()) proxyServer.available().stop(); - for (int i = 0; i < MAX_CLIENTS; i++) { - if (clients[i] && clients[i].connected()) clients[i].stop(); - } - - display.clearDisplay(); - display.setTextSize(1); - display.setTextColor(SH110X_WHITE); - display.setCursor(0, 10); - display.println("MODO OTA ACTIVADO"); - display.setCursor(0, 25); - display.println("Recibiendo firmware..."); - display.display(); + beginFirmwareUpdate("MODO OTA ACTIVADO"); }); ArduinoOTA.onEnd([]() { @@ -536,22 +539,7 @@ void setupOTA() { }); ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { - int percent = (progress / (total / 100)); - - display.clearDisplay(); - display.setTextSize(1); - display.setCursor(0, 0); - display.println("UPDATING (OTA)"); - - display.setCursor(0, 20); - display.print("Progreso: "); - display.print(percent); - display.println("%"); - - display.drawRect(0, 35, 128, 12, SH110X_WHITE); - display.fillRect(0, 35, (128 * percent) / 100, 12, SH110X_WHITE); - - display.display(); + drawUpdateProgress("UPDATING (OTA)", progress / (total / 100)); }); ArduinoOTA.onError([](ota_error_t error) { @@ -576,6 +564,7 @@ void setupOTA() { void setup() { Serial.begin(115200); esp_log_level_set("*", ESP_LOG_NONE); + initFirmwareValidation(); // ¿firmware recién actualizado pendiente de validar? // Comprobar si debemos entrar en modo Setup. // "runSetup"=true → entrar en Setup (default true = dispositivo virgen) @@ -586,6 +575,7 @@ void setup() { bool goSetup = _p.getBool("runSetup", SETUP_NEEDED); // default: virgen → Setup _p.end(); if (goSetup) { + markFirmwareValid(); // llegar al portal de Setup basta para dar el firmware por bueno loadConfig(); // poblar cfg con NVS/defaults antes de mostrar el setup Wire.begin(33, 32); if (!display.begin(DIRECCION_I2C, true)) {} @@ -644,6 +634,8 @@ void setup() { webServer.on("/factory-reset", HTTP_POST, handleFactoryReset); webServer.on("/reboot", HTTP_POST, handleReboot); webServer.on("/wifi-scan", HTTP_GET, handleWifiScan); + webServer.on("/firmware", HTTP_GET, handleWebFirmware); + webServer.on("/firmware/upload", HTTP_POST, handleFirmwareUploadDone, handleFirmwareUpload); webServer.begin(); backendMutex = xSemaphoreCreateMutex(); @@ -655,6 +647,7 @@ void setup() { Serial.println(); Serial.println("============================================"); Serial.println(" PROXY MODBUS TCP v" + FIRMWARE_VERSION); + Serial.println(String(" Marca : ") + FW_MARKER); Serial.println("============================================"); if (cfg.useEthernet) { @@ -703,6 +696,10 @@ void loop() { return; } + bool networkUp = cfg.useEthernet ? (ETH.localIP() != IPAddress(0,0,0,0)) + : (WiFi.status() == WL_CONNECTED); + checkFirmwareValidation(networkUp); + checkButtons(); handleNavigation(); renderUI(); @@ -741,6 +738,7 @@ String buildNavBar(const String &activePage) { nav += navLink("/", L->nav_dashboard, "dashboard"); nav += navLink("/log", L->nav_log, "log"); nav += navLink("/config", L->nav_config, "config"); + nav += navLink("/firmware", L->nav_firmware, "firmware"); nav += "v" + FIRMWARE_VERSION + ""; nav += ""; return nav; @@ -1349,6 +1347,7 @@ void handleApiStatus() { uint32_t currentUptime = millis() / 1000; String json = "{"; + json += "\"firmware_version\":\"" + FIRMWARE_VERSION + "\","; json += "\"uptime_seconds\":" + String(currentUptime) + ","; json += "\"backend_state\":\"" + stateStr + "\","; json += "\"device_model\":\"" + emmaDeviceModel + "\","; diff --git a/src/web_update.hpp b/src/web_update.hpp new file mode 100644 index 0000000..e9fdd81 --- /dev/null +++ b/src/web_update.hpp @@ -0,0 +1,349 @@ +// ==================================================================== +// ACTUALIZACIÓN DE FIRMWARE DESDE LA WEB (issue #16) — pestaña /firmware +// +// Flujo: +// 1. El navegador lee el .bin elegido y lo verifica ANTES de subirlo: +// - cabecera de imagen de aplicación ESP32 (0xE9, chip ESP32); +// si es la imagen factory (empieza por 0xFF) avisa de usar firmware.bin +// - marca del proyecto FW_MARKER ("MBPXY-FW:;") +// - compara versión actual -> fichero (más nueva / igual / más antigua) +// - exige aceptar el aviso de responsabilidad +// 2. POST /firmware/upload: el ESP32 escribe el fichero por trozos en la +// partición OTA libre (Update). Vuelve a buscar la marca en el flujo +// y, si no aparece, aborta sin tocar el arranque (aunque alguien se +// salte el JavaScript). Update.end() valida la integridad (SHA-256). +// 3. Si todo es correcto, la partición nueva queda como arranque y reinicia. +// +// Rollback: con CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE (activo en el core +// 2.x), un firmware recién instalado arranca "pendiente de validar". +// verifyRollbackLater() evita que el core lo valide nada más arrancar; +// checkFirmwareValidation() lo valida cuando lleva FW_VALIDATE_AFTER_MS +// arrancado con red. Si se reinicia antes (cuelgue con reset, crash...), +// el bootloader vuelve solo a la versión anterior. Aplica también a las +// actualizaciones por ArduinoOTA. Un firmware que se cuelgue SIN reiniciar +// no se revierte (no hay watchdog). +// +// Límite: ni por web ni por OTA se puede cambiar la tabla de particiones +// ni el bootloader; eso requiere USB. +// ==================================================================== +#pragma once + +const uint32_t FW_VALIDATE_AFTER_MS = 30000; // tiempo con red antes de validar el firmware nuevo +const char FW_MARKER_PREFIX[] = "MBPXY-FW:"; +const uint8_t FW_MARKER_MAX_VER = 16; // longitud máxima de la versión en la marca + +bool firmwarePendingVerify = false; // true si este arranque es un firmware aún sin validar + +// El core Arduino valida el firmware nada más arrancar salvo que esto devuelva true. +// extern "C": sustituye a la versión débil del core (esp32-hal-misc.c, en C); +// sin ello el nombre C++ no coincide y el core seguiría validando al arrancar. +extern "C" bool verifyRollbackLater() { return true; } + +// Llamar una vez en setup(): detecta si este firmware está pendiente de validar +void initFirmwareValidation() { + const esp_partition_t* running = esp_ota_get_running_partition(); + esp_ota_img_states_t state; + firmwarePendingVerify = (esp_ota_get_state_partition(running, &state) == ESP_OK + && state == ESP_OTA_IMG_PENDING_VERIFY); + if (firmwarePendingVerify) Serial.println(" Firmware : NUEVO, pendiente de validar (rollback activo)"); +} + +// Valida el firmware en curso: tras ella ya no hay vuelta atrás automática +void markFirmwareValid() { + if (!firmwarePendingVerify) return; + esp_ota_mark_app_valid_cancel_rollback(); + firmwarePendingVerify = false; + Serial.println("[FW] Firmware validado. Rollback cancelado."); +} + +// Llamar en loop(): valida el firmware nuevo cuando lleva un rato funcionando con red +void checkFirmwareValidation(bool networkUp) { + if (firmwarePendingVerify && networkUp && millis() > FW_VALIDATE_AFTER_MS) markFirmwareValid(); +} + +// Estado de la imagen en ejecución, para la pestaña Firmware +const char* firmwareStateText() { + const esp_partition_t* running = esp_ota_get_running_partition(); + esp_ota_img_states_t state; + if (esp_ota_get_state_partition(running, &state) != ESP_OK) return L->fw_state_usb; + switch (state) { + case ESP_OTA_IMG_VALID: return L->fw_state_valid; + case ESP_OTA_IMG_PENDING_VERIFY: return L->fw_state_pending; + default: return L->fw_state_usb; + } +} + +// ==================================================================== +// Pausa del proxy y progreso en OLED — compartido con ArduinoOTA +// ==================================================================== +void beginFirmwareUpdate(const char* title) { + otaInProgress = true; + if (backendClient.connected()) backendClient.stop(); + if (proxyServer.hasClient()) proxyServer.available().stop(); + for (int i = 0; i < MAX_CLIENTS; i++) { + if (clients[i] && clients[i].connected()) clients[i].stop(); + } + + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SH110X_WHITE); + display.setCursor(0, 10); + display.println(title); + display.setCursor(0, 25); + display.println("Recibiendo firmware..."); + display.display(); +} + +void drawUpdateProgress(const char* title, int percent) { + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0, 0); + display.println(title); + + display.setCursor(0, 20); + display.print("Progreso: "); + display.print(percent); + display.println("%"); + + display.drawRect(0, 35, 128, 12, SH110X_WHITE); + display.fillRect(0, 35, (128 * percent) / 100, 12, SH110X_WHITE); + + display.display(); +} + +// ==================================================================== +// Búsqueda de la marca FW_MARKER en el flujo subido (byte a byte, sirve +// aunque la marca quede partida entre dos trozos) +// ==================================================================== +struct MarkerScanner { + uint8_t matched = 0; // bytes del prefijo ya coincidentes + bool inVersion = false; // leyendo la versión tras el prefijo + char version[FW_MARKER_MAX_VER + 1] = ""; + uint8_t verLen = 0; + bool found = false; + + void reset() { *this = MarkerScanner(); } + + void feed(const uint8_t* data, size_t len) { + const uint8_t prefixLen = sizeof(FW_MARKER_PREFIX) - 1; + for (size_t i = 0; i < len && !found; i++) { + char c = (char)data[i]; + if (inVersion) { + bool valid = isalnum((unsigned char)c) || c == '.' || c == '-'; + if (c == ';' && verLen > 0) { version[verLen] = '\0'; found = true; } + else if (valid && verLen < FW_MARKER_MAX_VER) { version[verLen++] = c; } + else { inVersion = false; verLen = 0; matched = (c == FW_MARKER_PREFIX[0]) ? 1 : 0; } + } else if (c == FW_MARKER_PREFIX[matched]) { + if (++matched == prefixLen) { inVersion = true; verLen = 0; matched = 0; } + } else { + matched = (c == FW_MARKER_PREFIX[0]) ? 1 : 0; + } + } + } +}; + +MarkerScanner fwScanner; +String fwUploadError = ""; // "" = sin error +int fwLastPercent = -1; + +// ==================================================================== +// POST /firmware/upload — recepción por trozos +// ==================================================================== +void handleFirmwareUpload() { + HTTPUpload& up = webServer.upload(); + + if (up.status == UPLOAD_FILE_START) { + fwScanner.reset(); + fwUploadError = ""; + fwLastPercent = -1; + Serial.printf("[FW] Subida web: %s\n", up.filename.c_str()); + beginFirmwareUpdate("UPDATE WEB"); + if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) fwUploadError = Update.errorString(); + + } else if (up.status == UPLOAD_FILE_WRITE) { + if (fwUploadError.length() > 0) return; + fwScanner.feed(up.buf, up.currentSize); + if (Update.write(up.buf, up.currentSize) != up.currentSize) { + fwUploadError = Update.errorString(); + Update.abort(); + return; + } + int total = webServer.clientContentLength(); + int percent = total > 0 ? (int)((uint64_t)up.totalSize * 100 / total) : 0; + if (percent != fwLastPercent) { fwLastPercent = percent; drawUpdateProgress("UPDATE WEB", percent); } + + } else if (up.status == UPLOAD_FILE_END) { + if (fwUploadError.length() > 0) return; + if (!fwScanner.found) { + // No es un firmware de este proyecto: se descarta, el arranque no cambia + Update.abort(); + fwUploadError = L->fw_err_no_marker; + } else if (!Update.end(true)) { + fwUploadError = Update.errorString(); + } else { + Serial.printf("[FW] OK: %u bytes, version %s\n", (unsigned)up.totalSize, fwScanner.version); + } + + } else if (up.status == UPLOAD_FILE_ABORTED) { + Update.abort(); + fwUploadError = L->fw_err_aborted; + } +} + +// Respuesta final del POST (se llama tras recibir todo el fichero) +void handleFirmwareUploadDone() { + bool ok = fwUploadError.length() == 0 && Update.isFinished(); + String json = "{\"ok\":"; + json += ok ? "true" : "false"; + json += ",\"version\":\""; + json += fwScanner.version; + json += "\",\"error\":\""; + String err = fwUploadError; + err.replace("\"", "'"); + json += err; + json += "\"}"; + webServer.sendHeader("Connection", "close"); + webServer.send(ok ? 200 : 400, "application/json", json); + + if (ok) { + display.clearDisplay(); + display.setTextSize(2); + display.setCursor(12, 25); + display.println("SUCCESS!!"); + display.display(); + delay(1500); + ESP.restart(); + } + + Serial.printf("[FW] Error en la subida: %s\n", fwUploadError.c_str()); + otaInProgress = false; // el proxy se reanuda con el firmware actual +} + +// ==================================================================== +// GET /firmware — pestaña Firmware +// ==================================================================== +void handleWebFirmware() { + const esp_partition_t* running = esp_ota_get_running_partition(); + + String html = ""; + html += ""; + html += ""; + html += L->fw_title; + html += ""); + html += buildNavBar("firmware"); + html += "

"; + html += L->fw_h1; + html += "

"; + + // ---- Versión actual ---- + html += "

"; + html += L->fw_h3_current; + html += "

"; + html += "
"; + html += L->fw_version; + html += "" + FIRMWARE_VERSION + "
"; + html += L->fw_partition; + html += "" + String(running ? running->label : "?") + "
"; + html += L->fw_state; + html += ""; + html += firmwareStateText(); + html += "
"; + html += L->fw_space; + html += "" + String(ESP.getFreeSketchSpace() / 1024) + " KB
"; + + // ---- Actualizar ---- + html += "

"; + html += L->fw_h3_update; + html += "

"; + html += L->fw_disclaimer; + html += "

"; + html += L->fw_hint; + html += "

"; + html += ""; + html += "
"; + html += ""; + html += "
"; + html += "
"; + + // ---- JavaScript: verificación local + subida con progreso ---- + // T: textos i18n usados por el script + html += ""; + + webServer.send(200, "text/html", html); +}