modpoll2mqtt 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modpoll/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ import importlib.metadata as importlib_metadata
2
+
3
+ try:
4
+ __version__ = importlib_metadata.version("modpoll2mqtt")
5
+ except importlib_metadata.PackageNotFoundError:
6
+ __version__ = "0.0.0"
modpoll/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main import app
2
+
3
+ app()
modpoll/arg_parser.py ADDED
@@ -0,0 +1,209 @@
1
+ import argparse
2
+
3
+ from . import __version__
4
+
5
+ _CSV_DELIMITER_CHOICES = ("comma", "tab")
6
+
7
+
8
+ def get_parser():
9
+ parser = argparse.ArgumentParser(
10
+ description=f"modpoll2mqtt v{__version__} - Modbus to MQTT gateway"
11
+ )
12
+ parser.add_argument(
13
+ "-v",
14
+ "--version",
15
+ action="version",
16
+ version=f"v{__version__}",
17
+ )
18
+ parser.add_argument(
19
+ "-f",
20
+ "--config",
21
+ required=True,
22
+ help="A local path or URL of Modbus configuration file. Required!",
23
+ nargs="+",
24
+ )
25
+ parser.add_argument(
26
+ "--csv-delimiter",
27
+ default="comma",
28
+ choices=_CSV_DELIMITER_CHOICES,
29
+ help=(
30
+ "Column delimiter code for Modbus config files "
31
+ f"({', '.join(_CSV_DELIMITER_CHOICES)}). Defaults to comma"
32
+ ),
33
+ )
34
+ parser.add_argument(
35
+ "-d",
36
+ "--daemon",
37
+ action="store_true",
38
+ help="Run in daemon mode without printing result",
39
+ )
40
+ parser.add_argument(
41
+ "-r",
42
+ "--rate",
43
+ type=float,
44
+ default=10.0,
45
+ help="The sampling rate (s) to poll modbus device, Defaults to 10.0",
46
+ )
47
+ parser.add_argument(
48
+ "-1", "--once", action="store_true", help="Only run polling at one time"
49
+ )
50
+ parser.add_argument(
51
+ "--interval",
52
+ type=float,
53
+ default=0.5,
54
+ help="The time interval in seconds between two polling, Defaults to 0.5",
55
+ )
56
+ parser.add_argument(
57
+ "--tcp", help="Act as a Modbus TCP master, connecting to host TCP"
58
+ )
59
+ parser.add_argument(
60
+ "--tcp-port", type=int, default=502, help="Port for MODBUS TCP. Defaults to 502"
61
+ )
62
+ parser.add_argument(
63
+ "--udp", help="Act as a Modbus UDP master, connecting to host UDP"
64
+ )
65
+ parser.add_argument(
66
+ "--udp-port", type=int, default=502, help="Port for MODBUS UDP. Defaults to 502"
67
+ )
68
+ parser.add_argument(
69
+ "--serial",
70
+ "--rtu",
71
+ dest="serial",
72
+ help="pyserial URL (or port name) for serial transport (alias: --rtu)",
73
+ )
74
+ parser.add_argument(
75
+ "--serial-baud",
76
+ "--rtu-baud",
77
+ dest="serial_baud",
78
+ type=int,
79
+ default=9600,
80
+ help="Baud rate for serial port. Defaults to 9600",
81
+ )
82
+ parser.add_argument(
83
+ "--serial-parity",
84
+ "--rtu-parity",
85
+ dest="serial_parity",
86
+ choices=["none", "odd", "even"],
87
+ default="none",
88
+ help="Parity for serial port. Defaults to none",
89
+ )
90
+ parser.add_argument(
91
+ "--timeout",
92
+ type=float,
93
+ default=3.0,
94
+ help="Response time-out seconds for MODBUS devices, Defaults to 3.0",
95
+ )
96
+ parser.add_argument(
97
+ "-o",
98
+ "--export",
99
+ default=None,
100
+ help="The file name to export references/registers",
101
+ )
102
+ parser.add_argument(
103
+ "--mqtt-version",
104
+ choices=["3.1.1", "5.0"],
105
+ default="3.1.1",
106
+ help="MQTT version. Defaults to MQTT v3.1.1",
107
+ )
108
+ parser.add_argument(
109
+ "--mqtt-host",
110
+ default=None,
111
+ help="MQTT server address. Skip MQTT setup if not specified",
112
+ )
113
+ parser.add_argument(
114
+ "--mqtt-port",
115
+ type=int,
116
+ default=1883,
117
+ help="1883 for non-TLS or 8883 for TLS, Defaults to 1883",
118
+ )
119
+ parser.add_argument(
120
+ "--mqtt-clientid",
121
+ default=None,
122
+ help="MQTT client name, If qos > 0, set unique name for multiple clients",
123
+ )
124
+ parser.add_argument(
125
+ "--mqtt-publish-topic-pattern",
126
+ default="modpoll/{{device_name}}/data",
127
+ help='Topic pattern for MQTT publish. Use {{device_name}} as placeholder for the device names in Modbus config. Defaults to "modpoll/{{device_name}}/data"',
128
+ )
129
+ parser.add_argument(
130
+ "--mqtt-subscribe-topic-pattern",
131
+ default="modpoll/+/set",
132
+ help='Topic pattern for MQTT write commands. Use + as placeholder for device name. Defaults to "modpoll/+/set"',
133
+ )
134
+ parser.add_argument(
135
+ "--mqtt-diagnostics-topic-pattern",
136
+ default="modpoll/{{device_name}}/diagnostics",
137
+ help="Topic pattern for MQTT diagnostics. Use {{device_name}} as placeholder for the device names in Modbus config. Defaults to modpoll/{{device_name}}/diagnostics",
138
+ )
139
+ parser.add_argument(
140
+ "--mqtt-qos",
141
+ choices=[0, 1, 2],
142
+ type=int,
143
+ default=0,
144
+ help="MQTT QoS value. Defaults to 0",
145
+ )
146
+ parser.add_argument(
147
+ "--mqtt-rx-queue-size",
148
+ type=int,
149
+ default=1000,
150
+ metavar="N",
151
+ help="Max MQTT subscribe messages buffered between polls (default: 1000)",
152
+ )
153
+ parser.add_argument(
154
+ "--mqtt-user", default=None, help="Username for authentication (optional)"
155
+ )
156
+ parser.add_argument(
157
+ "--mqtt-pass", default=None, help="Password for authentication (optional)"
158
+ )
159
+ parser.add_argument("--mqtt-use-tls", action="store_true", help="Use TLS")
160
+ parser.add_argument(
161
+ "--mqtt-insecure",
162
+ action="store_true",
163
+ help="Use TLS without providing certificates",
164
+ )
165
+ parser.add_argument("--mqtt-cacerts", default=None, help="Path to ca keychain")
166
+ parser.add_argument(
167
+ "--mqtt-tls-version",
168
+ choices=["tlsv1.2", "tlsv1.1", "tlsv1"],
169
+ default="tlsv1.2",
170
+ help="TLS protocol version, can be one of tlsv1.2 tlsv1.1 or tlsv1",
171
+ )
172
+ parser.add_argument(
173
+ "--mqtt-single",
174
+ action="store_true",
175
+ help="Publish each value in a single topic. If not specified, groups all values in one topic.",
176
+ )
177
+ parser.add_argument(
178
+ "--diagnostics-rate",
179
+ type=float,
180
+ default=0,
181
+ help="Time in seconds as publishing period for each device diagnostics",
182
+ )
183
+ parser.add_argument(
184
+ "--autoremove",
185
+ action="store_true",
186
+ help="Automatically remove poller if modbus communication has failed 3 times.",
187
+ )
188
+ parser.add_argument(
189
+ "--loglevel",
190
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
191
+ default="INFO",
192
+ help="Set log level, Defaults to INFO",
193
+ )
194
+ parser.add_argument(
195
+ "--timestamp", action="store_true", help="Add timestamp to the result"
196
+ )
197
+ parser.add_argument(
198
+ "--delay",
199
+ type=int,
200
+ default=0,
201
+ help="Time to delay sending first request in seconds after connecting. Default to 0",
202
+ )
203
+ parser.add_argument(
204
+ "--framer",
205
+ default="default",
206
+ choices=["default", "ascii", "rtu", "socket"],
207
+ help="The type of framer for Modbus messages. Serial supports ascii/rtu; TCP/UDP use socket.",
208
+ )
209
+ return parser
modpoll/main.py ADDED
@@ -0,0 +1,256 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ import signal
5
+ import sys
6
+
7
+ from .arg_parser import get_parser
8
+ from .mqtt_task import MqttHandler
9
+ from .modbus_task import setup_modbus_handlers, modbus_connect, modbus_close
10
+
11
+ from . import __version__
12
+ from .utils import set_threading_event, delay_thread, on_threading_event, get_utc_time
13
+
14
+
15
+ LOG_SIMPLE = "%(asctime)s | %(levelname).1s | %(name)s | %(message)s"
16
+ logger = None
17
+
18
+
19
+ def _signal_handler(signal, frame):
20
+ logger.info(f"Exiting {sys.argv[0]}")
21
+ set_threading_event()
22
+
23
+
24
+ def extract_device_from_mqtt_topic(pattern: str, topic: str):
25
+ """Return device name from the first '+' wildcard segment, or None if no match."""
26
+ parts = pattern.split("+")
27
+ if len(parts) < 2:
28
+ raise ValueError("MQTT subscribe pattern must contain '+' wildcard")
29
+ topic_regex = "([^/\n]*)".join(re.escape(p) for p in parts)
30
+ match = re.fullmatch(topic_regex, topic)
31
+ return match.group(1) if match else None
32
+
33
+
34
+ def setup_logging(level, format):
35
+ logging.basicConfig(level=level, format=format)
36
+
37
+
38
+ def app(name="modpoll"):
39
+ mqtt_handler = None
40
+ modbus_client = None
41
+ modbus_handlers = []
42
+
43
+ print(
44
+ f"\nmodpoll2mqtt v{__version__} - Modbus to MQTT gateway\n",
45
+ flush=True,
46
+ )
47
+
48
+ # parse args
49
+ args = get_parser().parse_args()
50
+
51
+ # get logger
52
+ setup_logging(args.loglevel, LOG_SIMPLE)
53
+ global logger
54
+ logger = logging.getLogger(__name__)
55
+
56
+ signal.signal(signal.SIGINT, _signal_handler)
57
+ signal.signal(signal.SIGTERM, _signal_handler)
58
+
59
+ # setup mqtt
60
+ if not args.mqtt_host:
61
+ logger.info("No MQTT host specified, skip MQTT setup.")
62
+ else:
63
+ logger.info(f"Setup MQTT connection to {args.mqtt_host}:{args.mqtt_port}")
64
+ try:
65
+ if "+" not in args.mqtt_subscribe_topic_pattern:
66
+ logger.error(
67
+ "MQTT subscribe pattern must contain '+' wildcard for the device "
68
+ f"name segment: {args.mqtt_subscribe_topic_pattern}"
69
+ )
70
+ exit(1)
71
+ if args.mqtt_rx_queue_size < 1:
72
+ logger.error(
73
+ f"MQTT rx queue size must be at least 1: {args.mqtt_rx_queue_size}"
74
+ )
75
+ exit(1)
76
+ mqtt_handler = MqttHandler(
77
+ "MqttHandler",
78
+ args.mqtt_host,
79
+ args.mqtt_port,
80
+ args.mqtt_user,
81
+ args.mqtt_pass,
82
+ args.mqtt_clientid,
83
+ args.mqtt_qos,
84
+ subscribe_topics=[args.mqtt_subscribe_topic_pattern],
85
+ use_tls=args.mqtt_use_tls,
86
+ tls_version=args.mqtt_tls_version,
87
+ cacerts=args.mqtt_cacerts,
88
+ insecure=args.mqtt_insecure,
89
+ mqtt_version=args.mqtt_version,
90
+ log_level=args.loglevel,
91
+ rx_queue_size=args.mqtt_rx_queue_size,
92
+ )
93
+ if mqtt_handler.setup() and mqtt_handler.connect():
94
+ logger.info("Connected to MQTT broker.")
95
+ else:
96
+ logger.error("Failed to connect with MQTT broker, exiting...")
97
+ try:
98
+ mqtt_handler.close()
99
+ except Exception as close_err:
100
+ logger.debug(
101
+ f"Ignoring MQTT close error after failed connect: {close_err}"
102
+ )
103
+ exit(1)
104
+ except Exception as e:
105
+ logger.error(f"Error setting up MQTT input: {e}, exiting...")
106
+ if mqtt_handler:
107
+ try:
108
+ mqtt_handler.close()
109
+ except Exception as close_err:
110
+ logger.debug(
111
+ f"Ignoring MQTT close error after setup exception: {close_err}"
112
+ )
113
+ exit(1)
114
+
115
+ # setup modbus tasks
116
+ modbus_client, modbus_handlers = setup_modbus_handlers(args, mqtt_handler)
117
+ if modbus_handlers:
118
+ logger.info(f"Loaded {len(modbus_handlers)} Modbus config(s).")
119
+ delay_thread(args.delay)
120
+ else:
121
+ logger.error("No Modbus config(s) defined. Exiting...")
122
+ if mqtt_handler:
123
+ mqtt_handler.close()
124
+ exit(1)
125
+
126
+ # main loop
127
+ last_check = 0
128
+ last_diag = 0
129
+ while not on_threading_event():
130
+ now = get_utc_time()
131
+ # routine check
132
+ if now > last_check + args.rate:
133
+ if last_check == 0:
134
+ elapsed = args.rate
135
+ else:
136
+ elapsed = round(now - last_check, 6)
137
+ logger.info(
138
+ f" === Modpoll is polling at rate:{args.rate}s, actual:{elapsed}s ==="
139
+ )
140
+ if not modbus_connect(modbus_client):
141
+ for modbus_handler in modbus_handlers:
142
+ modbus_handler.on_connect_failure()
143
+ else:
144
+ try:
145
+ for modbus_handler in modbus_handlers:
146
+ modbus_handler.poll()
147
+ if on_threading_event():
148
+ break
149
+ finally:
150
+ modbus_close(modbus_client)
151
+ for modbus_handler in modbus_handlers:
152
+ if on_threading_event():
153
+ break
154
+ if args.mqtt_host:
155
+ if args.timestamp:
156
+ modbus_handler.publish_data(timestamp=now)
157
+ else:
158
+ modbus_handler.publish_data()
159
+ if args.export:
160
+ if args.timestamp:
161
+ modbus_handler.export(args.export, timestamp=now)
162
+ else:
163
+ modbus_handler.export(args.export)
164
+ last_check = get_utc_time()
165
+ if args.diagnostics_rate > 0 and now > last_diag + args.diagnostics_rate:
166
+ last_diag = now
167
+ for modbus_handler in modbus_handlers:
168
+ modbus_handler.publish_diagnostics()
169
+ if on_threading_event():
170
+ break
171
+ # Check if receive mqtt request
172
+ if mqtt_handler:
173
+ topic, payload = mqtt_handler.receive()
174
+ if topic and payload:
175
+ try:
176
+ device_name = extract_device_from_mqtt_topic(
177
+ args.mqtt_subscribe_topic_pattern, topic
178
+ )
179
+ except ValueError:
180
+ logger.error(
181
+ "MQTT subscribe pattern must contain '+' wildcard: "
182
+ f"{args.mqtt_subscribe_topic_pattern}"
183
+ )
184
+ continue
185
+ if not device_name:
186
+ logger.error(f"Failed to extract device name from topic: {topic}")
187
+ continue
188
+
189
+ try:
190
+ command = json.loads(payload)
191
+ ref_name = command["ref"]
192
+ value = command["value"]
193
+ except KeyError as e:
194
+ logger.error(f"Missing required key in payload: {e}")
195
+ continue
196
+ except json.JSONDecodeError:
197
+ logger.error(f"Failed to parse JSON message: {payload}")
198
+ continue
199
+
200
+ if "device" in command:
201
+ logger.debug(
202
+ "Ignoring 'device' in write payload; device is taken from topic"
203
+ )
204
+
205
+ logger.info(
206
+ f"Received write request for device={device_name}, ref={ref_name}"
207
+ )
208
+ device_found = False
209
+ write_success = False
210
+ connect_failed = False
211
+ for modbus_handler in modbus_handlers:
212
+ if not modbus_handler.has_device(device_name):
213
+ continue
214
+ device_found = True
215
+ if not modbus_connect(modbus_client):
216
+ connect_failed = True
217
+ else:
218
+ try:
219
+ write_success = modbus_handler.write_reference(
220
+ device_name, ref_name, value
221
+ )
222
+ finally:
223
+ modbus_close(modbus_client)
224
+ break
225
+
226
+ if not device_found:
227
+ logger.error(f"No device found with name: {device_name}")
228
+ elif connect_failed:
229
+ logger.error(
230
+ f"Modbus connect failed for write: device={device_name}, "
231
+ f"ref={ref_name}"
232
+ )
233
+ elif write_success:
234
+ logger.info(
235
+ f"Successfully wrote device={device_name}, ref={ref_name}, "
236
+ f"value={value}"
237
+ )
238
+ else:
239
+ logger.warning(
240
+ f"Failed to write device={device_name}, ref={ref_name}, "
241
+ f"value={value}"
242
+ )
243
+ if args.once:
244
+ set_threading_event()
245
+ break
246
+
247
+ remaining = last_check + args.rate - get_utc_time()
248
+ delay_thread(min(max(remaining, 0.01), 0.5))
249
+
250
+ modbus_close(modbus_client)
251
+ if mqtt_handler:
252
+ mqtt_handler.close()
253
+
254
+
255
+ if __name__ == "__main__":
256
+ app()