Skip to content

Commit cf33c8e

Browse files
authored
Merge pull request #39 from zhongys-c8y/feature-backup-write-operations
feat: Add write operation handlers for coils and registers
2 parents 4ba6ed4 + 1f31c20 commit cf33c8e

File tree

10 files changed

+511
-7
lines changed

10 files changed

+511
-7
lines changed

config/modbus.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,9 @@ databits=8
1212

1313
[thinedge]
1414
mqtthost="127.0.0.1"
15-
mqttport=1883
15+
mqttport=1883
16+
# Subscribe to MQTT topics for receiving messages
17+
subscribe_topics = [
18+
"te/device/+///cmd/modbus_SetRegister/+",
19+
"te/device/+///cmd/modbus_SetCoil/+",
20+
]

images/simulator/modbus.json

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,9 @@
3434
}
3535
},
3636
"invalid": [1],
37-
"write": [3],
37+
"write": [3, 48],
3838
"bits": [
39-
{
40-
"addr": 2,
41-
"value": -2,
42-
"action": "increment"
43-
}
39+
48
4440
],
4541
"uint16": [
4642
{

operations/c8y_SetCoil.template

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[exec]
2+
topic = "c8y/devicecontrol/notifications"
3+
on_fragment = "c8y_SetCoil"
4+
5+
[exec.workflow]
6+
operation = "modbus_SetCoil"
7+
input = "${.payload.c8y_SetCoil}"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[exec]
2+
topic = "c8y/devicecontrol/notifications"
3+
on_fragment = "c8y_SetRegister"
4+
5+
[exec.workflow]
6+
operation = "modbus_SetRegister"
7+
input = "${.payload.c8y_SetRegister}"

tedge_modbus/operations/__main__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# pylint: disable=R0801, duplicate-code
12
"""thin-edge.io Modbus operations handlers"""
23

34
import sys

tedge_modbus/operations/common.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Common helpers for Modbus operation handlers."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import logging
7+
8+
import toml
9+
from pymodbus.client import ModbusSerialClient, ModbusTcpClient
10+
11+
12+
def parse_json_arguments(arguments: str | list[str]) -> dict:
13+
"""Parse JSON arguments which may be a string or list of segments.
14+
15+
Raises ValueError on invalid JSON.
16+
"""
17+
if isinstance(arguments, str):
18+
raw = arguments
19+
else:
20+
raw = arguments[0] if len(arguments) == 1 else ",".join(arguments)
21+
try:
22+
return json.loads(raw)
23+
except json.JSONDecodeError as err:
24+
raise ValueError(f"Invalid JSON payload: {err}") from err
25+
26+
27+
def resolve_target_device(
28+
ip_address: str, slave_id: int, devices_path
29+
) -> tuple[dict, str]:
30+
"""Resolve device connection parameters from ip or devices.toml.
31+
32+
Returns (target_device, protocol).
33+
"""
34+
if ip_address:
35+
ip = ip_address or "127.0.0.1"
36+
protocol = "TCP"
37+
target_device = {
38+
"protocol": "TCP",
39+
"ip": ip,
40+
"port": 502,
41+
"address": slave_id,
42+
}
43+
else:
44+
devices_cfg = toml.load(devices_path)
45+
devices = devices_cfg.get("device", []) or []
46+
target_device = next(
47+
(d for d in devices if d.get("address") == slave_id), None
48+
) or next((d for d in devices if d.get("protocol") == "TCP"), None)
49+
if target_device is None:
50+
raise ValueError(f"No suitable device found in {devices_path}")
51+
protocol = target_device.get("protocol")
52+
return target_device, protocol # type: ignore[return-value]
53+
54+
55+
def backfill_serial_defaults(
56+
target_device: dict, protocol: str, base_config: dict
57+
) -> None:
58+
"""For RTU devices, backfill serial settings from base config if missing."""
59+
if protocol == "RTU":
60+
serial_defaults = base_config.get("serial") or {}
61+
for key in ["port", "baudrate", "stopbits", "parity", "databits"]:
62+
if target_device.get(key) is None and key in serial_defaults:
63+
target_device[key] = serial_defaults[key]
64+
65+
66+
def build_modbus_client(target_device: dict, protocol: str):
67+
"""Create a pymodbus client for given target and protocol."""
68+
if protocol == "TCP":
69+
return ModbusTcpClient(
70+
host=target_device["ip"],
71+
port=target_device["port"],
72+
auto_open=True,
73+
auto_close=True,
74+
debug=True,
75+
)
76+
if protocol == "RTU":
77+
return ModbusSerialClient(
78+
port=target_device["port"],
79+
baudrate=target_device["baudrate"],
80+
stopbits=target_device["stopbits"],
81+
parity=target_device["parity"],
82+
bytesize=target_device["databits"],
83+
)
84+
raise ValueError("Expected protocol to be RTU or TCP. Got " + str(protocol) + ".")
85+
86+
87+
def close_client_quietly(client) -> None:
88+
"""Close a pymodbus client and ignore any exceptions."""
89+
try:
90+
client.close()
91+
except Exception:
92+
pass
93+
94+
95+
def prepare_client(
96+
ip_address: str,
97+
slave_id: int,
98+
devices_path,
99+
base_config: dict,
100+
):
101+
"""Resolve target device, backfill defaults, and build a Modbus client."""
102+
target_device, protocol = resolve_target_device(ip_address, slave_id, devices_path)
103+
backfill_serial_defaults(target_device, protocol, base_config)
104+
return build_modbus_client(target_device, protocol)
105+
106+
107+
def apply_loglevel(logger, base_config: dict) -> None:
108+
"""Apply log level from base configuration to given logger."""
109+
loglevel = base_config["modbus"].get("loglevel") or "INFO"
110+
logger.setLevel(getattr(logging, loglevel.upper(), logging.INFO))
111+
112+
113+
def parse_register_params(payload: dict) -> dict:
114+
"""Parse and validate register operation parameters into a single dict.
115+
116+
Returns a dict with keys: ip_address, slave_id, register, start_bit, num_bits, write_value.
117+
"""
118+
ip_address = (payload.get("ipAddress") or "").strip()
119+
try:
120+
return {
121+
"ip_address": ip_address,
122+
"slave_id": int(payload["address"]),
123+
"register": int(payload["register"]),
124+
"start_bit": int(payload.get("startBit", 0)),
125+
"num_bits": int(payload.get("noBits", 16)),
126+
"write_value": int(payload["value"]),
127+
}
128+
except KeyError as err:
129+
raise ValueError(f"Missing required field: {err}") from err
130+
except (TypeError, ValueError) as err:
131+
raise ValueError(f"Invalid numeric field: {err}") from err
132+
133+
134+
def compute_masked_value(
135+
current_value: int, start_bit: int, num_bits: int, write_value: int
136+
) -> int:
137+
"""Validate bit-field and compute new register value with masked bits applied."""
138+
if start_bit < 0 or num_bits <= 0 or start_bit + num_bits > 16:
139+
raise ValueError(
140+
"startBit and noBits must define a range within a 16-bit register"
141+
)
142+
max_value = (1 << num_bits) - 1
143+
if write_value < 0 or write_value > max_value:
144+
raise ValueError(f"value must be within 0..{max_value} for noBits={num_bits}")
145+
mask = ((1 << num_bits) - 1) << start_bit
146+
return (current_value & ~mask) | ((write_value << start_bit) & mask)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env python3
2+
"""Modbus Write Coil Status operation handler"""
3+
import logging
4+
5+
from pymodbus.exceptions import ConnectionException
6+
from .context import Context
7+
from .common import (
8+
parse_json_arguments,
9+
prepare_client,
10+
apply_loglevel,
11+
close_client_quietly,
12+
)
13+
14+
logger = logging.getLogger(__name__)
15+
logging.basicConfig(
16+
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
17+
)
18+
19+
20+
def run(arguments: str | list[str]) -> None:
21+
"""Run set coil operation handler
22+
Expected arguments (JSON):
23+
{
24+
"input": false,
25+
"address": < Fieldbusaddress >,
26+
"coil": < coilnumber >,
27+
"value": < 0 | 1 >
28+
}
29+
Parse JSON payload"""
30+
payload = parse_json_arguments(arguments)
31+
32+
# Create context with default config directory
33+
context = Context()
34+
35+
# Load configs and set log level
36+
modbus_config = context.base_config
37+
apply_loglevel(logger, modbus_config)
38+
logger.info("New set coil operation. args=%s", arguments)
39+
40+
try:
41+
slave_id = int(payload["address"]) # Fieldbus address
42+
coil_number = int(payload["coil"]) # Coil address
43+
value_int = int(payload["value"]) # 0 or 1
44+
except KeyError as err:
45+
raise ValueError(f"Missing required field: {err}") from err
46+
except (TypeError, ValueError) as err:
47+
raise ValueError(f"Invalid numeric field: {err}") from err
48+
49+
if value_int not in (0, 1):
50+
raise ValueError("value must be 0 or 1 for a coil write")
51+
52+
# Prepare client (resolve target, backfill defaults, build client)
53+
client = prepare_client(
54+
payload["ipAddress"],
55+
slave_id,
56+
context.config_dir / "devices.toml",
57+
modbus_config,
58+
)
59+
60+
try:
61+
coil_value = bool(value_int)
62+
result = client.write_coil(
63+
address=coil_number,
64+
value=coil_value,
65+
slave=slave_id,
66+
)
67+
if result.isError():
68+
raise RuntimeError(f"Failed to write coil {coil_number}: {result}")
69+
logger.info(
70+
"Wrote %s to coil %d on slave %d", coil_value, coil_number, slave_id
71+
)
72+
except ConnectionException as err:
73+
logger.error("Connection error while writing to slave %d: %s", slave_id, err)
74+
raise
75+
finally:
76+
close_client_quietly(client)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# pylint: disable=duplicate-code
2+
"""Modbus Write register status operation handler"""
3+
import logging
4+
5+
from pymodbus.exceptions import ConnectionException
6+
from .context import Context
7+
from .common import (
8+
parse_json_arguments,
9+
prepare_client,
10+
apply_loglevel,
11+
close_client_quietly,
12+
parse_register_params,
13+
compute_masked_value,
14+
)
15+
16+
logger = logging.getLogger(__name__)
17+
logging.basicConfig(
18+
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
19+
)
20+
21+
22+
def run(arguments: str | list[str]) -> None:
23+
"""Run set register operation handler
24+
Expected arguments (JSON):
25+
{
26+
"input": false,
27+
"ipAddress": <ip address or empty>,
28+
"address": <Fieldbus address>,
29+
"register": <register number>,
30+
"startBit": <start bit>,
31+
"noBits": <number of bits>,
32+
"value": <register value>
33+
}
34+
Parse JSON arguments. Depending on the caller, we may receive the JSON as a single
35+
string or a list of comma-split segments. Handle both cases robustly."""
36+
payload = parse_json_arguments(arguments)
37+
38+
# Create context with default config directory
39+
context = Context()
40+
41+
# Load configs and set log level
42+
modbus_config = context.base_config
43+
apply_loglevel(logger, modbus_config)
44+
logger.info("New set register operation")
45+
46+
# Parse required fields from JSON
47+
params = parse_register_params(payload)
48+
49+
# Prepare client (resolve target, backfill defaults, build client)
50+
client = prepare_client(
51+
params["ip_address"],
52+
params["slave_id"],
53+
context.config_dir / "devices.toml",
54+
modbus_config,
55+
)
56+
57+
# Validate and compute new value
58+
59+
try:
60+
# Read current register value
61+
read_resp = client.read_holding_registers(
62+
address=params["register"], count=1, slave=params["slave_id"]
63+
)
64+
if read_resp.isError():
65+
raise RuntimeError(
66+
f"Failed to read register {params['register']}: {read_resp}"
67+
)
68+
current_value = read_resp.registers[0] & 0xFFFF
69+
new_value = compute_masked_value(
70+
current_value,
71+
params["start_bit"],
72+
params["num_bits"],
73+
params["write_value"],
74+
)
75+
76+
# Write back register
77+
write_resp = client.write_register(
78+
address=params["register"], value=new_value, slave=params["slave_id"]
79+
)
80+
if write_resp.isError():
81+
raise RuntimeError(
82+
f"Failed to write register {params['register']}: {write_resp}"
83+
)
84+
logger.info(
85+
"Updated register %d (bits %d..%d) from 0x%04X to 0x%04X on slave %d",
86+
params["register"],
87+
params["start_bit"],
88+
params["start_bit"] + params["num_bits"] - 1,
89+
current_value,
90+
new_value,
91+
params["slave_id"],
92+
)
93+
except ConnectionException as err:
94+
logger.error(
95+
"Connection error while writing to slave %d: %s",
96+
params["slave_id"],
97+
err,
98+
)
99+
raise
100+
finally:
101+
close_client_quietly(client)

0 commit comments

Comments
 (0)