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.
- package/.github/workflows/ghcr-publish.yml +1 -1
- package/157_issues.md +101 -0
- package/AGENTS.md +17 -1
- package/README.md +13 -2
- package/controller/Equipment.ts +49 -0
- package/controller/State.ts +8 -0
- package/controller/boards/AquaLinkBoard.ts +174 -2
- package/controller/boards/EasyTouchBoard.ts +44 -0
- package/controller/boards/IntelliCenterBoard.ts +360 -172
- package/controller/boards/NixieBoard.ts +7 -4
- package/controller/boards/SunTouchBoard.ts +1 -0
- package/controller/boards/SystemBoard.ts +39 -4
- package/controller/comms/Comms.ts +9 -3
- package/controller/comms/messages/Messages.ts +218 -24
- package/controller/comms/messages/config/EquipmentMessage.ts +34 -0
- package/controller/comms/messages/config/ExternalMessage.ts +1051 -989
- package/controller/comms/messages/config/GeneralMessage.ts +65 -0
- package/controller/comms/messages/config/OptionsMessage.ts +15 -2
- package/controller/comms/messages/config/PumpMessage.ts +427 -421
- package/controller/comms/messages/config/SecurityMessage.ts +37 -13
- package/controller/comms/messages/status/EquipmentStateMessage.ts +0 -218
- package/controller/comms/messages/status/HeaterStateMessage.ts +27 -15
- package/controller/comms/messages/status/NeptuneModbusStateMessage.ts +217 -0
- package/controller/comms/messages/status/VersionMessage.ts +67 -18
- package/controller/nixie/chemistry/ChemController.ts +65 -33
- package/controller/nixie/heaters/Heater.ts +10 -1
- package/controller/nixie/pumps/Pump.ts +145 -2
- package/docker-compose.yml +1 -0
- package/logger/Logger.ts +75 -64
- package/package.json +1 -1
- package/tsconfig.json +2 -1
- package/web/Server.ts +3 -1
- package/web/services/config/Config.ts +150 -1
- package/web/services/state/State.ts +21 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1169
|
-
if (typeof out === 'undefined')
|
|
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
|
-
|
|
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
|
-
|
|
926
|
-
if (
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
//
|
|
1321
|
-
// then use the passed in values.
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
//
|
|
1327
|
-
|
|
1328
|
-
if (msgIn.
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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)
|