Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b73b8f5
Improve Hayward HWVS pump reliability with comm failure tracking and …
camaro4life18 Apr 22, 2026
24dc8c8
Add configurable single mixing period option for chemistry controllers
camaro4life18 Apr 22, 2026
ee10988
Mitigate RS485 rewind memory pressure
camaro4life18 May 3, 2026
47cbb22
Fix no-op catch in Hayward remote control handler
camaro4life18 May 4, 2026
14bed83
feat(chem): add ORP formula-based chlorine demand calculation
camaro4life18 May 20, 2026
07a68d4
feat: add automatic 24-hour VS pump scheduler
camaro4life18 May 20, 2026
fd0d260
Merge remote-tracking branch 'upstream/master'
camaro4life18 May 20, 2026
4ae352c
Merge branch 'feature/orp-chemistry-demand'
camaro4life18 May 20, 2026
8cdef93
Merge branch 'feature/auto-pump-scheduler'
camaro4life18 May 20, 2026
af4b7c2
feat(chem): expose chlorineTypes in /config/options/chemControllers API
camaro4life18 May 20, 2026
f5952b7
Merge branch 'feature/orp-chemistry-demand'
camaro4life18 May 20, 2026
c988dd2
feat(pumps): add hwsp (Hayward SuperFlo VS) pump type support
camaro4life18 May 20, 2026
561b984
Rename hwsp pump to Hayward Super Pump VS
camaro4life18 May 20, 2026
746b668
Merge branch 'fix/hwsp-name-rename'
camaro4life18 May 20, 2026
bd3a420
fix: add try/catch to pumpScheduler route handlers
camaro4life18 May 21, 2026
b6643fc
Refactor pump scheduler: 3-input SimplePoolConfig, 2-block schedule, …
camaro4life18 May 21, 2026
f9a41bc
Fix: remove leftover saltCellWarning reference from _logPlan
camaro4life18 May 21, 2026
81976bd
Refactor: dynamic 3-block schedule (high/medium/low), remove hasSaltC…
camaro4life18 May 21, 2026
df5e7d1
Fix: always run _ensurePumpCircuitsAsync on save, not only when enabled
camaro4life18 May 21, 2026
5f371a6
Fix: auto-assign feature IDs, add heatSource to schedule, return pump…
camaro4life18 May 21, 2026
1ee2d73
Fix: use sys.pumps.get() to get plain array before filter/map
camaro4life18 May 21, 2026
01ba27c
Fix: use board-specific heatSource off value for schedule writes
camaro4life18 May 21, 2026
06dd4af
fix(chem): orpFormula demand used as dose instead of always using max…
camaro4life18 May 21, 2026
d605500
fix(chem): singleMixPeriod now blocks dosing while other chem is dosi…
camaro4life18 May 21, 2026
33704d7
Fix: calibrate refWatts for 1.5in pipe from actual 291W@2070RPM obser…
camaro4life18 May 22, 2026
387da74
Remove estimatedWatts from scheduler (unreliable affinity-law estimate)
camaro4life18 May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions controller/Equipment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -2522,6 +2528,8 @@ export class ChemicalORP extends Chemical {
if (typeof this.data.tolerance === 'undefined') this.data.tolerance = { low: 650, high: 800, enabled: true };
if (typeof this.data.phLockout === 'undefined') this.data.phLockout = 7.8;
if (typeof this.data.doserType === 'undefined') this.data.doserType = 0;
if (typeof this.data.chlorineType === 'undefined') this.data.chlorineType = 0;
if (typeof this.data.orpFormula === 'undefined') this.data.orpFormula = false;
super.initData();
}
public get useChlorinator(): boolean { return utils.makeBool(this.data.useChlorinator); }
Expand All @@ -2541,12 +2549,18 @@ export class ChemicalORP extends Chemical {
public set chlorDosingMethod(val: number | any) { this.setDataVal('chlorDosingMethod', sys.board.valueMaps.chemChlorDosingMethods.encode(val)); }
public get doserType(): number | any { return this.data.doserType; }
public set doserType(val: number | any) { this.setDataVal('doserType', sys.board.valueMaps.orpDoserTypes.encode(val)); }
public get chlorineType(): number | any { return this.data.chlorineType; }
public set chlorineType(val: number | any) { this.setDataVal('chlorineType', sys.board.valueMaps.chlorineTypes.encode(val)); }
public get orpFormula(): boolean { return utils.makeBool(this.data.orpFormula); }
public set orpFormula(val: boolean) { this.setDataVal('orpFormula', val); }

public getExtended() {
let chem = super.getExtended();
chem.probe = this.probe.getExtended();
chem.tank = this.tank.getExtended();
chem.doserType = sys.board.valueMaps.orpDoserTypes.transform(this.doserType);
chem.chlorineType = sys.board.valueMaps.chlorineTypes.transform(this.chlorineType);
chem.orpFormula = this.orpFormula;
return chem;
}
}
Expand Down
34 changes: 34 additions & 0 deletions controller/State.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -3200,6 +3201,39 @@ export class ChemicalORPState extends ChemicalState {
let cc = this.chemController;
return cc.alarms.comms !== 0 || cc.alarms.orpProbeFault !== 0 || cc.alarms.orpPumpFault !== 0 || cc.alarms.bodyFault !== 0;
}
public calcDemand(chem?: ChemController): number {
chem = typeof chem === 'undefined' ? sys.chemControllers.getItemById(this.chemController.id) : chem;
if (!chem.orp.orpFormula || this.level >= this.setpoint) return 0;

let totalGallons = 0;
if (chem.body === 32 && sys.equipment.shared) {
if (state.temps.bodies.getItemById(2).isOn === true) totalGallons = sys.bodies.getItemById(2).capacity;
else totalGallons = sys.bodies.getItemById(1).capacity + sys.bodies.getItemById(2).capacity;
}
else {
totalGallons = sys.bodies.getItemById(chem.body + 1).capacity;
}

// Use live pH reading; fall back to setpoint then a safe default
let pH = this.chemController.ph.level || chem.ph.setpoint || 7.4;

// Wojtowicz 1994 empirical formula: FC_ppm = 10^((ORP - 683 + 59.2*(pH-7.0)) / 48.9)
let fcCurrent = Math.pow(10, (this.level - 683 + 59.2 * (pH - 7.0)) / 48.9);
let fcTarget = Math.pow(10, (this.setpoint - 683 + 59.2 * (pH - 7.0)) / 48.9);
let deltaFC = fcTarget - fcCurrent;
if (deltaFC <= 0) return 0;

// Warn if CYA is outside the accurate range for this formula
if (typeof chem.cyanuricAcid !== 'undefined' && chem.cyanuricAcid > 0 &&
(chem.cyanuricAcid < 25 || chem.cyanuricAcid > 50)) {
logger.warn(`Chem ORP formula: CYA (${chem.cyanuricAcid} ppm) outside recommended 25-50 ppm range; dosing accuracy reduced.`);
}

let ct = sys.board.valueMaps.chlorineTypes.transform(chem.orp.chlorineType);
let dose = Math.round(deltaFC * totalGallons * 3.785411784 * ct.dosingFactor);
logger.verbose(`Chem ORP demand: level=${this.level}mV setpoint=${this.setpoint}mV pH=${pH} deltaFC=${deltaFC.toFixed(3)}ppm dose=${dose}mL`);
return dose;
}
public getExtended() {
let chem = super.getExtended();
chem.probe = this.probe.getExtended();
Expand Down
1 change: 1 addition & 0 deletions controller/boards/NixieBoard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', 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 }],
Expand Down
5 changes: 5 additions & 0 deletions controller/boards/SystemBoard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,11 @@ export class byteValueMaps {
[4, { name: 'a15.7', desc: '15.7% - 10 Baume', dosingFactor: 2.0 }],
[5, { name: 'a14.5', desc: '14.5% - 9.8 Baume', dosingFactor: 2.16897 }],
]);
public chlorineTypes: byteValueMap = new byteValueMap([
[0, { name: 'sodium10', desc: '10% Sodium Hypochlorite', dosingFactor: 0.00974 }],
[1, { name: 'sodium12_5', desc: '12.5% Sodium Hypochlorite', dosingFactor: 0.00763 }],
[2, { name: 'sodium6', desc: '6% Sodium Hypochlorite (Household Bleach)', dosingFactor: 0.01672 }],
]);
public filterTypes: byteValueMap = new byteValueMap([
[0, { name: 'sand', desc: 'Sand', hasBackwash: true }],
[1, { name: 'cartridge', desc: 'Cartridge', hasBackwash: false }],
Expand Down
17 changes: 16 additions & 1 deletion controller/comms/messages/Messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,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
Expand All @@ -361,6 +364,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) {
Expand Down Expand Up @@ -561,7 +570,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;
Expand Down
5 changes: 2 additions & 3 deletions controller/comms/messages/status/PumpStateMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,13 @@ export class PumpStateMessage {
// src act dest
//[0x10, 0x02, 0x00, 0x0C, 0x00][0x00, 0x62, 0x17, 0x81][0x01, 0x18, 0x10, 0x03]
//[0x10, 0x02, 0x00, 0x0C, 0x00][0x00, 0x2D, 0x02, 0x36][0x00, 0x83, 0x10, 0x03] -- Response from pump
let ptype = sys.board.valueMaps.pumpTypes.transformByName('hwvs');
let address = msg.source + 96;
//console.log({ src: msg.source, dest: msg.dest, action: msg.action, address: address });

let pump = sys.pumps.find(elem => elem.address === address && elem.type === 6);
let pump = sys.pumps.find(elem => elem.address === address && (elem.type === 6 || elem.type === 8));
if (typeof pump !== 'undefined') {
let ptype = sys.board.valueMaps.pumpTypes.transform(pump.type);
let pstate = state.pumps.getItemById(pump.id, true);
// 3450 * .5
pstate.rpm = Math.round(ptype.maxSpeed * (msg.extractPayloadByte(1) / 100));
// This is really goofy as the watts are actually the hex string from the two bytes.
pstate.watts = parseInt(msg.extractPayloadByte(2).toString(16) + msg.extractPayloadByte(3).toString(16), 10);
Expand Down
3 changes: 3 additions & 0 deletions controller/nixie/Nixie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { NixieFilterCollection } from './bodies/Filter';
import { NixieChlorinatorCollection } from './chemistry/Chlorinator';
import { NixiePump, NixiePumpCollection } from './pumps/Pump';
import { NixieScheduleCollection } from './schedules/Schedule';
import { pumpScheduler } from '../services/PumpSchedulerService';

/************************************************************************
* Nixie: Nixie is a control panel that controls devices as a master. It
Expand Down Expand Up @@ -92,6 +93,7 @@ export class NixieControlPanel implements INixieControlPanel {
await this.chemDosers.initAsync(equipment.chemDosers);
await this.pumps.initAsync(equipment.pumps);
await this.schedules.initAsync(equipment.schedules);
await pumpScheduler.initAsync();
logger.info(`Nixie Controller Initialized`)
}
catch (err) { return Promise.reject(err); }
Expand Down Expand Up @@ -133,6 +135,7 @@ export class NixieControlPanel implements INixieControlPanel {
await this.chlorinators.closeAsync();
await this.heaters.closeAsync();
await this.circuits.closeAsync();
await pumpScheduler.closeAsync();
await this.pumps.closeAsync();
await this.filters.closeAsync();
await this.bodies.closeAsync();
Expand Down
27 changes: 21 additions & 6 deletions controller/nixie/chemistry/ChemController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 !== 2) {
// Don't dose pH if ORP is dosing or mixing - enforce single dose/mix period
await this.cancelDosing(sph, sph.chemController.orp.dosingStatus === 0 ? 'orp dosing' : '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;
Expand Down Expand Up @@ -2066,6 +2072,8 @@ export class NixieChemicalORP extends NixieChemical {
if (typeof data.tolerance.low === 'number') this.orp.tolerance.low = data.tolerance.low;
if (typeof data.tolerance.high === 'number') this.orp.tolerance.high = data.tolerance.high;
}
if (typeof data.orpFormula !== 'undefined') this.orp.orpFormula = utils.makeBool(data.orpFormula);
if (typeof data.chlorineType !== 'undefined') this.orp.chlorineType = data.chlorineType;
}
}
catch (err) { logger.error(`setORPAsync: ${err.message}`); return Promise.reject(err); }
Expand Down Expand Up @@ -2289,6 +2297,11 @@ export class NixieChemicalORP extends NixieChemical {
await this.cancelDosing(sorp, 'ph pump dosing + dose priority');
return;
}
else if (chem.singleMixPeriod && sorp.chemController.ph.dosingStatus !== 2) {
// Don't dose ORP if pH is dosing or mixing - enforce single dose/mix period
await this.cancelDosing(sorp, sorp.chemController.ph.dosingStatus === 0 ? 'ph dosing' : 'ph mixing');
return;
}
else if (status === 'monitoring' || status === 'dosing') {
// let _doseCalculatedSec = 0;
if (!sorp.lockout) {
Expand Down Expand Up @@ -2433,17 +2446,20 @@ export class NixieChemicalORP extends NixieChemical {
else if (this.orp.setpoint > sorp.level) {
let pump = this.pump.pump;
// Calculate how many mL are required to raise to our ORP level.
let demand = Math.round(utils.convert.volume.convertUnits(0, 'oz', 'mL'));
let demand = sorp.calcDemand(chem);
let time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(demand / (pump.ratedFlow / 60));
let meth = sys.board.valueMaps.chemDosingMethods.getName(this.orp.dosingMethod);
// Now that we know our chlorine demand we need to adjust this dose based upon the limits provided in the setup.
// When orpFormula is enabled, demand is the formula result and limits act as caps; otherwise limits are the dose.
switch (meth) {
case 'time':
time = this.orp.maxDosingTime;
demand = typeof pump.ratedFlow === 'undefined' ? 0 : Math.round(time * (this.pump.pump.ratedFlow / 60));
if (demand <= 0 || time > this.orp.maxDosingTime) {
time = this.orp.maxDosingTime;
demand = typeof pump.ratedFlow === 'undefined' ? 0 : Math.round(time * (this.pump.pump.ratedFlow / 60));
}
break;
case 'volume':
demand = this.orp.maxDosingVolume;
if (demand <= 0 || demand > this.orp.maxDosingVolume) demand = this.orp.maxDosingVolume;
time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(demand / (pump.ratedFlow / 60));
break;
case 'volumeTime':
Expand All @@ -2462,8 +2478,7 @@ export class NixieChemicalORP extends NixieChemical {
}
logger.info(`Chem orp dose calculated ${demand}mL for ${utils.formatDuration(time)} Tank Level: ${sorp.tank.level} using ${meth}`);

sorp.demand = sorp.calcDemand(chem);
if (sorp.demand > 0) logger.info(`Chem orp dose calculated ${demand}mL for ${utils.formatDuration(time)} Tank Level: ${sorp.tank.level} using ${meth}`);
sorp.demand = demand;

if (typeof sorp.currentDose === 'undefined') {
// We will include this with the dose demand because our limits may reduce it.
Expand Down
Loading