rpi2home-assistant 2.3.0__py2.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.
raspy2mqtt/main.py ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env python3
2
+
3
+ #
4
+ # Author: fmontorsi
5
+ # Created: Feb 2024
6
+ # License: Apache license
7
+ #
8
+ # TODO: add HomeAssistant discovery messages
9
+
10
+ import argparse
11
+ import os
12
+ import fcntl
13
+ import sys
14
+ import asyncio
15
+ import gpiozero
16
+ import subprocess
17
+ import signal
18
+ from .stats import StatsCollector
19
+ from .constants import SeqMicroHatConstants, MiscAppDefaults
20
+ from .config import AppConfig
21
+ from .optoisolated_inputs_handler import OptoIsolatedInputsHandler
22
+ from .gpio_inputs_handler import GpioInputsHandler
23
+ from .gpio_outputs_handler import GpioOutputsHandler
24
+ from .homeassistant_status_tracker import HomeAssistantStatusTracker
25
+
26
+ # =======================================================================================================
27
+ # GLOBALs
28
+ # =======================================================================================================
29
+
30
+ # sets to True when the application was asked to exit:
31
+ g_stop_requested = False
32
+
33
+
34
+ # =======================================================================================================
35
+ # MAIN HELPERS
36
+ # =======================================================================================================
37
+
38
+
39
+ def parse_command_line():
40
+ """Parses the command line and returns the configuration as dictionary object."""
41
+ parser = argparse.ArgumentParser(
42
+ description=f"Utility to expose the {SeqMicroHatConstants.MAX_CHANNELS} digital inputs read by Raspberry over MQTT, to ease their integration as (binary) sensors in Home Assistant."
43
+ )
44
+
45
+ # Optional arguments
46
+ # NOTE: we cannot add required=True to --output option otherwise it's impossible to invoke this tool with just --version
47
+ parser.add_argument(
48
+ "-c",
49
+ "--config",
50
+ help=f"YAML file specifying the software configuration. Defaults to '{MiscAppDefaults.CONFIG_FILE}'",
51
+ default=MiscAppDefaults.CONFIG_FILE,
52
+ )
53
+ parser.add_argument(
54
+ "-d",
55
+ "--disable-hw",
56
+ help="This is mostly a debugging option; it disables interactions with HW components to ease integration tests",
57
+ action="store_true",
58
+ default=False,
59
+ )
60
+ parser.add_argument("-v", "--verbose", help="Be verbose.", action="store_true", default=False)
61
+ parser.add_argument(
62
+ "-V",
63
+ "--version",
64
+ help="Print version and exit",
65
+ action="store_true",
66
+ default=False,
67
+ )
68
+
69
+ if "COLUMNS" not in os.environ:
70
+ os.environ["COLUMNS"] = "120" # avoid too many line wraps
71
+ args = parser.parse_args()
72
+
73
+ if args.version:
74
+ cfg = AppConfig()
75
+ print(f"Version: {cfg.app_version}")
76
+ sys.exit(0)
77
+
78
+ return args
79
+
80
+
81
+ def instance_already_running(label="default"):
82
+ """
83
+ Detect if an an instance with the label is already running, globally
84
+ at the operating system level.
85
+
86
+ Using `os.open` ensures that the file pointer won't be closed
87
+ by Python's garbage collector after the function's scope is exited.
88
+
89
+ The lock will be released when the program exits, or could be
90
+ released if the file pointer were closed.
91
+ """
92
+
93
+ try:
94
+ lock_file_pointer = os.open(f"/tmp/instance_{label}.lock", os.O_WRONLY | os.O_CREAT)
95
+ except PermissionError as e:
96
+ print(f"Not enough permissions to write files under /tmp. Run this application as root: {e}")
97
+ sys.exit(4)
98
+
99
+ try:
100
+ # LOCK_NB = lock non-blocking
101
+ # LOCK_EX = exclusive lock
102
+ fcntl.lockf(lock_file_pointer, fcntl.LOCK_EX | fcntl.LOCK_NB)
103
+ already_running = False
104
+ except IOError:
105
+ already_running = True
106
+
107
+ return already_running
108
+
109
+
110
+ def shutdown():
111
+ print("!! Detected long-press on the Sequent Microsystem button. Triggering clean shutdown of the Raspberry PI !!")
112
+ subprocess.call(["sudo", "shutdown", "-h", "now"])
113
+
114
+
115
+ def init_hardware(cfg: AppConfig):
116
+ if cfg.disable_hw:
117
+ return []
118
+
119
+ # setup GPIO connected to the pushbutton (input) and
120
+ # assign the when_held function to be called when the button is held for more than 5 seconds
121
+ # (NOTE: the way gpiozero works is that a new thread is spawned to listed for this event on the Raspy GPIO)
122
+ if "GPIOZERO_PIN_FACTORY" in os.environ:
123
+ print(f"GPIO factory backend is: {os.environ['GPIOZERO_PIN_FACTORY']}")
124
+ else:
125
+ print(
126
+ "GPIO factory backend is the default one. This might fail on newer Raspbian versions with Linux kernel >= 6.6.20"
127
+ )
128
+
129
+ try:
130
+ gpiozero.Device.ensure_pin_factory()
131
+ except gpiozero.exc.BadPinFactory:
132
+ print("Unable to load a gpiozero pin factory. Typically this happens if you don't have pigpio installed.")
133
+ print("Alternatively you can run this software for basic testing exporting the env variable DISABLE_HW.")
134
+
135
+ buttons = []
136
+ print("Initializing SequentMicrosystem GPIO shutdown button")
137
+ b = gpiozero.Button(SeqMicroHatConstants.SHUTDOWN_BUTTON_GPIO, hold_time=5)
138
+ b.when_held = shutdown
139
+ buttons.append(b)
140
+
141
+ return buttons
142
+
143
+
144
+ # =======================================================================================================
145
+ # ASYNC HELPERS
146
+ # =======================================================================================================
147
+
148
+
149
+ async def signal_handler(sig: signal.Signals) -> None:
150
+ global g_stop_requested
151
+ g_stop_requested = True
152
+ print(f"Received signal {sig.name}... stopping all async tasks")
153
+
154
+
155
+ async def main_loop():
156
+ global g_stop_requested
157
+
158
+ cfg = AppConfig()
159
+ print(f"{MiscAppDefaults.THIS_APP_NAME} version {cfg.app_version} starting")
160
+
161
+ args = parse_command_line()
162
+
163
+ if not cfg.load(args.config):
164
+ return 1 # invalid config file... abort with failure exit code
165
+
166
+ cfg.merge_options_from_cli(args)
167
+ cfg.merge_options_from_env_vars()
168
+ cfg.print_config_summary()
169
+
170
+ # install signal handler
171
+ loop = asyncio.get_running_loop()
172
+ for sig in [signal.SIGINT, signal.SIGTERM]:
173
+ loop.add_signal_handler(sig, lambda: asyncio.create_task(signal_handler(sig)))
174
+
175
+ # initialize handlers
176
+ opto_inputs_handler = OptoIsolatedInputsHandler()
177
+ gpio_inputs_handler = GpioInputsHandler()
178
+ gpio_outputs_handler = GpioOutputsHandler()
179
+ homeassistant_status_tracker = HomeAssistantStatusTracker()
180
+ stats_collector = StatsCollector([opto_inputs_handler, gpio_inputs_handler, gpio_outputs_handler])
181
+
182
+ button_instances = init_hardware(cfg)
183
+ button_instances += opto_inputs_handler.init_hardware(cfg)
184
+ button_instances += gpio_inputs_handler.init_hardware(cfg, loop)
185
+ gpio_outputs_handler.init_hardware(cfg)
186
+
187
+ homeassistant_status_tracker.set_discovery_publish_coroutines(
188
+ [
189
+ opto_inputs_handler.homeassistant_discovery_message_publish,
190
+ gpio_outputs_handler.homeassistant_discovery_message_publish,
191
+ ]
192
+ )
193
+
194
+ # wrap with error-handling code the main loop
195
+ exit_code = 0
196
+ print("Starting main loop")
197
+ while not g_stop_requested:
198
+ # the double-nested 'try' is the only way I found in Python 3.11.2 to catch properly
199
+ # both exception groups (using the 'except*' syntax) and also have a default catch-all
200
+ # label using regular 'except' syntax.
201
+
202
+ # NOTE: unfortunately we cannot use a taskgroup: the problem is the function
203
+ # subscribe_and_activate_outputs() which uses the aiomqtt.Client.messages generator
204
+ # which does not allow to easily stop the 'waiting for next message' operation.
205
+ # This means we need to create each task manually with asyncio.EventLoop.create_task()
206
+ # and cancel() them whenever a SIGTERM is received.
207
+ #
208
+ # async with asyncio.TaskGroup() as tg:
209
+ # tg.create_task(print_stats_periodically(cfg))
210
+ # # inputs:
211
+ # tg.create_task(publish_optoisolated_inputs(cfg))
212
+ # tg.create_task(process_gpio_inputs_queue_and_publish(cfg))
213
+ # # outputs:
214
+ # tg.create_task(subscribe_and_activate_outputs(cfg))
215
+ # tg.create_task(publish_outputs_state(cfg))
216
+
217
+ # launch all coroutines:
218
+ loop = asyncio.get_running_loop()
219
+ tasks = [
220
+ loop.create_task(stats_collector.print_stats_periodically(cfg)),
221
+ loop.create_task(opto_inputs_handler.publish_optoisolated_inputs(cfg)),
222
+ loop.create_task(gpio_inputs_handler.process_gpio_inputs_queue_and_publish(cfg)),
223
+ loop.create_task(gpio_outputs_handler.subscribe_and_activate_outputs(cfg)),
224
+ loop.create_task(gpio_outputs_handler.publish_outputs_state(cfg)),
225
+ ]
226
+
227
+ if cfg.homeassistant_discovery_messages_enable:
228
+ # subscribe to HomeAssistant status notification and eventually trigger MQTT discovery messages
229
+ loop.create_task(homeassistant_status_tracker.subscribe_status(cfg)),
230
+
231
+ # this main coroutine will simply wait till a SIGTERM arrives and
232
+ # we get g_stop_requested=True:
233
+ while not g_stop_requested:
234
+ await asyncio.sleep(1)
235
+
236
+ print("Main coroutine is now cancelling all sub-tasks (coroutines)")
237
+ GpioInputsHandler.stop_requested = True
238
+ GpioOutputsHandler.stop_requested = True
239
+ OptoIsolatedInputsHandler.stop_requested = True
240
+ for t in tasks:
241
+ t.cancel()
242
+
243
+ print("Waiting cancellation of all tasks")
244
+ for t in tasks:
245
+ # Wait for the task to be cancelled
246
+ try:
247
+ await t
248
+ except asyncio.CancelledError:
249
+ pass
250
+
251
+ print("Printing stats for the last time:")
252
+ stats_collector.print_stats()
253
+
254
+ print(f"Exiting gracefully with exit code {exit_code}...")
255
+ return exit_code
256
+
257
+
258
+ def entrypoint():
259
+ if instance_already_running(MiscAppDefaults.THIS_APP_NAME):
260
+ print(
261
+ "Sorry, detected another instance of this daemon is already running. Using the same I2C bus from 2 sofware programs is not recommended. Aborting."
262
+ )
263
+ sys.exit(3)
264
+
265
+ try:
266
+ sys.exit(asyncio.run(main_loop()))
267
+ except KeyboardInterrupt:
268
+ print("Stopping due to CTRL+C")
269
+
270
+
271
+ # =======================================================================================================
272
+ # MAIN
273
+ # =======================================================================================================
274
+
275
+ if __name__ == "__main__":
276
+ entrypoint()
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import lib16inpind
4
+ import time
5
+ import asyncio
6
+ import gpiozero
7
+ import json
8
+ import sys
9
+ import aiomqtt
10
+ import threading
11
+ from .constants import MqttQOS, SeqMicroHatConstants
12
+ from .config import AppConfig
13
+ from .circular_buffer import CircularBuffer
14
+
15
+ #
16
+ # Author: fmontorsi
17
+ # Created: May 2024
18
+ # License: Apache license
19
+ #
20
+
21
+ # =======================================================================================================
22
+ # OptoIsolatedInputsHandler
23
+ # =======================================================================================================
24
+
25
+
26
+ class OptoIsolatedInputsHandler:
27
+ """
28
+ This class handles sampling inputs from the
29
+ [Sequent Microsystem 16 opto-insulated inputs HAT](https://sequentmicrosystems.com/collections/all-io-cards/products/16-universal-inputs-card-for-raspberry-pi)
30
+ and publishing the results to MQTT.
31
+ It exposes a coroutine that can be 'await'ed, which handles publishing.
32
+ """
33
+
34
+ # the stop-request is not related to a particular instance of this class... it applies to any instance
35
+ stop_requested = False
36
+
37
+ # the MQTT client identifier
38
+ client_identifier = "_optoisolated_publisher"
39
+ client_identifier_discovery_pub = "_optoisolated_discovery_publisher"
40
+
41
+ buffer_num_samples = 64
42
+
43
+ def __init__(self):
44
+ # last reading of the 16 digital opto-isolated inputs
45
+ self.optoisolated_inputs_sampled_values = []
46
+ for i in range(SeqMicroHatConstants.MAX_CHANNELS):
47
+ self.optoisolated_inputs_sampled_values.append(CircularBuffer(OptoIsolatedInputsHandler.buffer_num_samples))
48
+
49
+ self.lock = threading.Lock()
50
+
51
+ self.stats = {
52
+ "num_readings": 0,
53
+ "num_connections_publish": 0,
54
+ "num_mqtt_messages": 0,
55
+ "num_connections_discovery_publish": 0,
56
+ "num_mqtt_discovery_messages_published": 0,
57
+ "ERROR_no_stable_samples": 0,
58
+ "ERROR_num_connections_lost": 0,
59
+ }
60
+
61
+ def init_hardware(self, cfg: AppConfig) -> list[gpiozero.Button]:
62
+ buttons = []
63
+ if cfg.disable_hw:
64
+ print("Skipping optoisolated inputs HW initialization (--disable-hw was given)")
65
+
66
+ timestamp = int(time.time())
67
+ with self.lock:
68
+ for i in range(SeqMicroHatConstants.MAX_CHANNELS):
69
+ self.optoisolated_inputs_sampled_values[i].push_sample(timestamp, False)
70
+ else:
71
+ # check if the opto-isolated input board from Sequent Microsystem is indeed present:
72
+ try:
73
+ _ = lib16inpind.readAll(SeqMicroHatConstants.STACK_LEVEL)
74
+ except FileNotFoundError as e:
75
+ print(f"Could not read from the Sequent Microsystem opto-isolated input board: {e}. Aborting.")
76
+ return 2
77
+ except OSError as e:
78
+ print(f"Error while reading from the Sequent Microsystem opto-isolated input board: {e}. Aborting.")
79
+ return 2
80
+ except BaseException as e:
81
+ print(f"Error while reading from the Sequent Microsystem opto-isolated input board: {e}. Aborting.")
82
+ return 2
83
+
84
+ print("Initializing SequentMicrosystem GPIO interrupt line")
85
+ b = gpiozero.Button(SeqMicroHatConstants.INTERRUPT_GPIO, pull_up=True)
86
+ b.when_held = self.sample_optoisolated_inputs
87
+ buttons.append(b)
88
+
89
+ # do first sampling operation immediately:
90
+ self.sample_optoisolated_inputs()
91
+
92
+ return buttons
93
+
94
+ def sample_optoisolated_inputs(self):
95
+ """
96
+ This function is invoked when the SequentMicrosystem hat triggers an interrupt saying
97
+ "hey there is some change in my inputs"... so we read all the 16 digital inputs
98
+ """
99
+
100
+ # NOTE0: since this routine is invoked by the gpiozero library, it runs on a secondary OS thread
101
+ # so we must use a mutex when writing to the 'optoisolated_inputs_sampled_values' array
102
+ # NOTE1: this is a blocking call that will block until the 16 inputs are sampled
103
+ # NOTE2: this might raise a TimeoutError exception in case the I2C bus transaction fails
104
+ packed_sample = lib16inpind.readAll(SeqMicroHatConstants.STACK_LEVEL)
105
+ timestamp = int(time.time())
106
+
107
+ with self.lock:
108
+ for i in range(SeqMicroHatConstants.MAX_CHANNELS):
109
+ # Extract the bit at position i-th using bitwise AND operation
110
+ self.optoisolated_inputs_sampled_values[i].push_sample(timestamp, bool(packed_sample & (1 << i)))
111
+
112
+ self.stats["num_readings"] += 1
113
+
114
+ # FIXME: right now, it's hard to force-wake the coroutine
115
+ # which handles publishing to MQTT
116
+ # the reason is that we should be using
117
+ # https://docs.python.org/3/library/asyncio-sync.html#asyncio.Event
118
+ # which is not thread-safe. And this function executes in GPIOzero secondary thread :(
119
+ # This means that if an input changes rapidly from 0 to 1 and then back to 0, we might not
120
+ # publish this to MQTT (depends on the MQTT publish frequency... nyquist frequency)
121
+
122
+ async def publish_optoisolated_inputs(self, cfg: AppConfig):
123
+ """
124
+ Publishes over MQTT the status of all opto-isolated inputs.
125
+ This function has a particularity: it's designed to continuously publish over MQTT the status of
126
+ the input channels. This is a safety feature designed mostly for alarm systems: thanks to this continuous
127
+ updates, the subscriber can trigger the burglar alarm if the stream of input sensor updates stops for some reason.
128
+ """
129
+ print(
130
+ f"Connecting to MQTT broker with identifier {OptoIsolatedInputsHandler.client_identifier} to publish OPTOISOLATED INPUT states"
131
+ )
132
+ self.stats["num_connections_publish"] += 1
133
+ while True:
134
+ try:
135
+ async with cfg.create_aiomqtt_client(OptoIsolatedInputsHandler.client_identifier) as client:
136
+ while not OptoIsolatedInputsHandler.stop_requested:
137
+ # Publish each sampled value as a separate MQTT topic
138
+ update_loop_start_sec = time.perf_counter()
139
+ timestamp_now = int(time.time())
140
+ for i in range(SeqMicroHatConstants.MAX_CHANNELS):
141
+ # convert from zero-based index 'i' to 1-based index, as used in the config file
142
+ input_cfg = cfg.get_optoisolated_input_config(1 + i)
143
+ if input_cfg is None:
144
+ continue
145
+
146
+ # acquire last sampled value for the i-th input
147
+ with self.lock:
148
+ filter_min_duration = input_cfg["filter"]["stability_threshold_sec"]
149
+ if filter_min_duration == 0:
150
+ # filtering disabled -- just pick the most updated sample and use it
151
+ sample = self.optoisolated_inputs_sampled_values[i].get_last_sample()
152
+ assert (
153
+ sample is not None
154
+ ) # this should be impossible as buffer is always initialized with 1 sample at least
155
+ else:
156
+ # filtering enabled -- choose sensor status:
157
+ sample = self.optoisolated_inputs_sampled_values[i].get_stable_sample(
158
+ timestamp_now, filter_min_duration
159
+ )
160
+ if sample is None:
161
+ # failed to find a 'stable' sample -- assume the input is malfunctioning
162
+ self.stats["ERROR_no_stable_samples"] += 1
163
+ sample = (0, False)
164
+
165
+ # sample is a tuple (TIMESTAMP;VALUE), extract just the value:
166
+ bit_value = sample[1]
167
+ if input_cfg["active_low"]:
168
+ logical_value = not bit_value
169
+ # input_type = "active low"
170
+ else:
171
+ logical_value = bit_value
172
+ # input_type = "active high"
173
+
174
+ payload = (
175
+ input_cfg["mqtt"]["payload_on"] if logical_value else input_cfg["mqtt"]["payload_off"]
176
+ )
177
+ # print(f"From INPUT#{i+1} [{input_type}] read {int(bit_value)} -> {int(logical_value)}; publishing on mqtt topic [{topic}] the payload: {payload}")
178
+
179
+ await client.publish(input_cfg["mqtt"]["topic"], payload, qos=MqttQOS.AT_LEAST_ONCE)
180
+ self.stats["num_mqtt_messages"] += 1
181
+
182
+ update_loop_duration_sec = time.perf_counter() - update_loop_start_sec
183
+ # print(f"Updating all sensors on MQTT took {update_loop_duration_sec} secs")
184
+
185
+ # Now sleep a little bit before repeating
186
+ actual_sleep_time_sec = cfg.homeassistant_publish_period_sec
187
+ if actual_sleep_time_sec > update_loop_duration_sec:
188
+ # adjust for the time it took to update on MQTT broker all topics
189
+ actual_sleep_time_sec -= update_loop_duration_sec
190
+
191
+ await asyncio.sleep(actual_sleep_time_sec)
192
+ except aiomqtt.MqttError as err:
193
+ print(f"Connection lost: {err}; reconnecting in {cfg.mqtt_reconnection_period_sec} seconds ...")
194
+ self.stats["ERROR_num_connections_lost"] += 1
195
+ await asyncio.sleep(cfg.mqtt_reconnection_period_sec)
196
+ except Exception as err:
197
+ print(f"EXCEPTION: [{err}]. Exiting with code 99.")
198
+ sys.exit(99)
199
+
200
+ async def homeassistant_discovery_message_publish(self, cfg: AppConfig):
201
+ """
202
+ Publishes over MQTT a so-called 'discovery' message that allows HomeAssistant to automatically
203
+ detect the binary_sensors associated with the GPIO inputs.
204
+ See https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery
205
+ """
206
+ print(
207
+ f"Connecting to MQTT broker with identifier {OptoIsolatedInputsHandler.client_identifier_discovery_pub} to publish OPTOISOLATED INPUT discovery messages"
208
+ )
209
+ self.stats["num_connections_discovery_publish"] += 1
210
+
211
+ try:
212
+ async with cfg.create_aiomqtt_client(OptoIsolatedInputsHandler.client_identifier_discovery_pub) as client:
213
+ print("Publishing DISCOVERY messages for OPTOISOLATED INPUTs")
214
+ for entry in cfg.get_all_optoisolated_inputs():
215
+ mqtt_prefix = cfg.homeassistant_discovery_topic_prefix
216
+ mqtt_platform = entry["home_assistant"]["platform"]
217
+ assert mqtt_platform == "binary_sensor" # the only supported value for now
218
+ mqtt_node_id = cfg.homeassistant_discovery_topic_node_id
219
+ mqtt_discovery_topic = f"{mqtt_prefix}/{mqtt_platform}/{mqtt_node_id}/{entry['name']}/config"
220
+
221
+ # NOTE: the HomeAssistant unique_id is what appears in the config file as "name"
222
+ mqtt_payload_dict = {
223
+ "unique_id": entry["name"],
224
+ "name": entry["description"],
225
+ "state_topic": entry["mqtt"]["topic"],
226
+ "payload_on": entry["mqtt"]["payload_on"],
227
+ "payload_off": entry["mqtt"]["payload_off"],
228
+ "device_class": entry["home_assistant"]["device_class"],
229
+ "expire_after": entry["home_assistant"]["expire_after"],
230
+ "device": cfg.get_device_dict(),
231
+ }
232
+ if entry["home_assistant"]["icon"] is not None:
233
+ # add icon to the config of the entry:
234
+ mqtt_payload_dict["icon"] = entry["home_assistant"]["icon"]
235
+ mqtt_payload = json.dumps(mqtt_payload_dict)
236
+ await client.publish(mqtt_discovery_topic, mqtt_payload, qos=MqttQOS.AT_LEAST_ONCE)
237
+ self.stats["num_mqtt_discovery_messages_published"] += 1
238
+ except aiomqtt.MqttError as err:
239
+ print(f"Connection lost: {err}; reconnecting in {cfg.mqtt_reconnection_period_sec} seconds ...")
240
+ self.stats["ERROR_num_connections_lost"] += 1
241
+ await asyncio.sleep(cfg.mqtt_reconnection_period_sec)
242
+ except Exception as err:
243
+ print(f"EXCEPTION: {err}")
244
+ sys.exit(99)
245
+
246
+ def print_stats(self):
247
+ print(">> OPTO-ISOLATED INPUTS:")
248
+ print(f">> Num (re)connections to the MQTT broker [publish channel]: {self.stats['num_connections_publish']}")
249
+ print(f">> Num MQTT messages published to the broker: {self.stats['num_mqtt_messages']}")
250
+ print(f">> Num actual readings of optoisolated inputs: {self.stats['num_readings']}")
251
+ print(">> OPTO-ISOLATED DISCOVERY messages:")
252
+ print(f">> Num MQTT discovery messages published: {self.stats['num_mqtt_discovery_messages_published']}")
253
+ print(f">> Num (re)connections to the MQTT broker: {self.stats['num_connections_discovery_publish']}")
254
+ print(f">> ERROR: unstable samples found: {self.stats['ERROR_no_stable_samples']}")
255
+ print(f">> ERROR: MQTT connections lost: {self.stats['ERROR_num_connections_lost']}")
raspy2mqtt/stats.py ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import time
4
+ import asyncio
5
+
6
+ # from raspy2mqtt.constants import *
7
+ from .config import AppConfig
8
+
9
+ #
10
+ # Author: fmontorsi
11
+ # Created: Apr 2024
12
+ # License: Apache license
13
+ #
14
+
15
+ # =======================================================================================================
16
+ # StatsCollector
17
+ # =======================================================================================================
18
+
19
+
20
+ class StatsCollector:
21
+ """
22
+ This class handles collecting stats from other objects of the application and periodically
23
+ show them on the stdout
24
+ """
25
+
26
+ # the stop-request is not related to a particular instance of this class... it applies to any instance
27
+ stop_requested = False
28
+
29
+ def __init__(self, objs_with_stats: list):
30
+ self.start_time = time.time()
31
+ self.counter = 1
32
+ self.objs_with_stats = objs_with_stats
33
+
34
+ async def print_stats_periodically(self, cfg: AppConfig):
35
+ if cfg.stats_log_period_sec == 0:
36
+ return # the user requested to NOT print periodically the stats
37
+ next_stat_time = time.time() + cfg.stats_log_period_sec
38
+ while not StatsCollector.stop_requested:
39
+ # Print out stats to help debugging
40
+ if time.time() >= next_stat_time:
41
+ self.print_stats()
42
+ next_stat_time = time.time() + cfg.stats_log_period_sec
43
+
44
+ await asyncio.sleep(0.25)
45
+
46
+ def print_stats(self):
47
+ print(f">> STAT REPORT #{self.counter}")
48
+
49
+ uptime_sec = time.time() - self.start_time
50
+ m, s = divmod(uptime_sec, 60)
51
+ h, m = divmod(m, 60)
52
+ h = int(h)
53
+ m = int(m)
54
+ s = int(s)
55
+ print(f">> Uptime: {h}:{m:02}:{s:02}")
56
+
57
+ for x in self.objs_with_stats:
58
+ x.print_stats()
59
+
60
+ self.counter += 1