Electricity price based control of IoT devices in LuCI

I’d like to implement simple control of IoT devices based on the electricity price data for the current and the next day. I am aware of alternatives such as HomeAssistant and various proprietary clouds of IoT device manufacturers.

Last year, I wrote a shell script that made use of the jshn library to parse JSON price data and to schedule the cheapest time slot for a warm water boiler. I implemented an improved version of the logic in an ESP-32 based Tasmota device (pull request). Now I would want to port this to LuCI.

I see that many of the graphs in the LuCI web interface for CPU usage, bandwidth, connections, and so on are based on data that will be read from files in the directory /var/lib/luci-bwc by a C program luci-bwc.

I’d want to present a graph similar to https://sahkotin.fi or https://dashboard.elering.ee/et/nps/price and some user interface for highlighting the time slots when power will be used, as well as setting some parameters for the price optimization.

For a ‘price database’ I could simply use a JSON file in the file system. Every 15 minutes, a Lua or shell script would be invoked to parse the file, to write it back with any past entries removed, and to initiate action when needed. For example, an action could be sending an HTTP GET request to turn on a heater. About once per day, the script would try to refresh the price data from an online data source. Part of the code would be interacting directly with the web browser, such as sending the data for drawing the price graph.

I’d be grateful for any pointers, such as LuCI extensions that try to achieve anything similar.

1 Like

hass has those optimizing algorithms included, go for it.

2 Likes

Just to clarify, I want to minimize the amount of always-on hardware, and I would not dare to run too complex software on my router. The algorithm is rather trivial. Basically we only need a binary heap based priority queue or ‘lazy sort’ that determines the cheapest N time slots out of the total available number of slots. The result would have to be sorted in ascending order of timestamp. I see that Lua implementations of binary heap already exist. It could also be feasible to sort all time slots by price and pick and sort the first N times.

My question was mainly how to integrate one’s own code in LuCI. https://github.com/openwrt/luci/wiki/ModulesHowTo looks like a promising starting point.

I learned that an effort to migrate LuCI from Lua to server-side Javascript started in 2022. https://openwrt.github.io/luci/ and https://zhanzat.github.io/luci-js-cookbook/ look useful. Maybe the entire application could be written in Javascript.

I just learned that https://ucode.mein.io is the server side LuCI script interpreter. Its uloop binding would seem to allow me to implement the necessary 15-minute timer intervals. There is also a socket module, which is perfect for interacting with IoT devices in the LAN, such as http://tasmota.lan/cm?cmnd=POWER+ON. For updating the price information from an external HTTPS source, it should be simplest to invoke curl or wget via popen.

For the web browser, I think I will adapt the bandwidth graph of luci-mod-status. Overall, luci-app-example is a good starting point, although this document about parsing YAML has not been adjusted as part of the transition from Lua to ucode.

I’m making some slow progress here. I wrote a ucode script that converts the price list to a JSON format that I think should be adequate for displaying an SVG graph. I also installed luci-app-example in the device and started experimenting by editing its filed. I added a page that is a slightly modified copy of the bandwidth graph of luci-mod-status (/cgi-bin/luci/admin/status/realtime/bandwidth is now cloned at /cgi-bin/luci/admin/example/bandwidth). I still didn’t figure out how to load and graph my own JSON document. Unlike the realtime graphs, this one would change very seldomly (once per day, or at most once per 15 minutes if the server is to remove expired time intervals).

One thing that is bothering me is that in bootstrap/cascade.css there are [data-darkmode="true"] definitions for each and every SVG graph widget. For now, I added one more definition for my own page there, to have the graph honor dark mode. This does not feel right:

[data-darkmode="true"] [data-page="admin-status-channel_analysis"] #view> div > div > div > div > div[style],
[data-darkmode="true"] [data-page="admin-status-realtime-load"] #view > div > div > div[style],
[data-darkmode="true"] [data-page="admin-status-realtime-bandwidth"] #view > div > div > div > div[style],
[data-darkmode="true"] [data-page="admin-status-realtime-wireless"] #view > div > div > div > div[style],
[data-darkmode="true"] [data-page="admin-status-realtime-connections"] #view > div > div > div[style] {
	background-color: var(--background-color-high)!important;
}

Edit: I’m not a CSS expert, but I filed https://github.com/openwrt/luci/pull/7842 in an attempt to enable dark mode globally for all current or future graphs.

1 Like

It has been a while, and I still haven’t figured out how to best integrate the user interface to this. But, at least I have a back-end running “in production”:

# crontab -l
0 18 * * * /root/prices

Usually the prices for the next day should be updated well before 6 pm local time. It is rather simple to parse CSV and sort arrays in ucode:

#!/usr/bin/ucode
'use strict';
import{stat,readfile,writefile} from 'fs';
const csv='/tmp/prices.csv', N=3;
let tasmota='http://tasmota.lan/cm?cmnd=';
let now=time();
{
	let s=stat(csv);
	if (s == null || s.mtime <= now - 84000) {
		s=gmtime(now);
		let start=sprintf("%u-%02u-%02uT%02u:%02u:00.000Z",
				  s.year, s.mon, s.mday, s.hour, s.minute);
		s=gmtime(now + 172800);
		let end=sprintf("%u-%02u-%02uT%02u:00:00.000Z",
				s.year, s.mon, s.mday, s.hour);
		let url="https://sahkotin.fi/prices.csv?quarter&fix&vat" +
			"&start=" + start + "&end=" + end;
		s=system("wget -O " + csv + " '" + url + "'");
		if (s != 0) warn("downloading ", url, " failed with ", s);
	}
}
// discard the CSV heading and the final newline
let a=slice(split(readfile(csv),'\n'),1,-1);
// convert to an array of tp[x]=[time,price] pairs, without past times
{
	a=map(a,function(r){
		let s=split(r,',');let t=split(s[0],/[-T:]/);
		return [timegm
			({"year":+t[0],"mon":+t[1],"mday":+t[2],
			  "hour":+t[3],"min":+t[4]}),
			+s[1]]
	});
	const start=now-(length(a)>1?a[1][0]-a[0][0]:900);
	filter(a,tp=>tp[0]>start);
}
writefile('/tmp/prices.json',a);
// sort by price ascending, pick the times of the N cheapest slots
sort(a,function(tp1,tp2){return tp1[1]-tp2[1];});
a=map(slice(a,0,N),tp=>tp[0]);
sort(a);
writefile('/tmp/schedule.json',a);

for (let t in a) {
	sleep((t - time()) * 1000);
	system("wget -O /dev/stderr '" + tasmota + "POWER+ON' '" + tasmota + "STATE'");
}

Currently the “user interface” consists of the modification time of the file /tmp/schedule.json as well as the timestamps contained in it. The target will automatically turn off the power 15 minutes after receiving a POWER ON command. I don’t know if cron is logging the stderr anywhere, so my script might as well omit that output. In the system log I only see the following:

Mon Nov 17 18:00:00 2025 cron.err crond[23329]: USER root pid 32623 cmd /root/prices

I think that this really should be split into two: A periodic job to download the price list, and some inotify triggered script that watches the schedule.json and reschedules accordingly. The LuCI part would then visualize the prices.json and schedule.json, as well allow the schedule.json to be adjusted. Hints for that would be welcome.

I was thinking that it could make sense to use the inotifywait package to have the service react to changes of the "on" schedule. This command basically replaces the sleep() in the previous version of the script:

#!/usr/bin/ucode
'use strict';
import{stat,readfile,writefile} from 'fs';
const csv='/tmp/prices.csv', N=3;
let tasmota='http://192.168.1.136/cm?cmnd=';
let now=time();
{
	let s=stat(csv);
	if (s == null || s.mtime <= now - 42000) {
		s=gmtime(now);
		let start=sprintf("%u-%02u-%02uT%02u:%02u:00.000Z",
				  s.year, s.mon, s.mday, s.hour, s.minute);
		s=gmtime(now + 172800);
		let end=sprintf("%u-%02u-%02uT%02u:00:00.000Z",
				s.year, s.mon, s.mday, s.hour);
		let url="https://sahkotin.fi/prices.csv?quarter&fix&vat" +
			"&start=" + start + "&end=" + end;
		s=system("wget -O " + csv + " '" + url + "'");
		if (s != 0) warn("downloading ", url, " failed with ", s);
	}
}
// discard the CSV heading and the final newline
let a=slice(split(readfile(csv),'\n'),1,-1);
// convert to an array of tp[x]=[time,price] pairs, without past times
{
	a=map(a,function(r){
		let s=split(r,',');let t=split(s[0],/[-T:]/);
		return [timegm
			({"year":+t[0],"mon":+t[1],"mday":+t[2],
			  "hour":+t[3],"min":+t[4]}),
			+s[1]]
	});
	const start=now-(length(a)>1?a[1][0]-a[0][0]:900);
	a=filter(a,tp=>tp[0]>start);
}
writefile('/tmp/prices.json',a);
// sort by price ascending, pick the times of the N cheapest slots
sort(a,function(tp1,tp2){return tp1[1]-tp2[1];});
a=map(slice(a,0,N),tp=>tp[0]);
sort(a);
writefile('/tmp/schedule.json',a);
a=true;
while (a) {
	a=false;
	for (let t in json(readfile('/tmp/schedule.json'))) {
		if (!system("inotifywait -t " + (t - time()) + " /tmp/schedule.json")) {
			a=true;
			break;
		}
		system("wget -O - '" + tasmota + "POWER+ON'");
	}
}

The file /tmp/schedule.json could be updated somehow via the web interface, and the scheduler would promptly react to any changes. The only constraint is that at all times, the file would have to be a valid JSON array containing some timestamps that are in the future. The script could be invoked by cron once per day.

1 Like