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/config.py
ADDED
@@ -0,0 +1,674 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
|
3
|
+
#
|
4
|
+
# Author: fmontorsi
|
5
|
+
# Created: Apr 2024
|
6
|
+
# License: Apache license
|
7
|
+
#
|
8
|
+
|
9
|
+
import yaml
|
10
|
+
import aiomqtt
|
11
|
+
import os
|
12
|
+
import platform
|
13
|
+
from datetime import datetime, timezone
|
14
|
+
from .constants import MqttDefaults, HomeAssistantDefaults, SeqMicroHatConstants, MiscAppDefaults
|
15
|
+
|
16
|
+
from schema import Schema, Optional, SchemaError, Regex
|
17
|
+
|
18
|
+
|
19
|
+
# =======================================================================================================
|
20
|
+
# AppConfig
|
21
|
+
# =======================================================================================================
|
22
|
+
|
23
|
+
|
24
|
+
class AppConfig:
|
25
|
+
"""
|
26
|
+
This class represents the configuration of this application.
|
27
|
+
It contains helpers to read the YAML config file for this utility plus helpers to
|
28
|
+
receive configurations from CLI options and from environment variables.
|
29
|
+
|
30
|
+
This class is also in charge of filling all values that might be missing in the config file
|
31
|
+
with their defaults. All default constants are stored in constants.py
|
32
|
+
"""
|
33
|
+
|
34
|
+
def __init__(self):
|
35
|
+
self.config = None
|
36
|
+
self.optoisolated_inputs_map = None # None means "not loaded at all"
|
37
|
+
|
38
|
+
# config options related to CLI options:
|
39
|
+
self.disable_hw = False # can be get/set from the outside
|
40
|
+
self.verbose = False
|
41
|
+
|
42
|
+
# technically speaking the version is not an "app config" but centralizing it here is handy
|
43
|
+
try:
|
44
|
+
self.app_version = AppConfig.get_embedded_version()
|
45
|
+
except ModuleNotFoundError:
|
46
|
+
self.app_version = "N/A"
|
47
|
+
|
48
|
+
self.current_hostname = platform.node()
|
49
|
+
|
50
|
+
# before launching MQTT connections, define a unique MQTT prefix identifier:
|
51
|
+
self.mqtt_identifier_prefix = "rpi2home_assistant_" + datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
52
|
+
|
53
|
+
self.mqtt_schema_for_sensor_on_and_off = Schema(
|
54
|
+
{
|
55
|
+
Optional("topic"): str,
|
56
|
+
# the 'state_topic' makes sense only for OUTPUTs that have type=switch in HomeAssistant and
|
57
|
+
# are required to publish a state topic
|
58
|
+
Optional("state_topic"): str,
|
59
|
+
Optional("payload_on"): str,
|
60
|
+
Optional("payload_off"): str,
|
61
|
+
}
|
62
|
+
)
|
63
|
+
self.mqtt_schema_for_edge_triggered_sensor = Schema(
|
64
|
+
{
|
65
|
+
Optional("topic"): str,
|
66
|
+
# for edge-triggered sensors it's hard to propose a meaningful default payload...so it's not optional
|
67
|
+
"payload": str,
|
68
|
+
}
|
69
|
+
)
|
70
|
+
self.home_assistant_schema = Schema(
|
71
|
+
{
|
72
|
+
# device_class is required because it's hard to guess...
|
73
|
+
"device_class": str,
|
74
|
+
# the platform defaults to 'binary_sensor' for inputs and to 'switch' for outputs
|
75
|
+
Optional("platform"): str,
|
76
|
+
Optional("expire_after"): int,
|
77
|
+
Optional("icon"): str,
|
78
|
+
}
|
79
|
+
)
|
80
|
+
|
81
|
+
self.filter_schema = Schema(
|
82
|
+
{
|
83
|
+
Optional("stability_threshold_sec"): int,
|
84
|
+
}
|
85
|
+
)
|
86
|
+
|
87
|
+
self.config_file_schema = Schema(
|
88
|
+
{
|
89
|
+
"mqtt_broker": {
|
90
|
+
"host": str,
|
91
|
+
Optional("port"): int,
|
92
|
+
Optional("reconnection_period_msec"): int,
|
93
|
+
Optional("user"): str,
|
94
|
+
Optional("password"): str,
|
95
|
+
},
|
96
|
+
Optional("home_assistant"): {
|
97
|
+
Optional("default_topic_prefix"): str,
|
98
|
+
Optional("publish_period_msec"): int,
|
99
|
+
Optional("discovery_messages"): {
|
100
|
+
Optional("enable"): bool,
|
101
|
+
Optional("topic_prefix"): str,
|
102
|
+
Optional("node_id"): str,
|
103
|
+
},
|
104
|
+
},
|
105
|
+
Optional("log_stats_every"): int,
|
106
|
+
Optional("i2c_optoisolated_inputs"): [
|
107
|
+
{
|
108
|
+
"name": Regex(r"^[a-z0-9_]+$"),
|
109
|
+
Optional("description"): str,
|
110
|
+
"input_num": int,
|
111
|
+
"active_low": bool,
|
112
|
+
Optional("mqtt"): self.mqtt_schema_for_sensor_on_and_off,
|
113
|
+
"home_assistant": self.home_assistant_schema,
|
114
|
+
Optional("filter"): self.filter_schema,
|
115
|
+
}
|
116
|
+
],
|
117
|
+
Optional("gpio_inputs"): [
|
118
|
+
{
|
119
|
+
"name": Regex(r"^[a-z0-9_]+$"),
|
120
|
+
Optional("description"): str,
|
121
|
+
"gpio": int,
|
122
|
+
"active_low": bool,
|
123
|
+
# mqtt is NOT optional for GPIO inputs... we need to have a meaningful payload to send
|
124
|
+
"mqtt": self.mqtt_schema_for_edge_triggered_sensor,
|
125
|
+
# home_assistant is not allowed for GPIO inputs, since they do not create binary_sensors
|
126
|
+
}
|
127
|
+
],
|
128
|
+
Optional("outputs"): [
|
129
|
+
{
|
130
|
+
"name": Regex(r"^[a-z0-9_]+$"),
|
131
|
+
Optional("description"): str,
|
132
|
+
"gpio": int,
|
133
|
+
"active_low": bool,
|
134
|
+
Optional("mqtt"): self.mqtt_schema_for_sensor_on_and_off,
|
135
|
+
"home_assistant": self.home_assistant_schema,
|
136
|
+
}
|
137
|
+
],
|
138
|
+
}
|
139
|
+
)
|
140
|
+
|
141
|
+
@staticmethod
|
142
|
+
def get_embedded_version() -> str:
|
143
|
+
"""
|
144
|
+
Returns the embedded version of this utility, forged at build time
|
145
|
+
by the "hatch-vcs" plugin.
|
146
|
+
|
147
|
+
In particular the "hatch-vcs" plugin writes a _raspy2mqtt_version.py file
|
148
|
+
that contains a 'version' variable with the version string.
|
149
|
+
"""
|
150
|
+
|
151
|
+
from _raspy2mqtt_version import version as __version__
|
152
|
+
|
153
|
+
return __version__
|
154
|
+
|
155
|
+
def check_gpio(self, idx: int):
|
156
|
+
reserved_gpios = [
|
157
|
+
SeqMicroHatConstants.SHUTDOWN_BUTTON_GPIO,
|
158
|
+
SeqMicroHatConstants.INTERRUPT_GPIO,
|
159
|
+
SeqMicroHatConstants.I2C_SDA,
|
160
|
+
SeqMicroHatConstants.I2C_SCL,
|
161
|
+
]
|
162
|
+
if idx < 1 or idx > 40:
|
163
|
+
raise ValueError(
|
164
|
+
f"Invalid GPIO index {idx}. The legal range is [1-40] since the Raspberry GPIO connector is a 40-pin connector."
|
165
|
+
)
|
166
|
+
# some GPIO pins are reserved and cannot be configured!
|
167
|
+
if idx in reserved_gpios:
|
168
|
+
raise ValueError(
|
169
|
+
f"Invalid GPIO index {idx}: that GPIO pin is reserved for communication with the Sequent Microsystem HAT. Choose a different GPIO."
|
170
|
+
)
|
171
|
+
|
172
|
+
def populate_defaults_in_list_entry(
|
173
|
+
self,
|
174
|
+
entry_dict: dict,
|
175
|
+
populate_mqtt: bool = True,
|
176
|
+
populate_homeassistant: bool = True,
|
177
|
+
has_payload_on_off: bool = True,
|
178
|
+
has_state_topic: bool = True,
|
179
|
+
is_output: bool = True,
|
180
|
+
populate_filter: bool = True,
|
181
|
+
) -> dict:
|
182
|
+
if "description" not in entry_dict:
|
183
|
+
entry_dict["description"] = entry_dict["name"]
|
184
|
+
|
185
|
+
if populate_mqtt:
|
186
|
+
if "mqtt" not in entry_dict:
|
187
|
+
entry_dict["mqtt"] = {}
|
188
|
+
|
189
|
+
# an optional entry is the 'topic':
|
190
|
+
if "topic" not in entry_dict["mqtt"]:
|
191
|
+
entry_dict["mqtt"]["topic"] = f"{self.homeassistant_default_topic_prefix}/{entry_dict['name']}"
|
192
|
+
print(f"Topic for {entry_dict['name']} defaults to [{entry_dict['mqtt']['topic']}]")
|
193
|
+
|
194
|
+
if has_state_topic:
|
195
|
+
if "state_topic" not in entry_dict["mqtt"]:
|
196
|
+
entry_dict["mqtt"][
|
197
|
+
"state_topic"
|
198
|
+
] = f"{self.homeassistant_default_topic_prefix}/{entry_dict['name']}/state"
|
199
|
+
print(f"State topic for {entry_dict['name']} defaults to [{entry_dict['mqtt']['state_topic']}]")
|
200
|
+
|
201
|
+
if has_payload_on_off:
|
202
|
+
if "payload_on" not in entry_dict["mqtt"]:
|
203
|
+
entry_dict["mqtt"]["payload_on"] = MqttDefaults.PAYLOAD_ON
|
204
|
+
if "payload_off" not in entry_dict["mqtt"]:
|
205
|
+
entry_dict["mqtt"]["payload_off"] = MqttDefaults.PAYLOAD_OFF
|
206
|
+
|
207
|
+
if populate_homeassistant:
|
208
|
+
# the following assertion is justified because 'schema' library should garantuee
|
209
|
+
# that we get here only if all entries in the config file do have the 'home_assistant' section
|
210
|
+
assert "home_assistant" in entry_dict
|
211
|
+
|
212
|
+
if "expire_after" not in entry_dict["home_assistant"]:
|
213
|
+
entry_dict["home_assistant"]["expire_after"] = HomeAssistantDefaults.EXPIRE_AFTER_SEC
|
214
|
+
print(f"Expire-after for {entry_dict['name']} defaults to [{HomeAssistantDefaults.EXPIRE_AFTER_SEC}]")
|
215
|
+
if "icon" not in entry_dict["home_assistant"]:
|
216
|
+
entry_dict["home_assistant"]["icon"] = None
|
217
|
+
if "platform" not in entry_dict["home_assistant"]:
|
218
|
+
entry_dict["home_assistant"]["platform"] = "switch" if is_output else "binary_sensor"
|
219
|
+
|
220
|
+
if populate_filter and not is_output:
|
221
|
+
# filtering the output does not make sense, so the filter parameter is allowed only for inputs
|
222
|
+
if "filter" not in entry_dict:
|
223
|
+
entry_dict["filter"] = {}
|
224
|
+
if "stability_threshold_sec" not in entry_dict["filter"]:
|
225
|
+
entry_dict["filter"]["stability_threshold_sec"] = 0 # 0 means filtering is disabled
|
226
|
+
return entry_dict
|
227
|
+
|
228
|
+
def load(self, cfg_yaml: str) -> bool:
|
229
|
+
print(f"Loading configuration file {cfg_yaml}")
|
230
|
+
try:
|
231
|
+
with open(cfg_yaml, "r") as file:
|
232
|
+
self.config = yaml.safe_load(file)
|
233
|
+
except FileNotFoundError:
|
234
|
+
print(f"Error: configuration file '{cfg_yaml}' not found.")
|
235
|
+
return False
|
236
|
+
except yaml.YAMLError as e:
|
237
|
+
print(f"Error parsing YAML config file '{cfg_yaml}': {e}")
|
238
|
+
return False
|
239
|
+
|
240
|
+
# validate the config against its schema:
|
241
|
+
try:
|
242
|
+
self.config_file_schema.validate(self.config)
|
243
|
+
except SchemaError as e:
|
244
|
+
print("Failed YAML config file validation. Error follows.")
|
245
|
+
print(e)
|
246
|
+
return False
|
247
|
+
|
248
|
+
try:
|
249
|
+
# convert the 'i2c_optoisolated_inputs' part in a dictionary indexed by the DIGITAL INPUT CHANNEL NUMBER:
|
250
|
+
self.optoisolated_inputs_map = {}
|
251
|
+
|
252
|
+
if "i2c_optoisolated_inputs" not in self.config:
|
253
|
+
# empty list: feature disabled
|
254
|
+
self.config["i2c_optoisolated_inputs"] = []
|
255
|
+
|
256
|
+
for input_item in self.config["i2c_optoisolated_inputs"]:
|
257
|
+
input_item = self.populate_defaults_in_list_entry(
|
258
|
+
input_item, has_state_topic=False, is_output=False, populate_filter=True
|
259
|
+
)
|
260
|
+
|
261
|
+
# check GPIO index
|
262
|
+
idx = int(input_item["input_num"])
|
263
|
+
if idx < 1 or idx > 16:
|
264
|
+
raise ValueError(
|
265
|
+
f"Invalid input_num {idx} for entry [{input_item['name']}]: the legal range is [1-16] since the Sequent Microsystem HAT only handles 16 inputs."
|
266
|
+
)
|
267
|
+
if idx in self.optoisolated_inputs_map:
|
268
|
+
raise ValueError(
|
269
|
+
f"Invalid input_num {idx} for entry [{input_item['name']}]: such index for the Sequent Microsystem HAT input has already been used. Check again the configuration."
|
270
|
+
)
|
271
|
+
|
272
|
+
# check HomeAssistant section
|
273
|
+
if input_item["home_assistant"]["platform"] != "binary_sensor":
|
274
|
+
raise ValueError(
|
275
|
+
f"Invalid Home Assistant platform value [{input_item['home_assistant']['platform']}] for entry [{input_item['name']}]: only the 'binary_sensor' platform is supported for now."
|
276
|
+
)
|
277
|
+
if (
|
278
|
+
input_item["home_assistant"]["device_class"]
|
279
|
+
not in HomeAssistantDefaults.ALLOWED_DEVICE_CLASSES["binary_sensor"]
|
280
|
+
):
|
281
|
+
raise ValueError(
|
282
|
+
f"Invalid Home Assistant device_class value [{input_item['home_assistant']['device_class']}] for entry [{input_item['name']}]: the allowed values are: {HomeAssistantDefaults.ALLOWED_DEVICE_CLASSES['binary_sensor']}."
|
283
|
+
)
|
284
|
+
|
285
|
+
# store as valid entry
|
286
|
+
self.optoisolated_inputs_map[idx] = input_item
|
287
|
+
# print(input_item)
|
288
|
+
print(f"Loaded {len(self.optoisolated_inputs_map)} opto-isolated input configurations")
|
289
|
+
if len(self.optoisolated_inputs_map) == 0:
|
290
|
+
# reset to "not loaded at all" condition
|
291
|
+
self.optoisolated_inputs_map = None
|
292
|
+
except ValueError as e:
|
293
|
+
print(f"Error in YAML config file '{cfg_yaml}': {e}")
|
294
|
+
return False
|
295
|
+
except KeyError as e:
|
296
|
+
print(f"Error in YAML config file '{cfg_yaml}': {e} is missing")
|
297
|
+
return False
|
298
|
+
|
299
|
+
try:
|
300
|
+
# convert the 'gpio_inputs' part in a dictionary indexed by the GPIO PIN NUMBER:
|
301
|
+
self.gpio_inputs_map = {}
|
302
|
+
|
303
|
+
if "gpio_inputs" not in self.config:
|
304
|
+
# empty list: feature disabled
|
305
|
+
self.config["gpio_inputs"] = []
|
306
|
+
|
307
|
+
for input_item in self.config["gpio_inputs"]:
|
308
|
+
input_item = self.populate_defaults_in_list_entry(
|
309
|
+
input_item,
|
310
|
+
populate_homeassistant=False,
|
311
|
+
has_payload_on_off=False,
|
312
|
+
has_state_topic=False,
|
313
|
+
is_output=False,
|
314
|
+
populate_filter=False, # GPIO inputs do not support filtering at this time
|
315
|
+
)
|
316
|
+
|
317
|
+
# check GPIO index
|
318
|
+
idx = int(input_item["gpio"])
|
319
|
+
self.check_gpio(idx)
|
320
|
+
if idx in self.gpio_inputs_map:
|
321
|
+
raise ValueError(
|
322
|
+
f"Invalid gpio index {idx} for entry [{input_item['name']}]: such GPIO input has already been used. Check again the configuration."
|
323
|
+
)
|
324
|
+
|
325
|
+
# store as valid entry
|
326
|
+
self.gpio_inputs_map[idx] = input_item
|
327
|
+
# print(input_item)
|
328
|
+
print(f"Loaded {len(self.gpio_inputs_map)} GPIO input configurations")
|
329
|
+
if len(self.gpio_inputs_map) == 0:
|
330
|
+
# reset to "not loaded at all" condition
|
331
|
+
self.gpio_inputs_map = None
|
332
|
+
except ValueError as e:
|
333
|
+
print(f"Error in YAML config file '{cfg_yaml}': {e}")
|
334
|
+
return False
|
335
|
+
except KeyError as e:
|
336
|
+
print(f"Error in YAML config file '{cfg_yaml}': {e} is missing")
|
337
|
+
return False
|
338
|
+
|
339
|
+
try:
|
340
|
+
# convert the 'outputs' part in a dictionary indexed by the MQTT TOPIC:
|
341
|
+
self.outputs_map = {}
|
342
|
+
|
343
|
+
if "outputs" not in self.config:
|
344
|
+
# empty list: feature disabled
|
345
|
+
self.config["outputs"] = []
|
346
|
+
|
347
|
+
for output_item in self.config["outputs"]:
|
348
|
+
|
349
|
+
output_item = self.populate_defaults_in_list_entry(
|
350
|
+
output_item,
|
351
|
+
is_output=True,
|
352
|
+
populate_filter=False, # filtering does not make sense for outputs
|
353
|
+
)
|
354
|
+
|
355
|
+
# check GPIO index
|
356
|
+
idx = int(output_item["gpio"])
|
357
|
+
self.check_gpio(idx)
|
358
|
+
|
359
|
+
mqtt_topic = output_item["mqtt"]["topic"]
|
360
|
+
if mqtt_topic in self.outputs_map:
|
361
|
+
raise ValueError(
|
362
|
+
f"Invalid MQTT topic [{mqtt_topic}] for entry [{output_item['name']}]: such MQTT topic has already been used. Check again the configuration."
|
363
|
+
)
|
364
|
+
|
365
|
+
# check HomeAssistant section
|
366
|
+
if output_item["home_assistant"]["platform"] not in ["switch", "button"]:
|
367
|
+
raise ValueError(
|
368
|
+
f"Invalid Home Assistant platform value [{output_item['home_assistant']['platform']}] for entry [{output_item['name']}]: only the 'switch' or 'button' platforms are supported for now."
|
369
|
+
)
|
370
|
+
|
371
|
+
allowed_dev_classes = HomeAssistantDefaults.ALLOWED_DEVICE_CLASSES[
|
372
|
+
output_item["home_assistant"]["platform"]
|
373
|
+
]
|
374
|
+
if output_item["home_assistant"]["device_class"] not in allowed_dev_classes:
|
375
|
+
raise ValueError(
|
376
|
+
f"Invalid Home Assistant device_class value [{output_item['home_assistant']['device_class']}] for entry [{output_item['name']}]: the allowed values are: {allowed_dev_classes}."
|
377
|
+
)
|
378
|
+
|
379
|
+
# store as valid entry
|
380
|
+
self.outputs_map[mqtt_topic] = output_item
|
381
|
+
# print(output_item)
|
382
|
+
print(f"Loaded {len(self.outputs_map)} digital output configurations")
|
383
|
+
if len(self.outputs_map) == 0:
|
384
|
+
# reset to "not loaded at all" condition
|
385
|
+
self.outputs_map = None
|
386
|
+
except ValueError as e:
|
387
|
+
print(f"Error in YAML config file '{cfg_yaml}': {e}")
|
388
|
+
return False
|
389
|
+
except KeyError as e:
|
390
|
+
print(f"Error in YAML config file '{cfg_yaml}': {e} is missing")
|
391
|
+
return False
|
392
|
+
|
393
|
+
# validate that there is no duplicated 'name' across all configuration entries
|
394
|
+
name_set = set()
|
395
|
+
merged_entries_list = []
|
396
|
+
if self.optoisolated_inputs_map is not None:
|
397
|
+
merged_entries_list = merged_entries_list + list(self.optoisolated_inputs_map.values())
|
398
|
+
if self.gpio_inputs_map is not None:
|
399
|
+
merged_entries_list = merged_entries_list + list(self.gpio_inputs_map.values())
|
400
|
+
if self.outputs_map is not None:
|
401
|
+
merged_entries_list = merged_entries_list + list(self.outputs_map.values())
|
402
|
+
for entry in merged_entries_list:
|
403
|
+
if entry["name"] in name_set:
|
404
|
+
print(
|
405
|
+
f"Error in YAML config file '{cfg_yaml}': the name {entry['name']} is not unique across the configuration."
|
406
|
+
)
|
407
|
+
else:
|
408
|
+
name_set.add(entry["name"])
|
409
|
+
|
410
|
+
print("Successfully loaded configuration")
|
411
|
+
return True
|
412
|
+
|
413
|
+
def merge_options_from_cli(self, args: dict):
|
414
|
+
# merge CLI options into the configuration object:
|
415
|
+
self.disable_hw = args.disable_hw
|
416
|
+
self.verbose = args.verbose
|
417
|
+
|
418
|
+
def merge_options_from_env_vars(self):
|
419
|
+
# merge env vars into the configuration object:
|
420
|
+
if os.environ.get("DISABLE_HW", None) is not None:
|
421
|
+
self.disable_hw = True
|
422
|
+
if os.environ.get("VERBOSE", None) is not None:
|
423
|
+
self.verbose = True
|
424
|
+
if os.environ.get("MQTT_BROKER_HOST", None) is not None:
|
425
|
+
# this particular env var can override the value coming from the config file:
|
426
|
+
self.mqtt_broker_host = os.environ.get("MQTT_BROKER_HOST")
|
427
|
+
if os.environ.get("MQTT_BROKER_PORT", None) is not None:
|
428
|
+
# this particular env var can override the value coming from the config file:
|
429
|
+
self.mqtt_broker_port = os.environ.get("MQTT_BROKER_PORT")
|
430
|
+
|
431
|
+
def print_config_summary(self):
|
432
|
+
print("Config summary:")
|
433
|
+
print("** MQTT")
|
434
|
+
print(f" MQTT broker host:port: {self.mqtt_broker_host}:{self.mqtt_broker_port}")
|
435
|
+
if self.mqtt_broker_user is not None:
|
436
|
+
print(" MQTT broker authentication: ON")
|
437
|
+
else:
|
438
|
+
print(" MQTT broker authentication: OFF")
|
439
|
+
print(f" MQTT reconnection period: {self.mqtt_reconnection_period_sec}s")
|
440
|
+
print("** HomeAssistant")
|
441
|
+
print(f" MQTT publish period: {self.homeassistant_publish_period_sec}s")
|
442
|
+
print(f" Discovery messages: {self.homeassistant_discovery_messages_enable}")
|
443
|
+
print(f" Node ID: {self.homeassistant_discovery_topic_node_id}")
|
444
|
+
print("** I2C isolated inputs:")
|
445
|
+
if self.optoisolated_inputs_map is not None:
|
446
|
+
for k, v in self.optoisolated_inputs_map.items():
|
447
|
+
print(f" input#{k}: {v['name']}")
|
448
|
+
print("** GPIO inputs:")
|
449
|
+
if self.gpio_inputs_map is not None:
|
450
|
+
for k, v in self.gpio_inputs_map.items():
|
451
|
+
print(f" input#{k}: {v['name']}")
|
452
|
+
print("** OUTPUTs:")
|
453
|
+
i = 1
|
454
|
+
if self.outputs_map is not None:
|
455
|
+
for k, v in self.outputs_map.items():
|
456
|
+
print(f" output#{i}: {v['name']}")
|
457
|
+
i += 1
|
458
|
+
print("** MISC:")
|
459
|
+
print(f" Log stats every: {self.stats_log_period_sec}s")
|
460
|
+
|
461
|
+
# MQTT
|
462
|
+
|
463
|
+
@property
|
464
|
+
def mqtt_broker_host(self) -> str:
|
465
|
+
if self.config is None:
|
466
|
+
return "" # no meaningful default value
|
467
|
+
return self.config["mqtt_broker"]["host"]
|
468
|
+
|
469
|
+
@mqtt_broker_host.setter
|
470
|
+
def mqtt_broker_host(self, value):
|
471
|
+
self.config["mqtt_broker"]["host"] = value
|
472
|
+
|
473
|
+
@property
|
474
|
+
def mqtt_broker_user(self) -> str:
|
475
|
+
if self.config is None:
|
476
|
+
return None # default is unauthenticated
|
477
|
+
if "user" not in self.config["mqtt_broker"]:
|
478
|
+
return None # default is unauthenticated
|
479
|
+
return self.config["mqtt_broker"]["user"]
|
480
|
+
|
481
|
+
@property
|
482
|
+
def mqtt_broker_password(self) -> str:
|
483
|
+
if self.config is None:
|
484
|
+
return None # default is unauthenticated
|
485
|
+
if "password" not in self.config["mqtt_broker"]:
|
486
|
+
return None # default is unauthenticated
|
487
|
+
return self.config["mqtt_broker"]["password"]
|
488
|
+
|
489
|
+
@property
|
490
|
+
def mqtt_broker_port(self) -> int:
|
491
|
+
if self.config is None:
|
492
|
+
return MqttDefaults.BROKER_PORT # the default MQTT broker port
|
493
|
+
if "port" not in self.config["mqtt_broker"]:
|
494
|
+
return MqttDefaults.BROKER_PORT # the default MQTT broker port
|
495
|
+
return self.config["mqtt_broker"]["port"]
|
496
|
+
|
497
|
+
@mqtt_broker_port.setter
|
498
|
+
def mqtt_broker_port(self, value):
|
499
|
+
self.config["mqtt_broker"]["port"] = int(value)
|
500
|
+
|
501
|
+
@property
|
502
|
+
def mqtt_reconnection_period_sec(self) -> float:
|
503
|
+
if self.config is None:
|
504
|
+
return MqttDefaults.RECONNECTION_PERIOD_SEC # the default reconnection interval
|
505
|
+
|
506
|
+
try:
|
507
|
+
# convert the user-defined timeout from msec to (floating) sec
|
508
|
+
cfg_value = float(self.config["mqtt_broker"]["reconnection_period_msec"]) / 1000.0
|
509
|
+
return cfg_value
|
510
|
+
except (KeyError, ValueError):
|
511
|
+
# in this case the key is completely missing or does contain an integer value
|
512
|
+
return MqttDefaults.RECONNECTION_PERIOD_SEC # default value
|
513
|
+
|
514
|
+
#
|
515
|
+
# HOME-ASSISTANT
|
516
|
+
#
|
517
|
+
|
518
|
+
@property
|
519
|
+
def homeassistant_publish_period_sec(self) -> float:
|
520
|
+
if self.config is None:
|
521
|
+
return HomeAssistantDefaults.PUBLISH_PERIOD_SEC # default value
|
522
|
+
try:
|
523
|
+
cfg_value = float(self.config["home_assistant"]["publish_period_msec"]) / 1000.0
|
524
|
+
return cfg_value
|
525
|
+
except (KeyError, ValueError):
|
526
|
+
# in this case the key is completely missing or does contain an integer value
|
527
|
+
return HomeAssistantDefaults.PUBLISH_PERIOD_SEC # default value
|
528
|
+
|
529
|
+
@property
|
530
|
+
def homeassistant_default_topic_prefix(self) -> str:
|
531
|
+
if self.config is None:
|
532
|
+
return HomeAssistantDefaults.TOPIC_PREFIX # default value
|
533
|
+
try:
|
534
|
+
return self.config["home_assistant"]["default_topic_prefix"]
|
535
|
+
except KeyError:
|
536
|
+
# in this case the key is completely missing or does contain an integer value
|
537
|
+
return HomeAssistantDefaults.TOPIC_PREFIX # default value
|
538
|
+
|
539
|
+
@property
|
540
|
+
def homeassistant_discovery_messages_enable(self) -> bool:
|
541
|
+
if self.config is None:
|
542
|
+
return True # default value
|
543
|
+
try:
|
544
|
+
return self.config["home_assistant"]["discovery_messages"]["enable"]
|
545
|
+
except KeyError:
|
546
|
+
# in this case the key is completely missing or does contain an integer value
|
547
|
+
return True # default value
|
548
|
+
|
549
|
+
@property
|
550
|
+
def homeassistant_discovery_topic_prefix(self) -> str:
|
551
|
+
if self.config is None:
|
552
|
+
return HomeAssistantDefaults.DISCOVERY_TOPIC_PREFIX # default value
|
553
|
+
try:
|
554
|
+
return self.config["home_assistant"]["discovery_messages"]["topic_prefix"]
|
555
|
+
except KeyError:
|
556
|
+
# in this case the key is completely missing or does contain an integer value
|
557
|
+
return HomeAssistantDefaults.DISCOVERY_TOPIC_PREFIX # default value
|
558
|
+
|
559
|
+
@property
|
560
|
+
def homeassistant_discovery_topic_node_id(self) -> str:
|
561
|
+
if self.config is None:
|
562
|
+
return self.current_hostname # default value
|
563
|
+
try:
|
564
|
+
return self.config["home_assistant"]["discovery_messages"]["node_id"]
|
565
|
+
except KeyError:
|
566
|
+
# in this case the key is completely missing or does contain an integer value
|
567
|
+
return self.current_hostname # default value
|
568
|
+
|
569
|
+
#
|
570
|
+
# MISC
|
571
|
+
#
|
572
|
+
|
573
|
+
@property
|
574
|
+
def stats_log_period_sec(self) -> int:
|
575
|
+
if self.config is None or "log_stats_every" not in self.config:
|
576
|
+
return MiscAppDefaults.STATS_LOG_PERIOD_SEC # default value
|
577
|
+
return int(self.config["log_stats_every"])
|
578
|
+
|
579
|
+
#
|
580
|
+
# OPTO-ISOLATED INPUTS
|
581
|
+
#
|
582
|
+
|
583
|
+
def get_optoisolated_input_config(self, index: int) -> dict[str, any]:
|
584
|
+
"""
|
585
|
+
Returns a dictionary containing all the possible keys that are
|
586
|
+
valid for an opto-isolated input config (see the SCHEMA in the load() API),
|
587
|
+
including optional keys that were not given in the YAML.
|
588
|
+
"""
|
589
|
+
if self.optoisolated_inputs_map is None or index not in self.optoisolated_inputs_map:
|
590
|
+
return None # no meaningful default value
|
591
|
+
return self.optoisolated_inputs_map[index]
|
592
|
+
|
593
|
+
def get_all_optoisolated_inputs(self) -> list:
|
594
|
+
"""
|
595
|
+
Returns a list of dictionaries with configurations
|
596
|
+
"""
|
597
|
+
if "i2c_optoisolated_inputs" not in self.config:
|
598
|
+
return None # no meaningful default value
|
599
|
+
return self.config["i2c_optoisolated_inputs"]
|
600
|
+
|
601
|
+
#
|
602
|
+
# GPIO INPUTS
|
603
|
+
#
|
604
|
+
|
605
|
+
def get_gpio_input_config(self, index: int) -> dict[str, any]:
|
606
|
+
"""
|
607
|
+
Returns a dictionary containing all the possible keys that are
|
608
|
+
valid for a GPIO input config (see the SCHEMA in the load() API),
|
609
|
+
including optional keys that were not given in the YAML.
|
610
|
+
"""
|
611
|
+
if self.gpio_inputs_map is None or index not in self.gpio_inputs_map:
|
612
|
+
return None # no meaningful default value
|
613
|
+
return self.gpio_inputs_map[index]
|
614
|
+
|
615
|
+
def get_all_gpio_inputs(self) -> list:
|
616
|
+
"""
|
617
|
+
Returns a list of dictionaries with configurations
|
618
|
+
"""
|
619
|
+
if "gpio_inputs" not in self.config:
|
620
|
+
return None # no meaningful default value
|
621
|
+
return self.config["gpio_inputs"]
|
622
|
+
|
623
|
+
#
|
624
|
+
# OUTPUTS CONFIG
|
625
|
+
#
|
626
|
+
|
627
|
+
def get_output_config_by_mqtt_topic(self, topic: str) -> dict[str, any]:
|
628
|
+
"""
|
629
|
+
Returns a dictionary containing all the possible keys that are
|
630
|
+
valid for a GPIO output config (see the SCHEMA in the load() API),
|
631
|
+
including optional keys that were not given in the YAML.
|
632
|
+
"""
|
633
|
+
if self.outputs_map is None or topic not in self.outputs_map:
|
634
|
+
return None # no meaningful default value
|
635
|
+
return self.outputs_map[topic]
|
636
|
+
|
637
|
+
def get_all_outputs(self) -> list:
|
638
|
+
"""
|
639
|
+
Returns a list of dictionaries with configurations
|
640
|
+
"""
|
641
|
+
if "outputs" not in self.config:
|
642
|
+
return None # no meaningful default value
|
643
|
+
return self.config["outputs"]
|
644
|
+
|
645
|
+
#
|
646
|
+
# MQTT HELPERs
|
647
|
+
#
|
648
|
+
def create_aiomqtt_client(self, identifier_str: str) -> aiomqtt.Client:
|
649
|
+
"""
|
650
|
+
Creates an aiomqtt client based on the configuration information provided to this app.
|
651
|
+
The 'identifier_str' can be used to uniquely name the client connection.
|
652
|
+
Such unique name appears in MQTT broker logs and is useful for debug.
|
653
|
+
"""
|
654
|
+
return aiomqtt.Client(
|
655
|
+
hostname=self.mqtt_broker_host,
|
656
|
+
port=self.mqtt_broker_port,
|
657
|
+
timeout=self.mqtt_reconnection_period_sec,
|
658
|
+
username=self.mqtt_broker_user,
|
659
|
+
password=self.mqtt_broker_password,
|
660
|
+
identifier=self.mqtt_identifier_prefix + identifier_str,
|
661
|
+
)
|
662
|
+
|
663
|
+
def get_device_dict(self) -> dict:
|
664
|
+
return {
|
665
|
+
"manufacturer": HomeAssistantDefaults.MANUFACTURER,
|
666
|
+
"model": MiscAppDefaults.THIS_APP_NAME,
|
667
|
+
# rationale for having "device name == MQTT node_id":
|
668
|
+
# a) in the unlikely event that you have more than 1 raspberry running this software
|
669
|
+
# you likely have different hostnames on them and node_id defaults to the hostname
|
670
|
+
# b) node_id is configurable via config file
|
671
|
+
"name": self.homeassistant_discovery_topic_node_id,
|
672
|
+
"sw_version": self.app_version,
|
673
|
+
"identifiers": [f"{MiscAppDefaults.THIS_APP_NAME}-{self.homeassistant_discovery_topic_node_id}"],
|
674
|
+
}
|