// Spotelly Version 2.0
// This script uses EPEX spot hourly energy prices to control the power output of a Shelly device.
// See https://github.com/towiat/spotelly for the full documentation.
// This script uses price data from http://energy-charts.info

// <<<<< START OF CONFIGURATION - change values below to your preference >>>>>

let epexBZN = "AT"; // EPEX Bidding Zone - see documentation for valid codes

let switchOnDuration = 4; // minimum 1, maximum 24
let timeWindowStartHour = 7; // minimum 0, maximum 23
let timeWindowEndHour = 19; // minimum 0, maximum 23
let blockMode = true; // set calculation mode
let priceLimit = Infinity; // in cent/kWh

// change this function to display prices according to the conditions of your contract
function priceModifier(spotPrice) {
  return spotPrice; // spotPrice is in cent/kWh
}

let switchID = 0; // set the switch ID for multi-switch devices

let telegramActive = false; // set to true to activate the Telegram feature

// the following settings have no effect when telegramActive is false
let telegramToken = ""; // must be set when telegramActive is true
let telegramChatID = ""; // must be set when telegramActive is true
let deviceName = "Shelly"; // will be included in telegrams to identify the sender
let sendSchedule = true; // send telegram with schedule and price details after each run
let sendPowerOn = true; // send telegram when power has been switched on by this script
let sendPowerOff = true; // send telegram when power has been switched off by this script

// <<<<< END OF CONFIGURATION - no changes needed below this line >>>>>

let times = {};
let randomOffset = Math.random() * 300000;
let nextUpdate = 0;
let html = atob("{{ html }}"); // placeholder for compressed html - used by build script

function logAndNotify(msg, sendTelegram) {
  print(msg);
  if (telegramActive && sendTelegram) {
    Shelly.call("http.post", {
      url: "https://api.telegram.org/bot" + telegramToken + "/sendMessage",
      header: { content_type: "application/json" },
      body: { chat_id: telegramChatID, text: deviceName + ": " + msg },
    });
  }
}

function setSwitch(value) {
  if (Shelly.getComponentStatus("switch", switchID).output === value) return;
  let message = value ? "ON." : "OFF.";
  let flag = value ? sendPowerOn : sendPowerOff;
  Shelly.call("Switch.Set", { id: switchID, on: value }, function (result, error_code) {
    if (error_code === 0) {
      logAndNotify("Switch " + switchID + " has been turned " + message, flag);
    } else {
      logAndNotify("ERROR: Switch " + switchID + " could not be turned " + message, flag);
    }
  });
}

function getHour(timestamp, hour) {
  let d = new Date(timestamp);
  return new Date(
    d.getFullYear(),
    d.getMonth(),
    d.getDate() + Number(hour <= d.getHours()),
    hour,
  ).getTime();
}

function fetchPrices(window) {
  Shelly.call(
    "http.get",
    {
      url:
        "https://api.energy-charts.info/price?bzn=" +
        epexBZN +
        "&start=" +
        window.start / 1000 +
        "&end=" +
        (window.end / 1000 - 3600), // do not include last hour
    },

    function (res, error_code, error_message) {
      let data;

      let error;
      let success = true;
      if (error_code !== 0) {
        error = "API call failed with error " + error_code + " (" + error_message + "). ";
        success = false;
      } else if (res.code !== 200) {
        error = "API server responded with " + res.code + " (" + res.message + "). ";
        success = false;
      } else {
        data = JSON.parse(res.body);
        let expected = (window.end - window.start) / 3600000;
        if (data.price.length !== expected) {
          error = "Retrieved " + data.price.length + " records; expected " + expected + ". ";
          success = false;
        }
      }

      if (!success && window.end > Date.now()) {
        // try again after 20 minutes
        let period = 1200000;
        nextUpdate = Date.now() + period;
        print(error + "Trying again at " + new Date(nextUpdate).toString());
        Timer.set(period, false, fetchPrices, window);
        return;
      }

      (blockMode ? calculateBlock : calculateNonBlock)(data);

      for (let key of Object.keys(times)) {
        let hour = Number(key);
        if (!(hour + 3600000 in times)) {
          times[hour + 3600000] = null; // set switch off indicator if needed
        }
      }

      logAndNotify("Timetable has been updated.", sendSchedule);
      nextUpdate = getHour(nextUpdate, 15) + randomOffset;
    },
  );
}

function calculateBlock(data) {
  let startIndex = 0;
  let lowestSum = Infinity;
  for (let i = 0, j = switchOnDuration; j <= data.price.length; i++, j++) {
    let sliceSum = 0;
    data.price.slice(i, j).forEach(function (price) {
      sliceSum += price;
    });
    if (sliceSum < lowestSum) {
      startIndex = i;
      lowestSum = sliceSum;
    }
  }

  let cutoff = Date.now() + 5000; // only set switch markers for future hours
  for (let i = startIndex; i < startIndex + switchOnDuration; i++) {
    let hour = data.unix_seconds[i] * 1000;
    let price = priceModifier(data.price[i] / 10);
    if (hour > cutoff && price <= priceLimit) times[hour] = price;
  }
}

function calculateNonBlock(data) {
  // do this <switchOnDuration> times:
  // 1. move the element with the lowest price to the end of both arrays
  // 2. pop the elements and set switch ON markers
  let prices = data.price;
  let hours = data.unix_seconds;
  let cutoff = Date.now() + 5000; // only set switch markers for future hours
  let temp;
  for (let i = 0; i < switchOnDuration; i++) {
    for (let j = 1; j < prices.length; j++) {
      if (prices[j] > prices[j - 1]) {
        temp = prices[j];
        prices[j] = prices[j - 1];
        prices[j - 1] = temp;
        temp = hours[j];
        hours[j] = hours[j - 1];
        hours[j - 1] = temp;
      }
    }
    let hour = hours.pop() * 1000;
    let price = priceModifier(prices.pop() / 10);
    if (hour > cutoff && price <= priceLimit) times[hour] = price;
  }
}

function calculateWindow() {
  let start = getHour(Date.now(), timeWindowStartHour);
  let end = getHour(start, timeWindowEndHour);

  Timer.set(randomOffset, false, fetchPrices, { start: start, end: end });
}

// eslint-disable-next-line no-unused-vars
function hourly() {
  let now = Date.now();
  let thisHour = now - (now % 3600000);

  if (thisHour in times) {
    setSwitch(times[thisHour] !== null);
    delete times[thisHour];
  }

  // start calculation for next time window at 15:00
  if (new Date(thisHour).getHours() === 15) {
    calculateWindow();
  }
}

function startUp() {
  nextUpdate = getHour(Date.now(), 15) + randomOffset;
  Shelly.call("Schedule.List", {}, function (result) {
    let scriptID = Shelly.getCurrentScriptId();
    let method = "Schedule.Update";
    let schedule = null;
    let timespec = "0 0 * * * *";
    let code = "hourly()";

    for (let job of result.jobs) {
      let call = job.calls[0];
      if (!(call.method.toLowerCase() === "script.eval" && call.params.id === scriptID)) {
        continue; // this is not our schedule - skip
      }
      if (job.timespec === timespec && call.params.code === code) {
        return; // this IS our schedule and it matches the configuration - we are done
      }
      print("Schedule has changed.");
      schedule = job;
      schedule.timespec = timespec;
      call.params.code = code;
      break;
    }

    if (schedule === null) {
      // schedule does not exist - create it
      method = "Schedule.Create";
      schedule = {
        enable: true,
        timespec: timespec,
        calls: [{ method: "Script.Eval", params: { id: scriptID, code: code } }],
      };
    }

    Shelly.call(method, schedule);
  });
}

function spotellyEndpoint(request, response) {
  response.headers = [
    ["Content-Type", "text/html"],
    ["Content-Encoding", "gzip"],
  ];
  response.body = html;
  response.send();
}

function dataEndpoint(request, response) {
  response.headers = [["Content-Type", "application/json"]];
  response.body = JSON.stringify({
    nextUpdate: nextUpdate,
    switchID: switchID,
    times: times,
  });
  response.send();
}

HTTPServer.registerEndpoint("spotelly", spotellyEndpoint);
HTTPServer.registerEndpoint("data", dataEndpoint);

startUp();