homebridge-proflame
Version:
Homebridge plugin for Mendota / Proflame Connect fireplaces via WebSocket
466 lines (400 loc) • 20.2 kB
JavaScript
'use strict';
/**
* homebridge-proflame v1.8.0
* Layout toggle:
* - "heaterCooler" (default): Primary tile = HeaterCooler (heat-only) with RotationSpeed as flame.
* - "classic": Primary tile = Switch; optional child tiles as configured.
*
* Logging: 'none' (errors only), 'info' (startup + one-time connect + initial snapshot), 'debug' (verbose).
*/
const WebSocket = require('ws');
let Service, Characteristic;
module.exports = (homebridge) => {
Service = homebridge.hap.Service;
Characteristic = homebridge.hap.Characteristic;
homebridge.registerAccessory('homebridge-proflame', 'ProflameFireplace', ProflameFireplaceAccessory);
};
class ProflameFireplaceAccessory {
constructor(log, config = {}) {
this.log = log;
this.config = config || {};
// Basic
this.name = config.name || 'Fireplace';
this.ip = config.ip || null;
this.layout = (config.layout || 'heaterCooler').toLowerCase(); // 'heaterCooler' | 'classic'
// Logging
this.logLevel = config.logLevel || 'none';
this._debug = (...a) => { if (this.logLevel === 'debug') this.log.debug('[Proflame]', ...a); };
this._info = (...a) => { if (this.logLevel === 'info' || this.logLevel === 'debug') this.log.info('[Proflame]', ...a); };
this._warn = (...a) => { this.log.warn('[Proflame]', ...a); };
this._error = (...a) => {
this.log.error('[Proflame]', ...a);
if (this.logLevel === 'info' || this.logLevel === 'debug') this.log.info('[Proflame][error]', ...a);
};
// Optional features (child tiles). Default false.
this.pilotEnabled = !!config.pilotEnabled;
this.fanEnabled = !!config.fanEnabled;
this.splitFlowEnabled = !!config.splitFlowEnabled;
this.auxEnabled = !!config.auxEnabled;
this.flameControlEnabled = !!config.flameControlEnabled; // classic-only
this.thermostatEnabled = !!config.thermostatEnabled; // classic-only (legacy HC)
this.lampEnabled = !!config.lampEnabled;
this.smartModeEnabled = !!config.smartModeEnabled;
// Presets
this.presets = config.presets || {};
this.onPreset = this.presets.onPreset || {}; // { flameLevel, fanLevel, splitLevel(bool/0/1), lampLevel, mode }
// Internal state
this.isOn = false;
this.flameLevel = 0; // 0–6
this.fanLevel = 0; // 0–6
this.lampLevel = 0; // 0–6
this.currentTemp = 710; // tenths °F
this.targetTemperature = 770; // tenths °F
// WS
this.ws = null;
this.pingInterval = null;
this.outbox = [];
this.connectedLoggedOnce = false;
// Aggregate first-burst snapshot once ever
this._initialBuf = [];
this._initialTimer = null;
this._initialLogged = false;
this._initialSnapshotDone = false;
// Services
this.services = [];
this.setupServices();
if (this.ip) this.setupWebSocket();
else this._warn('No "ip" configured; WebSocket disabled.');
}
/* ---------------- Services ---------------- */
setupServices() {
const info = new Service.AccessoryInformation()
.setCharacteristic(Characteristic.Manufacturer, 'Mendota')
.setCharacteristic(Characteristic.Model, 'Proflame')
.setCharacteristic(Characteristic.SerialNumber, this.ip || 'unknown');
this.services.push(info);
if (this.layout === 'heatercooler') this._setupHeaterCoolerLayout();
else this._setupClassicLayout();
}
_setupHeaterCoolerLayout() {
// Primary HeaterCooler (heat-only)
const hc = new Service.HeaterCooler(this.name, 'main-hc');
// Active maps to ignition/kill
hc.getCharacteristic(Characteristic.Active)
.on('get', cb => cb(null, this.isOn ? 1 : 0))
.on('set', async (val, cb) => {
try {
if (val === 1) {
// Apply preset ONLY for features NOT exposed as child tiles; flame is controlled here, so skip flameLevel
await this._applyPresetOn({ skipFlame: true });
this.isOn = true;
} else {
await this.sendMainMode(0);
this.isOn = false;
}
cb(null);
} catch (e) { this._error('HeaterCooler Active set error:', e?.message || e); cb(e); }
});
// Only HEAT
const tgt = hc.getCharacteristic(Characteristic.TargetHeaterCoolerState);
tgt.setProps({ validValues: [Characteristic.TargetHeaterCoolerState.HEAT] });
tgt.on('get', cb => cb(null, Characteristic.TargetHeaterCoolerState.HEAT))
.on('set', (v, cb) => cb(null)); // ignore changes; locked to HEAT
// Current state
hc.getCharacteristic(Characteristic.CurrentHeaterCoolerState)
.on('get', cb => {
const state = this._currentHCState();
cb(null, state);
});
// CurrentTemperature is required (report in °C)
hc.getCharacteristic(Characteristic.CurrentTemperature)
.on('get', cb => cb(null, this._fTenthsToC(this.currentTemp)));
// RotationSpeed controls flame
hc.addCharacteristic(Characteristic.RotationSpeed)
.setProps({ minValue: 0, maxValue: 100, minStep: 1 })
.on('get', cb => cb(null, this._levelToPct(this.flameLevel)))
.on('set', (val, cb) => { this.flameLevel = this._pctToLevel(val); this.sendFlameControl(this.flameLevel); cb(null); });
this.services.push(hc);
// Child tiles
if (this.lampEnabled) this._addLamp();
if (this.fanEnabled) this._addBlowerFanV2();
if (this.splitFlowEnabled) this._addSplitFlow();
if (this.auxEnabled) this._addAux(); // optional
}
_setupClassicLayout() {
// Main switch
const main = new Service.Switch(this.name, 'main');
main.getCharacteristic(Characteristic.On)
.on('get', cb => cb(null, this.isOn))
.on('set', async (val, cb) => {
try { await this.handleMainSet(val); cb(null); }
catch (e) { this._error('Main set failed:', e?.message || e); cb(e); }
});
this.services.push(main);
// Optional child tiles (legacy behavior)
if (this.pilotEnabled) this._addPilot();
if (this.fanEnabled) this._addFanLegacy();
if (this.splitFlowEnabled) this._addSplitFlow();
if (this.auxEnabled) this._addAux();
if (this.flameControlEnabled)this._addFlameLegacy();
if (this.thermostatEnabled) this._addThermostatLegacy();
if (this.lampEnabled) this._addLamp();
if (this.smartModeEnabled) this._addSmartMode();
}
/* ----- Child service builders ----- */
_addLamp() {
const lamp = new Service.Lightbulb(`${this.name} Light`, 'lamp');
lamp.getCharacteristic(Characteristic.On)
.on('get', cb => cb(null, this.lampLevel > 0))
.on('set', (val, cb) => {
if (val && this.lampLevel === 0) this.lampLevel = 1;
if (!val) this.lampLevel = 0;
this.sendLampControl(this.lampLevel);
cb(null);
});
lamp.getCharacteristic(Characteristic.Brightness)
.setProps({ minValue: 0, maxValue: 100, minStep: 1 })
.on('get', cb => cb(null, this._levelToPct(this.lampLevel)))
.on('set', (val, cb) => { this.lampLevel = this._pctToLevel(val); this.sendLampControl(this.lampLevel); cb(null); });
this.services.push(lamp);
}
_addBlowerFanV2() {
// Prefer Fanv2 when available; otherwise fallback to Fan
const svc = Service.Fanv2 ? new Service.Fanv2(`${this.name} Blower`, 'blower') : new Service.Fan(`${this.name} Blower`, 'blower');
const hasActive = !!Service.Fanv2;
if (hasActive) {
svc.getCharacteristic(Characteristic.Active)
.on('get', cb => cb(null, this.fanLevel > 0 ? 1 : 0))
.on('set', (val, cb) => { if (val === 0) this.fanLevel = 0; else if (this.fanLevel === 0) this.fanLevel = 1; this.sendFanControl(this.fanLevel); cb(null); });
} else {
svc.getCharacteristic(Characteristic.On)
.on('get', cb => cb(null, this.fanLevel > 0))
.on('set', (val, cb) => { if (!val) this.fanLevel = 0; else if (this.fanLevel === 0) this.fanLevel = 1; this.sendFanControl(this.fanLevel); cb(null); });
}
svc.getCharacteristic(Characteristic.RotationSpeed)
.setProps({ minValue: 0, maxValue: 100, minStep: 1 })
.on('get', cb => cb(null, this._levelToPct(this.fanLevel)))
.on('set', (val, cb) => { this.fanLevel = this._pctToLevel(val); this.sendFanControl(this.fanLevel); cb(null); });
this.services.push(svc);
}
_addSplitFlow() {
const svc = new Service.Switch(`${this.name} Flame Split`, 'split');
svc.getCharacteristic(Characteristic.On)
.on('get', cb => cb(null, false))
.on('set', (val, cb) => { this.sendSplitFlow(val ? 1 : 0); cb(null); });
this.services.push(svc);
}
_addAux() {
const svc = new Service.Switch(`${this.name} Auxiliary`, 'aux');
svc.getCharacteristic(Characteristic.On)
.on('get', cb => cb(null, false))
.on('set', (val, cb) => { this.sendAux(val ? 1 : 0); cb(null); });
this.services.push(svc);
}
// ----- Classic-only builders -----
_addPilot() {
const svc = new Service.Switch(`${this.name} Pilot`, 'pilot');
svc.getCharacteristic(Characteristic.On)
.on('get', cb => cb(null, false))
.on('set', (val, cb) => { this.sendPilotMode(val ? 1 : 0); cb(null); });
this.services.push(svc);
}
_addFanLegacy() {
const fan = new Service.Fan(`${this.name} Fan`, 'fan');
fan.getCharacteristic(Characteristic.On)
.on('get', cb => cb(null, this.fanLevel > 0))
.on('set', (val, cb) => { if (!val) this.fanLevel = 0; else if (this.fanLevel === 0) this.fanLevel = 1; this.sendFanControl(this.fanLevel); cb(null); });
fan.getCharacteristic(Characteristic.RotationSpeed)
.setProps({ minValue: 0, maxValue: 100, minStep: 1 })
.on('get', cb => cb(null, this._levelToPct(this.fanLevel)))
.on('set', (val, cb) => { this.fanLevel = this._pctToLevel(val); this.sendFanControl(this.fanLevel); cb(null); });
this.services.push(fan);
}
_addFlameLegacy() {
const flame = new Service.Lightbulb(`${this.name} Flame`, 'flame');
flame.getCharacteristic(Characteristic.On)
.on('get', cb => cb(null, this.flameLevel > 0))
.on('set', (val, cb) => {
if (val && (!this.isOn || this.flameLevel === 0)) { this.isOn = true; this.sendMainMode(1); }
else if (!val) { this.flameLevel = 0; this.sendFlameControl(0); }
cb(null);
});
flame.getCharacteristic(Characteristic.Brightness)
.setProps({ minValue: 0, maxValue: 100, minStep: 1 })
.on('get', cb => cb(null, this._levelToPct(this.flameLevel)))
.on('set', (val, cb) => { this.flameLevel = this._pctToLevel(val); this.sendFlameControl(this.flameLevel); cb(null); });
this.services.push(flame);
}
_addThermostatLegacy() {
const thermo = new Service.HeaterCooler(`${this.name} Thermostat`, 'thermo');
thermo.getCharacteristic(Characteristic.Active)
.on('get', cb => cb(null, this.isOn ? 1 : 0))
.on('set', (val, cb) => { if (val === 1) { this.isOn = true; this.sendMainMode(2); } else { this.isOn = false; this.sendMainMode(0); } cb(null); });
thermo.getCharacteristic(Characteristic.TargetTemperature)
.setProps({ minValue: 10, maxValue: 30, minStep: 0.5 })
.on('get', cb => cb(null, this._fTenthsToC(this.targetTemperature)))
.on('set', (val, cb) => { const f10 = Math.round(((val * 9/5) + 32) * 10); this.targetTemperature = f10; this.sendTemperatureSet(f10); cb(null); });
thermo.getCharacteristic(Characteristic.CurrentTemperature)
.on('get', cb => cb(null, this._fTenthsToC(this.currentTemp)));
this.services.push(thermo);
}
/* ------------- Helpers ------------- */
_pctToLevel(pct) {
if (pct <= 0) return 0;
if (pct >= 100) return 6;
const level = Math.round((pct / 100) * 6);
return Math.max(0, Math.min(6, level));
}
_levelToPct(level) {
const l = Math.max(0, Math.min(6, Number(level) || 0));
return Math.round((l / 6) * 100);
}
_fTenthsToC(fTenths) {
const f = (Number(fTenths) || 0) / 10;
return Math.round(((f - 32) * 5 / 9) * 10) / 10;
}
_currentHCState() {
if (!this.isOn) return Characteristic.CurrentHeaterCoolerState.INACTIVE;
if (this.flameLevel > 0) return Characteristic.CurrentHeaterCoolerState.HEATING;
return Characteristic.CurrentHeaterCoolerState.IDLE; // pilot only
}
/* ------------- Behavior ------------- */
async handleMainSet(value) {
this._debug('handleMainSet ->', value);
if (value) {
await this._applyPresetOn({ skipFlame: !this.flameControlEnabled }); // classic primary has no flame unless flameControlEnabled
this.isOn = true;
} else {
await this.sendMainMode(0);
this.isOn = false;
}
}
async _applyPresetOn({ skipFlame }) {
const preset = this.onPreset && Object.keys(this.onPreset).length ? this.onPreset : null;
if (!preset) { this._debug('No preset configured; main_mode=1'); return this.sendMainMode(1); }
const payload = { main_mode: 1 };
if (!skipFlame && preset.flameLevel !== undefined) payload.flame_control = this._clampLevel(preset.flameLevel);
if (preset.fanLevel !== undefined && !this.fanEnabled) payload.fan_control = this._clampLevel(preset.fanLevel);
if (preset.splitLevel !== undefined && !this.splitFlowEnabled) payload.split_flow = (typeof preset.splitLevel === 'boolean') ? (preset.splitLevel ? 1 : 0) : (Number(preset.splitLevel) ? 1 : 0);
if (preset.lampLevel !== undefined && !this.lampEnabled) payload.lamp_control = this._clampLevel(preset.lampLevel);
if (preset.mode !== undefined) payload.mode = preset.mode;
this._debug('Preset ON payload:', payload);
return this.sendJSON(payload);
}
_clampLevel(n) { return Math.max(0, Math.min(6, Number(n) || 0)); }
/* ---------------- WebSocket ---------------- */
setupWebSocket() {
const wsUrl = `ws://${this.ip}:88/`;
// Startup summary
this._info(`Connecting to ${wsUrl} (layout=${this.layout}; features: pilot=${this.pilotEnabled}, fan=${this.fanEnabled}, split=${this.splitFlowEnabled}, aux=${this.auxEnabled}, lamp=${this.lampEnabled}, smart=${this.smartModeEnabled})`);
const p = this.onPreset || {};
this._info(`Preset -> flame=${p.flameLevel ?? '-'} fan=${p.fanLevel ?? '-'} split=${p.splitLevel ? 'on' : 'off'} lamp=${p.lampLevel ?? '-'} mode=${p.mode ?? 'manual'}`);
try {
this.ws = new WebSocket(wsUrl);
} catch (err) { this._error('WebSocket init failed:', err?.message || err); return; }
this.ws.on('open', () => {
if (!this.connectedLoggedOnce) { this._info('Connected successfully'); this.connectedLoggedOnce = true; }
else { this._debug('Reconnected'); }
// Reset per-session aggregation
this._initialBuf = []; this._initialLogged = false; this._initialTimer = null;
this._sendRaw('PROFLAMEPING');
this._sendRaw('PROFLAMECONNECTION');
// Flush queue
if (this.outbox.length) {
this._debug(`Flushing ${this.outbox.length} queued message(s)`);
for (const msg of this.outbox) { try { this.ws.send(msg); } catch(e) { this._error('Queue flush failed:', e?.message || e); } }
this.outbox = [];
}
if (this.pingInterval) clearInterval(this.pingInterval);
this.pingInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) this._sendRaw('PROFLAMEPING');
}, 30000);
});
this.ws.on('message', packet => {
const data = Buffer.isBuffer(packet) ? packet.toString('utf8') : packet;
if (data === 'PROFLAMEPONG') { this._debug('PONG'); return; }
try {
const msg = JSON.parse(data);
this._debug('Incoming:', msg);
// One-time aggregated initial snapshot
if (!this._initialSnapshotDone && !this._initialLogged && (this.logLevel === 'info' || this.logLevel === 'debug')) {
this._initialBuf.push(msg);
if (!this._initialTimer) {
this._initialTimer = setTimeout(() => {
try {
const merged = Object.assign({}, ...this._initialBuf);
this._info(`Initial response (aggregated ${this._initialBuf.length} msg):`, JSON.stringify(merged));
} catch (e) {
this._info('Initial response:', JSON.stringify(this._initialBuf[0] || {}));
} finally {
this._initialLogged = true;
this._initialSnapshotDone = true;
this._initialBuf = [];
this._initialTimer = null;
}
}, 1500);
}
}
// State updates
if (msg.main_mode !== undefined) this.isOn = (parseInt(msg.main_mode, 10) !== 0);
if (msg.fan_control !== undefined && (this.fanEnabled || this.layout === 'heatercooler')) this.fanLevel = parseInt(msg.fan_control, 10);
if (msg.flame_control !== undefined) this.flameLevel = parseInt(msg.flame_control, 10);
if (msg.temperature_set !== undefined) this.targetTemperature = parseInt(msg.temperature_set, 10);
if (msg.room_temperature !== undefined) this.currentTemp = parseInt(msg.room_temperature, 10);
if (msg.lamp_control !== undefined) this.lampLevel = parseInt(msg.lamp_control, 10);
// Update primary service quickly
const hc = this.services.find(s => s.subtype === 'main-hc');
if (hc) {
hc.updateCharacteristic(Characteristic.Active, this.isOn ? 1 : 0);
hc.updateCharacteristic(Characteristic.CurrentHeaterCoolerState, this._currentHCState());
hc.updateCharacteristic(Characteristic.CurrentTemperature, this._fTenthsToC(this.currentTemp));
const rs = hc.getCharacteristic(Characteristic.RotationSpeed);
if (rs) rs.updateValue(this._levelToPct(this.flameLevel));
} else {
const main = this.services.find(s => s.subtype === 'main');
if (main) main.updateCharacteristic(Characteristic.On, this.isOn);
}
} catch (e) { this._debug('Non-JSON or parse fail:', data); }
});
this.ws.on('close', (code, reason) => {
this._debug(`WebSocket closed; code=${code || 0} reason=${reason || ''}; reconnect in 10s`);
if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; }
this.outbox = [];
setTimeout(() => this.setupWebSocket(), 10000);
});
this.ws.on('error', err => { this._error('WebSocket error:', err?.message || err); });
}
/* ---------------- Send helpers ---------------- */
_sendRaw(payload) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this._debug('WS not open; raw send skipped'); return; }
this._debug('RAW ->', payload); this.ws.send(payload);
}
async sendJSON(payload) {
const msg = JSON.stringify(payload);
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this._debug('WS not open; queuing ->', msg);
if (!this.ws) this.setupWebSocket();
this.outbox.push(msg);
return;
}
this._debug('SEND ->', msg);
this.ws.send(msg);
}
async sendMainMode(mode) { return this.sendJSON({ main_mode: mode }); }
async sendPilotMode(val) { return this.sendJSON({ pilot_mode: val ? 1 : 0 }); }
async sendFanControl(level) { return this.sendJSON({ fan_control: this._clampLevel(level) }); }
async sendSplitFlow(val) { return this.sendJSON({ split_flow: val ? 1 : 0 }); }
async sendAux(val) { return this.sendJSON({ auxiliary_out: val ? 1 : 0 }); }
async sendFlameControl(level) { return this.sendJSON({ flame_control: this._clampLevel(level) }); }
async sendTemperatureSet(value) { return this.sendJSON({ temperature_set: value }); }
async sendLampControl(level) { return this.sendJSON({ lamp_control: this._clampLevel(level) }); }
/* ---------------- HB lifecycle ---------------- */
identify(cb) { cb(); }
getServices() { return this.services; }
shutdown() {
if (this.pingInterval) clearInterval(this.pingInterval);
if (this.ws) { try { this.ws.close(); } catch (e) {} this.ws = null; }
}
/* Placeholder to keep method reference if classic smart mode existed earlier */
_addSmartMode() { /* intentionally omitted in refactor */ }
}