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/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
+ }