Dieses Skript ist das erste echte Release. Es darf von hier per "mark, copy and paste" heruntergeladen werden.

Ich übernehme keine Gewähr für die Folgen des Einsatzes, darf aber feststellen, dass die Implementation sehr solide gebaut ist.

// (C) Gerhard Eichelsdörfer alias eiche
// Device: Shelly Plus 2
// Application for controlling a slave clock
// Version: 2023-04-17_01
// description at https://zisterne.eichelsdoerfer.net/index.php/projekte/89-emulation-einer-hauptuhr-per-shelly-plus-2

// There must exist two schedule jobs or more, a first (id=1) for sending regular pulses, a second (id=2) for sending missing pulses.

// a schedule job may create per http get
// http://<ip-address>/rpc/Schedule.Create?timespec="0 * * * * *"&calls=[{"method":"Script.Eval","params":{"id":1,"code":"pulse(500)"}}]
// to update the timespec with schedule job id=1
// http://<ip-address>/rpc/Schedule.Update?id=1&timespec="58 * * * * *"
// to update the duration parameter to 400ms
// http://<ip-address>/rpc/Schedule.Update?id=1&calls=[{"method":"Script.Eval","params":{"id":1,"code":"pulse(400)"}}]
// The duration parameter of function pulse() may unuse, if the duration is stored in the Config,
// but this pulse parameter allows the script independent define of the pulse duration.
// If the pulse duration in seconds is sufficient, Timer.set can be omitted and toggle_after used in Switch.Set.

// Schedule job 1 has a timespec "x * * * * *" (every minute at second x) and a params.code "pulse(<duration in ms>)".
// Schedule job 2 has a higher frequently timespec like "*/2 * * * * *" (every 2 seconds) and a params.code "pulse(<duration in ms>)".

let debug = false; // set to true if you need prints
let chkTime = false; // set to true if you want to check the values of getTime()

let AppKey = "app_"; // the key prefix for app keys in KVS

let Config = {
  DailyMin: 1440, // number of minutes of a day
  ClkPeriod: 720, // clock period in minutes - change it to 1440, if your clock shows 24 hours or set in KVS app_period to 24
  Decision: 2, // decision factor for adjusting the clock display
  NvsEver: true, // if true, timestamp is written at every pulse, also when clock is adjusted
  // If you change any of the following string values, you must also change the assigned structure identifier
  // at the end of this script in Shelly.call("KVS.GetMany", {"match":AppKey+'*'}, ...).
  KeyPulse: AppKey+"pulse", // key of pulse timestamp in KVS
  KeyPeriod: AppKey+"period", // key of clock period
  KeyDecision: AppKey+"decision" // key of decision factor
};

let Status = {
  PulseTs: 0, // last clock trigger timestamp, default 0 daily minutes = 00:00
  SchedJob: 0, // the enabled schedule job, 0: neither job 1 nor job 2
  Remain: 0, // remaining additional pulses
  En: false, // if true, the clock works, else it sleeps
  Run: true // the running status of the clock
};

// enable or disable one of the schedule jobs (1 or 2) without checking the id
function scheduleJob(id, en) {
  let doit = (Status.SchedJob & id)===id ? !en : en; // on change
  if (doit) {
    Shelly.call("Schedule.Update", {"id":id, "enable":en},
      function (res, errc, errm) {
        if (errc) print(errc, errm, JSON.stringify(res));
      }
    );
  }
  if (en) Status.SchedJob |= id else Status.SchedJob &= ~id; // register in Status.SchedJob
}

// change between two alternative schedule jobs 1 and 2
function changeSchedJob(id) { // id of the job to enable
  if (Status.SchedJob===0) scheduleJob(id, true)
  else {
    let i = [[2,1],[1,2]];
    scheduleJob(i[id-1][0], false);
    scheduleJob(i[id-1][1], true);
  }
}

// store the number of daily minutes in KVS
function putPulseTs () {
  Shelly.call("KVS.Set", {"key":Config.KeyPulse, "value":Status.PulseTs},
    function (res, errc, errm) {
      if (errc) print(errc, errm, JSON.stringify(res));    
    }
  );  
}

// send a pulse if the clock is in running mode
function pulse(duration) {
  if (Status.En) {
    if (Status.Run) {
      Shelly.call("Switch.Set", {"id":0, "on":true});
      Timer.set(duration, false,
        function(){
          Shelly.call("Switch.Toggle", {"id":0});
        }
      );
    } else {
      Status.Remain++;
      if (Status.Remain===0) Status.Run = true;
    }
    return duration;
  }
}

// calculate the number of pulses including the minutes while schedule job 2 works
function calcPulses(n) {
  let a = Math.floor((n+14)/30);
  return n + a + Math.floor((a+14)/30); // pulses in distances of 2s
}

// add n pulses, e.g. to adjust the displayed time or to switch to "summer time"
function addPulses(n, force) {
  if (force===undefined || force===null) force = false;
  if (Status.Run || force) {
    Status.Remain = calcPulses(n);
    Status.Run = Status.Remain>=0 ? true : false; //Status.Run = Status.Remain>=0;
    if (chkTime) print("addPulses n:", n, "Status:", JSON.stringify(Status));
    if (Status.Remain>0) changeSchedJob(2);
  }
}

// substract n pulses, e.g. to adjust the displayed time or to switch to "normal time"
function subPulses(n, force) {
  if (force===undefined || force===null) force = false;
  if (Status.Run || force) {
    Status.Remain = -n;
    Status.Run = Status.Remain===0 ? true : false; //Status.Run = Status.Remain===0;
    if (chkTime) print("subPulses n:", n, "Status:", JSON.stringify(Status));
    changeSchedJob(1);
  }
}

// adjust timestamp in relation to display period
function adjTsPeriod(dt) {
  while (dt>=Config.ClkPeriod) {
    dt -= Config.ClkPeriod;
    Status.PulseTs += Config.ClkPeriod;
    Status.PulseTs %= Config.DailyMin;
    putPulseTs();
  }
  while (dt<0) {
    dt += Config.ClkPeriod;
    if (Status.PulseTs>=Config.ClkPeriod) Status.PulseTs -= Config.ClkPeriod;
    putPulseTs();
  }
  return dt;
}

// a user can adjust the displayed time
function setClockRel(dt) {
  Status.En = true;
  if(dt===undefined || dt===null || dt===0) {
    Status.Run = true;
    return "synchronized, nothing else to do";
  }
  dt = adjTsPeriod(dt);
  if(dt>0) addPulses(dt, true) else subPulses(-dt, true);
  return "setting clock relative by "+JSON.stringify(dt)+" minutes";
}

// syncs the displayed time by a user
// todo:
// 1. import an additional date
// 2. store date and time into KVS per JSON string {"lastSync":{"date":<date>,"time":<time>}}
function setClock(h, m) {
  if (h<0 || h>23 || m<0 || m>59) return "invalid time";
  let tt = 60*h + m; // target time
  let hh = JSON.stringify(h), mm = JSON.stringify(m);
  let dt = adjTsPeriod(tt-Status.PulseTs);
  Status.En = true;
  if(dt>0) addPulses(dt) else if(dt<0) subPulses(-dt) else Status.Run = true;
  return "setting clock to "+(hh.length<2?"0":"")+hh+':'+(mm.length<2?"0":"")+mm+" -> "+JSON.stringify(tt);
}

// set the actually valid daily minutes as result of the imported time
// this should be applied depending on the actual time displayed
function setPulseTs(h, m) {
  if (h<0 || h>23 || m<0 || m>59) return "invalid time";
  Status.PulseTs = 60*h + m; // convert into local day minute
  // put the timestamp additional into KVS
  Status.En = false;
  Shelly.call("KVS.Set", {"key":Config.KeyPulse, "value":Status.PulseTs},
    function (res, errc, errm) {
      if (errc) print(errc, errm, JSON.stringify(res))
      else Status.En = true;   
    }
  );
  return "setting day minutes to " + JSON.stringify(Status.PulseTs);
}

// check the synchronization and adjust the daily minutes if available
// this also implements daylight saving time and standard time changeover automation
function getTime() {
  Shelly.call("Sys.GetStatus", {}, function (res) {
    if (res.time===null) return;
    // from time string
    let h = JSON.parse(res.time.slice(0,2));
    let m = JSON.parse(res.time.slice(3));
    let DayMin = 60*h+m; // local elapsed minutes of this day
    let dt = DayMin - Status.PulseTs;
    dt = adjTsPeriod(dt);
    if (chkTime) {
      print("getTime: DayMin =", DayMin, ", Status.PulseTs = ", Status.PulseTs, ", dt =", dt);
      print(JSON.stringify(Status));
    }
    Status.En = true;
    Status.Run = true;
    if (dt!==0) {
      // The fast action must be Config.Decision or more faster than the more economical action to be used.
      if (dt < Math.floor(Config.ClkPeriod/(30+Config.Decision))*30) addPulses(dt)
      else subPulses(Config.ClkPeriod-dt);
    }
  });
}

// the event handler when changing output 0
Shelly.addEventHandler(
  function (event) {
    let info = event.info;
    if(info.component==="switch:0" && info.event==="toggle") {
      if (debug) print(JSON.stringify(event));
        if (Status.En && info.state) {
        Status.PulseTs = (Status.PulseTs + 1) % Config.DailyMin;
        if (Config.NvsEver) putPulseTs();
        if (Status.SchedJob===2) {
          Status.Remain--;
          if (Status.Remain<=0) changeSchedJob(1);
        } else if (!Config.NvsEver) putPulseTs();
      }
      else Timer.set(500, false, function(){ Shelly.call("switch.toggle", {"id":1}); }); // new, instead of output configuration
    }
  }
);

// get the status of schedule job 1 vs. job 2
Shelly.call("Schedule.List", {}, function(res) {
  if (res.jobs[0].enable) Status.SchedJob |= 1;
  if (res.jobs[1].enable) Status.SchedJob |= 2;
});

// get the last pulse timestamp before reboot from KVS
Shelly.call("KVS.GetMany", {"match":AppKey+'*'},
  function (res, errc, errm) {
    if (errc) print(errc, errm, JSON.stringify(res));
    if(!errc) {
      let msg = "{";
      let itemsStr = JSON.stringify(res.items);
      if(debug) print(itemsStr);
      if (itemsStr.indexOf(Config.KeyPulse)>=0) {
        Status.PulseTs = res.items.app_pulse.value; // get the timestamp, app_pulse may be changed - look at Config
        msg += '"PulseTs":'+JSON.stringify(Math.round(Status.PulseTs));
      }
      if (itemsStr.indexOf(Config.KeyPeriod)>=0) {
        let cp = res.items.app_period.value; // get the clock period, period may be changed - look at Config
        if (cp>0) {
          Config.ClkPeriod = cp * 60;
          msg += ', "ClkPeriod":'+JSON.stringify(Config.ClkPeriod);
        }
      }
      if (itemsStr.indexOf(Config.KeyDecision)>=0) {
        let cd = res.items.app_decision.value; // get the decision factor, decision may be changed - look at Config
        if (cd>0) {
          Config.Decision = cd;
          msg += ', "Decision":'+JSON.stringify(Config.Decision);
        }
      }
      msg += '}';
      if (debug) print("from KVS: ", msg);
    }
  }
);

// after a short while enable schedule job 1 and start the clock
Timer.set(1000, false, function () {
  changeSchedJob(1);
});
// now pulse generation can be processed