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 +6 -0
- modpoll/__main__.py +3 -0
- modpoll/arg_parser.py +209 -0
- modpoll/main.py +256 -0
- modpoll/modbus_task.py +916 -0
- modpoll/mqtt_task.py +301 -0
- modpoll/reference_write.py +296 -0
- modpoll/register_decode.py +181 -0
- modpoll/utils.py +21 -0
- modpoll2mqtt-2.0.0.dist-info/LICENSE +22 -0
- modpoll2mqtt-2.0.0.dist-info/METADATA +158 -0
- modpoll2mqtt-2.0.0.dist-info/RECORD +14 -0
- modpoll2mqtt-2.0.0.dist-info/WHEEL +4 -0
- modpoll2mqtt-2.0.0.dist-info/entry_points.txt +3 -0
modpoll/__init__.py
ADDED
modpoll/__main__.py
ADDED
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()
|