Skip to content

Commit

Permalink
CheapPower module
Browse files Browse the repository at this point in the history
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_dk_no.tapp: Denmark, Norway (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
dr-m committed Jan 5, 2025
1 parent 2263305 commit dddfe01
Show file tree
Hide file tree
Showing 11 changed files with 483 additions and 0 deletions.
83 changes: 83 additions & 0 deletions tasmota/berry/modules/cheap_power/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
## 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_dk_no.tapp`: Denmark, Norway (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; see https://tasmota.github.io/docs/Timezone-Table/
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_dk_no.tapp` (Denmark, Norway): price zone DK1, DK2, NO1 to NO5
* `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
```
11 changes: 11 additions & 0 deletions tasmota/berry/modules/cheap_power/autoexec.be
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
)
177 changes: 177 additions & 0 deletions tasmota/berry/modules/cheap_power/cheap_power_base.be
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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(p_kWh)
self.p_kWh = p_kWh
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
64 changes: 64 additions & 0 deletions tasmota/berry/modules/cheap_power/dk_no/cheap_power.be
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
var cheap_power = module("cheap_power")

cheap_power.init = def (m)
import re
import string
import cheap_power_base

class CheapPower: classof(cheap_power_base)
var daystart, zone, host, unit

def init() super(self).init('øre') 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('^DK[12]$', payload)
self.zone = payload
self.host = 'https://www.elprisenligenu.dk'
self.unit = 'DKK_per_kWh'
return self.start_ok()
elif re.match('^NO[1-5]$', payload)
self.zone = payload
self.host = 'https://www.hvakosterstrommen.no'
self.unit = 'NOK_per_kWh'
return self.start_ok()
else
tasmota.log(f"CheapPower{idx} {payload}: unrecognized price zone")
end
self.start_failed()
end

def url_string()
return self.host +
tasmota.strftime('/api/v1/prices/%Y/%m-%d_', self.daystart) +
self.zone + '.json'
end

def url(rtc)
var now = rtc['local']
var night = tasmota.time_dump(now)
night = now - night['hour'] * 3600 - night['min'] * 60 - night['sec']
self.daystart = night
return self.url_string()
end

def parse(data, prices, times)
for i: data.keys()
var d = data[i]
prices.push(100.0 * d[self.unit])
var t = tasmota.strptime(d['time_start'], '%Y-%m-%dT%H:%M:%S')
times.push(t['epoch'] - number(string.split(t['unparsed'],':')[0])*3600)
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
34 changes: 34 additions & 0 deletions tasmota/berry/modules/cheap_power/fi/cheap_power.be
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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() 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
Loading

0 comments on commit dddfe01

Please sign in to comment.