Published: January 09, 2023, Edited by: Nicolas Padfield

Logging multiple energy meters with one ESP32 and getting NORDPOOL electricity prices

An after hours open sourced project by Nicolas, here is how to use one cheap ESP32 (DKR35/USD5/EUR5) to

  • Log the realtime electricity consumption on a standard electricity meter, using the standard pulse output
  • Log multiple meters (here 6, but up to about 30 should be possible, limited only by the number of free GPIO pins)
  • Upload the data to a server
  • Get the current electricity price in Denmark/Scandinavia/Nordpool
  • Calculate the total electricity price in Denmark, consisting of energinet, nettarif, elafgift, elspot, VAT. For example, currently (in DKR):
    • Energinet: 0.14
    • Nettarif: 1.9135
    • Elafgift: 0.01
    • Elspot: 1.34862503
    • Total: 3.41212503

The energy meters can be any industry standard energy meter with a pulse output. In this case DIN rail Carlo Gavazzi EM111 (PDF datasheet). These are single phase 230V 45A meters, but the advantage of using pulse reading is that almost all meters support it, including huge 3 phase current transformer 400A meters.

In Denmark at the moment, it is not unusual for the electricity price to be 0.4 kr/kWh at night and 5 kr/kWh during the 1700-2100 peak hours - a factor 15 difference! This project is to enable correct billing of multiple electric cars charging from a communal solution, without needing an expensive internet connected EVSE charger or smart meter per car.

The 6 meters and data logger enclosure.

The CSV file generated on the server.

When connecting pulse outputs on meters, remember

  • Only qualified personnel!
  • Double insulation
  • Insulated end terminals make it a lot easier to make nice and safe

How to connect the open collector pulse output on the electricity meter to a microcontroller:

I use a 5.7k pullup resistor. You can try using the 20k built in pullup resistor, but that is quite a high value at 3.3V if the cable is long.
The 100 ohm resistor is to protect the microcontrollers input. It will work without, but may not continue working in a real world setting and with long wires.

The ESP32 code uses a timer interrupt to sample the input pins 1000 times a second.

The meter pulses are 30ms or 100ms. 60ms is also common. With sampling at 1000 Hz, we can be sure to get all pulses, and at a high enough resolution to calclulate the approximate energy (kW) now. This is however only used to calculate the current per phase, not for billing. For billing, the counted number of 1 Wh pulses is the most accurate measure.

ESP32 code:

// https://www.energidataservice.dk/tso-electricity/DatahubPricelist
// https://api.energidataservice.dk/dataset/Elspotprices?limit=100&offset=0&start=2022-08-22T00:00&end=2022-08-22T00:00&filter=%7B%22PriceArea%22:%22DK2%22%7D&sort=HourUTC%20DESC&timezone=utc
// https://www.energidataservice.dk/guides/api-guides
// https://www.energidataservice.dk/tso-electricity/Elspotprices
// https://api.energidataservice.dk/dataset/Elspotprices?start=2022-08-18T05:00&end=2022-08-18T06:00&filter={%22PriceArea%22:%22dk2%22}

// https://cplusplus.com/reference/ctime/strftime/

/*

Common gnd to one side of electricity meter pulse output.  
Wire to other side of electricity meter pulse output.  
100 ohm in series from wire before it gets to esp32 for protection  
5.7k pullup from each to +3.3V (I don't trust the built in 20K pullup to be enough when these are long wires)

*/


#include <WiFi.h>
#include "time.h"
#include "sntp.h"
#include "credentials.h"
// esp_crt_bundle.h
#include <HTTPClient.h>

/*

// Credentials should be defined in a file credentials.h like this:

#define WIFIPASS "..."
#define WIFISSID "..."

#define PASSWORD "..."

*/

// uint32_t / float 

boolean led = false;  
boolean newlyBooted = true;  
unsigned long lastTimeUploaded = 0;  
unsigned long minimumBetweenUploadAttempts = 30000;  
unsigned long uploadInterval = 3600;  // in seconds, normally 3600 for one hour, except for testing  
unsigned long rebootAfter = 5400000;  // ms reboot if no succesfull uploads for 1.5 hours  
unsigned long previousWifiReconnectMillis = 0;  
unsigned long wifiReconnectInterval = 30000;  
unsigned long statusTime = 0;  
unsigned long statusTimeInterval = 2000;

unsigned long uploadCount = 0;  
unsigned long uploadAttemptCount = 0;

char timeStringBuff[50];  //50 chars should be enough

String serverName = "https://.../getprice.php";  
String serverPath = "";  
#define ALLOCATION_SIZE 512  // size to allocate for serverPath string - attempt to avoid fragmentation

hw_timer_t* My_timer = NULL;

const char* ntpServer1 = "pool.ntp.org";  
const char* ntpServer2 = "time.nist.gov";  
const long gmtOffset_sec = 3600;  
const int daylightOffset_sec = 3600;

boolean inputNow[6] = { 0, 0, 0, 0, 0, 0 };  
boolean inputLast[6] = { 0, 0, 0, 0, 0, 0 };

unsigned long pulseTime[6] = { 0, 0, 0, 0, 0, 0 };  
unsigned long lastTime[6] = { 0, 0, 0, 0, 0, 0 };

unsigned long count[6] = { 0, 0, 0, 0, 0, 0 };  
float powerkW[6] = { 0, 0, 0, 0, 0, 0 };  
float amps[6] = { 0, 0, 0, 0, 0, 0 };  
float ampsMax[6] = { 0, 0, 0, 0, 0, 0 };  
float kWh[6] = { 0, 0, 0, 0, 0, 0 };

unsigned int ppwh = 1;

const char* time_zone = "CET-1CEST,M3.5.0,M10.5.0/3";  // TimeZone rule for Europe/Rome including daylight adjustment rules (optional)

boolean isItUploadTime() {  
  // 1672534790 = Sun Jan 01 2023 00:59:50 GMT+0000

  if ((getUnixTime() - 1672534790) % uploadInterval == 0) {  // uploadInterval should normally be 3600, except during testing
    // it is x:59:50 of some hour
    return true;
  } else {
    return false;
  }
}

// Function that gets current epoch time
unsigned long getUnixTime() {  
  time_t now;
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) {
    //Serial.println("Failed to obtain time");
    return (0);
  }
  time(&now);
  return now;
}

void getCharLocalTime() {  
  time_t rawtime;
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) {
    Serial.println("Failed to obtain time");
    return;
  }

  strftime(timeStringBuff, sizeof(timeStringBuff), "%Y-%m-%d+%H:%M:%S", &timeinfo);
}

void printLocalTime() {  
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) {
    Serial.println("No time available (yet)");
    return;
  }
  Serial.println(&timeinfo, "%A, %B %d %Y %H:%M:%S");
}

// Callback function (gets called when time adjusts via NTP)
void timeavailable(struct timeval* t) {  
  Serial.println("Got time adjustment from NTP!");
  printLocalTime();
}

void IRAM_ATTR onTimer() {  
  // do stuff

  inputNow[0] = digitalRead(25);
  inputNow[1] = digitalRead(27);
  inputNow[2] = digitalRead(32);
  inputNow[3] = digitalRead(33);
  inputNow[4] = digitalRead(34);
  inputNow[5] = digitalRead(35);

  for (int i = 0; i < 6; i++) {

    if (!inputNow[i] && inputLast[i]) {  // there was a pulse leading edge

      //used to measure time between pulses.
      lastTime[i] = pulseTime[i];
      pulseTime[i] = micros();

      //Serial.print(" edge ");
      count[i]++;

      //Calculate power
      powerkW[i] = (3600000000.0 / (pulseTime[i] - lastTime[i])) / ppwh;

      // Calculate current
      amps[i] = powerkW[i] / 230.0;

      if (amps[i] > ampsMax[i]) {
        ampsMax[i] = amps[i];
      }

      //Find kwh elapsed
      kWh[i] = (1.0 * count[i] / (ppwh * 1000));  //multiply by 1000 to convert pulses per wh to kwh
    }
    inputLast[i] = inputNow[i];
  }
}

void setup() {

  pinMode(25, INPUT);  // 1
  pinMode(27, INPUT);  // 2
  pinMode(32, INPUT);  // 3
  pinMode(33, INPUT);  // 4
  pinMode(34, INPUT);  // 5
  pinMode(35, INPUT);  // 6

  pinMode(LED_BUILTIN, OUTPUT);

  serverPath.reserve(ALLOCATION_SIZE);  // pre-allocate string memory

  Serial.begin(115200);

  My_timer = timerBegin(0, 80, true);
  timerAttachInterrupt(My_timer, &onTimer, true);
  timerAlarmWrite(My_timer, 1000, true);  // 1000000 microseconds = 1 hz. 1000 = 1000 hz
  timerAlarmEnable(My_timer);             //Just Enable

  // set notification call-back function
  sntp_set_time_sync_notification_cb(timeavailable);

  /**
   * NTP server address could be aquired via DHCP,
   *
   * NOTE: This call should be made BEFORE esp32 aquires IP address via DHCP,
   * otherwise SNTP option 42 would be rejected by default.
   * NOTE: configTime() function call if made AFTER DHCP-client run
   * will OVERRIDE aquired NTP server address
   */
  sntp_servermode_dhcp(1);  // (optional)

  /**
   * This will set configured ntp servers and constant TimeZone/daylightOffset
   * should be OK if your time zone does not need to adjust daylightOffset twice a year,
   * in such a case time adjustment won't be handled automagicaly.
   */
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer1, ntpServer2);

  /**
   * A more convenient approach to handle TimeZones with daylightOffset 
   * would be to specify a environmnet variable with TimeZone definition including daylight adjustmnet rules.
   * A list of rules for your zone could be obtained from https://github.com/esp8266/Arduino/blob/master/cores/esp8266/TZ.h
   */
  //configTzTime(time_zone, ntpServer1, ntpServer2);

  //connect to WiFi
  Serial.printf("Connecting to %s ", WIFISSID);
  WiFi.begin(WIFISSID, WIFIPASS);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(" CONNECTED");
}

void loop() {

  if (millis() - statusTime > statusTimeInterval) {
    statusTime = millis();

    Serial.print("Counts");

    for (int i = 0; i < 6; i++) {
      Serial.print(" ");
      Serial.print(count[i]);
    }
    Serial.print(" ");
    printLocalTime();  // it will take some time to sync time :)

    if (WiFi.status() == WL_CONNECTED) {
      digitalWrite(LED_BUILTIN, led);
      led = !led;
    }
  }


  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every interval seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousWifiReconnectMillis >= wifiReconnectInterval)) {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousWifiReconnectMillis = currentMillis;
  }


  if ((isItUploadTime() && (millis() - lastTimeUploaded) > minimumBetweenUploadAttempts) || newlyBooted == true) {

    uploadAttemptCount++;

    //Check WiFi connection status
    if (WiFi.status() == WL_CONNECTED) {
      HTTPClient http;

      getCharLocalTime();  // update time

      //esp32_timestamp,esp32_uptime_s,uploadcount,uploadattemptcount,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,

      serverPath = serverName;
      serverPath += "?p=";
      serverPath += PASSWORD;
      serverPath += "&d=";
      serverPath += String(timeStringBuff);
      serverPath += ",";
      serverPath += millis() / 1000;  // uptime
      serverPath += ",";
      serverPath += uploadCount;
      serverPath += ",";
      serverPath += uploadAttemptCount;
      serverPath += ",";

      for (int i = 0; i < 6; i++) {
        serverPath += i + 1;
        serverPath += ",";
        serverPath += count[i];
        serverPath += ",";
        serverPath += powerkW[i];
        serverPath += ",";
        serverPath += amps[i];
        serverPath += ",";
        serverPath += ampsMax[i];
        serverPath += ",";
        serverPath += kWh[i];
        serverPath += ",";

        // clear some things
        count[i] = 0;
        powerkW[i] = 0;
        amps[i] = 0;
        ampsMax[i] = 0;
        kWh[i] = 0;
      }

      Serial.print("serverPath: ");
      Serial.println(serverPath);


      // Your Domain name with URL path or IP address with path
      http.begin(serverPath.c_str());

      // If you need Node-RED/server authentication, insert user and password below
      //http.setAuthorization("REPLACE_WITH_SERVER_USERNAME", "REPLACE_WITH_SERVER_PASSWORD");

      // Send HTTP GET request
      int httpResponseCode = http.GET();

      if (httpResponseCode > 0) {
        Serial.print("HTTP Response code: ");
        Serial.println(httpResponseCode);
        String payload = http.getString();
        Serial.println(payload);
      } else {
        Serial.print("Error uploading, Error code: ");
        Serial.println(httpResponseCode);
      }
      // Free resources
      http.end();

      uploadCount++;
      lastTimeUploaded = millis();
      newlyBooted = false;

    } else {
      Serial.println("Error uploading, WiFi Disconnected");
    }
  }

  if (millis() - lastTimeUploaded > rebootAfter) {
    ESP.restart();
  }
}

PHP code, on server end:

<?php

$pass = "...";
$iAmReallyInDenmarkAndWantToGetPrice = false; // change to true if you really want to bother the API server

// https://www.energidataservice.dk/tso-electricity/DatahubPricelist
// https://www.energidataservice.dk/guides/api-guides
// https://www.energidataservice.dk/tso-electricity/Elspotprices

$startTime = gmdate("Y-m-d\TH:i", time()-3660);
$endTime = gmdate("Y-m-d\TH:i", time()-60);

//$url = "https://api.energidataservice.dk/dataset/Elspotprices?limit=10&offset=0&start=2022-08-22T00:00&end=2022-08-22T00:00&filter=%7B%22PriceArea%22:%22DK2%22%7D&sort=HourUTC%20DESC&timezone=utc";
$url = "https://api.energidataservice.dk/dataset/Elspotprices?limit=10&offset=0&start=$startTime&end=$endTime&filter=%7B%22PriceArea%22:%22DK2%22%7D&sort=HourUTC%20DESC&timezone=utc";

//echo $url;
if($iAmReallyInDenmarkAndWantToGetPrice == true){  
  $json = file_get_contents($url);
}
//echo $json;

$obj = json_decode($json);

//var_dump($obj);

$elSpotExMoms = ($obj->records[0]->SpotPriceDKK)/1000.0;

//echo $elSpotExMoms;

$energinet = 0.140;
$nettarif = getNetTarif(time());
$elafgift = getElAfgift(time());  // was 0.904
$elSpot = $elSpotExMoms * 1.25;

$ialt = $energinet + $nettarif + $elafgift + $elSpot;

if(!isset($_GET['p'])){  
  echo("<br>Energinet: $energinet<br>Nettarif: $nettarif<br>Elafgift: $elafgift<br>Elspot: $elSpot<br>Ialt: $ialt");
}
//$d = strtotime('2023-08-01 20:00');
//echo "<br>";
//echo getNetTarif($d);

if ($_GET['p'] == $pass && strlen($_GET['d']) < 512 && strlen($_GET['d']) > 8) {

  $d = filter_var($_GET['d'],FILTER_SANITIZE_STRING);
  $d = urldecode($d);

  $fp = fopen('d.csv','a'); //opens file in append mode
  //server_timestamp, ... energinet,nettarif,elafgift,elspot,ialt

  //server_timestamp,esp32_timestamp,esp32_uptime_s,uploadcount,uploadattemptcount,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,meternum,count,powerkW,amps,ampsMax,kWh,energinet,nettarif,elafgift,elspot,ialt


  $r = fwrite($fp, date("Y-m-d H:i:s") . "," . $d . "". $energinet . ",".  $nettarif . ",".  $elafgift . ",".  $elSpot . ",". $ialt . "\n");
  fclose($fp);
  if($r){
    echo "ok";
  }
  else{
    echo "error";
  }

}

function getElAfgift($d){  
    $r=2000000000;

    if(date('Y', $d)== 2023 && date('m', $d)<=6){ // it is first half of 2023
      $r=0.01;
    }
    else {
      $r=0.904;
    }
    return $r;
}

function getNetTarif($d){  
    $r=1000000000;

if(date('Y', $d)== 2023 && date('m', $d)==1){  
    // until 1. feb 2023
    if(date('m', $d)>=10 || date('m', $d)<=3){ // it is winter

      if(date('H', $d)>=0 && date('H', $d)<=5){ // it is lavlast
        $r = 21.27;
      }
      else if(date('H', $d)>=17 && date('H', $d)<=20){ // it is spidslast
        $r = 191.35;
      }
      else { // it must be højlast
        $r=63.79;
      }
    }
    else { // it is summer

      if(date('H', $d)>=0 && date('H', $d)<=5){ // it is lavlast
        $r = 21.27;
      }
      else if(date('H', $d)>=17 && date('H', $d)<=20){ // it is spidslast
        $r = 82.92;
      }
      else { // it must be højlast
        $r=31.89;
      }
    }
    // until 1. feb 2023
}

if(date('Y', $d)== 2023 && date('m', $d)==2){  
    // 1. feb to 1. marts 2023
    if(date('m', $d)>=10 || date('m', $d)<=3){ // it is winter

      if(date('H', $d)>=0 && date('H', $d)<=5){ // it is lavlast
        $r = 18.37;
      }
      else if(date('H', $d)>=17 && date('H', $d)<=20){ // it is spidslast
        $r = 165.33;
      }
      else { // it must be højlast
        $r=55.11;
      }
    }
    else { // it is summer

      if(date('H', $d)>=0 && date('H', $d)<=5){ // it is lavlast
        $r = 18.37;
      }
      else if(date('H', $d)>=17 && date('H', $d)<=20){ // it is spidslast
        $r = 71.64;
      }
      else { // it must be højlast
        $r=27.56;
      }
    }
    // 1. feb to 1. marts 2023
}

if((date('Y', $d)== 2023 && date('m', $d)>2) || date('Y', $d)> 2023){

    // from 1. marts 2023
    if(date('m', $d)>=10 || date('m', $d)<=3){ // it is winter

      if(date('H', $d)>=0 && date('H', $d)<=5){ // it is lavlast
        $r = 15.09;
      }
      else if(date('H', $d)>=17 && date('H', $d)<=20){ // it is spidslast
        $r = 135.84;
      }
      else { // it must be højlast
        $r=45.28;
      }
    }
    else { // it is summer

      if(date('H', $d)>=0 && date('H', $d)<=5){ // it is lavlast
        $r = 15.09;
      }
      else if(date('H', $d)>=17 && date('H', $d)<=20){ // it is spidslast
        $r = 58.87;
      }
      else { // it must be højlast
        $r=22.67;
      }
    }
    // from 1. marts 2023
}




    return $r/100;
}

?>