1. Forside
  2. Scripts
  3. Billigste timer i træk

Scripts

Billigste timer i træk

Vælg hvor mange timer relæet skal være tændt, og hvornår det hele skal være færdigt — for eksempel at bilen skal være ladet kl. 07:00. Enheden finder de billigste timer i træk inden da og melder, hvornår perioden starter og slutter.

Opdateret: september 2026

Før du går i gang

Kun i Danmark. Prisen kommer fra stromligning.dk og er den pris, kunden rent faktisk betaler: spotpris plus netselskabets tariffer og afgifter, fundet ud fra adressen. Skal der bare styres efter en prisgrænse, så brug Aktuelle elpriser i stedet.

  • En enhed, der understøtter virtuelle komponenter — Gen3 eller nyere, eller en Pro-model fra Gen2
  • Firmware 2.0.0 eller nyere. Scriptet opretter selv sine komponenter, og det kan ældre firmware ikke
  • Internetadgang på enheden, så den kan hente priserne
  • Plads til 7 virtuelle komponenter — enheden kan højst have 10. Har den i forvejen mere end 3, så fjern nogle først. Det nemmeste er en enhed uden andre virtuelle komponenter
1
Script

Sæt scriptet ind

Opret et nyt script på enheden, sæt koden nedenfor ind, og gem. How to add a script (på engelsk) viser, hvor du finder det i appen og i webinterfacet.

Siden Scripts i webinterfacet på en Shelly Pro 3EM, endnu uden scripts, med knappen Create script øverst til højre
Scripts → Create script i enhedens webinterface.
/* @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"}}
  }
} */
// Version 2026-09-29
// 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 VERSION = "2026-09-29"; // Same date as the Version line at the top
let H = 3600000;
let KVS_KEY = "cheapest_last_deadline";
print("Start — version " + VERSION);
// Handles by role only — never a fixed id such as number:200, so the device's
// other virtual components are never mistaken for this script's own
let vPrice = null;
let vHours = null;
let vReady = null;
let vActive = null;
let vPlan = null;
let vAvg = null;
let vGroup = null;
let SLOTS = 10;        // Virtual components and groups a device can hold
let NEEDED = 7;        // Six components and their group
let VC_TYPES = ["boolean", "number", "text", "enum", "button", "group"];
// One timer for the whole start-up: first the wait for late components, then
// a watchdog on each step that waits for an answer. Never more than one at a time
let stepTimer = null;
let stepName = null;   // The step waiting for an answer, or null
let stepNext = null;   // What runs if it never answers
let STEP_LIMIT = 20;   // Seconds
let retries = 0;
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);
}
// ----- Steps: every call that waits for an answer is one step. A step that
// has not answered within STEP_LIMIT seconds is skipped, so nothing can
// hold the script up -----
function clearStep() {
  if (stepTimer !== null) Timer.clear(stepTimer);
  stepTimer = null;
  stepName = null;
  stepNext = null;
}
function stepBegin(name, next) {
  clearStep();
  stepName = name;
  stepNext = next;
  stepTimer = Timer.set(STEP_LIMIT * 1000, false, onStepTimeout);
}
// True if the answer belongs to the step that is waiting. An answer that
// comes after the watchdog is reported and ignored: the script has moved on
function stepEnd(name) {
  if (stepName !== name) {
    print("Step " + name + " answered after " + STEP_LIMIT + " seconds — ignored");
    return false;
  }
  clearStep();
  return true;
}
function onStepTimeout() {
  let name = stepName;
  let next = stepNext;
  stepTimer = null;
  stepName = null;
  stepNext = null;
  print("Step " + name + " did not respond — continuing");
  try {
    if (next) next();
  } catch (e) {
    print("Could not continue after " + name + ": " + e.message);
  }
}
// The first 120 characters of an answer, for the debug log of a failure
function dbgBody(res) {
  return res && res.body ? res.body.slice(0, 120) : "none";
}
// 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) {
    try {
      if (code !== 0) print("Could not save the last deadline: " + msg);
      if (code !== 0 && CONFIG.debug) dbg("KVS write failed: code " + code + ", " + msg);
    } catch (e) {
      print("Could not save the last deadline: " + e.message);
    }
  });
}
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) {
  if (!stepEnd("prices")) return;
  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");
      if (CONFIG.debug) dbg("price fetch failed: https://stromligning.dk/api/prices, rpc code " + code +
        ", HTTP " + (res ? res.code : "none") + ", answer " + dbgBody(res));
    } 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);
  stepBegin("prices", safeEvaluate);
  Shelly.call("HTTP.GET", { url: url, timeout: 15 }, onPrices);
}
function onSupplier(res, code, msg) {
  if (!stepEnd("grid company")) return;
  try {
    if (code !== 0 || !res || res.code !== 200) {
      print("Grid company lookup failed: " + (msg || "HTTP " + (res ? res.code : "none")));
      // The URL without the coordinates, so the log can be shared
      if (CONFIG.debug) dbg("grid company lookup failed: https://stromligning.dk/api/suppliers/find, rpc code " + code +
        ", HTTP " + (res ? res.code : "none") + ", answer " + dbgBody(res));
    } else {
      let list = JSON.parse(res.body);
      let n = list && list.length ? list.length : 0;
      if (n > 0) {
        supplier = { id: list[0].id, area: list[0].priceArea };
        if (CONFIG.debug) {
          dbg("grid company " + supplier.id + ", price area " + supplier.area + ", HTTP " + res.code + ", " + n + " candidates");
          if (n > 1) {
            let ids = [];
            for (let i = 0; i < n; i++) ids.push(list[i].id);
            dbg("candidates " + ids.join(", "));
          }
        }
        print("Grid company: " + supplier.id + ", price area " + supplier.area);
      } else {
        if (CONFIG.debug) dbg("grid company lookup: HTTP " + res.code + ", 0 candidates, answer " + dbgBody(res));
        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);
    clearStep();
  }
  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("looking up grid company for lat " + dbgCoord(CONFIG.lat) + ", long " + dbgCoord(CONFIG.long));
      stepBegin("grid company", safeEvaluate);
      Shelly.call("HTTP.GET", { url: url, timeout: 15 }, onSupplier);
    }
  } catch (e) {
    print("Tick failed: " + e.message);
  }
  try {
    scheduleTick();
  } catch (e) {
    print("Could not schedule the next price fetch: " + e.message);
  }
}
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. The group is a convenience: whether
// it works, fails or never answers, the script goes on to the prices
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");
    readLastDeadline();
    return;
  }
  stepBegin("group", readLastDeadline);
  Shelly.call("Group.Set", { id: g.id, value: keys }, function (res, code, msg) {
    if (!stepEnd("group")) return;
    try {
      print(code === 0 ? "Group filled: " + keys.join(", ") : "Could not fill the group: " + msg + " (code " + code + ")");
    } catch (e) {
      print("Could not fill the group: " + e.message);
    }
    readLastDeadline();
  });
}
function readLastDeadline() {
  try {
    stepBegin("last deadline", onTick);
    Shelly.call("KVS.Get", { key: KVS_KEY }, onLastDeadline);
  } catch (e) {
    print("Could not read the last deadline: " + e.message);
    clearStep();
    onTick();
  }
}
function onLastDeadline(res, code, msg) {
  if (!stepEnd("last deadline")) return;
  try {
    if (code === 0 && res && typeof res.value === "number") lastDeadline = res.value;
    // -105 is a key that does not exist yet: normal until the first window has ended
    if (code !== 0 && code !== -105) print("Could not read the last deadline: " + msg + " (code " + code + ")");
    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();
}
// The firmware may still be creating the @meta components when the script
// starts, so a handle can be null at first. Asks again for the missing ones
function getHandles() {
  if (!vPrice) vPrice = Script.getVcHandle("price");
  if (!vHours) vHours = Script.getVcHandle("hours");
  if (!vReady) vReady = Script.getVcHandle("ready");
  if (!vActive) vActive = Script.getVcHandle("active");
  if (!vPlan) vPlan = Script.getVcHandle("plan");
  if (!vAvg) vAvg = Script.getVcHandle("avg");
  if (!vGroup) vGroup = Script.getVcHandle("group");
}
function missingRoles() {
  let m = [];
  if (!vPrice) m.push("price");
  if (!vHours) m.push("hours");
  if (!vReady) m.push("ready");
  if (!vActive) m.push("active");
  if (!vPlan) m.push("plan");
  if (!vAvg) m.push("avg");
  if (!vGroup) m.push("group");
  return m;
}
// Keys of this script's own components that exist so far
function ownKeys() {
  let parts = [["number", vPrice], ["number", vHours], ["number", vReady],
    ["boolean", vActive], ["text", vPlan], ["number", vAvg], ["group", vGroup]];
  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);
  }
  return keys;
}
// Counts the device's virtual components that are not this script's. Stops
// if the seven do not fit, otherwise waits for the missing ones
function onComponents(res, code, msg) {
  if (!stepEnd("slot check")) return;
  let room = true;
  try {
    if (code !== 0 || !res || !res.components) {
      print("Could not count the virtual components: " + msg + " (code " + code + ") — waiting for the missing ones anyway");
    } else {
      let own = ownKeys();
      let others = 0;
      for (let i = 0; i < res.components.length; i++) {
        let key = res.components[i].key;
        if (VC_TYPES.indexOf(key.split(":")[0]) >= 0 && own.indexOf(key) < 0) others++;
      }
      if (CONFIG.debug) dbg("virtual components on the device: " + res.components.length + " dynamic, " + others + " not this script's");
      if (SLOTS - others < NEEDED) {
        print("Not enough free virtual component slots: " + (SLOTS - others) + " free, " + NEEDED +
          " needed. Remove unused virtual components and restart the script.");
        room = false;
      }
    }
  } catch (e) {
    print("Could not count the virtual components: " + e.message + " — waiting for the missing ones anyway");
  }
  if (room) waitForComponents();
}
function waitForComponents() {
  stepTimer = Timer.set(3000, true, onRetry);
}
function onRetry() {
  try {
    retries++;
    getHandles();
    let m = missingRoles();
    if (m.length === 0) {
      clearStep();
      begin();
    } else if (retries >= 10) {
      clearStep();
      print("Virtual components still missing after 30 seconds: " + m.join(", ") +
        ". Restart the device with run on startup switched on.");
    }
  } catch (e) {
    print("Waiting for the components failed: " + e.message);
  }
}
function start() {
  if (typeof Script.getVcHandle !== "function") {
    print("Virtual components missing — the device needs firmware 2.0.0 or newer");
    return;
  }
  getHandles();
  if (missingRoles().length === 0) {
    begin();
    return;
  }
  stepBegin("slot check", waitForComponents);
  Shelly.call("Shelly.GetComponents", { dynamic_only: true }, onComponents);
}
// The start-up runs one step at a time: group, last deadline, grid company,
// prices. Each step says how it went, and none can stop the ones after it
function begin() {
  print("Components found");
  if (CONFIG.debug) {
    try {
      dbgStart();
    } catch (e) {
      dbg("start info failed: " + e.message);
    }
  }
  try {
    vHours.on("change", onHoursChange);
    vReady.on("change", onReadyChange);
  } catch (e) {
    print("Could not follow changes to hours and deadline: " + e.message);
  }
  try {
    fillGroup();
  } catch (e) {
    print("Could not fill the group: " + e.message);
    clearStep();
    readLastDeadline();
  }
}
try {
  start();
} catch (e) {
  print("Start failed: " + e.message);
}
2
Opsætning

Skriv koordinaterne ind

Koordinaterne finder du i Shelly-appen: gå til enhedens Indstillinger (tandhjulet) → Geografisk placering og tidszone → Vælg fra kort. Træk markøren hen i nærheden af adressen, og tryk Vælg.

Skriv breddegraden i lat og længdegraden i long øverst i scriptet. Begge tal står i gåseøjne, og det skal de blive ved med. Det er nok, at markøren ligger i området. Kun hvis kunden bor lige på grænsen mellem to netselskaber, skal den være præcis.

Enhedens indstillinger med breddegrad og længdegrad, og dialogen Vælg fra kort, hvor en markør trækkes hen på adressen
Indstillinger → Geografisk placering og tidszone → Vælg fra kort.

Tjek derefter resten af blokken CONFIG, og gem:

Script-editoren med scriptet sat ind, erklæringen af de virtuelle komponenter øverst og blokken CONFIG nedenunder med lat, long, additional_cost og fetch_delay_seconds
Blokken CONFIG, med koordinaterne sløret.
Indstilling Standard Hvad den gør
lat, long København Hvor enheden står. Afgør netselskab og prisområde
additional_cost 0.00 Elselskabets eget tillæg i kr/kWh. Lægges oven i hver time
fetch_delay_seconds 10 Hvor mange sekunder efter hvert timeskift priserne hentes
debug false true skriver en udførlig log i konsollen til fejlfinding. Lad den ellers stå på false
3
Start

Start scriptet

Slå autostart til, og start scriptet. Uden autostart stopper det ved første strømafbrydelse og starter ikke igen af sig selv.

Efter få sekunder har scriptet oprettet seks komponenter på enheden — Price now, Hours in a row, Ready by (hour), Cheap window now, Plan og Average price in window — og samlet dem i en gruppe, der hedder Cheapest hours. Navnene er engelske, fordi de kommer fra scriptet, og det er dem, du skal kigge efter i appen.

Script-listen med et script, der er sat til at starte automatisk
Listen viser både, om scriptet kører nu, og om det starter igen af sig selv.
Enhedens brugerdefinerede komponenter: Cheap window now, Average price in window, Price now og Plan vist som label, Hours in a row og Ready by (hour) som slider, hver i én gruppe
De seks komponenter under Components. Værdierne, scriptet skriver, er labels og kan ikke ændres; de to indstillinger er sliders. Tælleren viser 0 for komponenter, et script ejer.
Enhedens brugerdefinerede grupper med én gruppe, Cheapest hours, der indeholder de seks komponenter
Gruppen Cheapest hours, fyldt af scriptet.
4
App

Pak gruppen ud som enhed

Komponenterne dukker først op i appen, når de ligger i en gruppe, der er pakket ud som enhed. Indtil da findes de kun på selve enheden.

Åbn enhedens Indstillinger i appen → Virtuelle komponenter → Grupper. Tryk på tandhjulet ud for Cheapest hours, slå Extract virtual group as device til, og tryk Gem. Så står de seks komponenter samlet som én enhed på forsiden, og scenerne kan bruge dem.

Enhedens indstillinger i appen under Virtual components og Groups, med gruppen Cheapest hours, tandhjulet ud for den og de seks komponenter med deres værdier
Indstillinger → Virtuelle komponenter → Grupper → tandhjulet ud for Cheapest hours → Extract virtual group as device. Vist i den engelske app.
The app's Virtual Group Settings dialog for the group Cheapest hours, with the note There are no Virtual components yet, the switch Extract virtual group as device turned on, and the Cancel and Save buttons
Slå Extract virtual group as device til, og tryk Gem. Listen kan vise There are no Virtual components yet — det er en kendt fejl i appen, og gruppen virker alligevel.
5
Brug

Vælg antal timer og sluttidspunkt

  • Hours in a row — hvor mange timer relæet skal være tændt, 1 til 12
  • Ready by (hour) — hvornår det hele skal være færdigt, 0 til 23. 7 betyder færdig kl. 07:00 — for eksempel at bilen skal være ladet til den tid

Plan viser så den periode, scriptet har valgt, for eksempel 01:00 → 06:00 (1.86 DKK), med gennemsnitsprisen. Begge indstillinger overlever en genstart.

Appens forside med den udpakkede enhed Cheapest hours åbnet: Price now, Hours in a row sat til 5 h, Ready by (hour) sat til 6:00, Cheap window now False, Plan 01:00 til 06:00 til 1,86 kr og gennemsnitsprisen
Den udpakkede enhed i appen.
6
Kontrol

Tjek konsollen

Åbn scriptet fra enhedens webinterface, og kig i konsollen. Inden for et minut skal der stå:

  • Start — version 2026-09-29 — scriptets version. Står der en ældre dato, er det et ældre script: sæt det ind igen fra denne side
  • Components found og Group filled — scriptet kører og har sine komponenter
  • Last deadline served — det sluttidspunkt, den seneste periode blev kørt til, eller “none” første gang
  • Grid company — netselskab og prisområde, fundet ud fra koordinaterne
  • Prices parsed og Prices known until — hvor mange timer der er hentet, og hvor langt frem de rækker
  • Plan — den valgte periode og dens gennemsnitspris, så snart der er en
Scriptets konsol med linjerne Start, Components found og Group filled efterfulgt af de seks komponenters nøgler
De første tre linjer, lige efter start.

Linjerne kommer i den rækkefølge, så den sidste viser, hvor langt scriptet nåede. Står der “No grid company found”, er koordinaterne forkerte. Står der “Step … did not respond — continuing”, har enheden ikke svaret på et trin inden for 20 sekunder. Scriptet springer det over og kører videre, så linjerne efter kommer stadig. Står der “Not enough free virtual component slots”, har enheden for mange virtuelle komponenter i forvejen: slet dem, der ikke bruges, og genstart scriptet. I enhedens webinterface kan du under Components se, hvor mange der er brugt: Groups and components: X/10. Springer en værdi, eller bliver den overskrevet hver hele time, er det et andet script, der skriver til samme komponent. Er konsollen tom, er websocket debug som regel slået fra — se How to add a script (på engelsk).

Brug perioden i en scene

Cheap window now er sand, mens den billigste periode kører. Der skal to scener (på engelsk) til: én, der tænder relæet, når Cheap window now bliver on, og én, der slukker det, når Cheap window now bliver off. Relæet kan sidde på en hvilken som helst Shelly, ikke kun den, scriptet kører på — for eksempel den, der styrer elbilladeren.

En scene i appen: Når Cheapest hours, Cheap window now On, udløser Once; gør en elbillader, Charging On; aktiv hele døgnet, alle ugens dage
Scenen, der starter opladningen. Den, der stopper den, er magen til, bare med Off.

Fjern løsningen igen

  • Slet scriptet, mens det kører. Så forsvinder komponenterne og gruppen sammen med det.
  • Er scriptet stoppet, så start det først, og slet det derefter. Sletter du et stoppet script, kan komponenterne blive liggende og være låst, så de ikke kan slettes i hånden.
  • Slet selv de scener, der bruger Cheap window now. De forsvinder ikke med scriptet.

Sådan vælger scriptet

  • Perioden slutter altid senest på sluttidspunktet. Scriptet vælger de billigste timer i træk, der kan nå at blive færdige inden Ready by.
  • Morgendagens priser kommer omkring kl. 13. Kan perioden række ind i morgendagen, står der Waiting for tomorrow’s prices i Plan, indtil de er der.
  • En periode, der er gået i gang, ligger fast. Nye priser flytter den ikke.
  • En genstart tænder ikke relæet to gange. Enheden husker, at den seneste periode allerede er kørt, så en strømafbrydelse bagefter ikke starter en ny inden sluttidspunktet.
  • Ændrer du Hours in a row undervejs, bliver den kørende periode længere eller kortere.
  • Uden priser starter scriptet så sent, at det stadig når at blive færdigt. Relæet bliver stadig tændt i tide, selv om et opslag slår fejl.
  • Scriptet tænder og slukker ikke selv noget. Cheap window now skifter én gang ved start og én gang ved slut, og det er scenen, der tænder og slukker. Tænder eller slukker du i hånden ind imellem, holder det til næste start eller slut.