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/constants.py
ADDED
@@ -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']}")
|