diff --git a/tasmota/berry/modules/cheap_power.tapp b/tasmota/berry/modules/cheap_power.tapp new file mode 100644 index 000000000000..2063d489a34b Binary files /dev/null and b/tasmota/berry/modules/cheap_power.tapp differ diff --git a/tasmota/berry/modules/cheap_power/autoexec.be b/tasmota/berry/modules/cheap_power/autoexec.be new file mode 100644 index 000000000000..63e71dd11db2 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/autoexec.be @@ -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 +) diff --git a/tasmota/berry/modules/cheap_power/cheap_power.be b/tasmota/berry/modules/cheap_power/cheap_power.be new file mode 100644 index 000000000000..42b4c531dec7 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/cheap_power.be @@ -0,0 +1,207 @@ +import webserver +import json +import re + +var cheap_power = module("cheap_power") + +cheap_power.init = def (m) +class CheapPower + 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_url # base URL for fetching price data + var p_zone # price zone (FI, SE1, SE2, …) + var p_kWh # currency unit/kWh + static var PAST = -3600 # minimum timer start age + static var FI_MULT = .1255 # conversion to ¢/kWh including 25.5% FI VAT + static var PREV = 0, PAUSE = 1, UPDATE = 2, NEXT = 3 + static var UI = "" + "" + "" + "" + "" + "
" + static var URLTIME = '%Y-%m-%dT%H:00:00.000Z' + static var URLDATE = '%Y-%m-%d' + + def init() + self.prices = [] + self.times = [] + end + + def start(idx, payload) + if !idx || idx < 1 || idx > tasmota.global.devices_present + tasmota.log(f"CheapPower{idx} is not a valid Power output") + tasmota.resp_cmnd_failed() + return + end + self.p_url = nil + if !payload + tasmota.log(f"CheapPower{idx}: a price zone name is expected") + elif payload == 'FI' + self.p_url = 'https://sahkotin.fi/prices?start=' + self.p_kWh = '¢' + elif re.match('^SE[1-4]$', payload) + self.p_url = 'https://mgrey.se/espot?format=json&domain=' + payload + + '&date=' + self.p_kWh = 'öre' + else + tasmota.log(f"CheapPower{idx} {payload}: unrecognized price zone") + end + if !self.p_url + tasmota.resp_cmnd_failed() + return + end + self.channel = idx - 1 + self.p_zone = payload + tasmota.add_driver(self) + tasmota.set_timer(0, /->self.update()) + tasmota.resp_cmnd_done() + 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 wc = webclient() + var rtc = tasmota.rtc() + self.tz = rtc['timezone'] * 60 + var now = rtc['utc'] + var daystart + var url + if self.p_zone + if self.p_zone == 'FI' + url = self.p_url + + tasmota.strftime(self.URLTIME, now) + '&end=' + + tasmota.strftime(self.URLTIME, now + 172800) + else + var date = tasmota.strftime(self.URLDATE, rtc['local']) + url = self.p_url + date + # This assumes that the served data is at rtc['local'] time. + daystart = tasmota.time_dump(rtc['local']) + daystart = rtc['utc'] - + daystart['hour'] * 3600 - daystart['min'] * 60 - daystart['sec'] + end + end + if !url + print('unknown price zone') + return + end + 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}') + else + data = json.load(data) + end + var prices = [], times = [] + if !data + elif daystart + data = data.find(self.p_zone) + if data + for i: data.keys() + var datum = data[i] + prices.push(datum['price_sek']) + times.push(datum['hour'] * 3600 + daystart) + end + end + else + data = data.find('prices') + if data + for i: data.keys() + var datum = data[i] + prices.push(self.FI_MULT * datum['value']) + times.push(tasmota.strptime(datum['date'], + '%Y-%m-%dT%H:%M:%S.000Z')['epoch']) + end + end + end + if data + self.timeout = nil + self.prices = prices + self.times = times + self.schedule_chosen(self.find_cheapest(), now, self.PAST) + return + 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 + + # 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 + 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 + 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 CheapPower() +end +return cheap_power