juham-core 0.0.0__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.
juham_core/__init__.py ADDED
@@ -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
+ ]
juham_core/juham.py ADDED
@@ -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
+ )
juham_core/rcloud.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ The `rcloud` module builds on the `thread` module by adding URL-based data acquisition capabilities.
3
+
4
+ Classes:
5
+ RCloud: An extension of the RThread class for handling URL-based data acquisition.
6
+ RCloudThread: An extension of the RThread class to support URL data fetching.
7
+
8
+ It is up to the subclasses of RCloudThread to implement the specific URL for data acquisition, such
9
+ as weather forecasts, and the `process_data()` method to handle the acquired data.
10
+ The `process_data()` method might, for instance, publish the data to an appropriate automation MQTT topic.
11
+
12
+ This module simplifies the process of integrating and processing data from web-based resources,
13
+ such as weather forecast websites, leveraging the asynchronous processing capabilities provided by the `thread` module.
14
+ """
15
+
16
+ from typing import Any, Optional
17
+ import requests
18
+ from masterpiece.mqtt import Mqtt
19
+ from .rthread import RThread, MasterPieceThread
20
+
21
+
22
+ class RCloudThread(MasterPieceThread):
23
+ """Data acuisition base class. Responsible for fetching data from clouds and other web
24
+ resources via url. It is up to the sub classes to implement get_url() and process_data()
25
+ methods.
26
+
27
+ Can be configured how often the query is being run.
28
+ """
29
+
30
+ timeout: float = 60
31
+
32
+ def __init__(self, client: Optional[Mqtt]) -> None:
33
+ """Construct automation object with the given MQTT client.
34
+
35
+ Args:
36
+ client (Mqtt, optional): Mqtt. Defaults to None.
37
+ """
38
+ super().__init__(client)
39
+
40
+ def make_weburl(self) -> str:
41
+ """Build http url for acquiring data from the web resource. Up to the
42
+ sub classes to implement.
43
+
44
+ This method is periodically called by update method.
45
+
46
+ Returns: Url to be used as parameter to requests.get().
47
+ """
48
+ return ""
49
+
50
+ def update(self) -> bool:
51
+ """Acquire and process.
52
+
53
+ This method is periodically called to acquire data from a the configured web url
54
+ and publish it to respective MQTT topic in the process_data() method.
55
+
56
+ Returns: True if the update succeeded. Returning False implies an error and
57
+ in which case the method should be called shortly again to retry. It is up
58
+ to the caller to decide the number of failed attempts before giving up.
59
+ """
60
+
61
+ headers: dict[Any, Any] = {}
62
+ params: dict[Any, Any] = {}
63
+ url = self.make_weburl()
64
+
65
+ try:
66
+
67
+ response = requests.get(
68
+ url, headers=headers, params=params, timeout=self.timeout
69
+ )
70
+
71
+ if response.status_code == 200:
72
+ self.process_data(response)
73
+ return True
74
+ else:
75
+ self.error(f"Reading {url} failed: {str(response)}")
76
+ except Exception as e:
77
+ self.error(f"Requesting data from {url} failed", str(e))
78
+ return False
79
+
80
+ def process_data(self, data: Any) -> None:
81
+ """Process the acquired data.
82
+
83
+ This method is called from the update method, to process the
84
+ data from the acquired data source. It is up to the sub classes
85
+ to implement this.
86
+ """
87
+
88
+
89
+ class RCloud(RThread):
90
+ """Base class for automation objects that query data from external sources e.g. web sites
91
+ using URL.
92
+
93
+ Spawns an asynchronous thread to acquire data at a specified time
94
+ interval.
95
+ """
juham_core/rthread.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ The `rthread` module provides foundational classes for creating multi-threaded automation objects.
3
+
4
+ Classes:
5
+ AutomationObject: A generic base class for automation objects.
6
+ IWorkerThread: A base class for threads that can be spawned by automation objects.
7
+
8
+ These classes are highly flexible and designed to handle various tasks asynchronously,
9
+ making them suitable for a wide range of applications.
10
+
11
+ Justification for subclassing from `Thread`: sharing the common memory space.
12
+
13
+ .. todo:: Decouple the functionality from the thread so that it
14
+ can be run by any means, e.g., by process or asyncio.
15
+ """
16
+
17
+ import json
18
+
19
+ import time
20
+ from typing import Any, Optional, cast
21
+ from typing_extensions import override
22
+ from masterpiece import MasterPieceThread
23
+ from masterpiece.mqtt import MqttMsg
24
+ from .juham import Juham
25
+
26
+
27
+ class RThread(Juham):
28
+ """Base class of automation classes that need to run automation tasks using asynchronously running thread.
29
+ Spawns the thread upon creation.
30
+ Subscribes to 'event' topic to listen log events from the thread, and dispatches
31
+ them to corresponding logging methods e.g. `self.info()`.
32
+
33
+ """
34
+
35
+ _systemstatus_topic = "status"
36
+
37
+ def __init__(self, name: str) -> None:
38
+ """Construct automation object. By default no thread is created nor started.
39
+
40
+ Args:
41
+ name (str): name of the automation object.
42
+ """
43
+ super().__init__(name)
44
+ self.worker: Optional[MasterPieceThread]
45
+ self.event_topic = self.make_topic_name("event")
46
+
47
+ def disconnect(self) -> None:
48
+ """Request the asynchronous acquisition thread to stop after it has finished its current job.
49
+ This method does not wait for the thread to stop. See `shutdown()`.
50
+ """
51
+ if self.worker != None:
52
+ worker: MasterPieceThread = cast(MasterPieceThread, self.worker)
53
+ worker.stay = False
54
+
55
+ @override
56
+ def shutdown(self) -> None:
57
+ if self.worker is not None:
58
+ self.worker.stop() # request to thread to exit its processing loop
59
+ self.worker.join() # wait for the thread to complete
60
+ super().shutdown()
61
+
62
+ @override
63
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
64
+ start_time = time.time()
65
+ if msg.topic == self.event_topic:
66
+ em = json.loads(msg.payload.decode())
67
+ self.on_event(em)
68
+ else:
69
+ self.error(f"Unknown message to {self.name}: {msg.topic}")
70
+ end_time: float = time.time()
71
+ elapsed_time = end_time - start_time
72
+ self.update_metrics(elapsed_time)
73
+
74
+ @override
75
+ def update_metrics(self, elapsed: float) -> None:
76
+ super().update_metrics(elapsed)
77
+ if self._elapsed > 2.0:
78
+ sysinfo: dict[str, dict] = {
79
+ "threads": {self.name: self.acquire_time_spent()}
80
+ }
81
+ self.publish(
82
+ self._systemstatus_topic, json.dumps(sysinfo), qos=0, retain=False
83
+ )
84
+
85
+ def on_event(self, em: dict[str, Any]) -> None:
86
+ """Notification event callback e.g info or warning.
87
+
88
+ Args:
89
+ em (dictionary): dictionary describing the event
90
+ """
91
+ if em["type"] == "Info":
92
+ self.info(em["msg"], em["details"])
93
+ elif em["type"] == "Debug":
94
+ self.debug(em["msg"], em["details"])
95
+ elif em["type"] == "Warning":
96
+ self.warning(em["msg"], em["details"])
97
+ elif em["type"] == "Error":
98
+ self.error(em["msg"], em["details"])
99
+ else:
100
+ self.error("PANIC: unknown event type " + em["type"], str(em))
101
+
102
+ @override
103
+ def run(self) -> None:
104
+ """Initialize and start the asynchronous acquisition thread."""
105
+ super().run()
106
+ if self.worker is not None:
107
+ self.worker.mqtt_client = self.mqtt_client
108
+ self.worker.name = "thread_" + self.name
109
+ self.worker.event_topic = self.event_topic
110
+ self.worker.start()
111
+ self.info(f"Starting up {self.name} - {self.worker.__class__} ")
112
+ else:
113
+ self.warning(f"No thread, cannot run {self.name}")
@@ -0,0 +1,171 @@
1
+ """Time management for Juham framework.
2
+ """
3
+
4
+ import datetime
5
+ import time
6
+ from typing import Optional
7
+ import pytz
8
+
9
+
10
+ def quantize(quanta: float, value: float) -> float:
11
+ """Quantize the given value.
12
+
13
+ Args:
14
+ quanta (float): resolution for quantization
15
+ value (float): value to be quantized
16
+
17
+ Returns:
18
+ (float): quantized value
19
+
20
+ Example:
21
+ ::
22
+
23
+ hour_of_a_day = quantize(3600, epoch_seconds)
24
+ """
25
+ return (value // quanta) * quanta
26
+
27
+
28
+ def epoc2utc(epoch: float) -> str:
29
+ """Converts the given epoch time to UTC time string. All time
30
+ coordinates are represented in UTC time. This allows the time
31
+ coordinate to be mapped to any local time representation without
32
+ ambiguity.
33
+
34
+ Args:
35
+ epoch (float) : timestamp in UTC time
36
+ rc (str): time string describing date, time and time zone e.g 2024-07-08T12:10:22Z
37
+
38
+ Returns:
39
+ UTC time
40
+ """
41
+ utc_time = datetime.datetime.fromtimestamp(epoch, datetime.timezone.utc)
42
+ utc_timestr = utc_time.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
43
+ return utc_timestr
44
+
45
+
46
+ def timestampstr(ts: float) -> str:
47
+ """Converts the given timestamp to human readable string of format 'Y-m-d
48
+ H:M:S'.
49
+
50
+ Args:
51
+ ts (timestamp): time stamp to be converted
52
+
53
+ Returns:
54
+ rc (string): human readable date-time string
55
+ """
56
+ return str(datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S"))
57
+
58
+
59
+ def timestamp() -> float:
60
+ """Returns the current date-time in UTC.
61
+
62
+ Returns:
63
+ rc (datetime): datetime in UTC.
64
+ """
65
+ return datetime.datetime.now(datetime.timezone.utc).timestamp()
66
+
67
+
68
+ def timestamp_hour(ts: float) -> float:
69
+ """Returns the hour in 24h format in UTC.
70
+
71
+ Args:
72
+ ts (float): timestamp
73
+ Returns:
74
+ rc (int): current hour in UTC 0 ...23
75
+ """
76
+ dt = datetime.datetime.fromtimestamp(ts)
77
+ return dt.hour
78
+
79
+
80
+ def timestamp_hour_local(ts: float, timezone: str) -> float:
81
+ """Returns the hour in 24h format in UTC.
82
+
83
+ Args:
84
+ ts (float): timestamp
85
+ Returns:
86
+ rc (int): current hour in UTC 0 ...23
87
+ """
88
+ utc_time = datetime.datetime.fromtimestamp(ts, tz=pytz.utc)
89
+
90
+ # Convert to your local timezone (e.g., 'US/Eastern' for Eastern Time)
91
+ local_time = utc_time.astimezone(pytz.timezone(timezone))
92
+
93
+ # Get the hour in your local timezone
94
+ return local_time.hour
95
+
96
+
97
+ def is_time_between(
98
+ begin_time: float, end_time: float, check_time: Optional[float] = None
99
+ ) -> bool:
100
+ """Check if the given time is within the given timeline. All
101
+ timestamps must be in UTC time.
102
+
103
+ Args:
104
+ begin_time (float): Beginning of the timeline (Unix timestamp).
105
+ end_time (float): End of the timeline (Unix timestamp).
106
+ check_time (Optional[float]): Time to be checked (Unix timestamp). Defaults to current time.
107
+
108
+ Returns:
109
+ bool: True if the time is within the timeline.
110
+ """
111
+
112
+ time_to_check: float = check_time if check_time is not None else time.time()
113
+ if begin_time < end_time:
114
+ return begin_time <= time_to_check <= end_time
115
+ else: # Crosses midnight
116
+ return time_to_check >= begin_time or time_to_check <= end_time
117
+
118
+
119
+ def is_hour_within_schedule(hour: float, start_time: float, stop_time: float) -> bool:
120
+ """
121
+ Check if the given hour is within the scheduled start and stop times.
122
+
123
+ :param hour: int, current hour (0-23)
124
+ :param start_time: int, start hour (0-23)
125
+ :param stop_time: int, stop hour (0-23)
126
+ :return: bool, True if the hour is within the schedule, False otherwise
127
+ """
128
+ if start_time < stop_time - 0.01:
129
+ # range does not cross midnight
130
+ return start_time <= hour < stop_time
131
+ elif stop_time < start_time - 0.01:
132
+ # Range crosses midnight
133
+ return hour >= start_time or hour < stop_time
134
+ else:
135
+ # null schedule, consider always in.
136
+ return True
137
+
138
+
139
+ def elapsed_seconds_in_hour(ts_utc: float) -> float:
140
+ """Given timestamp in UTC, Compute elapsed seconds within an hour
141
+
142
+ Args:
143
+ ts (float) : seconds since UTC epoch
144
+ Returns:
145
+ float: _description_
146
+ """
147
+
148
+ ts = datetime.datetime.fromtimestamp(ts_utc)
149
+ # Define start time (for example 9:15:30)
150
+ start_time = ts.replace(minute=15, second=30, microsecond=0)
151
+
152
+ # Compute the difference between the times
153
+ elapsed_time = ts - start_time
154
+
155
+ # Convert the difference to seconds
156
+ return elapsed_time.total_seconds()
157
+
158
+
159
+ def elapsed_seconds_in_day(ts_utc: float) -> float:
160
+ """Fetch the elapsed seconds since the be given time stamp 'ts_utc'.
161
+
162
+ Returns:
163
+ float: elapsed second today
164
+ """
165
+ # Convert the float timestamp into a datetime object
166
+ timestamp_datetime = datetime.datetime.fromtimestamp(ts_utc)
167
+ # Get the start of today (midnight)
168
+ midnight = datetime.datetime.combine(timestamp_datetime.date(), datetime.time())
169
+ # Calculate the elapsed seconds since midnight
170
+ elapsed_seconds = (timestamp_datetime - midnight).total_seconds()
171
+ return elapsed_seconds
@@ -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,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,10 @@
1
+ juham_core/__init__.py,sha256=uDREdFEIBk-GKmsu-gLY8KSithyIr0K_KAujXnYPXf0,338
2
+ juham_core/juham.py,sha256=5LvzKMDvmx2yPfviCChrYArNLZBAov-6Y1-vUZxOtFc,19259
3
+ juham_core/rcloud.py,sha256=p7J3CFdJIg9Cl-CsJ2bDe6o30umz3yVItFoUITx1hFA,3462
4
+ juham_core/rthread.py,sha256=gcSefDJgY1R9n-HpCGSuO0lYg6KoLvTVil9wVdZWJfo,4264
5
+ juham_core/timeutils.py,sha256=OiaA7I6Q8lOgBIdwPAcEVjiK68ek7JXseDYjqOw3Ldg,5210
6
+ juham_core-0.0.0.dist-info/LICENSE.rst,sha256=D3SSbUrv10lpAZ91lTMCQAke-MXMvrjFDsDyM3vEKJI,1114
7
+ juham_core-0.0.0.dist-info/METADATA,sha256=jSSM48qkrGl00kCC5ihf0hlFDYsreBk4x7zVen4RQzw,4243
8
+ juham_core-0.0.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
9
+ juham_core-0.0.0.dist-info/top_level.txt,sha256=hsNj6pdsgKj7iFwAU-OeIisfCzfA5AXU7QY_npOFFo4,11
10
+ juham_core-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.8.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ juham_core