homebridge-enphase-envoy
Version:
Homebridge plugin for Photovoltaic Energy System manufactured by Enphase.
246 lines (219 loc) • 10.8 kB
JavaScript
import { join } from 'path';
import { mkdirSync, existsSync, writeFileSync } from 'fs';
import EnvoyDevice from './src/envoydevice.js';
import ImpulseGenerator from './src/impulsegenerator.js';
import RestFul from './src/restful.js';
import Mqtt from './src/mqtt.js';
import { PluginName, PlatformName } from './src/constants.js';
import CustomCharacteristics from './src/customcharacteristics.js';
class EnvoyPlatform {
constructor(log, config, api) {
if (!config || !Array.isArray(config.devices)) {
log.warn(`No configuration found for ${PluginName}.`);
return;
}
this.accessories = [];
const prefDir = join(api.user.storagePath(), 'enphaseEnvoy');
try {
mkdirSync(prefDir, { recursive: true });
} catch (error) {
log.error(`Prepare directory error: ${error.message ?? error}.`);
return;
}
api.on('didFinishLaunching', () => {
// Each device is set up independently — a failure in one does not
// block the others. Promise.allSettled runs all in parallel.
Promise.allSettled(
config.devices.map((device, i) =>
this.setupDevice(device, i, prefDir, log, api)
)
).then(results => {
results.forEach((result, i) => {
if (result.status === 'rejected') {
log.error(`Device[${i}] setup error: ${result.reason?.message ?? result.reason}`);
}
});
});
});
}
// ── Per-device setup ───────────────────────────────────────────────────────
async setupDevice(device, index, prefDir, log, api) {
const displayType = device.displayType ?? 0;
if (displayType === 0) return;
const deviceName = device.name;
const host = device.host || (index === 0 ? 'envoy.local' : `envoy-${index + 1}.local`);
const { envoyFirmware7xxTokenGenerationMode = 0, envoyToken, enlightenUser, enlightenPasswd } = device;
const logLevel = {
devInfo: device.log?.deviceInfo ?? true,
success: device.log?.success ?? true,
info: device.log?.info ?? false,
warn: device.log?.warn ?? true,
error: device.log?.error ?? true,
debug: device.log?.debug ?? false,
};
if (!deviceName) {
log.warn(`Device: ${host}, Name missing.`);
return;
}
if (envoyFirmware7xxTokenGenerationMode === 1 && (!enlightenUser || !enlightenPasswd)) {
log.warn(`Device: ${host} ${deviceName}, missing Enlighten credentials.`);
return;
}
if (envoyFirmware7xxTokenGenerationMode === 2 && !envoyToken) {
log.warn(`Device: ${host} ${deviceName}, missing Envoy token.`);
return;
}
if (logLevel.debug) {
log.info(`Device: ${host} ${deviceName}, did finish launching.`);
const redactedConfig = JSON.stringify({
...device,
envoyPasswd: 'removed',
envoyToken: 'removed',
enlightenPasswd: 'removed',
mqtt: {
auth: {
...device.mqtt?.auth,
passwd: 'removed',
},
},
}, null, 2);
log.info(`Device: ${host} ${deviceName}, Config: ${redactedConfig}`);
}
const postFix = host.replaceAll('.', '');
const envoyIdFile = join(prefDir, `envoyId_${postFix}`);
const envoyTokenFile = join(prefDir, `envoyToken_${postFix}`);
const energyLifetimeHistoryFile = join(prefDir, `energyLifetimeHistory_${postFix}`);
const energyMeterHistoryFileName = `energyMeterHistory_${postFix}`;
try {
[envoyIdFile, envoyTokenFile, energyLifetimeHistoryFile].forEach(file => {
if (!existsSync(file)) writeFileSync(file, '0');
});
} catch (error) {
if (logLevel.error) log.error(`Device: ${host} ${deviceName}, File init error: ${error.message ?? error}`);
return;
}
const url = envoyFirmware7xxTokenGenerationMode > 0 ? `https://${host}` : `http://${host}`;
// Create RestFul and MQTT once — before the retry loop — so the port/connection
// is established a single time and survives across all connect attempts.
// The 'set' handler uses activeDevice so it always routes to the current instance.
let activeDevice = null;
let restFul1 = null;
let restFulConnected = false;
if (device.restFul?.enable) {
try {
await new Promise((resolve) => {
const timer = setTimeout(resolve, 5000);
restFul1 = new RestFul({
port: device.restFul.port || 3000,
logWarn: logLevel.warn,
logDebug: logLevel.debug,
})
.once('connected', (msg) => {
clearTimeout(timer);
restFulConnected = true;
if (logLevel.success) log.success(`Device: ${host} ${deviceName}, ${msg}`);
resolve();
})
.on('set', async (key, value) => {
try {
if (activeDevice) await activeDevice.setOverExternalIntegration('RESTFul', key, value);
} catch (error) {
if (logLevel.warn) log.warn(`Device: ${host} ${deviceName}, RESTFul set error: ${error.message ?? error}`);
}
})
.on('debug', (msg) => logLevel.debug && log.info(`Device: ${host} ${deviceName}, debug: ${msg}`))
.on('warn', (msg) => logLevel.warn && log.warn(`Device: ${host} ${deviceName}, ${msg}`))
.on('error', (msg) => logLevel.error && log.error(`Device: ${host} ${deviceName}, ${msg}`));
});
} catch (error) {
if (logLevel.warn) log.warn(`Device: ${host} ${deviceName}, RESTFul start error: ${error.message ?? error}`);
}
}
let mqtt1 = null;
let mqttConnected = false;
if (device.mqtt?.enable) {
try {
await new Promise((resolve) => {
const timer = setTimeout(resolve, 10000);
mqtt1 = new Mqtt({
host: device.mqtt.host,
port: device.mqtt.port || 1883,
clientId: device.mqtt.clientId ? `enphase_${device.mqtt.clientId}_${Math.random().toString(16).slice(3)}` : `enphase_${Math.random().toString(16).slice(3)}`,
prefix: device.mqtt.prefix ? `enphase/${device.mqtt.prefix}/${deviceName}` : `enphase/${deviceName}`,
user: device.mqtt.auth?.user,
passwd: device.mqtt.auth?.passwd,
logWarn: logLevel.warn,
logDebug: logLevel.debug,
})
.once('connected', (msg) => {
clearTimeout(timer);
mqttConnected = true;
if (logLevel.success) log.success(`Device: ${host} ${deviceName}, ${msg}`);
resolve();
})
.on('set', async (key, value) => {
try {
if (activeDevice) await activeDevice.setOverExternalIntegration('MQTT', key, value);
} catch (error) {
if (logLevel.warn) log.warn(`Device: ${host} ${deviceName}, MQTT set error: ${error.message ?? error}`);
}
})
.on('debug', (msg) => logLevel.debug && log.info(`Device: ${host} ${deviceName}, debug: ${msg}`))
.on('warn', (msg) => logLevel.warn && log.warn(`Device: ${host} ${deviceName}, ${msg}`))
.on('error', (msg) => logLevel.error && log.error(`Device: ${host} ${deviceName}, ${msg}`));
});
} catch (error) {
if (logLevel.warn) log.warn(`Device: ${host} ${deviceName}, MQTT start error: ${error.message ?? error}`);
}
}
// The startup impulse generator retries the full connect+start cycle
// every 120 s until it succeeds, then hands off to the device's own
// impulse generator and stops itself.
const impulseGenerator = new ImpulseGenerator()
.on('start', async () => {
try {
await this.startDevice({
device, deviceName, host, url,
envoyIdFile, envoyTokenFile, prefDir,
energyLifetimeHistoryFile, energyMeterHistoryFileName,
logLevel, log, api, impulseGenerator,
restFul1, restFulConnected, mqtt1, mqttConnected,
onDeviceReady: (d) => { activeDevice = d; },
});
} catch (error) {
if (logLevel.error) log.error(`Device: ${host} ${deviceName}, Start impulse generator error: ${error.message ?? error}, retrying.`);
}
})
.on('state', state => {
if (logLevel.debug) log.info(`Device: ${host} ${deviceName}, Start impulse generator ${state ? 'started' : 'stopped'}.`);
});
await impulseGenerator.state(true, [{ name: 'start', sampling: 120_000 }]);
}
// ── Connect and register accessories for one device ────────────────────────
async startDevice({ device, deviceName, host, url, envoyIdFile, envoyTokenFile, prefDir, energyLifetimeHistoryFile, energyMeterHistoryFileName, logLevel, log, api, impulseGenerator, restFul1, restFulConnected, mqtt1, mqttConnected, onDeviceReady }) {
const envoyDevice = new EnvoyDevice(api, log, url, deviceName, device, envoyIdFile, envoyTokenFile, prefDir, energyLifetimeHistoryFile, energyMeterHistoryFileName, restFul1, restFulConnected, mqtt1, mqttConnected)
.on('devInfo', (info) => logLevel.devInfo && log.info(info))
.on('success', (msg) => logLevel.success && log.success(`Device: ${host} ${deviceName}, ${msg}`))
.on('info', (msg) => logLevel.info && log.info(`Device: ${host} ${deviceName}, ${msg}`))
.on('debug', (msg, data) => logLevel.debug && log.info(`Device: ${host} ${deviceName}, debug: ${data ? `${msg} ${JSON.stringify(data, null, 2)}` : msg}`))
.on('warn', (msg) => logLevel.warn && log.warn(`Device: ${host} ${deviceName}, ${msg}`))
.on('error', (msg) => logLevel.error && log.error(`Device: ${host} ${deviceName}, ${msg}`));
const accessories = await envoyDevice.start();
if (!accessories) return;
onDeviceReady(envoyDevice);
api.publishExternalAccessories(PluginName, accessories);
if (logLevel.success) log.success(`Device: ${host} ${deviceName}, Published as external accessory.`);
// Stop the startup impulse generator and hand off to the device's
// own periodic impulse generator.
await impulseGenerator.state(false);
await envoyDevice.startStopImpulseGenerator(true);
}
// ── Homebridge accessory cache ─────────────────────────────────────────────
configureAccessory(accessory) {
this.accessories.push(accessory);
}
}
export default (api) => {
CustomCharacteristics(api);
api.registerPlatform(PluginName, PlatformName, EnvoyPlatform);
};