homebridge-enphase-envoy
Version:
Homebridge plugin for Photovoltaic Energy System manufactured by Enphase.
1,253 lines (1,102 loc) • 126 kB
JavaScript
import { XMLParser } from 'fast-xml-parser';
import cron from 'node-cron';
import EventEmitter from 'events';
import EnvoyToken from './envoytoken.js';
import DigestAuth from './digestauth.js';
import PasswdCalc from './passwdcalc.js';
import ImpulseGenerator from './impulsegenerator.js';
import Functions from './functions.js';
import { ApiUrls, PartNumbers, Authorization, ApiCodes, MetersKeyMap } from './constants.js';
class EnvoyData extends EventEmitter {
constructor(url, device, envoyIdFile, envoyTokenFile, restFulEnabled, mqttEnabled) {
super();
//device configuration
this.url = url;
this.envoyFirmware7xxTokenGenerationMode = device.envoyFirmware7xxTokenGenerationMode;
this.envoyPasswd = device.envoyPasswd;
this.enlightenUser = device.enlightenUser;
this.enlightenPasswd = device.enlightenPasswd;
this.envoyIdFile = envoyIdFile;
this.envoyTokenFile = envoyTokenFile;
//data refresh
this.productionDataRefreshTime = (device.productionDataRefreshTime || 10) * 1000;
this.liveDataRefreshTime = (device.liveDataRefreshTime || 5) * 1000;
this.ensembleDataRefreshTime = (device.ensembleDataRefreshTime || 15) * 1000;
//log
this.logWarn = device.log?.warn ?? true;
this.logError = device.log?.error ?? true;
this.logDebug = device.log?.debug ?? false;
//external integrations
this.restFulEnabled = restFulEnabled;
this.mqttEnabled = mqttEnabled;
//setup variables
this.functions = new Functions();
this.checkTokenRunning = false;
//supported functions
this.feature = {
info: {
devId: '',
envoyPasswd: '',
installerPasswd: '',
firmware: 500,
tokenRequired: false,
tokenValid: false,
cookie: '',
jwtToken: {
generation_time: 0,
token: device.envoyToken,
expires_at: 0,
installer: device.envoyFirmware7xxTokenGenerationMode === 2 ? device.envoyTokenInstaller : false
}
},
backboneApp: {
supported: false
},
productionState: {
supported: false
},
home: {
supported: false,
networkInterfaces: {
supported: false,
installed: false,
count: 0
},
wirelessConnections: {
supported: false,
installed: false,
count: 0
},
},
inventory: {
supported: false,
pcus: {
supported: false,
installed: false,
count: 0,
status: {
supported: false
},
detailedData: {
supported: false
},
},
nsrbs: {
supported: false,
installed: false,
count: 0,
detailedData: {
supported: false
},
},
acbs: {
supported: false,
installed: false,
count: 0
},
esubs: {
supported: false,
installed: false,
count: 0,
encharges: {
supported: false,
installed: false,
count: 0,
status: {
supported: false
},
settings: {
supported: false
},
power: {
supported: false
},
tariff: {
supported: false
},
},
enpowers: {
supported: false,
installed: false,
count: 0,
status: {
supported: false
},
dryContacts: {
supported: false,
installed: false,
count: 0,
settings: {
supported: false,
count: 0
}
},
},
collars: {
supported: false,
installed: false,
count: 0,
},
c6CombinerControllers: {
supported: false,
installed: false,
count: 0,
},
c6Rgms: {
supported: false,
installed: false,
count: 0,
},
status: {
supported: false
},
counters: {
supported: false
},
secctrl: {
supported: false
},
relay: {
supported: false
},
generator: {
supported: false,
installed: false,
count: 0,
settings: {
supported: false
}
},
},
},
pcuStatus: {
supported: false
},
meters: {
supported: false,
installed: false,
count: 0,
production: {
supported: false,
enabled: false
},
consumptionNet: {
supported: false,
enabled: false
},
consumptionTotal: {
supported: false,
enabled: false
},
storage: {
supported: false,
enabled: false
},
backfeed: {
supported: false,
enabled: false
},
load: {
supported: false,
enabled: false
},
evse: {
supported: false,
enabled: false
},
pv3p: {
supported: false,
enabled: false
},
generator: {
supported: false,
enabled: false
},
detailedData: {
supported: false
},
},
metersReading: {
supported: false,
installed: false
},
metersReports: {
supported: false,
installed: false
},
production: {
supported: false
},
productionPdm: {
supported: false,
pcu: {
supported: false
},
eim: {
supported: false
},
rgm: {
supported: false
},
pmu: {
supported: false
}
},
energyPdm: {
supported: false,
production: {
supported: false,
pcu: {
supported: false
},
eim: {
supported: false
},
rgm: {
supported: false
},
pmu: {
supported: false
}
},
consumptionNet: {
supported: false
},
consumptionTotal: {
supported: false
}
},
productionCt: {
supported: false,
production: {
supported: false,
pcu: {
supported: false
},
eim: {
supported: false
}
},
consumptionNet: {
supported: false
},
consumptionTotal: {
supported: false
},
storage: {
supported: false
}
},
ensemble: {
inventory: {
supported: false
},
status: {
supported: false
},
power: {
supported: false
}
},
plcLevel: {
supported: false,
pcus: {
supported: false
},
nsrbs: {
supported: false
},
acbs: {
supported: false
},
},
detailedDevicesData: {
supported: false,
installed: false,
},
liveData: {
supported: false
},
gridProfile: {
supported: false
}
};
//pv object
this.pv = {
info: {},
home: {},
inventory: {
pcus: [],
nsrbs: [],
acbs: [],
esubs: {
devices: [],
encharges: {
devices: [],
settings: {},
tariff: {},
tariffRaw: {},
ratedPowerSumKw: null,
realPowerSumKw: null,
phaseA: false,
phaseB: false,
phaseC: false,
},
enpowers: [],
collars: [],
c6CombinerControllers: [],
c6Rgms: [],
counters: {},
secctrl: {},
relay: {},
generator: {}
},
},
meters: [],
powerAndEnergy: {
production: {
pcu: {},
eim: {},
rgm: {},
pmu: {},
},
consumptionNet: {},
consumptionTotal: {},
},
liveData: {}
};
//lock flags
this.locks = {
updateHome: false,
updateInventory: false,
updateDevices: false,
updatePowerAndEnergy: false,
updateEnsemble: false,
updateLiveData: false,
updateGridPlcAndProductionState: false
};
//impulse generator
this.impulseGenerator = new ImpulseGenerator()
.on('updateHome', () => this.handleWithLock('updateHome', async () => {
await this.updateHome();
this.emit('updateHomeData', this.pv.home);
}))
.on('updateInventory', () => this.handleWithLock('updateInventory', async () => {
await this.updateInventory();
}))
.on('updateDevices', () => this.handleWithLock('updateDevices', async () => {
if (this.feature.inventory.pcus.status.supported && !this.feature.inventory.pcus.detailedData.supported) await this.updatePcuStatus();
if (this.feature.detailedDevicesData.supported) await this.updateDetailedDevices(false);
if (this.feature.inventory.pcus.installed) this.emit('updatePcusData', this.pv.inventory.pcus);
if (this.feature.inventory.nsrbs.installed) this.emit('updateNsrbsData', this.pv.inventory.nsrbs);
if (this.feature.inventory.acbs.installed) this.emit('updateAcbsData', this.pv.inventory.acbs);
}))
.on('updatePowerAndEnergy', () => this.handleWithLock('updatePowerAndEnergy', async () => {
if (this.feature.meters.supported) {
await this.updateMeters();
if (this.feature.meters.installed && this.feature.metersReading.installed) await this.updateMetersReading(false);
if (this.feature.meters.installed && this.feature.metersReports.installed) await this.updateMetersReports(false);
this.emit('updateMetersData', this.pv.meters);
}
if (this.feature.production.supported) await this.updateProduction();
if (this.feature.productionPdm.supported && !this.feature.energyPdm.supported) await this.updateProductionPdm();
if (this.feature.energyPdm.supported) await this.updateEnergyPdm();
if (this.feature.productionCt.supported) await this.updateProductionCt();
this.emit('updatePowerAndEnergyData', this.pv.powerAndEnergy, this.pv.meters);
}))
.on('updateEnsemble', () => this.handleWithLock('updateEnsemble', async () => {
const updateEnsemble = this.feature.ensemble.inventory.supported ? await this.updateEnsembleInventory() : false;
if (updateEnsemble && this.feature.ensemble.status.supported) await this.updateEnsembleStatus();
if (updateEnsemble && this.feature.inventory.esubs.encharges.power.supported) await this.updateEnsemblePower();
const updateEnchargeSettings = updateEnsemble && this.feature.inventory.esubs.encharges.settings.supported ? await this.updateEnchargesSettings() : false;
if (updateEnchargeSettings && this.feature.inventory.esubs.encharges.tariff.supported) await this.updateTariff();
const updateDryContacts = updateEnsemble && this.feature.inventory.esubs.enpowers.dryContacts.supported ? await this.updateDryContacts() : false;
if (updateDryContacts && this.feature.inventory.esubs.enpowers.dryContacts.settings.supported) await this.updateDryContactsSettings();
const updateGenerator = updateEnsemble && this.feature.inventory.esubs.generator.installed ? await this.updateGenerator() : false;
if (updateGenerator && this.feature.inventory.esubs.generator.settings.supported) await this.updateGeneratorSettings();
//Update feature and pv
this.emit('updateEnsembleData', this.pv.inventory.esubs);
}))
.on('updateLiveData', () => this.handleWithLock('updateLiveData', async () => {
await this.updateLiveData();
this.emit('updateLiveData', this.pv.liveData);
}))
.on('updateGridPlcAndProductionState', () => this.handleWithLock('updateGridPlcAndProductionState', async () => {
if (this.feature.gridProfile.supported) await this.updateGridProfile(false);
if (this.feature.plcLevel.supported) await this.updatePlcLevel(false);
if (this.feature.productionState.supported) await this.updateProductionState(false);
}))
.on('state', (state) => {
this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`);
this.emit('updateDataSampling', state);
if (this.restFulEnabled) this.emit('restFul', 'datasampling', state);
if (this.mqttEnabled) this.emit('mqtt', 'Data Sampling', state);
});
// 22:57
cron.schedule('57 22 * * *', async () => {
try {
// Stop impulse generator before daily reset
await this.impulseGenerator.state(false, []);
} catch (error) {
if (this.logError) this.emit('error', `Cron 22:57 error: ${error}`);
}
});
// 23:10
cron.schedule('10 23 * * *', async () => {
try {
// Start impulse generator after daily reset.
// runOnStart=false: let the first natural interval fire each handler
// one at a time instead of blasting all requests simultaneously right
// after Envoy has finished its own reset sequence.
if (!this.timers) return;
await this.impulseGenerator.state(true, this.timers, false);
} catch (error) {
if (this.logError) this.emit('error', `Cron 23:10 error: ${error}`);
}
});
}
handleError(error) {
const errorString = error.toString();
const tokenNotValid = errorString.includes('status code 401');
if (tokenNotValid) {
if (this.checkTokenRunning) return;
// Only invalidate the session cookie — do NOT clear jwtToken.token.
// Clearing the JWT forces the slow 30-second re-fetch path even when
// the JWT itself is still valid and only the cookie has expired.
// checkToken already checks jwt.expires_at independently.
this.feature.info.tokenValid = false;
return;
}
if (this.logError) this.emit('error', `Impulse generator: ${error}`);
}
async startStopImpulseGenerator(state) {
try {
//start impulse generator
const timers = state ? this.timers : [];
await this.impulseGenerator.state(state, timers);
return true;
} catch (error) {
throw new Error(`Impulse generator start error: ${error}`);
}
}
async handleWithLock(lockKey, fn) {
if (this.locks[lockKey]) return;
let tokenValid;
try {
tokenValid = await this.checkToken();
} catch (error) {
if (this.logError) this.emit('error', `Check token error in ${lockKey}: ${error}`);
return;
}
if (!tokenValid) return;
this.locks[lockKey] = true;
try {
await fn();
} catch (error) {
this.handleError(error);
} finally {
this.locks[lockKey] = false;
}
}
async getInfo() {
if (this.logDebug) this.emit('debug', 'Requesting info');
try {
const response = await this.axiosInstance.get(ApiUrls.GetInfo);
const xmlString = response.data;
// XML Parsing options
const options = {
ignoreAttributes: false,
ignorePiTags: true,
allowBooleanAttributes: true
};
const parser = new XMLParser(options);
const parsed = parser.parse(xmlString);
// Defensive structure checks
const envoyInfo = parsed.envoy_info ?? {};
const device = envoyInfo.device ?? {};
const buildInfo = envoyInfo.build_info ?? {};
// Masked debug version
const debugParsed = {
...parsed,
envoy_info: {
...envoyInfo,
device: {
...device,
sn: 'removed'
}
}
};
if (this.logDebug) this.emit('debug', 'Parsed info:', debugParsed);
const serialNumber = device.sn?.toString() ?? null;
if (!serialNumber) {
if (this.logWarn) this.emit('warn', 'Envoy serial number missing!');
return null;
}
// Construct info object
const obj = {
time: envoyInfo.time,
serialNumber,
partNumber: device.pn,
modelName: PartNumbers[device.pn] ?? device.pn,
software: device.software,
euaid: device.euaid,
seqNum: device.seqnum,
apiVer: device.apiver,
imeter: !!device.imeter,
webTokens: !!envoyInfo['web-tokens'],
packages: envoyInfo.package ?? [],
buildInfo: {
buildId: buildInfo.build_id,
buildTimeQmt: buildInfo.build_time_gmt,
releaseVer: buildInfo.release_ver,
releaseStage: buildInfo.release_stage
}
};
this.pv.info = obj;
// Feature: meters
this.feature.meters.supported = obj.imeter;
// Feature: firmware version
const cleanedVersion = obj.software?.replace(/\D/g, '') ?? '';
const parsedFirmware = cleanedVersion ? parseInt(cleanedVersion.slice(0, 3)) : 500;
this.feature.info.firmware = parsedFirmware;
this.feature.info.tokenRequired = obj.webTokens;
// RESTFul + MQTT update
if (this.restFulEnabled) this.emit('restFul', 'info', parsed);
if (this.mqttEnabled) this.emit('mqtt', 'Info', parsed);
return true;
} catch (error) {
throw new Error(`Update info error: ${error}`);
}
}
async checkToken(start) {
if (this.logDebug) this.emit('debug', 'Requesting check token');
if (this.checkTokenRunning) {
if (this.logDebug) this.emit('debug', 'Token check already running');
return null;
}
if (!this.feature.info.tokenRequired) {
if (this.logDebug) this.emit('debug', 'Token not required, skipping token check');
return true;
}
this.checkTokenRunning = true;
try {
const now = Math.floor(Date.now() / 1000);
// Load token from file on startup, only if mode is 1
if (this.envoyFirmware7xxTokenGenerationMode === 1 && start) {
try {
const data = await this.functions.readData(this.envoyTokenFile, true);
try {
const fileTokenExist = data.token ? 'Exist' : 'Missing';
if (this.logDebug) this.emit('debug', `Token from file: ${fileTokenExist}`);
if (data.token) {
this.feature.info.jwtToken = data;
}
} catch (error) {
if (this.logWarn) this.emit('warn', `Token parse error: ${error}`);
}
} catch (error) {
if (this.logWarn) this.emit('warn', `Read Token from file error: ${error}`);
}
}
const jwt = this.feature.info.jwtToken || {};
const tokenExist = jwt.token && (this.envoyFirmware7xxTokenGenerationMode === 2 || jwt.expires_at >= now + 60);
if (this.logDebug) {
const remaining = jwt.expires_at ? jwt.expires_at - now : 'N/A';
this.emit('debug', `Token: ${tokenExist ? 'Exist' : 'Missing'}, expires in ${remaining} seconds`);
}
const tokenValid = this.feature.info.tokenValid;
if (this.logDebug) this.emit('debug', `Token: ${tokenValid ? 'Valid' : 'Not valid'}`);
// RESTFul and MQTT sync
if (this.restFulEnabled) this.emit('restFul', 'token', jwt);
if (this.mqttEnabled) this.emit('mqtt', 'Token', jwt);
if (tokenExist && tokenValid) {
if (this.logDebug) this.emit('debug', 'Token check complete, state: Valid');
return true;
}
if (!tokenExist) {
if (this.logWarn) this.emit('warn', 'Token not exist, requesting new');
await this.delayBeforeRetry?.() ?? new Promise(resolve => setTimeout(resolve, 30000));
const gotToken = await this.getToken();
if (!gotToken) return null;
}
if (!this.feature.info.jwtToken.token) {
if (this.logWarn) this.emit('warn', 'Token became invalid before validation');
return null;
}
if (this.logWarn) this.emit('warn', 'Token exist but not valid, validating');
const validated = await this.validateToken();
if (!validated) return null;
if (this.logDebug) this.emit('debug', 'Token check complete: Valid=true');
return true;
} catch (error) {
throw new Error(`Check token error: ${error}`);
} finally {
this.checkTokenRunning = false;
}
}
async getToken() {
if (this.logDebug) this.emit('debug', 'Requesting token');
try {
// Create EnvoyToken instance and attach log event handlers
const envoyToken = new EnvoyToken({
user: this.enlightenUser,
passwd: this.enlightenPasswd,
serialNumber: this.pv.info.serialNumber,
logWarn: this.logWarn,
logError: this.logError,
})
.on('success', message => this.emit('success', message))
.on('warn', warn => this.emit('warn', warn))
.on('error', error => this.emit('error', error));
// Attempt to refresh token
const tokenData = await envoyToken.refreshToken();
if (!tokenData || !tokenData.token) {
if (this.logWarn) this.emit('warn', 'Token request returned empty or invalid');
return null;
}
// Mask token in debug output
const maskedTokenData = {
...tokenData,
token: `${tokenData.token.slice(0, 5)}...<redacted>`
};
if (this.logDebug) this.emit('debug', 'Token:', maskedTokenData);
// Save token in memory
this.feature.info.jwtToken = tokenData;
// Persist token to disk
try {
await this.functions.saveData(this.envoyTokenFile, tokenData);
} catch (error) {
if (this.logError) this.emit('error', `Save token error: ${error}`);
}
return true;
} catch (error) {
throw new Error(`Get token error: ${error}`);
}
}
async validateToken() {
if (this.logDebug) this.emit('debug', 'Requesting validate token');
this.feature.info.tokenValid = false;
try {
const jwt = this.feature.info.jwtToken;
// Create a token-authenticated Axios instance
const axiosInstance = this.functions.createAxiosInstance(this.url, `Bearer ${jwt.token}`, null);
// Send validation request
const response = await axiosInstance.get(ApiUrls.CheckJwt);
const responseBody = response.data;
// Check for expected response string
const tokenValid = typeof responseBody === 'string' && responseBody.includes('Valid token');
if (!tokenValid) {
if (this.logWarn) this.emit('warn', `Token not valid. Response: ${responseBody}`);
return null;
}
// Extract and validate cookie
const cookie = response.headers['set-cookie'];
if (!cookie) {
if (this.logWarn) this.emit('warn', 'No cookie received during token validation');
return null;
}
// Replace axios instance with cookie-authenticated one
this.axiosInstance = this.functions.createAxiosInstance(this.url, null, cookie);
// Update internal state
this.feature.info.tokenValid = true;
this.feature.info.cookie = cookie;
this.emit('success', 'Token validate success');
return true;
} catch (error) {
this.feature.info.tokenValid = false;
throw new Error(`Validate token error: ${error}`);
}
}
async digestAuthorizationEnvoy() {
if (this.logDebug) this.emit('debug', 'Requesting digest authorization envoy');
try {
const deviceSn = this.pv.info.serialNumber;
const envoyPasswd = this.envoyPasswd || deviceSn.substring(6);
const isValidPassword = envoyPasswd.length === 6;
if (this.logDebug) this.emit('debug', 'Digest authorization envoy password:', isValidPassword ? 'Valid' : 'Not valid');
if (!isValidPassword) {
if (this.logWarn) this.emit('warn', `Digest authorization envoy password is not correct, don't worry all working correct, only the power and power max of PCU will not be displayed`);
return null;
}
this.digestAuthEnvoy = new DigestAuth({
user: Authorization.EnvoyUser,
passwd: envoyPasswd
});
this.feature.info.envoyPasswd = envoyPasswd;
return true;
} catch (error) {
if (this.logWarn) this.emit('warn', `Digest authorization error: ${error}, don't worry all working correct, only the power and power max of PCU will not be displayed`);
return null;
}
}
async digestAuthorizationInstaller() {
if (this.logDebug) this.emit('debug', 'Requesting digest authorization installer');
try {
const deviceSn = this.pv.info.serialNumber;
// Calculate installer password
const passwdCalc = new PasswdCalc({
user: Authorization.InstallerUser,
realm: Authorization.Realm,
serialNumber: deviceSn
});
const installerPasswd = await passwdCalc.getPasswd();
const isValidPassword = installerPasswd.length > 1;
if (this.logDebug) this.emit('debug', 'Digest authorization installer password:', isValidPassword ? 'Valid' : 'Not valid');
if (!isValidPassword) {
if (this.logWarn) this.emit('warn', `Digest authorization installer password: ${installerPasswd}, is not correct, don't worry all working correct, only the power production state/control and PLC level will not be displayed`);
return null;
}
this.digestAuthInstaller = new DigestAuth({
user: Authorization.InstallerUser,
passwd: installerPasswd
});
this.feature.info.installerPasswd = installerPasswd;
return true;
} catch (error) {
if (this.logWarn) this.emit('warn', `Digest authorization installer error: ${error}, don't worry all working correct, only the power production state/control and PLC level will not be displayed`);
return null;
}
}
async getEnvoyDevId() {
if (this.logDebug) this.emit('debug', 'Requesting envoy dev Id');
try {
// Try reading Envoy dev ID from file
try {
const response = await this.functions.readData(this.envoyIdFile);
const envoyDevId = response.toString() ?? '';
const isValid = envoyDevId.length === 9;
if (this.logDebug) this.emit('debug', 'Envoy dev Id from file:', isValid ? 'Exist' : 'Missing');
if (isValid) {
this.feature.info.devId = envoyDevId;
return true;
}
} catch (error) {
if (this.logWarn) this.emit('warn', `Read envoy dev Id from file error, trying from device: ${error}`);
}
// Fallback: Read Envoy dev ID from device (Backbone Application)
const response = await this.axiosInstance.get(ApiUrls.BackboneApplication);
const envoyBackboneApp = response.data;
if (this.logDebug) this.emit('debug', 'Envoy backbone app:', envoyBackboneApp);
const keyword = 'envoyDevId:';
const startIndex = envoyBackboneApp.indexOf(keyword);
if (startIndex === -1) {
if (this.logWarn) this.emit('warn', `Envoy dev Id not found, don't worry all working correct, only the power production control will not be possible`);
return null;
}
const envoyDevId = envoyBackboneApp.substr(startIndex + keyword.length, 9);
if (envoyDevId.length !== 9) {
if (this.logWarn) this.emit('warn', `Envoy dev Id: ${envoyDevId} has wrong format, don't worry all working correct, only the power production control will not be possible`);
return null;
}
// Save dev ID to file
try {
await this.functions.saveData(this.envoyIdFile, envoyDevId);
} catch (error) {
if (this.logError) this.emit('error', `Save envoy dev Id error: ${error}`);
}
// Set in-memory values
this.feature.info.devId = envoyDevId;
this.feature.backboneApp.supported = true;
return true;
} catch (error) {
if (this.logWarn) this.emit('warn', `Get envoy dev Id from device error: ${error}, don't worry all working correct, only the power production control will not be possible`);
return null;
}
}
async updateProductionState(start) {
if (this.logDebug) this.emit('debug', `Requesting production state`);
try {
const url = ApiUrls.PowerForcedModeGetPut.replace("EID", this.feature.info.devId);
const options = {
method: 'GET',
baseURL: this.url,
headers: { Accept: 'application/json' }
};
// Choose axios method based on firmware version
const response = this.feature.info.tokenRequired ? await this.axiosInstance.get(url) : await this.digestAuthInstaller.request(url, options);
const productionState = response.data;
if (this.logDebug) this.emit('debug', `Power mode:`, productionState);
const hasPowerForcedOff = 'powerForcedOff' in productionState;
if (hasPowerForcedOff) {
const state = !productionState.powerForcedOff;
this.feature.productionState.supported = true;
// Update data
this.emit('updateProductionState', state);
}
// RESTFul and MQTT update
if (this.restFulEnabled) this.emit('restFul', 'productionstate', productionState);
if (this.mqttEnabled) this.emit('mqtt', 'Production State', productionState);
return true;
} catch (error) {
if (start) {
if (this.logWarn) this.emit('warn', `Production state not supported, don't worry, all is working correctly. Only the production state monitoring sensor and control will not be displayed.`);
return null;
}
throw new Error(`Update production state error: ${error}`);
}
}
async updateHome() {
if (this.logDebug) this.emit('debug', 'Requesting home');
try {
const response = await this.axiosInstance.get(ApiUrls.Home);
const home = response.data;
if (this.logDebug) this.emit('debug', 'Home:', home);
const comm = home.comm ?? {};
const network = home.network ?? {};
const wirelessConnections = home.wireless_connection ?? [];
const networkInterfaces = network.interfaces ?? [];
// Communication device support flags
const microinvertersSupported = 'pcu' in comm;
const acBatteriesSupported = 'acb' in comm;
const qRelaysSupported = 'nsrb' in comm;
const ensemblesSupported = 'esub' in comm;
const enchargesSupported = 'encharge' in comm;
this.pv.home = home;
// Update feature flags
this.feature.inventory.pcus.supported = microinvertersSupported;
this.feature.inventory.acbs.supported = acBatteriesSupported;
this.feature.inventory.nsrbs.supported = qRelaysSupported;
this.feature.inventory.esubs.supported = ensemblesSupported;
this.feature.inventory.esubs.encharges.supported = enchargesSupported;
this.feature.home.networkInterfaces.supported = networkInterfaces.length > 0;
this.feature.home.networkInterfaces.installed = networkInterfaces.some(i => i.carrier);
this.feature.home.networkInterfaces.count = networkInterfaces.length;
this.feature.home.wirelessConnections.supported = wirelessConnections.length > 0;
this.feature.home.wirelessConnections.installed = wirelessConnections.some(w => w.connected);
this.feature.home.wirelessConnections.count = wirelessConnections.length;
this.feature.home.supported = true;
// RESTful + MQTT
if (this.restFulEnabled) this.emit('restFul', 'home', home);
if (this.mqttEnabled) this.emit('mqtt', 'Home', home);
return true;
} catch (error) {
throw new Error(`Update home error: ${error.message || error}`);
}
}
async updateInventory() {
if (this.logDebug) this.emit('debug', 'Requesting inventory');
try {
const response = await this.axiosInstance.get(ApiUrls.Inventory);
const inventory = response.data;
if (this.logDebug) this.emit('debug', 'Inventory:', inventory);
const parseDeviceCommon = (device) => ({
partNumber: device.part_num,
installed: device.installed,
serialNumber: device.serial_num,
deviceStatus: device.device_status,
readingTime: device.last_rpt_date,
adminState: device.admin_state,
devType: device.dev_type,
createdDate: device.created_date,
imageLoadDate: device.img_load_date,
firmware: device.img_pnum_running,
ptpn: device.ptpn,
chaneId: device.chaneid,
deviceControl: device.device_control,
producing: device.producing,
communicating: device.communicating,
operating: device.operating,
provisioned: device.provisioned,
});
const parseMicroinverters = (devices) => devices.slice(0, 70).map(device => ({
type: 'pcu',
...parseDeviceCommon(device),
phase: device.phase
}));
const parseAcBatteries = (devices) => devices.map(device => ({
type: 'acb',
...parseDeviceCommon(device),
sleepEnabled: device.sleep_enabled,
percentFull: device.percentFull,
maxCellTemp: device.maxCellTemp,
sleepMinSoc: device.sleep_min_soc,
sleepMaxSoc: device.sleep_max_soc,
chargeState: device.charge_status
}));
const parseQRelays = (devices) => devices.map(device => ({
type: 'nsrb',
...parseDeviceCommon(device),
relay: device.relay,
reasonCode: device.reason_code,
reason: device.reason,
linesCount: device['line-count'],
line1Connected: device['line1-connected'],
line2Connected: device['line2-connected'],
line3Connected: device['line3-connected']
}));
const parseEnsembles = (devices) => devices.map(device => ({
type: 'esub',
...parseDeviceCommon(device),
}));
const inventoryKeys = inventory.map(i => i.type);
const getDevicesByType = (type) => inventory.find(i => i.type === type)?.devices ?? [];
// Microinverters
const microinverters = getDevicesByType('PCU');
this.feature.inventory.pcus.supported = inventoryKeys.includes('PCU');
this.feature.inventory.pcus.installed = microinverters.length > 0;
if (this.feature.inventory.pcus.installed) {
this.pv.inventory.pcus = parseMicroinverters(microinverters);
this.feature.inventory.pcus.count = microinverters.length;
}
// AC Batteries
const acbs = getDevicesByType('ACB');
this.feature.inventory.acbs.supported = inventoryKeys.includes('ACB');
this.feature.inventory.acbs.installed = acbs.length > 0;
if (this.feature.inventory.acbs.installed) {
this.pv.inventory.acbs = parseAcBatteries(acbs);
this.feature.inventory.acbs.count = acbs.length;
}
// Q-Relays
const nsrbs = getDevicesByType('NSRB');
this.feature.inventory.nsrbs.supported = inventoryKeys.includes('NSRB');
this.feature.inventory.nsrbs.installed = nsrbs.length > 0;
if (this.feature.inventory.nsrbs.installed) {
this.pv.inventory.nsrbs = parseQRelays(nsrbs);
this.feature.inventory.nsrbs.count = nsrbs.length;
}
// Ensembles
const esubs = getDevicesByType('ESUB');
this.feature.inventory.esubs.supported = inventoryKeys.includes('ESUB');
this.feature.inventory.esubs.installed = esubs.length > 0;
if (this.feature.inventory.esubs.installed) {
this.pv.inventory.esubs.devices = parseEnsembles(esubs);
this.feature.inventory.esubs.count = esubs.length;
}
// Inventory globally supported
this.feature.inventory.supported = true;
// RESTful & MQTT publishing
if (this.restFulEnabled) this.emit('restFul', 'inventory', inventory);
if (this.mqttEnabled) this.emit('mqtt', 'Inventory', inventory);
try {
const inventoryBySerialNumber = await this.functions.mapInventoryBySerial(inventory);
// RESTful & MQTT publishing
if (this.restFulEnabled) this.emit('restFul', 'inventorybyserialnumber', inventoryBySerialNumber);
if (this.mqttEnabled) this.emit('mqtt', 'Inventory By Serial Number', inventoryBySerialNumber);
} catch (error) {
if (this.logError) this.emit('error', `Map inventory by serial error: ${error}`);
}
return true;
} catch (error) {
throw new Error(`Update inventory error: ${error}`);
}
}
async updatePcuStatus() {
if (this.logDebug) this.emit('debug', `Requesting pcu status`);
try {
const options = {
method: 'GET',
baseURL: this.url,
headers: {
Accept: 'application/json'
}
};
const response = this.feature.info.tokenRequired ? await this.axiosInstance.get(ApiUrls.InverterProduction) : await this.digestAuthEnvoy.request(ApiUrls.InverterProduction, options);
const pcus = response.data;
if (this.logDebug) this.emit('debug', `Pcu status:`, pcus);
//pcu devices count
const pcusSupported = pcus.length > 0;
if (!pcusSupported) return;
this.pv.inventory.pcus.forEach((pcu) => {
const device = pcus.find(device => device.serialNumber === pcu.serialNumber);
if (!device) return;
const obj = {
type: 'pcu',
readingTime: device.lastReportDate,
power: device.lastReportWatts,
powerPeak: device.maxReportWatts,
};
Object.assign(pcu, obj);
});
this.feature.inventory.pcus.status.supported = true;
// RESTFul and MQTT update
if (this.restFulEnabled) this.emit('restFul', 'microinvertersstatus', pcus)
if (this.mqttEnabled) this.emit('mqtt', 'Microinverters Status', pcus);
return true;
} catch (error) {
throw new Error(`Update pcu status error: ${error}`);
}
}
async updateMeters() {
if (this.logDebug) this.emit('debug', `Requesting meters info`);
try {
const response = await this.axiosInstance.get(ApiUrls.InternalMeterInfo);
const responseData = response.data;
if (this.logDebug) this.emit('debug', `Meters:`, responseData);
// Check if any meters are installed
const metersInstalled = responseData.length > 0;
if (metersInstalled) {
const arr = [];
for (const meter of responseData) {
const key = MetersKeyMap[meter.measurementType];
if (!key) {
if (this.logDebug) this.emit('debug', `Unknown meter measurement type: ${meter.measurementType}`);
continue;
}
const phaseModeCode = ApiCodes[meter.phaseMode];
const meteringStatusCode = ApiCodes[meter.meteringStatus];
const voltageDivide = meter.phaseMode === 'split' ? 2 : meter.phaseMode === 'three' ? 3 : 1;
const powerFactorDivide = meter.phaseMode === 'split' ? 2 : 1;
const obj = {
eid: meter.eid,
type: 'eim',
activeCount: 1,
measurementType: meter.measurementType,
state: meter.state === 'enabled',
phaseMode: phaseModeCode,
phaseCount: meter.phaseCount ?? 1,
meteringStatus: meteringStatusCode,
statusFlags: meter.statusFlags,
voltageDivide: voltageDivide,
powerFactorDivide: powerFactorDivide,
};
arr.push(obj);
this.feature.meters[key].supported = true;
this.feature.meters[key].enabled = obj.state;
}
this.pv.meters = arr;
this.feature.meters.installed = arr.some(m => m.state);
this.feature.meters.count = arr.length;
}
//meters supported
this.feature.meters.supported = true;
// RESTFul and MQTT update
if (this.restFulEnabled) this.emit('restFul', 'meters', responseData);
if (this.mqttEnabled) this.emit('mqtt', 'Meters', responseData);
return true;
} catch (error) {
throw new Error(`Update meters error: ${error}`);
}
}
async updateMetersReading(start) {
if (this.logDebug) this.emit('debug', `Requesting meters reading`);
try {
const response = await this.axiosInstance.get(ApiUrls.InternalMeterReadings);
const responseData = response.data;
if (this.logDebug) this.emit('debug', `Meters reading:`, responseData);
// Check if readings exist and are valid
const metersReadingInstalled = Array.isArray(responseData) && responseData.length > 0;
if (metersReadingInstalled) {
for (const meter of responseData) {
const meterConfig = this.pv.meters.find(m => m.eid === meter.eid && m.state === true);
if (!meterConfig) continue;
const obj = {
readingTime: meter.timestamp,
power: meter.activePower,
apparentPower: meter.apparentPower,
reactivePower: meter.reactivePower,
energyLifetime: meter.actEnergyDlvd,
energyLifetimeUpload: meter.actEnergyRcvd,
apparentEnergy: meter.apparentEnergy,
current: meter.current,
voltage: meter.voltage / meterConfig.voltageDivide,
pwrFactor: meter.pwrFactor / meterConfig.powerFactorDivide,
frequency: meter.freq,
channels: meter.channels ?? [],
};
Object.assign(meterConfig, obj);
}
this.feature.metersReading.installed = true;
}
//meters readings supported
this.feature.metersReading.supported = true;
// RESTFul and MQTT update