EMA's app tells me how much power my solar panels are making. It just doesn't tell me on my home screen — so I built the piece it was missing.
My solar panels report their production through the EMA app, which has an API but no iOS widget. Checking daily, monthly, or annual output meant opening the app every time — a small but constant bit of friction for something I wanted to glance at.
The widget, lives on my home screen.
The widget runs on Scriptable, an iOS app that turns JavaScript into home-screen widgets. I used Claude Code to work through the EMA API documentation, troubleshoot pulling the right data, and get it displaying cleanly on the widget face — daily, monthly, and annual kWh, no app required.
A tap-free way to see solar production without opening the EMA app — daily, monthly, and annual kWh sitting right on the home screen.
The full Scriptable script, for anyone who wants to build their own. appId, appSecret, and sid are blanked out — drop your own in locally, don’t commit them.
// APsystems Solar Widget for Scriptable
// Only polls during daylight hours to stay under API limits
var CONFIG = {
appId: "ENTER",
appSecret: "ENTER",
sid: "ENTER",
baseUrl: "https://api.apsystemsema.com:9282",
// Location for sunrise/sunset (Tigard, OR)
latitude: 45.43,
longitude: -122.77,
// Polling settings
refreshInterval: 30,
sunriseOffset: 30,
sunsetOffset: 30,
debug: true
};
function log(msg) {
if (CONFIG.debug) console.log("[APsystems] " + msg);
}
// ============ SUNRISE/SUNSET CALCULATION ============
function calculateSunTimes(lat, lng, date) {
var rad = Math.PI / 180;
var dayOfYear = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 86400000);
var declination = -23.45 * Math.cos(rad * (360 / 365) * (dayOfYear + 10));
var cosHourAngle = -Math.tan(lat * rad) * Math.tan(declination * rad);
if (cosHourAngle > 1) cosHourAngle = 1;
if (cosHourAngle < -1) cosHourAngle = -1;
var hourAngle = Math.acos(cosHourAngle) / rad;
var solarNoon = 12 - (lng / 15);
var sunriseUTC = solarNoon - (hourAngle / 15);
var sunsetUTC = solarNoon + (hourAngle / 15);
var tzOffset = -date.getTimezoneOffset() / 60;
var sunriseLocal = sunriseUTC + tzOffset;
var sunsetLocal = sunsetUTC + tzOffset;
var sunrise = new Date(date);
sunrise.setHours(Math.floor(sunriseLocal), (sunriseLocal % 1) * 60, 0, 0);
var sunset = new Date(date);
sunset.setHours(Math.floor(sunsetLocal), (sunsetLocal % 1) * 60, 0, 0);
return { sunrise: sunrise, sunset: sunset };
}
function isDaylightHours() {
var now = new Date();
var sunTimes = calculateSunTimes(CONFIG.latitude, CONFIG.longitude, now);
var startTime = new Date(sunTimes.sunrise.getTime() + CONFIG.sunriseOffset * 60000);
var endTime = new Date(sunTimes.sunset.getTime() + CONFIG.sunsetOffset * 60000);
var isDaylight = now >= startTime && now <= endTime;
log("Poll window: " + startTime.toLocaleTimeString() + " - " + endTime.toLocaleTimeString());
log("Is daylight: " + isDaylight);
return {
isDaylight: isDaylight,
sunrise: sunTimes.sunrise,
sunset: sunTimes.sunset,
nextPollTime: isDaylight ? new Date(now.getTime() + CONFIG.refreshInterval * 60000) : startTime
};
}
// ============ SHA-256 / HMAC ============
var SHA256 = (function() {
function rightRotate(value, amount) {
return (value >>> amount) | (value << (32 - amount));
}
function sha256(ascii) {
var mathPow = Math.pow;
var maxWord = mathPow(2, 32);
var lengthProperty = "length";
var i, j;
var result = "";
var words = [];
var asciiBitLength = ascii[lengthProperty] * 8;
var hash = [];
var k = [];
var primeCounter = 0;
var isComposite = {};
for (var candidate = 2; primeCounter < 64; candidate++) {
if (!isComposite[candidate]) {
for (i = 0; i < 313; i += candidate) {
isComposite[i] = candidate;
}
hash[primeCounter] = (mathPow(candidate, 0.5) * maxWord) | 0;
k[primeCounter++] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
}
}
ascii += "\x80";
while ((ascii[lengthProperty] % 64) - 56) ascii += "\x00";
for (i = 0; i < ascii[lengthProperty]; i++) {
j = ascii.charCodeAt(i);
if (j >> 8) return;
words[i >> 2] |= j << (((3 - i) % 4) * 8);
}
words[words[lengthProperty]] = (asciiBitLength / maxWord) | 0;
words[words[lengthProperty]] = asciiBitLength;
for (j = 0; j < words[lengthProperty]; ) {
var w = words.slice(j, (j += 16));
var oldHash = hash;
hash = hash.slice(0, 8);
for (i = 0; i < 64; i++) {
var w15 = w[i - 15], w2 = w[i - 2];
var a = hash[0], e = hash[4];
var temp1 = hash[7] + (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25)) + ((e & hash[5]) ^ (~e & hash[6])) + k[i] + (w[i] = i < 16 ? w[i] : (w[i - 16] + (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3)) + w[i - 7] + (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10))) | 0);
var temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22)) + ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
hash = [(temp1 + temp2) | 0].concat(hash);
hash[4] = (hash[4] + temp1) | 0;
}
for (i = 0; i < 8; i++) {
hash[i] = (hash[i] + oldHash[i]) | 0;
}
}
for (i = 0; i < 8; i++) {
for (j = 3; j + 1; j--) {
var b = (hash[i] >> (j * 8)) & 255;
result += (b < 16 ? "0" : "") + b.toString(16);
}
}
return result;
}
function hexToBytes(hex) {
var bytes = [];
for (var i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substr(i, 2), 16));
}
return bytes;
}
function stringToBytes(str) {
var bytes = [];
for (var i = 0; i < str.length; i++) {
bytes.push(str.charCodeAt(i) & 0xff);
}
return bytes;
}
function hmacSha256(key, message) {
var blockSize = 64;
var keyBytes = stringToBytes(key);
var messageBytes = stringToBytes(message);
if (keyBytes.length > blockSize) {
keyBytes = hexToBytes(sha256(key));
}
while (keyBytes.length < blockSize) {
keyBytes.push(0);
}
var oKeyPad = [];
var iKeyPad = [];
for (var i = 0; i < blockSize; i++) {
oKeyPad.push(keyBytes[i] ^ 0x5c);
iKeyPad.push(keyBytes[i] ^ 0x36);
}
var innerStr = String.fromCharCode(...iKeyPad) + String.fromCharCode(...messageBytes);
var innerHash = sha256(innerStr);
var innerHashBytes = hexToBytes(innerHash);
var outerStr = String.fromCharCode(...oKeyPad) + String.fromCharCode(...innerHashBytes);
return sha256(outerStr);
}
function hexToBase64(hexStr) {
var bytes = hexToBytes(hexStr);
var base64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var result = "";
var i;
for (i = 0; i < bytes.length - 2; i += 3) {
result += base64chars.charAt(bytes[i] >> 2);
result += base64chars.charAt(((bytes[i] & 0x03) << 4) | (bytes[i + 1] >> 4));
result += base64chars.charAt(((bytes[i + 1] & 0x0f) << 2) | (bytes[i + 2] >> 6));
result += base64chars.charAt(bytes[i + 2] & 0x3f);
}
if (i === bytes.length - 2) {
result += base64chars.charAt(bytes[i] >> 2);
result += base64chars.charAt(((bytes[i] & 0x03) << 4) | (bytes[i + 1] >> 4));
result += base64chars.charAt((bytes[i + 1] & 0x0f) << 2);
result += "=";
} else if (i === bytes.length - 1) {
result += base64chars.charAt(bytes[i] >> 2);
result += base64chars.charAt((bytes[i] & 0x03) << 4);
result += "==";
}
return result;
}
return {
hmacBase64: function(key, message) {
return hexToBase64(hmacSha256(key, message));
}
};
})();
// ============ API ============
function generateUUID() { return Math.random().toString(36).slice(2).repeat(2); }
function buildSignature(appId, appSecret, path, method) {
var ts = Date.now().toString();
var nonce = generateUUID();
var sigMethod = "HmacSHA256";
var requestPathToSign = path.split("/").pop();
var s2s = [ts, nonce, appId, requestPathToSign, method.toUpperCase(), sigMethod].join("/");
var sig = SHA256.hmacBase64(appSecret, s2s);
return {
"Content-Type": "application/json",
"X-CA-AppId": appId,
"X-CA-Timestamp": ts,
"X-CA-Nonce": nonce,
"X-CA-Signature-Method": sigMethod,
"X-CA-Signature": sig
};
}
async function apiGet(path) {
var url = CONFIG.baseUrl + path;
var headers = buildSignature(CONFIG.appId, CONFIG.appSecret, path, "GET");
log("GET " + url);
var req = new Request(url);
req.method = "GET";
req.headers = headers;
req.timeoutInterval = 30;
var response = await req.loadJSON();
log("Response code: " + response.code);
return response;
}
// ============ CACHING ============
var CACHE_FILE = "apsystems_cache.json";
async function loadCache() {
var fm = FileManager.local();
var cachePath = fm.joinPath(fm.documentsDirectory(), CACHE_FILE);
if (fm.fileExists(cachePath)) {
try {
var cacheData = fm.readString(cachePath);
return JSON.parse(cacheData);
} catch (e) {
return null;
}
}
return null;
}
async function saveCache(data) {
var fm = FileManager.local();
var cachePath = fm.joinPath(fm.documentsDirectory(), CACHE_FILE);
var cacheData = { timestamp: Date.now(), data: data };
fm.writeString(cachePath, JSON.stringify(cacheData));
}
async function getData() {
var cache = await loadCache();
var daylight = isDaylightHours();
if (!daylight.isDaylight) {
log("Outside solar hours - using cache only");
if (cache) {
return { success: true, data: cache.data, fromCache: true, nextRefresh: daylight.nextPollTime };
}
return { success: false, error: "No cached data", nextRefresh: daylight.nextPollTime };
}
var now = Date.now();
var cacheAge = cache ? (now - cache.timestamp) / 1000 / 60 : Infinity;
if (cache && cacheAge < CONFIG.refreshInterval) {
log("Using cache (" + Math.round(cacheAge) + " min old)");
return { success: true, data: cache.data, fromCache: true, nextRefresh: daylight.nextPollTime };
}
log("Fetching fresh data...");
try {
var response = await apiGet("/user/api/v2/systems/summary/" + CONFIG.sid);
if (response.code === 0) {
await saveCache(response.data);
return { success: true, data: response.data, fromCache: false, nextRefresh: daylight.nextPollTime };
}
if (cache) {
return { success: true, data: cache.data, fromCache: true, nextRefresh: daylight.nextPollTime };
}
return { success: false, error: "API Error " + response.code };
} catch (error) {
log("Error: " + error);
if (cache) {
return { success: true, data: cache.data, fromCache: true, nextRefresh: daylight.nextPollTime };
}
return { success: false, error: error.message || "Network error" };
}
}
// ============ WIDGET ============
function createWidget(data, error, nextRefresh) {
var widget = new ListWidget();
var gradient = new LinearGradient();
gradient.locations = [0, 1];
gradient.colors = [new Color("#1a1a2e"), new Color("#16213e")];
widget.backgroundGradient = gradient;
widget.setPadding(12, 14, 12, 14);
if (error) {
var header = widget.addText("Solar");
header.font = Font.boldSystemFont(16);
header.textColor = Color.white();
widget.addSpacer(8);
var errText = widget.addText(error);
errText.font = Font.systemFont(12);
errText.textColor = new Color("#ff6b6b");
if (nextRefresh) widget.refreshAfterDate = nextRefresh;
return widget;
}
var headerStack = widget.addStack();
headerStack.centerAlignContent();
var sunEmoji = headerStack.addText("☀️");
sunEmoji.font = Font.systemFont(18);
headerStack.addSpacer(6);
var title = headerStack.addText("Solar Power");
title.font = Font.mediumSystemFont(14);
title.textColor = new Color("#feca57");
widget.addSpacer(6);
var todayValue = parseFloat(data.today || 0);
var heroStack = widget.addStack();
heroStack.centerAlignContent();
var valueText = heroStack.addText(todayValue.toFixed(1));
valueText.font = Font.boldSystemFont(36);
valueText.textColor = Color.white();
var unitText = heroStack.addText(" kWh");
unitText.font = Font.mediumSystemFont(16);
unitText.textColor = new Color("#a0a0a0");
var todayLabel = widget.addText("Today");
todayLabel.font = Font.mediumSystemFont(12);
todayLabel.textColor = new Color("#1dd1a1");
widget.addSpacer(10);
var statsRow = widget.addStack();
statsRow.spacing = 16;
var monthVal = parseFloat(data.month || 0);
var mBlock = statsRow.addStack();
mBlock.layoutVertically();
var mText = mBlock.addText(monthVal.toFixed(0) + " kWh");
mText.font = Font.semiboldSystemFont(13);
mText.textColor = Color.white();
var mLabel = mBlock.addText("Month");
mLabel.font = Font.systemFont(10);
mLabel.textColor = new Color("#54a0ff");
var yearVal = parseFloat(data.year || 0);
var yBlock = statsRow.addStack();
yBlock.layoutVertically();
var yText = yBlock.addText(yearVal.toFixed(0) + " kWh");
yText.font = Font.semiboldSystemFont(13);
yText.textColor = Color.white();
var yLabel = yBlock.addText("Year");
yLabel.font = Font.systemFont(10);
yLabel.textColor = new Color("#54a0ff");
widget.addSpacer();
var timeStr = new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
var updateText = widget.addText("Updated " + timeStr);
updateText.font = Font.systemFont(9);
updateText.textColor = new Color("#555555");
if (nextRefresh) widget.refreshAfterDate = nextRefresh;
return widget;
}
// ============ MAIN ============
async function main() {
log("Starting widget");
var result = await getData();
var widget;
if (result.success) {
log("Today: " + result.data.today + " kWh");
widget = createWidget(result.data, null, result.nextRefresh);
} else {
log("Error: " + result.error);
widget = createWidget(null, result.error, result.nextRefresh);
}
if (config.runsInWidget) {
Script.setWidget(widget);
} else {
widget.presentSmall();
}
Script.complete();
}
await main();