Skip to content

Commit 6f03a24

Browse files
committed
Supports simple cmd payload without register info
Supports simple cmd payload without register info, made register address 6 and 7 writable and fixed writing float issue
1 parent ae286a1 commit 6f03a24

File tree

7 files changed

+471
-112
lines changed

7 files changed

+471
-112
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+
name="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+
name="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+
name="TestAlarm"

images/simulator/modbus.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
}
3535
},
3636
"invalid": [1],
37-
"write": [3, 48],
37+
"write": [3, 6, 7, 48],
3838
"bits": [
3939
48
4040
],

tedge_modbus/operations/common.py

Lines changed: 151 additions & 14 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
@@ -32,24 +33,25 @@ def resolve_target_device(
3233
Returns (target_device, protocol).
3334
"""
3435
if ip_address:
35-
ip = ip_address or "127.0.0.1"
36-
protocol = "TCP"
3736
target_device = {
3837
"protocol": "TCP",
39-
"ip": ip,
38+
"ip": ip_address or "127.0.0.1",
4039
"port": 502,
4140
"address": slave_id,
4241
}
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]
42+
return target_device, "TCP"
43+
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+
50+
if target_device is None:
51+
raise ValueError(f"No suitable device found in {devices_path}")
52+
53+
protocol = target_device.get("protocol", "TCP")
54+
return target_device, protocol
5355

5456

5557
def backfill_serial_defaults(
@@ -81,7 +83,7 @@ def build_modbus_client(target_device: dict, protocol: str):
8183
parity=target_device["parity"],
8284
bytesize=target_device["databits"],
8385
)
84-
raise ValueError("Expected protocol to be RTU or TCP. Got " + str(protocol) + ".")
86+
raise ValueError(f"Expected protocol to be RTU or TCP, got {protocol}")
8587

8688

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

0 commit comments

Comments
 (0)