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_version.py +1 -0
- raspy2mqtt/__init__.py +0 -0
- raspy2mqtt/circular_buffer.py +122 -0
- raspy2mqtt/config.py +674 -0
- raspy2mqtt/constants.py +89 -0
- raspy2mqtt/gpio_inputs_handler.py +151 -0
- raspy2mqtt/gpio_outputs_handler.py +288 -0
- raspy2mqtt/homeassistant_status_tracker.py +100 -0
- raspy2mqtt/main.py +276 -0
- raspy2mqtt/optoisolated_inputs_handler.py +255 -0
- raspy2mqtt/stats.py +60 -0
- rpi2home_assistant-2.3.0.dist-info/METADATA +201 -0
- rpi2home_assistant-2.3.0.dist-info/RECORD +16 -0
- rpi2home_assistant-2.3.0.dist-info/WHEEL +5 -0
- rpi2home_assistant-2.3.0.dist-info/entry_points.txt +2 -0
- rpi2home_assistant-2.3.0.dist-info/licenses/LICENSE +28 -0
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
|