|
4 | 4 |
|
5 | 5 | import json |
6 | 6 | import logging |
| 7 | +import re |
7 | 8 |
|
8 | 9 | import toml |
9 | 10 | from pymodbus.client import ModbusSerialClient, ModbusTcpClient |
@@ -32,24 +33,25 @@ def resolve_target_device( |
32 | 33 | Returns (target_device, protocol). |
33 | 34 | """ |
34 | 35 | if ip_address: |
35 | | - ip = ip_address or "127.0.0.1" |
36 | | - protocol = "TCP" |
37 | 36 | target_device = { |
38 | 37 | "protocol": "TCP", |
39 | | - "ip": ip, |
| 38 | + "ip": ip_address or "127.0.0.1", |
40 | 39 | "port": 502, |
41 | 40 | "address": slave_id, |
42 | 41 | } |
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 |
53 | 55 |
|
54 | 56 |
|
55 | 57 | def backfill_serial_defaults( |
@@ -81,7 +83,7 @@ def build_modbus_client(target_device: dict, protocol: str): |
81 | 83 | parity=target_device["parity"], |
82 | 84 | bytesize=target_device["databits"], |
83 | 85 | ) |
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}") |
85 | 87 |
|
86 | 88 |
|
87 | 89 | def close_client_quietly(client) -> None: |
@@ -144,3 +146,138 @@ def compute_masked_value( |
144 | 146 | raise ValueError(f"value must be within 0..{max_value} for noBits={num_bits}") |
145 | 147 | mask = ((1 << num_bits) - 1) << start_bit |
146 | 148 | 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