-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This fetches electricity prices and chooses the cheapest future time slot. Currently, the following price data sources have been implemented: * cheap_power_fi.tapp: Finland (Nord Pool) * cheap_power_se.tapp: Sweden (Nord Pool); assuming Swedish time zone * cheap_power_uk.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 the Tasmota command `CheapPower1`, `CheapPower2`, … to * download prices for some time into the future * 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 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 ¢ For controlling my 3×2kW warm water boiler, 1 hour of power every 24 or 48 hours is usually sufficient.
- Loading branch information
Showing
9 changed files
with
428 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
## CheapPower Module | ||
|
||
This fetches electricity prices and chooses the cheapest future time slot. | ||
Currently, the following price data sources have been implemented: | ||
* `cheap_power_fi.tapp`: Finland (Nord Pool) | ||
* `cheap_power_se.tapp`: Sweden (Nord Pool); assuming Swedish time zone | ||
* `cheap_power_uk.tapp`: United Kingdom (Octopus Energy) | ||
|
||
### Usage: | ||
|
||
* copy the `cheap_power_*.tapp` for your data source to the file system | ||
* Invoke the Tasmota command `CheapPower1`, `CheapPower2`, … to | ||
* download prices for some time into the future | ||
* 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 | ||
|
||
### Timer Installation: | ||
``` | ||
# Europe/Helsinki time zone | ||
Backlog0 Timezone 99; TimeStd 0,0,10,1,4,120; TimeDst 0,0,3,1,3,180 | ||
# Detach Switch1 from Power1 | ||
Backlog0 SwitchMode1 15; SwitchTopic1 0 | ||
Backlog0 WebButton1 boiler; WebButton2 heat | ||
# Power off after 3600 seconds (60 minutes, 1 hour) | ||
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. | ||
|
||
For controlling my 3×2kW warm water boiler, 1 hour of power | ||
every 24 or 48 hours is usually sufficient. | ||
|
||
### Additional Parameters | ||
|
||
* `cheap_power_fi.tapp` (Finland): none | ||
* `cheap_power_se.tapp` (Sweden): price zone SE1 to SE4: | ||
``` | ||
CheapPower1 SE2 | ||
``` | ||
* `cheap_power_uk.tapp` (United Kingdom): tariff name and price zone: | ||
``` | ||
CheapPower AGILE-24-10-01 B | ||
``` | ||
|
||
### Web User Interface | ||
|
||
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 ¢ | ||
``` | ||
|
||
### Application Contents | ||
|
||
```bash | ||
for i in */cheap_power.be | ||
do | ||
zip ../cheap_power_${i%/*}.tapp -j -0 autoexec.be cheap_power_base.be "$i" | ||
done | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
var wd = tasmota.wd | ||
tasmota.add_cmd("CheapPower", | ||
def (cmd, idx, payload) | ||
import sys | ||
var path = sys.path() | ||
path.push(wd) | ||
import cheap_power | ||
path.pop() | ||
cheap_power.start(idx, payload) | ||
end | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
var cheap_power_base = module("cheap_power_base") | ||
|
||
cheap_power_base.init = def (m) | ||
import webserver | ||
import json | ||
|
||
class CheapPowerBase | ||
var prices # future prices for up to 48 hours | ||
var times # start times of the prices | ||
var timeout# timeout until retrying to update prices | ||
var chosen # the chosen time slot | ||
var channel# the channel to control | ||
var tz # the current time zone offset from UTC | ||
var p_kWh # currency unit/kWh | ||
static PAST = -3600 # minimum timer start age | ||
static PREV = 0, PAUSE = 1, UPDATE = 2, NEXT = 3 | ||
static UI = "<table style='width:100%'><tr>" | ||
"<td style='width:25%'><button onclick='la(\"&op=0\");'>⏮</button></td>" | ||
"<td style='width:25%'><button onclick='la(\"&op=1\");'>⏯</button></td>" | ||
"<td style='width:25%'><button onclick='la(\"&op=2\");'>🔄</button></td>" | ||
"<td style='width:25%'><button onclick='la(\"&op=3\");'>⏭</button></td>" | ||
"</tr></table>" | ||
|
||
def init() | ||
self.prices = [] | ||
self.times = [] | ||
self.tz = 0 | ||
end | ||
|
||
def start(idx) if self.start_args(idx) self.start_ok() end end | ||
|
||
def start_args(idx) | ||
if !idx || idx < 1 || idx > tasmota.global.devices_present | ||
tasmota.log(f"CheapPower{idx} is not a valid Power output") | ||
return self.start_failed() | ||
else | ||
self.channel = idx - 1 | ||
return true | ||
end | ||
end | ||
|
||
def start_ok() | ||
tasmota.add_driver(self) | ||
tasmota.set_timer(0, /->self.update()) | ||
tasmota.resp_cmnd_done() | ||
end | ||
|
||
def start_failed() tasmota.resp_cmnd_failed() return nil end | ||
|
||
def power(on) tasmota.set_power(self.channel, on) end | ||
|
||
def url(rtc) print('unknown price zone') return nil end | ||
def parse(data, prices, times) return self.url() end | ||
|
||
# fetch the prices for the next 0 to 48 hours from now | ||
def update() | ||
var rtc = tasmota.rtc() | ||
self.tz = rtc['timezone'] * 60 | ||
var url = self.url(rtc) | ||
if !url return end | ||
var wc = webclient() | ||
var prices = [], times = [] | ||
while true | ||
wc.begin(url) | ||
var rc = wc.GET() | ||
var data = rc == 200 ? wc.get_string() : nil | ||
wc.close() | ||
if data == nil | ||
print(f'error {rc} for {url}') | ||
break | ||
else | ||
data = json.load(data) | ||
end | ||
|
||
if data != nil | ||
url = self.parse(data, prices, times) | ||
if url continue end | ||
if size(prices) | ||
self.timeout = nil | ||
self.prices = prices | ||
self.times = times | ||
self.prune_old(rtc['utc']) | ||
self.schedule_chosen(self.find_cheapest(), rtc['utc'], self.PAST) | ||
return | ||
end | ||
end | ||
break | ||
end | ||
# We failed to update the prices. Retry in 1, 2, 4, 8, …, 64 minutes. | ||
if !self.timeout | ||
self.timeout = 60000 | ||
elif self.timeout < 3840000 | ||
self.timeout = self.timeout * 2 | ||
end | ||
tasmota.set_timer(self.timeout, /->self.update()) | ||
end | ||
|
||
def prune_old(now) | ||
var N = size(self.prices) | ||
if N | ||
var ch = self.chosen | ||
while N | ||
if self.date_from_now(0, now) > self.PAST break end | ||
ch = ch ? ch - 1 : nil | ||
self.prices.pop(0) | ||
self.times.pop(0) | ||
N -= 1 | ||
end | ||
self.chosen = ch | ||
end | ||
return N | ||
end | ||
|
||
# determine the cheapest slot | ||
def find_cheapest() | ||
var cheapest, N = size(self.prices) | ||
if N | ||
cheapest = 0 | ||
for i: 1..N-1 | ||
if self.prices[i] < self.prices[cheapest] cheapest = i end | ||
end | ||
end | ||
return cheapest | ||
end | ||
|
||
def date_from_now(chosen, now) return self.times[chosen] - now end | ||
|
||
# trigger the timer at the chosen hour | ||
def schedule_chosen(chosen, now, old) | ||
tasmota.remove_timer('power_on') | ||
var d = chosen == nil ? self.PAST : self.date_from_now(chosen, now) | ||
if d != old self.power(d > self.PAST && d <= 0) end | ||
if d > 0 | ||
tasmota.set_timer(d * 1000, def() self.power(true) end, 'power_on') | ||
elif d <= self.PAST | ||
chosen = nil | ||
end | ||
self.chosen = chosen | ||
end | ||
|
||
def web_add_main_button() webserver.content_send(self.UI) end | ||
|
||
def web_sensor() | ||
var ch, old = self.PAST, now = tasmota.rtc()['utc'] | ||
var N = size(self.prices) | ||
if N | ||
ch = self.chosen | ||
if ch != nil && ch < N old = self.date_from_now(ch, now) end | ||
N = self.prune_old(now) | ||
end | ||
size(self.prices) | ||
var op = webserver.has_arg('op') ? int(webserver.arg('op')) : nil | ||
if op == self.UPDATE | ||
self.update() | ||
ch = self.chosen | ||
end | ||
if !N | ||
elif op == self.PAUSE | ||
ch = ch == nil ? self.find_cheapest() : nil | ||
elif op == self.PREV | ||
ch = (!ch ? N : ch) - 1 | ||
elif op == self.NEXT | ||
ch = ch != nil && ch + 1 < N ? ch + 1 : 0 | ||
end | ||
self.schedule_chosen(ch, now, old) | ||
var status = ch == nil | ||
? '{s}⭘{m}{e}' | ||
: format('{s}⭙ %s{m}%.3g %s{e}', | ||
tasmota.strftime('%Y-%m-%d %H:%M', self.tz + self.times[ch]), | ||
self.prices[ch], self.p_kWh) | ||
tasmota.web_send_decimal(status) | ||
end | ||
end | ||
return CheapPowerBase() | ||
end | ||
return cheap_power_base |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
var cheap_power = module("cheap_power") | ||
|
||
cheap_power.init = def (m) | ||
import cheap_power_base | ||
|
||
class CheapPower: classof(cheap_power_base) | ||
static URLTIME = '%Y-%m-%dT%H:00:00.000Z' | ||
static MULT = .1255 # conversion to ¢/kWh including 25.5% VAT | ||
|
||
def init() | ||
self.p_kWh = '¢' | ||
super(self).init() | ||
end | ||
|
||
def url(rtc) | ||
var now = rtc['utc'] | ||
return 'https://sahkotin.fi/prices?start=' + | ||
tasmota.strftime(self.URLTIME, now) + '&end=' + | ||
tasmota.strftime(self.URLTIME, now + 172800) | ||
end | ||
|
||
def parse(data, prices, times) | ||
var d = data.find('prices') | ||
if d | ||
for i: d.keys() | ||
var datum = d[i] | ||
prices.push(self.MULT * datum['value']) | ||
times.push(tasmota.strptime(datum['date'], | ||
'%Y-%m-%dT%H:%M:%S.000Z')['epoch']) | ||
end | ||
end | ||
return nil | ||
end | ||
end | ||
return CheapPower() | ||
end | ||
return cheap_power |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
var cheap_power = module("cheap_power") | ||
|
||
cheap_power.init = def (m) | ||
import re | ||
import cheap_power_base | ||
|
||
class CheapPower: classof(cheap_power_base) | ||
var daystart | ||
var zone | ||
static URLDATE = '%Y-%m-%d' | ||
|
||
def init() | ||
self.p_kWh = 'öre' | ||
super(self).init() | ||
end | ||
|
||
def start(idx, payload) | ||
if !self.start_args(idx) | ||
return | ||
elif !payload | ||
tasmota.log(f"CheapPower{idx}: a price zone name is expected") | ||
elif !re.match('^SE[1-4]$', payload) | ||
tasmota.log(f"CheapPower{idx} {payload}: unrecognized price zone") | ||
else | ||
self.zone = payload | ||
return self.start_ok() | ||
end | ||
self.start_failed() | ||
end | ||
|
||
def url_string() | ||
return 'https://mgrey.se/espot?format=json&domain=' + self.zone + | ||
'&date=' + tasmota.strftime(self.URLDATE, self.daystart) | ||
end | ||
|
||
def url(rtc) | ||
var now = rtc['local'] | ||
var daystart = tasmota.time_dump(now) | ||
daystart = rtc['utc'] - | ||
daystart['hour'] * 3600 - daystart['min'] * 60 - daystart['sec'] | ||
self.daystart = daystart | ||
return self.url_string() | ||
end | ||
|
||
def parse(data, prices, times) | ||
var d = data.find(self.zone) | ||
if d | ||
for i: d.keys() | ||
var datum = d[i] | ||
prices.push(datum['price_sek']) | ||
times.push(datum['hour'] * 3600 + self.daystart) | ||
end | ||
end | ||
if self.daystart < tasmota.rtc()['utc'] | ||
self.daystart += 86400 | ||
return self.url_string() | ||
end | ||
return nil | ||
end | ||
end | ||
return CheapPower() | ||
end | ||
return cheap_power |
Oops, something went wrong.