UNPKG

homebridge-enphase-envoy

Version:

Homebridge plugin for Photovoltaic Energy System manufactured by Enphase.

1,253 lines (1,102 loc) 126 kB
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