Skip to content

Commit 90b62ad

Browse files
committed
fix writing float issue
1 parent 0f02ed4 commit 90b62ad

File tree

4 files changed

+193
-181
lines changed

4 files changed

+193
-181
lines changed

config/devices.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ multiplier=1
1919
divisor=1
2020
decimalshiftright=0
2121
input=false # true = Input Register false = Holding Register
22-
registerid="Test_Int16"
22+
name="Test_Int16"
2323
#see https://thin-edge.github.io/thin-edge.io/html/architecture/thin-edge-json.html
2424
measurementmapping.templatestring="{\"Test\":{\"Int16\":%% }}" # tedge JSON format string, %% will be replaced with the calculated value
2525
#measurementmapping.combinemeasurements=true # Overrides device setting; Combines all measurements of a device to reduce the number of created measurements in the cloud
@@ -35,7 +35,7 @@ divisor=1
3535
decimalshiftright=0
3636
input=false
3737
datatype="float"
38-
registerid="Test_Float32"
38+
name="Test_Float32"
3939
measurementmapping.templatestring="{\"Test\":{\"Float32\":%% }}"
4040

4141

@@ -45,4 +45,4 @@ input=false
4545
alarmmapping.severity="MAJOR"
4646
alarmmapping.text="This alarm should be created once"
4747
alarmmapping.type="TestAlarm"
48-
coilid="TestAlarm"
48+
name="TestAlarm"

tedge_modbus/operations/common.py

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,25 @@ def resolve_target_device(
3333
Returns (target_device, protocol).
3434
"""
3535
if ip_address:
36-
ip = ip_address or "127.0.0.1"
37-
protocol = "TCP"
3836
target_device = {
3937
"protocol": "TCP",
40-
"ip": ip,
38+
"ip": ip_address or "127.0.0.1",
4139
"port": 502,
4240
"address": slave_id,
4341
}
44-
else:
45-
devices_cfg = toml.load(devices_path)
46-
devices = devices_cfg.get("device", []) or []
47-
target_device = next(
48-
(d for d in devices if d.get("address") == slave_id), None
49-
) or next((d for d in devices if d.get("protocol") == "TCP"), None)
50-
if target_device is None:
51-
raise ValueError(f"No suitable device found in {devices_path}")
52-
protocol = target_device.get("protocol")
53-
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
5455

5556

5657
def backfill_serial_defaults(
@@ -82,7 +83,7 @@ def build_modbus_client(target_device: dict, protocol: str):
8283
parity=target_device["parity"],
8384
bytesize=target_device["databits"],
8485
)
85-
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}")
8687

8788

8889
def close_client_quietly(client) -> None:
@@ -180,7 +181,7 @@ def _match_from_metrics( # pylint: disable=too-many-arguments
180181
device_name: Name of the device to search
181182
devices_path: Path to devices.toml
182183
config_type: Type of config ("registers" or "coils")
183-
id_key: ID field name ("registerid" or "coilid")
184+
id_key: ID field name ("name" for registers and coils)
184185
value_type: Type to convert value to (float or int)
185186
186187
Returns:
@@ -199,8 +200,19 @@ def _match_from_metrics( # pylint: disable=too-many-arguments
199200
)
200201

201202

202-
def _extract_metric_from_payload(payload, value_type, logger):
203-
"""Extract metric name and value from payload."""
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+
"""
204216
metrics = payload.get("metrics", [])
205217
if not metrics:
206218
raise ValueError("No metrics found in payload")
@@ -212,8 +224,16 @@ def _extract_metric_from_payload(payload, value_type, logger):
212224
return metric.get("name", ""), value_type(metric.get("value", 0))
213225

214226

215-
def _load_target_device(devices_path, device_name):
216-
"""Load and find target device from devices.toml."""
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+
"""
217237
devices_cfg = toml.load(devices_path)
218238
devices = devices_cfg.get("device", []) or []
219239
return next((d for d in devices if d.get("name") == device_name), None)
@@ -222,24 +242,29 @@ def _load_target_device(devices_path, device_name):
222242
def _match_config_by_id(
223243
configs, id_key, metric_name, target_device, value, logger
224244
): # pylint: disable=too-many-arguments
225-
"""Match config by checking if metric name starts with id."""
245+
"""Match config by checking if metric name starts with or contains id."""
226246
partial_match = None
227247

228248
for config in configs:
229249
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-
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
243268
if partial_match:
244269
config, config_id = partial_match
245270
logger.info(
@@ -252,7 +277,7 @@ def _match_config_by_id(
252277

253278
logger.warning(
254279
"No matching %s found for metric name '%s'",
255-
configs.__class__.__name__,
280+
type(configs).__name__,
256281
metric_name,
257282
)
258283
return None, None, None, None
Lines changed: 56 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
#!/usr/bin/env python3
1+
# pylint: disable=duplicate-code
22
"""Modbus Write Coil Status operation handler"""
3+
34
import logging
45

56
from pymodbus.exceptions import ConnectionException
@@ -32,12 +33,12 @@ def run(arguments: str | list[str], topic: str | None = None) -> None:
3233
"value": < 0 | 1 >
3334
}
3435
35-
2. New format (coilid-based):
36+
2. New format (name-based):
3637
{
3738
"timestamp": "2025-09-23T00:00:00Z",
3839
"uuid": "device-id",
3940
"metrics": [{
40-
"name": "<coilid>_xxxxxxxx",
41+
"name": "<name>_xxxxxxxx",
4142
"timestamp": "2025-09-23T01:00:00Z",
4243
"value": 0 or 1
4344
}]
@@ -48,53 +49,63 @@ def run(arguments: str | list[str], topic: str | None = None) -> None:
4849
context = Context()
4950
modbus_config = context.base_config
5051
apply_loglevel(logger, modbus_config)
51-
logger.info("New set coil operation")
52+
logger.info("Processing set coil operation")
5253

54+
# Determine format and extract parameters
5355
if "metrics" in payload and topic:
54-
slave_id, coil_number, value_int, ip_address = _process_new_format_coil(
55-
payload, topic, context
56-
)
56+
params = _process_new_format_coil(payload, topic, context)
5757
else:
58-
slave_id, coil_number, value_int, ip_address = _process_legacy_format_coil(
59-
payload
60-
)
58+
params = _process_legacy_format_coil(payload)
6159

62-
# Prepare client (resolve target, backfill defaults, build client)
60+
# Prepare client
6361
client = prepare_client(
64-
ip_address,
65-
slave_id,
62+
params["ip_address"],
63+
params["slave_id"],
6664
context.config_dir / "devices.toml",
6765
modbus_config,
6866
)
6967

7068
try:
71-
coil_value = bool(value_int)
7269
result = client.write_coil(
73-
address=coil_number,
74-
value=coil_value,
75-
slave=slave_id,
70+
address=params["coil_number"],
71+
value=bool(params["value"]),
72+
slave=params["slave_id"],
7673
)
7774
if result.isError():
78-
raise RuntimeError(f"Failed to write coil {coil_number}: {result}")
75+
raise RuntimeError(
76+
f"Failed to write coil {params['coil_number']}: {result}"
77+
)
7978
logger.info(
80-
"Wrote %s to coil %d on slave %d", coil_value, coil_number, slave_id
79+
"Wrote %s to coil %d on slave %d",
80+
bool(params["value"]),
81+
params["coil_number"],
82+
params["slave_id"],
8183
)
8284
except ConnectionException as err:
83-
logger.error("Connection error while writing to slave %d: %s", slave_id, err)
85+
logger.error(
86+
"Connection error while writing to slave %d: %s",
87+
params["slave_id"],
88+
err,
89+
)
8490
raise
8591
finally:
8692
close_client_quietly(client)
8793

8894

89-
def _process_new_format_coil(payload: dict, topic: str, context):
90-
"""Process new format coil payload."""
95+
def _process_new_format_coil(payload: dict, topic: str, context) -> dict:
96+
"""Process new format coil payload with metrics array."""
9197
logger.info("Processing new format payload with metrics array")
9298
device_name = extract_device_from_topic(topic)
9399
if not device_name:
94100
raise ValueError(f"Could not extract device name from topic: {topic}")
95101

96-
coil_config, target_device, coil_id, write_value = _match_coil_from_metrics(
97-
payload, device_name, context.config_dir / "devices.toml"
102+
coil_config, target_device, coil_id, write_value = _match_from_metrics(
103+
payload,
104+
device_name,
105+
context.config_dir / "devices.toml",
106+
"coils",
107+
"name",
108+
int,
98109
)
99110

100111
if not coil_config:
@@ -103,53 +114,40 @@ def _process_new_format_coil(payload: dict, topic: str, context):
103114
raise ValueError(f"Could not find device '{device_name}' in devices.toml")
104115

105116
logger.info("Matched CoilID: %s, Value: %s", coil_id, write_value)
106-
coil_num = coil_config.get("number")
107-
if coil_num is None:
117+
118+
coil_number = coil_config.get("number")
119+
if coil_number is None:
108120
raise ValueError(
109-
f"Coil configuration missing 'number' field for coilid '{coil_id}'"
121+
f"Coil configuration missing 'number' field for name '{coil_id}'"
110122
)
111123

112124
value_int = int(write_value)
113125
if value_int not in (0, 1):
114126
raise ValueError("Coil value must be 0 or 1")
115127

116-
return (
117-
target_device.get("address"),
118-
coil_num,
119-
value_int,
120-
target_device.get("ip", ""),
121-
)
128+
return {
129+
"ip_address": target_device.get("ip", ""),
130+
"slave_id": target_device.get("address"),
131+
"coil_number": coil_number,
132+
"value": value_int,
133+
}
122134

123135

124-
def _process_legacy_format_coil(payload: dict):
136+
def _process_legacy_format_coil(payload: dict) -> dict:
125137
"""Process legacy format coil payload."""
126138
logger.info("Processing legacy format payload")
127139
try:
128-
slave_id = int(payload["address"])
129-
coil_number = int(payload["coil"])
130-
value_int = int(payload["value"])
140+
value = int(payload["value"])
141+
if value not in (0, 1):
142+
raise ValueError("value must be 0 or 1 for a coil write")
143+
144+
return {
145+
"ip_address": payload.get("ipAddress", ""),
146+
"slave_id": int(payload["address"]),
147+
"coil_number": int(payload["coil"]),
148+
"value": value,
149+
}
131150
except KeyError as err:
132151
raise ValueError(f"Missing required field: {err}") from err
133152
except (TypeError, ValueError) as err:
134153
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)