Skip to content

Commit 5b56eb9

Browse files
committed
CheapPower module
This fetches electricity prices and chooses the cheapest future time slot. Currently, the only data source is the Nordpool prices for Finland, as provided by ENTSO-E and https://sahkotin.fi. To use: * copy cheap_power.tapp to the file system * Invoke the Tasmota command CheapPower1, CheapPower2, … to * download prices for the next 24 to 48 hours * automatically choose the cheapest future time slot * to schedule Power1 ON, Power2 ON, … at the chosen slot * to install a Web UI in the main menu * For a full installation, you will want something like the following: ``` Backlog0 Timezone 99; TimeStd 0,0,10,1,4,120; TimeDst 0,0,3,1,3,180 Backlog0 SwitchMode1 15; SwitchTopic1 0 Backlog0 WebButton1 boiler; WebButton2 heat PulseTime1 3700 Rule1 ON Clock#Timer DO CheapPower1 ENDON Timer {"Enable":1,"Mode":0,"Time":"18:00","Window":0,"Days":"1111111","Repeat":1,"Output":1,"Action":3} Rule1 1 Timers 1 ``` The download schedule can be adjusted in the timer configuration menu. The prices for the next day will typically be updated in the afternoon or evening of the previous day. In case the prices cannot be downloaded, the download will be retried in 1, 2, 4, 8, 16, 32, 64, 64, 64, … minutes until it succeeds. The user interface in the main menu consists of 4 buttons: ⏮ moves to the previous time slot (or wraps from the first to the last) ⏯ pauses (switches off) or chooses the optimal slot 🔄 requests the prices to be downloaded and the optimal slot to be chosen ⏭ moves to the next time slot (or wraps from the last to the first) The status output above the buttons may also indicate that the output is paused until further command or price update: ⭘ It may also indicate the start time and the price of the slot: ⭙ 2024-11-22 21:00 12.8 ¢ I am using this for controlling a 3×2kW warm water boiler. For my usage, 1 hour every 24 or 48 hours is sufficient.
1 parent bf872de commit 5b56eb9

File tree

3 files changed

+165
-0
lines changed

3 files changed

+165
-0
lines changed
4.18 KB
Binary file not shown.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
var wd = tasmota.wd
2+
tasmota.add_cmd("CheapPower",
3+
def (cmd, idx)
4+
import sys
5+
var path = sys.path()
6+
path.push(wd)
7+
import cheap_power
8+
path.pop()
9+
cheap_power.start(idx)
10+
end
11+
)
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import webserver
2+
import json
3+
4+
var cheap_power = module("cheap_power")
5+
6+
cheap_power.init = def (m)
7+
class CheapPower
8+
var prices # future prices for up to 48 hours
9+
var times # start times of the prices
10+
var timeout# timeout until retrying to update prices
11+
var chosen # the chosen time slot
12+
var channel# the channel to control
13+
var tz # the current time zone offset from UTC
14+
static var PAST = -3600 # minimum timer start age
15+
static var MULT = .1255 # conversion to ¢/kWh including 25.5% VAT
16+
static var PREV = 0, PAUSE = 1, UPDATE = 2, NEXT= 3
17+
static var UI = "<table style='width:100%'><tr>"
18+
"<td style='width:25%'><button onclick='la(\"&op=0\");'>⏮</button></td>"
19+
"<td style='width:25%'><button onclick='la(\"&op=1\");'>⏯</button></td>"
20+
"<td style='width:25%'><button onclick='la(\"&op=2\");'>🔄</button></td>"
21+
"<td style='width:25%'><button onclick='la(\"&op=3\");'>⏭</button></td>"
22+
"</tr></table>"
23+
static var URL0 = 'http://sahkotin.fi/prices?start=', URL1 = '&end='
24+
static var URLTIME = '%Y-%m-%dT%H:00:00.000Z'
25+
26+
def init()
27+
self.prices = []
28+
self.times = []
29+
end
30+
31+
def start(idx)
32+
if idx == nil || idx < 1 || idx > tasmota.global.devices_present
33+
tasmota.log(f"CheapPower{idx} is not a valid Power output")
34+
tasmota.resp_cmnd_failed()
35+
else
36+
self.channel = idx - 1
37+
tasmota.add_driver(self)
38+
self.update()
39+
tasmota.resp_cmnd_done()
40+
end
41+
end
42+
43+
def power(on) tasmota.set_power(self.channel, on) end
44+
45+
# fetch the prices for the next 24 to 48 hours
46+
def update()
47+
var wc = webclient()
48+
var rtc = tasmota.rtc()
49+
self.tz = rtc['timezone'] * 60
50+
var now = rtc['utc']
51+
var url = self.URL0 +
52+
tasmota.strftime(self.URLTIME, now) + self.URL1 +
53+
tasmota.strftime(self.URLTIME, now + 172800)
54+
wc.begin(url)
55+
var rc = wc.GET()
56+
if rc == 200
57+
var data = json.load(wc.get_string())
58+
wc.close()
59+
if data data = data.find('prices') end
60+
var prices = [], times = []
61+
if data
62+
for i: data.keys()
63+
var datum = data[i]
64+
prices.push(self.MULT * datum['value'])
65+
times.push(tasmota.strptime(datum['date'],
66+
'%Y-%m-%dT%H:%M:%S.000Z')['epoch'])
67+
end
68+
self.timeout = nil
69+
self.prices = prices
70+
self.times = times
71+
self.schedule_chosen(self.find_cheapest(), now, self.PAST)
72+
return
73+
end
74+
else
75+
wc.close()
76+
print(f'error {rc} for {url}')
77+
end
78+
# We failed to update the prices. Retry in 1, 2, 4, 8, …, 64 minutes.
79+
if !self.timeout
80+
self.timeout = 60000
81+
elif self.timeout < 3840000
82+
self.timeout = self.timeout * 2
83+
end
84+
tasmota.set_timer(self.timeout, /->self.update())
85+
end
86+
87+
# determine the cheapest slot
88+
def find_cheapest()
89+
var cheapest, N = size(self.prices)
90+
if N
91+
cheapest = 0
92+
for i: 1..N-1
93+
if self.prices[i] < self.prices[cheapest] cheapest = i end
94+
end
95+
end
96+
return cheapest
97+
end
98+
99+
def date_from_now(chosen, now) return self.times[chosen] - now end
100+
101+
# trigger the timer at the chosen hour
102+
def schedule_chosen(chosen, now, old)
103+
tasmota.remove_timer('power_on')
104+
var d = chosen == nil ? self.PAST : self.date_from_now(chosen, now)
105+
if d != old self.power(d > self.PAST && d <= 0) end
106+
if d > 0
107+
tasmota.set_timer(d * 1000, def() self.power(true) end, 'power_on')
108+
elif d <= self.PAST
109+
chosen = nil
110+
end
111+
self.chosen = chosen
112+
end
113+
114+
def web_add_main_button() webserver.content_send(self.UI) end
115+
116+
def web_sensor()
117+
var ch, old = self.PAST, now = tasmota.rtc()['utc']
118+
var N = size(self.prices)
119+
if N
120+
ch = self.chosen
121+
if ch != nil && ch < N old = self.date_from_now(ch, now) end
122+
while N
123+
if self.date_from_now(0, now) > self.PAST break end
124+
ch = ch ? ch - 1 : nil
125+
self.prices.pop(0)
126+
self.times.pop(0)
127+
N -= 1
128+
end
129+
end
130+
var op = webserver.has_arg('op') ? int(webserver.arg('op')) : nil
131+
if op == self.UPDATE
132+
self.update()
133+
ch = self.chosen
134+
end
135+
if !N
136+
elif op == self.PAUSE
137+
ch = ch == nil ? self.find_cheapest() : nil
138+
elif op == self.PREV
139+
ch = (!ch ? N : ch) - 1
140+
elif op == self.NEXT
141+
ch = ch != nil && ch + 1 < N ? ch + 1 : 0
142+
end
143+
self.schedule_chosen(ch, now, old)
144+
var status = ch == nil
145+
? '{s}⭘{m}{e}'
146+
: format('{s}⭙ %s{m}%.3g ¢{e}',
147+
tasmota.strftime('%Y-%m-%d %H:%M', self.tz + self.times[ch]),
148+
self.prices[ch])
149+
tasmota.web_send_decimal(status)
150+
end
151+
end
152+
return CheapPower()
153+
end
154+
return cheap_power

0 commit comments

Comments
 (0)