1. Home
  2. Scripting
  3. Cheapest hours in a row

Scripting

Cheapest hours in a row

Pick how many hours a load needs and when it has to be done. The device finds the cheapest run of hours before then and says when it starts and ends.

Updated: September 2026

Before you start

Denmark only. The price comes from stromligning.dk: the price the customer actually pays, spot plus grid tariffs plus taxes, looked up from the address. For anything that only has to follow a price threshold, use live electricity prices instead.

  • A device that supports virtual components — Gen3 or newer, or a Pro model from Gen2
  • Firmware 2.0.0 or newer. The script creates its own components, and older firmware cannot
  • Internet access on the device, to fetch the prices
1
Script

Paste the script

Create a new script on the device, paste the code below and save it. How to add a script shows where, in the app and in the web interface.

The Scripts page in the device's web interface on a Shelly Pro 3EM, with no scripts yet and the Create script button at the top right
Scripts → Create script in the device's web interface.
/* @meta {
  "vc": {
    "price":  {"type": "number",  "config": {"name": "Price now", "min": 0, "max": 20, "meta": {"ui": {"view": "label", "unit": "DKK/kWh"}}}},
    "hours":  {"type": "number",  "config": {"name": "Hours in a row", "min": 1, "max": 12, "default_value": 3, "persisted": true, "meta": {"ui": {"view": "slider", "step": 1, "unit": "h"}}}},
    "ready":  {"type": "number",  "config": {"name": "Ready by (hour)", "min": 0, "max": 23, "default_value": 7, "persisted": true, "meta": {"ui": {"view": "slider", "step": 1, "unit": ":00"}}}},
    "active": {"type": "boolean", "config": {"name": "Cheap window now", "meta": {"ui": {"view": "label"}}}},
    "plan":   {"type": "text",    "config": {"name": "Plan", "meta": {"ui": {"view": "label"}}}},
    "avg":    {"type": "number",  "config": {"name": "Average price in window", "min": 0, "max": 20, "meta": {"ui": {"view": "label", "unit": "DKK/kWh"}}}},
    "group":  {"type": "group",   "config": {"name": "Cheapest hours"}}
  }
} */
// Cheapest hours in a row — Danish delivered price from stromligning.dk
// (spot + grid tariffs + taxes). Requires firmware 2.0.0 or newer.
// The script creates its own virtual components and puts them in a group.
// It switches nothing itself: "Cheap window now" is true while the cheapest
// window is running, and scenes or local actions switch the load on it.
// ===== CONFIGURATION =====
let CONFIG = {
  lat: "55.6736",         // Latitude
  long: "12.5681",        // Longitude
  additional_cost: 0.00,  // Extra cost from your electricity supplier, DKK/kWh
  fetch_delay_seconds: 10, // Seconds after each full hour the prices are fetched
  debug: false            // true = detailed log in the console, for troubleshooting
};
// ===== END OF CONFIGURATION =====
let H = 3600000;
let KVS_KEY = "cheapest_last_deadline";
print("Start");
let vPrice = Script.getVcHandle("price");
let vHours = Script.getVcHandle("hours");
let vReady = Script.getVcHandle("ready");
let vActive = Script.getVcHandle("active");
let vPlan = Script.getVcHandle("plan");
let vAvg = Script.getVcHandle("avg");
let vGroup = Script.getVcHandle("group");
let supplier = null;   // { id, area }
let prices = [];       // Hourly delivered prices, DKK/kWh
let firstTs = 0;       // Start of prices[0], ms
let plan = null;       // { start, end, avg, deadline }
let lastDeadline = 0;  // Deadline served by the last finished window. Kept in KVS across restarts
let waiting = false;   // True while tomorrow's prices are needed but not yet published
let wasActive = null;  // Last state written to the boolean
function pad2(n) {
  return ("0" + n).slice(-2);
}
function round2(v) {
  return Math.round(v * 100) / 100;
}
// Price components are limited to 0–20 DKK/kWh, so a value outside is clamped
function clampPrice(v) {
  return Math.min(20, Math.max(0, v));
}
function hourStart(ts) {
  return Math.floor(ts / H) * H;
}
function hhmm(ts) {
  return pad2(new Date(ts).getHours()) + ":00";
}
function dayHour(ts) {
  let d = new Date(ts);
  return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + hhmm(ts);
}
// ----- Debug: writes only, never changes a decision. Every caller checks
// CONFIG.debug first, so with debug off no debug string is even built -----
function dbg(s) {
  print("[debug] " + s);
}
function dbgTime(ts) {
  let d = new Date(ts);
  return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " +
    pad2(d.getHours()) + ":" + pad2(d.getMinutes());
}
function dbgCoord(v) {
  return Math.round(parseFloat(v) * 100) / 100;
}
// Prices as "18:2.31 19:2.10 …", at most 12 hours per line
function dbgPrices() {
  let line = "";
  for (let i = 0; i < prices.length; i++) {
    line += pad2(new Date(firstTs + i * H).getHours()) + ":" + prices[i].toFixed(2) + " ";
    if ((i + 1) % 12 === 0 || i === prices.length - 1) {
      dbg("prices " + line.trim());
      line = "";
    }
  }
}
function dbgStart() {
  let info = Shelly.getDeviceInfo();
  let sys = Shelly.getComponentConfig("sys");
  dbg("firmware " + (info ? (info.ver || info.fw_id) : "?") + ", model " + (info ? info.model : "?"));
  dbg("time zone " + (sys && sys.location ? sys.location.tz : "?") + ", local time " + dbgTime(Date.now()));
  dbg("config lat " + dbgCoord(CONFIG.lat) + ", long " + dbgCoord(CONFIG.long) +
    ", additional_cost " + CONFIG.additional_cost + ", fetch_delay_seconds " + CONFIG.fetch_delay_seconds);
}
// Never calls a handle that may be null, and never lets a failed write stop the script
function setVc(h, v) {
  if (!h) return;
  try {
    h.setValue(v);
  } catch (e) {
    print("Could not write a component: " + e.message);
  }
}
function getVc(h, fallback) {
  if (!h) return fallback;
  let v = h.getValue();
  return typeof v === "number" ? v : fallback;
}
// The script engine has no regular expressions, so colons are replaced by hand
function stamp(ts, minutes) {
  let d = new Date(ts);
  let s = d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) +
    "T" + pad2(d.getHours()) + ":" + minutes + ":00";
  return s.split(":").join("%3A");
}
function hoursWanted() {
  let n = Math.round(getVc(vHours, 3));
  if (!(n >= 1)) n = 1;
  if (n > 12) n = 12;
  return n;
}
function readyHour() {
  let h = Math.round(getVc(vReady, 7));
  if (!(h >= 0)) h = 0;
  if (h > 23) h = 23;
  return h;
}
// Average price between two hour boundaries, or null if a price is missing
function avgOf(start, end) {
  let i = (start - firstTs) / H;
  let n = (end - start) / H;
  if (i < 0 || i + n > prices.length) return null;
  let sum = 0;
  for (let j = 0; j < n; j++) sum += prices[i + j];
  return round2(sum / n);
}
// Next time the load has to be done. Skips the deadline the last window
// already served, so a window ending at 05:00 does not start a new one before 07:00
function nextDeadline(now) {
  let d = new Date(now);
  let dl = new Date(d.getFullYear(), d.getMonth(), d.getDate(), readyHour(), 0, 0).getTime();
  while (dl <= now || dl <= lastDeadline) dl += 24 * H;
  return dl;
}
function computePlan() {
  let now = Date.now();
  let n = hoursWanted();
  let deadline = nextDeadline(now);
  // The current hour only counts if we are within its first five minutes
  let from = hourStart(now);
  if (now - from > 5 * 60000) from += H;
  let latestStart = deadline - n * H;
  if (latestStart < from) latestStart = from; // Not enough time left: start as soon as possible
  let knownEnd = firstTs + prices.length * H;
  // Tomorrow's prices are published around 13:00. If the window reaches past
  // the known prices and there is still time, wait for them instead of guessing
  if (CONFIG.debug) dbg("compute: now " + dbgTime(now) + ", hours " + n + ", deadline " + dbgTime(deadline) +
    ", from " + dbgTime(from) + ", latestStart " + dbgTime(latestStart) + ", knownEnd " + (knownEnd ? dbgTime(knownEnd) : "none"));
  if (latestStart + n * H > knownEnd && from < latestStart) {
    if (CONFIG.debug) dbg("waiting: the window may end at " + hhmm(latestStart + n * H) +
      ", prices are known until " + (knownEnd ? hhmm(knownEnd) : "none") + ", and there is still time");
    plan = null;
    waiting = true;
    return;
  }
  waiting = false;
  let best = -1;
  let bestAvg = 0;
  let top = [];  // The three cheapest candidates, for debug only
  for (let s = from; s <= latestStart; s += H) {
    let a = avgOf(s, s + n * H);
    if (a !== null && (best < 0 || a < bestAvg)) {
      best = s;
      bestAvg = a;
    }
    if (CONFIG.debug && a !== null) {
      top.push({ s: s, a: a });
      top.sort(function (x, y) { return x.a - y.a; });
      if (top.length > 3) top.pop();
    }
  }
  if (CONFIG.debug) {
    let t = "";
    for (let k = 0; k < top.length; k++) t += hhmm(top[k].s) + "-" + hhmm(top[k].s + n * H) + " " + top[k].a.toFixed(2) + "  ";
    dbg("cheapest candidates: " + (t || "none"));
  }
  if (best < 0) {
    // No usable prices and no time left: run now so the load is still done on time
    print("No prices for the window — starting now as a fallback");
    plan = { start: from, end: from + n * H, avg: null, deadline: deadline };
  } else {
    plan = { start: best, end: best + n * H, avg: bestAvg, deadline: deadline };
  }
  print("Plan: " + hhmm(plan.start) + "-" + hhmm(plan.end) + ", average " + plan.avg + " DKK/kWh");
  if (CONFIG.debug) dbg("chosen: " + dbgTime(plan.start) + " to " + dbgTime(plan.end));
}
function show() {
  if (plan) {
    let text = hhmm(plan.start) + " → " + hhmm(plan.end);
    if (plan.avg !== null) {
      text += " (" + plan.avg.toFixed(2) + " DKK)";
      setVc(vAvg, clampPrice(plan.avg));
    }
    setVc(vPlan, text);
  } else {
    setVc(vPlan, waiting ? "Waiting for tomorrow's prices" : "No plan");
  }
}
// Written only when a window ends, so a restart before the deadline does not run the load again
function saveLastDeadline() {
  if (CONFIG.debug) dbg("KVS write " + KVS_KEY + " = " + lastDeadline + " (" + dbgTime(lastDeadline) + ")");
  Shelly.call("KVS.Set", { key: KVS_KEY, value: lastDeadline }, function (res, code, msg) {
    if (code !== 0) print("Could not save the last deadline: " + msg);
    if (code !== 0 && CONFIG.debug) dbg("KVS write failed: code " + code + ", " + msg);
  });
}
function evaluate() {
  let now = Date.now();
  if (plan && now >= plan.end) {
    lastDeadline = plan.deadline;
    saveLastDeadline();
    plan = null;
  }
  // A window that has started is locked. Anything else is recalculated,
  // so newly published prices are used right away
  if (!(plan && now >= plan.start)) computePlan();
  let on = plan !== null && now >= plan.start && now < plan.end;
  // Only written on changes, so scenes and actions fire once per start and end
  if (on !== wasActive) {
    setVc(vActive, on);
    let was = wasActive;
    wasActive = on;
    print(on ? "Cheap window started" : "Cheap window ended");
    if (CONFIG.debug && on) dbg("window started and locked: " + dbgTime(plan.start) + " to " + dbgTime(plan.end));
    if (CONFIG.debug && !on && was === true) dbg("window ended at " + dbgTime(now));
  }
  show();
}
function safeEvaluate() {
  try {
    evaluate();
  } catch (e) {
    print("Evaluate failed: " + e.message);
  }
}
function onPrices(res, code, msg) {
  try {
    if (CONFIG.debug) dbg("price fetch: rpc code " + code + ", HTTP " + (res ? res.code : "none") +
      ", " + (res && res.body ? res.body.length : 0) + " characters" + (code !== 0 ? ", error " + msg : ""));
    if (code !== 0 || !res || res.code !== 200) {
      print("Price fetch failed: " + (msg || (res ? res.code : "")) + " — keeping the prices already known");
    } else {
      let data = JSON.parse(res.body);
      res = null;
      let list = data.prices ? data.prices : data;
      let p = [];
      let t0 = 0;
      for (let i = 0; i < list.length; i++) {
        let item = list[i];
        let ts = new Date(item.date).getTime();
        let v = typeof item.price === "number" ? item.price : item.price.total;
        if (i === 0) t0 = ts;
        if (ts !== t0 + p.length * H) {
          print("Gap in the price data — using the prices before it");
          break;
        }
        p.push(v + CONFIG.additional_cost);
      }
      data = null;
      list = null;
      if (p.length > 0) {
        prices = p;
        firstTs = t0;
        print("Prices parsed: " + prices.length + " hours");
        print("Prices known until " + hhmm(firstTs + prices.length * H));
        if (CONFIG.debug) {
          dbg(prices.length + " hours in the answer, first " + dbgTime(firstTs) + ", last " + dbgTime(firstTs + (prices.length - 1) * H));
          dbgPrices();
        }
      }
    }
    let i = (hourStart(Date.now()) - firstTs) / H;
    if (i >= 0 && i < prices.length) setVc(vPrice, clampPrice(round2(prices[i])));
  } catch (e) {
    print("Could not read the prices: " + e.message);
    if (CONFIG.debug) dbg("price parse error: " + e.message);
  }
  safeEvaluate();
}
function fetchPrices() {
  let from = hourStart(Date.now());
  let url = "https://stromligning.dk/api/prices?from=" + stamp(from, "00") +
    "&to=" + stamp(from + 35 * H, "45") +
    "&supplierId=" + supplier.id + "&priceArea=" + supplier.area +
    "&aggregation=1h&lean=true";
  if (CONFIG.debug) dbg("fetch " + url);
  Shelly.call("HTTP.GET", { url: url, timeout: 15 }, onPrices);
}
function onSupplier(res, code, msg) {
  try {
    if (CONFIG.debug) dbg("grid company lookup: rpc code " + code + ", HTTP " + (res ? res.code : "none") +
      ", " + (res && res.body ? res.body.length : 0) + " characters" + (code !== 0 ? ", error " + msg : ""));
    if (code !== 0 || !res || res.code !== 200) {
      print("Grid company lookup failed: " + msg);
    } else {
      let list = JSON.parse(res.body);
      if (list && list.length > 0) {
        supplier = { id: list[0].id, area: list[0].priceArea };
        print("Grid company: " + supplier.id + ", price area " + supplier.area);
      } else {
        print("No grid company found for these coordinates — check lat and long");
      }
    }
    if (supplier) {
      fetchPrices();
      return;
    }
  } catch (e) {
    print("Could not read the grid company: " + e.message);
  }
  safeEvaluate();
}
function onTick() {
  try {
    if (supplier) {
      fetchPrices();
    } else {
      let url = "https://stromligning.dk/api/suppliers/find?lat=" + CONFIG.lat + "&long=" + CONFIG.long;
      if (CONFIG.debug) dbg("grid company lookup near lat " + dbgCoord(CONFIG.lat) + ", long " + dbgCoord(CONFIG.long));
      Shelly.call("HTTP.GET", { url: url, timeout: 15 }, onSupplier);
    }
  } catch (e) {
    print("Tick failed: " + e.message);
  }
  scheduleTick();
}
function scheduleTick() {
  let now = Date.now();
  let delay = hourStart(now) + H - now + CONFIG.fetch_delay_seconds * 1000;
  Timer.set(delay, false, onTick);
}
// Changing the hours during a running window extends or shortens it.
// Otherwise the plan is recalculated at once
function onHoursChange(ev) {
  try {
    let now = Date.now();
    if (plan && now >= plan.start) {
      let oldEnd = plan.end;
      plan.end = plan.start + hoursWanted() * H;
      plan.avg = avgOf(plan.start, plan.end);
      if (CONFIG.debug) dbg("running window " + (plan.end > oldEnd ? "extended" : "shortened") + ": " +
        dbgTime(plan.start) + " to " + dbgTime(plan.end) + " (was to " + dbgTime(oldEnd) + ")");
    } else {
      plan = null;
    }
  } catch (e) {
    print("Hours change failed: " + e.message);
  }
  safeEvaluate();
}
// A new deadline takes effect at once, unless a window is running
function onReadyChange(ev) {
  try {
    if (!(plan && Date.now() >= plan.start)) plan = null;
  } catch (e) {
    print("Deadline change failed: " + e.message);
  }
  safeEvaluate();
}
// Puts the six components in the script's own group, so the user only has
// to extract it as a device in the app
function fillGroup() {
  let parts = [["number", vPrice], ["number", vHours], ["number", vReady],
    ["boolean", vActive], ["text", vPlan], ["number", vAvg]];
  let keys = [];
  for (let i = 0; i < parts.length; i++) {
    let c = parts[i][1] ? parts[i][1].getConfig() : null;
    if (c && typeof c.id === "number") keys.push(parts[i][0] + ":" + c.id);
  }
  if (CONFIG.debug) dbg("components " + keys.join(", "));
  let g = vGroup ? vGroup.getConfig() : null;
  if (!g || typeof g.id !== "number") {
    print("Group missing — add the components to a group by hand");
    return;
  }
  Shelly.call("Group.Set", { id: g.id, value: keys }, function (res, code, msg) {
    print(code === 0 ? "Group filled: " + keys.join(", ") : "Could not fill the group: " + msg);
    if (code !== 0 && CONFIG.debug) dbg("Group.Set failed: code " + code + ", " + msg);
  });
}
function onLastDeadline(res, code, msg) {
  try {
    if (code === 0 && res && typeof res.value === "number") lastDeadline = res.value;
    if (CONFIG.debug) dbg("KVS " + KVS_KEY + " = " + (code === 0 && res ? res.value : "not set (code " + code + ")"));
    print("Last deadline served: " + (lastDeadline ? dayHour(lastDeadline) : "none"));
  } catch (e) {
    print("Could not read the last deadline: " + e.message);
  }
  onTick();
}
function start() {
  if (!vHours || !vReady || !vActive || !vPlan || !vAvg || !vPrice) {
    print("Virtual components missing — the device needs firmware 2.0.0 or newer");
    return;
  }
  print("Components found");
  if (CONFIG.debug) {
    try {
      dbgStart();
    } catch (e) {
      dbg("start info failed: " + e.message);
    }
  }
  try {
    fillGroup();
  } catch (e) {
    print("Could not fill the group: " + e.message);
  }
  vHours.on("change", onHoursChange);
  vReady.on("change", onReadyChange);
  Shelly.call("KVS.Get", { key: KVS_KEY }, onLastDeadline);
}
try {
  start();
} catch (e) {
  print("Start failed: " + e.message);
}
2
Settings

Set your coordinates

Open the device’s settings (the gear), open the location and time zone section, and pick the location from the map. Drag the marker near the address and confirm.

Write the latitude into lat and the longitude into long at the top of the script. Both stay in quotes. The marker only has to be in the right area — it matters only when the address is right on the border between two grid companies.

Then check the rest of the CONFIG block, and save:

The script editor with the script pasted in, the virtual component declarations at the top and the CONFIG block below with lat, long, additional_cost and fetch_delay_seconds
The CONFIG block, with the coordinates blurred.
Setting Default What it does
lat, long Copenhagen Where the device is. Decides the grid company and the price area
additional_cost 0.00 The electricity supplier’s own mark-up, DKK/kWh. Added to every hour
fetch_delay_seconds 10 Seconds after each full hour that the prices are fetched
debug false true writes a detailed log in the console, for troubleshooting. Leave it false otherwise
3
Start

Start the script

Switch on run on startup and start the script. Without run on startup it stops at the first power cut and does not come back.

Within a few seconds the script creates six components on the device — Price now, Hours in a row, Ready by (hour), Cheap window now, Plan and Average price in window — and puts them in a group called Cheapest hours.

Script list showing a script set to start automatically
The list shows both whether a script is running now and whether it will restart by itself.
The device's user-defined components: Cheap window now, Average price in window, Price now and Plan shown as label, Hours in a row and Ready by (hour) as slider, each in one group
The six components under Components. The output values are labels and cannot be changed; the two settings are sliders. The count reads 0 for components a script owns.
The device's user-defined groups with one group, Cheapest hours, holding the six components
The group Cheapest hours, filled by the script.
4
App

Show the group as a device

The components only show up in the app once they are in a group that is extracted as a device. Until then they exist only on the device itself.

In the app, open the device’s Settings → Virtual components → Groups. Under Cheapest hours, tap extract device. The six components then show up together as one device on the home screen, and scenes can use them.

The device's settings in the app, on Virtual components, Groups, with the group Cheapest hours, its extract device link and the six components with their values
Settings → Virtual components → Groups → extract device.
5
Use

Set the hours and the deadline

  • Hours in a row — how long the load needs, 1 to 12 hours
  • Ready by (hour) — when it has to be done, 0 to 23. 7 means finished by 07:00

Plan then shows the window it chose, for example 01:00 → 06:00 (1.86 DKK), with the average price. Both settings survive a restart.

The app's home screen with the extracted device Cheapest hours open: Price now, Hours in a row set to 5 h, Ready by (hour) set to 6:00, Cheap window now False, Plan 01:00 to 06:00 at 1.86 DKK, and the average price
The extracted device in the app.
6
Check

Check the console

Open the script from the device’s web interface and look at the console. Within a minute it should show:

  • Start, Components found and Group filled — the script is running and has its components
  • Grid company — the grid company and price area found from the coordinates
  • Prices parsed and Prices known until — how many hours were read and how far ahead they go
  • Plan — the window chosen and its average price, once there is one
The script console with the lines Start, Components found and Group filled followed by the six component keys
The first three lines, right after the start.

The lines come in that order, so the last one printed shows how far the script got. “No grid company found” means the coordinates are wrong. An empty console usually means websocket debug is off — see how to add a script.

Use it in a scene

Cheap window now is true while the cheapest window is running. It takes two scenes: one that starts the load when Cheap window now turns on, and one that stops it when Cheap window now turns off. The load can sit on any Shelly, not only the one running the script.

A scene in the app: When Cheapest hours, Cheap window now On, trigger Once; Do an EV charger, Charging On; active all day, every day
The scene that starts the charging. The one that stops it is the same with Off.

Remove the solution

  • Delete the script while it is running. Its components and the group are removed with it.
  • If the script is stopped, start it first, then delete it. Deleting a stopped script can leave the components behind, locked, so they cannot be deleted by hand.
  • Delete the scenes that use Cheap window now yourself. They are not removed with the script.

How it decides

  • The window always ends by the deadline. It picks the cheapest run of hours that fits before Ready by.
  • Tomorrow’s prices arrive around 13:00. Until then, if the window may reach into tomorrow, Plan says Waiting for tomorrow’s prices.
  • A window that has started is locked. New prices do not move it.
  • A restart does not run the load twice. The device remembers which deadline the last window served, so a power cut after the window has ended does not start another one before the deadline.
  • Changing Hours in a row during a window extends or shortens it.
  • Without prices, it starts as late as still finishes on time. A failed lookup does not leave the load undone.
  • It switches nothing itself. Cheap window now changes once at the start and once at the end, and a scene does the switching. Switch the load by hand in between, and that holds until the next start or end.

Already running the electricity price script?

This script replaces it. Stop the old one, so the device does not fetch the price twice — Price now shows the same delivered price. The one thing the old script does that this one does not is send the price to the app’s own electricity price setting.

A device holds at most 10 virtual components. This script creates seven: six components and their group. Delete the component and group you made for the old script if the device runs out.