Skip to content

Commit e536ffd

Browse files
committed
CheapPower module
This fetches electricity prices and chooses the cheapest future time slots. Currently, the following price data sources have been implemented: * cheap_power_dk_no.tapp: Denmark, Norway (Nord Pool) * cheap_power_elering.tapp: Estonia, Finland, Latvia, Lithuania * cheap_power_fi.tapp: Finland (Nord Pool) * cheap_power_se.tapp: Sweden (Nord Pool); assuming Swedish time zone * cheap_power_uk_octopus.tapp: United Kingdom (Octopus Energy) See cheap_power/README.md for more details. To use: * copy the cheap_power_*.tapp for your data source to the file system * Invoke BrRestart or restart the entire firmware * Invoke the Tasmota command CheapPower1, CheapPower2, … to * download prices for some time into the future * automatically choose the cheapest future time slots (default: 1) * to schedule Power1 ON, Power2 ON, … at the chosen slots * to install a Web UI in the main menu 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 6 buttons: ⏮ moves the first time slot earlier (or wraps from the beginning to the end) ⏭ moves the first time slot later (or wraps from the end to the beginning) ⏯ pauses (switches off) or chooses the optimal slots 🔄 requests the prices to be downloaded and the optimal slots to be chosen ➖ decreases the number of slots (minimum: 1) ➕ increases the number of slots (maximum: currently available price slots) The status output above the buttons may also indicate that the output is paused until further command or price update: ⭘ (0≤1) It may also indicate the start time and the price of the slot: ⭙ (1≤1) 2025-01-28 10:00 5.11 ¢ The first number indicates the number of active or scheduled time slots, and the second number indicates the maximum number of time slots per day (default 1). The scheduled slots are also indicated by red bars in the graph of the available current and future prices.
1 parent 9613004 commit e536ffd

File tree

13 files changed

+636
-0
lines changed

13 files changed

+636
-0
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
## CheapPower Module
2+
3+
This fetches electricity prices and chooses the cheapest future time slots.
4+
Currently, the following price data sources have been implemented:
5+
* `cheap_power_dk_no.tapp`: Denmark, Norway (Nord Pool)
6+
* `cheap_power_elering.tapp`: Estonia, Finland, Latvia, Lithuania (Nord Pool via [Elering](https://elering.ee/en), ex VAT)
7+
* `cheap_power_fi.tapp`: Finland (Nord Pool)
8+
* `cheap_power_se.tapp`: Sweden (Nord Pool); assuming Swedish time zone
9+
* `cheap_power_uk_octopus.tapp`: United Kingdom (Octopus Energy)
10+
11+
### Usage:
12+
13+
* copy the `cheap_power_*.tapp` for your data source to the file system
14+
* Invoke the Tasmota command `BrRestart` or restart the entire firmware
15+
* Invoke the Tasmota command `CheapPower1`, `CheapPower2`, … to
16+
* download prices for some time into the future
17+
* automatically choose the cheapest future time slots (default: 1)
18+
* to schedule `Power1 ON`, `Power2 ON`, … at the chosen slots
19+
* to install a Web UI in the main menu
20+
21+
### Timer Installation:
22+
```
23+
# Europe/Helsinki time zone; see https://tasmota.github.io/docs/Timezone-Table/
24+
Backlog0 Timezone 99; TimeStd 0,0,10,1,4,120; TimeDst 0,0,3,1,3,180
25+
26+
# Detach Switch1 from Power1
27+
Backlog0 SwitchMode1 15; SwitchTopic1 0
28+
Backlog0 WebButton1 boiler; WebButton2 heat
29+
## Power off after 900 seconds (15 minutes)
30+
#PulseTime1 1000
31+
# Power off after 3600 seconds (60 minutes, 1 hour)
32+
PulseTime1 3700
33+
34+
Rule1 ON Clock#Timer DO CheapPower1 ENDON
35+
Timer {"Enable":1,"Mode":0,"Time":"18:00","Window":0,"Days":"1111111","Repeat":1,"Output":1,"Action":3}
36+
Rule1 1
37+
Timers 1
38+
```
39+
The download schedule can be adjusted in the timer configuration menu.
40+
The prices for the next day will typically be updated in the afternoon
41+
or evening of the previous day.
42+
43+
In case the prices cannot be downloaded, the download will be retried
44+
in 1, 2, 4, 8, 16, 32, 64, 64, 64, … minutes until it succeeds.
45+
46+
For controlling my 3×2kW warm water boiler, 1 hour of power
47+
every 24 or 48 hours is usually sufficient.
48+
49+
### Additional Parameters
50+
51+
* `cheap_power_fi.tapp` (Finland): none
52+
* `cheap_power_elering.tapp`: price zone FI, EE, LT, LV
53+
* `cheap_power_dk_no.tapp` (Denmark, Norway): price zone DK1, DK2, NO1 to NO5
54+
* `cheap_power_se.tapp` (Sweden): price zone SE1 to SE4
55+
```
56+
CheapPower1 SE2
57+
```
58+
* `cheap_power_uk.tapp` (United Kingdom): tariff name and price zone:
59+
```
60+
CheapPower AGILE-24-10-01 B
61+
```
62+
63+
### Web User Interface
64+
65+
The user interface in the main menu consists of 6 buttons:
66+
* ⏮ moves the first time slot earlier (or wraps from the beginning to the end)
67+
* ⏭ moves the first time slot later (or wraps from the end to the beginning)
68+
* ⏯ pauses (switches off) or chooses the optimal slots
69+
* 🔄 requests the prices to be downloaded and the optimal slots to be chosen
70+
* ➖ decreases the number of slots (minimum: 1)
71+
* ➕ increases the number of slots (maximum: currently available price slots)
72+
73+
The status output above the buttons may indicate that the output
74+
is paused until further command or price update:
75+
```
76+
⭘ (0≤1)
77+
```
78+
Or it may indicate the start time and the price of the slot:
79+
```
80+
⭙ (1≤1) 2025-01-28 10:00 5.11 ¢
81+
```
82+
The first number indicates the number of active or scheduled time slots,
83+
and the second number indicates the maximum number of time slots per day
84+
(default 1).
85+
86+
The scheduled slots are also indicated by red bars in the graph of the
87+
available current and future prices.
88+
89+
### Application Contents
90+
91+
```bash
92+
for i in */cheap_power.be
93+
do
94+
zip ../cheap_power_${i%/*}.tapp -j -0 autoexec.be cheap_power_base.be "$i"
95+
done
96+
```
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, payload)
4+
import sys
5+
var path = sys.path()
6+
path.push(wd)
7+
import cheap_power
8+
path.pop()
9+
cheap_power.start(idx, payload)
10+
end
11+
)
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
var cheap_power_base = module("cheap_power_base")
2+
3+
cheap_power_base.init = def (m)
4+
5+
# https://en.wikipedia.org/wiki/Binary_heap
6+
def heapify(array, cmp, i)
7+
while true
8+
try
9+
var m = i, child = 2 * i
10+
child += 1
11+
if cmp(array[child], array[m]) m = child end
12+
child += 1
13+
if cmp(array[child], array[m]) m = child end
14+
if m != i
15+
var e = array[i]
16+
array[i] = array[m]
17+
i = m
18+
array[i] = e
19+
continue
20+
end
21+
except .. end
22+
break
23+
end
24+
end
25+
26+
def make_heap(array, cmp)
27+
for i: range(size(array) / 2, 0, -1) heapify(array, cmp, i) end
28+
end
29+
30+
def remove_heap(array, cmp)
31+
var m
32+
try
33+
m = array[0]
34+
try array[0] = array.pop() heapify(array, cmp, 0) except .. end
35+
except .. end
36+
return m
37+
end
38+
39+
import webserver
40+
41+
class CheapPowerBase
42+
var plugin # the data source
43+
var prices # future prices for up to 48 hours
44+
var times # start times of the prices
45+
var timeout# timeout until retrying to update prices
46+
var chosen # the chosen time slots
47+
var slots # the maximum number of time slots to choose
48+
var channel# the channel to control
49+
var tz # the current time zone offset from UTC
50+
var p_kWh # currency unit/kWh
51+
var past # minimum timer start age (one time slot duration)
52+
static PREV = 0, NEXT = 1, PAUSE = 2, UPDATE = 3, LESS = 4, MORE = 5
53+
static UI = "<table style='width:100%'><tr>"
54+
"<td style='width:12.5%'><button onclick='la(\"&op=0\");'>⏮</button></td>"
55+
"<td style='width:12.5%'><button onclick='la(\"&op=1\");'>⏭</button></td>"
56+
"<td style='width:25%'><button onclick='la(\"&op=2\");'>⏯</button></td>"
57+
"<td style='width:25%'><button onclick='la(\"&op=3\");'>🔄</button></td>"
58+
"<td style='width:12.5%'><button onclick='la(\"&op=4\");'>➖</button></td>"
59+
"<td style='width:12.5%'><button onclick='la(\"&op=5\");'>➕</button></td>"
60+
"</tr></table>"
61+
62+
def init(p_kWh, plugin)
63+
self.p_kWh = p_kWh
64+
self.plugin = plugin
65+
self.past = -900
66+
self.slots = 1
67+
self.prices = []
68+
self.times = []
69+
self.tz = 0
70+
end
71+
72+
def start(idx, payload)
73+
if self.start_args(idx) && !self.plugin.start_args(idx, payload)
74+
self.start_ok()
75+
end
76+
end
77+
78+
def start_args(idx)
79+
if !idx || idx < 1 || idx > tasmota.global.devices_present
80+
tasmota.log(f"CheapPower{idx} is not a valid Power output")
81+
return self.start_failed()
82+
else
83+
self.channel = idx - 1
84+
return true
85+
end
86+
end
87+
88+
def start_ok()
89+
tasmota.add_driver(self)
90+
tasmota.set_timer(0, /->self.update())
91+
tasmota.resp_cmnd_done()
92+
end
93+
94+
def start_failed() tasmota.resp_cmnd_failed() return nil end
95+
96+
def power(on) tasmota.set_power(self.channel, on) end
97+
98+
# fetch the prices for the next 0 to 48 hours from now
99+
def update()
100+
var rtc = tasmota.rtc()
101+
self.tz = rtc['timezone'] * 60
102+
var url = self.plugin.url(rtc)
103+
if !url return end
104+
var wc = webclient()
105+
var prices = [], times = []
106+
while true
107+
wc.begin(url)
108+
var rc = wc.GET()
109+
var data = rc == 200 ? wc.get_string() : nil
110+
wc.close()
111+
if data == nil
112+
print(f'error {rc} for {url}')
113+
break
114+
else
115+
url = self.plugin.parse(data, prices, times)
116+
if url continue end
117+
if size(prices)
118+
try self.past = times[0] - times[1] except .. end
119+
self.timeout = nil
120+
self.prices = prices
121+
self.times = times
122+
self.prune_old(rtc['utc'])
123+
self.schedule_chosen(self.find_cheapest(), rtc['utc'], self.past)
124+
return
125+
end
126+
end
127+
break
128+
end
129+
# We failed to update the prices. Retry in 1, 2, 4, 8, …, 64 minutes.
130+
if !self.timeout
131+
self.timeout = 60000
132+
elif self.timeout < 3840000
133+
self.timeout = self.timeout * 2
134+
end
135+
tasmota.set_timer(self.timeout, /->self.update())
136+
end
137+
138+
def prune_old(now, ch)
139+
var N = size(self.prices)
140+
while N
141+
if self.times[0] - now > self.past break end
142+
self.prices.pop(0)
143+
self.times.pop(0)
144+
end
145+
var M = size(self.prices)
146+
if M && ch
147+
N -= M
148+
try while ch[0] < N ch.pop(0) end except .. end
149+
if size(ch)
150+
for i: 0..size(ch)-1 ch[i] -= N end
151+
else
152+
ch = nil
153+
end
154+
else
155+
ch = nil
156+
end
157+
return M
158+
end
159+
160+
# determine the cheapest slots by constructing a binary heap
161+
def find_cheapest(first)
162+
var cheapest, N = size(self.prices)
163+
if N
164+
var heap = []
165+
for i: (first == nil ? 0 : first + 1)..N-1 heap.push(i) end
166+
var cmp = / a b -> self.prices[a] < self.prices[b]
167+
make_heap(heap, cmp)
168+
var slots = size(heap)
169+
if slots > self.slots slots = self.slots end
170+
if first != nil slots -= 1 end
171+
# Pick slots, cheapest first
172+
cheapest = []
173+
for i: 1..slots cheapest.push(remove_heap(heap, cmp)) end
174+
# Order the N slots by time
175+
heap = cheapest
176+
cheapest = []
177+
if first != nil cheapest.push(first) end
178+
cmp = / a b -> a < b
179+
make_heap(heap, cmp)
180+
for i: 1..slots cheapest.push(remove_heap(heap, cmp)) end
181+
end
182+
return cheapest
183+
end
184+
185+
# trigger the timer at the chosen hour
186+
def schedule_chosen(chosen, now, old)
187+
tasmota.remove_timer('power_on')
188+
var d = chosen && size(chosen) ? self.times[chosen[0]] - now : self.past
189+
if d != old self.power(d > self.past && d <= 0) end
190+
if d > 0
191+
tasmota.set_timer(d * 1000, def() self.power(true) end, 'power_on')
192+
elif d <= self.past
193+
if chosen==nil || size(chosen) < 2 chosen = nil else chosen.pop(0) end
194+
end
195+
self.chosen = chosen
196+
end
197+
198+
def web_add_main_button() webserver.content_send(self.UI) end
199+
200+
def web_sensor()
201+
var ch, old = self.past, now = tasmota.rtc()['utc']
202+
var N = size(self.prices)
203+
if N
204+
ch = []
205+
try
206+
for i: self.chosen ch.push(i) end
207+
old = self.times[ch[0]] - now
208+
except .. end
209+
N = self.prune_old(now, ch)
210+
end
211+
var op = webserver.has_arg('op') ? int(webserver.arg('op')) : nil
212+
if op == self.UPDATE
213+
self.update()
214+
ch = self.chosen
215+
end
216+
while N
217+
var first
218+
if op == self.MORE
219+
if self.slots < N self.slots += 1 end
220+
elif op == self.LESS
221+
if self.slots > 1 self.slots -= 1 end
222+
elif op == self.PAUSE
223+
if self.chosen != nil ch = nil break end
224+
elif op == self.PREV
225+
try first = (!ch[0] ? N : ch[0]) - 1 except .. end
226+
elif op == self.NEXT
227+
try first = ch[0] + 1 < N ? ch[0] + 1 : 0 except .. end
228+
else break end
229+
ch = self.find_cheapest(first)
230+
break
231+
end
232+
self.schedule_chosen(ch, now, old)
233+
var status = size(ch)
234+
? format("{s}⭙ (%d≤%d) %s{m}%.3g %s{e}", size(ch), self.slots,
235+
tasmota.strftime("%Y-%m-%d %H:%M", self.tz + self.times[ch[0]]),
236+
self.prices[ch[0]], self.p_kWh)
237+
: format("{s}⭘ (0≤%d){m}{e}", self.slots)
238+
status+="<tr><td colspan='2'>"
239+
"<svg width='100%' height='4ex' viewBox='-1 -1 1052 102'>"
240+
if N
241+
var w = 1050.0 / size(self.prices)
242+
var min = self.prices[0], max = min
243+
for p : self.prices if p < min min = p elif p > max max = p end end
244+
var scale = 100.0 / (max - min)
245+
try
246+
for choice: ch
247+
status+=format("<rect x='%g' width='%g' height='100'"
248+
" fill='red'></rect>", w * choice, w)
249+
end
250+
except .. end
251+
status+="<path d='"
252+
var fmt="M0 %gh%g"
253+
for p: self.prices
254+
status+=format(fmt, 100 - scale * (p - min), w)
255+
fmt="V%gh%g"
256+
end
257+
status+="' fill='transparent' stroke='white' stroke-width='2'></path>"
258+
end
259+
status+="</svg>{e}"
260+
tasmota.web_send_decimal(status)
261+
end
262+
end
263+
return CheapPowerBase
264+
end
265+
return cheap_power_base

0 commit comments

Comments
 (0)