Skip to content

Commit e54a5ca

Browse files
committed
Supports simple cmd payload without register info
1 parent ae286a1 commit e54a5ca

File tree

6 files changed

+433
-86
lines changed

6 files changed

+433
-86
lines changed

config/devices.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ multiplier=1
1919
divisor=1
2020
decimalshiftright=0
2121
input=false # true = Input Register false = Holding Register
22+
registerid="Test_Int16"
2223
#see https://thin-edge.github.io/thin-edge.io/html/architecture/thin-edge-json.html
2324
measurementmapping.templatestring="{\"Test\":{\"Int16\":%% }}" # tedge JSON format string, %% will be replaced with the calculated value
2425
#measurementmapping.combinemeasurements=true # Overrides device setting; Combines all measurements of a device to reduce the number of created measurements in the cloud
@@ -34,6 +35,7 @@ divisor=1
3435
decimalshiftright=0
3536
input=false
3637
datatype="float"
38+
registerid="Test_Float32"
3739
measurementmapping.templatestring="{\"Test\":{\"Float32\":%% }}"
3840

3941

@@ -42,4 +44,5 @@ number=2
4244
input=false
4345
alarmmapping.severity="MAJOR"
4446
alarmmapping.text="This alarm should be created once"
45-
alarmmapping.type="TestAlarm"
47+
alarmmapping.type="TestAlarm"
48+
coilid="TestAlarm"

tedge_modbus/operations/common.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import logging
7+
import re
78

89
import toml
910
from pymodbus.client import ModbusSerialClient, ModbusTcpClient
@@ -144,3 +145,114 @@ def compute_masked_value(
144145
raise ValueError(f"value must be within 0..{max_value} for noBits={num_bits}")
145146
mask = ((1 << num_bits) - 1) << start_bit
146147
return (current_value & ~mask) | ((write_value << start_bit) & mask)
148+
149+
150+
def extract_device_from_topic(topic: str) -> str:
151+
"""Extract device-id from topic.
152+
153+
Expected topic format: te/device/<device-id>///cmd/modbus_Set{Register|Coil}/<mapper-id>
154+
155+
Supports both SetRegister and SetCoil operations.
156+
157+
Returns device_id or empty string
158+
"""
159+
# Match pattern like:
160+
# - te/device/TestCase1///cmd/modbus_SetRegister/c8y-mapper-123
161+
# - te/device/TestCase1///cmd/modbus_SetCoil/c8y-mapper-123
162+
match = re.search(r"te/device/([^/]+)///cmd/modbus_Set(?:Register|Coil)/.+$", topic)
163+
if match:
164+
return match.group(1)
165+
return ""
166+
167+
168+
def _match_from_metrics( # pylint: disable=too-many-arguments
169+
payload: dict,
170+
device_name: str,
171+
devices_path,
172+
config_type: str,
173+
id_key: str,
174+
value_type,
175+
):
176+
"""Generic function to match register/coil from devices.toml.
177+
178+
Args:
179+
payload: Payload containing metrics array
180+
device_name: Name of the device to search
181+
devices_path: Path to devices.toml
182+
config_type: Type of config ("registers" or "coils")
183+
id_key: ID field name ("registerid" or "coilid")
184+
value_type: Type to convert value to (float or int)
185+
186+
Returns:
187+
Tuple of (config_dict, target_device, matched_id, value)
188+
"""
189+
logger = logging.getLogger(__name__)
190+
191+
metric_name, value = _extract_metric_from_payload(payload, value_type, logger)
192+
target_device = _load_target_device(devices_path, device_name)
193+
if target_device is None:
194+
return None, None, None, None
195+
196+
configs = target_device.get(config_type, []) or []
197+
return _match_config_by_id(
198+
configs, id_key, metric_name, target_device, value, logger
199+
)
200+
201+
202+
def _extract_metric_from_payload(payload, value_type, logger):
203+
"""Extract metric name and value from payload."""
204+
metrics = payload.get("metrics", [])
205+
if not metrics:
206+
raise ValueError("No metrics found in payload")
207+
208+
if len(metrics) > 1:
209+
logger.warning("Multiple metrics found, using first one")
210+
211+
metric = metrics[0]
212+
return metric.get("name", ""), value_type(metric.get("value", 0))
213+
214+
215+
def _load_target_device(devices_path, device_name):
216+
"""Load and find target device from devices.toml."""
217+
devices_cfg = toml.load(devices_path)
218+
devices = devices_cfg.get("device", []) or []
219+
return next((d for d in devices if d.get("name") == device_name), None)
220+
221+
222+
def _match_config_by_id(
223+
configs, id_key, metric_name, target_device, value, logger
224+
): # pylint: disable=too-many-arguments
225+
"""Match config by checking if metric name starts with id."""
226+
partial_match = None
227+
228+
for config in configs:
229+
config_id = config.get(id_key)
230+
if config_id:
231+
if metric_name.startswith(config_id):
232+
logger.info(
233+
"Matched metric name '%s' with %s '%s'",
234+
metric_name,
235+
id_key,
236+
config_id,
237+
)
238+
return config, target_device, config_id, value
239+
240+
if config_id in metric_name and partial_match is None:
241+
partial_match = (config, config_id)
242+
243+
if partial_match:
244+
config, config_id = partial_match
245+
logger.info(
246+
"Matched metric name '%s' with %s '%s' (partial match)",
247+
metric_name,
248+
id_key,
249+
config_id,
250+
)
251+
return config, target_device, config_id, value
252+
253+
logger.warning(
254+
"No matching %s found for metric name '%s'",
255+
configs.__class__.__name__,
256+
metric_name,
257+
)
258+
return None, None, None, None

tedge_modbus/operations/set_coil.py

Lines changed: 105 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
prepare_client,
1010
apply_loglevel,
1111
close_client_quietly,
12+
extract_device_from_topic,
13+
_match_from_metrics,
1214
)
1315

1416
logger = logging.getLogger(__name__)
@@ -17,41 +19,49 @@
1719
)
1820

1921

20-
def run(arguments: str | list[str]) -> None:
22+
def run(arguments: str | list[str], topic: str | None = None) -> None:
2123
"""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)
3124
32-
# Create context with default config directory
33-
context = Context()
25+
Supports two payload formats:
26+
27+
1. Legacy format (full coil details):
28+
{
29+
"input": false,
30+
"address": < Fieldbusaddress >,
31+
"coil": < coilnumber >,
32+
"value": < 0 | 1 >
33+
}
3434
35-
# Load configs and set log level
35+
2. New format (coilid-based):
36+
{
37+
"timestamp": "2025-09-23T00:00:00Z",
38+
"uuid": "device-id",
39+
"metrics": [{
40+
"name": "<coilid>_xxxxxxxx",
41+
"timestamp": "2025-09-23T01:00:00Z",
42+
"value": 0 or 1
43+
}]
44+
}
45+
Requires topic: te/device/<device-id>///cmd/modbus_SetCoil/<mapper-id>
46+
"""
47+
payload = parse_json_arguments(arguments)
48+
context = Context()
3649
modbus_config = context.base_config
3750
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
51+
logger.info("New set coil operation")
4852

49-
if value_int not in (0, 1):
50-
raise ValueError("value must be 0 or 1 for a coil write")
53+
if "metrics" in payload and topic:
54+
slave_id, coil_number, value_int, ip_address = _process_new_format_coil(
55+
payload, topic, context
56+
)
57+
else:
58+
slave_id, coil_number, value_int, ip_address = _process_legacy_format_coil(
59+
payload
60+
)
5161

5262
# Prepare client (resolve target, backfill defaults, build client)
5363
client = prepare_client(
54-
payload["ipAddress"],
64+
ip_address,
5565
slave_id,
5666
context.config_dir / "devices.toml",
5767
modbus_config,
@@ -74,3 +84,72 @@ def run(arguments: str | list[str]) -> None:
7484
raise
7585
finally:
7686
close_client_quietly(client)
87+
88+
89+
def _process_new_format_coil(payload: dict, topic: str, context):
90+
"""Process new format coil payload."""
91+
logger.info("Processing new format payload with metrics array")
92+
device_name = extract_device_from_topic(topic)
93+
if not device_name:
94+
raise ValueError(f"Could not extract device name from topic: {topic}")
95+
96+
coil_config, target_device, coil_id, write_value = _match_coil_from_metrics(
97+
payload, device_name, context.config_dir / "devices.toml"
98+
)
99+
100+
if not coil_config:
101+
raise ValueError("Could not match any coil for metrics in payload")
102+
if not target_device:
103+
raise ValueError(f"Could not find device '{device_name}' in devices.toml")
104+
105+
logger.info("Matched CoilID: %s, Value: %s", coil_id, write_value)
106+
coil_num = coil_config.get("number")
107+
if coil_num is None:
108+
raise ValueError(
109+
f"Coil configuration missing 'number' field for coilid '{coil_id}'"
110+
)
111+
112+
value_int = int(write_value)
113+
if value_int not in (0, 1):
114+
raise ValueError("Coil value must be 0 or 1")
115+
116+
return (
117+
target_device.get("address"),
118+
coil_num,
119+
value_int,
120+
target_device.get("ip", ""),
121+
)
122+
123+
124+
def _process_legacy_format_coil(payload: dict):
125+
"""Process legacy format coil payload."""
126+
logger.info("Processing legacy format payload")
127+
try:
128+
slave_id = int(payload["address"])
129+
coil_number = int(payload["coil"])
130+
value_int = int(payload["value"])
131+
except KeyError as err:
132+
raise ValueError(f"Missing required field: {err}") from err
133+
except (TypeError, ValueError) as err:
134+
raise ValueError(f"Invalid numeric field: {err}") from err
135+
136+
if value_int not in (0, 1):
137+
raise ValueError("value must be 0 or 1 for a coil write")
138+
139+
return slave_id, coil_number, value_int, payload.get("ipAddress", "")
140+
141+
142+
def _match_coil_from_metrics(payload: dict, device_name: str, devices_path):
143+
"""Match coil from devices.toml by checking if metric name starts with coilid.
144+
145+
Args:
146+
payload: Payload containing metrics array
147+
device_name: Name of the device to search
148+
devices_path: Path to devices.toml
149+
150+
Returns:
151+
Tuple of (coil_config, target_device, coilid, value)
152+
"""
153+
return _match_from_metrics(
154+
payload, device_name, devices_path, "coils", "coilid", int
155+
)

0 commit comments

Comments
 (0)