juham-core 0.0.0__tar.gz

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,25 @@
1
+ LICENSE
2
+ =======
3
+
4
+ Copyright (c) 2024, Juha Meskanen
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining
7
+ a copy of this software and associated documentation files (the
8
+ "Software"), to deal in the Software without restriction, including
9
+ without limitation the rights to use, copy, modify, merge, publish,
10
+ distribute, sublicense, and/or sell copies of the Software, and to
11
+ permit persons to whom the Software is furnished to do so, subject to
12
+ the following conditions:
13
+
14
+ **The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.**
16
+
17
+
18
+ ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
21
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
22
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
23
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
24
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. **
25
+
@@ -0,0 +1,12 @@
1
+ include docs/source/README.rst
2
+ include docs/source/CHANGELOG.rst
3
+ include docs/source/LICENSE.rst
4
+ include docs/source/CONTRIBUTING.rst
5
+
6
+ recursive-include examples *
7
+ recursive-include tests *
8
+ recursive-include docs/build/html *
9
+
10
+ # files to be excluded
11
+ global-exclude *~ \#*
12
+ config/*
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.2
2
+ Name: juham-core
3
+ Version: 0.0.0
4
+ Summary: Juha's Ultimate Home Automation Masterpiece
5
+ Author-email: J Meskanen <juham.api@gmail.com>
6
+ Maintainer-email: "J. Meskanen" <juham.api@gmail.com>
7
+ License: LICENSE
8
+ =======
9
+
10
+ Copyright (c) 2024, Juha Meskanen
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining
13
+ a copy of this software and associated documentation files (the
14
+ "Software"), to deal in the Software without restriction, including
15
+ without limitation the rights to use, copy, modify, merge, publish,
16
+ distribute, sublicense, and/or sell copies of the Software, and to
17
+ permit persons to whom the Software is furnished to do so, subject to
18
+ the following conditions:
19
+
20
+ **The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.**
22
+
23
+
24
+ ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
25
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
26
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
27
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
28
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
29
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
30
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. **
31
+
32
+
33
+ Project-URL: Homepage, https://meskanen.com
34
+ Project-URL: Bug Reports, https://meskanen.com
35
+ Project-URL: Funding, https://meskanen.com
36
+ Project-URL: Say Thanks!, http://meskanen.com
37
+ Project-URL: Source, https://meskanen.com
38
+ Keywords: home,automation,juham
39
+ Classifier: Development Status :: 2 - Pre-Alpha
40
+ Classifier: Intended Audience :: Developers
41
+ Classifier: Topic :: Software Development
42
+ Classifier: License :: Public Domain
43
+ Classifier: Programming Language :: Python :: 3.8
44
+ Requires-Python: >=3.8
45
+ Description-Content-Type: text/markdown
46
+ License-File: LICENSE.rst
47
+ Requires-Dist: masterpiece>=0.1.15
48
+ Requires-Dist: masterpiece_influx>=0.0.2
49
+ Requires-Dist: masterpiece_pahomqtt>=0.0.3
50
+ Requires-Dist: requests>=2.31
51
+ Requires-Dist: pytz>=2024.1
52
+ Requires-Dist: importlib-metadata
53
+ Provides-Extra: dev
54
+ Requires-Dist: check-manifest; extra == "dev"
55
+ Requires-Dist: types-pyz; extra == "dev"
56
+
57
+ Welcome to Juham™ - Juha's Ultimate Home Automation Masterpiece
58
+ ===============================================================
59
+
60
+ Description
61
+ -----------
62
+
63
+ The ``juham-core`` package introduces the ``Juham`` class, the core functionality for Juha's
64
+ ultimate home automation solution.
65
+
66
+ ``Juham`` extend the functionality of ``Masterpiece`` object by adding capabilities for MQTT
67
+ communication and integration with time series databases. The base class provides only a minimal
68
+ set of interfaces. Actual features, such as MQTT and time series database implementations — along with home
69
+ automation-specific functionalities — are provided through separate plugin modules built on ``Juham`` class.
70
+
71
+
72
+ Project Status
73
+ --------------
74
+
75
+ **Current State**: **Pre-Alpha (Status 2)**
76
+
77
+ In its current form, Juham™ may still resemble more of a home automation experiment (or even a "mess") than
78
+ a "masterpiece," but I'm working hard to reach that goal!
79
+
80
+
81
+ Goals
82
+ -----
83
+
84
+ The aim of Juham™ is to have fun by learning Python and GitLab ecosystems, by developing a home automation
85
+ framework capable of controlling all the devices in my home, with the potential to be adapted for other homes as well.
86
+
87
+
88
+
89
+ Special Thanks
90
+ --------------
91
+
92
+ This project would not have been possible without the generous support of two exceptional
93
+ individuals: my friend, **Teppo K.**, and my son, **Mahi**.
94
+
95
+ - Teppo provided the initial spark for this project by donating a Raspberry Pi, a temperature sensor, and an inspiring demonstration of his own home automation system.
96
+ - My son Mahi has been instrumental in translating my ideas into Python code, offering invaluable support and encouragement throughout the development process.
97
+
98
+ I am deeply grateful to both of you — thank you!
@@ -0,0 +1,42 @@
1
+ Welcome to Juham™ - Juha's Ultimate Home Automation Masterpiece
2
+ ===============================================================
3
+
4
+ Description
5
+ -----------
6
+
7
+ The ``juham-core`` package introduces the ``Juham`` class, the core functionality for Juha's
8
+ ultimate home automation solution.
9
+
10
+ ``Juham`` extend the functionality of ``Masterpiece`` object by adding capabilities for MQTT
11
+ communication and integration with time series databases. The base class provides only a minimal
12
+ set of interfaces. Actual features, such as MQTT and time series database implementations — along with home
13
+ automation-specific functionalities — are provided through separate plugin modules built on ``Juham`` class.
14
+
15
+
16
+ Project Status
17
+ --------------
18
+
19
+ **Current State**: **Pre-Alpha (Status 2)**
20
+
21
+ In its current form, Juham™ may still resemble more of a home automation experiment (or even a "mess") than
22
+ a "masterpiece," but I'm working hard to reach that goal!
23
+
24
+
25
+ Goals
26
+ -----
27
+
28
+ The aim of Juham™ is to have fun by learning Python and GitLab ecosystems, by developing a home automation
29
+ framework capable of controlling all the devices in my home, with the potential to be adapted for other homes as well.
30
+
31
+
32
+
33
+ Special Thanks
34
+ --------------
35
+
36
+ This project would not have been possible without the generous support of two exceptional
37
+ individuals: my friend, **Teppo K.**, and my son, **Mahi**.
38
+
39
+ - Teppo provided the initial spark for this project by donating a Raspberry Pi, a temperature sensor, and an inspiring demonstration of his own home automation system.
40
+ - My son Mahi has been instrumental in translating my ideas into Python code, offering invaluable support and encouragement throughout the development process.
41
+
42
+ I am deeply grateful to both of you — thank you!
@@ -0,0 +1,19 @@
1
+ """
2
+ Description
3
+ ===========
4
+
5
+ Base classes for Juham - Juha's Ultimate Home Automation framework
6
+
7
+ """
8
+
9
+ from .juham import Juham
10
+ from .rcloud import RCloud, RCloudThread
11
+ from .rthread import RThread, MasterPieceThread
12
+
13
+ __all__ = [
14
+ "Juham",
15
+ "RThread",
16
+ "RCloud",
17
+ "RCloudThread",
18
+ "MasterPieceThread",
19
+ ]
@@ -0,0 +1,497 @@
1
+ import json
2
+ import traceback
3
+ from typing import Any, Dict, Optional, cast, Union
4
+ from typing_extensions import override
5
+
6
+ from masterpiece.mqtt import Mqtt, MqttMsg
7
+ from masterpiece import MasterPiece, URL
8
+ from masterpiece.timeseries import TimeSeries, Measurement
9
+ from .timeutils import timestamp
10
+
11
+
12
+ class Juham(MasterPiece):
13
+ """Base class for automation objects with MQTT networking and time series data storage.
14
+
15
+ To configure the class to use a specific MQTT and database set
16
+ the `database_class_id` and `mqtt_class_id` class attributes to desired
17
+ MQTT and database implementations. When instantiated the object will instantiate
18
+ the given MQTT and database objects with it.
19
+ """
20
+
21
+ database_class_id: str = ""
22
+ mqtt_class_id: str = ""
23
+ write_attempts: int = 3
24
+ mqtt_root_topic: str = ""
25
+ mqtt_host: str = "localhost"
26
+ mqtt_port: int = 1883
27
+
28
+ def __init__(self, name: str = "") -> None:
29
+ """Constructs new automation object with the given name, configured
30
+ time series recorder and MQTT network features.
31
+
32
+ Args:
33
+ name (str): name of the object
34
+ """
35
+ super().__init__(name)
36
+ self.database_client: Optional[Union[TimeSeries, None]] = None
37
+ self.mqtt_client: Optional[Union[Mqtt, None]] = None
38
+ self.mqtt_topic_base: str = ""
39
+ self.mqtt_topic_control: str = ""
40
+ self.mqtt_topic_log: str = ""
41
+
42
+ @override
43
+ def to_dict(self) -> Dict[str, Any]:
44
+ data: Dict[str, Any] = super().to_dict()
45
+ data["_base"] = {}
46
+ attributes = ["mqtt_host", "mqtt_port", "mqtt_root_topic", "write_attempts"]
47
+ for attr in attributes:
48
+ if getattr(self, attr) != getattr(type(self), attr):
49
+ data["_base"][attr] = getattr(self, attr)
50
+ if self.database_client is not None:
51
+ data["_database"] = {"db_client": self.database_client.to_dict()}
52
+ return data
53
+
54
+ @override
55
+ def from_dict(self, data: Dict[str, Any]) -> None:
56
+ super().from_dict(data)
57
+ for key, value in data["_base"].items():
58
+ if key == "db_client":
59
+ self.database_client = cast(
60
+ Optional[TimeSeries], MasterPiece.instantiate(value["_class"])
61
+ )
62
+ if self.database_client is not None:
63
+ self.database_client.from_dict(value)
64
+ else:
65
+ setattr(self, key, value)
66
+
67
+ def initialize(self) -> None:
68
+ """Initialize time series database and mqtt networking for use. This method must be called
69
+ after the object name has been set .
70
+ """
71
+ self.init_database(self.name)
72
+ self.init_mqtt(self.name)
73
+
74
+ def measurement(self, name: str) -> Measurement:
75
+ """Instantiates measurement object.
76
+ Args:
77
+ measurement (str): name of the object
78
+ Returns
79
+ (Measurement) measurement object
80
+ """
81
+ timeseries: TimeSeries = cast(TimeSeries, self.database_client)
82
+ return timeseries.measurement(name)
83
+
84
+ def init_database(self, name: str) -> None:
85
+ """Instantiates the configured time series database object.
86
+
87
+ Issues a warning if the :attr:`~database_class_id` has not
88
+ been configured, in which case the object will not have the time series
89
+ recording feature.
90
+
91
+ This method is called internally and typically there is no need to call it
92
+ from the application code.
93
+ """
94
+
95
+ if (
96
+ Juham.database_class_id != None
97
+ and MasterPiece.find_class(Juham.database_class_id) != None
98
+ ):
99
+ self.database_client = cast(
100
+ Optional[TimeSeries], MasterPiece.instantiate(Juham.database_class_id)
101
+ )
102
+ else:
103
+ self.warning("Suspicious configuration: no database_class_id set")
104
+
105
+ def init_topic_base(self) -> None:
106
+ url: URL = self.make_url()
107
+ self.mqtt_root_topic = self.root().make_url().get()[1:]
108
+ self.mqtt_topic_base = url.get()[1:]
109
+ self.mqtt_topic_control = self.mqtt_root_topic + "/control"
110
+ self.mqtt_topic_log = self.mqtt_root_topic + "/log"
111
+
112
+ def make_topic_name(self, topic: str) -> str:
113
+ """Make topic name for the object. The topic name
114
+ consists of the base name plus the given 'topic'.
115
+
116
+ Args:
117
+ topic (str): topic name
118
+
119
+ Returns:
120
+ str: mqtt topic name
121
+ """
122
+ return f"{self.mqtt_root_topic}/{topic}"
123
+
124
+ def init_mqtt(self, name: str) -> None:
125
+ """Instantiates the configured MQTT object for networking. Calls `init_topic()`
126
+ to construct topic base name for the object, and instantiates the mqtt
127
+ client.
128
+
129
+ This method is called internally and typically there is no need to call it
130
+ from the application code.
131
+
132
+ Issues a warning if the :attr:`mqtt_class_id` has not
133
+ been configured, even though objects without a capability to communicate
134
+ are rather crippled.
135
+ """
136
+ self.init_topic_base()
137
+ if Juham.mqtt_class_id == "":
138
+ self.warning(
139
+ f"Suscpicious configuration: no mqtt_class_id set for {self.name}:{self.get_class_id()}"
140
+ )
141
+ elif not Juham.find_class(Juham.mqtt_class_id):
142
+ self.error(
143
+ f"Couldn't create mqtt broker {Juham.mqtt_class_id}, class not imported"
144
+ )
145
+ else:
146
+ self.mqtt_client = cast(
147
+ Optional[Mqtt], MasterPiece.instantiate(Juham.mqtt_class_id, name)
148
+ )
149
+ if self.mqtt_client is not None:
150
+ self.mqtt_client.on_message = self.on_message
151
+ self.mqtt_client.on_connect = self.on_connect
152
+ self.mqtt_client.on_disconnect = self.on_disconnect
153
+ if (
154
+ self.mqtt_client.connect_to_server(self.mqtt_host, self.mqtt_port)
155
+ != 0
156
+ ):
157
+ self.error(
158
+ f"Couldn't connect to the mqtt broker at {self.mqtt_client.host}"
159
+ )
160
+ else:
161
+ self.debug(
162
+ f"{self.name} with mqtt broker {self.mqtt_client.name} connected to {self.mqtt_client.host}"
163
+ )
164
+ else:
165
+ self.error(f"Couldn't create mqtt broker {Juham.mqtt_class_id}")
166
+
167
+ def subscribe(self, topic: str) -> None:
168
+ """Subscribe to the given MQTT topic.
169
+
170
+ This method sets up the subscription to the specified MQTT topic and registers
171
+ the :meth:`on_message` method as the callback for incoming messages.
172
+
173
+ Args:
174
+ topic (str): The MQTT topic to subscribe to.
175
+
176
+ Example:
177
+ ::
178
+
179
+ # configure
180
+ obj.subscribe('foo/bar')
181
+ """
182
+
183
+ if self.mqtt_client:
184
+ self.mqtt_client.connected_flag = True
185
+ self.mqtt_client.subscribe(topic)
186
+ self.info(f"{self.name} subscribed to { topic}")
187
+
188
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
189
+ """MQTT message notification on arrived message.
190
+
191
+ Called whenever a new message is posted on one of the
192
+ topics the object has subscribed to via subscribe() method.
193
+ This method is the heart of automation: here, derived subclasses should
194
+ automate whatever they were designed to automate. For example, they could switch a
195
+ relay when a boiler temperature sensor signals that the temperature is too low for
196
+ a comforting shower for say one's lovely wife.
197
+
198
+ For more information on this method consult MQTT documentation available
199
+ in many public sources.
200
+
201
+ Args:
202
+ client (obj): MQTT client
203
+ userdata (Any): application specific data
204
+ msg (object): The MQTT message
205
+ """
206
+
207
+ if msg.topic == self.mqtt_topic_control:
208
+ m = json.loads(msg.payload)
209
+ if m["command"] == "shutdown" and self.mqtt_client:
210
+ self.mqtt_client.disconnect()
211
+ self.mqtt_client.loop_stop()
212
+
213
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
214
+ """Notification on connect.
215
+
216
+ This method is called whenever the MQTT broker is connected.
217
+ For more information on this method consult MQTT documentation available
218
+ in many public sources.
219
+
220
+ Args:
221
+ client (obj): MQTT client
222
+ userdata (Any): application specific data
223
+ flags (int): Consult MQTT
224
+ rc (int): See MQTT docs
225
+ """
226
+ if self.mqtt_client:
227
+ self.mqtt_client.subscribe(self.mqtt_topic_control)
228
+ self.debug(self.name + " connected to the mqtt broker ")
229
+
230
+ def on_disconnect(self, client: object, userdata: Any, rc: int = 0) -> None:
231
+ """Notification on disconnect.
232
+
233
+ This method is called whenever the MQTT broker is disconnected.
234
+ For more information on this method consult MQTT documentation available
235
+ in many public sources.
236
+
237
+ Args:
238
+ client (obj): MQTT client
239
+ userdata (Any): application specific data
240
+ rc (int): See MQTT docs
241
+ """
242
+ self.info(f"{self.name} disconnected from the mqtt broker, {rc} ")
243
+
244
+ def write(self, point: Measurement) -> None:
245
+ """Writes the given measurement to the database. In case of an error,
246
+ it tries again until the maximum number of attempts is reached. If it
247
+ is still unsuccessful, it gives up and passes the first encountered
248
+ exception to the caller.
249
+
250
+ Args:
251
+ point: a measurement describing a time stamp and related attributes for one measurement.
252
+ """
253
+ if not self.database_client:
254
+ raise ValueError("Database client is not initialized.")
255
+
256
+ first_exception: Optional[BaseException] = None
257
+ for i in range(self.write_attempts):
258
+ try:
259
+ self.database_client.write(point)
260
+ return
261
+ except Exception as e:
262
+ if first_exception is None:
263
+ first_exception = e
264
+ self.warning(f"Writing ts failed, attempt {i+1}: {repr(e)}")
265
+
266
+ self.log_message(
267
+ "Error",
268
+ f"Writing failed after {self.write_attempts} attempts, giving up",
269
+ "".join(
270
+ traceback.format_exception_only(type(first_exception), first_exception)
271
+ ),
272
+ )
273
+
274
+ def write_point(
275
+ self, name: str, tags: dict[str, Any], fields: dict[str, Any], ts: str
276
+ ) -> None:
277
+ """Writes the given measurement to the database. In case of an error,
278
+ it tries again until the maximum number of attempts is reached. If it
279
+ is still unsuccessful, it gives up and passes the first encountered
280
+ exception to the caller.
281
+
282
+ Args:
283
+ point: a measurement describing a time stamp and related attributes for one measurement.
284
+ """
285
+ if not self.database_client:
286
+ raise ValueError("Database client is not initialized.")
287
+
288
+ first_exception: Optional[BaseException] = None
289
+ for i in range(self.write_attempts):
290
+ try:
291
+ self.database_client.write_dict(name, tags, fields, ts)
292
+ return
293
+ except Exception as e:
294
+ if first_exception is None:
295
+ first_exception = e
296
+ self.warning(f"Writing ts failed, attempt {i+1}: {repr(e)}")
297
+
298
+ self.log_message(
299
+ "Error",
300
+ f"Writing failed after {self.write_attempts} attempts, giving up",
301
+ "".join(
302
+ traceback.format_exception_only(type(first_exception), first_exception)
303
+ ),
304
+ )
305
+
306
+ def read_last_value(
307
+ self,
308
+ measurement: str,
309
+ tags: Optional[dict[str, Any]] = None,
310
+ fields: Optional[list[str]] = None,
311
+ ) -> dict[str, Any]:
312
+ """Writes the given measurement to the database. In case of an error,
313
+ it tries again until the maximum number of attempts is reached. If it
314
+ is still unsuccessful, it gives up and passes the first encountered
315
+ exception to the caller.
316
+
317
+ Args:
318
+ point: a measurement describing a time stamp and related attributes for one measurement.
319
+ """
320
+ if not self.database_client:
321
+ raise ValueError("Database client is not initialized.")
322
+
323
+ first_exception: Optional[BaseException] = None
324
+ for i in range(self.write_attempts):
325
+ try:
326
+ return self.database_client.read_last_value(measurement, tags, fields)
327
+ except Exception as e:
328
+ if first_exception is None:
329
+ first_exception = e
330
+ self.warning(f"Reading ts failed, attempt {i+1}: {repr(e)}")
331
+
332
+ self.log_message(
333
+ "Error",
334
+ f"Reading failed after {self.write_attempts} attempts, giving up",
335
+ "".join(
336
+ traceback.format_exception_only(type(first_exception), first_exception)
337
+ ),
338
+ )
339
+ return {}
340
+
341
+ def read(self, point: Measurement) -> None:
342
+ """Reads the given measurement from the database.
343
+
344
+ Args:
345
+ point: point with initialized time stamp.
346
+
347
+ ... note: NOT IMPLEMENTED YET
348
+ """
349
+ # if self.database_client:
350
+ # self.database_client.read(point)
351
+ pass
352
+
353
+ @override
354
+ def debug(self, msg: str, details: str = "") -> None:
355
+ """Logs the given debug message to the database after logging it using
356
+ the BaseClass's info() method.
357
+
358
+ Args:
359
+ msg (str): The information message to be logged.
360
+ details (str): Additional detailed information for the message to be logged
361
+ """
362
+ super().debug(msg, details)
363
+ self.log_message("Debug", msg, details="")
364
+
365
+ @override
366
+ def info(self, msg: str, details: str = "") -> None:
367
+ """Logs the given information message to the database after logging it
368
+ using the BaseClass's info() method.
369
+
370
+ Args:
371
+ msg : The information message to be logged.
372
+ details : Additional detailed information for the message to be logged
373
+
374
+ Example:
375
+ ::
376
+
377
+ obj = new Base('test')
378
+ obj.info('Message arrived', str(msg))
379
+ """
380
+ super().info(msg, details)
381
+ self.log_message("Info", msg, details="")
382
+
383
+ @override
384
+ def warning(self, msg: str, details: str = "") -> None:
385
+ """Logs the given warning message to the database after logging it
386
+ using the BaseClass's info() method.
387
+
388
+ Args:
389
+ msg (str): The information message to be logged.
390
+ details (str): Additional detailed information for the message to be logged
391
+ """
392
+ super().warning(msg, details)
393
+ self.log_message("Warn", msg, details)
394
+
395
+ @override
396
+ def error(self, msg: str, details: str = "") -> None:
397
+ """Logs the given error message to the database after logging it using
398
+ the BaseClass's info() method.
399
+
400
+ Args:
401
+ msg (str): The information message to be logged.
402
+ details (str): Additional detailed information for the message to be logged
403
+ """
404
+ super().error(msg, details)
405
+ self.log_message("Error", msg, details)
406
+
407
+ def log_message(self, type: str, msg: str, details: str = "") -> None:
408
+ """Publish the given log message to the MQTT 'log' topic.
409
+
410
+ This method constructs a log message with a timestamp, class type, source name,
411
+ message, and optional details. It then publishes this message to the 'log' topic
412
+ using the MQTT protocol.
413
+
414
+ Parameters:
415
+ type : str
416
+ The classification or type of the log message (e.g., 'Error', 'Info').
417
+ msg : str
418
+ The main log message to be published.
419
+ details : str, optional
420
+ Additional details about the log message (default is an empty string).
421
+
422
+ Returns:
423
+ None
424
+
425
+ Raises:
426
+ Exception
427
+ If there is an issue with the MQTT client while publishing the message.
428
+
429
+ Example:
430
+ ::
431
+
432
+ # publish info message to the Juham's 'log' topic
433
+ self.log_message("Info", f"Some cool message {some_stuff}", str(dict))
434
+ """
435
+
436
+ try:
437
+ lmsg: dict[str, Any] = {
438
+ "Timestamp": timestamp(),
439
+ "Class": type,
440
+ "Source": self.name,
441
+ "Msg": msg,
442
+ "Details": str(details),
443
+ }
444
+ self.publish(self.mqtt_topic_log, json.dumps(lmsg), 1)
445
+ except Exception as e:
446
+ if self._log is not None:
447
+ self._log.error(f"Publishing log event failed {str(e)}")
448
+
449
+ def publish(self, topic: str, msg: str, qos: int = 1, retain: bool = True) -> None:
450
+ """Publish the given message to the given MQTT topic.
451
+ For more information consult MQTT.
452
+
453
+ Args:
454
+ topic (str): topic
455
+ msg (str): message to be published
456
+ qos (int, optional): quality of service. Defaults to 1.
457
+ retain (bool, optional): retain. Defaults to True.
458
+ """
459
+ if self.mqtt_client:
460
+ self.mqtt_client.publish(topic, msg, qos, retain)
461
+
462
+ def shutdown(self) -> None:
463
+ """Shut down all services, free resources, stop threads, disconnect
464
+ from mqtt, in general, prepare for shutdown."""
465
+ if self.mqtt_client:
466
+ self.mqtt_client.disconnect()
467
+ self.mqtt_client.loop_stop()
468
+
469
+ @override
470
+ def run(self) -> None:
471
+ """Start a new thread to runs the network loop in the background.
472
+
473
+ Allows the main program to continue executing while the MQTT
474
+ client handles incoming and outgoing messages in the background.
475
+ """
476
+ self.initialize()
477
+ if self.mqtt_client:
478
+ self.mqtt_client.loop_start()
479
+ super().run()
480
+
481
+ @override
482
+ def run_forever(self) -> None:
483
+ """Starts the network loop and blocks the main thread, continuously
484
+ running the loop to process MQTT messages.
485
+
486
+ The loop will run indefinitely unless the connection is lost or
487
+ the program is terminated.
488
+ """
489
+ self.initialize()
490
+ if self.mqtt_client:
491
+ self.info(f"{self.name} has mqtt client, calling forever...")
492
+ self.mqtt_client.loop_forever()
493
+ self.info(f"{self.name} mqtt client run_forever returned")
494
+ else:
495
+ self.error(
496
+ f"{self.name} does NOT have mqtt client, cannot run_forever, giving up"
497
+ )