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 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 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 8, 2025
1 parent 1fc6f5c commit 87d47ab
Show file tree
Hide file tree
Showing 11 changed files with 474 additions and 0 deletions.
84 changes: 84 additions & 0 deletions tasmota/berry/modules/cheap_power/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
## 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 `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 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
)
179 changes: 179 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,179 @@
var cheap_power_base = module("cheap_power_base")

cheap_power_base.init = def (m)
import webserver
import json

class CheapPowerBase
var plugin # the data source
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
var past # minimum timer start age (one time slot duration)
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, plugin, past)
self.p_kWh = p_kWh
self.plugin = plugin
self.past = past
self.prices = []
self.times = []
self.tz = 0
end

def start(idx, payload)
if self.start_args(idx) && !self.plugin.start_args(idx, payload)
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

# 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.plugin.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.plugin.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, ch)
var N = size(self.prices)
if N
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
end
return ch
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
ch = self.prune_old(now, ch)
N = size(self.prices)
end
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
60 changes: 60 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,60 @@
var cheap_power = module("cheap_power")

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

class CheapPower
var daystart, zone, host, unit

def start_args(idx, payload)
if !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 nil
elif re.match('^NO[1-5]$', payload)
self.zone = payload
self.host = 'https://www.hvakosterstrommen.no'
self.unit = 'NOK_per_kWh'
return nil
else
tasmota.log(f"CheapPower{idx} {payload}: unrecognized price zone")
end
return true
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 cheap_power_base('øre',CheapPower(), -3600)
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
static URLTIME = '%Y-%m-%dT%H:00:00.000Z'
static MULT = .1255 # conversion to ¢/kWh including 25.5% VAT

def start_args() 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 cheap_power_base('¢', CheapPower(), -3600)
end
return cheap_power
Loading

0 comments on commit 87d47ab

Please sign in to comment.