From 34d2d9caa0cbc11682bb6f9e7e8795519fb0b999 Mon Sep 17 00:00:00 2001 From: Tony Date: Mon, 4 May 2026 14:34:05 +0800 Subject: [PATCH] Add serial configuration support to Gateway Modbus - Introduced GatewayModbusSerialConfig structure to encapsulate serial communication settings. - Added clamping functions for integer and size values to ensure valid configuration ranges. - Updated GatewayModbusConfigFromValue to parse serial configuration from JSON input. - Implemented transport type checking functions for TCP, RTU, ASCII, and Serial. - Enhanced GatewayModbusConfigToValue to include serial configuration in output. Signed-off-by: Tony --- MODBUS.md | 361 ++++++++ README.md | 8 +- apps/gateway/main/Kconfig.projbuild | 98 +- apps/gateway/main/app_main.cpp | 109 ++- apps/gateway/sdkconfig | 5 + components/gateway_bridge/CMakeLists.txt | 1 + .../gateway_bridge/include/gateway_bridge.hpp | 5 + .../gateway_bridge/src/gateway_bridge.cpp | 870 ++++++++++++++---- .../gateway_modbus/include/gateway_modbus.hpp | 29 + .../gateway_modbus/src/gateway_modbus.cpp | 82 ++ 10 files changed, 1364 insertions(+), 204 deletions(-) create mode 100644 MODBUS.md diff --git a/MODBUS.md b/MODBUS.md new file mode 100644 index 0000000..402a8a5 --- /dev/null +++ b/MODBUS.md @@ -0,0 +1,361 @@ +# Gateway Modbus + +The native gateway exposes each DALI channel as a Modbus server. Supported transports are: + +- `tcp-server`: Modbus TCP server, default TCP port `1502`. +- `rtu-server`: Modbus RTU slave/server on an ESP-IDF UART. +- `ascii-server`: Modbus ASCII slave/server on an ESP-IDF UART. + +The generated Modbus map is identical for TCP, RTU, and ASCII. Only the frame transport changes. + +## Configuration + +Compile-time defaults live in `apps/gateway/main/Kconfig.projbuild`: + +- `GATEWAY_MODBUS_DEFAULT_TRANSPORT_TCP` +- `GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU` +- `GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII` +- `GATEWAY_MODBUS_TCP_PORT` +- `GATEWAY_MODBUS_UNIT_ID` +- `GATEWAY_MODBUS_SERIAL_UART_PORT` +- `GATEWAY_MODBUS_SERIAL_TX_PIN` +- `GATEWAY_MODBUS_SERIAL_RX_PIN` +- `GATEWAY_MODBUS_SERIAL_BAUDRATE` +- `GATEWAY_MODBUS_SERIAL_RESPONSE_TIMEOUT_MS` +- `GATEWAY_MODBUS_SERIAL_RS485_ENABLED` +- `GATEWAY_MODBUS_SERIAL_RS485_DE_PIN` +- `GATEWAY_MODBUS_ALLOW_UART0` + +UART0 is reserved for the ESP-IDF console by default. Select `GATEWAY_MODBUS_ALLOW_UART0` only when the console has been moved away from UART0 or the deployment intentionally repurposes it. The app validates the Kconfig default UART against DALI serial PHY assignments at boot. + +Runtime bridge config keeps the existing top-level `modbus` object. The serial fields are nested under `serial`: + +```json +{ + "modbus": { + "transport": "rtu-server", + "unitID": 1, + "port": 1502, + "serial": { + "uartPort": 1, + "txPin": 17, + "rxPin": 18, + "baudrate": 9600, + "dataBits": 8, + "parity": "none", + "stopBits": 1, + "rxBufferBytes": 512, + "txBufferBytes": 512, + "responseTimeoutMs": 20, + "interFrameGapUs": 4000, + "rs485": { + "enabled": true, + "dePin": 16 + } + } + } +} +``` + +`unitID` accepts aliases `unitId` and `unit_id`. A configured `unitID` of `0` keeps the gateway compatibility behavior where incoming unit ids are accepted as a wildcard. Incoming serial requests addressed to unit id `0` are treated as Modbus broadcast and do not generate a response. + +## UART Management Commands + +When a Modbus serial transport is active, the UART also accepts management lines before Modbus frame parsing. A management line starts with `@DALIGW `, contains JSON, and ends with newline. + +Examples: + +```text +@DALIGW {"action":"modbus_status","gw":3} +@DALIGW {"action":"modbus_stop","gw":3} +@DALIGW {"action":"modbus_config","gw":3,"modbus":{"transport":"ascii-server","unitID":1,"serial":{"uartPort":1,"txPin":17,"rxPin":18,"baudrate":9600,"rs485":{"enabled":true,"dePin":16}}}} +``` + +Responses are newline-terminated `@DALIGW` JSON lines. A successful `modbus_config` command is saved to NVS and restarts the Modbus transport after the response is written. + +HTTP still supports the bridge routes: + +- `GET /bridge?action=status&gw=N` +- `GET /bridge?action=config&gw=N` +- `GET /bridge?action=modbus&gw=N` +- `POST /bridge?action=config&gw=N` +- `POST /bridge?action=modbus_start&gw=N` +- `POST /bridge?action=modbus_stop&gw=N` + +## Function Codes + +Supported function codes are: + +| Code | Name | Map space | +| --- | --- | --- | +| `0x01` | Read coils | Coils | +| `0x02` | Read discrete inputs | Discrete inputs | +| `0x03` | Read holding registers | Holding registers | +| `0x04` | Read input registers | Input registers | +| `0x05` | Write single coil | Coils | +| `0x06` | Write single holding register | Holding registers | +| `0x0F` | Write multiple coils | Coils | +| `0x10` | Write multiple holding registers | Holding registers | + +Limits are `2000` read bits, `125` read registers, `1968` write bits, `123` write registers, and `252` PDU bytes. + +Exception codes used by the gateway: + +| Code | Meaning | +| --- | --- | +| `0x01` | Unsupported function | +| `0x02` | Unmapped address or read failure | +| `0x03` | Invalid quantity, value, or request shape | +| `0x04` | DALI execution failure | +| `0x0B` | Unit id mismatch | + +## Address Calculation + +Modbus protocol frames use zero-based wire offsets. Most tools show human addresses with traditional bases: + +| Space | Human base | Function codes | +| --- | ---: | --- | +| Coil | `1` | `0x01`, `0x05`, `0x0F` | +| Discrete input | `10001` | `0x02` | +| Input register | `30001` | `0x04` | +| Holding register | `40001` | `0x03`, `0x06`, `0x10` | + +For the main generated slice, every DALI short address `0-63` gets a stride of `32` points in each space: + +```text +human = base + shortAddress * 32 + offset +wire = human - base +``` + +Example: short address `5` brightness is holding-register offset `0`: + +```text +human = 40001 + 5 * 32 + 0 = 40161 +wire = 40161 - 40001 = 160 +``` + +The diagnostic discrete-input extension starts after the first `64 * 32` discrete inputs: + +```text +diagnosticBase = 10001 + 64 * 32 = 12049 +human = 12049 + shortAddress * 128 + diagnosticOffset +wire = human - 10001 +``` + +Example: short address `5`, diagnostic offset `105` (`DT8 xy out of range`): + +```text +human = 12049 + 5 * 128 + 105 = 12794 +wire = 12794 - 10001 = 2793 +``` + +## Main Generated Map + +Generated points exist for all short addresses `0-63`, even when no device has been discovered. Unknown numeric values read as `0xFFFF`; unknown booleans read as false unless inventory or cache state proves otherwise. Generated reads prefer gateway cache state and do not poll the DALI bus for every Modbus read. + +### Coils + +| Offset | Address formula | Access | Function | +| ---: | --- | --- | --- | +| `0` | `1 + short * 32 + 0` | Write | On / recall max | +| `1` | `1 + short * 32 + 1` | Write | Off | +| `2` | `1 + short * 32 + 2` | Write | Recall max | +| `3` | `1 + short * 32 + 3` | Write | Recall min | + +### Discrete Inputs + +| Offset | Address formula | Function | +| ---: | --- | --- | +| `0` | `10001 + short * 32 + 0` | Discovered | +| `1` | `10001 + short * 32 + 1` | Online | +| `2` | `10001 + short * 32 + 2` | Supports DT1 | +| `3` | `10001 + short * 32 + 3` | Supports DT4 | +| `4` | `10001 + short * 32 + 4` | Supports DT5 | +| `5` | `10001 + short * 32 + 5` | Supports DT6 | +| `6` | `10001 + short * 32 + 6` | Supports DT8 | +| `7` | `10001 + short * 32 + 7` | Group mask known | +| `8` | `10001 + short * 32 + 8` | Actual level known | +| `9` | `10001 + short * 32 + 9` | Scene known | +| `10` | `10001 + short * 32 + 10` | Settings known | +| `16` | `10001 + short * 32 + 16` | Control gear present | +| `17` | `10001 + short * 32 + 17` | Lamp failure | +| `18` | `10001 + short * 32 + 18` | Lamp power on | +| `19` | `10001 + short * 32 + 19` | Limit error | +| `20` | `10001 + short * 32 + 20` | Fading completed | +| `21` | `10001 + short * 32 + 21` | Reset state | +| `22` | `10001 + short * 32 + 22` | Missing short address | +| `23` | `10001 + short * 32 + 23` | Power supply fault | + +### Holding Registers + +| Offset | Address formula | Access | Function | +| ---: | --- | --- | --- | +| `0` | `40001 + short * 32 + 0` | Read/write | Brightness | +| `1` | `40001 + short * 32 + 1` | Write, cache-read if known | Color temperature | +| `2` | `40001 + short * 32 + 2` | Read/write | Group mask | +| `3` | `40001 + short * 32 + 3` | Read/write | Power-on level | +| `4` | `40001 + short * 32 + 4` | Read/write | System-failure level | +| `5` | `40001 + short * 32 + 5` | Read/write | Minimum level | +| `6` | `40001 + short * 32 + 6` | Read/write | Maximum level | +| `7` | `40001 + short * 32 + 7` | Read/write | Fade time | +| `8` | `40001 + short * 32 + 8` | Read/write | Fade rate | + +### Input Registers + +| Offset | Address formula | Function | +| ---: | --- | --- | +| `0` | `30001 + short * 32 + 0` | Inventory state: `0` never seen, `1` offline, `2` online | +| `1` | `30001 + short * 32 + 1` | Primary DALI device type | +| `2` | `30001 + short * 32 + 2` | Device type mask | +| `3` | `30001 + short * 32 + 3` | Actual level | +| `4` | `30001 + short * 32 + 4` | Scene id | +| `5` | `30001 + short * 32 + 5` | Raw status | +| `6` | `30001 + short * 32 + 6` | Group mask | +| `7` | `30001 + short * 32 + 7` | Power-on level | +| `8` | `30001 + short * 32 + 8` | System-failure level | +| `9` | `30001 + short * 32 + 9` | Minimum level | +| `10` | `30001 + short * 32 + 10` | Maximum level | +| `11` | `30001 + short * 32 + 11` | Fade time | +| `12` | `30001 + short * 32 + 12` | Fade rate | + +## Diagnostic Discrete Inputs + +Diagnostic addresses use `12049 + short * 128 + offset`. + +| Offset | Function | +| ---: | --- | +| `0` | DT1 circuit failure | +| `1` | DT1 battery duration failure | +| `2` | DT1 battery failure | +| `3` | DT1 emergency lamp failure | +| `4` | DT1 function test max delay exceeded | +| `5` | DT1 duration test max delay exceeded | +| `6` | DT1 function test failed | +| `7` | DT1 duration test failed | +| `8` | DT1 inhibit mode | +| `9` | DT1 function test result valid | +| `10` | DT1 duration test result valid | +| `11` | DT1 battery fully charged | +| `12` | DT1 function test request pending | +| `13` | DT1 duration test request pending | +| `14` | DT1 identification active | +| `15` | DT1 physically selected | +| `16` | DT1 rest mode active | +| `17` | DT1 normal mode active | +| `18` | DT1 emergency mode active | +| `19` | DT1 extended emergency mode active | +| `20` | DT1 function test in progress | +| `21` | DT1 duration test in progress | +| `22` | DT1 hardwired inhibit active | +| `23` | DT1 hardwired switch on | +| `24` | DT1 integral emergency gear | +| `25` | DT1 maintained gear | +| `26` | DT1 switched maintained gear | +| `27` | DT1 auto test capability | +| `28` | DT1 adjustable emergency level | +| `29` | DT1 hardwired inhibit supported | +| `30` | DT1 physical selection supported | +| `31` | DT1 relight in rest mode supported | +| `32` | DT4 leading edge running | +| `33` | DT4 trailing edge running | +| `34` | DT4 reference measurement running | +| `35` | DT4 non-log curve active | +| `36` | DT4 can query load over-current shutdown | +| `37` | DT4 can query open circuit | +| `38` | DT4 can query load decrease | +| `39` | DT4 can query load increase | +| `40` | DT4 can query thermal shutdown | +| `41` | DT4 can query thermal overload | +| `42` | DT4 physical selection supported | +| `43` | DT4 can query temperature | +| `44` | DT4 can query supply voltage | +| `45` | DT4 can query supply frequency | +| `46` | DT4 can query load voltage | +| `47` | DT4 can query load current | +| `48` | DT4 can query real load power | +| `49` | DT4 can query load rating | +| `50` | DT4 can query current overload | +| `51` | DT4 can select non-log curve | +| `52` | DT4 can query unsuitable load | +| `53` | DT4 load over-current shutdown | +| `54` | DT4 open circuit detected | +| `55` | DT4 load decrease detected | +| `56` | DT4 load increase detected | +| `57` | DT4 thermal shutdown | +| `58` | DT4 thermal overload reduction | +| `59` | DT4 reference failed | +| `60` | DT4 unsuitable load | +| `61` | DT4 supply voltage out of limits | +| `62` | DT4 supply frequency out of limits | +| `63` | DT4 load voltage out of limits | +| `64` | DT4 load current overload | +| `65` | DT5 output range selectable | +| `66` | DT5 pull-up selectable | +| `67` | DT5 fault detection selectable | +| `68` | DT5 mains relay | +| `69` | DT5 output level queryable | +| `70` | DT5 non-log curve supported | +| `71` | DT5 output-loss selection supported | +| `72` | DT5 selection switch supported | +| `73` | DT5 output fault detected | +| `74` | DT5 0-10V operation | +| `75` | DT5 pull-up on | +| `76` | DT5 non-log curve active | +| `77` | DT6 power supply integrated | +| `78` | DT6 LED module integrated | +| `79` | DT6 AC supply possible | +| `80` | DT6 DC supply possible | +| `81` | DT6 PWM possible | +| `82` | DT6 AM possible | +| `83` | DT6 current control possible | +| `84` | DT6 high current possible | +| `85` | DT6 can query short circuit | +| `86` | DT6 can query open circuit | +| `87` | DT6 can query load decrease | +| `88` | DT6 can query load increase | +| `89` | DT6 can query current protector | +| `90` | DT6 can query thermal shutdown | +| `91` | DT6 can query thermal overload | +| `92` | DT6 short circuit | +| `93` | DT6 open circuit | +| `94` | DT6 load decrease | +| `95` | DT6 load increase | +| `96` | DT6 current protector active | +| `97` | DT6 thermal shutdown | +| `98` | DT6 thermal overload | +| `99` | DT6 reference failed | +| `100` | DT6 PWM active | +| `101` | DT6 AM active | +| `102` | DT6 current controlled output | +| `103` | DT6 high current active | +| `104` | DT6 non-log curve active | +| `105` | DT8 xy out of range | +| `106` | DT8 color-temperature out of range | +| `107` | DT8 auto calibration active | +| `108` | DT8 auto calibration success | +| `109` | DT8 xy active | +| `110` | DT8 color-temperature active | +| `111` | DT8 primary-N active | +| `112` | DT8 RGBWAF active | +| `113` | DT8 xy capable | +| `114` | DT8 color-temperature capable | +| `115` | DT8 primary-N capable | +| `116` | DT8 RGBWAF capable | +| `117` | DT6 physical selection supported | +| `118` | DT6 current protector enabled | +| `119` | DT1 control gear failure | + +## Provisioned Overrides + +Provisioned bridge models are applied after the generated map. If a Modbus model uses the same space and human address as a generated point, the model replaces that generated point. + +For Modbus models: + +- `external.objectType` must be `coil`, `discrete_input`, `input_register`, or `holding_register`. +- `external.registerAddress` is the human address, not the zero-based wire offset. +- `external.bitIndex` exposes one bit from a numeric read result for boolean objects. +- DALI targets use short addresses `0-63`, groups as `64 + group`, and broadcast as `127`. +- DALI query/read operations must target short addresses only. +- `valueTransform` scaling, offset, rounding, and clamps are applied by the bridge engine. + +Use `GET /bridge?action=modbus&gw=N` to inspect the effective generated and provisioned bindings served by a running gateway. \ No newline at end of file diff --git a/README.md b/README.md index ac5b221..49ee906 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This folder hosts the native ESP-IDF C++ rewrite of the Lua DALI gateway. - `dali_domain/`: native DALI domain facade over `dali_cpp` and raw frame sinks. - `gateway_cache/`: DALI scene/group/settings/runtime cache used by controller reconciliation and protocol bridges. - `gateway_bridge/`: per-channel bridge provisioning, command execution, protocol startup, and HTTP bridge actions. - - `gateway_modbus/`: gateway-owned Modbus TCP config, generated DALI point tables, and provisioned Modbus model override dispatch. + - `gateway_modbus/`: gateway-owned Modbus TCP/RTU/ASCII config, generated DALI point tables, and provisioned Modbus model override dispatch. - `gateway_bacnet/`: BACnet/IP server adapter backed by bacnet-stack, including the gateway-owned BACnet bridge model adapter. - `gateway_ble/`: NimBLE GATT bridge for BLE transport parity on `FFF1`/`FFF2`/`FFF3`, including raw DALI notifications. - `gateway_controller/`: Lua-compatible gateway command dispatcher, internal scene/group state, and notification fan-out. @@ -24,9 +24,11 @@ This folder hosts the native ESP-IDF C++ rewrite of the Lua DALI gateway. The native rewrite now wires a shared `gateway_core` bootstrap component, a multi-channel `dali_domain` wrapper over `dali_cpp`, a local vendored `dali` hardware backend from the LuatOS ESP-IDF port with raw receive fan-out, an initial `gateway_runtime` service that provides persistent settings, device info, Lua-compatible command framing helpers, and Lua-style query command deduplication, plus a `gateway_controller` service that starts the gateway command task, dispatches core Lua gateway opcodes, and owns internal scene/group state. The gateway app also includes a `gateway_ble` NimBLE bridge that advertises a Lua-compatible GATT service and forwards `FFF3` framed notifications, incoming `FFF1`/`FFF2`/`FFF3` writes, and native raw DALI frame notifications into the matching raw channel, and a `gateway_network` service that provides the native HTTP `/info`, `GET`/`POST /dali/cmd`, `/led/1`, `/led/0`, `/jq.js`, UDP control-plane router on port `2020`, Wi-Fi STA lifecycle, ESP-Touch smartconfig credential provisioning, the Lua-style `LAMMIN_Gateway` setup AP on `192.168.3.1`, ESP-NOW setup ingress for Lua-compatible `connReq`/`connAck`/`echo`/`cmd`/`data`/`uart` packets, native raw DALI frame forwarding back to connected setup peers, and BOOT-button Wi-Fi credential clearing. Startup behavior is configured in `main/Kconfig.projbuild`: BLE is enabled by default, Wi-Fi STA, smartconfig, and ESP-NOW setup mode are disabled by default, and the built-in USB Serial/JTAG interface stays in debug mode unless the optional USB setup bridge mode is selected. Runtime settings and internal scene/group data are cached in RAM after load, skip unchanged flash writes, and batch Wi-Fi credential commits to reduce flash stalls on ESP32-S3 boards where flash and PSRAM share the SPI bus. The gateway app exposes per-channel PHY selection through `main/Kconfig.projbuild`; each channel can be disabled, bound to the native DALI GPIO HAL, or bound to a UART1/UART2 serial PHY. The checked-in `sdkconfig` is aligned with the app's custom 16 MB partition table so the Wi-Fi/BLE/network-enabled image fits the OTA app slots. -## Modbus TCP +## Modbus -Modbus TCP is owned by `gateway/components/gateway_modbus` and started through the per-channel bridge service. The gateway keeps the existing bridge config JSON shape with a top-level `modbus` object containing `transport`, `host`, `port`, and `unitID`, but parsing and runtime behavior now live in the gateway project rather than in `dali_cpp`. +Modbus TCP, RTU, and ASCII are owned by `gateway/components/gateway_modbus` and started through the per-channel bridge service. The gateway keeps the existing bridge config JSON shape with a top-level `modbus` object containing `transport`, `host`, `port`, and `unitID`, and now adds nested serial UART settings for RTU/ASCII. Parsing and runtime behavior live in the gateway project rather than in `dali_cpp`. + +See `MODBUS.md` for transport setup, UART0 policy, RS485 wiring, runtime `@DALIGW` management commands, supported function codes, and the full generated address map with address formulas. The first generated map slice creates stable points for every DALI short address `0-63` whether the device is online, offline, or never seen. Per short address, the generated map reserves a 32-point stride in each Modbus space: diff --git a/apps/gateway/main/Kconfig.projbuild b/apps/gateway/main/Kconfig.projbuild index 54b7795..17a5a14 100644 --- a/apps/gateway/main/Kconfig.projbuild +++ b/apps/gateway/main/Kconfig.projbuild @@ -387,20 +387,104 @@ config GATEWAY_BRIDGE_SUPPORTED Enables per-channel dali_cpp bridge model provisioning, execution, and protocol adapter state. config GATEWAY_MODBUS_BRIDGE_SUPPORTED - bool "Modbus TCP bridge is supported" - depends on GATEWAY_BRIDGE_SUPPORTED && GATEWAY_WIFI_SUPPORTED + bool "Modbus bridge is supported" + depends on GATEWAY_BRIDGE_SUPPORTED default y help - Enables the gateway-owned per-channel Modbus TCP server, generated DALI point map, - and provisioned model overrides. Runtime startup still requires persisted bridge - config with Modbus settings. + Enables the gateway-owned per-channel Modbus TCP, RTU, or ASCII server, + generated DALI point map, and provisioned model overrides. config GATEWAY_START_MODBUS_BRIDGE_ENABLED - bool "Start Modbus TCP bridge at startup" + bool "Start Modbus bridge at startup" depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED default n help - Starts configured Modbus TCP listeners at boot. Disabled by default so ports are opened only after provisioning or explicit runtime start. + Starts the configured or Kconfig-default Modbus listener at boot. Disabled + by default so ports are opened only after provisioning or explicit runtime start. + +choice GATEWAY_MODBUS_DEFAULT_TRANSPORT + prompt "Default Modbus transport" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED + default GATEWAY_MODBUS_DEFAULT_TRANSPORT_TCP + help + Selects the default Modbus transport used before runtime bridge config is saved. + +config GATEWAY_MODBUS_DEFAULT_TRANSPORT_TCP + bool "TCP server" + depends on GATEWAY_WIFI_SUPPORTED + +config GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU + bool "RTU server on UART" + +config GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII + bool "ASCII server on UART" + +endchoice + +config GATEWAY_MODBUS_TCP_PORT + int "Default Modbus TCP port" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED && GATEWAY_MODBUS_DEFAULT_TRANSPORT_TCP + range 1 65535 + default 1502 + +config GATEWAY_MODBUS_UNIT_ID + int "Default Modbus unit id" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED + range 0 247 + default 1 + help + Unit id used by the default Modbus server. A value of 0 keeps the existing + gateway wildcard behavior for incoming requests. + +config GATEWAY_MODBUS_SERIAL_UART_PORT + int "Default Modbus serial UART port" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED && (GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU || GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) + range 0 2 + default 1 + +config GATEWAY_MODBUS_ALLOW_UART0 + bool "Allow Modbus/setup to claim UART0" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED && (GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU || GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) + default n + help + UART0 is normally reserved for the ESP-IDF console. Enable only when the + console has been moved away from UART0 or the deployment intentionally + repurposes it. + +config GATEWAY_MODBUS_SERIAL_TX_PIN + int "Default Modbus serial TX pin" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED && (GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU || GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) + range -1 48 + default -1 + +config GATEWAY_MODBUS_SERIAL_RX_PIN + int "Default Modbus serial RX pin" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED && (GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU || GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) + range -1 48 + default -1 + +config GATEWAY_MODBUS_SERIAL_BAUDRATE + int "Default Modbus serial baudrate" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED && (GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU || GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) + range 1200 921600 + default 9600 + +config GATEWAY_MODBUS_SERIAL_RESPONSE_TIMEOUT_MS + int "Default Modbus serial response timeout ms" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED && (GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU || GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) + range 1 1000 + default 20 + +config GATEWAY_MODBUS_SERIAL_RS485_ENABLED + bool "Enable Modbus RS485 half-duplex mode" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED && (GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU || GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) + default n + +config GATEWAY_MODBUS_SERIAL_RS485_DE_PIN + int "Default Modbus RS485 DE/RTS pin" + depends on GATEWAY_MODBUS_SERIAL_RS485_ENABLED + range -1 48 + default -1 config GATEWAY_BACNET_BRIDGE_SUPPORTED bool "BACnet/IP bridge is supported" diff --git a/apps/gateway/main/app_main.cpp b/apps/gateway/main/app_main.cpp index e431bd3..258581f 100644 --- a/apps/gateway/main/app_main.cpp +++ b/apps/gateway/main/app_main.cpp @@ -67,6 +67,38 @@ #define CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_PRIORITY 4 #endif +#ifndef CONFIG_GATEWAY_MODBUS_TCP_PORT +#define CONFIG_GATEWAY_MODBUS_TCP_PORT 1502 +#endif + +#ifndef CONFIG_GATEWAY_MODBUS_UNIT_ID +#define CONFIG_GATEWAY_MODBUS_UNIT_ID 1 +#endif + +#ifndef CONFIG_GATEWAY_MODBUS_SERIAL_UART_PORT +#define CONFIG_GATEWAY_MODBUS_SERIAL_UART_PORT 1 +#endif + +#ifndef CONFIG_GATEWAY_MODBUS_SERIAL_TX_PIN +#define CONFIG_GATEWAY_MODBUS_SERIAL_TX_PIN -1 +#endif + +#ifndef CONFIG_GATEWAY_MODBUS_SERIAL_RX_PIN +#define CONFIG_GATEWAY_MODBUS_SERIAL_RX_PIN -1 +#endif + +#ifndef CONFIG_GATEWAY_MODBUS_SERIAL_BAUDRATE +#define CONFIG_GATEWAY_MODBUS_SERIAL_BAUDRATE 9600 +#endif + +#ifndef CONFIG_GATEWAY_MODBUS_SERIAL_RESPONSE_TIMEOUT_MS +#define CONFIG_GATEWAY_MODBUS_SERIAL_RESPONSE_TIMEOUT_MS 20 +#endif + +#ifndef CONFIG_GATEWAY_MODBUS_SERIAL_RS485_DE_PIN +#define CONFIG_GATEWAY_MODBUS_SERIAL_RS485_DE_PIN -1 +#endif + #ifndef CONFIG_GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE #define CONFIG_GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE 8192 #endif @@ -212,6 +244,25 @@ constexpr gateway::GatewayCachePriorityMode kCachePriorityMode = gateway::GatewayCachePriorityMode::kOutsideBusFirst; #endif +#if defined(CONFIG_GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU) || \ + defined(CONFIG_GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) +constexpr bool kModbusDefaultSerialTransport = true; +#else +constexpr bool kModbusDefaultSerialTransport = false; +#endif + +#ifdef CONFIG_GATEWAY_MODBUS_ALLOW_UART0 +constexpr bool kModbusAllowUart0 = true; +#else +constexpr bool kModbusAllowUart0 = false; +#endif + +#ifdef CONFIG_GATEWAY_MODBUS_SERIAL_RS485_ENABLED +constexpr bool kModbusSerialRs485Enabled = true; +#else +constexpr bool kModbusSerialRs485Enabled = false; +#endif + std::unique_ptr s_dali_domain; std::unique_ptr s_runtime; std::unique_ptr s_cache; @@ -322,6 +373,21 @@ bool ValidateChannelBindings() { } } + if (kModbusBridgeSupported && kModbusDefaultSerialTransport) { + const int modbus_uart = CONFIG_GATEWAY_MODBUS_SERIAL_UART_PORT; + if (modbus_uart == 0 && !kModbusAllowUart0) { + ESP_LOGE(kTag, "Modbus serial is configured on UART0, but UART0 is reserved for console"); + return false; + } + for (int i = 0; i < CONFIG_GATEWAY_CHANNEL_COUNT; ++i) { + if (channels[i].enabled && channels[i].serial_phy && channels[i].uart_port == modbus_uart) { + ESP_LOGE(kTag, "Modbus serial UART%d conflicts with DALI channel %d serial PHY", + modbus_uart, i + 1); + return false; + } + } + } + if (!any_enabled) { ESP_LOGE(kTag, "no DALI PHY is configured; enable at least one native or serial channel"); return false; @@ -474,9 +540,8 @@ extern "C" void app_main(void) { if (kBridgeSupported) { gateway::GatewayBridgeServiceConfig bridge_config; bridge_config.bridge_enabled = true; - bridge_config.modbus_enabled = profile.enable_wifi && kModbusBridgeSupported; - bridge_config.modbus_startup_enabled = profile.enable_wifi && kModbusBridgeSupported && - kModbusBridgeStartupEnabled; + bridge_config.modbus_enabled = kModbusBridgeSupported; + bridge_config.modbus_startup_enabled = kModbusBridgeSupported && kModbusBridgeStartupEnabled; bridge_config.bacnet_enabled = profile.enable_wifi && kBacnetBridgeSupported; bridge_config.bacnet_startup_enabled = profile.enable_wifi && kBacnetBridgeSupported && kBacnetBridgeStartupEnabled; @@ -487,6 +552,44 @@ extern "C" void app_main(void) { static_cast(CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_STACK_SIZE); bridge_config.modbus_task_priority = static_cast(CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_PRIORITY); + bridge_config.allow_modbus_uart0 = kModbusAllowUart0; + if (!kModbusAllowUart0) { + bridge_config.reserved_uart_ports.push_back(0); + } + #if CONFIG_GATEWAY_CHANNEL1_PHY_UART1 + bridge_config.reserved_uart_ports.push_back(1); + #elif CONFIG_GATEWAY_CHANNEL1_PHY_UART2 + bridge_config.reserved_uart_ports.push_back(2); + #endif + #if CONFIG_GATEWAY_CHANNEL_COUNT >= 2 + #if CONFIG_GATEWAY_CHANNEL2_PHY_UART1 + bridge_config.reserved_uart_ports.push_back(1); + #elif CONFIG_GATEWAY_CHANNEL2_PHY_UART2 + bridge_config.reserved_uart_ports.push_back(2); + #endif + #endif + if (kModbusBridgeSupported) { + gateway::GatewayModbusConfig default_modbus; +#if defined(CONFIG_GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU) + default_modbus.transport = "rtu-server"; +#elif defined(CONFIG_GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII) + default_modbus.transport = "ascii-server"; +#else + default_modbus.transport = "tcp-server"; +#endif + default_modbus.port = static_cast(CONFIG_GATEWAY_MODBUS_TCP_PORT); + default_modbus.unit_id = static_cast(CONFIG_GATEWAY_MODBUS_UNIT_ID); + default_modbus.serial.uart_port = CONFIG_GATEWAY_MODBUS_SERIAL_UART_PORT; + default_modbus.serial.tx_pin = CONFIG_GATEWAY_MODBUS_SERIAL_TX_PIN; + default_modbus.serial.rx_pin = CONFIG_GATEWAY_MODBUS_SERIAL_RX_PIN; + default_modbus.serial.baudrate = + static_cast(CONFIG_GATEWAY_MODBUS_SERIAL_BAUDRATE); + default_modbus.serial.response_timeout_ms = + static_cast(CONFIG_GATEWAY_MODBUS_SERIAL_RESPONSE_TIMEOUT_MS); + default_modbus.serial.rs485.enabled = kModbusSerialRs485Enabled; + default_modbus.serial.rs485.de_pin = CONFIG_GATEWAY_MODBUS_SERIAL_RS485_DE_PIN; + bridge_config.default_modbus_config = default_modbus; + } bridge_config.bacnet_task_stack_size = static_cast(CONFIG_GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE); bridge_config.bacnet_task_priority = diff --git a/apps/gateway/sdkconfig b/apps/gateway/sdkconfig index b6ee934..64f12ca 100644 --- a/apps/gateway/sdkconfig +++ b/apps/gateway/sdkconfig @@ -647,6 +647,11 @@ CONFIG_GATEWAY_SMARTCONFIG_TIMEOUT_SEC=60 CONFIG_GATEWAY_BRIDGE_SUPPORTED=y CONFIG_GATEWAY_MODBUS_BRIDGE_SUPPORTED=y # CONFIG_GATEWAY_START_MODBUS_BRIDGE_ENABLED is not set +CONFIG_GATEWAY_MODBUS_DEFAULT_TRANSPORT_TCP=y +# CONFIG_GATEWAY_MODBUS_DEFAULT_TRANSPORT_RTU is not set +# CONFIG_GATEWAY_MODBUS_DEFAULT_TRANSPORT_ASCII is not set +CONFIG_GATEWAY_MODBUS_TCP_PORT=1502 +CONFIG_GATEWAY_MODBUS_UNIT_ID=1 CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED=y # CONFIG_GATEWAY_START_BACNET_BRIDGE_ENABLED is not set CONFIG_GATEWAY_CLOUD_BRIDGE_SUPPORTED=y diff --git a/components/gateway_bridge/CMakeLists.txt b/components/gateway_bridge/CMakeLists.txt index 0ef237f..3e765ac 100644 --- a/components/gateway_bridge/CMakeLists.txt +++ b/components/gateway_bridge/CMakeLists.txt @@ -2,6 +2,7 @@ set(GATEWAY_BRIDGE_REQUIRES dali_domain dali_cpp espressif__cjson + esp_driver_uart freertos gateway_cache gateway_modbus diff --git a/components/gateway_bridge/include/gateway_bridge.hpp b/components/gateway_bridge/include/gateway_bridge.hpp index b0babce..8e599c7 100644 --- a/components/gateway_bridge/include/gateway_bridge.hpp +++ b/components/gateway_bridge/include/gateway_bridge.hpp @@ -2,12 +2,14 @@ #include #include +#include #include #include #include "esp_err.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "gateway_modbus.hpp" namespace gateway { @@ -24,6 +26,9 @@ struct GatewayBridgeServiceConfig { bool cloud_startup_enabled{false}; uint32_t modbus_task_stack_size{6144}; UBaseType_t modbus_task_priority{4}; + std::optional default_modbus_config; + bool allow_modbus_uart0{false}; + std::vector reserved_uart_ports; uint32_t bacnet_task_stack_size{8192}; UBaseType_t bacnet_task_priority{5}; }; diff --git a/components/gateway_bridge/src/gateway_bridge.cpp b/components/gateway_bridge/src/gateway_bridge.cpp index b7986fa..da95372 100644 --- a/components/gateway_bridge/src/gateway_bridge.cpp +++ b/components/gateway_bridge/src/gateway_bridge.cpp @@ -16,12 +16,16 @@ #include "gateway_provisioning.hpp" #include "cJSON.h" +#include "driver/uart.h" #include "esp_log.h" #include "freertos/semphr.h" #include "lwip/inet.h" #include "lwip/sockets.h" #include +#include +#include +#include #include #include #include @@ -29,6 +33,7 @@ #include #include #include +#include #include #include @@ -48,6 +53,7 @@ constexpr uint32_t kBacnetGeneratedBinaryInputChannelStride = 32768; constexpr uint32_t kBacnetMaxObjectInstance = 4194303; constexpr uint32_t kBacnetReliabilityNoFaultDetected = 0; constexpr uint32_t kBacnetReliabilityCommunicationFailure = 12; +constexpr const char* kModbusManagementPrefix = "@DALIGW"; struct GatewayBridgeStoredConfig { BridgeRuntimeConfig bridge; @@ -1014,10 +1020,106 @@ bool SendModbusFrame(int sock, const uint8_t* mbap, const std::vector& return SendAll(sock, frame.data(), frame.size()); } -bool SendModbusException(int sock, const uint8_t* mbap, uint8_t function_code, - uint8_t exception_code) { - const std::vector pdu{static_cast(function_code | 0x80), exception_code}; - return SendModbusFrame(sock, mbap, pdu); +std::vector ModbusExceptionPdu(uint8_t function_code, uint8_t exception_code) { + return {static_cast(function_code | 0x80), exception_code}; +} + +uint16_t ModbusCrc16(const uint8_t* data, size_t len) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < len; ++i) { + crc ^= data[i]; + for (int bit = 0; bit < 8; ++bit) { + if ((crc & 0x0001) != 0) { + crc = static_cast((crc >> 1) ^ 0xA001); + } else { + crc = static_cast(crc >> 1); + } + } + } + return crc; +} + +uint8_t ModbusAsciiLrc(const uint8_t* data, size_t len) { + uint8_t sum = 0; + for (size_t i = 0; i < len; ++i) { + sum = static_cast(sum + data[i]); + } + return static_cast(-sum); +} + +std::optional HexNibble(char ch) { + if (ch >= '0' && ch <= '9') { + return static_cast(ch - '0'); + } + if (ch >= 'A' && ch <= 'F') { + return static_cast(ch - 'A' + 10); + } + if (ch >= 'a' && ch <= 'f') { + return static_cast(ch - 'a' + 10); + } + return std::nullopt; +} + +std::optional> DecodeModbusAsciiLine(std::string_view line) { + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) { + line.remove_suffix(1); + } + if (line.size() < 7 || line.front() != ':' || ((line.size() - 1) % 2) != 0) { + return std::nullopt; + } + std::vector bytes; + bytes.reserve((line.size() - 1) / 2); + for (size_t i = 1; i + 1 < line.size(); i += 2) { + const auto high = HexNibble(line[i]); + const auto low = HexNibble(line[i + 1]); + if (!high.has_value() || !low.has_value()) { + return std::nullopt; + } + bytes.push_back(static_cast((high.value() << 4) | low.value())); + } + uint8_t sum = 0; + for (const auto byte : bytes) { + sum = static_cast(sum + byte); + } + if (sum != 0) { + return std::nullopt; + } + return bytes; +} + +std::string EncodeModbusAsciiLine(const std::vector& bytes) { + constexpr char kHex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(1 + bytes.size() * 2 + 2); + out.push_back(':'); + for (const auto byte : bytes) { + out.push_back(kHex[(byte >> 4) & 0x0F]); + out.push_back(kHex[byte & 0x0F]); + } + out.append("\r\n"); + return out; +} + +bool LineStartsWith(std::string_view line, std::string_view prefix) { + return line.size() >= prefix.size() && line.substr(0, prefix.size()) == prefix; +} + +uart_word_length_t UartWordLength(int bits) { + return bits <= 7 ? UART_DATA_7_BITS : UART_DATA_8_BITS; +} + +uart_parity_t UartParity(const std::string& parity) { + if (parity == "even") { + return UART_PARITY_EVEN; + } + if (parity == "odd") { + return UART_PARITY_ODD; + } + return UART_PARITY_DISABLE; +} + +uart_stop_bits_t UartStopBits(int bits) { + return bits >= 2 ? UART_STOP_BITS_2 : UART_STOP_BITS_1; } } // namespace @@ -1062,6 +1164,12 @@ struct GatewayBridgeService::ChannelRuntime { bool modbus_started{false}; bool bacnet_started{false}; TaskHandle_t modbus_task_handle{nullptr}; + std::atomic_bool modbus_stop_requested{false}; + std::atomic_bool modbus_restart_requested{false}; + int modbus_listen_sock{-1}; + int modbus_client_sock{-1}; + int modbus_uart_port{-1}; + std::string modbus_last_error; struct DiagnosticSnapshotCacheEntry { DaliDomainSnapshot snapshot; @@ -1709,10 +1817,29 @@ struct GatewayBridgeService::ChannelRuntime { if (modbus_json != nullptr) { cJSON_AddBoolToObject(modbus_json, "enabled", service_config.modbus_enabled); cJSON_AddBoolToObject(modbus_json, "started", modbus_started); + cJSON_AddStringToObject(modbus_json, "lastError", modbus_last_error.c_str()); if (modbus_config.has_value()) { cJSON_AddStringToObject(modbus_json, "transport", modbus_config->transport.c_str()); cJSON_AddNumberToObject(modbus_json, "port", modbus_config->port); cJSON_AddNumberToObject(modbus_json, "unitID", modbus_config->unit_id); + if (GatewayModbusTransportIsSerial(modbus_config->transport)) { + cJSON* serial_json = cJSON_CreateObject(); + if (serial_json != nullptr) { + cJSON_AddNumberToObject(serial_json, "uartPort", modbus_config->serial.uart_port); + cJSON_AddNumberToObject(serial_json, "txPin", modbus_config->serial.tx_pin); + cJSON_AddNumberToObject(serial_json, "rxPin", modbus_config->serial.rx_pin); + cJSON_AddNumberToObject(serial_json, "baudrate", modbus_config->serial.baudrate); + cJSON_AddStringToObject(serial_json, "parity", modbus_config->serial.parity.c_str()); + cJSON_AddNumberToObject(serial_json, "stopBits", modbus_config->serial.stop_bits); + cJSON* rs485_json = cJSON_CreateObject(); + if (rs485_json != nullptr) { + cJSON_AddBoolToObject(rs485_json, "enabled", modbus_config->serial.rs485.enabled); + cJSON_AddNumberToObject(rs485_json, "dePin", modbus_config->serial.rs485.de_pin); + cJSON_AddItemToObject(serial_json, "rs485", rs485_json); + } + cJSON_AddItemToObject(modbus_json, "serial", serial_json); + } + } } cJSON_AddItemToObject(root, "modbus", modbus_json); } @@ -2404,7 +2531,190 @@ struct GatewayBridgeService::ChannelRuntime { return result.ok; } - esp_err_t startModbus(std::set* used_ports = nullptr) { + std::optional activeModbusConfigLocked() const { + if (modbus_config.has_value()) { + return modbus_config; + } + return service_config.default_modbus_config; + } + + std::optional activeModbusConfig() const { + LockGuard guard(lock); + return activeModbusConfigLocked(); + } + + bool isReservedUartLocked(int uart_port) const { + return std::find(service_config.reserved_uart_ports.begin(), + service_config.reserved_uart_ports.end(), uart_port) != + service_config.reserved_uart_ports.end(); + } + + esp_err_t validateSerialModbusConfigLocked(const GatewayModbusConfig& config) const { + const int uart_port = config.serial.uart_port; + if (uart_port < 0 || uart_port > 2) { + return ESP_ERR_INVALID_ARG; + } + if (uart_port == 0 && !service_config.allow_modbus_uart0) { + return ESP_ERR_INVALID_STATE; + } + if (isReservedUartLocked(uart_port)) { + return ESP_ERR_INVALID_STATE; + } + return ESP_OK; + } + + esp_err_t saveModbusConfig(const GatewayModbusConfig& config) { + LockGuard guard(lock); + BridgeProvisioningStore store(bridgeNamespace()); + const esp_err_t err = store.saveObject( + kBridgeConfigKey, + GatewayBridgeStoredConfigToValue(bridge_config, config, bacnet_server_config)); + if (err != ESP_OK) { + return err; + } + modbus_config = config; + bridge_config_loaded = true; + if (modbus != nullptr) { + modbus->setConfig(config); + } + return ESP_OK; + } + + std::vector processModbusPdu(const GatewayModbusConfig& config, + uint8_t unit_id, + const std::vector& pdu) { + if (pdu.empty()) { + return {}; + } + if (config.unit_id != 0 && unit_id != config.unit_id) { + return ModbusExceptionPdu(pdu[0], 0x0B); + } + + if ((pdu[0] == 0x01 || pdu[0] == 0x02) && pdu.size() == 5) { + const auto space = GatewayModbusReadSpaceForFunction(pdu[0]); + const uint16_t start_address = ReadBe16(&pdu[1]); + const uint16_t quantity = ReadBe16(&pdu[3]); + if (!space.has_value() || quantity == 0 || quantity > kGatewayModbusMaxReadBits) { + return ModbusExceptionPdu(pdu[0], 0x03); + } + const uint8_t byte_count = static_cast((quantity + 7U) / 8U); + std::vector response(2 + byte_count, 0); + response[0] = pdu[0]; + response[1] = byte_count; + for (uint16_t index = 0; index < quantity; ++index) { + const auto human_address = static_cast( + GatewayModbusHumanAddressFromWire(space.value(), start_address + index)); + const auto value = readModbusBoolPoint(space.value(), human_address); + if (!value.has_value()) { + return ModbusExceptionPdu(pdu[0], 0x02); + } + if (value.value()) { + response[2 + (index / 8)] |= static_cast(1U << (index % 8)); + } + } + return response; + } + + if ((pdu[0] == 0x03 || pdu[0] == 0x04) && pdu.size() == 5) { + const auto space = GatewayModbusReadSpaceForFunction(pdu[0]); + const uint16_t start_address = ReadBe16(&pdu[1]); + const uint16_t quantity = ReadBe16(&pdu[3]); + if (!space.has_value() || quantity == 0 || quantity > kGatewayModbusMaxReadRegisters) { + return ModbusExceptionPdu(pdu[0], 0x03); + } + std::vector response(2 + quantity * 2); + response[0] = pdu[0]; + response[1] = static_cast(quantity * 2); + for (uint16_t index = 0; index < quantity; ++index) { + const auto human_address = static_cast( + GatewayModbusHumanAddressFromWire(space.value(), start_address + index)); + const auto value = readModbusRegisterPoint(space.value(), human_address); + if (!value.has_value()) { + return ModbusExceptionPdu(pdu[0], 0x02); + } + WriteBe16(&response[2 + index * 2], value.value()); + } + return response; + } + + if (pdu[0] == 0x05 && pdu.size() == 5) { + const uint16_t wire_address = ReadBe16(&pdu[1]); + const uint16_t raw_value = ReadBe16(&pdu[3]); + if (raw_value != 0x0000 && raw_value != 0xFF00) { + return ModbusExceptionPdu(pdu[0], 0x03); + } + const auto coil = static_cast(GatewayModbusHumanAddressFromWire( + GatewayModbusSpace::kCoil, wire_address)); + if (!writeModbusCoilPoint(coil, raw_value == 0xFF00)) { + return ModbusExceptionPdu(pdu[0], 0x04); + } + return pdu; + } + + if (pdu[0] == 0x06 && pdu.size() == 5) { + const uint16_t wire_register = ReadBe16(&pdu[1]); + const uint16_t value = ReadBe16(&pdu[3]); + const auto holding_register = static_cast(GatewayModbusHumanAddressFromWire( + GatewayModbusSpace::kHoldingRegister, wire_register)); + if (!writeModbusRegisterPoint(holding_register, value)) { + return ModbusExceptionPdu(pdu[0], 0x04); + } + return pdu; + } + + if (pdu[0] == 0x0F && pdu.size() >= 6) { + const uint16_t start_address = ReadBe16(&pdu[1]); + const uint16_t quantity = ReadBe16(&pdu[3]); + const uint8_t byte_count = pdu[5]; + if (quantity == 0 || quantity > kGatewayModbusMaxWriteBits || + pdu.size() != static_cast(6 + byte_count) || + byte_count != static_cast((quantity + 7U) / 8U)) { + return ModbusExceptionPdu(pdu[0], 0x03); + } + for (uint16_t index = 0; index < quantity; ++index) { + const bool value = (pdu[6 + (index / 8)] & (1U << (index % 8))) != 0; + const auto coil = static_cast(GatewayModbusHumanAddressFromWire( + GatewayModbusSpace::kCoil, start_address + index)); + if (!writeModbusCoilPoint(coil, value)) { + return ModbusExceptionPdu(pdu[0], 0x04); + } + } + std::vector response(5); + response[0] = pdu[0]; + WriteBe16(&response[1], start_address); + WriteBe16(&response[3], quantity); + return response; + } + + if (pdu[0] == 0x10 && pdu.size() >= 6) { + const uint16_t start_register = ReadBe16(&pdu[1]); + const uint16_t quantity = ReadBe16(&pdu[3]); + const uint8_t byte_count = pdu[5]; + if (quantity == 0 || quantity > kGatewayModbusMaxWriteRegisters || + pdu.size() != static_cast(6 + byte_count) || byte_count != quantity * 2) { + return ModbusExceptionPdu(pdu[0], 0x03); + } + for (uint16_t index = 0; index < quantity; ++index) { + const size_t offset = 6 + (index * 2); + const uint16_t value = ReadBe16(&pdu[offset]); + const auto holding_register = static_cast(GatewayModbusHumanAddressFromWire( + GatewayModbusSpace::kHoldingRegister, start_register + index)); + if (!writeModbusRegisterPoint(holding_register, value)) { + return ModbusExceptionPdu(pdu[0], 0x04); + } + } + std::vector response(5); + response[0] = pdu[0]; + WriteBe16(&response[1], start_register); + WriteBe16(&response[3], quantity); + return response; + } + + return ModbusExceptionPdu(pdu[0], 0x01); + } + + esp_err_t startModbus(std::set* used_ports = nullptr, + std::set* used_uarts = nullptr) { LockGuard guard(lock); if (!service_config.modbus_enabled) { return ESP_ERR_NOT_SUPPORTED; @@ -2412,19 +2722,43 @@ struct GatewayBridgeService::ChannelRuntime { if (modbus_started || modbus_task_handle != nullptr) { return ESP_OK; } - if (!modbus_config.has_value()) { + const auto config = activeModbusConfigLocked(); + if (!config.has_value()) { return ESP_ERR_NOT_FOUND; } - const uint16_t port = modbus_config->port == 0 ? kGatewayModbusDefaultTcpPort - : modbus_config->port; - if (used_ports != nullptr) { + if (GatewayModbusTransportIsSerial(config->transport)) { + const esp_err_t serial_err = validateSerialModbusConfigLocked(config.value()); + if (serial_err != ESP_OK) { + modbus_last_error = "invalid or reserved Modbus serial UART"; + return serial_err; + } + if (used_uarts != nullptr) { + const int uart_port = config->serial.uart_port; + if (used_uarts->find(uart_port) != used_uarts->end()) { + ESP_LOGW(kTag, "gateway=%u skips duplicate Modbus serial UART%d", channel.gateway_id, + uart_port); + return ESP_ERR_INVALID_STATE; + } + used_uarts->insert(uart_port); + } + } + const uint16_t port = config->port == 0 ? kGatewayModbusDefaultTcpPort : config->port; + if (GatewayModbusTransportIsTcp(config->transport) && used_ports != nullptr) { if (used_ports->find(port) != used_ports->end()) { ESP_LOGW(kTag, "gateway=%u skips duplicate Modbus TCP port %u", channel.gateway_id, port); return ESP_ERR_INVALID_STATE; } used_ports->insert(port); } - const BaseType_t created = xTaskCreate(&ChannelRuntime::ModbusTaskEntry, "gw_modbus_tcp", + modbus_stop_requested = false; + modbus_restart_requested = false; + modbus_last_error.clear(); + const char* task_name = GatewayModbusTransportIsTcp(config->transport) + ? "gw_modbus_tcp" + : (GatewayModbusTransportIsAscii(config->transport) + ? "gw_modbus_ascii" + : "gw_modbus_rtu"); + const BaseType_t created = xTaskCreate(&ChannelRuntime::ModbusTaskEntry, task_name, service_config.modbus_task_stack_size, this, service_config.modbus_task_priority, &modbus_task_handle); @@ -2436,21 +2770,74 @@ struct GatewayBridgeService::ChannelRuntime { return ESP_OK; } + esp_err_t stopModbus() { + LockGuard guard(lock); + if (!modbus_started && modbus_task_handle == nullptr) { + return ESP_OK; + } + modbus_stop_requested = true; + modbus_restart_requested = false; + if (modbus_client_sock >= 0) { + shutdown(modbus_client_sock, SHUT_RDWR); + } + if (modbus_listen_sock >= 0) { + shutdown(modbus_listen_sock, SHUT_RDWR); + } + return ESP_OK; + } + void modbusTaskLoop() { - const uint16_t port = modbus_config.has_value() && modbus_config->port != 0 - ? modbus_config->port - : kGatewayModbusDefaultTcpPort; + const auto config = activeModbusConfig(); + if (!config.has_value()) { + modbus_last_error = "missing Modbus config"; + finishModbusTask(); + return; + } + if (GatewayModbusTransportIsTcp(config->transport)) { + modbusTcpTaskLoop(config.value()); + } else if (GatewayModbusTransportIsSerial(config->transport)) { + modbusSerialTaskLoop(config.value()); + } else { + modbus_last_error = "unsupported Modbus transport"; + ESP_LOGE(kTag, "gateway=%u unsupported Modbus transport %s", channel.gateway_id, + config->transport.c_str()); + } + finishModbusTask(); + } + + void finishModbusTask() { + const bool restart = modbus_restart_requested.exchange(false); + { + LockGuard guard(lock); + modbus_started = false; + modbus_task_handle = nullptr; + modbus_stop_requested = false; + modbus_listen_sock = -1; + modbus_client_sock = -1; + modbus_uart_port = -1; + } + if (restart) { + startModbus(); + } + vTaskDelete(nullptr); + } + + void modbusTcpTaskLoop(const GatewayModbusConfig& config) { + const uint16_t port = config.port != 0 ? config.port : kGatewayModbusDefaultTcpPort; const int listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); if (listen_sock < 0) { ESP_LOGE(kTag, "gateway=%u failed to create Modbus socket", channel.gateway_id); - modbus_started = false; - modbus_task_handle = nullptr; - vTaskDelete(nullptr); + modbus_last_error = "failed to create Modbus TCP socket"; return; } + modbus_listen_sock = listen_sock; int reuse = 1; setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + timeval timeout{}; + timeout.tv_sec = 1; + timeout.tv_usec = 0; + setsockopt(listen_sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); sockaddr_in address = {}; address.sin_family = AF_INET; @@ -2460,15 +2847,13 @@ struct GatewayBridgeService::ChannelRuntime { if (bind(listen_sock, reinterpret_cast(&address), sizeof(address)) != 0 || listen(listen_sock, 2) != 0) { ESP_LOGE(kTag, "gateway=%u failed to bind Modbus TCP port %u", channel.gateway_id, port); + modbus_last_error = "failed to bind Modbus TCP port"; close(listen_sock); - modbus_started = false; - modbus_task_handle = nullptr; - vTaskDelete(nullptr); return; } ESP_LOGI(kTag, "gateway=%u Modbus TCP listening on port %u", channel.gateway_id, port); - while (true) { + while (!modbus_stop_requested) { sockaddr_in client_address = {}; socklen_t client_len = sizeof(client_address); const int client_sock = accept(listen_sock, reinterpret_cast(&client_address), @@ -2476,14 +2861,19 @@ struct GatewayBridgeService::ChannelRuntime { if (client_sock < 0) { continue; } - handleModbusClient(client_sock); + setsockopt(client_sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + modbus_client_sock = client_sock; + handleModbusClient(config, client_sock); + modbus_client_sock = -1; close(client_sock); } + close(listen_sock); + modbus_listen_sock = -1; } - void handleModbusClient(int client_sock) { + void handleModbusClient(const GatewayModbusConfig& config, int client_sock) { uint8_t header[7] = {}; - while (RecvAll(client_sock, header, sizeof(header))) { + while (!modbus_stop_requested && RecvAll(client_sock, header, sizeof(header))) { const uint16_t protocol_id = ReadBe16(&header[2]); const uint16_t length = ReadBe16(&header[4]); if (protocol_id != 0 || length < 2 || length > kGatewayModbusMaxPduBytes) { @@ -2494,175 +2884,265 @@ struct GatewayBridgeService::ChannelRuntime { if (!RecvAll(client_sock, pdu.data(), pdu.size()) || pdu.empty()) { break; } - - if (modbus_config.has_value() && modbus_config->unit_id != 0 && - header[6] != modbus_config->unit_id) { - SendModbusException(client_sock, header, pdu[0], 0x0B); - continue; - } - - if ((pdu[0] == 0x01 || pdu[0] == 0x02) && pdu.size() == 5) { - const auto space = GatewayModbusReadSpaceForFunction(pdu[0]); - const uint16_t start_address = ReadBe16(&pdu[1]); - const uint16_t quantity = ReadBe16(&pdu[3]); - if (!space.has_value() || quantity == 0 || quantity > kGatewayModbusMaxReadBits) { - SendModbusException(client_sock, header, pdu[0], 0x03); - continue; - } - const uint8_t byte_count = static_cast((quantity + 7U) / 8U); - std::vector response(2 + byte_count, 0); - response[0] = pdu[0]; - response[1] = byte_count; - bool ok = true; - for (uint16_t index = 0; index < quantity; ++index) { - const auto human_address = static_cast( - GatewayModbusHumanAddressFromWire(space.value(), start_address + index)); - const auto value = readModbusBoolPoint(space.value(), human_address); - if (!value.has_value()) { - ok = false; - break; - } - if (value.value()) { - response[2 + (index / 8)] |= static_cast(1U << (index % 8)); - } - } - if (!ok) { - SendModbusException(client_sock, header, pdu[0], 0x02); - continue; - } - SendModbusFrame(client_sock, header, response); - continue; - } - - if ((pdu[0] == 0x03 || pdu[0] == 0x04) && pdu.size() == 5) { - const auto space = GatewayModbusReadSpaceForFunction(pdu[0]); - const uint16_t start_address = ReadBe16(&pdu[1]); - const uint16_t quantity = ReadBe16(&pdu[3]); - if (!space.has_value() || quantity == 0 || quantity > kGatewayModbusMaxReadRegisters) { - SendModbusException(client_sock, header, pdu[0], 0x03); - continue; - } - std::vector response(2 + quantity * 2); - response[0] = pdu[0]; - response[1] = static_cast(quantity * 2); - bool ok = true; - for (uint16_t index = 0; index < quantity; ++index) { - const auto human_address = static_cast( - GatewayModbusHumanAddressFromWire(space.value(), start_address + index)); - const auto value = readModbusRegisterPoint(space.value(), human_address); - if (!value.has_value()) { - ok = false; - break; - } - WriteBe16(&response[2 + index * 2], value.value()); - } - if (!ok) { - SendModbusException(client_sock, header, pdu[0], 0x02); - continue; - } - SendModbusFrame(client_sock, header, response); - continue; - } - - if (pdu[0] == 0x05 && pdu.size() == 5) { - const uint16_t wire_address = ReadBe16(&pdu[1]); - const uint16_t raw_value = ReadBe16(&pdu[3]); - if (raw_value != 0x0000 && raw_value != 0xFF00) { - SendModbusException(client_sock, header, pdu[0], 0x03); - continue; - } - const auto coil = static_cast(GatewayModbusHumanAddressFromWire( - GatewayModbusSpace::kCoil, wire_address)); - if (!writeModbusCoilPoint(coil, raw_value == 0xFF00)) { - SendModbusException(client_sock, header, pdu[0], 0x04); - continue; - } - SendModbusFrame(client_sock, header, pdu); - continue; - } - - if (pdu[0] == 0x06 && pdu.size() == 5) { - const uint16_t wire_register = ReadBe16(&pdu[1]); - const uint16_t value = ReadBe16(&pdu[3]); - const auto holding_register = static_cast(GatewayModbusHumanAddressFromWire( - GatewayModbusSpace::kHoldingRegister, wire_register)); - if (!writeModbusRegisterPoint(holding_register, value)) { - SendModbusException(client_sock, header, pdu[0], 0x04); - continue; - } - SendModbusFrame(client_sock, header, pdu); - continue; - } - - if (pdu[0] == 0x0F && pdu.size() >= 6) { - const uint16_t start_address = ReadBe16(&pdu[1]); - const uint16_t quantity = ReadBe16(&pdu[3]); - const uint8_t byte_count = pdu[5]; - if (quantity == 0 || quantity > kGatewayModbusMaxWriteBits || - pdu.size() != static_cast(6 + byte_count) || - byte_count != static_cast((quantity + 7U) / 8U)) { - SendModbusException(client_sock, header, pdu[0], 0x03); - continue; - } - bool ok = true; - for (uint16_t index = 0; index < quantity; ++index) { - const bool value = (pdu[6 + (index / 8)] & (1U << (index % 8))) != 0; - const auto coil = static_cast(GatewayModbusHumanAddressFromWire( - GatewayModbusSpace::kCoil, start_address + index)); - if (!writeModbusCoilPoint(coil, value)) { - ok = false; - break; - } - } - if (!ok) { - SendModbusException(client_sock, header, pdu[0], 0x04); - continue; - } - std::vector response(5); - response[0] = pdu[0]; - WriteBe16(&response[1], start_address); - WriteBe16(&response[3], quantity); - SendModbusFrame(client_sock, header, response); - continue; - } - - if (pdu[0] == 0x10 && pdu.size() >= 6) { - const uint16_t start_register = ReadBe16(&pdu[1]); - const uint16_t quantity = ReadBe16(&pdu[3]); - const uint8_t byte_count = pdu[5]; - if (quantity == 0 || quantity > kGatewayModbusMaxWriteRegisters || - pdu.size() != static_cast(6 + byte_count) || - byte_count != quantity * 2) { - SendModbusException(client_sock, header, pdu[0], 0x03); - continue; - } - bool ok = true; - for (uint16_t index = 0; index < quantity; ++index) { - const size_t offset = 6 + (index * 2); - const uint16_t value = ReadBe16(&pdu[offset]); - const auto holding_register = static_cast(GatewayModbusHumanAddressFromWire( - GatewayModbusSpace::kHoldingRegister, start_register + index)); - if (!writeModbusRegisterPoint(holding_register, value)) { - ok = false; - break; - } - } - if (!ok) { - SendModbusException(client_sock, header, pdu[0], 0x04); - continue; - } - std::vector response(5); - response[0] = pdu[0]; - WriteBe16(&response[1], start_register); - WriteBe16(&response[3], quantity); - SendModbusFrame(client_sock, header, response); - continue; - } - - SendModbusException(client_sock, header, pdu[0], 0x01); + SendModbusFrame(client_sock, header, processModbusPdu(config, header[6], pdu)); } } + esp_err_t installSerialModbus(const GatewayModbusConfig& config) { + const auto uart_port = static_cast(config.serial.uart_port); + uart_config_t uart_config{}; + uart_config.baud_rate = static_cast(config.serial.baudrate); + uart_config.data_bits = UartWordLength(config.serial.data_bits); + uart_config.parity = UartParity(config.serial.parity); + uart_config.stop_bits = UartStopBits(config.serial.stop_bits); + uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; + uart_config.source_clk = UART_SCLK_DEFAULT; + + esp_err_t err = uart_param_config(uart_port, &uart_config); + if (err != ESP_OK) { + return err; + } + const int rts_pin = config.serial.rs485.enabled ? config.serial.rs485.de_pin + : UART_PIN_NO_CHANGE; + err = uart_set_pin(uart_port, config.serial.tx_pin, config.serial.rx_pin, rts_pin, + UART_PIN_NO_CHANGE); + if (err != ESP_OK) { + return err; + } + err = uart_driver_install(uart_port, static_cast(config.serial.rx_buffer_size), + static_cast(config.serial.tx_buffer_size), 0, nullptr, 0); + if (err != ESP_OK) { + return err; + } + if (config.serial.rs485.enabled) { + err = uart_set_mode(uart_port, UART_MODE_RS485_HALF_DUPLEX); + if (err != ESP_OK) { + uart_driver_delete(uart_port); + return err; + } + } + modbus_uart_port = config.serial.uart_port; + return ESP_OK; + } + + void modbusSerialTaskLoop(const GatewayModbusConfig& config) { + const esp_err_t err = installSerialModbus(config); + if (err != ESP_OK) { + modbus_last_error = "failed to install Modbus serial UART"; + ESP_LOGE(kTag, "gateway=%u failed to install Modbus serial UART%d: %s", + channel.gateway_id, config.serial.uart_port, esp_err_to_name(err)); + return; + } + ESP_LOGI(kTag, "gateway=%u Modbus %s listening on UART%d baud=%lu", channel.gateway_id, + GatewayModbusTransportIsAscii(config.transport) ? "ASCII" : "RTU", + config.serial.uart_port, static_cast(config.serial.baudrate)); + if (GatewayModbusTransportIsAscii(config.transport)) { + modbusAsciiTaskLoop(config); + } else { + modbusRtuTaskLoop(config); + } + uart_driver_delete(static_cast(config.serial.uart_port)); + } + + void modbusRtuTaskLoop(const GatewayModbusConfig& config) { + const auto uart_port = static_cast(config.serial.uart_port); + std::vector frame; + std::array read_buffer{}; + const TickType_t timeout = pdMS_TO_TICKS(config.serial.response_timeout_ms); + while (!modbus_stop_requested) { + const int read_len = uart_read_bytes(uart_port, read_buffer.data(), read_buffer.size(), timeout); + if (read_len > 0) { + frame.insert(frame.end(), read_buffer.begin(), read_buffer.begin() + read_len); + if (!frame.empty() && frame.front() == '@' && + std::find(frame.begin(), frame.end(), '\n') != frame.end()) { + const std::string line(frame.begin(), frame.end()); + handleModbusManagementLine(config.serial.uart_port, line); + frame.clear(); + } else if (frame.size() > 512) { + frame.clear(); + } + continue; + } + if (frame.empty() || frame.front() == '@') { + continue; + } + handleModbusRtuFrame(config, frame); + frame.clear(); + } + } + + void handleModbusRtuFrame(const GatewayModbusConfig& config, const std::vector& frame) { + if (frame.size() < 4) { + return; + } + const uint16_t received_crc = static_cast(frame[frame.size() - 2] | + (frame[frame.size() - 1] << 8)); + if (ModbusCrc16(frame.data(), frame.size() - 2) != received_crc) { + return; + } + const uint8_t unit_id = frame[0]; + if (unit_id == 0) { + return; + } + const std::vector pdu(frame.begin() + 1, frame.end() - 2); + const auto response_pdu = processModbusPdu(config, unit_id, pdu); + if (response_pdu.empty()) { + return; + } + std::vector response; + response.reserve(1 + response_pdu.size() + 2); + response.push_back(unit_id); + response.insert(response.end(), response_pdu.begin(), response_pdu.end()); + const uint16_t crc = ModbusCrc16(response.data(), response.size()); + response.push_back(static_cast(crc & 0xFF)); + response.push_back(static_cast((crc >> 8) & 0xFF)); + uart_write_bytes(static_cast(config.serial.uart_port), response.data(), + response.size()); + } + + void modbusAsciiTaskLoop(const GatewayModbusConfig& config) { + const auto uart_port = static_cast(config.serial.uart_port); + std::string line; + std::array read_buffer{}; + const TickType_t timeout = pdMS_TO_TICKS(config.serial.response_timeout_ms); + while (!modbus_stop_requested) { + const int read_len = uart_read_bytes(uart_port, read_buffer.data(), read_buffer.size(), timeout); + if (read_len <= 0) { + continue; + } + for (int i = 0; i < read_len; ++i) { + const char ch = static_cast(read_buffer[i]); + if (line.empty()) { + if (ch != ':' && ch != '@') { + continue; + } + } + line.push_back(ch); + if (ch == '\n') { + if (LineStartsWith(line, kModbusManagementPrefix)) { + handleModbusManagementLine(config.serial.uart_port, line); + } else if (!line.empty() && line.front() == ':') { + handleModbusAsciiFrame(config, line); + } + line.clear(); + } else if (line.size() > 1024) { + line.clear(); + } + } + } + } + + void handleModbusAsciiFrame(const GatewayModbusConfig& config, std::string_view line) { + const auto decoded = DecodeModbusAsciiLine(line); + if (!decoded.has_value() || decoded->size() < 4) { + return; + } + const uint8_t unit_id = decoded->front(); + if (unit_id == 0) { + return; + } + const std::vector pdu(decoded->begin() + 1, decoded->end() - 1); + const auto response_pdu = processModbusPdu(config, unit_id, pdu); + if (response_pdu.empty()) { + return; + } + std::vector response; + response.reserve(1 + response_pdu.size() + 1); + response.push_back(unit_id); + response.insert(response.end(), response_pdu.begin(), response_pdu.end()); + response.push_back(ModbusAsciiLrc(response.data(), response.size())); + const std::string encoded = EncodeModbusAsciiLine(response); + uart_write_bytes(static_cast(config.serial.uart_port), encoded.data(), + encoded.size()); + } + + void handleModbusManagementLine(int uart_port, std::string_view line) { + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) { + line.remove_suffix(1); + } + if (!LineStartsWith(line, kModbusManagementPrefix)) { + return; + } + line.remove_prefix(std::char_traits::length(kModbusManagementPrefix)); + while (!line.empty() && line.front() == ' ') { + line.remove_prefix(1); + } + cJSON* root = line.empty() ? cJSON_CreateObject() : cJSON_ParseWithLength(line.data(), line.size()); + if (root == nullptr || !cJSON_IsObject(root)) { + cJSON_Delete(root); + writeModbusManagementResponse(uart_port, false, "unknown", "invalid JSON"); + return; + } + const auto gateway_id = JsonGatewayId(root); + if (gateway_id.has_value() && gateway_id.value() != channel.gateway_id) { + cJSON_Delete(root); + writeModbusManagementResponse(uart_port, false, "unknown", "gateway id mismatch"); + return; + } + const char* action_raw = JsonString(root, "action"); + const std::string action = action_raw == nullptr ? "modbus_status" : action_raw; + if (action == "modbus_config") { + const cJSON* modbus_node = cJSON_GetObjectItemCaseSensitive(root, "modbus"); + if (modbus_node == nullptr) { + modbus_node = root; + } + const DaliValue modbus_value = FromCjson(modbus_node); + const auto parsed = GatewayModbusConfigFromValue(&modbus_value); + if (!parsed.has_value()) { + cJSON_Delete(root); + writeModbusManagementResponse(uart_port, false, action.c_str(), "invalid modbus config"); + return; + } + const esp_err_t err = saveModbusConfig(parsed.value()); + cJSON_Delete(root); + if (err != ESP_OK) { + writeModbusManagementResponse(uart_port, false, action.c_str(), esp_err_to_name(err)); + return; + } + writeModbusManagementResponse(uart_port, true, action.c_str(), nullptr); + modbus_restart_requested = true; + modbus_stop_requested = true; + return; + } + if (action == "modbus_stop") { + cJSON_Delete(root); + writeModbusManagementResponse(uart_port, true, action.c_str(), nullptr); + modbus_stop_requested = true; + return; + } + if (action == "modbus_start" || action == "modbus_status") { + cJSON_Delete(root); + writeModbusManagementResponse(uart_port, true, action.c_str(), nullptr); + return; + } + cJSON_Delete(root); + writeModbusManagementResponse(uart_port, false, action.c_str(), "unknown action"); + } + + void writeModbusManagementResponse(int uart_port, const bool ok, const char* action, + const char* error) const { + cJSON* root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", ok); + cJSON_AddNumberToObject(root, "gw", channel.gateway_id); + cJSON_AddStringToObject(root, "action", action == nullptr ? "unknown" : action); + if (const auto config = activeModbusConfig()) { + cJSON_AddStringToObject(root, "transport", config->transport.c_str()); + cJSON_AddNumberToObject(root, "unitID", config->unit_id); + if (GatewayModbusTransportIsSerial(config->transport)) { + cJSON_AddNumberToObject(root, "uartPort", config->serial.uart_port); + cJSON_AddNumberToObject(root, "baudrate", config->serial.baudrate); + } else { + cJSON_AddNumberToObject(root, "port", config->port); + } + } + if (error != nullptr) { + cJSON_AddStringToObject(root, "error", error); + } + const std::string body = "@DALIGW " + PrintJson(root) + "\n"; + cJSON_Delete(root); + uart_write_bytes(static_cast(uart_port), body.data(), body.size()); + } + }; GatewayBridgeService::GatewayBridgeService(DaliDomainService& dali_domain, @@ -2696,8 +3176,9 @@ esp_err_t GatewayBridgeService::start() { if (config_.modbus_enabled && config_.modbus_startup_enabled) { std::set used_ports; + std::set used_uarts; for (const auto& runtime : runtimes_) { - const esp_err_t err = runtime->startModbus(&used_ports); + const esp_err_t err = runtime->startModbus(&used_ports, &used_uarts); if (err != ESP_OK && err != ESP_ERR_NOT_FOUND && err != ESP_ERR_NOT_SUPPORTED) { ESP_LOGW(kTag, "gateway=%u Modbus startup skipped: %s", runtime->channel.gateway_id, esp_err_to_name(err)); @@ -2981,7 +3462,14 @@ GatewayBridgeHttpResponse GatewayBridgeService::handlePost( if (action == "modbus_start") { const esp_err_t err = runtime->startModbus(); if (err != ESP_OK) { - return ErrorResponse(err, "failed to start Modbus TCP bridge"); + return ErrorResponse(err, "failed to start Modbus bridge"); + } + return handleGet("modbus", gateway_id.value()); + } + if (action == "modbus_stop") { + const esp_err_t err = runtime->stopModbus(); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to stop Modbus bridge"); } return handleGet("modbus", gateway_id.value()); } diff --git a/components/gateway_modbus/include/gateway_modbus.hpp b/components/gateway_modbus/include/gateway_modbus.hpp index eaedf5f..181df71 100644 --- a/components/gateway_modbus/include/gateway_modbus.hpp +++ b/components/gateway_modbus/include/gateway_modbus.hpp @@ -18,14 +18,43 @@ constexpr uint16_t kGatewayModbusMaxReadBits = 2000; constexpr uint16_t kGatewayModbusMaxReadRegisters = 125; constexpr uint16_t kGatewayModbusMaxWriteBits = 1968; constexpr uint16_t kGatewayModbusMaxWriteRegisters = 123; +constexpr uint32_t kGatewayModbusDefaultSerialBaudrate = 9600; +constexpr uint32_t kGatewayModbusDefaultSerialResponseTimeoutMs = 20; +constexpr uint32_t kGatewayModbusDefaultSerialInterFrameGapUs = 4000; + +struct GatewayModbusRs485Config { + bool enabled{false}; + int de_pin{-1}; +}; + +struct GatewayModbusSerialConfig { + int uart_port{1}; + int tx_pin{-1}; + int rx_pin{-1}; + uint32_t baudrate{kGatewayModbusDefaultSerialBaudrate}; + int data_bits{8}; + std::string parity{"none"}; + int stop_bits{1}; + size_t rx_buffer_size{512}; + size_t tx_buffer_size{512}; + uint32_t response_timeout_ms{kGatewayModbusDefaultSerialResponseTimeoutMs}; + uint32_t inter_frame_gap_us{kGatewayModbusDefaultSerialInterFrameGapUs}; + GatewayModbusRs485Config rs485; +}; struct GatewayModbusConfig { std::string transport{"tcp-server"}; std::string host; uint16_t port{kGatewayModbusDefaultTcpPort}; uint8_t unit_id{1}; + GatewayModbusSerialConfig serial; }; +bool GatewayModbusTransportIsTcp(const std::string& transport); +bool GatewayModbusTransportIsRtu(const std::string& transport); +bool GatewayModbusTransportIsAscii(const std::string& transport); +bool GatewayModbusTransportIsSerial(const std::string& transport); + enum class GatewayModbusSpace : uint8_t { kCoil = 1, kDiscreteInput = 2, diff --git a/components/gateway_modbus/src/gateway_modbus.cpp b/components/gateway_modbus/src/gateway_modbus.cpp index e7fa2c5..dc3dbe0 100644 --- a/components/gateway_modbus/src/gateway_modbus.cpp +++ b/components/gateway_modbus/src/gateway_modbus.cpp @@ -396,8 +396,44 @@ GatewayModbusPointBinding toBinding(const GatewayModbusPoint& point) { point.diagnostic_device_type}; } +int clampedInt(const DaliValue::Object& json, const std::string& key, int fallback, + int min_value, int max_value) { + const int value = getObjectInt(json, key).value_or(fallback); + return std::clamp(value, min_value, max_value); +} + +uint32_t clampedU32(const DaliValue::Object& json, const std::string& key, uint32_t fallback, + uint32_t min_value, uint32_t max_value) { + const int value = getObjectInt(json, key).value_or(static_cast(fallback)); + return static_cast(std::clamp(value, static_cast(min_value), + static_cast(max_value))); +} + +size_t clampedSize(const DaliValue::Object& json, const std::string& key, size_t fallback, + size_t min_value, size_t max_value) { + const int value = getObjectInt(json, key).value_or(static_cast(fallback)); + return static_cast(std::clamp(value, static_cast(min_value), + static_cast(max_value))); +} + } // namespace +bool GatewayModbusTransportIsTcp(const std::string& transport) { + return transport.empty() || transport == "tcp" || transport == "tcp-server"; +} + +bool GatewayModbusTransportIsRtu(const std::string& transport) { + return transport == "rtu" || transport == "rtu-server" || transport == "modbus-rtu"; +} + +bool GatewayModbusTransportIsAscii(const std::string& transport) { + return transport == "ascii" || transport == "ascii-server" || transport == "modbus-ascii"; +} + +bool GatewayModbusTransportIsSerial(const std::string& transport) { + return GatewayModbusTransportIsRtu(transport) || GatewayModbusTransportIsAscii(transport); +} + std::optional GatewayModbusConfigFromValue(const DaliValue* value) { if (value == nullptr || value->asObject() == nullptr) { return std::nullopt; @@ -410,6 +446,35 @@ std::optional GatewayModbusConfigFromValue(const DaliValue* getObjectInt(json, "port").value_or(kGatewayModbusDefaultTcpPort)); config.unit_id = static_cast(getObjectInt(json, "unitID").value_or( getObjectInt(json, "unitId").value_or(getObjectInt(json, "unit_id").value_or(1)))); + if (const auto* serial_value = getObjectValue(json, "serial")) { + if (const auto* serial = serial_value->asObject()) { + config.serial.uart_port = clampedInt(*serial, "uartPort", config.serial.uart_port, 0, 2); + config.serial.tx_pin = clampedInt(*serial, "txPin", config.serial.tx_pin, -1, 48); + config.serial.rx_pin = clampedInt(*serial, "rxPin", config.serial.rx_pin, -1, 48); + config.serial.baudrate = clampedU32(*serial, "baudrate", config.serial.baudrate, + 1200, 921600); + config.serial.data_bits = clampedInt(*serial, "dataBits", config.serial.data_bits, 7, 8); + config.serial.parity = getObjectString(*serial, "parity").value_or(config.serial.parity); + config.serial.stop_bits = clampedInt(*serial, "stopBits", config.serial.stop_bits, 1, 2); + config.serial.rx_buffer_size = clampedSize(*serial, "rxBufferBytes", + config.serial.rx_buffer_size, 128, 4096); + config.serial.tx_buffer_size = clampedSize(*serial, "txBufferBytes", + config.serial.tx_buffer_size, 0, 4096); + config.serial.response_timeout_ms = clampedU32(*serial, "responseTimeoutMs", + config.serial.response_timeout_ms, 1, 1000); + config.serial.inter_frame_gap_us = clampedU32(*serial, "interFrameGapUs", + config.serial.inter_frame_gap_us, 1000, + 100000); + if (const auto* rs485_value = getObjectValue(*serial, "rs485")) { + if (const auto* rs485 = rs485_value->asObject()) { + config.serial.rs485.enabled = getObjectBool(*rs485, "enabled") + .value_or(config.serial.rs485.enabled); + config.serial.rs485.de_pin = clampedInt(*rs485, "dePin", + config.serial.rs485.de_pin, -1, 48); + } + } + } + } return config; } @@ -419,6 +484,23 @@ DaliValue GatewayModbusConfigToValue(const GatewayModbusConfig& config) { out["host"] = config.host; out["port"] = static_cast(config.port); out["unitID"] = static_cast(config.unit_id); + DaliValue::Object serial; + serial["uartPort"] = config.serial.uart_port; + serial["txPin"] = config.serial.tx_pin; + serial["rxPin"] = config.serial.rx_pin; + serial["baudrate"] = static_cast(config.serial.baudrate); + serial["dataBits"] = config.serial.data_bits; + serial["parity"] = config.serial.parity; + serial["stopBits"] = config.serial.stop_bits; + serial["rxBufferBytes"] = static_cast(config.serial.rx_buffer_size); + serial["txBufferBytes"] = static_cast(config.serial.tx_buffer_size); + serial["responseTimeoutMs"] = static_cast(config.serial.response_timeout_ms); + serial["interFrameGapUs"] = static_cast(config.serial.inter_frame_gap_us); + DaliValue::Object rs485; + rs485["enabled"] = config.serial.rs485.enabled; + rs485["dePin"] = config.serial.rs485.de_pin; + serial["rs485"] = std::move(rs485); + out["serial"] = std::move(serial); return DaliValue(std::move(out)); }