juham-automation 0.0.2__py3-none-any.whl → 0.0.4__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,527 @@
1
+ import json
2
+ from typing import Any
3
+ from typing_extensions import override
4
+
5
+ from masterpiece.mqtt import MqttMsg
6
+ from juham_core import Juham
7
+ from juham_core.timeutils import (
8
+ quantize,
9
+ timestamp,
10
+ timestamp_hour,
11
+ timestampstr,
12
+ is_hour_within_schedule,
13
+ timestamp_hour_local,
14
+ )
15
+
16
+
17
+ class HotWaterOptimizer(Juham):
18
+ """Automation class for optimized control of temperature driven home energy consumers e.g hot
19
+ water radiators. Reads spot prices, boiler water temperatures to minimize electricity bill.
20
+ Additional control over heating is provided by the following attributes
21
+
22
+ Attributes:
23
+ schedule_start_hour (float): hour heating can start, in local time
24
+ schedule_stop_hour (float): hour heating is forced to stop
25
+ start_hour (float): start_hour (int): ordinal of the first allowed electricity price slot to be consumed
26
+ num_hours (int): the number of hour slots allowed
27
+
28
+ """
29
+
30
+ maximum_boiler_temperature = 70
31
+ minimum_boiler_temperature = 40
32
+ energy_balancing_interval: float = 3600
33
+ radiator_power = 3000 # 3 kW
34
+ operation_threshold = 5 * 60
35
+ heating_hours_per_day = 4 # use the four cheapest hours per day
36
+ schedule_start_hour: float = 0
37
+ schedule_stop_hour: float = 0
38
+ timezone = "Europe/Helsinki"
39
+
40
+ def __init__(
41
+ self,
42
+ name: str,
43
+ temperature_sensor: str,
44
+ start_hour: int,
45
+ num_hours: int,
46
+ spot_limit: float,
47
+ ) -> None:
48
+ """Create power plan for automating temperature driven systems, e.g. heating radiators
49
+ to optimize energy consumption based on electricity prices.
50
+
51
+ Electricity Price MQTT Topic: This specifies the MQTT topic through which the controller receives
52
+ hourly electricity price forecasts for the next day or two.
53
+ Radiator Control Topic: The MQTT topic used to control the radiator relay.
54
+ Temperature Sensor Topic: The MQTT topic where the temperature sensor publishes its readings.
55
+ Electricity Price Slot Range: A pair of integers determining which electricity price slots the
56
+ controller uses. The slots are ranked from the cheapest to the most expensive. For example:
57
+ - A range of 0, 3 directs the controller to use electricity during the three cheapest hours.
58
+ - A second controller with a range of 3, 2 would target the next two cheapest hours, and so on.
59
+ Maximum Electricity Price Threshold: An upper limit for the electricity price, serving as an additional control.
60
+ The controller only operates within its designated price slots if the prices are below this threshold.
61
+
62
+ The maximum price threshold reflects the criticality of the radiator's operation:
63
+
64
+ High thresholds indicate that the radiator should remain operational regardless of the price.
65
+ Low thresholds imply the radiator can be turned off during expensive periods, suggesting it has a less critical role.
66
+
67
+ By combining these attributes, the controller ensures efficient energy usage while maintaining desired heating levels.
68
+
69
+ Args:
70
+ name (str): name of the heating radiator
71
+ temperature_sensor (str): temperature sensor of the heating radiator
72
+ start_hour (int): ordinal of the first allowed electricity price slot to be consumed
73
+ num_hours (int): the number of slots allowed
74
+ spot_limit (float): maximum price allowed
75
+ """
76
+ super().__init__(name)
77
+
78
+ self.heating_hours_per_day = num_hours
79
+ self.start_hour = start_hour
80
+ self.spot_limit = spot_limit
81
+
82
+ self.topic_spot = self.make_topic_name("spot")
83
+ self.topic_forecast = self.make_topic_name("forecast")
84
+ self.topic_temperature = self.make_topic_name(temperature_sensor)
85
+ self.topic_powerplan = self.make_topic_name("powerplan")
86
+ self.topic_power = self.make_topic_name("power")
87
+ self.topic_in_powerconsumption = self.make_topic_name("powerconsumption")
88
+ self.topic_in_net_energy_balance = self.make_topic_name("net_energy_balance")
89
+
90
+ self.current_temperature = 100
91
+ self.current_heating_plan = 0
92
+ self.current_relay_state = -1
93
+ self.heating_plan: list[dict[str, int]] = []
94
+ self.power_plan: list[dict[str, Any]] = []
95
+ self.ranked_spot_prices: list[dict[Any, Any]] = []
96
+ self.ranked_solarpower: list[dict[Any, Any]] = []
97
+ self.relay: bool = False
98
+ self.relay_started_ts: float = 0
99
+ self.current_power: float = 0
100
+ self.net_energy_balance: float = 0.0
101
+ self.net_energy_power: float = 0
102
+ self.net_energy_balance_ts: float = 0
103
+ self.net_energy_balancing_rc: bool = False
104
+ self.net_energy_balancing_mode = False
105
+
106
+ @override
107
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
108
+ super().on_connect(client, userdata, flags, rc)
109
+ if rc == 0:
110
+ self.subscribe(self.topic_spot)
111
+ self.subscribe(self.topic_forecast)
112
+ self.subscribe(self.topic_temperature)
113
+ self.subscribe(self.topic_in_powerconsumption)
114
+ self.subscribe(self.topic_in_net_energy_balance)
115
+
116
+ def sort_by_rank(
117
+ self, hours: list[dict[str, Any]], ts_utc_now: float
118
+ ) -> list[dict[str, Any]]:
119
+ """Sort the given electricity prices by their rank value. Given a list
120
+ of electricity prices, return a sorted list from the cheapest to the
121
+ most expensive hours. Entries that represent electricity prices in the
122
+ past are excluded.
123
+
124
+ Args:
125
+ hours (list): list of hourly electricity prices
126
+ ts_utc_now (float): current time
127
+
128
+ Returns:
129
+ list: sorted list of electricity prices
130
+ """
131
+ sh = sorted(hours, key=lambda x: x["Rank"])
132
+ ranked_hours = []
133
+ for h in sh:
134
+ utc_ts = h["Timestamp"]
135
+ if utc_ts > ts_utc_now:
136
+ ranked_hours.append(h)
137
+
138
+ return ranked_hours
139
+
140
+ def sort_by_power(
141
+ self, solarpower: list[dict[Any, Any]], ts_utc: float
142
+ ) -> list[dict[Any, Any]]:
143
+ """Sort forecast of solarpower to decreasing order.
144
+
145
+ Args:
146
+ solarpower (list): list of entries describing hourly solar energy forecast
147
+ ts_utc(float): start time, for exluding entries that are in the past
148
+
149
+ Returns:
150
+ list: list from the highest solarenergy to lowest.
151
+ """
152
+
153
+ # if all items have solarenergy key then
154
+ # sh = sorted(solarpower, key=lambda x: x["solarenergy"], reverse=True)
155
+ # else skip items that don't have solarenergy key
156
+ sh = sorted(
157
+ [item for item in solarpower if "solarenergy" in item],
158
+ key=lambda x: x["solarenergy"],
159
+ reverse=True,
160
+ )
161
+ self.debug(
162
+ f"Sorted {len(sh)} days of forecast starting at {timestampstr(ts_utc)}"
163
+ )
164
+ ranked_hours = []
165
+
166
+ for h in sh:
167
+ utc_ts: float = float(h["ts"])
168
+ if utc_ts >= ts_utc:
169
+ ranked_hours.append(h)
170
+ self.debug(f"Forecast sorted for the next {str(len(ranked_hours))} hours")
171
+ return ranked_hours
172
+
173
+ @override
174
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
175
+ m = None
176
+ ts: float = timestamp()
177
+ ts_utc_quantized: float = quantize(3600, ts - 3600)
178
+ if msg.topic == self.topic_spot:
179
+ self.ranked_spot_prices = self.sort_by_rank(
180
+ json.loads(msg.payload.decode()), ts_utc_quantized
181
+ )
182
+ self.debug(
183
+ f"Spot prices received and ranked for {len(self.ranked_spot_prices)} hours"
184
+ )
185
+ self.power_plan = [] # reset power plan, it depends on spot prices
186
+ return
187
+ elif msg.topic == self.topic_forecast:
188
+ forecast = json.loads(msg.payload.decode())
189
+ # reject messages that don't have solarenergy forecast
190
+
191
+ for f in forecast:
192
+ if not "solarenergy" in f:
193
+ return
194
+
195
+ self.ranked_solarpower = self.sort_by_power(forecast, ts_utc_quantized)
196
+ self.debug(
197
+ f"Solar energy forecast received and ranked for {len(self.ranked_solarpower)} hours"
198
+ )
199
+ self.power_plan = [] # reset power plan, it depends on forecast
200
+ return
201
+ elif msg.topic == self.topic_temperature:
202
+ m = json.loads(msg.payload.decode())
203
+ self.current_temperature = m["temperature"]
204
+ elif msg.topic == self.topic_in_net_energy_balance:
205
+ m = json.loads(msg.payload.decode())
206
+ self.net_energy_balance = m["energy"]
207
+ self.net_energy_power = m["power"]
208
+ elif msg.topic == self.topic_in_powerconsumption:
209
+ m = json.loads(msg.payload.decode())
210
+ self.current_power = m["real_total"]
211
+ else:
212
+ super().on_message(client, userdata, msg)
213
+ return
214
+ self.on_powerplan(ts)
215
+
216
+ def on_powerplan(self, ts_utc_now: float) -> None:
217
+ """Apply power plan.
218
+
219
+ Args:
220
+ ts_utc_now (float): utc time
221
+ """
222
+
223
+ # optimization, check only once a minute
224
+ elapsed: float = ts_utc_now - self.relay_started_ts
225
+ if elapsed < 60:
226
+ return
227
+ self.relay_started_ts = ts_utc_now
228
+
229
+ if not self.ranked_spot_prices:
230
+ self.debug("Waiting spot prices...", "")
231
+ return
232
+
233
+ if not self.power_plan:
234
+ self.power_plan = self.create_power_plan()
235
+ self.heating_plan = []
236
+ self.info(
237
+ f"Power plan of length {len(self.power_plan)} created",
238
+ str(self.power_plan),
239
+ )
240
+
241
+ if not self.power_plan:
242
+ self.error("Failed to create a power plan", "")
243
+ return
244
+
245
+ if len(self.power_plan) < 3:
246
+ self.warning(
247
+ f"Suspiciously short {len(self.power_plan)} power plan, wait more data ..",
248
+ "",
249
+ )
250
+ self.heating_plan = []
251
+ self.power_plan = []
252
+ return
253
+
254
+ if not self.ranked_solarpower or len(self.ranked_solarpower) < 4:
255
+ self.warning(
256
+ f"Short of forecast {len(self.ranked_solarpower)}, optimization compromised..",
257
+ "",
258
+ )
259
+
260
+ if not self.heating_plan:
261
+ self.heating_plan = self.create_heating_plan()
262
+ if not self.heating_plan:
263
+ self.error("Failed to create heating plan")
264
+ return
265
+ else:
266
+ self.info(
267
+ f"Heating plan of length {len(self.heating_plan)} created", ""
268
+ )
269
+ if len(self.heating_plan) < 3:
270
+ self.info(f"Short heating plan {len(self.heating_plan)}, no can do", "")
271
+ self.heating_plan = []
272
+ self.power_plan = []
273
+ return
274
+
275
+ relay = self.consider_heating(ts_utc_now)
276
+ if self.current_relay_state != relay:
277
+ heat = {"Unit": self.name, "Timestamp": ts_utc_now, "State": relay}
278
+ self.publish(self.topic_power, json.dumps(heat), 1, False)
279
+ self.info(
280
+ f"Relay state {self.name} changed to {relay} at {timestampstr(ts_utc_now)}",
281
+ "",
282
+ )
283
+ self.current_relay_state = relay
284
+
285
+ def consider_net_energy_balance(self, ts: float) -> bool:
286
+ """Check when there is enough energy available for the radiators heat
287
+ the water the remaining time within the balancing interval,
288
+ and switch the balancing mode on. If the remaining time in the
289
+ current balancing slot is less than the threshold then
290
+ optimize out.
291
+
292
+
293
+ Args:
294
+ ts (float): current time
295
+
296
+ Returns:
297
+ bool: true if production exceeds the consumption
298
+ """
299
+
300
+ # elapsed and remaining time within the current balancing slot
301
+ elapsed_ts = ts - quantize(self.energy_balancing_interval, ts)
302
+ remaining_ts = self.energy_balancing_interval - elapsed_ts
303
+
304
+ # don't bother to switch the relay on for small intervals, to avoid
305
+ # wearing contactors out
306
+ if remaining_ts < self.operation_threshold:
307
+ return False
308
+
309
+ # check if the balance is sufficient for heating the next half of the energy balancing interval
310
+ # if yes then switch heating on for the next half an hour
311
+ needed_energy = 0.5 * self.radiator_power * remaining_ts
312
+ elapsed_interval = ts - self.net_energy_balance_ts
313
+ if (
314
+ self.net_energy_balance > needed_energy
315
+ ) and not self.net_energy_balancing_rc:
316
+ self.net_energy_balance_ts = ts
317
+ self.net_energy_balancing_rc = True # heat
318
+ # self.info("Enough to supply the radiator, enable")
319
+ self.net_energy_balancing_mode = True # balancing mode indicator on
320
+ else:
321
+ # check if we have reach the end of the interval, or consumed all the energy
322
+ # of the current slot. If so switch the energy balancer mode off
323
+ if (
324
+ elapsed_interval > self.energy_balancing_interval / 2.0
325
+ or self.net_energy_balance < 0
326
+ ):
327
+ self.net_energy_balancing_rc = False # heating off
328
+ # self.info("Balance used, or the end of the interval reached, disable")
329
+ return self.net_energy_balancing_rc
330
+
331
+ def consider_heating(self, ts: float) -> int:
332
+ """Consider whether the target boiler needs heating.
333
+
334
+ Args:
335
+ ts (float): current UTC time
336
+
337
+ Returns:
338
+ int: 1 if heating is needed, 0 if not
339
+ """
340
+
341
+ # check if we have energy to consume, if so return 1
342
+ if self.consider_net_energy_balance(ts):
343
+ self.warning("TODO: Net energy balance positive, but disabled for now")
344
+ # return 1
345
+ elif self.net_energy_balancing_mode:
346
+ balancing_slot_start_ts = quantize(self.energy_balancing_interval, ts)
347
+ elapsed_b = ts - balancing_slot_start_ts
348
+ if elapsed_b > self.energy_balancing_interval:
349
+ self.net_energy_balancing_mode = False
350
+ self.info(
351
+ f"TODO: Net energy balancing mode because elapsed {elapsed_b}s is less than balancing interval {self.energy_balancing_interval}s"
352
+ )
353
+ else:
354
+ self.info(
355
+ f"TODO: Net energy balance waiting interval {elapsed_b}s to end"
356
+ )
357
+ # return 0
358
+
359
+ if self.current_temperature > self.maximum_boiler_temperature:
360
+ self.info(
361
+ f"Current temperature {self.current_temperature}C already beyond max {self.maximum_boiler_temperature}C"
362
+ )
363
+ return 0
364
+ hour = timestamp_hour(ts)
365
+
366
+ for pp in self.heating_plan:
367
+ ppts: float = pp["Timestamp"]
368
+ h: float = timestamp_hour(ppts)
369
+ if h == hour:
370
+ return pp["State"]
371
+
372
+ self.error(f"Cannot find heating plan for hour {hour}")
373
+ return 0
374
+
375
+ # compute figure of merit (FOM) for each hour
376
+ # the higher the solarenergy and the lower the spot the higher the FOM
377
+
378
+ # compute fom
379
+ def compute_fom(self, solpower: float, spot: float, hour: float) -> float:
380
+ """Compute UOI - utilization optimization index.
381
+
382
+ Args:
383
+ solpower (float): current solar power forecast
384
+ spot (float): spot price
385
+ hour (float) : the hour of the day
386
+
387
+ Returns:
388
+ float: utilization optimization index
389
+ """
390
+
391
+ if not is_hour_within_schedule(
392
+ hour, self.schedule_start_hour, self.schedule_stop_hour
393
+ ):
394
+ return 0.0
395
+
396
+ # total solar power is 6kW and max pow consumption about twice as much
397
+ # so when sun is shining with full power nearly half of the energy comes for free
398
+
399
+ if spot < 0.001:
400
+ return 2.0 # use
401
+ elif spot > 0.2:
402
+ return 0.0 # try not to use
403
+ else:
404
+ fom = 2 * (0.101 - spot) / 0.1
405
+ return fom
406
+
407
+ def create_power_plan(self) -> list[dict[Any, Any]]:
408
+ """Create power plan.
409
+
410
+ Returns:
411
+ list: list of utilization entries
412
+ """
413
+ ts_utc_quantized = quantize(3600, timestamp() - 3600)
414
+ starts: str = timestampstr(ts_utc_quantized)
415
+ self.info(
416
+ f"Trying to create power plan starting at {starts} with {len(self.ranked_spot_prices)} hourly spot prices",
417
+ "",
418
+ )
419
+
420
+ # syncronize spot and solarenergy by timestamp
421
+ spots = []
422
+ for s in self.ranked_spot_prices:
423
+ if s["Timestamp"] > ts_utc_quantized:
424
+ spots.append(
425
+ {"Timestamp": s["Timestamp"], "PriceWithTax": s["PriceWithTax"]}
426
+ )
427
+ self.info(
428
+ f"Have spot prices for the next {len(spots)} hours",
429
+ "",
430
+ )
431
+ powers = []
432
+ for s in self.ranked_solarpower:
433
+ if s["ts"] >= ts_utc_quantized:
434
+ powers.append({"Timestamp": s["ts"], "Solarenergy": s["solarenergy"]})
435
+
436
+ num_powers: int = len(powers)
437
+ if num_powers == 0:
438
+ self.debug(
439
+ f"No solar forecast initialized yet, can't proceed",
440
+ "",
441
+ )
442
+ return []
443
+ self.debug(
444
+ f"Have solar forecast for the next {num_powers} hours",
445
+ "",
446
+ )
447
+ hplan = []
448
+ hour: float = 0
449
+ if len(powers) >= 8:
450
+ for spot, solar in zip(spots, powers):
451
+ ts = spot["Timestamp"]
452
+ solarenergy = solar["Solarenergy"]
453
+ spotprice = spot["PriceWithTax"]
454
+ hour = timestamp_hour_local(ts, self.timezone)
455
+ fom = self.compute_fom(solarenergy, spotprice, hour)
456
+ plan = {
457
+ "Timestamp": ts,
458
+ "FOM": fom,
459
+ "Spot": spotprice,
460
+ }
461
+ hplan.append(plan)
462
+ else:
463
+ for spot in spots:
464
+ ts = spot["Timestamp"]
465
+ solarenergy = 0.0
466
+ spotprice = spot["PriceWithTax"]
467
+ hour = timestamp_hour_local(ts, self.timezone)
468
+ fom = self.compute_fom(solarenergy, spotprice, hour)
469
+ plan = {"Timestamp": spot["Timestamp"], "FOM": fom, "Spot": spotprice}
470
+ hplan.append(plan)
471
+
472
+ shplan = sorted(hplan, key=lambda x: x["FOM"], reverse=True)
473
+
474
+ self.debug(f"Powerplan starts {starts} up to {len(shplan)} hours")
475
+ return shplan
476
+
477
+ def enable_relay(
478
+ self, hour: float, spot: float, fom: float, end_hour: float
479
+ ) -> bool:
480
+ return (
481
+ hour >= self.start_hour
482
+ and hour < end_hour
483
+ and float(spot) < self.spot_limit
484
+ and fom
485
+ > (self.current_temperature - self.minimum_boiler_temperature)
486
+ / (self.maximum_boiler_temperature - self.minimum_boiler_temperature)
487
+ )
488
+
489
+ def create_heating_plan(self) -> list[dict[str, Any]]:
490
+ """Create heating plan.
491
+
492
+ Returns:
493
+ int: list of heating entries
494
+ """
495
+
496
+ state = 0
497
+ heating_plan = []
498
+ hour: int = 0
499
+ for hp in self.power_plan:
500
+ ts: float = hp["Timestamp"]
501
+ fom = hp["FOM"]
502
+ spot = hp["Spot"]
503
+ end_hour: float = self.start_hour + self.heating_hours_per_day
504
+ local_hour: float = timestamp_hour_local(ts, self.timezone)
505
+ schedule_on: bool = is_hour_within_schedule(
506
+ local_hour, self.schedule_start_hour, self.schedule_stop_hour
507
+ )
508
+
509
+ if self.enable_relay(hour, spot, fom, end_hour) and schedule_on:
510
+ state = 1
511
+ else:
512
+ state = 0
513
+ heat = {
514
+ "Unit": self.name,
515
+ "Timestamp": ts,
516
+ "State": state,
517
+ "Schedule": schedule_on,
518
+ "UOI": fom,
519
+ "Spot": spot,
520
+ }
521
+
522
+ self.publish(self.topic_powerplan, json.dumps(heat), 1, False)
523
+ heating_plan.append(heat)
524
+ hour = hour + 1
525
+
526
+ self.info(f"Heating plan of {len(heating_plan)} hours created", "")
527
+ return heating_plan
@@ -0,0 +1,139 @@
1
+ import json
2
+ from typing import Any, Dict, cast
3
+ from typing_extensions import override
4
+
5
+ from masterpiece.mqtt import MqttMsg
6
+ from juham_core import Juham
7
+ from juham_core.timeutils import timestamp
8
+ from juham_core import MasterPieceThread, JuhamThread
9
+
10
+
11
+ class PowerMeterSimulatorThread(MasterPieceThread):
12
+ """Thread simulating Energy Meter."""
13
+
14
+ _power: float = 1000.0 # W
15
+ _power_topic: str = "power"
16
+ _interval: float = 10 # 10 seconds
17
+
18
+ def __init__(self) -> None:
19
+ """Construct a thread for publishing power data.
20
+
21
+ Args:
22
+ topic (str, optional): MQTT topic to post the sensor readings. Defaults to None.
23
+ interval (float, optional): Interval specifying how often the sensor is read. Defaults to 60 seconds.
24
+ """
25
+ super().__init__(None)
26
+ self.current_ts: float = timestamp()
27
+
28
+ @classmethod
29
+ def initialize(cls, power_topic: str, power: float, interval: float) -> None:
30
+ """Initialize thread class attributes.
31
+
32
+ Args:
33
+ power_topic (str): topic to publish the energy meter readings
34
+ power (float): power to be simulated, the default is 1kW
35
+ interval (float): update interval, the default is 10s
36
+ """
37
+ cls._power = power
38
+ cls._interval = interval
39
+ cls._power_topic = power_topic
40
+
41
+ @override
42
+ def update_interval(self) -> float:
43
+ return self._interval
44
+
45
+ def publish_active_power(self, ts: float) -> None:
46
+ """Publish the active power, also known as real power. This is that
47
+ part of the power that can be converted to useful work.
48
+
49
+ Args:
50
+ ts (str): time stamp of the event
51
+
52
+ """
53
+ dt = ts - self.current_ts
54
+ self.current_ts = ts
55
+
56
+ msg = {
57
+ "timestamp": ts,
58
+ "real_a": self._power * dt,
59
+ "real_b": self._power * dt,
60
+ "real_c": self._power * dt,
61
+ "real_total": 3 * self._power * dt,
62
+ }
63
+ self.publish(self._power_topic, json.dumps(msg), 1, True)
64
+
65
+ @override
66
+ def update(self) -> bool:
67
+ super().update()
68
+ self.publish_active_power(timestamp())
69
+ return True
70
+
71
+
72
+ class PowerMeterSimulator(JuhamThread):
73
+ """Simulator energy meter sensor. Spawns a thread
74
+ to simulate Shelly PM mqtt messages"""
75
+
76
+ workerThreadId = PowerMeterSimulatorThread.get_class_id()
77
+ update_interval: float = 10
78
+ power: float = 1000.0
79
+
80
+ _POWERMETERSIMULATOR: str = "_powermetersimulator"
81
+
82
+ def __init__(
83
+ self,
84
+ name: str = "em",
85
+ interval: float = 0,
86
+ ) -> None:
87
+ """Create energy meter simulator.
88
+
89
+ Args:
90
+ name (str, optional): Name of the object. Defaults to 'em'.
91
+ topic (str, optional): MQTT topic to publish the energy meter reports. Defaults to None.
92
+ interval (float, optional): interval between events, in seconds. Defaults to None.
93
+ """
94
+ super().__init__(name)
95
+ self.update_ts: float = 0.0
96
+ if interval > 0.0:
97
+ self.update_interval = interval
98
+ self.power_topic = self.make_topic_name("powerconsumption") # target topic
99
+
100
+ @override
101
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
102
+ if msg.topic == self.power_topic:
103
+ em = json.loads(msg.payload.decode())
104
+ self.on_sensor(em)
105
+ else:
106
+ super().on_message(client, userdata, msg)
107
+
108
+ def on_sensor(self, em: dict[Any, Any]) -> None:
109
+ """Handle data coming from the energy meter.
110
+
111
+ Simply log the event to indicate the presense of simulated device.
112
+ Args:
113
+ em (dict): data from the sensor
114
+ """
115
+ self.debug(f"Simulated power meter sensor {em}")
116
+
117
+ @override
118
+ def run(self) -> None:
119
+ PowerMeterSimulatorThread.initialize(
120
+ self.power_topic, self.power, self.update_interval
121
+ )
122
+ self.worker = cast(
123
+ PowerMeterSimulatorThread,
124
+ Juham.instantiate(PowerMeterSimulatorThread.get_class_id()),
125
+ )
126
+ super().run()
127
+
128
+ @override
129
+ def to_dict(self) -> Dict[str, Any]:
130
+ data: Dict[str, Any] = super().to_dict()
131
+ data[self._POWERMETERSIMULATOR] = {"power_topic": self.power_topic}
132
+ return data
133
+
134
+ @override
135
+ def from_dict(self, data: Dict[str, Any]) -> None:
136
+ super().from_dict(data)
137
+ if self._POWERMETERSIMULATOR in data:
138
+ for key, value in data[self._POWERMETERSIMULATOR].items():
139
+ setattr(self, key, value)