1. Home
  2. Scripting
  3. EV charging SPOT-price tracker

Scripting

EV charging SPOT-price tracker

Get a per-session charging cost at spot plus transfer plus tax.

Updated August 2026

Requirements

  • A Shelly 3EM Pro or 3EM Gen3, metering only the charger’s circuit
  • A Google account with access to Sheets and Apps Script
  • Your regional tariff values: grid transfer day and night, electricity tax, and any contract margin

The price comes from spot-hinta.fi and covers FI, SE1–SE4, NO1–NO5, DK1–DK2, EE, LV and LT.

Part A — the Google Sheet

About three minutes. The Apps Script is bound to the template, so it travels with your copy — you only deploy it.

2
Apps Script

Deploy it as a web app

Open Extensions → Apps Script. The code is already there.

Choose Deploy → New deployment, give it a description, and deploy. Set Execute as: Me and Who has access: Anyone — the Shelly posts without a Google login, so it has to be reachable.

3
Apps Script

Authorise it

Click Authorize access, choose your account, tick Select all and continue. The script needs permission to edit its own spreadsheet.

Google will warn that the app is not verified. That is expected — it is your own copy, not something Google has reviewed. Choose Advanced → Go to project → Allow.

4
Apps Script

Copy the web app URL

The deployment shows a URL ending in /exec. Write it down — it goes into the Shelly script’s APPS_SCRIPT_URL in Part B.

5
Google Sheets

Reload the sheet and set the secret

Switch back to the sheet and reload the page. An EV-SPOT menu appears in the top bar.

Choose EV-SPOT → Set secret key and pick something long — twenty characters or so. Write it down: the exact same string goes into the Shelly script.

The secret is stored in your copy’s Script Properties. It is never part of the shared template, and it is the only thing standing between your sheet and anyone who learns the URL.

Part B — the Shelly

About five minutes.

  1. Find the device IP. In Shelly Smart Control, open the device and read it from the network panel. Open that address in a browser — see the web interface
  2. Create a script. Scripts → Create script, and paste in ev-spot-shelly.js from the bottom of this page
  3. Edit the CONFIG block. At minimum APPS_SCRIPT_URL, SECRET_KEY, AREA, and your transfer and tax values
  4. Save, start, and enable run on startup — or it stops at the first power cut
  5. Check the console. It prints EV-SPOT: started …. On the next 15-minute boundary while charging, the first row appears in the sheet

CONFIG parameters

All at the top of the Shelly script. Enter money with the same VAT convention as your bill — gross throughout, or net throughout.

Parameter Default What it is
APPS_SCRIPT_URL The /exec URL from Part A
SECRET_KEY The same string you set in the sheet
AREA FI FI, SE1–SE4, NO1–NO5, DK1–DK2, EE, LV, LT
POWER_THRESHOLD_W 100 Above this average power, the interval counts as charging
PRICE_RESOLUTION_60MIN false False for 15-minute spot pricing; true if you are still billed hourly
TRANSFER_DAY 0.0255 Grid transfer per kWh, day
TRANSFER_NIGHT 0.0112 Grid transfer per kWh, night
NIGHT_HRS 22–06 Which local hours count as night
TAX 0.02917875 Electricity tax per kWh — a Finnish rate; check your own
MARGIN 0.0 Your contract’s markup per kWh
EM_ID 0 The 3EM’s EMData component; leave it

Using the report

Fill in rows 1–4 of the Report tab: metering point, name, address and vehicle registration. That is the header block on the PDF.

Cell B6 is a month dropdown. All shows every session; otherwise pick a YYYY-MM and the table and totals filter themselves.

Each row is one charging session: start, end, duration, energy, average price and cost.

To export: pick the month, then File → Download → PDF. Choose A4 portrait and Fit to width, and enable Repeat frozen rows so the header block appears on every page.

The “data” tab is the record — keep it. Every 15-minute interval is stored there permanently, and the Report tab only reads from it. Deleting it loses the history. A single stray row can be removed safely; the report recalculates.

Reliability & troubleshooting

If a post fails, the datapoint is buffered in the Shelly’s KVS memory and retried on the next cycle. The Apps Script deduplicates by timestamp and holds a lock, so a resend never creates a row twice.

The price is frozen at the start of each interval, so every interval is priced with its own price rather than whatever the price happened to be when the block closed.

Symptom Cause
No rows appear Was the car actually drawing more than the threshold? Then check the URL and that the deployment is Web app / Anyone
unauthorized in the Shelly log SECRET_KEY does not match the sheet. Set it again from the menu
Rows arrive but the report is empty Run Build / repair sheets, and check that B6 is set to All
Price shows 0 spot-hinta.fi did not answer, or AREA is wrong. The next interval is priced once the price returns
Wrong times in the report Set the sheet’s time zone under File → Settings

The two scripts

ev-spot-shelly.js

For the Shelly 3EM Pro or Gen3, under Scripts.

// =============================================================================
//  EV charging SPOT-price tracker  —  Shelly 3EM PRO/Gen3 (device side, Espruino/mJS)
//  Measures charging energy at 15-min resolution, prices it as SPOT + transfer + tax
//  and POSTs every active interval to a Google Apps Script web app.
//
// =============================================================================

// ------------------------------- CONFIG --------------------------------------
// All editable without reading the code. Values in (€ or kr)/kWh (VAT included if you want gross).
let CONFIG = {
  APPS_SCRIPT_URL:        "https://script.google.com/macros/s/PASTE_YOUR_DEPLOY_ID/exec",
  SECRET_KEY:             "change-this-to-your-own-secret",
  AREA:                   "FI",          // FI, SE1-SE4, NO1-NO5, DK1-DK2, EE, LV, LT
  POWER_THRESHOLD_W:      100,           // standby/charging threshold (avg power per 15 min).
                                         // 3EM dedicated to the charger -> ~100 W separates
                                         // standby from charging and also captures short edge blocks.
  PRICE_RESOLUTION_60MIN: false,         // false = 15 min spot price. true = 60 min hourly average.
  TRANSFER_DAY:           0.0255,        // grid transfer fee, day    (€ or kr)/kWh
  TRANSFER_NIGHT:         0.0112,        // grid transfer fee, night  (€ or kr)/kWh
  NIGHT_HRS:              [22,23,0,1,2,3,4,5,6],  // night hours (local time)
  TAX:                    0.02917875,    // electricity tax (€ or kr)/kWh
  MARGIN:                 0.0,           // your own margin (€ or kr)/kWh
  EM_ID:                  0              // 3EM PRO/Gen3 triphase EMData
};

// Hard cap for buffered points (KVS limit is 50 keys). Protects the store from overflow.
let MAX_PENDING = 45;

// ------------------------------- STATE (globals) -----------------------------
let g_lastMin       = -1;    // last processed minute (once per minute)
let g_started       = true;  // boot: warm the price cache immediately
let g_busy          = false; // prevent overlapping boundary processing
let g_havePrice     = false; // has the SPOT price been fetched at least once
let g_blockPriceEur = 0;     // SPOT (€ or kr)/kWh — cached for pricing the NEXT block
let g_closePriceEur = 0;     // SPOT (€ or kr)/kWh — frozen for the block closing NOW
let g_blockEndSec   = 0;     // end of the closing block (epoch, UTC)
let g_sessionId     = 0;     // 0 = idle; otherwise epoch of the session's first block
let g_retryItems    = [];    // KVS pending points for the retry round
let g_retryIdx      = 0;
let g_retryCurKey   = "";
let g_freshDp       = null;  // point currently being sent (for the callback)

// ------------------------------- HELPERS -------------------------------------
function r3(x)   { return Math.round(x * 1000) / 1000; }
function r4(x)   { return Math.round(x * 10000) / 10000; }

// Epoch of the current 15-min boundary (= end of the closing block). This aligns to the local
// wall-clock quarter-hour for ANY whole-hour UTC offset, so BOTH CET (SE/NO/DK, UTC+1/+2) and
// EET (FI/EE/LV/LT, UTC+2/+3) work — no supported AREA has a half-hour offset. Uses UTC epoch,
// matching EMData's UTC timestamps; day/night below uses local device time instead.
function alignedNowSec() {
  return Math.floor(Date.now() / 1000 / 900) * 900;
}

// ------------------------------- PRICE FETCH ---------------------------------
function fetchPrice() {
  // priceResolution: "15" = current 15-min period price; "60" = current hour's average
  // (for consumers still billed per hour, not yet on 15-min metering).
  let resMin = CONFIG.PRICE_RESOLUTION_60MIN ? "60" : "15";
  Shelly.call("HTTP.GET",
    { url: "https://api.spot-hinta.fi/JustNow?region=" + CONFIG.AREA + "&priceResolution=" + resMin,
      timeout: 10, ssl_ca: "*" },
    onPrice, null);
}
function onPrice(res, err, msg, ud) {
  if (err === 0 && res && res.code === 200 && res.body) {
    let p = null;
    try { p = JSON.parse(res.body); } catch (e) { p = null; }
    // PriceWithTax = spot incl. VAT/ALV — the exact value the legacy JustNowPrice returned.
    if (p !== null && p.PriceWithTax !== undefined && p.PriceWithTax !== null) {
      g_blockPriceEur = (p.PriceWithTax * 1);   // (€ or kr)/kWh incl. ALV
      g_havePrice = true;
    }
  }
  // On failure keep the previous cache; if a price was never obtained, the closing
  // block is skipped (see processClosingBlock).
}

// ------------------------------- BOUNDARY HANDLING ---------------------------
function tick() {
  if (g_busy) { return; }                 // previous boundary still processing
  let now = new Date();                    // local time (DST-safe)
  let m = now.getMinutes();
  if (m === g_lastMin) { return; }         // once per minute
  g_lastMin = m;

  if (g_started) {                         // boot: fetch price, no block
    g_started = false;
    fetchPrice();
    return;
  }
  if (m % 15 !== 0) { return; }            // only on 15-min boundaries
  onBoundary(now);
}

function onBoundary(now) {
  g_blockEndSec   = alignedNowSec();       // end of the closing block
  g_closePriceEur = g_blockPriceEur;       // FREEZE the price before the new fetch (no race)

  // Fetch the price for the STARTING block. The flag controls the API resolution (15/60 min),
  // NOT the fetch frequency: in 60-min mode every quarter simply gets the same hourly average.
  fetchPrice();

  g_busy = true;
  retryPending();                          // -> processClosingBlock -> ... -> g_busy=false
}

// ------------------------------- BUFFER RETRY --------------------------------
// Retry everything pending in KVS BEFORE processing the new point.
function retryPending() {
  Shelly.call("KVS.GetMany", { match: "pending_*" }, onPendingList, null);
}
function onPendingList(res, err, msg, ud) {
  g_retryItems = [];
  if (err === 0 && res && res.items) {
    let it = res.items;                    // array: [{key, etag, value}, ...]
    for (let i = 0; i < it.length; i++) { g_retryItems.push(it[i]); }
  }
  g_retryIdx = 0;
  retryNext();
}
function retryNext() {
  if (g_retryIdx >= g_retryItems.length) {
    processClosingBlock();                 // retry done -> handle the fresh block
    return;
  }
  let item = g_retryItems[g_retryIdx];
  g_retryCurKey = item.key;
  let dp = null;
  try { dp = JSON.parse(item.value); } catch (e) { dp = null; }
  if (dp === null) {                       // corrupt -> delete and continue
    Shelly.call("KVS.Delete", { key: g_retryCurKey }, null, null);
    g_retryIdx++;
    retryNext();
    return;
  }
  postDp(dp, onRetryResult);
}
function onRetryResult(res, err, msg, ud) {
  if (err === 0 && res && res.code === 200) {
    Shelly.call("KVS.Delete", { key: g_retryCurKey }, null, null);  // success -> delete
  }
  g_retryIdx++;
  retryNext();                             // continue (failed ones stay for the next round)
}

// ------------------------------- BLOCK PROCESSING ----------------------------
function processClosingBlock() {
  if (!g_havePrice) {                      // no price yet (e.g. boot + fetch failed)
    g_busy = false;                        // skip this block; the price catches up next round
    return;
  }
  // Read the closing block's [end-900, end) one-minute records (15 of them).
  Shelly.call("EMData.GetData",
    { id: CONFIG.EM_ID, ts: g_blockEndSec - 900, end_ts: g_blockEndSec - 1, add_keys: false },
    onBlockData, null);
}
function onBlockData(res, err, msg, ud) {
  let wh = 0;
  if (err === 0 && res && res.data) {
    // Triphase key indices (add_keys:false): a=0, b=16, c=32 total_act_energy (Wh).
    let d = res.data;
    for (let i = 0; i < d.length; i++) {
      let vals = d[i].values;
      if (!vals) { continue; }
      for (let j = 0; j < vals.length; j++) {
        let row = vals[j];
        if (row) { wh += (row[0] || 0) + (row[16] || 0) + (row[32] || 0); }
      }
    }
  }
  finishBlock(wh);
}
function finishBlock(wh) {
  // Active if avg power > threshold. avgW = wh / 0.25 h  =>  active when wh > threshold*0.25.
  let active = (wh > (CONFIG.POWER_THRESHOLD_W * 0.25));

  if (!active) {
    g_sessionId = 0;                       // charging -> idle
    g_busy = false;
    return;
  }

  // idle -> charging: the session starts at this block
  let blockStart = g_blockEndSec - 900;
  if (g_sessionId === 0) { g_sessionId = blockStart; }

  let kwh      = wh / 1000;
  let hour     = new Date(blockStart * 1000).getHours();   // local hour for day/night
  let transfer = (CONFIG.NIGHT_HRS.indexOf(hour) > -1) ? CONFIG.TRANSFER_NIGHT : CONFIG.TRANSFER_DAY;
  let spot     = g_closePriceEur;                          // (€ or kr)/kWh (frozen at block start)
  let totalEur = spot + transfer + CONFIG.TAX + CONFIG.MARGIN;
  let costEur  = kwh * totalEur;

  g_freshDp = {
    ts:         blockStart,          // block start, epoch UTC (also the idempotency key)
    sid:        g_sessionId,
    kwh:        r3(kwh),
    spot_c:     r3(spot * 100),      // c/kWh for the report
    transfer_c: r3(transfer * 100),
    tax_c:      r3(CONFIG.TAX * 100),
    total_c:    r3(totalEur * 100),
    cost:       r4(costEur)          // (€ or kr)
  };
  postDp(g_freshDp, onFreshResult);
}
function onFreshResult(res, err, msg, ud) {
  if (!(err === 0 && res && res.code === 200)) {
    bufferPending(g_freshDp);              // send failed -> buffer to KVS
  }
  g_busy = false;
}

// ------------------------------- SEND & BUFFER -------------------------------
function postDp(dp, cb) {
  let body = JSON.stringify({
    secret:     CONFIG.SECRET_KEY,   // NOTE: secret in the body — Apps Script cannot see headers
    ts:         dp.ts,
    sid:        dp.sid,
    kwh:        dp.kwh,
    spot_c:     dp.spot_c,
    transfer_c: dp.transfer_c,
    tax_c:      dp.tax_c,
    total_c:    dp.total_c,
    cost:       dp.cost
  });
  // HTTP.POST uses the content_type parameter (default application/json), NOT a headers object.
  // Shelly follows the 302 redirect (Apps Script /exec -> googleusercontent) → res.code=200.
  Shelly.call("HTTP.POST",
    { url: CONFIG.APPS_SCRIPT_URL, body: body, timeout: 10, ssl_ca: "*",
      content_type: "application/json" },
    cb, null);
}
function bufferPending(dp) {
  if (g_retryItems.length >= MAX_PENDING) { return; }   // cap: don't exceed the KVS limit
  Shelly.call("KVS.Set", { key: "pending_" + dp.ts, value: JSON.stringify(dp) }, null, null);
}

// Start the 15 s poll loop LAST — mJS has no function hoisting, so every function
// referenced here (tick and everything it calls) must already be declared above.
print("EV-SPOT: started. AREA=" + CONFIG.AREA + ", threshold=" + CONFIG.POWER_THRESHOLD_W + "W");
Timer.set(15000, true, tick, null);

Code.gs

For the Google Sheet, under Extensions → Apps Script. It is already in the template copy — this is here for reference, or if you want to build the sheet yourself.

/**
 * EV charging SPOT-price tracker — Google Apps Script (bound to the template Sheet)
 *
 * Responsibilities:
 *   doPost(e)  — receives Shelly datapoints, validates the secret, writes to the "data" tab
 *                (LockService + idempotency = no duplicate rows even if the Shelly retries)
 *   setup()    — builds the "data", "Report" and hidden "_calc" tabs with their formulas
 *   onOpen()   — the ⚡ EV-SPOT menu (setup / secret / test / PDF / help)
 *
 * Session grouping + month/year filtering is done with FORMULAS ONLY (QUERY/SUM) → always
 * live, no time-driven trigger, no race conditions.
 */

var SHEET_DATA   = 'data';
var SHEET_REPORT = 'Report';
var SHEET_CALC   = '_calc';

var DATA_HEADERS = ['Timestamp','Session_ID','Energy_kWh','SPOT_c_per_kWh',
                    'Transfer_c_per_kWh','Tax_c_per_kWh','Total_c_per_kWh','Cost'];

// ============================================================================
//  HTTP INTERFACE
// ============================================================================
function doPost(e) {
  var lock = LockService.getScriptLock();
  lock.waitLock(30000);                       // serialize concurrent POSTs
  try {
    var secret = PropertiesService.getScriptProperties().getProperty('SECRET_KEY');
    var body = {};
    if (e && e.postData && e.postData.contents) {
      body = JSON.parse(e.postData.contents);
    } else if (e && e.parameter) {
      body = e.parameter;                      // fallback: secret+values in the query string
    }

    if (!secret || String(body.secret) !== String(secret)) {
      return jsonOut({ ok: false, error: 'unauthorized' });
    }

    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var sh = ss.getSheetByName(SHEET_DATA);
    if (!sh) { sh = buildDataSheet(ss); }

    var ts = Number(body.ts);
    if (!ts) { return jsonOut({ ok: false, error: 'missing ts' }); }

    // Idempotency: the same block (ts) only once, even if the response was lost and the Shelly retries.
    if (tsExists(sh, ts)) { return jsonOut({ ok: true, dup: true }); }

    sh.appendRow([
      new Date(ts * 1000),                     // Timestamp as a real datetime value (Sheet tz)
      Number(body.sid),
      Number(body.kwh),
      Number(body.spot_c),
      Number(body.transfer_c),
      Number(body.tax_c),
      Number(body.total_c),
      Number(body.cost)
    ]);
    return jsonOut({ ok: true });
  } catch (err) {
    return jsonOut({ ok: false, error: String(err) });
  } finally {
    lock.releaseLock();
  }
}

// Opening in a browser: confirms the deployment works.
function doGet(e) {
  return jsonOut({ ok: true, service: 'ev-spot', hint: 'POST datapoints to this URL' });
}

function jsonOut(obj) {
  return ContentService.createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}

// Is ts already logged? Scan only the last ~400 rows (retries are recent).
function tsExists(sh, ts) {
  var last = sh.getLastRow();
  if (last < 2) { return false; }
  var start = Math.max(2, last - 400);
  var vals = sh.getRange(start, 1, last - start + 1, 1).getValues();
  for (var i = 0; i < vals.length; i++) {
    var v = vals[i][0];
    if (v instanceof Date && Math.round(v.getTime() / 1000) === ts) { return true; }
  }
  return false;
}

// ============================================================================
//  MENU
// ============================================================================
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('⚡ EV-SPOT')
    .addItem('⚙️  Build / repair sheets', 'setup')
    .addItem('🔑  Set secret key',        'setSecret')
    .addSeparator()
    .addItem('🧪  Send test datapoint',    'sendTestPoint')
    .addSeparator()
    .addItem('❔  Setup guide',            'showHelp')
    .addToUi();
}

function setSecret() {
  var ui = SpreadsheetApp.getUi();
  var r = ui.prompt('Set secret key',
    'Enter the shared SECRET_KEY (exactly the same as CONFIG.SECRET_KEY in the Shelly script):',
    ui.ButtonSet.OK_CANCEL);
  if (r.getSelectedButton() === ui.Button.OK) {
    var k = r.getResponseText();
    if (k && k.length > 0) {
      PropertiesService.getScriptProperties().setProperty('SECRET_KEY', k);
      ui.alert('✅ Secret saved to Script Properties.');
    } else {
      ui.alert('Empty secret was not saved.');
    }
  }
}

function sendTestPoint() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sh = ss.getSheetByName(SHEET_DATA) || buildDataSheet(ss);
  var blockStart = Math.floor(Date.now() / 1000 / 900) * 900 - 900;  // previous 15-min block
  if (tsExists(sh, blockStart)) { blockStart -= 900; }
  sh.appendRow([new Date(blockStart * 1000), blockStart, 2.500, 5.100, 2.550, 2.828, 10.478, 0.2620]);
  SpreadsheetApp.getUi().alert('🧪 Test datapoint added to the "data" tab. Check "Report".');
}

function showHelp() {
  var html =
    '<div style="font-family:Arial;font-size:13px;line-height:1.5">'
  + '<h3>Setup (~2 min)</h3><ol>'
  + '<li>Run <b>⚡ EV-SPOT → ⚙️ Build / repair sheets</b>.</li>'
  + '<li><b>Deploy → New deployment → Web app.</b> Execute as: <i>Me</i>. Who has access: <i>Anyone</i>.</li>'
  + '<li>Approve the authorization prompt (Advanced → Go to project → Allow).</li>'
  + '<li>Copy the <b>Web app URL</b> → the Shelly script\'s <code>APPS_SCRIPT_URL</code>.</li>'
  + '<li><b>⚡ EV-SPOT → 🔑 Set secret key</b> (same as the Shelly <code>SECRET_KEY</code>).</li>'
  + '<li>Fill in the "Report" tab rows 1–4 (Metering point / Name / Address / Vehicle registration).</li>'
  + '<li>Test: <b>🧪 Send test datapoint</b> → a row appears on the "data" tab and "Report" updates.</li>'
  + '</ol><p>Pick the month/year from the "Report" tab dropdown. PDF: <b>File → Download → PDF</b>.</p></div>';
  SpreadsheetApp.getUi().showSidebar(
    HtmlService.createHtmlOutput(html).setTitle('EV-SPOT — setup'));
}

// ============================================================================
//  SHEET BUILDERS (setup)
// ============================================================================
function setup() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  buildDataSheet(ss);
  buildCalcSheet(ss);
  buildReportSheet(ss);
  SpreadsheetApp.getUi().alert(
    '✅ Sheets built.\n\nNext:\n' +
    '1) Deploy → New deployment → Web app\n' +
    '2) 🔑 Set secret key\n' +
    '3) Fill in Report tab rows 1–4');
}

function buildDataSheet(ss) {
  var sh = ss.getSheetByName(SHEET_DATA) || ss.insertSheet(SHEET_DATA);
  sh.getRange(1, 1, 1, DATA_HEADERS.length).setValues([DATA_HEADERS])
    .setFontWeight('bold').setBackground('#e8eaed');
  sh.setFrozenRows(1);
  sh.getRange('A2:A').setNumberFormat('yyyy-mm-dd hh:mm');
  sh.getRange('C2:C').setNumberFormat('0.000');
  sh.getRange('D2:G').setNumberFormat('0.000');
  sh.getRange('H2:H').setNumberFormat('0.0000');
  sh.setColumnWidth(1, 130);
  return sh;
}

// Hidden intermediate calc: grouped QUERY whose WHERE filters by the Report!B6 selection.
function buildCalcSheet(ss) {
  var sh = ss.getSheetByName(SHEET_CALC) || ss.insertSheet(SHEET_CALC);
  sh.clear();

  // A1: sessions grouped by Session_ID, filtered by the selected YYYY-MM.
  // NOTE: QUERY month() is 0-based (Jan=0) → hence (month - 1).
  var q =
    '=IFERROR(QUERY(' + SHEET_DATA + '!A2:H,'
  + '"select B, min(A), max(A), sum(C), sum(H) where B is not null"'
  + '&IF(OR(' + SHEET_REPORT + '!$B$6="",' + SHEET_REPORT + '!$B$6="All"),"",'
  + '" and year(A)="&LEFT(' + SHEET_REPORT + '!$B$6,4)&" and month(A)="&(VALUE(MID('
  + SHEET_REPORT + '!$B$6,6,2))-1))'
  + '&" group by B order by B desc",0),)';   // B (=Session_ID epoch) desc = newest session first
  sh.getRange('A1').setFormula(q);

  // Column G: dropdown source — "All" + the months present in the data (YYYY-MM).
  sh.getRange('G1').setValue('All');
  sh.getRange('G2').setFormula(
    '=IFERROR(SORT(UNIQUE(FILTER(TEXT(' + SHEET_DATA + '!A2:A,"yyyy-mm"),'
    + SHEET_DATA + '!A2:A<>"")),1,FALSE),)');

  sh.hideSheet();
  return sh;
}

function buildReportSheet(ss) {
  var sh = ss.getSheetByName(SHEET_REPORT) || ss.insertSheet(SHEET_REPORT);
  // Move Report to the first visible position.
  ss.setActiveSheet(sh); ss.moveActiveSheet(1);

  // --- Header block, rows 1–4 (the user fills column B) ----------------------
  sh.getRange('A1').setValue('Metering point').setFontWeight('bold');
  sh.getRange('A2').setValue('Name').setFontWeight('bold');
  sh.getRange('A3').setValue('Address').setFontWeight('bold');
  sh.getRange('A4').setValue('Vehicle registration').setFontWeight('bold');
  sh.getRange('B1:F1').merge();
  sh.getRange('B2:F2').merge();
  sh.getRange('B3:F3').merge();
  sh.getRange('B4:F4').merge();
  sh.getRange('B1:B4').setBackground('#fff8e1');

  // --- Filter, row 6 ---------------------------------------------------------
  sh.getRange('A6').setValue('Period (month):').setFontWeight('bold');
  // B6 MUST be plain text ("@"): otherwise Google auto-parses "2026-07" as a date, whose
  // value then no longer matches the text months in _calc!G (→ "violates data validation"),
  // and it would also break the QUERY month filter (LEFT/MID on B6).
  sh.getRange('B6').setNumberFormat('@');
  var rule = SpreadsheetApp.newDataValidation()
    .requireValueInRange(ss.getSheetByName(SHEET_CALC).getRange('G1:G100'), true)
    .setAllowInvalid(true).build();   // show dropdown; never hard-block (warning at most)
  sh.getRange('B6').setDataValidation(rule).setValue('All').setBackground('#e3f2fd');

  // --- Summary, row 8 --------------------------------------------------------
  sh.getRange('A8').setValue('Total energy (kWh):').setFontWeight('bold');
  sh.getRange('B8').setFormula('=IFERROR(SUM(D11:D),0)').setNumberFormat('0.000');
  sh.getRange('D8').setValue('Total cost (€/kr):').setFontWeight('bold');
  sh.getRange('E8').setFormula('=IFERROR(SUM(F11:F),0)').setNumberFormat('0.00');

  // --- Table headers, row 10 -------------------------------------------------
  var hdr = ['Start','End','Duration','Energy (kWh)','Average price (c/kWh)','Cost (€/kr)'];
  sh.getRange(10, 1, 1, hdr.length).setValues([hdr])
    .setFontWeight('bold').setBackground('#e8eaed');

  // --- Session rows from row 11 (ARRAYFORMULA over _calc) --------------------
  // _calc: A=SID, B=min(A)=start, C=max(A)=last block's start, D=sum energy, E=sum cost.
  var C = "'" + SHEET_CALC + "'";
  sh.getRange('A11').setFormula('=ARRAYFORMULA(IF(' + C + '!A1:A="","",' + C + '!B1:B))');                         // Start
  sh.getRange('B11').setFormula('=ARRAYFORMULA(IF(' + C + '!A1:A="","",' + C + '!C1:C+(15/1440)))');               // End = last block's start + 15 min
  sh.getRange('C11').setFormula('=ARRAYFORMULA(IF(' + C + '!A1:A="","",(' + C + '!C1:C-' + C + '!B1:B)+(15/1440)))'); // Duration
  sh.getRange('D11').setFormula('=ARRAYFORMULA(IF(' + C + '!A1:A="","",' + C + '!D1:D))');                         // Energy
  sh.getRange('E11').setFormula('=ARRAYFORMULA(IF(' + C + '!A1:A="","",IF(' + C + '!D1:D=0,0,' + C + '!E1:E/' + C + '!D1:D*100)))'); // Average price c/kWh
  sh.getRange('F11').setFormula('=ARRAYFORMULA(IF(' + C + '!A1:A="","",' + C + '!E1:E))');                         // Cost

  // --- Formats ---------------------------------------------------------------
  sh.getRange('A11:A').setNumberFormat('yyyy-mm-dd hh:mm');
  sh.getRange('B11:B').setNumberFormat('yyyy-mm-dd hh:mm');
  sh.getRange('C11:C').setNumberFormat('[h]:mm');
  sh.getRange('D11:D').setNumberFormat('0.000');
  sh.getRange('E11:E').setNumberFormat('0.00');
  sh.getRange('F11:F').setNumberFormat('0.00');
  sh.setColumnWidth(1, 130); sh.setColumnWidth(2, 130); sh.setColumnWidth(3, 70);
  sh.setColumnWidth(4, 100); sh.setColumnWidth(5, 150); sh.setColumnWidth(6, 120);
  sh.setFrozenRows(10);

  return sh;
}

The price-fetch logic is based on the spot-hinta.fi LiveTariffUpload example.

What this does

Charging an electric car at home means the cost disappears into the household bill. If somebody else should pay for it — an employer, a company, a tenant — you need to know what the charging alone cost, and when.

This meters the charger with a Shelly 3EM, prices every 15-minute interval at spot price + grid transfer + electricity tax, and logs it to a Google Sheet that groups the intervals into charging sessions. Pick a month, export a PDF, hand it over.

No home server, no database, no subscription. A Shelly, a Google account, and about fifteen minutes.

How it works

Three parts, and the middle one is the only surprising bit.

The Shelly reads its own metering every 15 minutes. If the interval’s average power was above a threshold, it counts as charging: the script fetches the spot price, adds transfer and tax, works out the cost, and posts the result.

An Apps Script web app receives that post and appends a row to the Sheet. It exists because a Shelly cannot do the OAuth2 authentication the Google Sheets API requires — the web app is a small relay that is allowed to write to its own spreadsheet.

The Sheet does the rest with formulas alone. A hidden tab groups intervals into sessions; the Report tab filters by month and totals it.