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.
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+
3
+ #
4
+ # Author: fmontorsi
5
+ # Created: Apr 2024
6
+ # License: Apache license
7
+ #
8
+
9
+
10
+ # MQTT constants
11
+ class MqttQOS:
12
+ AT_LEAST_ONCE = 1
13
+
14
+
15
+ class MqttDefaults:
16
+ PAYLOAD_ON = "ON"
17
+ PAYLOAD_OFF = "OFF"
18
+ BROKER_PORT = 1883
19
+ RECONNECTION_PERIOD_SEC = 1
20
+
21
+
22
+ # HomeAssistant constants/defaults
23
+ class HomeAssistantDefaults:
24
+ TOPIC_PREFIX = "rpi2home-assistant"
25
+ DISCOVERY_TOPIC_PREFIX = "homeassistant"
26
+ PUBLISH_PERIOD_SEC = 1
27
+ DISCOVERY_PUBLISH_PERIOD_SEC = 100
28
+ EXPIRE_AFTER_SEC = 30
29
+ MANUFACTURER = "github.com/f18m"
30
+ BUTTON_MOMENTARY_PRESS_SEC = 0.5
31
+ ALLOWED_DEVICE_CLASSES = {
32
+ # see https://www.home-assistant.io/integrations/binary_sensor/#device-class
33
+ "binary_sensor": [
34
+ "battery",
35
+ "battery_charging",
36
+ "carbon_monoxide",
37
+ "cold",
38
+ "connectivity",
39
+ "door",
40
+ "garage_door",
41
+ "gas",
42
+ "heat",
43
+ "light",
44
+ "lock",
45
+ "moisture",
46
+ "motion",
47
+ "moving",
48
+ "occupancy",
49
+ "opening",
50
+ "plug",
51
+ "power",
52
+ "presence",
53
+ "problem",
54
+ "running",
55
+ "safety",
56
+ "smoke",
57
+ "sound",
58
+ "tamper",
59
+ "update",
60
+ "vibration",
61
+ "window",
62
+ ],
63
+ # see https://www.home-assistant.io/integrations/switch/#device-class
64
+ "switch": ["outlet", "switch"],
65
+ # see https://www.home-assistant.io/integrations/button/#device-class
66
+ "button": ["identify", "restart", "update"],
67
+ }
68
+
69
+
70
+ # SequentMicrosystem-specific constants
71
+ class SeqMicroHatConstants:
72
+ STACK_LEVEL = 0 # 0 means the first "stacked" board (this code supports only 1!)
73
+ MAX_CHANNELS = 16
74
+ SHUTDOWN_BUTTON_GPIO = 26 # GPIO pin connected to the push button
75
+ INTERRUPT_GPIO = 11 # GPIO pin connected to the interrupt line of the I/O expander (need pullup resistor)
76
+ I2C_SDA = 2 # reserved for I2C communication between Raspberry CPU and the input HAT
77
+ I2C_SCL = 3 # reserved for I2C communication between Raspberry CPU and the input HAT
78
+
79
+
80
+ # Generic app constants/defaults
81
+ class MiscAppDefaults:
82
+ THIS_APP_NAME = "rpi2home-assistant"
83
+
84
+ # File paths constants
85
+ CONFIG_FILE = "/etc/rpi2home-assistant.yaml"
86
+ INTEGRATION_TESTS_OUTPUT_FILE = "/tmp/integration-tests-output"
87
+
88
+ # Misc constants
89
+ STATS_LOG_PERIOD_SEC = 30
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import gpiozero
4
+ import signal
5
+ import asyncio
6
+ import queue
7
+ import sys
8
+ import aiomqtt
9
+ from .constants import MqttQOS
10
+ from .config import AppConfig
11
+
12
+ #
13
+ # Author: fmontorsi
14
+ # Created: May 2024
15
+ # License: Apache license
16
+ #
17
+
18
+ # =======================================================================================================
19
+ # GpioInputsHandler
20
+ # =======================================================================================================
21
+
22
+
23
+ class GpioInputsHandler:
24
+ """
25
+ This class handles sampling GPIO pins configured for inputs and publishing the results to MQTT.
26
+ It exposes a coroutine that can be 'await'ed, which handles publishing.
27
+ """
28
+
29
+ # the stop-request is not related to a particular instance of this class... it applies to any instance
30
+ stop_requested = False
31
+
32
+ # the MQTT client identifier
33
+ client_identifier = "_gpio_publisher"
34
+
35
+ def __init__(self):
36
+ # thread-safe queue to communicate from GPIOzero secondary threads to main thread
37
+ self.gpio_queue = queue.Queue()
38
+
39
+ # in case integration tests are running:
40
+ self.last_emulated_gpio_number = 0
41
+
42
+ self.stats = {
43
+ "num_connections_publish": 0,
44
+ "num_gpio_notifications": 0,
45
+ "num_mqtt_messages": 0,
46
+ "ERROR_noconfig": 0,
47
+ "ERROR_num_connections_lost": 0,
48
+ }
49
+
50
+ def on_gpio_input(self, device):
51
+ """
52
+ This is a gpiozero callback function.
53
+ Remember: gpiozero will invoke such functions from a SECONDARY thread. That's why we use a
54
+ thread-safe queue to communicate back to the main thread (which runs the event loop)
55
+ """
56
+ print(f"!! Detected activation of GPIO{device.pin.number} !! ")
57
+ self.gpio_queue.put(device.pin.number)
58
+
59
+ async def emulate_gpio_input(self, sig: signal.Signals) -> None:
60
+ """
61
+ Used for integration tests.
62
+ Emulates a GPIO input activation
63
+ """
64
+ self.last_emulated_gpio_number += 1
65
+ print(f"Received signal {sig.name}: emulating press of GPIO {self.last_emulated_gpio_number}")
66
+ self.gpio_queue.put(self.last_emulated_gpio_number)
67
+
68
+ def init_hardware(self, cfg: AppConfig, loop: asyncio.BaseEventLoop) -> list[gpiozero.Button]:
69
+ buttons = []
70
+
71
+ if cfg.disable_hw:
72
+ print("Skipping GPIO inputs HW initialization (--disable-hw was given)")
73
+
74
+ for sig in [signal.SIGUSR1, signal.SIGUSR2]:
75
+ loop.add_signal_handler(sig, lambda: asyncio.create_task(self.emulate_gpio_input(sig)))
76
+
77
+ else:
78
+
79
+ # setup GPIO pins for the INPUTs
80
+ print("Initializing GPIO input pins")
81
+ for input_ch in cfg.get_all_gpio_inputs():
82
+ # the short hold-time is to ensure that the digital input is served ASAP (i.e. on_gpio_input gets
83
+ # invoked almost immediately)
84
+ active_high = not bool(input_ch["active_low"])
85
+ b = gpiozero.Button(input_ch["gpio"], hold_time=0.1, pull_up=None, active_state=active_high)
86
+ b.when_held = self.on_gpio_input
87
+ buttons.append(b)
88
+
89
+ return buttons
90
+
91
+ async def process_gpio_inputs_queue_and_publish(self, cfg: AppConfig):
92
+ """
93
+ Publishes over MQTT a message each time a GPIO input changes status.
94
+ This function can be gracefully stopped by setting the
95
+ GpioInputsHandler.stop_requested
96
+ class variable to true.
97
+ """
98
+ print(
99
+ f"Connecting to MQTT broker with identifier {GpioInputsHandler.client_identifier} to publish GPIO INPUT states"
100
+ )
101
+ self.stats["num_connections_publish"] += 1
102
+ while True:
103
+ try:
104
+ async with cfg.create_aiomqtt_client(GpioInputsHandler.client_identifier) as client:
105
+ while not GpioInputsHandler.stop_requested:
106
+ # get next notification coming from the gpiozero secondary thread:
107
+ try:
108
+ gpio_number = self.gpio_queue.get_nowait()
109
+ except queue.Empty:
110
+ # if there's no notification (typical case), then do not block the event loop
111
+ # and keep processing other tasks... to ensure low-latency in processing the
112
+ # GPIO inputs the sleep time is set equal to the MQTT publish freq
113
+ await asyncio.sleep(cfg.homeassistant_publish_period_sec)
114
+ continue
115
+
116
+ # there is a GPIO notification to process:
117
+ gpio_config = cfg.get_gpio_input_config(gpio_number)
118
+ self.stats["num_gpio_notifications"] += 1
119
+ if gpio_config is None or "mqtt" not in gpio_config:
120
+ print(
121
+ f"Main thread got notification of GPIO#{gpio_number} being activated but there is NO CONFIGURATION for that pin. Ignoring."
122
+ )
123
+ self.stats["ERROR_noconfig"] += 1
124
+ else:
125
+ # extract MQTT config
126
+ mqtt_topic = gpio_config["mqtt"]["topic"]
127
+ mqtt_payload = gpio_config["mqtt"]["payload"]
128
+ print(
129
+ f"Main thread got notification of GPIO#{gpio_number} being activated; a valid MQTT configuration is attached: topic={mqtt_topic}, payload={mqtt_payload}"
130
+ )
131
+
132
+ # send to broker
133
+ await client.publish(mqtt_topic, mqtt_payload, qos=MqttQOS.AT_LEAST_ONCE)
134
+ self.stats["num_mqtt_messages"] += 1
135
+
136
+ self.gpio_queue.task_done()
137
+ except aiomqtt.MqttError as err:
138
+ print(f"Connection lost: {err}; reconnecting in {cfg.mqtt_reconnection_period_sec} seconds ...")
139
+ self.stats["ERROR_num_connections_lost"] += 1
140
+ await asyncio.sleep(cfg.mqtt_reconnection_period_sec)
141
+ except Exception as err:
142
+ print(f"EXCEPTION: {err}")
143
+ sys.exit(99)
144
+
145
+ def print_stats(self):
146
+ print(">> GPIO INPUTS:")
147
+ print(f">> Num (re)connections to the MQTT broker [publish channel]: {self.stats['num_connections_publish']}")
148
+ print(f">> Num GPIO activations detected: {self.stats['num_gpio_notifications']}")
149
+ print(f">> Num MQTT messages published to the broker: {self.stats['num_mqtt_messages']}")
150
+ print(f">> ERROR: GPIO inputs detected but missing configuration: {self.stats['ERROR_noconfig']}")
151
+ print(f">> ERROR: MQTT connections lost: {self.stats['ERROR_num_connections_lost']}")
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import gpiozero
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ import aiomqtt
8
+ from .constants import MqttQOS, MiscAppDefaults, HomeAssistantDefaults
9
+ from .config import AppConfig
10
+
11
+ #
12
+ # Author: fmontorsi
13
+ # Created: May 2024
14
+ # License: Apache license
15
+ #
16
+
17
+ # =======================================================================================================
18
+ # DummyOutputCh
19
+ # =======================================================================================================
20
+
21
+
22
+ class DummyOutputCh:
23
+ """
24
+ This class exists just to make it easier to run integration tests on platforms that do not have
25
+ true GPIO output pins (like a GitHub runner)
26
+ """
27
+
28
+ def __init__(self, gpio: int) -> None:
29
+ self.is_lit = False
30
+ self.gpio = gpio
31
+
32
+ def on(self):
33
+ print(
34
+ f"INTEGRATION-TEST-HELPER: DummyOutputCh: ON method invoked... writing into {MiscAppDefaults.INTEGRATION_TESTS_OUTPUT_FILE}"
35
+ )
36
+ self.is_lit = True
37
+ with open(MiscAppDefaults.INTEGRATION_TESTS_OUTPUT_FILE, "w") as opened_file:
38
+ opened_file.write(f"{self.gpio}: ON")
39
+
40
+ def off(self):
41
+ print(
42
+ f"INTEGRATION-TEST-HELPER: DummyOutputCh: OFF method invoked... writing into {MiscAppDefaults.INTEGRATION_TESTS_OUTPUT_FILE}"
43
+ )
44
+ self.is_lit = False
45
+ with open(MiscAppDefaults.INTEGRATION_TESTS_OUTPUT_FILE, "w") as opened_file:
46
+ opened_file.write(f"{self.gpio}: OFF")
47
+
48
+
49
+ # =======================================================================================================
50
+ # GpioOutputsHandler
51
+ # =======================================================================================================
52
+
53
+
54
+ class GpioOutputsHandler:
55
+ """
56
+ This class handles subscribing to MQTT topics and then based on what gets published it handles
57
+ driving GPIO pins configured as outputs.
58
+ It exposes a coroutine that can be 'await'ed, which handles subscriptions for commands and state publishing.
59
+ """
60
+
61
+ # the stop-request is not related to a particular instance of this class... it applies to any instance
62
+ stop_requested = False
63
+
64
+ # the MQTT client identifiers
65
+ client_identifier_pub = "_outputs_state_publisher"
66
+ client_identifier_sub = "_outputs_cmd_subscriber"
67
+ client_identifier_discovery_pub = "_outputs_discovery_publisher"
68
+
69
+ def __init__(self):
70
+ # global dictionary of gpiozero.LED instances used to drive outputs; key=MQTT topic
71
+ self.output_channels = {}
72
+
73
+ self.stats = {
74
+ "num_connections_subscribe": 0,
75
+ "num_mqtt_commands_processed": 0,
76
+ "num_connections_publish": 0,
77
+ "num_mqtt_states_published": 0,
78
+ "num_connections_discovery_publish": 0,
79
+ "num_mqtt_discovery_messages_published": 0,
80
+ "ERROR_invalid_payload_received": 0,
81
+ "ERROR_num_connections_lost": 0,
82
+ }
83
+
84
+ def init_hardware(self, cfg: AppConfig) -> None:
85
+ if cfg.disable_hw:
86
+ # populate with dummies the output channels:
87
+ print("Skipping GPIO outputs HW initialization (--disable-hw was given)")
88
+ for output_ch in cfg.get_all_outputs():
89
+ topic_name = output_ch["mqtt"]["topic"]
90
+ self.output_channels[topic_name] = DummyOutputCh(output_ch["gpio"])
91
+ else:
92
+ # setup GPIO pins for the OUTPUTs
93
+ print("Initializing GPIO output pins")
94
+ for output_ch in cfg.get_all_outputs():
95
+ topic_name = output_ch["mqtt"]["topic"]
96
+ active_high = not bool(output_ch["active_low"])
97
+ self.output_channels[topic_name] = gpiozero.LED(pin=output_ch["gpio"], active_high=active_high)
98
+
99
+ async def subscribe_and_activate_outputs(self, cfg: AppConfig):
100
+ """
101
+ Subscribes to MQTT topics that will receive commands to activate/turn-off GPIO outputs
102
+ and takes care of interfacing with gpiozero to actually drive the GPIO output pin high or low.
103
+ """
104
+ print(
105
+ f"Connecting to MQTT broker with identifier {GpioOutputsHandler.client_identifier_sub} to subscribe to OUTPUT commands"
106
+ )
107
+ self.stats["num_connections_subscribe"] += 1
108
+ while True:
109
+ try:
110
+ async with cfg.create_aiomqtt_client(GpioOutputsHandler.client_identifier_sub) as client:
111
+ for output_ch in cfg.get_all_outputs():
112
+ topic = output_ch["mqtt"]["topic"]
113
+ print(f"GpioOutputsHandler: Subscribing to topic [{topic}]")
114
+ await client.subscribe(topic)
115
+
116
+ async for message in client.messages:
117
+ # IMPORTANT: the message.payload and message.topic are not strings and would fail
118
+ # a direct comparison to strings... so convert them explicitly to strings first:
119
+ mqtt_topic = str(message.topic)
120
+ mqtt_payload = message.payload.decode("UTF-8")
121
+
122
+ output_ch = cfg.get_output_config_by_mqtt_topic(mqtt_topic)
123
+ assert (
124
+ output_ch is not None
125
+ ) # this is garantueed because we subscribed only to topics that are present in config
126
+
127
+ output_name = output_ch["name"]
128
+ if mqtt_payload == output_ch["mqtt"]["payload_on"]:
129
+
130
+ if output_ch["home_assistant"]["platform"] == "switch":
131
+ print(
132
+ f"Received message for SWITCH digital output [{output_name}] from topic [{mqtt_topic}] with payload {mqtt_payload}... changing GPIO output pin state"
133
+ )
134
+ self.output_channels[mqtt_topic].on()
135
+ elif output_ch["home_assistant"]["platform"] == "button":
136
+ print(
137
+ f"Received message for BUTTON digital output [{output_name}] from topic [{mqtt_topic}] with payload {mqtt_payload}... changing GPIO output pin state for {HomeAssistantDefaults.BUTTON_MOMENTARY_PRESS_SEC}sec"
138
+ )
139
+ self.output_channels[mqtt_topic].on()
140
+ await asyncio.sleep(HomeAssistantDefaults.BUTTON_MOMENTARY_PRESS_SEC)
141
+ self.output_channels[mqtt_topic].off()
142
+
143
+ elif mqtt_payload == output_ch["mqtt"]["payload_off"]:
144
+ print(
145
+ f"Received message for SWITCH digital output [{output_name}] from topic [{mqtt_topic}] with payload {mqtt_payload}... changing GPIO output pin state"
146
+ )
147
+ self.output_channels[mqtt_topic].off()
148
+ else:
149
+ print(
150
+ f"Unrecognized payload received for digital output [{output_name}] from topic [{mqtt_topic}]: {mqtt_payload}"
151
+ )
152
+ self.stats["ERROR_invalid_payload_received"] += 1
153
+
154
+ self.stats["num_mqtt_commands_processed"] += 1
155
+ except aiomqtt.MqttError as err:
156
+ print(f"Connection lost: {err}; reconnecting in {cfg.mqtt_reconnection_period_sec} seconds ...")
157
+ self.stats["ERROR_num_connections_lost"] += 1
158
+ await asyncio.sleep(cfg.mqtt_reconnection_period_sec)
159
+ except Exception as err:
160
+ print(f"EXCEPTION: {err}")
161
+ sys.exit(99)
162
+
163
+ async def publish_outputs_state(self, cfg: AppConfig):
164
+ """
165
+ For each output GPIO pin this function publishes over MQTT the 'state topic'.
166
+ The 'state topic' is a HomeAssistant-thing that acts as confirmation of the output commands:
167
+ only when the output truly can change from OFF->ON or from ON->OFF the state topic gets updated.
168
+
169
+ This function can be gracefully stopped by setting the
170
+ GpioOutputsHandler.stop_requested
171
+ class variable to true.
172
+ """
173
+
174
+ print(
175
+ f"Connecting to MQTT broker with identifier {GpioOutputsHandler.client_identifier_pub} to publish GPIO OUTPUT states"
176
+ )
177
+ self.stats["num_connections_publish"] += 1
178
+ output_status_map = {}
179
+ while True:
180
+ try:
181
+ async with cfg.create_aiomqtt_client(GpioOutputsHandler.client_identifier_pub) as client:
182
+ while not GpioOutputsHandler.stop_requested:
183
+ for output_ch in cfg.get_all_outputs():
184
+ mqtt_topic = output_ch["mqtt"]["topic"]
185
+ mqtt_state_topic = output_ch["mqtt"]["state_topic"]
186
+ assert mqtt_topic in self.output_channels # this should be garantueed due to initial setup
187
+ output_status = self.output_channels[mqtt_topic].is_lit
188
+
189
+ if mqtt_topic not in output_status_map or output_status_map[mqtt_topic] != output_status:
190
+ # need to publish an update over MQTT... the state has changed
191
+ mqtt_payload = (
192
+ output_ch["mqtt"]["payload_on"]
193
+ if output_status
194
+ else output_ch["mqtt"]["payload_off"]
195
+ )
196
+
197
+ # publish with RETAIN flag so that Home Assistant will always find an updated status on
198
+ # the broker about each switch/button
199
+ print(f"Publishing to topic {mqtt_state_topic} the payload {mqtt_payload}")
200
+ await client.publish(
201
+ mqtt_state_topic, mqtt_payload, qos=MqttQOS.AT_LEAST_ONCE, retain=True
202
+ )
203
+ self.stats["num_mqtt_states_published"] += 1
204
+
205
+ # remember the status we just published in order to later skip meaningless updates
206
+ # when there is no state change:
207
+ output_status_map[mqtt_topic] = output_status
208
+
209
+ await asyncio.sleep(cfg.homeassistant_publish_period_sec)
210
+ except aiomqtt.MqttError as err:
211
+ print(f"Connection lost: {err}; reconnecting in {cfg.mqtt_reconnection_period_sec} seconds ...")
212
+ self.stats["ERROR_num_connections_lost"] += 1
213
+ await asyncio.sleep(cfg.mqtt_reconnection_period_sec)
214
+ except Exception as err:
215
+ print(f"EXCEPTION: {err}")
216
+ sys.exit(99)
217
+
218
+ async def homeassistant_discovery_message_publish(self, cfg: AppConfig):
219
+ """
220
+ Publishes over MQTT a so-called 'discovery' message that allows HomeAssistant to automatically
221
+ detect the binary_sensors associated with the GPIO inputs.
222
+ See https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery
223
+ """
224
+ print(
225
+ f"Connecting to MQTT broker with identifier {GpioOutputsHandler.client_identifier_discovery_pub} to publish OUTPUT discovery messages"
226
+ )
227
+ self.stats["num_connections_discovery_publish"] += 1
228
+
229
+ try:
230
+ async with cfg.create_aiomqtt_client(GpioOutputsHandler.client_identifier_discovery_pub) as client:
231
+ print("Publishing DISCOVERY messages for GPIO OUTPUTs")
232
+ for entry in cfg.get_all_outputs():
233
+ mqtt_prefix = cfg.homeassistant_discovery_topic_prefix
234
+ mqtt_platform = entry["home_assistant"]["platform"]
235
+ mqtt_node_id = cfg.homeassistant_discovery_topic_node_id
236
+ mqtt_discovery_topic = f"{mqtt_prefix}/{mqtt_platform}/{mqtt_node_id}/{entry['name']}/config"
237
+
238
+ # NOTE: the HomeAssistant unique_id is what appears in the config file as "name"
239
+ mqtt_payload_dict = {
240
+ "unique_id": entry["name"],
241
+ "name": entry["description"],
242
+ "command_topic": entry["mqtt"]["topic"],
243
+ "state_topic": entry["mqtt"]["state_topic"],
244
+ "device_class": entry["home_assistant"]["device_class"],
245
+ # "expire_after": entry['home_assistant']["expire_after"], -- not supported by MQTT switch :(
246
+ "device": cfg.get_device_dict(),
247
+ }
248
+ if entry["home_assistant"]["icon"] is not None:
249
+ # add icon to the config of the entry:
250
+ mqtt_payload_dict["icon"] = entry["home_assistant"]["icon"]
251
+
252
+ if mqtt_platform == "switch":
253
+ mqtt_payload_dict["payload_on"] = entry["mqtt"]["payload_on"]
254
+ mqtt_payload_dict["payload_off"] = entry["mqtt"]["payload_off"]
255
+ elif mqtt_platform == "button":
256
+ mqtt_payload_dict["payload_press"] = entry["mqtt"]["payload_on"]
257
+
258
+ mqtt_payload = json.dumps(mqtt_payload_dict)
259
+ await client.publish(mqtt_discovery_topic, mqtt_payload, qos=MqttQOS.AT_LEAST_ONCE)
260
+ self.stats["num_mqtt_discovery_messages_published"] += 1
261
+
262
+ except aiomqtt.MqttError as err:
263
+ print(f"Connection lost: {err}; reconnecting in {cfg.mqtt_reconnection_period_sec} seconds ...")
264
+ self.stats["ERROR_num_connections_lost"] += 1
265
+ await asyncio.sleep(cfg.mqtt_reconnection_period_sec)
266
+ except Exception as err:
267
+ print(f"EXCEPTION: {err}")
268
+ sys.exit(99)
269
+
270
+ def print_stats(self):
271
+ print(">> OUTPUTS:")
272
+ print(
273
+ f">> Num (re)connections to the MQTT broker [subscribe channel]: {self.stats['num_connections_subscribe']}"
274
+ )
275
+ print(
276
+ f">> Num commands for output channels processed from MQTT broker: {self.stats['num_mqtt_commands_processed']}"
277
+ )
278
+ print(f">> Num (re)connections to the MQTT broker [publish channel]: {self.stats['num_connections_publish']}")
279
+ print(
280
+ f">> Num states for output channels published on the MQTT broker: {self.stats['num_mqtt_states_published']}"
281
+ )
282
+ print(">> OUTPUTs DISCOVERY messages:")
283
+ print(f">> Num MQTT discovery messages published: {self.stats['num_mqtt_discovery_messages_published']}")
284
+ print(f">> Num (re)connections to the MQTT broker: {self.stats['num_connections_discovery_publish']}")
285
+ print(
286
+ f">> ERROR: invalid payloads received [subscribe channel]: {self.stats['ERROR_invalid_payload_received']}"
287
+ )
288
+ print(f">> ERROR: MQTT connections lost: {self.stats['ERROR_num_connections_lost']}")
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+
3
+ #
4
+ # Author: fmontorsi
5
+ # Created: June 2024
6
+ # License: Apache license
7
+ #
8
+
9
+ import asyncio
10
+ import sys
11
+ import aiomqtt
12
+ from .config import AppConfig
13
+
14
+
15
+ # =======================================================================================================
16
+ # HomeAssistantStatusTracker
17
+ # =======================================================================================================
18
+
19
+
20
+ class HomeAssistantStatusTracker:
21
+ """
22
+ This class tracks the HomeAssistant MQTT birth message, which gets published, by default, on the
23
+ topic 'homeassistant/status'.
24
+ When the HomeAssistant status changes to 'online', MQTT discovery messages can be sent.
25
+ """
26
+
27
+ # the stop-request is not related to a particular instance of this class... it applies to any instance
28
+ stop_requested = False
29
+
30
+ # the MQTT client identifier
31
+ client_identifier = "_homeassistant_status_tracker"
32
+
33
+ def __init__(self):
34
+ self.coroutines_list = []
35
+ self.stats = {
36
+ "num_connections_subscribe": 0,
37
+ "num_mqtt_status_msg_processed": 0,
38
+ "ERROR_num_connections_lost": 0,
39
+ }
40
+
41
+ def set_discovery_publish_coroutines(self, coroutines_list):
42
+ self.coroutines_list = coroutines_list
43
+
44
+ async def trigger_discovery_messages(self, cfg: AppConfig):
45
+ idx = 1
46
+ for coro in self.coroutines_list:
47
+ print(f"Launching MQTT discovery message generator coroutine #{idx}...")
48
+ await coro(cfg)
49
+ idx += 1
50
+
51
+ async def subscribe_status(self, cfg: AppConfig):
52
+ """
53
+ Subscribes to the MQTT topic used by HomeAssistant to signal that it has restarted.
54
+ """
55
+ print(
56
+ f"Connecting to MQTT broker with identifier {HomeAssistantStatusTracker.client_identifier} to subscribe to HOME ASSISTANT status topic"
57
+ )
58
+ self.stats["num_connections_subscribe"] += 1
59
+ while True:
60
+ try:
61
+ async with cfg.create_aiomqtt_client(HomeAssistantStatusTracker.client_identifier) as client:
62
+
63
+ # immediately after startup of rpi2home-assistant we launch discovery messages in case
64
+ # HomeAssistant is listening...
65
+ await self.trigger_discovery_messages(cfg)
66
+
67
+ # subscribe
68
+ topic = f"{cfg.homeassistant_discovery_topic_prefix}/status"
69
+ print(f"HomeAssistantStatusTracker: Subscribing to topic [{topic}]")
70
+ await client.subscribe(topic)
71
+
72
+ async for message in client.messages:
73
+ # IMPORTANT: the message.payload and message.topic are not strings and would fail
74
+ # a direct comparison to strings... so convert them explicitly to strings first:
75
+ # mqtt_topic = str(message.topic)
76
+ mqtt_payload = message.payload.decode("UTF-8")
77
+
78
+ self.stats["num_mqtt_status_msg_processed"] += 1
79
+ if mqtt_payload == "online":
80
+ print("HomeAssistant status changed to 'online'. Sending MQTT discovery messages.")
81
+ await self.trigger_discovery_messages(cfg)
82
+ elif mqtt_payload == "offline":
83
+ # this is typically not a good news, unless it's a planned maintainance
84
+ print("!!! HomeAssistant status changed to 'offline' !!!")
85
+
86
+ except aiomqtt.MqttError as err:
87
+ print(f"Connection lost: {err}; reconnecting in {cfg.mqtt_reconnection_period_sec} seconds ...")
88
+ self.stats["ERROR_num_connections_lost"] += 1
89
+ await asyncio.sleep(cfg.mqtt_reconnection_period_sec)
90
+ except Exception as err:
91
+ print(f"EXCEPTION: {err}")
92
+ sys.exit(99)
93
+
94
+ def print_stats(self):
95
+ print(">> HOME ASSISTANT STATUS TRACKER:")
96
+ print(
97
+ f">> Num (re)connections to the MQTT broker [subscribe channel]: {self.stats['num_connections_subscribe']}"
98
+ )
99
+ print(f">> Num MQTT status messages processed: {self.stats['num_mqtt_status_msg_processed']}")
100
+ print(f">> ERROR: MQTT connections lost: {self.stats['ERROR_num_connections_lost']}")