EV charging SPOT-price tracker

Application Guide

EV charging SPOT-price tracker

Measure your EV’s charging energy with a Shelly 3EM PRO/Gen3, price it as SPOT + transfer + tax at 15-minute resolution, and report the result into Google Sheets — a tax-friendly PDF in a few clicks. No home server, no database.

Last updated: July 2026

What this guide covers

This is a standalone, freely shareable solution that meters your EV’s charging energy with a Shelly 3EM PRO/Gen3, prices each 15-minute interval as SPOT price + grid transfer + electricity tax, and logs the result into a Google Sheet.

There is no home server, no database and no other infrastructure. Any Shelly 3EM PRO/Gen3 owner can set it up in about 15 minutes.

How it works

The Shelly meters the charging circuit, prices each active interval, and POSTs it to a bound Google Apps Script that appends the row to your Sheet.

Shelly 3EM mJS script · every 15 min
Apps Script bound Web App relay
Google Sheet “data” + “Report”
  1. On every 15-minute boundary (:00/:15/:30/:45) the Shelly reads the previous interval’s energy from its own memory.
  2. If the interval’s average power exceeded the threshold (default 100 W), it counts as charging. The script fetches the SPOT price from spot-hinta.fi, adds transfer + tax + margin, and computes the cost.
  3. The datapoint is POSTed to the Apps Script web app, which appends it to the “data” tab.
  4. The “Report” tab groups the data into charging sessions with formulas only and filters by month/year.

Prerequisite: the 3EM must measure only the charger’s circuit, not the whole house. Then 100 W cleanly separates the charger’s standby (a few watts) from active charging. An interval classified as active is always logged with its true measured energy.

Why the Apps Script layer? A Shelly device cannot perform the OAuth2 authentication the Google Sheets API requires. An Apps Script Web App is a lightweight, free relay that is bound directly to the Sheet — the code travels with the Sheet when it is copied.

Requirements

  • A Shelly 3EM PRO or 3EM Gen3 metering only the charger’s circuit.
  • A Google account with access to Google Sheets and Apps Script.
  • The charger on its own circuit so the 100 W threshold reliably separates standby from charging.
  • Your regional tariff values: grid transfer (day/night), electricity tax, and any contract margin.

Part A — Set up the Google Sheet

The fastest route: copy the ready-made template. The Apps Script (Code.gs) is bound to the Sheet, so it comes with your copy — you only deploy it, copy the URL, and set a secret. About 3 minutes.

Make a copy of the template →

Opens Google’s “Make a copy” dialog. The copy is yours; the template stays untouched.

Make your own copy

Click the button above (or open the /copy link). Google shows a Copy document dialog noting that the attached Apps Script is copied too. Click Make a copy.

Google Sheets Copy document dialog noting the attached Apps Script file will also be copied.
The Copy document dialog — the Apps Script comes along with the copy.

Open your copy and go to Apps Script

A “Copy of ev-spot-tracker” opens with the Report, data and hidden _calc tabs already built. Open Extensions → Apps Script — the Code.gs that powers the tracker is already there.

Copy of ev-spot-tracker open, showing the ready-made Report tab.
The copied Sheet opens with the Report tab ready.

Deploy as a Web App

In the Apps Script editor choose Deploy → New deployment. Type a name in New description (e.g. ev-spot-tracker), then click Deploy.

Apps Script New deployment dialog with a description entered and Deploy highlighted.
Deploy → New deployment → name it → Deploy.

Authorize access

Click Authorize access. A Google account popup opens — choose your account, tick Select all and click Continue. The script needs to edit its own spreadsheet.

If Google shows “this app isn’t verified”, that is normal — the script is your own copy, not Google-verified. Choose Advanced → Go to project (unsafe) → Allow.

Authorize access button and the Google account popup with Select all and Continue.
Authorize access opens the account popup → Select all → Continue.

Copy the Web app URL

After deploying, copy the Web app URL (of the form https://script.google.com/macros/s/AKfy…/exec). Write it down — you paste this into the Shelly script’s CONFIG (APPS_SCRIPT_URL) in Part B, step 3.

Deployment success dialog showing the Web app URL and Copy button.
Copy the /exec URL from the deployment dialog.

Reload the Sheet

Switch back to the Sheet tab and reload the page. This makes the ⚡ EV-SPOT menu appear in the top bar.

Google Sheets tab being reloaded so the EV-SPOT menu appears.
Reload the Sheet tab — the ⚡ EV-SPOT menu then appears.

Set the secret key

Choose ⚡ EV-SPOT → 🔑 Set secret key. Pick a strong secret (e.g. 20 characters) and save. Write it down — you paste the exact same secret into the Shelly script’s CONFIG (SECRET_KEY) in Part B, step 3. The two must match.

The secret is stored only in your own copy’s Script Properties — it is never part of the shared template.

Set secret key dialog and the confirmation that the secret was saved.
Enter the shared secret — it is saved to Script Properties.

That’s the Sheet done. Now set up the Shelly in Part B.

Part B — Shelly 3EM PRO/Gen3

About 5 minutes. Install the device script, fill in the CONFIG block, and start it.

Find the device IP

Open Shelly Control, select the device, open the network tab, and note its IP address. Open that IP in a browser to reach the device web UI.

Shelly Control dashboard with the EV charger device and its network IP address highlighted.
Select the EV charger device and read its IP from the network panel.

Create a new script

In the device web UI, open Scripts → Create script.

Shelly device web UI Scripts page with Create script highlighted.
Scripts → Create script in the device web UI.

Paste the script and edit CONFIG

Paste the contents of shelly-ev-spot.js, name the script, and edit the CONFIG block. At minimum set APPS_SCRIPT_URL, SECRET_KEY (exactly the same as in the Sheet), AREA, and your transfer/tax values.

Shelly script editor showing the shelly-ev-spot script and its CONFIG block.
Paste the script, name it, and edit the highlighted CONFIG values.

Save, start, and enable Run on startup

Save and start the script. Enable Run on startup so it restarts automatically after a power cut.

Shelly scripts list with Run on startup enabled and the start button highlighted.
Enable Run on startup and press start.

Confirm it is running

The console shows EV-SPOT: started …. On the next 15-minute boundary while charging, the first row appears in the Sheet.

Shelly script console showing the EV-SPOT started log line and a fetched price.
The console confirms the script started and fetched a price.

CONFIG parameters (Shelly script)

All values are editable at the top of the Shelly script. Enter monetary values with the same VAT convention as your bill.

Parameter Default Description
APPS_SCRIPT_URLThe …/exec URL of the Apps Script Web App deployment.
SECRET_KEYShared secret. The same as in the Sheet’s Script Property. Sent in the POST body.
AREA"FI"Price area: FI, SE1–SE4, NO1–NO5, DK1–DK2, EE, LV, LT.
POWER_THRESHOLD_W100Standby/charging threshold. An interval counts as charging when its average power exceeds this.
PRICE_RESOLUTION_60MINfalsefalse = 15-min spot price (once on 15-min metering). true = 60-min hourly average, if still billed per hour.
TRANSFER_DAY0.0255Grid transfer fee, day, per kWh.
TRANSFER_NIGHT0.0112Grid transfer fee, night, per kWh.
NIGHT_HRS[22,23,0…6]Night hours in local time; TRANSFER_NIGHT applies during these hours.
TAX0.02917875Electricity tax per kWh (FI rate — verify against your own bill).
MARGIN0.0Your electricity contract’s margin/markup per kWh.
EM_ID0Id of the 3EM PRO/Gen3 triphase EMData component (leave at 0).

Time zone: AREA only selects the price region. Set the Shelly’s own time zone to your location so the day/night transfer split is correct. No code change is needed when you switch AREA between countries.

Using the Report tab

The Report tab fills in automatically. Enter your details in rows 1–4: metering point, name, address, and vehicle registration.

  • Month selector (cell B6): a dropdown. All shows every session; otherwise pick YYYY-MM. The table and summary filter automatically.
  • Columns: Start · End · Duration · Energy (kWh) · Average price (c/kWh) · Cost. One row is one continuous charging session.
The Report tab with metering details, month selector, totals, and session rows.
The Report tab: metering details, month selector, and grouped charging sessions.

About the other tabs

  • “data” tab — your record, keep it. Every 15-minute charging interval is stored here permanently; the Report tab only reads from it with formulas. Do not clear or delete this tab or you lose your charging history. You can safely remove an individual stray row (e.g. a test datapoint) — the Report recalculates automatically.
  • “_calc” tab (hidden). Helper formulas for the Report. Leave it as-is; it is hidden on purpose.

Tax PDF

Pick the month, then File → Download → PDF. In the export dialog choose A4 portrait and “Fit to width”, and enable “Repeat frozen rows” so the header block repeats on every page.

File menu Download submenu with PDF highlighted.
File → Download → PDF (.pdf).
PDF export dialog set to A4 portrait and Fit to width.
Export as A4 portrait, Fit to width.

Reliability & troubleshooting

Built-in reliability

  • Buffering: if a POST fails, the datapoint is stored in the Shelly’s KVS memory and retried on the next cycle.
  • No duplicate rows: the Apps Script deduplicates by timestamp and uses a lock, so a resend never creates a row twice.
  • Price alignment: the SPOT price is fetched at the start of the interval and frozen, so each interval is priced with its own price.

Troubleshooting

SymptomCause / fix
No rows appear in the SheetWas the car actually charging (avg power above the threshold)? Check the URL and that the deployment is Web app / Anyone.
unauthorized in the Shelly logSECRET_KEY does not match the Sheet’s Script Property. Re-set it via Set secret key.
Rows arrive but Report is emptyRun Build / repair sheets. Check that B6 = All.
Price shows 0 or is missingspot-hinta.fi did not respond, or AREA is wrong. The next interval is priced once the price is available.
Wrong time in ReportSet the Sheet’s time zone: File → Settings → Time zone.

Files

The full source for both scripts is provided as copy-ready code blocks below. Paste each into the place shown in this guide.

shelly-ev-spot.js For the Shelly 3EM PRO/Gen3 (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 (Extensions → Apps Script)
/** * 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. Next: ' + '1) Deploy → New deployment → Web app ' + '2) 🔑 Set secret key ' + '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 Shelly LiveTariffUpload example.