From f8232ba2c4f9d9b45a2f3b499751ea8dc5a44b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20M=C3=A4kel=C3=A4?= Date: Tue, 24 Dec 2024 14:51:07 +0200 Subject: [PATCH] CheapPower module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fetches electricity prices and chooses the cheapest future time slot. Currently, the only data sources are the Nordpool prices, as provided by ENTSO-E and https://sahkotin.fi (FI) and https://mgrey.se/espot (price zones SE1 through SE4). To use: * copy cheap_power.tapp to the file system * Invoke the Tasmota command CheapPower1 FI, CheapPower2 SE2, … to * download prices for the next 24 to 48 hours * 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 * For a full installation, you will want something like the following: ``` Backlog0 Timezone 99; TimeStd 0,0,10,1,4,120; TimeDst 0,0,3,1,3,180 Backlog0 SwitchMode1 15; SwitchTopic1 0 Backlog0 WebButton1 boiler; WebButton2 heat PulseTime1 3700 Rule1 ON Clock#Timer DO CheapPower1 FI 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. For the SE data source, prices are currently fetched only for one day (the current day) at a time and they are assumed to be in the local time zone. 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 ¢ I am using this for controlling a 3×2kW warm water boiler. For my usage, 1 hour every 24 or 48 hours is sufficient. --- tasmota/berry/modules/cheap_power.tapp | Bin 0 -> 6622 bytes tasmota/berry/modules/cheap_power/autoexec.be | 11 + .../berry/modules/cheap_power/cheap_power.be | 207 ++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 tasmota/berry/modules/cheap_power.tapp create mode 100644 tasmota/berry/modules/cheap_power/autoexec.be create mode 100644 tasmota/berry/modules/cheap_power/cheap_power.be diff --git a/tasmota/berry/modules/cheap_power.tapp b/tasmota/berry/modules/cheap_power.tapp new file mode 100644 index 0000000000000000000000000000000000000000..2063d489a34b10cdc1e78058bcb94ed6f1a160b8 GIT binary patch literal 6622 zcmcIp&2Jn@70+f_4hC_C3#vGbyC)g9$957T<8c%P2dsR^#P%-1YewzvnwiEu-L3BG zOdPKzR^rZuLpUK;D=ui^#+~Iq;Knt70T(2W`+KjddTPcFTF^4P&U9D3_v(Fr)a*Wb z_iGDR==06&_49xIJ^S}R@JxT-z+YIaT)vc1a4cVhrI^H`CsbICa}@@Yc)>n~aomr_ zvA_6WB*S7ipU85tvmit)2g1ijM+5H z;DBcxnAdzD)X3sGfO)O1GYB3;ov9=hrBvlGZ8{{+;8RtSXQZ6ZKcRC(l0|79lPqhugWE0fa7VmfxBqLZGNhW>|(OL@`jez@5Ffw%SM*MHhv(Lh_9D=Pk9$*o#) zfBs6yt!Z-q{M~PU^_9?Dx9oj(Ik^=m65^AdSp$yez+?A!o{*Wm*7N1oc)1ntw|>0Q z?c)ES+wK0$o3q)o$HuG;yte=l${LhFO!BK_ItWZ^=!wH4wjwX;d|=in0~+MZQzY{A zKwLxO6d!#=NnC7-Rr|Qrl!9TJABSlW%NLZJ`b8;`5-9lTV~a$Z5B-5tA!n0jG;*)!qN)hW_&)SU=iL_p2qK5qr za&B7~MDzkw@0^m(MUV<|CSNMib=v}6K(l-T=_qMfE?HQrd6NjbL5G8sjvzKMMjNG~ z9V;^4++3qxVGnDE9_fMf=pBh0;!d}riz6*ats+mSB%&LKr|ka?Bq};T!|;w>c!^`! zS)2VEUU07us8T#Cy1dW*15RR2eOZ~VW7ru)(<;y`~0Jf}HZb-t%jCBt^>Pp!u zxJz1gcF->wZ$Us#5h`ijM6j!qA#8r9q+tC-Q0{=MdUbXJ8)i-_@hq#Cl!yt9HNzzH zK}UnJm$Umv!H>509p9%8lDrq2XsFQS-9wxNL&PS`h~~_1i;bqtU?)n^6&rl8s?n!~ z$Z|aJWLctLJu9Pgj@dKta&DM*9LO9`$vU7B%BKa*vsDQ3?iBnE+qMGFrudV>2#Za_ zwZtmg7|WbNU#Lu3tb?QIQ!HC88c`baQm>tCuz10VJgtnxWT?jH(FbFw0oB7q$GqOv zyz0RYyY=|c1DycrXYMkZ&BqnDi z+xFieosiUMd}<`iDep59+jI*zD>!8tEPMfphU_o%X?jKKEn81ET6?;;Y#(~E$fJ>a zymcr?b<;vjIx}aoG{h3kimq$x6+~x7FBktUyA+A<% zJm!$1#}(LRd#VFWe_dVhIKW&4G|uaHZ#Da-Nt>e1z}^sRe1J7Y;{;8c;eub@aS$$u zTt|^tvV<_wOz2vZc08vRU{1w+G^G=(bBP#=AGC%5KUWZuCS~{ zOF7P8NYgud+~@W4%p@Xijv%@@1eFJa3@Q)v%U2E?`uXi5vS}!tiEBMN-RPO_%=}nv zIi!}B0JI6y>9*3?iD0ZTEzi?tH%5nts{-`AkQ^mY8ZP>tMlZ18t9wG&#^WX~rvW#M2(xj=>w3X)DZ>*d(gYn(9tgL?KNy#sK8Jino17CuP~h&h zX@8vzhvGfE^HeP3ipt1q*|y*0^?89qr^XwFRX;3;zLytX8=O$HH{ES^+#U>CBqBy* z`Yy~I$Slu{Jw{5$%NW#h1X|a0l4I9(=<;=&A!`*i8>Vx))zRJsTDZh>N%_}U>ra6a zjoQtmI*&$IdoCJ~W_$-@3HuM*k6t!RKwIfp>Wf*6ox{mkWEQ%&s;P97tkWnJFo}0o zoxl0)_1XCROrG0|j14LPxMlG5?r%k_0zfNRAJP-xF7z)ps+!@b>0AVNF1YMqelc#| zG*8qWB5xnHFA!r$Q)B>OPz$=#)y2!6W?~-$zKoJ_nEF~!0wOKZpD7g&n(KIE31^*4 zmiaAW_t867zx(dne^kM*3jF!>WBPJ_VsQ~K`z=DFUAgk<$5-E(|7BwL(R*K~{j2z| K#`oTL=" + "" + "" + "" + "" + 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