juham-automation 0.0.2__py3-none-any.whl → 0.2.8__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.
@@ -0,0 +1,162 @@
1
+ """
2
+ Leak Detector based on Motion Detection and Water Meter Monitoring.
3
+
4
+ This class listens to water meter and motion sensor topics to detect potential water leaks.
5
+ If a leak is detected, the system triggers a leak alarm.
6
+ """
7
+ import json
8
+ from datetime import datetime
9
+ from typing import Any
10
+ from typing_extensions import override
11
+ from masterpiece.mqtt import MqttMsg
12
+ from juham_core.timeutils import timestamp
13
+ from juham_core import Juham
14
+
15
+
16
+ class LeakDetector(Juham):
17
+ """
18
+ Water Leak Detector Class
19
+
20
+ Listens to water meter and motion sensor topics to identify potential water leaks.
21
+ If water consumption is detected without corresponding motion, or if water usage
22
+ remains constant for prolonged periods, a leak alarm is triggered.
23
+
24
+ Detection considers the time since the last motion detection and compares it to
25
+ the configured leak detection period, which is the maximum runtime of water-consuming
26
+ appliances.
27
+ """
28
+
29
+ _LEAKDETECTOR: str = "leakdetector"
30
+ _LEAKDETECTOR_ATTRS: list[str] = [
31
+ "watermeter_topic",
32
+ "motion_topic",
33
+ "motion_last_detected_ts",
34
+ ]
35
+
36
+ watermeter_topic: str = "watermeter"
37
+ motion_topic: str = "motion"
38
+ leak_detection_period: float = (
39
+ 3 * 3600.0
40
+ ) # Maximum runtime for appliances, in seconds
41
+ location: str = "home"
42
+ conseq_zero_periods: int = (
43
+ 60 # this many subsequent zero flow reports imply no leak
44
+ )
45
+
46
+ def __init__(self, name: str = "leakdetector") -> None:
47
+ """
48
+ Initialize the leak detector.
49
+
50
+ Args:
51
+ name (str, optional): Name of the detector instance. Defaults to "leakdetector".
52
+ """
53
+ super().__init__(name)
54
+ self.motion_last_detected_ts: float = timestamp()
55
+ self.watermeter_full_topic: str = self.make_topic_name(self.watermeter_topic)
56
+ self.motion_full_topic: str = self.make_topic_name(self.motion_topic)
57
+ self.leak_detected: bool = False
58
+ self.zero_usage_periods_count: int = 0
59
+
60
+ @override
61
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
62
+ """
63
+ Handle MQTT connection. Subscribe to water meter and motion topics.
64
+ """
65
+ super().on_connect(client, userdata, flags, rc)
66
+ if rc == 0:
67
+ self.subscribe(self.watermeter_full_topic)
68
+ self.subscribe(self.motion_full_topic)
69
+
70
+ @override
71
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
72
+ """
73
+ Process incoming MQTT messages for water meter and motion topics.
74
+ """
75
+ payload = json.loads(msg.payload.decode())
76
+ if msg.topic == self.watermeter_full_topic:
77
+ self.process_water_meter_data(payload)
78
+ elif msg.topic == self.motion_full_topic:
79
+ self.process_motion_data(payload)
80
+ else:
81
+ super().on_message(client, userdata, msg)
82
+
83
+ def detect_activity(self, current_ts: float) -> bool:
84
+ """
85
+ Check if activity (motion) has been detected within the leak detection period.
86
+
87
+ Args:
88
+ current_ts (float): Current timestamp.
89
+
90
+ Returns:
91
+ bool: True if activity detected within the period, False otherwise.
92
+ """
93
+ elapsed_time = current_ts - self.motion_last_detected_ts
94
+ return elapsed_time < self.leak_detection_period
95
+
96
+ def publish_leak_status(self, current_ts: float, leak_suspected: bool) -> None:
97
+ """
98
+ Publish the leak detection status.
99
+
100
+ Args:
101
+ current_ts (float): Current timestamp.
102
+ leak_suspected (bool): Whether a leak is suspected.
103
+ """
104
+ status : dict[str, Any] = {
105
+ "location": self.location,
106
+ "sensor": self.name,
107
+ "leak_suspected": leak_suspected,
108
+ "ts": current_ts,
109
+ }
110
+ payload = json.dumps(status)
111
+ self.publish(self.watermeter_full_topic, payload, qos=1, retain=False)
112
+
113
+ def process_water_meter_data(self, data: dict[str, float]) -> None:
114
+ """
115
+ Handle water meter data and apply leak detection logic.
116
+
117
+ Args:
118
+ data (dict): Water meter data containing flow rate and timestamp.
119
+ """
120
+ if "active_lpm" in data and "ts" in data:
121
+ flow_rate = data["active_lpm"]
122
+ current_ts = data["ts"]
123
+
124
+ if flow_rate > 0.0:
125
+ if not self.detect_activity(current_ts):
126
+ if not self.leak_detected:
127
+ self.leak_detected = True
128
+ readable :str = datetime.fromtimestamp(self.motion_last_detected_ts).strftime("%Y-%m-%d %H:%M:%S")
129
+ self.warning(f"LEAK SUSPECT", f"Flow {flow_rate} lpm, last detected motion {readable}")
130
+
131
+ self.zero_usage_periods_count = 0
132
+ else:
133
+ self.zero_usage_periods_count += 1
134
+ if self.zero_usage_periods_count > self.conseq_zero_periods:
135
+ self.leak_detected = False
136
+ self.motion_last_detected_ts = current_ts
137
+ self.publish_leak_status(current_ts, self.leak_detected)
138
+
139
+ def process_motion_data(self, data: dict[str, float]) -> None:
140
+ """
141
+ Update the last detected motion timestamp.
142
+
143
+ Args:
144
+ data (dict): Motion sensor data containing timestamp.
145
+ """
146
+ if "motion" in data and data["motion"]:
147
+ self.motion_last_detected_ts = data["ts"]
148
+
149
+ @override
150
+ def to_dict(self) -> dict[str, Any]:
151
+ data = super().to_dict()
152
+ attributes = {attr: getattr(self, attr) for attr in self._LEAKDETECTOR_ATTRS}
153
+ data[self._LEAKDETECTOR] = attributes
154
+ return data
155
+
156
+ @override
157
+ def from_dict(self, data: dict[str, Any]) -> None:
158
+ super().from_dict(data)
159
+ if self._LEAKDETECTOR in data:
160
+ attributes = data[self._LEAKDETECTOR]
161
+ for attr in self._LEAKDETECTOR_ATTRS:
162
+ setattr(self, attr, attributes.get(attr, None))
@@ -0,0 +1,140 @@
1
+ from datetime import datetime
2
+ import time
3
+ import json
4
+ from typing import Any, Dict, Optional, cast
5
+ from typing_extensions import override
6
+
7
+ from masterpiece.mqtt import Mqtt, MqttMsg
8
+ from juham_core import JuhamCloudThread, JuhamThread
9
+
10
+
11
+ class SpotHintaFiThread(JuhamCloudThread):
12
+ """Thread running SpotHinta.fi.
13
+
14
+ Periodically fetches the spot electricity prices and publishes them
15
+ to 'spot' topic.
16
+ """
17
+
18
+ _spot_topic: str = ""
19
+ _url: str = ""
20
+ _interval: float = 12 * 3600
21
+ grid_cost_day: float = 0.0314
22
+ grid_cost_night: float = 0.0132
23
+ grid_cost_tax: float = 0.028272
24
+
25
+ def __init__(self, client: Optional[Mqtt] = None) -> None:
26
+ super().__init__(client)
27
+ self._interval = 60
28
+
29
+ def init(self, topic: str, url: str, interval: float) -> None:
30
+ self._spot_topic = topic
31
+ self._url = url
32
+ self._interval = interval
33
+
34
+ @override
35
+ def make_weburl(self) -> str:
36
+ return self._url
37
+
38
+ @override
39
+ def update_interval(self) -> float:
40
+ return self._interval
41
+
42
+ @override
43
+ def process_data(self, rawdata: Any) -> None:
44
+ """Publish electricity price message to Juham topic.
45
+
46
+ Args:
47
+ rawdata (dict): electricity prices
48
+ """
49
+
50
+ super().process_data(rawdata)
51
+ data = rawdata.json()
52
+
53
+ spot = []
54
+ for e in data:
55
+ dt = datetime.fromisoformat(e["DateTime"]) # Correct timezone handling
56
+ ts = int(dt.timestamp()) # Ensure integer timestamps like in the test
57
+
58
+ hour = dt.strftime("%H") # Correctly extract hour
59
+
60
+ if 6 <= int(hour) < 22:
61
+ grid_cost = self.grid_cost_day
62
+ else:
63
+ grid_cost = self.grid_cost_night
64
+
65
+ total_price = round(e["PriceWithTax"] + grid_cost + self.grid_cost_tax, 6)
66
+ grid_cost_total = round(grid_cost + self.grid_cost_tax, 6)
67
+
68
+ h = {
69
+ "Timestamp": ts,
70
+ "hour": hour,
71
+ "Rank": e["Rank"],
72
+ "PriceWithTax": total_price,
73
+ "GridCost": grid_cost_total,
74
+ }
75
+ spot.append(h)
76
+
77
+ self.publish(self._spot_topic, json.dumps(spot), 1, True)
78
+ # self.info(f"Spot electricity prices published for the next {len(spot)} days")
79
+
80
+
81
+ class SpotHintaFi(JuhamThread):
82
+ """Spot electricity price for reading hourly electricity prices from
83
+ https://api.spot-hinta.fi site.
84
+ """
85
+
86
+ _SPOTHINTAFI: str = "_spothintafi"
87
+ worker_thread_id = SpotHintaFiThread.get_class_id()
88
+ url = "https://api.spot-hinta.fi/TodayAndDayForward"
89
+ update_interval = 12 * 3600
90
+
91
+ def __init__(self, name: str = "rspothintafi") -> None:
92
+ super().__init__(name)
93
+ self.active_liter_lpm = -1
94
+ self.update_ts = None
95
+ self.spot_topic = self.make_topic_name("spot")
96
+
97
+ @override
98
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
99
+ super().on_connect(client, userdata, flags, rc)
100
+ if rc == 0:
101
+ self.subscribe(self.spot_topic)
102
+
103
+ @override
104
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
105
+ if msg.topic == self.spot_topic:
106
+ em = json.loads(msg.payload.decode())
107
+ self.on_spot(em)
108
+ else:
109
+ super().on_message(client, userdata, msg)
110
+
111
+ def on_spot(self, m: dict[Any, Any]) -> None:
112
+ """Write hourly spot electricity prices to time series database.
113
+
114
+ Args:
115
+ m (dict): holding hourly spot electricity prices
116
+ """
117
+ pass
118
+
119
+ @override
120
+ def run(self) -> None:
121
+ self.worker = cast(SpotHintaFiThread, self.instantiate(self.worker_thread_id))
122
+ self.worker.init(self.spot_topic, self.url, self.update_interval)
123
+ super().run()
124
+
125
+ @override
126
+ def to_dict(self) -> Dict[str, Any]:
127
+ data: Dict[str, Any] = super().to_dict()
128
+ data[self._SPOTHINTAFI] = {
129
+ "topic": self.spot_topic,
130
+ "url": self.url,
131
+ "interval": self.update_interval,
132
+ }
133
+ return data
134
+
135
+ @override
136
+ def from_dict(self, data: Dict[str, Any]) -> None:
137
+ super().from_dict(data)
138
+ if self._SPOTHINTAFI in data:
139
+ for key, value in data[self._SPOTHINTAFI].items():
140
+ setattr(self, key, value)
@@ -0,0 +1,140 @@
1
+ from typing import Any
2
+ from typing_extensions import override
3
+ import json
4
+
5
+ from masterpiece.mqtt import MqttMsg
6
+ from juham_core import Juham
7
+ from juham_core.timeutils import timestamp
8
+
9
+
10
+ class WaterCirculator(Juham):
11
+ """Hot Water Circulation Automation
12
+
13
+ This system monitors motion sensor data to detect home occupancy.
14
+
15
+ - **When motion is detected**: The water circulator pump is activated, ensuring hot water is
16
+ instantly available when the tap is turned on.
17
+ - **When no motion is detected for a specified period (in seconds)**: The pump automatically
18
+ switches off to conserve energy.
19
+
20
+ Future improvement idea
21
+ ------------------------
22
+
23
+ In cold countries, such as Finland, energy conservation during the winter season may not be a priority.
24
+ In this case, an additional temperature sensor measuring the outside temperature could be used to determine whether
25
+ the circulator should be switched off at all. The circulating water could potentially act as an additional heating radiator.
26
+
27
+ Points to consider
28
+ ------------------
29
+
30
+ - Switching the pump on and off may affect its lifetime.
31
+ - Keeping the pump running with hot water could impact the lifespan of the pipes, potentially causing
32
+ corrosion due to constant hot water flow.
33
+
34
+ """
35
+
36
+ uptime = 1800 # half an hour
37
+ min_temperature = 37
38
+
39
+ def __init__(self, name: str, temperature_sensor: str) -> None:
40
+ super().__init__(name)
41
+
42
+ # input topics
43
+ self.motion_topic = self.make_topic_name("motion") # motion detection
44
+ self.temperature_topic = self.make_topic_name(temperature_sensor)
45
+
46
+ # relay to be controlled
47
+ self.topic_power = self.make_topic_name("power")
48
+
49
+ # for the pump controlling logic
50
+ self.current_motion: bool = False
51
+ self.relay_started_ts: float = 0
52
+ self.water_temperature: float = 0
53
+ self.water_temperature_updated: float = 0
54
+ self.initialized = False
55
+
56
+ @override
57
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
58
+ super().on_connect(client, userdata, flags, rc)
59
+ if rc == 0:
60
+ self.subscribe(self.motion_topic)
61
+ self.subscribe(self.temperature_topic)
62
+ # reset the relay to make sure the initial state matches the state of us
63
+ self.publish_relay_state(0)
64
+
65
+ @override
66
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
67
+ if msg.topic == self.temperature_topic:
68
+ m = json.loads(msg.payload.decode())
69
+ self.on_temperature_sensor(m, timestamp())
70
+ elif msg.topic == self.motion_topic:
71
+ m = json.loads(msg.payload.decode())
72
+ self.on_motion_sensor(m, timestamp())
73
+ else:
74
+ super().on_message(client, userdata, msg)
75
+
76
+ def on_temperature_sensor(self, m: dict[str, Any], ts_utc_now: float) -> None:
77
+ """Handle message from the hot water pipe temperature sensor.
78
+ Records the temperature and updates the water_temperature_updated attribute.
79
+
80
+ Args:
81
+ m (dict): temperature reading from the hot water blump sensor
82
+ ts_utc_now (float): _current utc time
83
+ """
84
+
85
+ self.water_temperature = m["temperature"]
86
+ self.water_temperature_updated = ts_utc_now
87
+
88
+ def on_motion_sensor(self, m: dict[str, dict[str, Any]], ts_utc_now: float) -> None:
89
+ """Control the water cirulator bump.
90
+
91
+ Given message from the motion sensor consider switching the
92
+ circulator bump on.
93
+
94
+ Args:
95
+ msg (dict): directionary holding motion sensor data
96
+ ts_utc_now (float): current time stamp
97
+ """
98
+ sensor = m["sensor"]
99
+ vibration: bool = bool(m["vibration"])
100
+ motion: bool = bool(m["motion"])
101
+
102
+ if motion or vibration:
103
+ if not self.current_motion:
104
+ if self.water_temperature > self.min_temperature:
105
+ self.publish_relay_state(0)
106
+ else:
107
+ self.current_motion = True
108
+ self.relay_started_ts = ts_utc_now
109
+ self.publish_relay_state(1)
110
+ self.initialized = True
111
+ self.info(
112
+ f"Circulator pump started, will run for {int(self.uptime / 60)} minutes "
113
+ )
114
+ else:
115
+ self.publish_relay_state(1)
116
+ self.relay_started_ts = ts_utc_now
117
+ else:
118
+ if self.current_motion or not self.initialized:
119
+ elapsed: float = ts_utc_now - self.relay_started_ts
120
+ if elapsed > self.uptime:
121
+ self.publish_relay_state(0)
122
+ self.info(
123
+ f"Circulator pump stopped, no motion in {int(elapsed/60)} minutes detected",
124
+ "",
125
+ )
126
+ self.current_motion = False
127
+ self.initialized = True
128
+ else:
129
+ self.publish_relay_state(1)
130
+ else:
131
+ self.publish_relay_state(0)
132
+
133
+ def publish_relay_state(self, state: int) -> None:
134
+ """Publish power status.
135
+
136
+ Args:
137
+ state (int): 1 for on, 0 for off, as defined by Juham 'power' topic
138
+ """
139
+ heat = {"Unit": self.name, "Timestamp": timestamp(), "State": state}
140
+ self.publish(self.topic_power, json.dumps(heat), 1, False)
juham_automation/japp.py CHANGED
@@ -1,55 +1,53 @@
1
- from masterpiece import Application
2
- from juham_core import Juham
3
- from juham_systemstatus import SystemStatus
4
-
5
- from .ts.forecast_ts import ForecastTs
6
- from .ts.power_ts import PowerTs
7
- from .ts.powerplan_ts import PowerPlanTs
8
- from .ts.powermeter_ts import PowerMeterTs
9
- from .ts.log_ts import LogTs
10
- from .ts.energycostcalculator_ts import EnergyCostCalculatorTs
11
- from .ts.electricityprice_ts import ElectricityPriceTs
12
- from .automation.spothintafi import SpotHintaFi
13
- from .automation.energycostcalculator import EnergyCostCalculator
14
-
15
-
16
- class JApp(Application):
17
- """Juham home automation application base class. Registers new plugin
18
- group 'juham' on which general purpose Juham plugins can be written on.
19
- """
20
-
21
- def __init__(self, name: str) -> None:
22
- """Creates home automation application with the given name.
23
- If --enable_plugins is False create hard coded configuration
24
- by calling instantiate_classes() method.
25
-
26
- Args:
27
- name (str): name for the application
28
- """
29
- super().__init__(name, Juham(name))
30
-
31
- def instantiate_classes(self) -> None:
32
- """Instantiate automation classes .
33
-
34
- Returns:
35
- None
36
- """
37
- self.add(ForecastTs())
38
- self.add(PowerTs())
39
- self.add(PowerPlanTs())
40
- self.add(PowerMeterTs())
41
- self.add(LogTs())
42
- self.add(SpotHintaFi())
43
- self.add(EnergyCostCalculator())
44
- self.add(EnergyCostCalculatorTs())
45
- self.add(ElectricityPriceTs())
46
-
47
- # install plugins
48
- self.add(self.instantiate_plugin_by_name("SystemStatus"))
49
- self.add(self.instantiate_plugin_by_name("VisualCrossing"))
50
- self.add(self.instantiate_plugin_by_name("OpenWeatherMap"))
51
-
52
- @classmethod
53
- def register(cls) -> None:
54
- """Register plugin group `juham`."""
55
- Application.register_plugin_group("juham")
1
+ from juham_automation.automation.energybalancer import EnergyBalancer
2
+ from masterpiece import Application
3
+ from juham_core import Juham
4
+
5
+ from .ts import ForecastTs
6
+ from .ts import PowerTs
7
+ from .ts import PowerPlanTs
8
+ from .ts import PowerMeterTs
9
+ from .ts import EnergyBalancerTs
10
+ from .ts import LogTs
11
+ from .ts import EnergyCostCalculatorTs
12
+ from .ts import ElectricityPriceTs
13
+ from .automation import SpotHintaFi
14
+ from .automation import EnergyCostCalculator
15
+
16
+
17
+ class JApp(Application):
18
+ """Juham home automation application base class. Registers new plugin
19
+ group 'juham' on which general purpose Juham plugins can be written on.
20
+ """
21
+
22
+ def __init__(self, name: str) -> None:
23
+ """Creates home automation application with the given name.
24
+ If --enable_plugins is False create hard coded configuration
25
+ by calling instantiate_classes() method.
26
+
27
+ Args:
28
+ name (str): name for the application
29
+ """
30
+ super().__init__(name, Juham(name))
31
+
32
+ def instantiate_classes(self) -> None:
33
+ """Instantiate automation classes .
34
+
35
+ Returns:
36
+ None
37
+ """
38
+ self.add(ForecastTs())
39
+ self.add(PowerTs())
40
+ self.add(PowerPlanTs())
41
+ self.add(PowerMeterTs())
42
+ self.add(LogTs())
43
+ self.add(SpotHintaFi())
44
+ self.add(EnergyCostCalculator())
45
+ self.add(EnergyCostCalculatorTs())
46
+ self.add(ElectricityPriceTs())
47
+ self.add(EnergyBalancer())
48
+ self.add(EnergyBalancerTs())
49
+
50
+ @classmethod
51
+ def register(cls) -> None:
52
+ """Register plugin group `juham`."""
53
+ Application.register_plugin_group("juham")
@@ -0,0 +1,27 @@
1
+ """
2
+ Description
3
+ ===========
4
+
5
+ Time series recorders for Juha's Ultimate Home Automation classes.
6
+
7
+ """
8
+
9
+ from .energycostcalculator_ts import EnergyCostCalculatorTs
10
+ from .log_ts import LogTs
11
+ from .power_ts import PowerTs
12
+ from .powerplan_ts import PowerPlanTs
13
+ from .powermeter_ts import PowerMeterTs
14
+ from .electricityprice_ts import ElectricityPriceTs
15
+ from .forecast_ts import ForecastTs
16
+ from .energybalancer_ts import EnergyBalancerTs
17
+
18
+ __all__ = [
19
+ "EnergyCostCalculatorTs",
20
+ "ForecastTs",
21
+ "LogTs",
22
+ "PowerTs",
23
+ "PowerPlanTs",
24
+ "PowerMeterTs",
25
+ "ElectricityPriceTs",
26
+ "EnergyBalancerTs",
27
+ ]
@@ -0,0 +1,51 @@
1
+ from datetime import datetime
2
+ import time
3
+ import json
4
+ from typing import Any, Dict, Optional, cast
5
+ from typing_extensions import override
6
+
7
+ from masterpiece.mqtt import Mqtt, MqttMsg
8
+ from juham_core.timeutils import epoc2utc
9
+ from juham_core import JuhamTs
10
+
11
+
12
+ class ElectricityPriceTs(JuhamTs):
13
+ """Spot electricity price for reading hourly electricity prices from"""
14
+
15
+ def __init__(self, name: str = "electricityprice_ts") -> None:
16
+ super().__init__(name)
17
+
18
+ self.spot_topic = self.make_topic_name("spot")
19
+
20
+ @override
21
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
22
+ super().on_connect(client, userdata, flags, rc)
23
+ if rc == 0:
24
+ self.subscribe(self.spot_topic)
25
+
26
+ @override
27
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
28
+ if msg.topic == self.spot_topic:
29
+ em = json.loads(msg.payload.decode())
30
+ self.on_spot(em)
31
+ else:
32
+ super().on_message(client, userdata, msg)
33
+
34
+ def on_spot(self, m: dict[Any, Any]) -> None:
35
+ """Write spot electricity prices to time series database.
36
+
37
+ Args:
38
+ m (dict): holding hourlys spot electricity prices
39
+ """
40
+ grid_cost : float
41
+ for h in m:
42
+ if "GridCost" in h:
43
+ grid_cost = h["GridCost"]
44
+ point = (
45
+ self.measurement("spot")
46
+ .tag("hour", h["Timestamp"])
47
+ .field("value", h["PriceWithTax"])
48
+ .field("grid", grid_cost)
49
+ .time(epoc2utc(h["Timestamp"]))
50
+ )
51
+ self.write(point)