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 <tonylu@tony-cloud.com>
This commit is contained in:
Tony
2026-05-04 14:34:05 +08:00
parent 640e78f688
commit 34d2d9caa0
10 changed files with 1364 additions and 204 deletions
+361
View File
@@ -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.
+5 -3
View File
@@ -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:
+91 -7
View File
@@ -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"
+106 -3
View File
@@ -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<gateway::DaliDomainService> s_dali_domain;
std::unique_ptr<gateway::GatewayRuntime> s_runtime;
std::unique_ptr<gateway::GatewayCache> 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<uint32_t>(CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_STACK_SIZE);
bridge_config.modbus_task_priority =
static_cast<UBaseType_t>(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<uint16_t>(CONFIG_GATEWAY_MODBUS_TCP_PORT);
default_modbus.unit_id = static_cast<uint8_t>(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<uint32_t>(CONFIG_GATEWAY_MODBUS_SERIAL_BAUDRATE);
default_modbus.serial.response_timeout_ms =
static_cast<uint32_t>(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<uint32_t>(CONFIG_GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE);
bridge_config.bacnet_task_priority =
+5
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@ set(GATEWAY_BRIDGE_REQUIRES
dali_domain
dali_cpp
espressif__cjson
esp_driver_uart
freertos
gateway_cache
gateway_modbus
@@ -2,12 +2,14 @@
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#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<GatewayModbusConfig> default_modbus_config;
bool allow_modbus_uart0{false};
std::vector<int> reserved_uart_ports;
uint32_t bacnet_task_stack_size{8192};
UBaseType_t bacnet_task_priority{5};
};
+647 -159
View File
@@ -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 <algorithm>
#include <array>
#include <atomic>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <cstring>
@@ -29,6 +33,7 @@
#include <optional>
#include <set>
#include <string_view>
#include <sys/time.h>
#include <utility>
#include <vector>
@@ -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<uint8_t>&
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<uint8_t> pdu{static_cast<uint8_t>(function_code | 0x80), exception_code};
return SendModbusFrame(sock, mbap, pdu);
std::vector<uint8_t> ModbusExceptionPdu(uint8_t function_code, uint8_t exception_code) {
return {static_cast<uint8_t>(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<uint16_t>((crc >> 1) ^ 0xA001);
} else {
crc = static_cast<uint16_t>(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<uint8_t>(sum + data[i]);
}
return static_cast<uint8_t>(-sum);
}
std::optional<uint8_t> HexNibble(char ch) {
if (ch >= '0' && ch <= '9') {
return static_cast<uint8_t>(ch - '0');
}
if (ch >= 'A' && ch <= 'F') {
return static_cast<uint8_t>(ch - 'A' + 10);
}
if (ch >= 'a' && ch <= 'f') {
return static_cast<uint8_t>(ch - 'a' + 10);
}
return std::nullopt;
}
std::optional<std::vector<uint8_t>> 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<uint8_t> 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<uint8_t>((high.value() << 4) | low.value()));
}
uint8_t sum = 0;
for (const auto byte : bytes) {
sum = static_cast<uint8_t>(sum + byte);
}
if (sum != 0) {
return std::nullopt;
}
return bytes;
}
std::string EncodeModbusAsciiLine(const std::vector<uint8_t>& 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<uint16_t>* used_ports = nullptr) {
std::optional<GatewayModbusConfig> activeModbusConfigLocked() const {
if (modbus_config.has_value()) {
return modbus_config;
}
return service_config.default_modbus_config;
}
std::optional<GatewayModbusConfig> 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<uint8_t> processModbusPdu(const GatewayModbusConfig& config,
uint8_t unit_id,
const std::vector<uint8_t>& 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<uint8_t>((quantity + 7U) / 8U);
std::vector<uint8_t> 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<uint16_t>(
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<uint8_t>(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<uint8_t> response(2 + quantity * 2);
response[0] = pdu[0];
response[1] = static_cast<uint8_t>(quantity * 2);
for (uint16_t index = 0; index < quantity; ++index) {
const auto human_address = static_cast<uint16_t>(
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<uint16_t>(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<uint16_t>(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<size_t>(6 + byte_count) ||
byte_count != static_cast<uint8_t>((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<uint16_t>(GatewayModbusHumanAddressFromWire(
GatewayModbusSpace::kCoil, start_address + index));
if (!writeModbusCoilPoint(coil, value)) {
return ModbusExceptionPdu(pdu[0], 0x04);
}
}
std::vector<uint8_t> 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<size_t>(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<uint16_t>(GatewayModbusHumanAddressFromWire(
GatewayModbusSpace::kHoldingRegister, start_register + index));
if (!writeModbusRegisterPoint(holding_register, value)) {
return ModbusExceptionPdu(pdu[0], 0x04);
}
}
std::vector<uint8_t> 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<uint16_t>* used_ports = nullptr,
std::set<int>* 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<sockaddr*>(&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<sockaddr*>(&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,173 +2884,263 @@ 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;
SendModbusFrame(client_sock, header, processModbusPdu(config, header[6], pdu));
}
}
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;
esp_err_t installSerialModbus(const GatewayModbusConfig& config) {
const auto uart_port = static_cast<uart_port_t>(config.serial.uart_port);
uart_config_t uart_config{};
uart_config.baud_rate = static_cast<int>(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 uint8_t byte_count = static_cast<uint8_t>((quantity + 7U) / 8U);
std::vector<uint8_t> 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<uint16_t>(
GatewayModbusHumanAddressFromWire(space.value(), start_address + index));
const auto value = readModbusBoolPoint(space.value(), human_address);
if (!value.has_value()) {
ok = false;
break;
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;
}
if (value.value()) {
response[2 + (index / 8)] |= static_cast<uint8_t>(1U << (index % 8));
err = uart_driver_install(uart_port, static_cast<int>(config.serial.rx_buffer_size),
static_cast<int>(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;
}
}
if (!ok) {
SendModbusException(client_sock, header, pdu[0], 0x02);
continue;
}
SendModbusFrame(client_sock, header, response);
continue;
modbus_uart_port = config.serial.uart_port;
return ESP_OK;
}
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;
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;
}
std::vector<uint8_t> response(2 + quantity * 2);
response[0] = pdu[0];
response[1] = static_cast<uint8_t>(quantity * 2);
bool ok = true;
for (uint16_t index = 0; index < quantity; ++index) {
const auto human_address = static_cast<uint16_t>(
GatewayModbusHumanAddressFromWire(space.value(), start_address + index));
const auto value = readModbusRegisterPoint(space.value(), human_address);
if (!value.has_value()) {
ok = false;
break;
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<unsigned long>(config.serial.baudrate));
if (GatewayModbusTransportIsAscii(config.transport)) {
modbusAsciiTaskLoop(config);
} else {
modbusRtuTaskLoop(config);
}
WriteBe16(&response[2 + index * 2], value.value());
}
if (!ok) {
SendModbusException(client_sock, header, pdu[0], 0x02);
continue;
}
SendModbusFrame(client_sock, header, response);
continue;
uart_driver_delete(static_cast<uart_port_t>(config.serial.uart_port));
}
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);
void modbusRtuTaskLoop(const GatewayModbusConfig& config) {
const auto uart_port = static_cast<uart_port_t>(config.serial.uart_port);
std::vector<uint8_t> frame;
std::array<uint8_t, 128> 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;
}
const auto coil = static_cast<uint16_t>(GatewayModbusHumanAddressFromWire(
GatewayModbusSpace::kCoil, wire_address));
if (!writeModbusCoilPoint(coil, raw_value == 0xFF00)) {
SendModbusException(client_sock, header, pdu[0], 0x04);
if (frame.empty() || frame.front() == '@') {
continue;
}
SendModbusFrame(client_sock, header, pdu);
continue;
handleModbusRtuFrame(config, frame);
frame.clear();
}
}
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<uint16_t>(GatewayModbusHumanAddressFromWire(
GatewayModbusSpace::kHoldingRegister, wire_register));
if (!writeModbusRegisterPoint(holding_register, value)) {
SendModbusException(client_sock, header, pdu[0], 0x04);
continue;
void handleModbusRtuFrame(const GatewayModbusConfig& config, const std::vector<uint8_t>& frame) {
if (frame.size() < 4) {
return;
}
SendModbusFrame(client_sock, header, pdu);
continue;
const uint16_t received_crc = static_cast<uint16_t>(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<uint8_t> pdu(frame.begin() + 1, frame.end() - 2);
const auto response_pdu = processModbusPdu(config, unit_id, pdu);
if (response_pdu.empty()) {
return;
}
std::vector<uint8_t> 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<uint8_t>(crc & 0xFF));
response.push_back(static_cast<uint8_t>((crc >> 8) & 0xFF));
uart_write_bytes(static_cast<uart_port_t>(config.serial.uart_port), response.data(),
response.size());
}
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<size_t>(6 + byte_count) ||
byte_count != static_cast<uint8_t>((quantity + 7U) / 8U)) {
SendModbusException(client_sock, header, pdu[0], 0x03);
void modbusAsciiTaskLoop(const GatewayModbusConfig& config) {
const auto uart_port = static_cast<uart_port_t>(config.serial.uart_port);
std::string line;
std::array<uint8_t, 128> 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;
}
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<uint16_t>(GatewayModbusHumanAddressFromWire(
GatewayModbusSpace::kCoil, start_address + index));
if (!writeModbusCoilPoint(coil, value)) {
ok = false;
break;
}
}
if (!ok) {
SendModbusException(client_sock, header, pdu[0], 0x04);
for (int i = 0; i < read_len; ++i) {
const char ch = static_cast<char>(read_buffer[i]);
if (line.empty()) {
if (ch != ':' && ch != '@') {
continue;
}
std::vector<uint8_t> response(5);
response[0] = pdu[0];
WriteBe16(&response[1], start_address);
WriteBe16(&response[3], quantity);
SendModbusFrame(client_sock, header, response);
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();
}
}
}
}
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<size_t>(6 + byte_count) ||
byte_count != quantity * 2) {
SendModbusException(client_sock, header, pdu[0], 0x03);
continue;
void handleModbusAsciiFrame(const GatewayModbusConfig& config, std::string_view line) {
const auto decoded = DecodeModbusAsciiLine(line);
if (!decoded.has_value() || decoded->size() < 4) {
return;
}
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<uint16_t>(GatewayModbusHumanAddressFromWire(
GatewayModbusSpace::kHoldingRegister, start_register + index));
if (!writeModbusRegisterPoint(holding_register, value)) {
ok = false;
break;
const uint8_t unit_id = decoded->front();
if (unit_id == 0) {
return;
}
const std::vector<uint8_t> pdu(decoded->begin() + 1, decoded->end() - 1);
const auto response_pdu = processModbusPdu(config, unit_id, pdu);
if (response_pdu.empty()) {
return;
}
if (!ok) {
SendModbusException(client_sock, header, pdu[0], 0x04);
continue;
}
std::vector<uint8_t> response(5);
response[0] = pdu[0];
WriteBe16(&response[1], start_register);
WriteBe16(&response[3], quantity);
SendModbusFrame(client_sock, header, response);
continue;
std::vector<uint8_t> 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<uart_port_t>(config.serial.uart_port), encoded.data(),
encoded.size());
}
SendModbusException(client_sock, header, pdu[0], 0x01);
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<char>::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_t>(uart_port), body.data(), body.size());
}
};
@@ -2696,8 +3176,9 @@ esp_err_t GatewayBridgeService::start() {
if (config_.modbus_enabled && config_.modbus_startup_enabled) {
std::set<uint16_t> used_ports;
std::set<int> 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());
}
@@ -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,
@@ -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<int>(fallback));
return static_cast<uint32_t>(std::clamp(value, static_cast<int>(min_value),
static_cast<int>(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<int>(fallback));
return static_cast<size_t>(std::clamp(value, static_cast<int>(min_value),
static_cast<int>(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<GatewayModbusConfig> GatewayModbusConfigFromValue(const DaliValue* value) {
if (value == nullptr || value->asObject() == nullptr) {
return std::nullopt;
@@ -410,6 +446,35 @@ std::optional<GatewayModbusConfig> GatewayModbusConfigFromValue(const DaliValue*
getObjectInt(json, "port").value_or(kGatewayModbusDefaultTcpPort));
config.unit_id = static_cast<uint8_t>(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<int>(config.port);
out["unitID"] = static_cast<int>(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<int>(config.serial.baudrate);
serial["dataBits"] = config.serial.data_bits;
serial["parity"] = config.serial.parity;
serial["stopBits"] = config.serial.stop_bits;
serial["rxBufferBytes"] = static_cast<int>(config.serial.rx_buffer_size);
serial["txBufferBytes"] = static_cast<int>(config.serial.tx_buffer_size);
serial["responseTimeoutMs"] = static_cast<int>(config.serial.response_timeout_ms);
serial["interFrameGapUs"] = static_cast<int>(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));
}