nodejs-poolcontroller 8.4.0 → 8.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.github/workflows/ghcr-publish.yml +1 -1
  2. package/157_issues.md +101 -0
  3. package/AGENTS.md +17 -1
  4. package/README.md +13 -2
  5. package/controller/Equipment.ts +49 -0
  6. package/controller/State.ts +8 -0
  7. package/controller/boards/AquaLinkBoard.ts +174 -2
  8. package/controller/boards/EasyTouchBoard.ts +44 -0
  9. package/controller/boards/IntelliCenterBoard.ts +360 -172
  10. package/controller/boards/NixieBoard.ts +7 -4
  11. package/controller/boards/SunTouchBoard.ts +1 -0
  12. package/controller/boards/SystemBoard.ts +39 -4
  13. package/controller/comms/Comms.ts +9 -3
  14. package/controller/comms/messages/Messages.ts +218 -24
  15. package/controller/comms/messages/config/EquipmentMessage.ts +34 -0
  16. package/controller/comms/messages/config/ExternalMessage.ts +1051 -989
  17. package/controller/comms/messages/config/GeneralMessage.ts +65 -0
  18. package/controller/comms/messages/config/OptionsMessage.ts +15 -2
  19. package/controller/comms/messages/config/PumpMessage.ts +427 -421
  20. package/controller/comms/messages/config/SecurityMessage.ts +37 -13
  21. package/controller/comms/messages/status/EquipmentStateMessage.ts +0 -218
  22. package/controller/comms/messages/status/HeaterStateMessage.ts +27 -15
  23. package/controller/comms/messages/status/NeptuneModbusStateMessage.ts +217 -0
  24. package/controller/comms/messages/status/VersionMessage.ts +67 -18
  25. package/controller/nixie/chemistry/ChemController.ts +65 -33
  26. package/controller/nixie/heaters/Heater.ts +10 -1
  27. package/controller/nixie/pumps/Pump.ts +145 -2
  28. package/docker-compose.yml +1 -0
  29. package/logger/Logger.ts +75 -64
  30. package/package.json +1 -1
  31. package/tsconfig.json +2 -1
  32. package/web/Server.ts +3 -1
  33. package/web/services/config/Config.ts +150 -1
  34. package/web/services/state/State.ts +21 -0
  35. package/web/services/state/StateSocket.ts +28 -0
@@ -33,6 +33,9 @@ const addrsPentairPump = Object.freeze([96, 97, 98, 99, 100, 101, 102, 103, 104,
33
33
  const addrsRegalModbusPump = Object.freeze(
34
34
  Array.from({ length: ((0xF7 - 0x15) / 2) + 1 }, (_, i) => 0x15 + i * 2) // Odd numbers fro 0x15 through 0xF7
35
35
  );
36
+ const addrsNeptuneModbusPump = Object.freeze(
37
+ Array.from({ length: 247 }, (_, i) => i + 1) // Modbus slave IDs 1..247
38
+ );
36
39
 
37
40
 
38
41
  export class NixieBoard extends SystemBoard {
@@ -91,6 +94,7 @@ export class NixieBoard extends SystemBoard {
91
94
  [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: [] }],
92
95
  [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: [] }],
93
96
  [200, { name: 'regalmodbus', desc: 'Regal Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsRegalModbusPump}],
97
+ [201, { name: 'neptunemodbus', desc: 'Neptune Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsNeptuneModbusPump }],
94
98
  ]);
95
99
  // RSG - same as systemBoard definition; can delete.
96
100
  this.valueMaps.heatModes = new byteValueMap([
@@ -249,7 +253,7 @@ export class NixieBoard extends SystemBoard {
249
253
  [64, { name: 'violet', desc: 'Violet', types: ['watercolors'], sequence: 9 }],
250
254
  [65, { name: 'slowcolorsplash', desc: 'Slow Color Splash', types: ['watercolors'], sequence: 10 }],
251
255
  [66, { name: 'fastcolorsplash', desc: 'Fast Color Splash', types: ['watercolors'], sequence: 11 }],
252
- [67, { name: 'americathebeautiful', desc: 'America the Beautiful', types: ['watercolors'], sequence: 12 }],
256
+ [67, { name: 'americathebeautiful', desc: 'America Beautiful', types: ['watercolors'], sequence: 12 }],
253
257
  [68, { name: 'fattuesday', desc: 'Fat Tuesday', types: ['watercolors'], sequence: 13 }],
254
258
  [69, { name: 'discotech', desc: 'Disco Tech', types: ['watercolors'], sequence: 14 }],
255
259
  [255, { name: 'none', desc: 'None' }]
@@ -1111,9 +1115,7 @@ export class NixieCircuitCommands extends CircuitCommands {
1111
1115
  return arr;
1112
1116
  }
1113
1117
  public getCircuitFunctions() {
1114
- let cf = sys.board.valueMaps.circuitFunctions.toArray();
1115
- if (!sys.equipment.shared) cf = cf.filter(x => { return x.name !== 'spillway' && x.name !== 'spadrain' });
1116
- return cf;
1118
+ return super.getCircuitFunctions();
1117
1119
  }
1118
1120
  public getCircuitNames() {
1119
1121
  return [...sys.board.valueMaps.circuitNames.toArray(), ...sys.board.valueMaps.customNames.toArray()];
@@ -1133,6 +1135,7 @@ export class NixieCircuitCommands extends CircuitCommands {
1133
1135
  if (data.name) circuit.name = scircuit.name = data.name;
1134
1136
  else if (!circuit.name && !data.name) circuit.name = scircuit.name = Circuit.getIdName(id);
1135
1137
  if (typeof data.type !== 'undefined' || typeof circuit.type === 'undefined') circuit.type = scircuit.type = parseInt(data.type, 10) || 0;
1138
+ this.assertSinglePoolSpaType(id, circuit.type);
1136
1139
  if (typeof data.freeze !== 'undefined' || typeof circuit.freeze === 'undefined') circuit.freeze = utils.makeBool(data.freeze) || false;
1137
1140
  if (typeof data.showInFeatures !== 'undefined' || typeof data.showInFeatures === 'undefined') circuit.showInFeatures = scircuit.showInFeatures = utils.makeBool(data.showInFeatures);
1138
1141
  if (typeof data.dontStop !== 'undefined' && utils.makeBool(data.dontStop) === true) data.eggTimer = 1440;
@@ -354,6 +354,7 @@ class SunTouchCircuitCommands extends TouchCircuitCommands {
354
354
  return circ;
355
355
  }
356
356
  let typeByte = parseInt(data.type, 10) || circuit.type || sys.board.valueMaps.circuitFunctions.getValue('generic');
357
+ this.assertSinglePoolSpaType(id, typeByte);
357
358
  let nameByte = circuit.nameId; // You cannot change the Name Id in SunTouch.
358
359
  if (typeof data.nameId !== 'undefined') nameByte = data.nameId;
359
360
  let mappedId = id;
@@ -1149,7 +1149,10 @@ export class SystemCommands extends BoardCommands {
1149
1149
  let tzOffsetObj = state.time.calcTZOffset();
1150
1150
  if (sys.general.options.clockSource === 'server' || typeof sys.general.location.timeZone === 'undefined') {
1151
1151
  let tzs = sys.board.valueMaps.timeZones.toArray();
1152
- sys.general.location.timeZone = tzs.find(tz => tz.utcOffset === tzOffsetObj.tzOffset).val;
1152
+ let tzMatch = tzs.find(tz => tz.utcOffset === tzOffsetObj.tzOffset);
1153
+ // Some environments can report offsets that are not present in the map.
1154
+ // Keep the current value instead of throwing when no map entry exists.
1155
+ if (typeof tzMatch !== 'undefined') sys.general.location.timeZone = tzMatch.val;
1153
1156
  }
1154
1157
  if (sys.general.options.clockSource === 'server' || typeof sys.general.options.adjustDST === 'undefined') {
1155
1158
  sys.general.options.adjustDST = tzOffsetObj.adjustDST;
@@ -2627,6 +2630,9 @@ export class CircuitCommands extends BoardCommands {
2627
2630
  circ.level = level;
2628
2631
  return Promise.resolve(circ as ICircuitState);
2629
2632
  }
2633
+ public setLightColorAsync(id: number, color: { red: number; green: number; blue: number }): Promise<ICircuitState> {
2634
+ return Promise.reject(new InvalidOperationError(`Light color control is not supported for circuit ${id}`, 'setLightColorAsync'));
2635
+ }
2630
2636
  public getCircuitReferences(includeCircuits?: boolean, includeFeatures?: boolean, includeVirtual?: boolean, includeGroups?: boolean) {
2631
2637
  let arrRefs = [];
2632
2638
  if (includeCircuits) {
@@ -2679,9 +2685,32 @@ export class CircuitCommands extends BoardCommands {
2679
2685
  public getCircuitFunctions() {
2680
2686
  let cf = sys.board.valueMaps.circuitFunctions.toArray();
2681
2687
  if (!sys.equipment.shared) cf = cf.filter(x => { return x.name !== 'spillway' && x.name !== 'spadrain' });
2688
+ const poolType = sys.board.valueMaps.circuitFunctions.findItem('pool');
2689
+ const spaType = sys.board.valueMaps.circuitFunctions.findItem('spa');
2690
+ if (typeof poolType !== 'undefined') {
2691
+ const hasPool = typeof sys.circuits.find(elem => elem.isActive !== false && elem.type === poolType.val) !== 'undefined';
2692
+ if (hasPool) cf = cf.filter(x => x.name !== 'pool');
2693
+ }
2694
+ if (typeof spaType !== 'undefined') {
2695
+ const hasSpa = typeof sys.circuits.find(elem => elem.isActive !== false && elem.type === spaType.val) !== 'undefined';
2696
+ if (hasSpa) cf = cf.filter(x => x.name !== 'spa');
2697
+ }
2682
2698
  return cf;
2683
2699
  }
2684
2700
  public getCircuitNames() { return [...sys.board.valueMaps.circuitNames.toArray(), ...sys.board.valueMaps.customNames.toArray()]; }
2701
+ protected assertSinglePoolSpaType(id: number, type: number): void {
2702
+ if (isNaN(type)) return;
2703
+ const poolType = sys.board.valueMaps.circuitFunctions.findItem('pool');
2704
+ const spaType = sys.board.valueMaps.circuitFunctions.findItem('spa');
2705
+ let typeName: string;
2706
+ if (typeof poolType !== 'undefined' && type === poolType.val) typeName = 'pool';
2707
+ else if (typeof spaType !== 'undefined' && type === spaType.val) typeName = 'spa';
2708
+ if (typeof typeName === 'undefined') return;
2709
+ const dup = sys.circuits.find(elem => elem.isActive !== false && elem.id !== id && elem.type === type);
2710
+ if (typeof dup !== 'undefined') {
2711
+ throw new InvalidEquipmentDataError(`Only one ${typeName} circuit type is allowed. Circuit ${dup.id}-${dup.name} is already configured as ${typeName}.`, 'Circuit', type);
2712
+ }
2713
+ }
2685
2714
  public async setCircuitAsync(data: any, send: boolean = true): Promise<ICircuit> {
2686
2715
  try {
2687
2716
  let id = parseInt(data.id, 10);
@@ -2716,6 +2745,7 @@ export class CircuitCommands extends BoardCommands {
2716
2745
  }
2717
2746
  if (id === 6) circuit.type = sys.board.valueMaps.circuitFunctions.getValue('pool');
2718
2747
  if (id === 1 && sys.equipment.shared) circuit.type = sys.board.valueMaps.circuitFunctions.getValue('spa');
2748
+ this.assertSinglePoolSpaType(id, circuit.type);
2719
2749
  if (typeof data.freeze !== 'undefined' || typeof circuit.freeze === 'undefined') circuit.freeze = utils.makeBool(data.freeze) || false;
2720
2750
  if (typeof data.showInFeatures !== 'undefined' || typeof data.showInFeatures === 'undefined') circuit.showInFeatures = scircuit.showInFeatures = utils.makeBool(data.showInFeatures);
2721
2751
  if (typeof data.dontStop !== 'undefined' && utils.makeBool(data.dontStop) === true) data.eggTimer = 1440;
@@ -4032,7 +4062,7 @@ export class HeaterCommands extends BoardCommands {
4032
4062
  public updateHeaterServices() {
4033
4063
  let htypes = sys.board.heaters.getInstalledHeaterTypes();
4034
4064
  let solarInstalled = htypes.solar > 0;
4035
- let heatPumpInstalled = htypes.heatpump > 0;
4065
+ let heatPumpInstalled = htypes.heatpump > 0 || htypes.ultratemp > 0;
4036
4066
  let gasHeaterInstalled = htypes.gas > 0;
4037
4067
  if (sys.heaters.length > 0) sys.board.valueMaps.heatSources = new byteValueMap([[0, { name: 'off', desc: 'Off' }]]);
4038
4068
  if (gasHeaterInstalled) sys.board.valueMaps.heatSources.set(3, { name: 'heater', desc: 'Heater' });
@@ -4223,6 +4253,9 @@ export class HeaterCommands extends BoardCommands {
4223
4253
  // so that if we have a heater preference set up then we do not have to evaluate the other heater.
4224
4254
  let heaterTypes = sys.board.valueMaps.heaterTypes;
4225
4255
  bodyHeaters.sort((a, b) => {
4256
+ // Sort master=1 (NCP-controlled) heaters before master=0 (OCP-controlled)
4257
+ // so directly controlled heaters get priority over OCP ghosts.
4258
+ if (a.master !== b.master) return b.master - a.master;
4226
4259
  if (heaterTypes.transform(a.type).hasPreference) return -1;
4227
4260
  else if (heaterTypes.transform(b.type).hasPreference) return 1;
4228
4261
  return 0;
@@ -4300,11 +4333,11 @@ export class HeaterCommands extends BoardCommands {
4300
4333
  // This is the default operation on IntelliCenter and it appears to simply not start on the setpoint. We can do better
4301
4334
  // than this by heating 1 degree past the setpoint then applying this rule for 30 minutes. This allows for a more
4302
4335
  // responsive heater.
4303
- //
4336
+ //
4304
4337
  // For Ultratemp we need to determine whether the differential temp
4305
4338
  // is within range. The other thing that needs to be calculated here is
4306
4339
  // whether Ultratemp can effeciently heat the pool.
4307
- if (mode === 'ultratemp' || mode === 'ultratemppref') {
4340
+ if (mode === 'ultratemp' || mode === 'ultratemppref' || mode === 'heatpump' || mode === 'heatpumppref') {
4308
4341
  if (hstate.isOn) {
4309
4342
  // For the preference mode we will try to reach the setpoint for a period of time then
4310
4343
  // switch over to the gas heater. Our algorithm for this is to check the rate of
@@ -5046,6 +5079,8 @@ export class ChemControllerCommands extends BoardCommands {
5046
5079
  if (t.hasAddress) chem.address = address;
5047
5080
  }
5048
5081
  chem.isActive = true;
5082
+ // IntelliChem standalone polling is only valid when Nixie is the controller.
5083
+ if (t.name === 'intellichem' && sys.controllerType !== ControllerType.Nixie) data.intellichemStandalone = false;
5049
5084
  // So here is the thing. If you have an OCP then the IntelliChem must be controlled by that.
5050
5085
  // the messages on the bus will talk back to the OCP so if you do not do this mayhem will ensue.
5051
5086
  if (t.name === 'intellichem') {
@@ -1165,18 +1165,24 @@ export class RS485Port {
1165
1165
  // triggers that cause the outbound message may come at the same time that another controller makes a call.
1166
1166
  var i = this._outBuffer.length - 1;
1167
1167
  while (i >= 0) {
1168
- let out = this._outBuffer[i--];
1169
- if (typeof out === 'undefined') continue;
1168
+ const out = this._outBuffer[i];
1169
+ if (typeof out === 'undefined') {
1170
+ i--;
1171
+ continue;
1172
+ }
1170
1173
  let resp = out.response;
1171
1174
  // RG - added check for msgOut because the *Touch chlor packet 153 adds an status packet 217
1172
1175
  // but if it is the only packet on the queue the outbound will have been cleared out already.
1173
- if (out.requiresResponse && msgOut !== null) {
1176
+ if (out.requiresResponse && typeof msgOut !== 'undefined' && msgOut !== null) {
1174
1177
  if (resp instanceof Response && resp.isResponse(msgIn, out) && (typeof out.scope === 'undefined' || out.scope === msgOut.scope)) {
1175
1178
  resp.message = msgIn;
1176
1179
  if (typeof (resp.callback) === 'function' && resp.callback) callback = resp.callback;
1177
1180
  this._outBuffer.splice(i, 1);
1181
+ // Resolve any async sender whose queued duplicate was satisfied by this inbound response.
1182
+ if (typeof out.onComplete === 'function') out.onComplete(undefined, msgIn);
1178
1183
  }
1179
1184
  }
1185
+ i--;
1180
1186
  }
1181
1187
  // RKS: This callback is important because we are managing queues. The position of this callback
1182
1188
  // occurs after all things related to the message have been processed including removal of subsequent
@@ -43,6 +43,7 @@ import { TouchScheduleCommands } from "controller/boards/EasyTouchBoard";
43
43
  import { IntelliValveStateMessage } from "./status/IntelliValveStateMessage";
44
44
  import { IntelliChemStateMessage } from "./status/IntelliChemStateMessage";
45
45
  import { RegalModbusStateMessage } from "./status/RegalModbusStateMessage";
46
+ import { NeptuneModbusStateMessage } from "./status/NeptuneModbusStateMessage";
46
47
  import { OutboundMessageError } from "../../Errors";
47
48
  import { conn } from "../Comms"
48
49
  import extend = require("extend");
@@ -63,7 +64,8 @@ export enum Protocol {
63
64
  AquaLink = 'aqualink',
64
65
  Hayward = 'hayward',
65
66
  Unidentified = 'unidentified',
66
- RegalModbus = 'regalmodbus'
67
+ RegalModbus = 'regalmodbus',
68
+ NeptuneModbus = 'neptunemodbus'
67
69
  }
68
70
  export class Message {
69
71
  constructor() { }
@@ -110,6 +112,9 @@ export class Message {
110
112
  else if (this.protocol === Protocol.RegalModbus) {
111
113
  return this.header.length > 0 ? this.header[0] : -1;
112
114
  }
115
+ else if (this.protocol === Protocol.NeptuneModbus) {
116
+ return this.header.length > 0 ? this.header[0] : -1;
117
+ }
113
118
  else return this.header.length > 2 ? this.header[2] : -1;
114
119
  }
115
120
  else return -1;
@@ -137,6 +142,10 @@ export class Message {
137
142
  // No source address in RegalModbus.
138
143
  return -1;
139
144
  }
145
+ else if (this.protocol === Protocol.NeptuneModbus) {
146
+ // No source address in Neptune Modbus RTU messages.
147
+ return -1;
148
+ }
140
149
  if (this.header.length > 3) return this.header[3];
141
150
  else return -1;
142
151
  }
@@ -153,6 +162,9 @@ export class Message {
153
162
  else if (this.protocol === Protocol.RegalModbus) {
154
163
  return this.header.length > 1 ? this.header[1]: -1;
155
164
  }
165
+ else if (this.protocol === Protocol.NeptuneModbus) {
166
+ return this.header.length > 1 ? this.header[1] : -1;
167
+ }
156
168
  else if (this.header.length > 4) return this.header[4];
157
169
  else return -1;
158
170
  if (this.header.length > 4) return this.header[4];
@@ -199,6 +211,21 @@ export class Message {
199
211
  return 0;
200
212
  }
201
213
  }
214
+ else if (this.protocol === Protocol.NeptuneModbus) {
215
+ let action = this.action;
216
+ if (action === 0x03 || action === 0x04) {
217
+ // Payload format: [byteCount, data...]
218
+ return this.payload.length > 0 ? this.payload[0] + 1 : -1;
219
+ }
220
+ if (action === 0x06 || action === 0x08 || action === 0x10) {
221
+ // Write single / diagnostics / write multiple response: addrHi, addrLo, val/qtyHi, val/qtyLo
222
+ return 4;
223
+ }
224
+ if ((action & 0x80) === 0x80) {
225
+ // Modbus exception response: one-byte exception code.
226
+ return 1;
227
+ }
228
+ }
202
229
  return this.header.length > 5 ? this.header[5] : -1;
203
230
  }
204
231
  public get chkHi(): number { return this.protocol === Protocol.Chlorinator || this.protocol === Protocol.AquaLink ? 0 : this.term.length > 0 ? this.term[0] : -1; }
@@ -315,6 +342,12 @@ export class Inbound extends Message {
315
342
  const crcReceived = (this.chkLo << 8) | this.chkHi;
316
343
  return crcComputed === crcReceived;
317
344
  }
345
+ case Protocol.NeptuneModbus: {
346
+ const data = this.header.concat(this.payload);
347
+ const crcComputed = computeCRC16(data);
348
+ const crcReceived = (this.chkLo << 8) | this.chkHi;
349
+ return crcComputed === crcReceived;
350
+ }
318
351
  default:
319
352
  return (this.chkHi * 256) + this.chkLo === this.checksum;
320
353
  }
@@ -365,12 +398,47 @@ export class Inbound extends Message {
365
398
  // logger.debug('Testing RegalModbus header', bytes, addr, func, ack, acceptableAcks.includes(ack));
366
399
  // logger.debug(`Current bytes: ${JSON.stringify(bytes)}`);
367
400
 
368
- if (addr >= 0x15 && addr <= 0xF7 && func >= 0x00 && func <= 0x7F && acceptableAcks.includes(ack)) {
401
+ if (addr >= 0x15 && addr <= 0xF7 && func >= 0x00 && func <= 0x7F && acceptableAcks.includes(ack) &&
402
+ this.isAddressForPumpType(addr, 'regalmodbus', ['neptunemodbus'])) {
369
403
  return true;
370
404
  }
371
405
  }
372
406
  return false;
373
407
  }
408
+ private isAddressForPumpType(address: number, pumpTypeName: string, peerPumpTypes: string[] = []): boolean {
409
+ let hasTargetType = false;
410
+ let hasPeerType = false;
411
+ for (let i = 0; i < sys.pumps.length; i++) {
412
+ const pump = sys.pumps.getItemByIndex(i);
413
+ const typeName = sys.board.valueMaps.pumpTypes.getName(pump.type);
414
+ if (typeName === pumpTypeName) {
415
+ hasTargetType = true;
416
+ if (pump.address === address) return true;
417
+ }
418
+ else if (peerPumpTypes.includes(typeName)) {
419
+ hasPeerType = true;
420
+ }
421
+ }
422
+ if (hasTargetType) return false;
423
+ if (hasPeerType) return false;
424
+ // If neither protocol type is configured yet, allow detection.
425
+ return true;
426
+ }
427
+ private testNeptuneModbusHeader(bytes: number[], ndx: number): boolean {
428
+ // Neptune Modbus RTU: address, function, payload..., crcLo, crcHi
429
+ if (bytes.length > ndx + 4 && sys.controllerType === 'nixie') {
430
+ const addr = bytes[ndx];
431
+ const func = bytes[ndx + 1];
432
+ const supportedFuncs = [0x03, 0x04, 0x06, 0x08, 0x10, 0x83, 0x84, 0x86, 0x88, 0x90];
433
+ if (addr < 1 || addr > 247) return false;
434
+ if (!supportedFuncs.includes(func)) return false;
435
+ if (!this.isAddressForPumpType(addr, 'neptunemodbus', ['regalmodbus'])) return false;
436
+ // For read responses, byte count must be reasonable.
437
+ if ((func === 0x03 || func === 0x04) && bytes[ndx + 2] > 250) return false;
438
+ return true;
439
+ }
440
+ return false;
441
+ }
374
442
  private testAquaLinkHeader(bytes: number[], ndx: number): boolean {
375
443
  if (bytes.length > ndx + 4 && sys.controllerType === 'aqualink') {
376
444
  if (bytes[ndx] === 16 && bytes[ndx + 1] === 2) {
@@ -499,6 +567,11 @@ export class Inbound extends Message {
499
567
  this.protocol = Protocol.Hayward;
500
568
  break;
501
569
  }
570
+ if (this.testNeptuneModbusHeader(bytes, ndx)) {
571
+ this.protocol = Protocol.NeptuneModbus;
572
+ logger.debug(`NeptuneModbus header detected. ${JSON.stringify(bytes)}`);
573
+ break;
574
+ }
502
575
  if (this.testRegalModbusHeader(bytes, ndx)) {
503
576
  this.protocol = Protocol.RegalModbus;
504
577
  logger.debug(`RegalModbus header detected. ${JSON.stringify(bytes)}`);
@@ -599,6 +672,15 @@ export class Inbound extends Message {
599
672
  return ndxHeader;
600
673
  }
601
674
  break;
675
+ case Protocol.NeptuneModbus:
676
+ ndx = this.pushBytes(this.header, bytes, ndx, 2);
677
+ if (this.header.length < 2) {
678
+ logger.debug(`We have an incoming NeptuneModbus message but the serial port hasn't given a complete header. [${this.padding}][${this.preamble}][${this.header}]`);
679
+ this.preamble = [];
680
+ this.header = [];
681
+ return ndxHeader;
682
+ }
683
+ break;
602
684
  default:
603
685
  // We didn't get a message signature. don't do anything with it.
604
686
  ndx = ndxStart;
@@ -681,6 +763,44 @@ export class Inbound extends Message {
681
763
  }
682
764
  }
683
765
  break;
766
+ case Protocol.NeptuneModbus: {
767
+ // Neptune Modbus RTU: [addr, fn][payload][crcLo, crcHi]
768
+ const functionCode = this.action;
769
+ if (functionCode === 0x03 || functionCode === 0x04) {
770
+ // Read response payload: [byteCount, data...]
771
+ if (this.payload.length === 0 && ndx < bytes.length - 2) {
772
+ this.payload.push(bytes[ndx++]);
773
+ }
774
+ const byteCount = this.payload[0];
775
+ if (typeof byteCount !== 'undefined') {
776
+ if (byteCount > 250) {
777
+ this.isValid = false;
778
+ logger.debug(`NeptuneModbus message marked invalid due to unreasonable byteCount ${byteCount}`);
779
+ break;
780
+ }
781
+ ndx = this.pushBytes(this.payload, bytes, ndx, (byteCount + 1) - this.payload.length);
782
+ }
783
+ }
784
+ else if (functionCode === 0x06 || functionCode === 0x08 || functionCode === 0x10) {
785
+ // Echo response payload: 4 bytes.
786
+ ndx = this.pushBytes(this.payload, bytes, ndx, 4 - this.payload.length);
787
+ }
788
+ else if ((functionCode & 0x80) === 0x80) {
789
+ // Exception response payload: one-byte code.
790
+ ndx = this.pushBytes(this.payload, bytes, ndx, 1 - this.payload.length);
791
+ }
792
+ else {
793
+ while (ndx + 3 <= bytes.length) {
794
+ this.payload.push(bytes[ndx++]);
795
+ if (this.payload.length > 253) {
796
+ this.isValid = false;
797
+ logger.debug(`NeptuneModbus message marked invalid due to payload more than 253 bytes`);
798
+ break;
799
+ }
800
+ }
801
+ }
802
+ break;
803
+ }
684
804
 
685
805
  }
686
806
  return ndx;
@@ -695,10 +815,11 @@ export class Inbound extends Message {
695
815
  case Protocol.IntelliChem:
696
816
  case Protocol.Heater:
697
817
  case Protocol.RegalModbus:
818
+ case Protocol.NeptuneModbus:
698
819
  case Protocol.Unidentified:
699
820
  // If we don't have enough bytes to make the terminator then continue on and
700
821
  // hope we get them on the next go around.
701
- if (this.payload.length >= this.datalen && ndx + 2 <= bytes.length) {
822
+ if (this.datalen >= 0 && this.payload.length >= this.datalen && ndx + 2 <= bytes.length) {
702
823
  this._complete = true;
703
824
  ndx = this.pushBytes(this.term, bytes, ndx, 2);
704
825
  this.isValid = this.isValidChecksum();
@@ -762,7 +883,13 @@ export class Inbound extends Message {
762
883
  case ControllerType.IntelliCenter:
763
884
  switch (this.action) {
764
885
  case 1: // ACK
765
- VersionMessage.processAction168Ack(this);
886
+ // v3.004+ piggyback: only route ACKs we care about (168/184) into a single handler
887
+ // to avoid doing extra work on every ACK frame.
888
+ if (this.payload.length === 1 && (this.payload[0] === 168 || this.payload[0] === 184)) {
889
+ VersionMessage.processActionAck(this);
890
+ } else {
891
+ this.isProcessed = true;
892
+ }
766
893
  break;
767
894
  case 2:
768
895
  case 204:
@@ -922,12 +1049,15 @@ export class Inbound extends Message {
922
1049
  }
923
1050
  }
924
1051
  public process() {
925
- let port = conn.findPortById(this.portId);
926
- if (this.portId === sys.anslq25.portId) {
927
- return MessagesMock.process(this);
928
- }
929
- if (port.mock && port.hasAssignedEquipment()){
930
- return MessagesMock.process(this);
1052
+ const isReplay = this.scope === 'replay';
1053
+ if (!isReplay) {
1054
+ let port = conn.findPortById(this.portId);
1055
+ if (this.portId === sys.anslq25.portId) {
1056
+ return MessagesMock.process(this);
1057
+ }
1058
+ if (port.mock && port.hasAssignedEquipment()){
1059
+ return MessagesMock.process(this);
1060
+ }
931
1061
  }
932
1062
  switch (this.protocol) {
933
1063
  case Protocol.Broadcast:
@@ -957,6 +1087,9 @@ export class Inbound extends Message {
957
1087
  case Protocol.RegalModbus:
958
1088
  RegalModbusStateMessage.process(this);
959
1089
  break;
1090
+ case Protocol.NeptuneModbus:
1091
+ NeptuneModbusStateMessage.process(this);
1092
+ break;
960
1093
  default:
961
1094
  logger.debug(`Unprocessed Message ${this.toPacket()}`)
962
1095
  break;
@@ -970,6 +1103,7 @@ class OutboundCommon extends Message {
970
1103
  if (this.protocol === Protocol.Chlorinator) this.header[2] = val;
971
1104
  else if (this.protocol === Protocol.Hayward) this.header[4] = val;
972
1105
  else if (this.protocol === Protocol.RegalModbus) this.header[0] = val;
1106
+ else if (this.protocol === Protocol.NeptuneModbus) this.header[0] = val;
973
1107
  else this.header[2] = val;
974
1108
  }
975
1109
  public get dest() { return super.dest; }
@@ -982,6 +1116,8 @@ class OutboundCommon extends Message {
982
1116
  break;
983
1117
  case Protocol.RegalModbus:
984
1118
  break;
1119
+ case Protocol.NeptuneModbus:
1120
+ break;
985
1121
  default:
986
1122
  this.header[3] = val;
987
1123
  break;
@@ -1001,6 +1137,9 @@ class OutboundCommon extends Message {
1001
1137
  case Protocol.RegalModbus:
1002
1138
  this.header[1] = val;
1003
1139
  break;
1140
+ case Protocol.NeptuneModbus:
1141
+ this.header[1] = val;
1142
+ break;
1004
1143
  default:
1005
1144
  this.header[4] = val;
1006
1145
  break;
@@ -1008,7 +1147,7 @@ class OutboundCommon extends Message {
1008
1147
  }
1009
1148
  public get action() { return super.action; }
1010
1149
  public set datalen(val: number) {
1011
- if (this.protocol !== Protocol.Chlorinator && this.protocol !== Protocol.Hayward && this.protocol !== Protocol.RegalModbus) {
1150
+ if (this.protocol !== Protocol.Chlorinator && this.protocol !== Protocol.Hayward && this.protocol !== Protocol.RegalModbus && this.protocol !== Protocol.NeptuneModbus) {
1012
1151
  this.header[5] = val;
1013
1152
  }
1014
1153
  }
@@ -1046,6 +1185,13 @@ class OutboundCommon extends Message {
1046
1185
  this.chkLo = (crc >> 8) & 0xFF;
1047
1186
  this.chkHi = crc & 0xFF;
1048
1187
  break;
1188
+ case Protocol.NeptuneModbus:
1189
+ // Modbus RTU CRC16 (LSB-first on the wire).
1190
+ let modbusData: number[] = this.header.concat(this.payload);
1191
+ const modbusCrc: number = computeCRC16(modbusData);
1192
+ this.chkLo = (modbusCrc >> 8) & 0xFF;
1193
+ this.chkHi = modbusCrc & 0xFF;
1194
+ break;
1049
1195
  }
1050
1196
  }
1051
1197
  }
@@ -1081,6 +1227,10 @@ export class Outbound extends OutboundCommon {
1081
1227
  else if (proto === Protocol.RegalModbus) {
1082
1228
  this.header.push.apply(this.header, [this.dest, this.action, 0x20]);
1083
1229
  }
1230
+ else if (proto === Protocol.NeptuneModbus) {
1231
+ this.header.push.apply(this.header, [this.dest, this.action]);
1232
+ this.term.push.apply(this.term, [0, 0]);
1233
+ }
1084
1234
  this.scope = scope;
1085
1235
  this.source = source;
1086
1236
  this.dest = dest;
@@ -1177,6 +1327,14 @@ export class Outbound extends OutboundCommon {
1177
1327
  if (ndx + 1 < this.payload.length) this.payload[ndx + 1] = b1;
1178
1328
  return this;
1179
1329
  }
1330
+ public setPayloadIntBE(ndx: number, value: number, def?: number) {
1331
+ if (typeof value === 'undefined' || isNaN(value)) value = def;
1332
+ let b1 = Math.floor(value / 256);
1333
+ let b0 = value - (b1 * 256);
1334
+ if (ndx < this.payload.length) this.payload[ndx] = b1;
1335
+ if (ndx + 1 < this.payload.length) this.payload[ndx + 1] = b0;
1336
+ return this;
1337
+ }
1180
1338
  public appendPayloadInt(value: number, def?: number) {
1181
1339
  if (typeof value === 'undefined' || isNaN(value)) value = def;
1182
1340
  let b1 = Math.floor(value / 256);
@@ -1185,6 +1343,14 @@ export class Outbound extends OutboundCommon {
1185
1343
  this.payload.push(b1);
1186
1344
  return this;
1187
1345
  }
1346
+ public appendPayloadIntBE(value: number, def?: number) {
1347
+ if (typeof value === 'undefined' || isNaN(value)) value = def;
1348
+ let b1 = Math.floor(value / 256);
1349
+ let b0 = value - (b1 * 256);
1350
+ this.payload.push(b1);
1351
+ this.payload.push(b0);
1352
+ return this;
1353
+ }
1188
1354
  public insertPayloadInt(ndx: number, value: number, def?: number) {
1189
1355
  if (typeof value === 'undefined' || isNaN(value)) value = def;
1190
1356
  let b1 = Math.floor(value / 256);
@@ -1317,19 +1483,26 @@ export class Response extends OutboundCommon {
1317
1483
  if (msgIn.protocol !== msgOut.protocol) { return false; }
1318
1484
  if (typeof msgIn === 'undefined') { return false; } // getting here on msg send failure
1319
1485
 
1320
- // if these properties were set on the Response (this) object via creation,
1321
- // then use the passed in values. Otherwise, use the msgIn/msgOut matching rules
1322
- // IntelliCenter config queue uses (action,payload-prefix) matching for Action 30 responses.
1323
- // Keep this stricter prefix matching scoped to IntelliCenter to avoid unintended effects on other controllers.
1324
- if (this.action > 0 && sys.controllerType === ControllerType.IntelliCenter) {
1325
- if (this.action !== msgIn.action) return false;
1326
- // If no payload prefix is provided, action match is sufficient (e.g. v3 Action 30 with empty payload).
1327
- if (this.payload.length === 0) return true;
1328
- if (msgIn.payload.length < this.payload.length) return false;
1329
- for (let i = 0; i < this.payload.length; i++) {
1330
- if (this.payload[i] !== msgIn.payload[i]) return false;
1331
- }
1332
- return true;
1486
+ // If these properties were set on the Response (this) object via creation,
1487
+ // then use the passed in values. Otherwise, use the msgIn/msgOut matching rules.
1488
+ //
1489
+ // NOTE: IntelliCenter response matching is handled in the IntelliCenter-specific block below
1490
+ // to keep the logic in one place.
1491
+ if (msgOut.protocol === Protocol.Heater) {
1492
+ // Heater protocol: request action 114 response action 115, etc.
1493
+ // Verify response comes from the heater we addressed.
1494
+ if (msgIn.source !== msgOut.dest || (msgIn.dest !== msgOut.source && msgIn.dest !== 16)) { return false; }
1495
+ if (this.action > 0 && this.action === msgIn.action) return true;
1496
+ return false;
1497
+ }
1498
+ //
1499
+ // Restore Response-level action matching for non-IntelliCenter protocols (e.g., Hayward).
1500
+ // The Hayward Outbound action getter has a known index mismatch (reads source instead of action),
1501
+ // so we use the Response object's action which stores it correctly in header[4].
1502
+ // See: https://github.com/tagyoureit/nodejs-poolController/issues/1098
1503
+ if (sys.controllerType !== ControllerType.IntelliCenter && this.action > 0) {
1504
+ if (this.action === msgIn.action) return true;
1505
+ else return false;
1333
1506
  }
1334
1507
  else if (msgOut.protocol === Protocol.Pump) {
1335
1508
  switch (msgIn.action) {
@@ -1358,6 +1531,13 @@ export class Response extends OutboundCommon {
1358
1531
  if (msgIn.action === msgOut.action && msgIn.header[2] === 0x10) return true;
1359
1532
  return false;
1360
1533
  }
1534
+ else if (msgIn.protocol === Protocol.NeptuneModbus) {
1535
+ // Neptune Modbus: match by address and function code; allow exception responses (fn | 0x80).
1536
+ if (msgIn.dest !== msgOut.dest) return false;
1537
+ if (msgIn.action === msgOut.action) return true;
1538
+ if (msgIn.action === (msgOut.action | 0x80)) return true;
1539
+ return false;
1540
+ }
1361
1541
  else if (msgIn.protocol === Protocol.Chlorinator) {
1362
1542
  switch (msgIn.action) {
1363
1543
  case 1:
@@ -1404,6 +1584,20 @@ export class Response extends OutboundCommon {
1404
1584
  }
1405
1585
  else if (sys.controllerType === ControllerType.IntelliCenter) {
1406
1586
  // intellicenter packets
1587
+ // IntelliCenter config queue uses (action,payload-prefix) matching for Action 30 responses.
1588
+ // Keep this scoped to IntelliCenter to avoid unintended effects on other controllers.
1589
+ if (sys.equipment.isIntellicenterV3 && this.action > 0) {
1590
+ if (this.action !== msgIn.action) return false;
1591
+ // If a destination was specified on the Response, enforce it (critical for v3 unicast flows).
1592
+ if (this.dest >= 0 && msgIn.dest !== this.dest) return false;
1593
+ // If no payload prefix is provided, action match is sufficient (e.g. v3 Action 30 with empty payload).
1594
+ if (this.payload.length === 0) return true;
1595
+ if (msgIn.payload.length < this.payload.length) return false;
1596
+ for (let i = 0; i < this.payload.length; i++) {
1597
+ if (msgIn.payload[i] !== this.payload[i]) return false;
1598
+ }
1599
+ return true;
1600
+ }
1407
1601
  if (this.dest >= 0 && msgIn.dest !== this.dest) return false;
1408
1602
  for (let i = 0; i < this.payload.length; i++) {
1409
1603
  if (i > msgIn.payload.length - 1)