diff --git a/tasmota/berry/modules/cheap_power/README.md b/tasmota/berry/modules/cheap_power/README.md new file mode 100644 index 000000000000..9be12936f8f2 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/README.md @@ -0,0 +1,105 @@ +## CheapPower Module + +This fetches electricity prices and chooses the cheapest future time slots. +Currently, the following price data sources have been implemented: +* `cheap_power_dk_no.tapp`: Denmark, Norway (Nord Pool) +* `cheap_power_elering.tapp`: Estonia, Finland, Latvia, Lithuania (Nord Pool via [Elering](https://elering.ee/en), ex VAT) +* `cheap_power_fi.tapp`: Finland (Nord Pool) +* `cheap_power_se.tapp`: Sweden (Nord Pool); assuming Swedish time zone +* `cheap_power_smard.tapp`: https://smard.de (Central Europe; local time) +* `cheap_power_uk_octopus.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 slots (default: 1) + * to schedule `Power1 ON`, `Power2 ON`, … at the chosen slots + * 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 900 seconds (15 minutes) +#PulseTime1 1000 +# 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. + +### Additional Parameters + +* `cheap_power_fi.tapp` (Finland): none +* `cheap_power_elering.tapp`: price zone FI, EE, LT, LV +* `cheap_power_dk_no.tapp` (Denmark, Norway): price zone DK1, DK2, NO1 to NO5 +* `cheap_power_se.tapp` (Sweden): price zone SE1 to SE4 +* `cheap_power_smard.tapp`: zone AT, BE, CH, CZ, DE, FR, HU, IT, LU, NL, PL, SI +``` +CheapPower1 DE +``` +* `cheap_power_uk_octopus.tapp` (United Kingdom): tariff name and price zone: +``` +CheapPower AGILE-24-10-01 B +``` +The default number of active price slots per day is 1. It can be set in the +web user interface. + +### Web User Interface + +The user interface in the main menu consists of the following buttons: +* ⏮ moves the first time slot earlier (or wraps from the beginning to the end) +* ⏭ moves the first time slot later (or wraps from the end to the beginning) +* ⏯ pauses (switches off) or chooses the optimal slots +* 🔄 requests the prices to be downloaded and the optimal slots to be chosen +* ➖ decreases the number of slots (minimum: 1) +* ➕ increases the number of slots (maximum: currently available price slots) + +The status output above the buttons may indicate that the output +is paused until further command or price update: +``` +(0≤1)0.299‥2.38 ¢/kWh ⭘ +``` +Or it may indicate the price and duration until the next active slot: +``` +(1≤1)0.299‥2.38 ¢/kWh (0.299) ⭙ 5:05 +``` +Or it may indicate the price of the currently active time slot: +``` +(1≤1)0.299‥2.38 ¢/kWh (0.299) ⭙ +``` +The first number indicates the number of remaining scheduled time slots, +and the second number indicates the maximum number of time slots per day +(default 1). + +The two quantities around the ‥ are the minimum and maximum known prices +from the current time onwards. + +The scheduled slots are also indicated by red bars in the line graph +of the available current and future prices. + +### Application Contents + +```bash +for i in */cheap_power.be +do + zip ../cheap_power_${i%/*}.tapp -j -0 autoexec.be \ + cheap_power_base.be binary_heap.be "$i" +done +``` 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/binary_heap.be b/tasmota/berry/modules/cheap_power/binary_heap.be new file mode 100644 index 000000000000..2f95bdb5115f --- /dev/null +++ b/tasmota/berry/modules/cheap_power/binary_heap.be @@ -0,0 +1,54 @@ +# https://en.wikipedia.org/wiki/Binary_heap +# This allows to choose the M first elements of an N-sized array +# with respect to a comparison predicate cmp that defines a total order. +# This avoids the overhead of sorting the entire array and then picking +# the first elements. This is related to a priority queue. +# We also define a binary heap based sort() of an entire array. + +var binary_heap = module("binary_heap") + +binary_heap._heapify = def(array, cmp, i) + var m = i, child, e, am, ac + while true + child = 2 * i + 1 + if child >= array.size() return end + ac = array[child] + am = array[m] + if cmp(ac, am) m = child am = ac end + child += 1 + if child < array.size() + ac = array[child] + if cmp(ac, am) m = child am = ac end + end + if m == i break end + array[m] = array[i] + array[i] = am + i = m + end +end + +# similar to C++11 std::make_heap +binary_heap.make_heap = def(array, cmp) + var i = size(array) / 2 + while i >= 0 binary_heap._heapify(array, cmp, i) i -= 1 end +end + +# similar to C++11 std::pop_heap, but removes and returns the element +binary_heap.remove_heap = def(array, cmp) + var m = array.size() + if m < 2 return m == 1 ? array.pop() : nil end + m = array[0] + array[0] = array.pop() + binary_heap._heapify(array, cmp, 0) + return m +end + +# https://en.wikipedia.org/wiki/Heapsort +binary_heap.sort = def(array, cmp) + var i = array.size(), heap = array.copy() + binary_heap.make_heap(heap, cmp) + array.clear() + while i > 0 array.push(binary_heap.remove_heap(heap, cmp)) i -= 1 end +end + +return binary_heap diff --git a/tasmota/berry/modules/cheap_power/cheap_power_base.be b/tasmota/berry/modules/cheap_power/cheap_power_base.be new file mode 100644 index 000000000000..ff35ed87ad05 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/cheap_power_base.be @@ -0,0 +1,237 @@ +var cheap_power_base = module("cheap_power_base") + +cheap_power_base.init = def (m) + +import binary_heap +var make_heap = binary_heap.make_heap, remove_heap = binary_heap.remove_heap +import webserver + +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 slots + var slots # the maximum number of time slots to choose + 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, NEXT = 1, PAUSE = 2, UPDATE = 3, LESS = 4, MORE = 5 + static UI = "" + "" + "" + "" + "" + "" + "" + "
" + + def init(p_kWh, plugin) + self.p_kWh = p_kWh + self.plugin = plugin + self.past = -900 + self.slots = 1 + 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(),params=[],post,url=self.plugin.url(rtc, params) + if !url return end + self.tz = rtc['timezone'] * 60 + try + post = params[0] + self.p_kWh = params[1] + except .. end + var wc = webclient() + var prices = [], times = [] + while true + wc.begin(url) + var rc = post == nil ? wc.GET() : wc.POST(post) + var data = rc == 200 ? wc.get_string() : nil + wc.close() + if data != nil + url = self.plugin.parse(data, prices, times) + if url continue end + end + if size(prices) + try self.past = times[0] - times[1] except .. end + 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 + if data == nil print(f'error {rc} for {url} {post=}') 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) + while N + if self.times[0] - now > self.past break end + self.prices.pop(0) + self.times.pop(0) + end + var M = size(self.prices) + if M && ch + N -= M + var chS = size(ch) + while chS && ch[0] < N ch.pop(0) chS -= 1 end + if chS + while chS chS -= 1 ch[chS] -= N end + else + ch = nil + end + else + ch = nil + end + return M + end + + def find_cheapest(first) + var cheapest, N = size(self.prices) + if N + var heap = [], i = first == nil ? 0 : first + while i a == first || (b != first && self.prices[a] < self.prices[b]) + make_heap(heap, cmp) + var slots = size(heap) + if slots > self.slots slots = self.slots end + cheapest = [] + while slots slots -= 1 cheapest.push(remove_heap(heap, cmp)) end + binary_heap.sort(cheapest, / a b -> a < b) + end + return cheapest + end + + # trigger the timer at the chosen hour + def schedule_chosen(chosen, now, old) + tasmota.remove_timer('power_on') + var d = chosen && size(chosen) ? self.times[chosen[0]] - now : self.past + 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 + if chosen==nil || size(chosen) < 2 chosen = nil else chosen.pop(0) end + 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 = [] + try + for i: self.chosen ch.push(i) end + old = self.times[ch[0]] - now + except .. end + N = self.prune_old(now, ch) + end + var op = webserver.has_arg('op') ? int(webserver.arg('op')) : nil + if op == self.UPDATE + self.update() + ch = self.chosen + end + while N + var first + if op == self.MORE + if self.slots < N self.slots += 1 end + elif op == self.LESS + if self.slots > 1 self.slots -= 1 end + elif op == self.PAUSE + if self.chosen != nil ch = nil break end + elif op == self.PREV + try first = (!ch[0] ? N : ch[0]) - 1 except .. end + elif op == self.NEXT + try first = ch[0] + 1 < N ? ch[0] + 1 : 0 except .. end + else break end + ch = self.find_cheapest(first) + break + end + self.schedule_chosen(ch, now, old) + var min = self.prices[0], max = min + for p : self.prices if p < min min = p elif p > max max = p end end + var status + if size(ch) + var delay = self.times[ch[0]] - now + if delay <= 0 delay = '' + else + delay /= 60 + delay = format(" %d:%02d", delay / 60, delay % 60) + end + status = format("{s}(%d≤%d)%.3g‥%.3g %s (%.3g){m}⭙%s{e}", + size(ch), self.slots, min, max, + self.p_kWh, self.prices[ch[0]], delay) + else + status = format("{s}(0≤%d)%.3g‥%.3g %s{m}⭘{e}", + self.slots, min, max, self.p_kWh) + end + status+="" + "" + if N + var w = 1050.0 / N + var scale = 100.0 / (max - min) + try + for choice: ch + status+=format("", w * choice, w) + end + except .. end + status+="" + end + status+="{e}" + tasmota.web_send_decimal(status) + end +end +return CheapPowerBase +end +return cheap_power_base diff --git a/tasmota/berry/modules/cheap_power/dk_no/cheap_power.be b/tasmota/berry/modules/cheap_power/dk_no/cheap_power.be new file mode 100644 index 000000000000..0986b785d273 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/dk_no/cheap_power.be @@ -0,0 +1,65 @@ +var cheap_power = module("cheap_power") + +cheap_power.init = def (m) +import re +import string +import json +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,out) + var now = rtc['local'] + var night = tasmota.time_dump(now) + night = now - night['hour'] * 3600 - night['min'] * 60 - night['sec'] + self.daystart = night + out.push(nil) + out.push(f'øre/kWh') + return self.url_string() + end + + def parse(data, prices, times) + data = json.load(data) + if data == nil return nil end + 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(nil,CheapPower()) +end +return cheap_power diff --git a/tasmota/berry/modules/cheap_power/elering/cheap_power.be b/tasmota/berry/modules/cheap_power/elering/cheap_power.be new file mode 100644 index 000000000000..0a8df792e071 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/elering/cheap_power.be @@ -0,0 +1,47 @@ +var cheap_power = module("cheap_power") + +cheap_power.init = def (m) +import re +import string +import cheap_power_base + +class CheapPower + static URLTIME = '%Y-%m-%dT%H:00:00.000Z' + var zone + + def start_args(idx, payload) + var zone = string.tolower(payload) + if !zone + tasmota.log(f"CheapPower{idx}: a price zone name is expected") + elif re.match('^(ee|fi|l[tv])$', zone) + self.zone = '&fields=' + zone + return nil + else + tasmota.log(f"CheapPower{idx} {payload}: unrecognized price zone") + end + return true + end + + def url(rtc) + var now = rtc['utc'] + return 'https://dashboard.elering.ee/api/nps/price/csv?start=' + + tasmota.strftime(self.URLTIME, now) + '&end=' + + tasmota.strftime(self.URLTIME, now + 172800) + self.zone + end + + def parse(data, prices, times) + try + for d: string.split(data,'\n')[1..] + var c = string.split(string.tr(d,'\042\r',''),';') + prices.push(real(string.tr(c[2],',','.'))/10) + times.push(int(c[0])) + end + except .. end + return nil + end +end +return cheap_power_base +('¢/kWh', + CheapPower()) +end +return cheap_power diff --git a/tasmota/berry/modules/cheap_power/fi/cheap_power.be b/tasmota/berry/modules/cheap_power/fi/cheap_power.be new file mode 100644 index 000000000000..3bb0cc9db3f3 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/fi/cheap_power.be @@ -0,0 +1,38 @@ +var cheap_power = module("cheap_power") + +cheap_power.init = def (m) +import json +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) + data = json.load(data) + if data == nil return nil end + 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 +('¢/kWh', CheapPower()) +end +return cheap_power diff --git a/tasmota/berry/modules/cheap_power/se/cheap_power.be b/tasmota/berry/modules/cheap_power/se/cheap_power.be new file mode 100644 index 000000000000..e0f7c86dd7e0 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/se/cheap_power.be @@ -0,0 +1,58 @@ +var cheap_power = module("cheap_power") + +cheap_power.init = def (m) +import re +import json +import cheap_power_base + +class CheapPower + var daystart, zone + + def start_args(idx, payload) + if !payload + tasmota.log(f"CheapPower{idx}: a price zone name is expected") + elif re.match('^SE[1-4]$', payload) + self.zone = payload + return nil + else + tasmota.log(f"CheapPower{idx} {payload}: unrecognized price zone") + end + return true + end + + def url_string() + return 'https://mgrey.se/espot?format=json&domain=' + self.zone + + '&date=' + tasmota.strftime('%Y-%m-%d', 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) + data = json.load(data) + if data == nil return nil end + 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 cheap_power_base +('öre/kWh', CheapPower()) +end +return cheap_power diff --git a/tasmota/berry/modules/cheap_power/smard/cheap_power.be b/tasmota/berry/modules/cheap_power/smard/cheap_power.be new file mode 100644 index 000000000000..34d392e148fc --- /dev/null +++ b/tasmota/berry/modules/cheap_power/smard/cheap_power.be @@ -0,0 +1,54 @@ +var cheap_power = module("cheap_power") + +cheap_power.init = def (m) +import string +import cheap_power_base + +class CheapPower + var zone, tz + static zones={'AT':8004170,'BE':8004996,'CH':8000259,'CZ':8000261, + 'DE':8004169,'FR':8000254,'HU':8000262,'IT':8000255, + 'LU':8004169,'NL':8000256,'PL':8000257,'SI':8000260} + def start_args(idx, payload) + var zone = string.toupper(payload) + if !zone + tasmota.log(f"CheapPower{idx}: a price zone name is expected") + else + try + self.zone = self.zones[zone] + return nil + except .. end + tasmota.log(f"CheapPower{idx} {payload}: unrecognized price zone") + end + return true + end + + def url(rtc,out) + var now = rtc['utc'], hour = tasmota.time_dump(now) + self.tz = rtc['timezone'] * 60 + hour = now - hour['min'] * 60 - hour['sec'] + out.push(format('{"request_form":[{"format":"CSV","moduleIds":[%d],' + '"region":"DE",' + '"timestamp_from":%u000,"timestamp_to":%u000,' + '"type":"discrete","language":"de"}]}', + self.zone, hour, hour + 172800)) + return 'https://www.smard.de/nip-download-manager/nip/download/market-data' + end + + def parse(data, prices, times) + try + for d: string.split(data,'\n')[1..] + var c = string.split(string.tr(d,'\042\r',''),';') + if c[2] != '-' + prices.push(real(string.tr(c[2],',','.'))/10) + times.push(tasmota.strptime(c[0],'%d.%m.%Y %H:%M')['epoch']-self.tz) + end + end + except .. end + return nil + end +end +return cheap_power_base +('¢/kWh', CheapPower()) +end +return cheap_power diff --git a/tasmota/berry/modules/cheap_power/uk_octopus/cheap_power.be b/tasmota/berry/modules/cheap_power/uk_octopus/cheap_power.be new file mode 100644 index 000000000000..da41b2885cf5 --- /dev/null +++ b/tasmota/berry/modules/cheap_power/uk_octopus/cheap_power.be @@ -0,0 +1,57 @@ +var cheap_power = module("cheap_power") + +cheap_power.init = def (m) +import string +import json +import cheap_power_base + +class CheapPower + var tariff, zone + + def start_args(idx, payload) + if !payload + tasmota.log(f"CheapPower{idx}: expected tariff zone") + else + var s = string.split(payload, ' ') + if size(s) != 2 + tasmota.log(f"CheapPower{idx} {payload}: expected tariff zone") + else + self.tariff = s[0] + self.zone = s[1] + return nil + end + end + return true + end + + def url(rtc) + return format('https://api.octopus.energy/v1/products/%s/electricity-tariffs/E-1R-%s-%s/standard-unit-rates/', + self.tariff, self.tariff, self.zone) + end + + def parse(data, prices, times) + data = json.load(data) + if data == nil return nil end + var d = data.find('results') + if d + var now = tasmota.rtc()['utc'] + for i: d.keys() + var datum = d[i] + var date = tasmota.strptime(datum['valid_from'], + '%Y-%m-%dT%H:%M:%SZ')['epoch'] + if date < now + break + else + times.insert(0, date) + prices.insert(0, datum['value_inc_vat']) + end + end + end + return nil + end +end +return cheap_power_base +('p/kWh', + CheapPower()) +end +return cheap_power diff --git a/tasmota/berry/modules/cheap_power_dk_no.tapp b/tasmota/berry/modules/cheap_power_dk_no.tapp new file mode 100644 index 000000000000..bab6e2c114ee Binary files /dev/null and b/tasmota/berry/modules/cheap_power_dk_no.tapp differ diff --git a/tasmota/berry/modules/cheap_power_elering.tapp b/tasmota/berry/modules/cheap_power_elering.tapp new file mode 100644 index 000000000000..0c91347838f1 Binary files /dev/null and b/tasmota/berry/modules/cheap_power_elering.tapp differ diff --git a/tasmota/berry/modules/cheap_power_fi.tapp b/tasmota/berry/modules/cheap_power_fi.tapp new file mode 100644 index 000000000000..bcb49099d71e Binary files /dev/null and b/tasmota/berry/modules/cheap_power_fi.tapp differ diff --git a/tasmota/berry/modules/cheap_power_se.tapp b/tasmota/berry/modules/cheap_power_se.tapp new file mode 100644 index 000000000000..2649c23d05d8 Binary files /dev/null and b/tasmota/berry/modules/cheap_power_se.tapp differ diff --git a/tasmota/berry/modules/cheap_power_smard.tapp b/tasmota/berry/modules/cheap_power_smard.tapp new file mode 100644 index 000000000000..74b1c66c68f4 Binary files /dev/null and b/tasmota/berry/modules/cheap_power_smard.tapp differ diff --git a/tasmota/berry/modules/cheap_power_uk_octopus.tapp b/tasmota/berry/modules/cheap_power_uk_octopus.tapp new file mode 100644 index 000000000000..1c6a8dee06b7 Binary files /dev/null and b/tasmota/berry/modules/cheap_power_uk_octopus.tapp differ