Skip to content

Instantly share code, notes, and snippets.

@karlmikko
Last active September 6, 2023 10:45
Show Gist options
  • Save karlmikko/7b9db30a71c71ca0a3fd5bcc3fe029cf to your computer and use it in GitHub Desktop.
Save karlmikko/7b9db30a71c71ca0a3fd5bcc3fe029cf to your computer and use it in GitHub Desktop.
Fronius Amber Powerwall
import digestHeader from "digest-header";
import fetch from "node-fetch";
import http from "http";
import https from "https";
const froniusHost = '';
const froniusPassword = '';
const amberKey = '';
const teslaHost = '';
const teslaEmail = '';
const teslaPassword = '';
const froniusMaxExport = 9600; // inverter size in watts
const amberAgent = new https.Agent({keepAlive: false});
const amberApi = async ({uri}) => {
return await (await fetch(`https://api.amber.com.au${uri}`, {
method: "GET",
agent: amberAgent,
headers: {
Authorization: `Bearer ${amberKey}`,
accept: 'application/json'
}
}))?.json();
}
const froniusAgent = new http.Agent({keepAlive: false});
let wwwauth = undefined;
const froniusApiFactory = ({froniusHost, froniusPassword}) => {
const inner = async ({uri, method, payload, username, retrying}) => {
const auth = wwwauth?digestHeader(method, uri, wwwauth, `${username}:${froniusPassword}`):undefined;
const res = await fetch(`http://${froniusHost}${uri}`, {
agent: froniusAgent,
method,
headers: {
accept: `application/json`,
Authorization: auth
},
body: method==="POST"?JSON.stringify(payload):undefined
});
const r = retrying || 0;
if (res.status === 401 && r < 2) {
wwwauth = res.headers.get("x-www-authenticate");
return inner({uri, method, payload, username, retrying: r+1});
}
return res;
};
return inner;
};
const froniusApi = froniusApiFactory({froniusHost, froniusPassword});
const powerLimit = (limit) => ({"powerLimits":{"exportLimits":{"activePower":{"hardLimit":{"enabled":false,"powerLimit":0},"mode":"entireSystem","softLimit":{"enabled":true,"powerLimit":limit}},"failSafeModeEnabled":false},"visualization":{"exportLimits":{"activePower":{"displayModeHardLimit":"absolute","displayModeSoftLimit":"absolute"}},"wattPeakReferenceValue":froniusMaxExport}}});
const setPowerLimit = async (limit, currentLimit, currentExport) => {
if (currentLimit === limit) {
return {
status: "ok",
message: "already set"
};
}
const moveBy = Math.round(froniusMaxExport / 10);
const moveTo = await (async () => {
const movingUp = !!(limit > currentLimit);
if (Math.abs(currentLimit - limit) < moveBy || movingUp) {
return limit;
}
if (currentExport < 0 && ((currentExport * -1) + moveBy) < currentLimit) { // currently exporting less than current limit
return Math.round(currentExport * -1); // jump to just below current export
}
return currentLimit - moveBy;
})();
return await froniusApi({
uri: "/config/exportlimit/?method=save",
method: "POST",
payload: powerLimit(moveTo),
username: 'service'
});
};
export default setPowerLimit;
const teslaAgent = new https.Agent({
rejectUnauthorized: false,
keepAlive: false
});
const teslaApi = async (url, options, headers = {}) => (await fetch(`https://${teslaHost}/api/${url}`, {
method: 'GET',
agent: teslaAgent,
...options,
headers: {
"content-type": "application/json",
...headers
}
})).json();
const teslaAuthedApiFactory = async () => {
const loginToken = (await teslaApi("login/Basic", {
method: "POST",
body: JSON.stringify({
"username":"customer",
"password":teslaPassword,
"email":teslaEmail,
"clientInfo":{"timezone":"Australia/Sydney"}
})
}))?.token;
return (url, options) => teslaApi(url, options, {
"Authorization": `Bearer ${loginToken}`
});
};
const teslaAuthedApi = await teslaAuthedApiFactory();
const todayAt = (hour, min, s = 0, ms = 0) => {
const now = new Date();
now.setHours(hour, min, s, ms);
return now;
};
const ToUTariff = (kwh) => {
return kwh;
// if (kwh <= 0 || kwh >= 0) {
// const now = new Date();
// if (now >= todayAt(10, 0) && now <= todayAt(14, 0)) {
// return kwh + 2.1944; // inc as I get charged GST for exporting during this time
// }
// if (now >= todayAt(14, 0) && now <= todayAt(20, 0)) {
// return kwh - 26.5828; // ex as I don't charge GST when exporting
// }
// }
// return kwh;
};
const isNumber = (x) => !!(x >= 0 || x < 0);
console.log("Starting:", new Date());
const siteId = (await amberApi({uri: "/v1/sites"}))?.[0]?.id;
const poll = async () => {
const start = new Date();
const aggregatesCall = teslaAuthedApi("meters/aggregates");
const gridStatusCall = teslaAuthedApi("system_status/grid_status");
const soeCall = teslaAuthedApi("system_status/soe");
const priceDataCall = siteId && amberApi({uri: `/v1/sites/${siteId}/prices/current?next=0&previous=1&resolution=30`});
const currentLimitCall = froniusApi({
uri: "/config/exportlimit/",
method: "GET",
username: 'service'
});
const currentExportCall = (async () => await (await froniusApi({uri: "/solar_api/v1/GetPowerFlowRealtimeData.fcgi", method: "GET", username: 'service'}))?.json())();
const currentExport = (await currentExportCall)?.Body?.Data?.Site?.P_Grid;
const aggregates = await aggregatesCall;
const priceData = priceDataCall && await priceDataCall;
const gridStatus = (await gridStatusCall)?.grid_status;
const soe = (await soeCall)?.percentage;
const currentLimit = await currentLimitCall;
const currentLimitValue = currentLimit.status == 200 ? (await currentLimit?.json())?.Body?.Data?.powerLimits?.exportLimits?.activePower?.softLimit?.powerLimit || froniusMaxExport : null;
const feedInPerkw = ToUTariff(priceData
?.filter(x=>x.channelType==='feedIn')
?.filter(x=>x.type==='CurrentInterval')
?.[0]
?.perKwh);
const buyPerkw = priceData
?.filter(x=>x.channelType==='general')
?.filter(x=>x.type==='CurrentInterval')
?.[0]
?.perKwh;
// console.dir(aggregates);
// console.log(gridStatus);
const batteryIP = aggregates?.battery?.instant_power || 0;
const solarIP = aggregates?.solar?.instant_power || 0;
const loadIP = aggregates?.load?.instant_power || 0;
const siteIP = aggregates?.site?.instant_power || 0;
const gridConnected = gridStatus && !!(gridStatus === "SystemGridConnected");
// console.table(priceData);
const limit = (() => {
if (feedInPerkw > 0 && gridConnected) {
if (buyPerkw < 0) {
return 0;
}
const base = (soe === 100 ? 50 : 250) + (siteIP>0?siteIP:0);
// Battery Charging
if (batteryIP < 0) {
return (Math.ceil(Math.abs(batteryIP)/100)*100) + base;
}
return base;
}
return froniusMaxExport;
})();
const setLimit = isNumber(feedInPerkw) && isNumber(currentLimitValue);
if ( setLimit ) {
await setPowerLimit(limit, currentLimitValue, currentExport);
}
console.table({
siteId,
batteryIP,
solarIP,
siteIP,
loadIP,
soe: Math.round(soe/10)*10,
gridConnected,
buyPerkw,
feedInPerkw,
inverterExport: currentExport,
currentLimit: currentLimitValue,
targetLimit: setLimit && limit,
start: JSON.parse(JSON.stringify(start)),
end: JSON.parse(JSON.stringify(new Date()))
});
};
const start = new Date();
const loop = async () => {
await poll();
setTimeout(async () => {
const end = new Date();
if (end - start > 55000) {
return;
}
await loop();
}, 12000);
};
await loop();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment