diff --git a/controller/Equipment.ts b/controller/Equipment.ts index 76e1837b..95fbfc10 100644 --- a/controller/Equipment.ts +++ b/controller/Equipment.ts @@ -2292,6 +2292,7 @@ export class ChemController extends EqItem implements IChemController { if (typeof this.data.borates === 'undefined') this.data.borates = 0; if (typeof this.data.siCalcType === 'undefined') this.data.siCalcType = 0; if (typeof this.data.intellichemStandalone === 'undefined') this.data.intellichemStandalone = false; + if (typeof this.data.singleMixPeriod === 'undefined') this.data.singleMixPeriod = false; super.initData(); } public dataName = 'chemControllerConfig'; @@ -2327,6 +2328,8 @@ export class ChemController extends EqItem implements IChemController { public get lsiRange(): AlarmSetting { return new AlarmSetting(this.data, 'lsiRange', this); } public get firmware(): string { return this.data.firmware; } public set firmware(val: string) { this.setDataVal('firmware', val); } + public get singleMixPeriod(): boolean { return this.data.singleMixPeriod; } + public set singleMixPeriod(val: boolean) { this.setDataVal('singleMixPeriod', val); } public getExtended() { let chem = this.get(true); chem.type = sys.board.valueMaps.chemControllerTypes.transform(this.type); @@ -2364,6 +2367,7 @@ export class ChemDoser extends EqItem implements IChemical { if (typeof this.mixingTime === 'undefined') this.data.mixingTime = 3600; if (typeof this.data.setpoint === 'undefined') this.data.setpoint = 100; if (typeof this.data.type === 'undefined') this.data.type = 0; + if (typeof this.data.singleMixPeriod === 'undefined') this.data.singleMixPeriod = false; super.initData(); } public get id(): number { return this.data.id; } @@ -2402,6 +2406,8 @@ export class ChemDoser extends EqItem implements IChemical { public get flowSensor(): ChemFlowSensor { return new ChemFlowSensor(this.data, 'flowSensor', this); } public get flowOnlyMixing(): boolean { return utils.makeBool(this.data.flowOnlyMixing); } public set flowOnlyMixing(val: boolean) { this.setDataVal('flowOnlyMixing', val); } + public get singleMixPeriod(): boolean { return this.data.singleMixPeriod; } + public set singleMixPeriod(val: boolean) { this.setDataVal('singleMixPeriod', val); } public get pump(): ChemicalPump { return new ChemicalPump(this.data, 'pump', this); } public get tank(): ChemicalTank { return new ChemicalTank(this.data, 'tank', this); } public getExtended() { diff --git a/controller/State.ts b/controller/State.ts index 39b79cbb..e06d1079 100644 --- a/controller/State.ts +++ b/controller/State.ts @@ -1106,6 +1106,7 @@ export class PumpState extends EqState { c.units = sys.board.valueMaps.pumpUnits.transformByName('gpm'); break; case 'hwvs': + case 'hwsp': case 'vssvrs': case 'vs': case 'regalmodbus': diff --git a/controller/boards/EasyTouchBoard.ts b/controller/boards/EasyTouchBoard.ts index b6444f3a..ea0d5586 100644 --- a/controller/boards/EasyTouchBoard.ts +++ b/controller/boards/EasyTouchBoard.ts @@ -180,7 +180,8 @@ export class EasyTouchBoard extends SystemBoard { [257, { name: 'ss', desc: 'Single Speed', maxCircuits: 0, hasAddress: false, hasBody: true, equipmentMaster: 1, maxRelays: 1, relays: [{ id: 1, name: 'Pump On/Off' }] }], [256, { name: 'sf', desc: 'SuperFlo VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 4, relays: [{ id: 1, name: 'Program #1' }, { id: 2, name: 'Program #2' }, { id: 3, name: 'Program #3' }, { id: 4, name: 'Program #4' }] }], [258, { name: 'hwrly', desc: 'Hayward Relay VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 8, relays: [{ id: 1, name: 'Step #1' }, { id: 2, name: 'Step #2' }, { id: 3, name: 'Step #3' }, { id: 4, name: 'Pump On' }] }], - [259, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, equipmentMaster: 1 }] + [259, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, equipmentMaster: 1 }], + [260, { name: 'hwsp', desc: 'Hayward Super Pump VS 700', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, equipmentMaster: 1 }] ]); this.valueMaps.heaterTypes = new byteValueMap([ [0, { name: 'none', desc: 'No Heater', hasAddress: false }], diff --git a/controller/boards/IntelliCenterBoard.ts b/controller/boards/IntelliCenterBoard.ts index 40801c74..ab3c3057 100644 --- a/controller/boards/IntelliCenterBoard.ts +++ b/controller/boards/IntelliCenterBoard.ts @@ -84,7 +84,8 @@ export class IntelliCenterBoard extends SystemBoard { [5, { name: 'vf', desc: 'Intelliflo VF', maxPrimingTime: 6, minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true }], [100, { name: 'sf', desc: 'SuperFlo VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 4, relays: [{ id: 1, name: 'Program #1' }, { id: 2, name: 'Program #2' }, { id: 3, name: 'Program #3' }, { id: 4, name: 'Program #4' }] }], [101, { name: 'hwrly', desc: 'Hayward Relay VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 8, relays: [{ id: 1, name: 'Step #1' }, { id: 2, name: 'Step #2' }, { id: 3, name: 'Step #3' }, { id: 4, name: 'Pump On' }] }], - [102, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, equipmentMaster: 1 }] + [102, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, equipmentMaster: 1 }], + [103, { name: 'hwsp', desc: 'Hayward Super Pump VS 700', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, equipmentMaster: 1 }] ]); // RSG - same as systemBoard definition; can delete. this.valueMaps.heatModes = new byteValueMap([ diff --git a/controller/boards/NixieBoard.ts b/controller/boards/NixieBoard.ts index 387b1ded..28da57ad 100644 --- a/controller/boards/NixieBoard.ts +++ b/controller/boards/NixieBoard.ts @@ -92,6 +92,7 @@ export class NixieBoard extends SystemBoard { [5, { name: 'vf', desc: 'Intelliflo VF', minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [6, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [7, { name: 'hwrly', desc: 'Hayward Relay VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, maxSpeeds: 8, relays: [{ id: 1, name: 'Step #1' }, { id: 2, name: 'Step #2'}, { id: 3, name: 'Step #3' }, { id: 4, name: 'Pump On' }], addresses: [] }], + [8, { name: 'hwsp', desc: 'Hayward Super Pump VS 700', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [100, { name: 'sf', desc: 'SuperFlo VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 4, relays: [{ id: 1, name: 'Program #1' }, { id: 2, name: 'Program #2' }, { id: 3, name: 'Program #3' }, { id: 4, name: 'Program #4' }], addresses: [] }], [200, { name: 'regalmodbus', desc: 'Regal Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsRegalModbusPump}], [201, { name: 'neptunemodbus', desc: 'Neptune Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsNeptuneModbusPump }], diff --git a/controller/boards/SystemBoard.ts b/controller/boards/SystemBoard.ts index 8a1516ac..f6037df3 100644 --- a/controller/boards/SystemBoard.ts +++ b/controller/boards/SystemBoard.ts @@ -29,6 +29,9 @@ import { setTimeout as setTimeoutSync } from 'timers'; export class byteValueMap extends Map { + constructor(entries?: [number, any][]) { + super(entries); + } public transform(byte: number, ext?: number) { return extend(true, { val: byte || 0 }, this.get(byte) || this.get(0)); } public toArray(): any[] { let arrKeys = Array.from(this.keys()); diff --git a/controller/comms/messages/Messages.ts b/controller/comms/messages/Messages.ts index 64d45e09..66f31456 100755 --- a/controller/comms/messages/Messages.ts +++ b/controller/comms/messages/Messages.ts @@ -339,6 +339,9 @@ export class Message { } } export class Inbound extends Message { + private static readonly MAX_REWINDS_PER_MESSAGE = 250; + private static readonly REWIND_LOG_EVERY = 25; + private static readonly REWIND_LOG_PREVIEW_BYTES = 32; // /usr/bin/socat TCP-LISTEN:9801,fork,reuseaddr FILE:/dev/ttyUSB0,b9600,raw // /usr/bin/socat TCP-LISTEN:9801,fork,reuseaddr FILE:/dev/ttyUSB0,b9600,cs8,cstopb=1,parenb=0,raw // /usr/bin / socat TCP - LISTEN: 9801,fork,reuseaddr FILE:/dev/ttyUSB0, b9600, cs8, cstopb = 1, parenb = 0, raw @@ -358,6 +361,12 @@ export class Inbound extends Message { public isProcessed: boolean = false; public collisions: number = 0; public rewinds: number = 0; + private logRewindCollision(buff: number[], ndx: number, inLen: number) { + if (this.collisions === 1 || this.collisions % Inbound.REWIND_LOG_EVERY === 0) { + const preview = buff.slice(0, Inbound.REWIND_LOG_PREVIEW_BYTES); + logger.warn(`rewinding message collision count=${this.collisions} rewinds=${this.rewinds} ndx=${ndx} inLen=${inLen} buffLen=${buff.length} preview=${JSON.stringify(preview)}${buff.length > preview.length ? '...truncated' : ''}`); + } + } // Private methods private isValidChecksum(): boolean { switch (this.protocol) { @@ -543,7 +552,13 @@ export class Inbound extends Message { this.collisions++; this.rewinds++; - logger.info(`rewinding message collision ${this.collisions} ${ndx} ${bytes.length} ${JSON.stringify(buff)}`); + if (this.rewinds > Inbound.MAX_REWINDS_PER_MESSAGE) { + logger.warn(`rewind limit exceeded for inbound message: rewinds=${this.rewinds} collisions=${this.collisions} inLen=${bytes.length}. Dropping current packet to protect heap.`); + this._complete = true; + this.isValid = false; + return bytes.length; + } + this.logRewindCollision(buff, ndx, bytes.length); this.readPacket(buff); return ndx; //return this.padding.length + this.preamble.length; diff --git a/controller/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index 4a6f836a..b305898f 100644 --- a/controller/nixie/chemistry/ChemController.ts +++ b/controller/nixie/chemistry/ChemController.ts @@ -586,6 +586,7 @@ export class NixieChemController extends NixieChemControllerBase { if (typeof data.lsiRange.low === 'number') chem.lsiRange.low = data.lsiRange.low; if (typeof data.lsiRange.high === 'number') chem.lsiRange.high = data.lsiRange.high; } + if (typeof data.singleMixPeriod !== 'undefined') chem.singleMixPeriod = utils.makeBool(data.singleMixPeriod); if (typeof data.siCalcType !== 'undefined') schem.siCalcType = chem.siCalcType = data.siCalcType; await this.flowSensor.setSensorAsync(data.flowSensor); // Alright we are down to the equipment items all validation should have been completed by now. @@ -1794,6 +1795,11 @@ export class NixieChemicalPh extends NixieChemical { else if (sph.dailyLimitReached) { await this.cancelDosing(sph, 'daily limit'); } + else if (this.chemController.chem.singleMixPeriod && sph.chemController.orp.dosingStatus === 1) { + // Don't dose pH if ORP is mixing - enforce single mixing period (only when enabled) + await this.cancelDosing(sph, 'orp mixing'); + return; + } else if (status === 'monitoring' || status === 'dosing') { // Figure out what mode we are in and what mode we should be in. //sph.level = 7.61; @@ -2288,6 +2294,11 @@ export class NixieChemicalORP extends NixieChemical { await this.cancelDosing(sorp, 'ph pump dosing + dose priority'); return; } + else if (chem.singleMixPeriod && sorp.chemController.ph.dosingStatus === 1) { + // Don't dose ORP if pH is mixing - enforce single mixing period (only when enabled) + await this.cancelDosing(sorp, 'ph mixing'); + return; + } else if (status === 'monitoring' || status === 'dosing') { // let _doseCalculatedSec = 0; if (!sorp.lockout) { diff --git a/controller/nixie/chemistry/ChemDoser.ts b/controller/nixie/chemistry/ChemDoser.ts index ef46904c..f928008f 100644 --- a/controller/nixie/chemistry/ChemDoser.ts +++ b/controller/nixie/chemistry/ChemDoser.ts @@ -429,6 +429,7 @@ export class NixieChemDoser extends NixieChemDoserBase implements INixieChemical (typeof data.mixingTimeSeconds !== 'undefined' ? parseInt(data.mixingTimeSeconds, 10) : 0); } chem.mixingTime = typeof data.mixingTime !== 'undefined' ? parseInt(data.mixingTime, 10) : chem.mixingTime; + if (typeof data.singleMixPeriod !== 'undefined') chem.singleMixPeriod = utils.makeBool(data.singleMixPeriod); await this.flowSensor.setSensorAsync(data.flowSensor); await this.tank.setTankAsync(schem.tank, data.tank); await this.pump.setPumpAsync(schem.pump, data.pump); @@ -586,6 +587,22 @@ export class NixieChemDoser extends NixieChemDoserBase implements INixieChemical await this.cancelDosing(sd, 'daily limit'); } else if (status === 'monitoring' || status === 'dosing') { + // Check if any other chem doser is currently mixing - only if singleMixPeriod is enabled + if (this.chem.singleMixPeriod) { + let otherDoserMixing = false; + for (let i = 0; i < state.chemDosers.length; i++) { + let otherDoser = state.chemDosers.getItemByIndex(i); + if (otherDoser.id !== sd.id && otherDoser.dosingStatus === 1) { // 1 is mixing + logger.info(`Cannot dose ${sd.chemType} - ${otherDoser.chemType} doser is currently mixing`); + otherDoserMixing = true; + break; + } + } + if (otherDoserMixing) { + await this.cancelDosing(sd, 'another doser mixing'); + return; + } + } // Figure out what mode we are in and what mode we should be in. //sph.level = 7.61; // Check the setpoint and the current level to see if we need to dose. diff --git a/controller/nixie/pumps/Pump.ts b/controller/nixie/pumps/Pump.ts index 5b283dbf..ccafcb04 100644 --- a/controller/nixie/pumps/Pump.ts +++ b/controller/nixie/pumps/Pump.ts @@ -135,6 +135,7 @@ export class NixiePumpCollection extends NixieEquipmentCollection { case 'vs': return new NixiePumpVS(this.controlPanel, pump); case 'hwvs': + case 'hwsp': return new NixiePumpHWVS(this.controlPanel, pump); case 'hwrly': return new NixiePumpHWRLY(this.controlPanel, pump); @@ -895,6 +896,10 @@ export class NixiePumpVSF extends NixiePumpRS485 { }; }; export class NixiePumpHWVS extends NixiePumpRS485 { + private _consecutiveCommFailures: number = 0; + private _lastSuccessfulComm: Date = new Date(); + private _commFailureThreshold: number = 5; // Number of failures before exponential backoff + public setTargetSpeed(pState: PumpState) { let _newSpeed = 0; if (!pState.pumpOnDelay) { @@ -931,7 +936,35 @@ export class NixiePumpHWVS extends NixiePumpRS485 { } finally { this.suspendPolling = false; } }; - protected async requestPumpStatusAsync() { return Promise.resolve(); }; + protected async requestPumpStatusAsync() { + // Actively poll Hayward pump for current status to maintain sync + if (conn.isPortEnabled(this.pump.portId || 0)) { + let out = Outbound.create({ + portId: this.pump.portId || 0, + protocol: Protocol.Hayward, + source: 1, + dest: this.pump.address - 96, + action: 12, + payload: [Math.min(Math.round((this._targetSpeed / sys.board.valueMaps.pumpTypes.get(this.pump.type).maxSpeed) * 100), 100)], + retries: 3, + timeout: 2500, + response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) + }); + try { + await out.sendAsync(); + // Communication successful - reset failure counter + this._consecutiveCommFailures = 0; + this._lastSuccessfulComm = new Date(); + let pstate = state.pumps.getItemById(this.pump.id); + pstate.status = 0; // OK status + } + catch (err) { + this._consecutiveCommFailures++; + logger.warn(`Hayward pump ${this.pump.name} status request failed (${this._consecutiveCommFailures} consecutive failures): ${err.message}`); + this.updateCommStatus(); + } + } + }; protected setPumpFeatureAsync(feature?: number) { return Promise.resolve(); } protected async setPumpToRemoteControlAsync(running: boolean = true) { try { @@ -945,20 +978,23 @@ export class NixiePumpHWVS extends NixiePumpRS485 { dest: this.pump.address, action: 1, payload: [0], // when stopAsync is called, pass false to return control to pump panel - // payload: spump.virtualControllerStatus === sys.board.valueMaps.virtualControllerStatus.getValue('running') ? [255] : [0], - retries: 1, + retries: 3, + timeout: 2500, response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) }); try { await out.sendAsync(); + this._consecutiveCommFailures = 0; + this._lastSuccessfulComm = new Date(); } catch (err) { - logger.error(`Error sending setPumpToRemoteControl for ${this.pump.name}: ${err.message}`); - + this._consecutiveCommFailures++; + logger.error(`Error sending setPumpToRemoteControl for ${this.pump.name} (${this._consecutiveCommFailures} failures): ${err.message}`); + this.updateCommStatus(); } } } - } catch(err) { `Error sending setPumpToRemoteControl message for ${this.pump.name}: ${err.message}` }; + } catch(err) { logger.error(`Error sending setPumpToRemoteControl message for ${this.pump.name}: ${err.message}`); }; } protected async setPumpRPMAsync() { // Address 1 @@ -979,29 +1015,77 @@ export class NixiePumpHWVS extends NixiePumpRS485 { source: 1, // Use the broadcast address dest: this.pump.address - 96, action: 12, - payload: [Math.min(Math.round((this._targetSpeed / pt.maxSpeed) * 100), 100)], // when stopAsync is called, pass false to return control to pump panel - retries: 1, + payload: [Math.min(Math.round((this._targetSpeed / pt.maxSpeed) * 100), 100)], + retries: 3, + timeout: 2500, response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) }); try { await out.sendAsync(); + // Communication successful - reset failure counter + this._consecutiveCommFailures = 0; + this._lastSuccessfulComm = new Date(); + let pstate = state.pumps.getItemById(this.pump.id); + pstate.status = 0; // OK status } catch (err) { - logger.error(`Error sending setPumpRPM for ${this.pump.name}: ${err.message}`); - let pstate = state.pumps.getItemById(this.pump.id); - pstate.command = 0; - pstate.rpm = 0; - pstate.watts = 0; + this._consecutiveCommFailures++; + logger.error(`Hayward pump ${this.pump.name} speed command failed (${this._consecutiveCommFailures} consecutive failures): ${err.message}`); + // DO NOT clear state - keep showing last known values so user knows pump may still be running + this.updateCommStatus(); } } else { + // Port is disabled - safe to clear state as pump is not accessible let pstate = state.pumps.getItemById(this.pump.id); pstate.command = 0; pstate.rpm = 0; pstate.watts = 0; + pstate.status = 16; // Communication error status } }; + + public async pollEquipmentAsync() { + let self = this; + try { + if (typeof this._pollTimer !== 'undefined' || this._pollTimer) clearTimeout(this._pollTimer); + this._pollTimer = null; + if (this.suspendPolling || this.closing || this.pump.address > 112) { + if (this.suspendPolling) logger.info(`Pump ${this.id} Polling Suspended`); + if (this.closing) logger.info(`Pump ${this.id} is closing`); + return; + } + let pstate = state.pumps.getItemById(this.pump.id); + this.setTargetSpeed(pstate); + await this.setPumpStateAsync(pstate); + // Additionally poll for status to verify pump state + await this.requestPumpStatusAsync(); + } + catch (err) { logger.error(`Nixie Error running Hayward pump sequence - ${err}`); } + finally { + if (!self.closing) { + // Exponential backoff if communication is failing + let pollInterval = self.pollingInterval || 2000; + if (this._consecutiveCommFailures >= this._commFailureThreshold) { + // Exponential backoff: 2s -> 4s -> 8s -> 16s (max 30s) + pollInterval = Math.min(pollInterval * Math.pow(2, this._consecutiveCommFailures - this._commFailureThreshold), 30000); + logger.info(`Hayward pump ${this.pump.name} polling backed off to ${pollInterval}ms due to failures`); + } + this._pollTimer = setTimeoutSync(async () => await self.pollEquipmentAsync(), pollInterval); + } + } + } + + private updateCommStatus() { + let pstate = state.pumps.getItemById(this.pump.id); + if (this._consecutiveCommFailures >= this._commFailureThreshold) { + pstate.status = 16; // Communication error + logger.warn(`Hayward pump ${this.pump.name} has ${this._consecutiveCommFailures} consecutive communication failures. Last successful: ${this._lastSuccessfulComm.toISOString()}`); + } else if (this._consecutiveCommFailures > 0) { + pstate.status = 1; // Warning - intermittent issues + } + } } export class NixiePumpRegalModbus extends NixiePump { diff --git a/package-lock.json b/package-lock.json index 2ab6cb16..c3d32dad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "nodejs-poolcontroller", - "version": "9.0.0", + "version": "8.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "nodejs-poolcontroller", - "version": "9.0.0", - "license": "GNU Affero General Public License v3.0", + "version": "8.4.0", + "license": "AGPL-3.0-only", "dependencies": { "@influxdata/influxdb-client": "^1.35.0", "eslint-config-promise": "^2.0.2",