UNPKG

homebridge-proflame

Version:

Homebridge plugin for Mendota / Proflame Connect fireplaces via WebSocket

466 lines (400 loc) 20.2 kB
'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 */ } }