From e78344d322528513222909a6a7ca114b075b8c42 Mon Sep 17 00:00:00 2001 From: Guybrush Date: Mon, 2 Nov 2026 13:22:54 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20configurable=20startup=20Test=20Tar?= =?UTF-8?q?get=20(EMMA=20/=20SUN2000=20/=20Custom)=20=E2=80=94=20closes=20?= =?UTF-8?q?#12,=20closes=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot Modbus liveness check is no longer hard-wired to the EMMA (MODBUS_FIXED_ID / MODBUS_TEST_REG). A "Test Target" subsection inside "Modbus TCP target" (both /config and the Setup portal) lets the user: - pick a device profile: EMMA, SUN2000 or Custom - enable/disable the startup check (the ICMP ping toll always stays) - set the 4 request fields: Unit ID, function code, start register and register count. Presets lock everything but the Unit ID; Custom makes all of them free and shows a responsibility warning. Values persist in NVS; when missing (upgrade from 6.0-0) they default to the EMMA values, so behaviour is unchanged until modified. The check now accepts the requested function's normal reply or its exception (FC | 0x80). Reply text parsing is shared and bounds-checked (the menu test and scanner could read past the PDU buffer). The scanner also probes ID 255, which the loop skipped. /api/status exposes the target settings. Firmware bumped to 6.1-0. Co-Authored-By: Claude Opus 5.5 --- README.en.md | 26 ++- README.md | 26 ++- src/i18n/de.hpp | 15 ++ src/i18n/en.hpp | 15 ++ src/i18n/es.hpp | 15 ++ src/i18n/strings.hpp | 16 ++ src/proxy_operativo.hpp | 390 ++++++++++++++++++++++++++++++++-------- src/setup_mode.hpp | 11 +- 8 files changed, 428 insertions(+), 86 deletions(-) diff --git a/README.en.md b/README.en.md index c0dbc72..aad6e69 100644 --- a/README.en.md +++ b/README.en.md @@ -1,4 +1,4 @@ -# 🔋 ESP32 Modbus TCP Proxy & Multiplexer for Huawei EMMA & SUN2000 (v6.0-0) +# 🔋 ESP32 Modbus TCP Proxy & Multiplexer for Huawei EMMA & SUN2000 (v6.1-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,14 @@ SERVIDORES = { ## 🚀 Version History and Changelog -### 🟢 Version 6.0-0 (Current) +### 🟢 Version 6.1-0 (Current) + +* **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. +* **Per-function validation:** The check accepts the normal reply of the requested function or its exception (`FC | 0x80`), and parsing the reply text is bounds-checked. +* **`/api/status`** adds `target_profile`, `startup_check`, `test_unit_id`, `test_function`, `test_register` and `test_reg_count`. + +### 🔵 Version 6.0-0 * **Multilingual web interface (i18n):** The proxy's web interface now supports multiple languages dynamically. The user can select the language from the Settings page without needing to recompile the firmware. The chosen language is saved to the ESP32's NVS and persists across reboots. * **Included languages:** Spanish (default), English, and German. All three languages cover 100% of the interface texts: setup portal, dashboard, Modbus log, settings page, shutdown page, and reboot page. @@ -184,8 +191,13 @@ The complete behavior of the board and network stack is managed via parameteriza * `USE_ETHERNET` (`const bool`): `false` to use the ESP32's internal WiFi antenna. `true` to route all traffic over a physical cable using the native Ethernet stack (e.g. LAN8720). * `USE_DHCP` (`const bool`): `true` to request a dynamic IP from the home router. `false` to force the static rescue IP configured in the firmware. * `ROTATE_SCREEN` (`const bool`): `true` applies a physical 180-degree rotation to the SH1106 OLED display. `false` keeps the standard orientation. -* `MODBUS_FIXED_ID` (`const uint8_t`): Unit ID used for the boot test and initial priming (default `0`, corresponding to the EMMA). -* `MODBUS_TEST_REG` (`const uint16_t`): Modbus register address queried during the boot liveness test (`30000`, Model Name ASCII). +* **Target device / startup check** (formerly `MODBUS_FIXED_ID` and `MODBUS_TEST_REG`, now configurable in the **Test Target** subsection of "Modbus TCP target", both in `/config` and in the Setup portal — issue #12): + * **Device type:** `EMMA`, `SUN2000` or `Custom`. Each profile is a preset of the 4 request fields (`TARGET_PROFILES` table). + * **Connection check on boot:** enabled by default. When disabled, Toll 2 is skipped and the gateway opens as soon as the ping succeeds (the ping to the target IP always stays). + * **The 4 request fields:** Unit ID, function code, start register and number of registers. EMMA = `0 / FC03 / 30000 / 15`, SUN2000 = `1 / FC03 / 30000 / 15`. With EMMA and SUN2000 only the Unit ID is editable; with Custom, all 4 are free (function 1–127, register and quantity 0–65535) and the web shows a warning that the values are the user's responsibility. The check passes with that function's normal reply or its exception (FC | 0x80). Beware: a write function would modify the device on every attempt. + * The Unit ID only affects the check and the menu diagnostics; client requests are forwarded with their own Unit ID. + * **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-`). @@ -202,7 +214,7 @@ To guarantee that Home Assistant or any secondary client never triggers a ban on ### Toll 2: The Atomic Modbus Priming (Layer 4 / Layer 7) -* **The surgical injection:** Opens a single TCP socket to Huawei's port 502 and injects the exact hexadecimal frame requesting 15 registers starting from address 30000. +* **The surgical injection:** Opens a single TCP socket to the configured Modbus port and injects the request configured in Test Target (default FC03, 15 registers from 30000 to Unit ID 0, EMMA). * **Why 15 registers (Atomic Integrity):** Huawei requires atomic integrity when reading its text strings. The model name at register 30000 occupies exactly 15 registers (30 bytes). If a client requests a shorter length (e.g. 10 registers), Huawei's firmware considers it an attempt to fragment an indivisible variable and responds with data error code `0x03`. Requesting exactly 15 results in clean communication. * **Intelligent Liveness Criterion (Exception Detector):** The proxy analyzes the returned function code. If it receives a success (`0x03` or `0x04`), it extracts the text bytes (e.g. `SmartHEMS`) and releases the gateway. But if the device responds with a legitimate Huawei Modbus exception code (`0x83` or `0x84`), the proxy **considers the toll passed anyway**. This is because a device responding with an official Modbus exception is an active device whose firewall is listening correctly. @@ -359,7 +371,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.0-0` (no password). +1. Creates an **open WiFi access point** named `modbusproxy-6.1-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: @@ -386,7 +398,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.0-0` AP. +4. The device restarts and brings up the `modbusproxy-6.1-0` AP. --- diff --git a/README.md b/README.md index 67fed39..5cc54f4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🔋 ESP32 Modbus TCP Proxy & Multiplexer for Huawei EMMA & SUN2000 (v6.0-0) +# 🔋 ESP32 Modbus TCP Proxy & Multiplexer for Huawei EMMA & SUN2000 (v6.1-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,14 @@ SERVIDORES = { ## 🚀 Historial de Versiones y Novedades -### 🟢 Versión 6.0-0 (Actual) +### 🟢 Versión 6.1-0 (Actual) + +* **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. +* **Validación por función:** El test acepta la respuesta normal de la función pedida o su excepción (`FC | 0x80`), y la lectura del texto de respuesta comprueba los límites del buffer. +* **`/api/status`** añade `target_profile`, `startup_check`, `test_unit_id`, `test_function`, `test_register` y `test_reg_count`. + +### 🔵 Versión 6.0-0 * **Interfaz web multilingüe (i18n):** La interfaz web del proxy ahora soporta múltiples idiomas de forma dinámica. El usuario puede seleccionar el idioma en la página de Configuración sin necesidad de recompilar el firmware. El idioma elegido se guarda en la NVS del ESP32 y persiste entre reinicios. * **Idiomas incluidos:** Español (por defecto), English e Deutsch. Los tres idiomas cubren el 100 % de los textos de la interfaz: portal de setup, dashboard, log Modbus, página de configuración, página de apagado y página de reinicio. @@ -187,8 +194,13 @@ El comportamiento completo de la placa y la pila de red se administra mediante v * `USE_ETHERNET` (`const bool`): `false` para usar la antena interna WiFi del ESP32. `true` para enrutar todo el tráfico por el cable físico usando la pila Ethernet nativa (ej: LAN8720). * `USE_DHCP` (`const bool`): `true` para solicitar una IP dinámica al router de la vivienda. `false` para forzar la IP estática de rescate configurada en el firmware. * `ROTATE_SCREEN` (`const bool`): `true` aplica un giro físico de 180 grados a la visualización del OLED SH1106. `false` mantiene la orientación estándar. -* `MODBUS_FIXED_ID` (`const uint8_t`): Identificador de Unidad (Unit ID) utilizado para el test de arranque y primado inicial (Por defecto `0`, correspondiente a la EMMA). -* `MODBUS_TEST_REG` (`const uint16_t`): Dirección del registro Modbus consultado durante el test de vida del arranque (`30000`, Model Name ASCII). +* **Dispositivo destino / test de arranque** (antes `MODBUS_FIXED_ID` y `MODBUS_TEST_REG`, ahora configurables en la subsección **Test Target** de "Destino Modbus TCP", tanto en `/config` como en el portal de Setup — issue #12): + * **Tipo de dispositivo:** `EMMA`, `SUN2000` o `Personalizado`. Cada perfil es un preset de los 4 datos de la petición (tabla `TARGET_PROFILES`). + * **Test de conexión al arrancar:** activado por defecto. Si se desactiva, se salta el Peaje 2 y la pasarela se abre en cuanto responde el ping (el ping, a la IP destino, se mantiene siempre). + * **Los 4 datos de la petición:** Unit ID, código de función, registro inicial y número de registros. EMMA = `0 / FC03 / 30000 / 15`, SUN2000 = `1 / FC03 / 30000 / 15`. En EMMA y SUN2000 solo el Unit ID es editable; en Personalizado, los 4 son libres (función 1–127, registro y cantidad 0–65535) y la web muestra un aviso de que los valores van bajo responsabilidad del usuario. El test pasa con la respuesta normal de esa función o con su excepción (FC | 0x80). Ojo: una función de escritura modificaría el equipo en cada intento. + * El Unit ID solo afecta al test y a los diagnósticos del menú; las peticiones de los clientes se reenvían con su propio Unit ID. + * **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-`). @@ -205,7 +217,7 @@ Para garantizar que Home Assistant o cualquier cliente secundario jamás provoqu ### Peaje 2: El Primado Atómico Modbus (Capa 4 / Capa 7) -* **La inyección quirúrgica:** Abre un único socket TCP hacia el puerto 502 de Huawei e inyecta la trama hexadecimal exacta pidiendo leer 15 registros desde la dirección 30000. +* **La inyección quirúrgica:** Abre un único socket TCP hacia el puerto Modbus configurado e inyecta la petición configurada en Test Target (por defecto FC03, 15 registros desde el 30000 al Unit ID 0, EMMA). * **El porqué de los 15 registros (Integridad Atómica):** Huawei exige integridad atómica en la lectura de sus cadenas de texto (Strings). El nombre del modelo en el registro 30000 ocupa exactamente 15 registros (30 bytes). Si un cliente le pide una longitud menor (como 10 registros), el firmware de Huawei considera que se está fragmentando una variable indivisible y responde con un código de error de datos `0x03`. Al pedir la longitud exacta de 15, la comunicación se realiza de forma limpia. * **Criterio de Vida Inteligente (Detector de Excepciones):** El proxy analiza el código de función devuelto. Si recibe un éxito (`0x03` o `0x04`), extrae los bytes del texto (ej: `SmartHEMS`) y libera la pasarela. Pero si el equipo responde con un código de excepción de error legítimo de Huawei (`0x83` o `0x84`), el proxy **da por superado el peaje igualmente**. Esto es debido a que un dispositivo que responde con una excepción Modbus oficial es un equipo activo, libre y cuyo cortafuegos está escuchando perfectamente. @@ -364,7 +376,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.0-0` (sin contraseña). +1. Levanta un **punto de acceso WiFi abierto** con el nombre `modbusproxy-6.1-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: @@ -391,7 +403,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.0-0`. +4. El dispositivo reinicia y levanta el AP `modbusproxy-6.1-0`. --- diff --git a/src/i18n/de.hpp b/src/i18n/de.hpp index 758e5b1..db1d518 100644 --- a/src/i18n/de.hpp +++ b/src/i18n/de.hpp @@ -162,4 +162,19 @@ static const LangStrings LANG_DE = { .log_col_exc = "Ausnahme", .log_col_req_hex = "Anfrage-Bytes (HEX)", .log_col_resp_hex = "Antwort-Bytes (HEX)", + + .tgt_h3 = "Test Target", + .tgt_note = "Beim Start pingt der Proxy die Ziel-IP an und sendet dann diesen Modbus-Testlesezugriff; Clients werden abgewiesen, bis das Ziel antwortet. Die Unit-ID wird nur für diesen Test verwendet: Client-Anfragen werden mit ihrer eigenen Unit-ID weitergeleitet.", + .tgt_profile = "Gerätetyp", + .tgt_custom = "Benutzerdefiniert", + .tgt_custom_warn = "⚠ Achtung: im benutzerdefinierten Modus liegen diese Werte in Ihrer Verantwortung. Stellen Sie sicher, dass Sie wissen, was Sie eingeben: eine falsche Anfrage kann den Start des Proxys blockieren oder, mit Schreibfunktionen, das Zielgerät verändern.", + .tgt_check = "Verbindungstest beim Start", + .tgt_check_on = "Aktiviert", + .tgt_check_off = "Deaktiviert (nach Ping öffnen)", + .tgt_unit_id = "Unit-ID für den Test", + .tgt_unit_note = "EMMA: 0. SUN2000 direkt/SDongle: 1. Wechselrichter hinter EMMA/SmartLogger: ihre Bus-ID.", + .tgt_func = "Modbus-Funktion", + .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", }; diff --git a/src/i18n/en.hpp b/src/i18n/en.hpp index c569f39..0bf134b 100644 --- a/src/i18n/en.hpp +++ b/src/i18n/en.hpp @@ -162,4 +162,19 @@ static const LangStrings LANG_EN = { .log_col_exc = "Exception", .log_col_req_hex = "Request Bytes (HEX)", .log_col_resp_hex = "Response Bytes (HEX)", + + .tgt_h3 = "Test Target", + .tgt_note = "On boot, the proxy pings the target IP and then sends this test Modbus read; clients are refused until the target answers. The Unit ID is only used for this check: client requests are forwarded with their own Unit ID.", + .tgt_profile = "Device type", + .tgt_custom = "Custom", + .tgt_custom_warn = "⚠ Watch out: in Custom mode these values are your responsibility. Make sure you know what you enter: a wrong request can block the proxy start-up or, with write functions, modify the target device.", + .tgt_check = "Connection check on boot", + .tgt_check_on = "Enabled", + .tgt_check_off = "Disabled (open after ping)", + .tgt_unit_id = "Check Unit ID", + .tgt_unit_note = "EMMA: 0. SUN2000 direct/SDongle: 1. Inverters behind EMMA/SmartLogger: their bus ID.", + .tgt_func = "Modbus function", + .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", }; diff --git a/src/i18n/es.hpp b/src/i18n/es.hpp index 4e30f87..c552d9c 100644 --- a/src/i18n/es.hpp +++ b/src/i18n/es.hpp @@ -162,4 +162,19 @@ static const LangStrings LANG_ES = { .log_col_exc = "Excepción", .log_col_req_hex = "Bytes Request (HEX)", .log_col_resp_hex = "Bytes Response (HEX)", + + .tgt_h3 = "Test Target", + .tgt_note = "Al arrancar, el proxy hace ping a la IP destino y después esta lectura Modbus de prueba; no acepta clientes hasta que el destino responde. El Unit ID solo se usa en este test: las peticiones de los clientes se reenvían con su propio Unit ID.", + .tgt_profile = "Tipo de dispositivo", + .tgt_custom = "Personalizado", + .tgt_custom_warn = "⚠ Ojo, cuidado: en modo Personalizado los valores van bajo tu responsabilidad. Asegúrate de que sabes lo que pones: una petición errónea puede bloquear el arranque del proxy o, con funciones de escritura, modificar el equipo destino.", + .tgt_check = "Test de conexión al arrancar", + .tgt_check_on = "Activado", + .tgt_check_off = "Desactivado (abrir tras el ping)", + .tgt_unit_id = "Unit ID del test", + .tgt_unit_note = "EMMA: 0. SUN2000 directo/SDongle: 1. Inversores detrás de EMMA/SmartLogger: su ID en el bus.", + .tgt_func = "Función Modbus", + .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", }; diff --git a/src/i18n/strings.hpp b/src/i18n/strings.hpp index a210468..cc30cbc 100644 --- a/src/i18n/strings.hpp +++ b/src/i18n/strings.hpp @@ -167,4 +167,20 @@ struct LangStrings { const char* log_col_exc; const char* log_col_req_hex; const char* log_col_resp_hex; + + // --- Dispositivo destino / test de arranque (/config y Setup) --- + const char* tgt_h3; + const char* tgt_note; + const char* tgt_profile; + const char* tgt_custom; // etiqueta de la opcion "Custom" del desplegable + const char* tgt_custom_warn; // aviso de responsabilidad al elegir Custom + const char* tgt_check; + const char* tgt_check_on; + const char* tgt_check_off; + const char* tgt_unit_id; + const char* tgt_unit_note; + const char* tgt_func; + const char* tgt_func_note; + const char* tgt_register; + const char* tgt_reg_count; }; diff --git a/src/proxy_operativo.hpp b/src/proxy_operativo.hpp index 5d75964..061a5f0 100644 --- a/src/proxy_operativo.hpp +++ b/src/proxy_operativo.hpp @@ -20,6 +20,78 @@ // ==================================================================== Preferences prefs; +// ==================================================================== +// DISPOSITIVO DESTINO Y TEST DE CONEXIÓN DE ARRANQUE (issue #12) +// +// Al arrancar, el proxy NO acepta clientes externos (Home Assistant...) +// hasta superar dos "peajes", ambos contra la IP de "Destino Modbus TCP" +// (cfg.modbusIP / cfg.modbusPort): +// 1. Ping ICMP a cfg.modbusIP (reintento cada 30 s hasta que responde; +// una vez superado no se repite). Siempre activo. También se puede +// lanzar a mano desde el menú OLED ("Test Ping"). +// 2. Test Modbus (opcional, cfg.startupCheck), reintento cada 10 s. +// Petición definida por 4 datos, todos persistentes en NVS: +// - cfg.testUnitId Unit ID al que se pregunta +// - cfg.testFunc Código de función (1..127). Default 0x03. +// - cfg.testRegister Registro inicial (campo "dirección" de la PDU) +// - cfg.testRegCount Número de registros (campo "cantidad", 16 bits) +// Trama: [MBAP][Unit ID][FC][registro 2B][cantidad 2B]. Es el formato +// de las lecturas FC01..FC04. En Custom se admite cualquier FC: con +// FC05/06 esos 4 bytes serían dirección + VALOR A ESCRIBIR, y el +// equipo se modificaría en cada intento. Es responsabilidad de quien +// lo configura. +// Se da por superado con cualquier respuesta Modbus válida: +// a) respuesta normal (misma FC): con FC03/FC04 el texto ASCII leído +// (cortado en el primer espacio) se muestra como modelo en OLED y +// web; con otras FC se muestra "FCnn-OK"; o +// b) excepción Modbus (FC | 0x80): el destino tiene el stack Modbus +// vivo aunque rechace la petición. +// Con startupCheck = false el peaje 2 se salta y la pasarela se abre +// en cuanto responde el ping. +// +// IMPORTANTE: el Unit ID configurado solo afecta al test de arranque y a +// los diagnósticos del menú. Las peticiones de los clientes se reenvían +// tal cual, con su propio Unit ID, así que un cliente puede hablar con +// varios equipos (EMMA, inversores...) a través del proxy. +// +// Perfiles (TARGET_PROFILES): presets de los 4 datos. Al elegir EMMA o +// SUN2000 se cargan sus valores; el Unit ID sigue siendo editable y los +// otros 3 quedan fijos. CUSTOM permite editar los 4 libremente. +// En la web: subsección "Test Target" dentro de "Destino Modbus TCP". +// +// Persistencia: si las claves no existen en NVS (primer arranque o +// actualización desde un firmware anterior) se inicializan con los +// valores de la EMMA, que es lo que hacía el firmware hasta ahora. +// La NVS sobrevive a reinicios y a flasheos (USB u OTA); solo se pierde +// con un borrado completo de flash (pio run -t erase). +// ==================================================================== +enum TargetProfileId : uint8_t { + TARGET_EMMA = 0, + TARGET_SUN2000 = 1, + TARGET_CUSTOM = 2, +}; + +struct TargetProfile { + const char* name; + uint8_t unitId; // Unit ID sugerido (editable) + uint8_t func; // Código de función (0x03 holding / 0x04 input...) + uint16_t reg; // Registro inicial + uint16_t count; // Número de registros +}; + +// Indexado por TargetProfileId. Para añadir un perfil: nueva entrada aquí +// (antes de Custom) y nuevo valor en el enum. +// Huawei exige leer las cadenas completas: el Model name de 30000 son +// 15 registros (30 bytes). Pedir menos devuelve excepción 0x03. +const TargetProfile TARGET_PROFILES[] = { + // nombre ID func registro nº regs + { "EMMA", 0, 0x03, 30000, 15 }, // EMMA: Unit ID 0, reg 30000 = Model name (ASCII) + { "SUN2000", 1, 0x03, 30000, 15 }, // Inversor SUN2000 (directo / SDongle): Unit ID 1 habitual + { "Custom", 0, 0x03, 30000, 15 }, // Solo valores iniciales del formulario +}; +const uint8_t TARGET_PROFILE_COUNT = sizeof(TARGET_PROFILES) / sizeof(TARGET_PROFILES[0]); + + struct AppConfig { bool useEthernet = false; bool useDHCP = false; @@ -34,10 +106,65 @@ struct AppConfig { char dns[16] = DEFAULT_DNS; // <- secrets.h char otaPassword[32] = DEFAULT_OTA_PASSWORD; // <- secrets.h char lang[3] = "es"; + + // --- Dispositivo destino / test de arranque (ver bloque de arriba) --- + // Defaults = EMMA (comportamiento del firmware anterior) + uint8_t targetProfile = TARGET_EMMA; // TargetProfileId + bool startupCheck = true; // false = saltar el peaje 2 + uint8_t testUnitId = 0; // Unit ID + uint8_t testFunc = 0x03; // Código de función (1..127) + uint16_t testRegister = 30000; // Registro inicial + uint16_t testRegCount = 15; // Número de registros }; AppConfig cfg; +// Trama Modbus TCP del test con la función, registro y nº de registros de cfg +void buildTestRequest(uint8_t unitId, uint8_t frame[12]) { + const uint8_t f[12] = { 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, unitId, cfg.testFunc, + (uint8_t)(cfg.testRegister >> 8), (uint8_t)(cfg.testRegister & 0xFF), + (uint8_t)(cfg.testRegCount >> 8), (uint8_t)(cfg.testRegCount & 0xFF) }; + memcpy(frame, f, 12); +} + +// Clasificación de la respuesta: normal = misma FC; excepción = FC | 0x80 +bool isTestReply(uint8_t fc) { return fc == cfg.testFunc; } +bool isTestException(uint8_t fc) { return fc == (uint8_t)(cfg.testFunc | 0x80); } + +// Texto ASCII de una respuesta FC03/FC04 (no imprimibles -> espacio). +// "" si la FC no devuelve registros o el byte count no cabe en la PDU. +String testReplyText(const uint8_t* pdu, uint16_t pduLen) { + if ((cfg.testFunc != 0x03 && cfg.testFunc != 0x04) || pduLen < 2) return ""; + uint8_t n = pdu[1]; + if (n == 0 || n > pduLen - 2) return ""; + String out; + out.reserve(n); + for (uint8_t i = 0; i < n; i++) { + char c = (char)pdu[2 + i]; + out += (c >= 32 && c <= 126) ? c : ' '; + } + return out; +} + +// Modelo a mostrar tras una respuesta normal del test +String testReplyLabel(const uint8_t* pdu, uint16_t pduLen) { + String txt = testReplyText(pdu, pduLen); + txt.trim(); + if (txt.length() > 0) return txt; + if (cfg.testFunc == 0x03 || cfg.testFunc == 0x04) return "UNKNOWN"; + return "FC" + String(cfg.testFunc) + "-OK"; +} + +// Persistencia de los campos de destino (compartida por /config y Setup) +void saveTargetConfig(Preferences& p) { + p.putUChar ("tgtProf", cfg.targetProfile); + p.putBool ("tstOn", cfg.startupCheck); + p.putUChar ("tstId", cfg.testUnitId); + p.putUChar ("tstFc", cfg.testFunc); + p.putUShort("tstReg", cfg.testRegister); + p.putUShort("tstCnt", cfg.testRegCount); +} + // Cambia a true para que el proximo arranque entre en modo Setup. // Solo afecta a dispositivos sin NVS configurada (virgen o borrada). // Para forzar Setup en un dispositivo ya configurado usa Factory Reset desde /config. @@ -58,6 +185,16 @@ void loadConfig() { prefs.getString("dns", cfg.dns, sizeof(cfg.dns)); prefs.getString("otaPass", cfg.otaPassword, sizeof(cfg.otaPassword)); prefs.getString("lang", cfg.lang, sizeof(cfg.lang)); + // Test de arranque: si la clave no existe (firmware anterior) -> valores EMMA + const TargetProfile& emma = TARGET_PROFILES[TARGET_EMMA]; + cfg.targetProfile = prefs.getUChar ("tgtProf", TARGET_EMMA); + cfg.startupCheck = prefs.getBool ("tstOn", true); + cfg.testUnitId = prefs.getUChar ("tstId", emma.unitId); + cfg.testFunc = prefs.getUChar ("tstFc", emma.func); + cfg.testRegister = prefs.getUShort("tstReg", emma.reg); + cfg.testRegCount = prefs.getUShort("tstCnt", emma.count); + if (cfg.targetProfile >= TARGET_PROFILE_COUNT) cfg.targetProfile = TARGET_EMMA; + if (cfg.testFunc < 1 || cfg.testFunc > 127) cfg.testFunc = emma.func; prefs.end(); i18nInit(cfg.lang); } @@ -77,6 +214,7 @@ void saveConfig() { prefs.putString("dns", cfg.dns); prefs.putString("otaPass", cfg.otaPassword); prefs.putString("lang", cfg.lang); + saveTargetConfig(prefs); prefs.putBool("runSetup", false); prefs.end(); } @@ -93,11 +231,12 @@ const int BOTON_BACK = 14; const int BOTON_MAS = 15; const int BOTON_MENOS = 39; -const uint8_t MODBUS_FIXED_ID = 0; -const uint16_t MODBUS_TEST_REG = 30000; -const uint32_t RECONNECT_DELAY = 100; +// MODBUS_FIXED_ID / MODBUS_TEST_REG ya no son constantes: ahora son +// configurables desde la web (cfg.testUnitId, cfg.testRegister...). +// 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.0-0"; +const String FIRMWARE_VERSION = "6.1-0"; // ==================================================================== // Máquina de estados extendida @@ -233,6 +372,133 @@ void handleReboot(); void handleWifiScan(); String buildNavBar(const String &activePage); +// ==================================================================== +// SUBSECCIÓN "TEST TARGET" — compartida por /config y Setup. +// Se inserta dentro de "Destino Modbus TCP", justo tras IP y puerto. +// lblClass: clase CSS de los