homebridge-solaredge-accfactory
Version:
Homebridge support for SolarEdge Invertor(s)
499 lines (417 loc) • 18.6 kB
JavaScript
// homebridge-solaredge-accfactory
//
// reference for details:
// v1 API - https://www.solaredge.com/sites/default/files/se_monitoring_api.pdf
// v2 API - https://developers.solaredge.com/docs/monitoring/e9nwvc91l1jf5-getting-started-with-monitoring-api
//
// Expose "outlet" service with additonal battery service
// Outlet On = Generating Solar
// Outlet Off = Not generating Solar
//
// Battery Level = Percentage of solar generating vs max system specs
// Battery Charging Yes = Generating solar and exporting to grid
// Battery Charging No = Generating solar only, not exprting to grid
// Low battery indicator = Importing from grid
//
// Mark Hulskamp
'use strict';
// Define nodejs module requirements
import { clearInterval, setTimeout } from 'node:timers';
import crypto from 'node:crypto';
// Import our modules
import HomeKitDevice from './HomeKitDevice.js';
HomeKitDevice.PLUGIN_NAME = 'homebridge-solaredge-accfactory';
HomeKitDevice.PLATFORM_NAME = 'SolarEdgeAccfactory';
import HomeKitHistory from './HomeKitHistory.js';
HomeKitDevice.HISTORY = HomeKitHistory;
// Solar Inverter class
class SolarInverter extends HomeKitDevice {
static TYPE = 'SolarInverter';
static VERSION = '2025.07.22'; // Code version
batteryService = undefined;
outletService = undefined;
lightService = undefined;
// Class functions
onAdd() {
// Setup the outlet service if not already present on the accessory
this.outletService = this.addHKService(this.hap.Service.Outlet, '', 1, { messages: this.message.bind(this) });
this.outletService.setPrimaryService();
// Setup set characteristics
this.addHKCharacteristic(this.outletService, this.hap.Characteristic.On, {
// eslint-disable-next-line no-unused-vars
onSet: (value) => {
// Reject manual changes and revert to current inverter state
setTimeout(() => {
this.outletService.updateCharacteristic(
this.hap.Characteristic.On,
this.deviceData?.powerflow?.PV?.currentPower !== 0 || this.deviceData?.powerflow?.PV?.status?.toUpperCase() === 'ACTIVE',
);
}, 100);
},
});
// Setup battery service if not already present on the accessory
this.batteryService = this.addHKService(this.hap.Service.Battery, '', 1);
this.batteryService.setHiddenService(true);
this.batteryService.getCharacteristic(this.hap.Characteristic.BatteryLevel).displayName = 'Solar Generation';
this.batteryService.getCharacteristic(this.hap.Characteristic.ChargingState).displayName = 'Exporting';
// Setup LightSensor service for solar generation LUX
this.lightService = this.addHKService(this.hap.Service.LightSensor, '', 1);
this.lightService.setHiddenService(true);
this.addHKCharacteristic(this.lightService, this.hap.Characteristic.CurrentAmbientLightLevel);
this.lightService.getCharacteristic(this.hap.Characteristic.CurrentAmbientLightLevel).displayName = 'Solar Generation';
}
onUpdate(deviceData) {
if (typeof deviceData !== 'object' || this.outletService === undefined || this.batteryService === undefined) {
return;
}
// Update energy flows
this.outletService.updateCharacteristic(
this.hap.Characteristic.On,
deviceData.powerflow.PV.currentPower !== 0 || deviceData.powerflow.PV.status.toUpperCase() === 'ACTIVE' ? true : false,
);
this.outletService.updateCharacteristic(
this.hap.Characteristic.OutletInUse,
deviceData.powerflow.PV.currentPower !== 0 || deviceData.powerflow.PV.status.toUpperCase() === 'ACTIVE' ? true : false,
);
// Update battery level and status
let batteryLevel = scaleValue(deviceData.powerflow.PV.currentPower, 0, deviceData.peakPower, 0, 100);
this.batteryService.updateCharacteristic(this.hap.Characteristic.BatteryLevel, batteryLevel);
this.batteryService.updateCharacteristic(this.hap.Characteristic.ChargingState, this.hap.Characteristic.ChargingState.NOT_CHARGING); // By default, not sending power to grid. gets updated below
deviceData.powerflow.connections &&
deviceData.powerflow.connections.forEach((flow) => {
// Work out how the power is flowing
if (flow.from.toUpperCase() === 'LOAD' && flow.to.toUpperCase() === 'GRID') {
// We're exporting power to the grid
this.batteryService.updateCharacteristic(this.hap.Characteristic.ChargingState, this.hap.Characteristic.ChargingState.CHARGING);
this.batteryService.updateCharacteristic(
this.hap.Characteristic.StatusLowBattery,
this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL,
);
}
if (flow.from.toUpperCase() === 'GRID' && flow.to.toUpperCase() === 'LOAD') {
// We're importing power from the grid
this.batteryService.updateCharacteristic(
this.hap.Characteristic.ChargingState,
this.hap.Characteristic.ChargingState.NOT_CHARGING,
);
this.batteryService.updateCharacteristic(
this.hap.Characteristic.StatusLowBattery,
this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW,
);
}
});
// Solar generation in watts as a LUX reading
this.lightService.updateCharacteristic(
this.hap.Characteristic.CurrentAmbientLightLevel,
deviceData.powerflow.PV.currentPower < 0.0001 ? 0.0001 : deviceData.powerflow.PV.currentPower,
);
// If we have the history service running and power output has changed to previous in past 2mins
this.history(
this.outletService,
{
time: Math.floor(Date.now() / 1000),
status: (deviceData.powerflow.PV.currentPower !== 0 || deviceData.powerflow.PV.status.toUpperCase() === 'ACTIVE' ? true : false)
? 1
: 0,
volts: 0,
watts: deviceData.powerflow.PV.currentPower,
amps: 0,
},
120,
);
// Update our internal data with properties Eve will need to process then Notify Eve App of device status changes if linked
this.deviceData.powerflow.PV.currentPower = deviceData.powerflow.PV.currentPower;
this.historyService?.updateEveHome?.(this.outletService);
}
onMessage(type, message) {
if (typeof type !== 'string' || type === '' || message === null || typeof message !== 'object' || message?.constructor !== Object) {
return;
}
if (type === HomeKitDevice?.HISTORY?.GET) {
// Pass back extra data for Eve Energy onGet() to process command
// Data will already be an object, our only job is to add/modify it
message.volts = 0;
message.watts = this.deviceData.powerflow.PV.currentPower;
message.amps = 0;
return message;
}
}
}
// SolarEdge class
const SUBSCRIBE_INTERVAL = 1000 * 60 * 10; // every 10minutes
class SolarEdgeAccfactory {
cachedAccessories = []; // Track restored cached accessories
// Internal data only for this class
#connections = {}; // Object of confirmed connections
#rawData = {}; // Cached copy of data from Rest API
#trackedDevices = {}; // Object of devices we've created. used to track comms uuid. key'd by serial #
constructor(log, config, api) {
this.config = config;
this.log = log;
this.api = api;
// Perform validation on the configuration passed into us and set defaults if not present
if (config?.solaredge?.apiKey === undefined || config.solaredge.apiKey === '') {
this?.log?.error?.('Required SolarEdge API Key is missing from JSON configuration. Please review');
return;
}
// Valid connection object
this.#connections[crypto.randomUUID()] = {
authorised: false,
apiKey: config.solaredge.apiKey,
};
this.config.options.eveHistory = typeof this.config.options?.eveHistory === 'boolean' ? this.config.options.eveHistory : true;
api?.on?.('didFinishLaunching', async () => {
// We got notified that Homebridge has finished loading, so we are ready to process
// Start reconnect loop per connection with backoff for failed tries
for (const uuid of Object.keys(this.#connections)) {
let reconnectDelay = 15000;
const reconnectLoop = async () => {
if (this.#connections?.[uuid]?.authorised === false) {
try {
await this.#connect(uuid);
this.#subscribeREST(uuid);
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
reconnectDelay = this.#connections?.[uuid]?.authorised === true ? 15000 : Math.min(reconnectDelay * 2, 60000);
} else {
reconnectDelay = 15000;
}
setTimeout(reconnectLoop, reconnectDelay);
};
reconnectLoop();
}
});
api?.on?.('shutdown', async () => {
// We got notified that Homebridge is shutting down
// Perform cleanup of internal state
Object.values(this.#trackedDevices).forEach((device) => {
Object.values(device?.timers || {}).forEach((timer) => clearInterval(timer));
});
this.#trackedDevices = {};
this.#rawData = {};
});
}
configureAccessory(accessory) {
// This gets called from Homebridge each time it restores an accessory from its cache
this?.log?.info?.('Loading accessory from cache:', accessory.displayName);
// add the restored accessory to the accessories cache, so we can track if it has already been registered
this.cachedAccessories.push(accessory);
}
async #connect(uuid) {
if (typeof this.#connections?.[uuid] === 'object') {
this?.log?.info?.('Performing authorisation to SolarEdge Monitoring API');
try {
let response = await fetchWrapper(
'get',
'https://monitoringapi.solaredge.com/sites/list?sortProperty=name&sortOrder=ASC&api_key=' + this.#connections[uuid].apiKey,
{},
);
await response.json();
this.#connections[uuid].authorised = true;
this?.log?.success?.('Successfully authorised to SolarEdge Monitoring API');
} catch (error) {
this.#connections[uuid].authorised = false;
this?.log?.error?.(
'Authorisation failed to SolarEdge Monitoring API. A periodic retry event will be triggered',
String(error?.cause),
);
}
}
}
async #subscribeREST(uuid) {
if (typeof this.#connections?.[uuid] !== 'object' || this.#connections?.[uuid]?.authorised !== true) {
return;
}
try {
let response = await fetchWrapper(
'get',
'https://monitoringapi.solaredge.com/sites/list?sortProperty=name&sortOrder=ASC&api_key=' + this.#connections[uuid].apiKey,
{},
);
let data = await response.json();
if (Array.isArray(data?.sites?.site) === true) {
const FETCHURLS = ['/inventory.json', '/currentPowerFlow.json'];
for (const site of data.sites.site) {
let tempObject = {};
await Promise.all(
FETCHURLS.map(async (url) => {
try {
let response = await fetchWrapper(
'get',
'https://monitoringapi.solaredge.com/site/' + site.id + url + '?api_key=' + this.#connections[uuid].apiKey,
{
timeout: 30000,
},
);
tempObject[url] = await response.json();
} catch (error) {
if (String(error?.cause).toUpperCase().includes('TIMEOUT') === false && this?.log?.debug) {
this.log.debug('REST API had an error obtaining data from url "%s" for uuid "%s"', url, uuid);
this.log.debug('Error was "%s"', String(error?.cause));
}
}
}),
);
if (Object.keys(tempObject).length === FETCHURLS.length) {
this.#rawData[site.id] = {
connection: uuid,
site: site,
inventory: tempObject['/inventory.json'].Inventory,
powerflow: tempObject['/currentPowerFlow.json'].siteCurrentPowerFlow,
};
await this.#processPostSubscribe();
}
}
}
} catch {
// Suppress site list fetch errors
}
setTimeout(() => this.#subscribeREST(uuid), SUBSCRIBE_INTERVAL);
}
#processPostSubscribe() {
Object.values(this.#processData('')).forEach((deviceData) => {
if (this.#trackedDevices?.[deviceData?.serialNumber] === undefined && deviceData?.excluded === true) {
// We haven't tracked this device before (ie: should be a new one) and but its excluded
this?.log?.warn?.('Device "%s" is ignored due to it being marked as excluded', deviceData.description);
// Track this device even though its excluded
this.#trackedDevices[deviceData.serialNumber] = {
uuid: HomeKitDevice.generateUUID(HomeKitDevice.PLUGIN_NAME, this.api, deviceData.serialNumber),
timers: undefined,
exclude: true,
};
// If the device is now marked as excluded and present in accessory cache
// Then we'll unregister it from the Homebridge platform
let accessory = this.cachedAccessories.find((accessory) => accessory?.UUID === this.#trackedDevices[deviceData.serialNumber].uuid);
if (accessory !== undefined && typeof accessory === 'object') {
this.api.unregisterPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [accessory]);
}
}
if (this.#trackedDevices?.[deviceData?.serialNumber] === undefined && deviceData?.excluded === false) {
// SolarEdge Inverter - Categories.OUTLET = 7
let tempDevice = new SolarInverter(this.cachedAccessories, this.api, this.log, deviceData);
tempDevice.add('SolarEdge Invertor', 7, true);
// Track this device once created
this.#trackedDevices[deviceData.serialNumber] = {
uuid: tempDevice.uuid,
timers: undefined,
exclude: false,
};
}
// Finally, if device is not excluded, send updated data to device for it to process
if (deviceData.excluded === false && this.#trackedDevices?.[deviceData?.serialNumber] !== undefined) {
if (this.#trackedDevices?.[deviceData?.serialNumber]?.uuid !== undefined) {
HomeKitDevice.message(this.#trackedDevices[deviceData.serialNumber].uuid, HomeKitDevice.UPDATE, deviceData);
}
}
});
}
#processData(deviceUUID) {
if (typeof deviceUUID !== 'string') {
deviceUUID = '';
}
let devices = {};
Object.values(this.#rawData).forEach((data) => {
let powerflow = structuredClone(data.powerflow);
let unitMultiplier = 1000;
if (typeof powerflow?.unit === 'string') {
let unit = powerflow.unit.toUpperCase();
if (unit === 'MW') {
unitMultiplier = 1000000;
} else if (unit === 'W') {
unitMultiplier = 1;
}
}
['GRID', 'PV', 'LOAD'].forEach((key) => {
if (powerflow?.[key]?.currentPower !== undefined) {
powerflow[key].currentPower *= unitMultiplier;
}
});
Array.isArray(data?.inventory?.inverters) === true &&
data.inventory.inverters.forEach((inverter) => {
let serial = inverter.SN.toUpperCase();
let location = typeof data?.site?.location?.city === 'string' ? data.site.location.city : '';
let description = inverter.name;
if (description === '') {
description = location;
}
devices[serial] = {
excluded: false,
serialNumber: serial,
softwareVersion: inverter.cpuVersion.replace(/-/g, '.'),
model: inverter.model,
manufacturer: inverter.manufacturer,
siteid: data.site.id,
installationDate: data.site.installationDate,
description: HomeKitDevice.makeValidHKName(location === '' ? description : description + ' - ' + location),
peakPower: data.site.peakPower * unitMultiplier,
powerflow: powerflow,
online: true,
eveHistory: this.config.options.eveHistory === true || this.config?.devices?.[serial]?.eveHistory === true,
};
});
});
return devices;
}
}
// General helper functions which don't need to be part of an object class
function scaleValue(value, sourceMin, sourceMax, targetMin, targetMax) {
if (sourceMax === sourceMin) {
return targetMin;
}
value = Math.max(sourceMin, Math.min(sourceMax, value));
return ((value - sourceMin) * (targetMax - targetMin)) / (sourceMax - sourceMin) + targetMin;
}
async function fetchWrapper(method, url, options, data) {
if ((method !== 'get' && method !== 'post') || typeof url !== 'string' || url === '' || typeof options !== 'object') {
return;
}
if (isNaN(options?.timeout) === false && Number(options.timeout) > 0) {
// eslint-disable-next-line no-undef
options.signal = AbortSignal.timeout(Number(options.timeout));
}
if (isNaN(options.retry) === true || options.retry < 1) {
options.retry = 1;
}
if (isNaN(options._retryCount) === true) {
options._retryCount = 0;
}
options.method = method;
if (method === 'post' && data !== undefined) {
options.body = data;
}
let response;
try {
// eslint-disable-next-line no-undef
response = await fetch(url, options);
} catch (error) {
if (options.retry > 1) {
options.retry--;
options._retryCount++;
const delay = 500 * 2 ** (options._retryCount - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWrapper(method, url, options, data);
}
error.message = `Fetch failed for ${method.toUpperCase()} ${url} after ${options._retryCount + 1} attempt(s): ${error.message}`;
throw error;
}
if (response?.ok === false) {
if (options.retry > 1) {
options.retry--;
options._retryCount++;
let delay = 500 * 2 ** (options._retryCount - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWrapper(method, url, options, data);
}
let error = new Error(`HTTP ${response.status} on ${method.toUpperCase()} ${url}: ${response.statusText || 'Unknown error'}`);
error.code = response.status;
throw error;
}
return response;
}
// Startup code
export default (api) => {
// Register our platform with Homebridge
api.registerPlatform(HomeKitDevice.PLATFORM_NAME, SolarEdgeAccfactory);
};