Last active
September 6, 2023 10:45
-
-
Save karlmikko/7b9db30a71c71ca0a3fd5bcc3fe029cf to your computer and use it in GitHub Desktop.
Fronius Amber Powerwall
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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