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
|
@@ -23,6 +23,20 @@ export class VersionMessage {
|
|
|
23
23
|
// Debounce config refresh requests to avoid duplicate requests from overlapping triggers
|
|
24
24
|
private static lastConfigRefreshTime: number = 0;
|
|
25
25
|
private static readonly CONFIG_REFRESH_DEBOUNCE_MS = 2000; // 2 seconds
|
|
26
|
+
private static pendingConfigRefreshTimer?: NodeJS.Timeout;
|
|
27
|
+
private static pendingConfigRefreshSource?: string;
|
|
28
|
+
private static scheduleConfigRefresh(delayMs: number, source: string, reason: string): void {
|
|
29
|
+
this.pendingConfigRefreshSource = source;
|
|
30
|
+
if (!this.pendingConfigRefreshTimer) {
|
|
31
|
+
this.pendingConfigRefreshTimer = setTimeout(() => {
|
|
32
|
+
this.pendingConfigRefreshTimer = undefined;
|
|
33
|
+
const src = this.pendingConfigRefreshSource ? `${this.pendingConfigRefreshSource} (trailing)` : 'Trailing';
|
|
34
|
+
this.pendingConfigRefreshSource = undefined;
|
|
35
|
+
this.triggerConfigRefresh(src);
|
|
36
|
+
}, delayMs);
|
|
37
|
+
}
|
|
38
|
+
logger.silly(`v3.004+ ${source}: Deferring config refresh (${reason}, retry in ${delayMs}ms)`);
|
|
39
|
+
}
|
|
26
40
|
|
|
27
41
|
/**
|
|
28
42
|
* Shared method to trigger a config refresh with debouncing.
|
|
@@ -30,21 +44,43 @@ export class VersionMessage {
|
|
|
30
44
|
*/
|
|
31
45
|
private static triggerConfigRefresh(source: string): void {
|
|
32
46
|
const now = Date.now();
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
const elapsed = now - this.lastConfigRefreshTime;
|
|
48
|
+
if (elapsed < this.CONFIG_REFRESH_DEBOUNCE_MS) {
|
|
49
|
+
// Throttle-with-trailing: don't lose rapid toggle updates; schedule one refresh at end of window.
|
|
50
|
+
const remainingMs = Math.max(0, this.CONFIG_REFRESH_DEBOUNCE_MS - elapsed);
|
|
51
|
+
this.scheduleConfigRefresh(remainingMs, source, `debounced (last send ${elapsed}ms ago)`);
|
|
35
52
|
return;
|
|
36
53
|
}
|
|
37
|
-
this.lastConfigRefreshTime = now;
|
|
38
|
-
|
|
39
54
|
(sys.board as any).needsConfigChanges = true;
|
|
40
55
|
// Invalidate cached options version so queueChanges() will request category 0.
|
|
41
56
|
// OCP doesn't increment options version when heat mode/setpoints change,
|
|
42
57
|
// so we force a refresh by clearing our cached version.
|
|
43
58
|
sys.configVersion.options = 0;
|
|
59
|
+
// v3.004+: OCP does NOT reliably increment systemState when features toggle (esp. rapid OFF/ON sequences).
|
|
60
|
+
// Force a systemState refresh so queueChanges() will request category 15 (systemState), option [0] => Action 222 [15,0].
|
|
61
|
+
sys.configVersion.systemState = 0;
|
|
62
|
+
// v3.004+: OCP does NOT increment pumps version when pump config changes via WCP (Action 168 type 4).
|
|
63
|
+
// Force a pumps refresh so queueChanges() will request category 4 (pumps).
|
|
64
|
+
sys.configVersion.pumps = 0;
|
|
65
|
+
// v3.x: personal-information updates (Action 168 type 12) are not always reflected by
|
|
66
|
+
// version deltas in the field; force a general refresh so category 12 is re-polled.
|
|
67
|
+
sys.configVersion.general = 0;
|
|
68
|
+
|
|
69
|
+
// If a config queue run is already active, coalesce this refresh and retry after a short delay.
|
|
70
|
+
// This avoids extra 228/164 churn in the middle of an in-flight loading pass.
|
|
71
|
+
if (typeof (sys.board as any).isConfigQueueProcessing === 'function' &&
|
|
72
|
+
(sys.board as any).isConfigQueueProcessing()) {
|
|
73
|
+
this.scheduleConfigRefresh(this.CONFIG_REFRESH_DEBOUNCE_MS, source, 'config queue already processing');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.lastConfigRefreshTime = now;
|
|
44
78
|
logger.silly(`v3.004+ ${source}: Sending Action 228`);
|
|
45
79
|
Outbound.create({
|
|
46
80
|
dest: 16, action: 228, payload: [0], retries: 2,
|
|
47
|
-
|
|
81
|
+
scope: 'v3RefreshTrigger',
|
|
82
|
+
// v3.004+: require 164 addressed to us (not to Wireless).
|
|
83
|
+
response: Response.create({ dest: Message.pluginAddress, action: 164 })
|
|
48
84
|
}).sendAsync();
|
|
49
85
|
}
|
|
50
86
|
|
|
@@ -62,21 +98,34 @@ export class VersionMessage {
|
|
|
62
98
|
}
|
|
63
99
|
|
|
64
100
|
/**
|
|
65
|
-
* v3.004+ ACK Trigger
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
101
|
+
* v3.004+ ACK Trigger (single entrypoint):
|
|
102
|
+
* When OCP ACKs a Wireless/other device's Action 168 or 184, trigger a debounced config refresh.
|
|
103
|
+
*
|
|
104
|
+
* Intended call-site: `Messages.ts` should gate on ACK payload[0] (168/184) and then call this method once.
|
|
69
105
|
*/
|
|
70
|
-
public static
|
|
71
|
-
//
|
|
72
|
-
if (sys.equipment.isIntellicenterV3
|
|
73
|
-
msg.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
public static processActionAck(msg: Inbound): void {
|
|
107
|
+
// Gate: only v3.004+
|
|
108
|
+
if (!sys.equipment.isIntellicenterV3) {
|
|
109
|
+
msg.isProcessed = true;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Gate: only when ACK originates from OCP (src=16) to some other device (not us, not OCP).
|
|
113
|
+
if (msg.source !== 16 || msg.dest === Message.pluginAddress || msg.dest === 16) {
|
|
114
|
+
msg.isProcessed = true;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Gate: only ACKing Action 168 or 184 (caller should gate, but keep defensive checks here too).
|
|
118
|
+
const ackedAction = msg.payload.length > 0 ? msg.payload[0] : undefined;
|
|
119
|
+
if (ackedAction !== 168 && ackedAction !== 184) {
|
|
120
|
+
msg.isProcessed = true;
|
|
121
|
+
return;
|
|
79
122
|
}
|
|
123
|
+
|
|
124
|
+
const label =
|
|
125
|
+
ackedAction === 168
|
|
126
|
+
? `ACK(168) Trigger (device ${msg.dest})`
|
|
127
|
+
: `ACK(184) Trigger (device ${msg.dest})`;
|
|
128
|
+
this.triggerConfigRefresh(label);
|
|
80
129
|
msg.isProcessed = true;
|
|
81
130
|
}
|
|
82
131
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { clearTimeout, setTimeout } from 'timers';
|
|
2
2
|
import { conn } from '../../../controller/comms/Comms';
|
|
3
3
|
import { Outbound, Protocol, Response } from '../../../controller/comms/messages/Messages';
|
|
4
4
|
import { IChemical, IChemController, Chlorinator, ChemController, ChemControllerCollection, ChemFlowSensor, Chemical, ChemicalORP, ChemicalORPProbe, ChemicalPh, ChemicalPhProbe, ChemicalProbe, ChemicalPump, ChemicalTank, sys } from "../../../controller/Equipment";
|
|
@@ -234,14 +234,15 @@ export class NixieIntelliChemController extends NixieChemControllerBase {
|
|
|
234
234
|
|
|
235
235
|
let schem = state.chemControllers.getItemById(this.chem.id, !this.closing);
|
|
236
236
|
schem.calculateSaturationIndex();
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
237
|
+
// By default IntelliChem tracks body relay state. When standalone is enabled,
|
|
238
|
+
// allow polling even if the body circuit is currently off.
|
|
239
|
+
let canCommunicate = this.isBodyOn() || this.chem.intellichemStandalone;
|
|
240
|
+
if (canCommunicate && !this.closing) {
|
|
240
241
|
if (!this.configSent) await this.sendConfig(schem);
|
|
241
242
|
if (!this.closing) await this.requestStatus(schem);
|
|
242
243
|
}
|
|
243
244
|
}
|
|
244
|
-
catch (err) { logger.error(`Error polling IntelliChem Controller - ${err}`);
|
|
245
|
+
catch (err) { logger.error(`Error polling IntelliChem Controller - ${err}`); }
|
|
245
246
|
finally { this.suspendPolling = false; if (!this.closing) this._pollTimer = setTimeout(() => { self.pollEquipmentAsync(); }, this.pollingInterval || 10000); }
|
|
246
247
|
}
|
|
247
248
|
public async setServiceModeAsync() {}
|
|
@@ -257,6 +258,7 @@ export class NixieIntelliChemController extends NixieChemControllerBase {
|
|
|
257
258
|
let cyanuricAcid = typeof data.cyanuricAcid !== 'undefined' ? parseInt(data.cyanuricAcid, 10) : chem.cyanuricAcid;
|
|
258
259
|
let alkalinity = typeof data.alkalinity !== 'undefined' ? parseInt(data.alkalinity, 10) : chem.alkalinity;
|
|
259
260
|
let borates = typeof data.borates !== 'undefined' ? parseInt(data.borates, 10) : chem.borates || 0;
|
|
261
|
+
let intellichemStandalone = typeof data.intellichemStandalone !== 'undefined' ? utils.makeBool(data.intellichemStandalone) : chem.intellichemStandalone;
|
|
260
262
|
let body = sys.board.bodies.mapBodyAssociation(typeof data.body === 'undefined' ? chem.body : data.body);
|
|
261
263
|
if (typeof body === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Invalid body assignment`, 'chemController', data.body || chem.body));
|
|
262
264
|
// Do a final validation pass so we dont send this off in a mess.
|
|
@@ -309,6 +311,7 @@ export class NixieIntelliChemController extends NixieChemControllerBase {
|
|
|
309
311
|
chem.borates = borates;
|
|
310
312
|
chem.body = schem.body = body;
|
|
311
313
|
chem.type = schem.type = type.val;
|
|
314
|
+
chem.intellichemStandalone = intellichemStandalone;
|
|
312
315
|
|
|
313
316
|
let acidTankLevel = typeof data.ph !== 'undefined' && typeof data.ph.tank !== 'undefined' && typeof data.ph.tank.level !== 'undefined' ? parseInt(data.ph.tank.level, 10) : schem.ph.tank.level;
|
|
314
317
|
let orpTankLevel = typeof data.orp !== 'undefined' && typeof data.orp.tank !== 'undefined' && typeof data.orp.tank.level !== 'undefined' ? parseInt(data.orp.tank.level, 10) : schem.orp.tank.level;
|
|
@@ -320,6 +323,7 @@ export class NixieIntelliChemController extends NixieChemControllerBase {
|
|
|
320
323
|
chem.alkalinity = alkalinity;
|
|
321
324
|
chem.borates = borates;
|
|
322
325
|
chem.body = schem.body = body;
|
|
326
|
+
chem.intellichemStandalone = intellichemStandalone;
|
|
323
327
|
schem.isActive = chem.isActive = true;
|
|
324
328
|
chem.lsiRange.enabled = lsiRange.enabled;
|
|
325
329
|
chem.lsiRange.low = lsiRange.low;
|
|
@@ -348,35 +352,39 @@ export class NixieIntelliChemController extends NixieChemControllerBase {
|
|
|
348
352
|
}
|
|
349
353
|
public async sendConfig(schem: ChemControllerState): Promise<boolean> {
|
|
350
354
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
355
|
+
this.configSent = false;
|
|
356
|
+
let out = Outbound.create({
|
|
357
|
+
protocol: Protocol.IntelliChem,
|
|
358
|
+
source: 16,
|
|
359
|
+
dest: this.chem.address,
|
|
360
|
+
action: 146,
|
|
361
|
+
payload: [],
|
|
362
|
+
retries: 3, // We are going to try 4 times.
|
|
363
|
+
response: Response.create({ protocol: Protocol.IntelliChem, action: 1 }),
|
|
364
|
+
onAbort: () => { }
|
|
365
|
+
});
|
|
366
|
+
out.insertPayloadBytes(0, 0, 21);
|
|
367
|
+
out.setPayloadByte(0, Math.floor((this.chem.ph.setpoint * 100) / 256) || 0);
|
|
368
|
+
out.setPayloadByte(1, Math.round((this.chem.ph.setpoint * 100) % 256) || 0);
|
|
369
|
+
out.setPayloadByte(2, Math.floor(this.chem.orp.setpoint / 256) || 0);
|
|
370
|
+
out.setPayloadByte(3, Math.round(this.chem.orp.setpoint % 256) || 0);
|
|
371
|
+
out.setPayloadByte(4, schem.ph.enabled ? schem.ph.tank.level + 1 : 0);
|
|
372
|
+
out.setPayloadByte(5, schem.orp.enabled ? schem.orp.tank.level + 1 : 0);
|
|
373
|
+
out.setPayloadByte(6, Math.floor(this.chem.calciumHardness / 256) || 0);
|
|
374
|
+
out.setPayloadByte(7, Math.round(this.chem.calciumHardness % 256) || 0);
|
|
375
|
+
out.setPayloadByte(9, this.chem.cyanuricAcid);
|
|
376
|
+
out.setPayloadByte(10, Math.floor(this.chem.alkalinity / 256) || 0);
|
|
377
|
+
out.setPayloadByte(12, Math.round(this.chem.alkalinity % 256) || 0);
|
|
378
|
+
logger.verbose(`Nixie: ${this.chem.name} sending IntelliChem settings action 146`);
|
|
379
|
+
await out.sendAsync();
|
|
380
|
+
this.configSent = true;
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
this.configSent = false;
|
|
385
|
+
logger.error(`Error updating IntelliChem: ${err.message}`);
|
|
386
|
+
return false;
|
|
378
387
|
}
|
|
379
|
-
catch (err) { logger.error(`Error updating IntelliChem: ${err.message}`); }
|
|
380
388
|
}
|
|
381
389
|
public async requestStatus(schem: ChemControllerState): Promise<boolean> {
|
|
382
390
|
try {
|
|
@@ -1413,6 +1421,7 @@ export class NixieChemPump extends NixieChildEquipment {
|
|
|
1413
1421
|
// about an EZO pump all the values are maintained anyway through the state settings.
|
|
1414
1422
|
let res = await NixieEquipment.putDeviceService(this.pump.connectionId, `/state/device/${this.pump.deviceBinding}`, { state: false });
|
|
1415
1423
|
this.isOn = schem.pump.isDosing = false;
|
|
1424
|
+
await this.syncBoundCircuitStatesAsync(false);
|
|
1416
1425
|
return res;
|
|
1417
1426
|
}
|
|
1418
1427
|
catch (err) { logger.error(`chemController.pump.turnOff: ${err.message}`); return Promise.reject(err); }
|
|
@@ -1421,10 +1430,33 @@ export class NixieChemPump extends NixieChildEquipment {
|
|
|
1421
1430
|
try {
|
|
1422
1431
|
let res = await NixieEquipment.putDeviceService(this.pump.connectionId, `/state/device/${this.pump.deviceBinding}`, typeof latchTimeout !== 'undefined' ? { isOn: true, latch: latchTimeout } : { isOn: true });
|
|
1423
1432
|
this.isOn = schem.pump.isDosing = true;
|
|
1433
|
+
await this.syncBoundCircuitStatesAsync(true);
|
|
1424
1434
|
return res;
|
|
1425
1435
|
}
|
|
1426
1436
|
catch (err) { logger.error(`chemController.pump.turnOn: ${err.message}`); return Promise.reject(err); }
|
|
1427
1437
|
}
|
|
1438
|
+
private async syncBoundCircuitStatesAsync(isOn: boolean): Promise<void> {
|
|
1439
|
+
if (utils.isNullOrEmpty(this.pump.connectionId) || utils.isNullOrEmpty(this.pump.deviceBinding)) return;
|
|
1440
|
+
try {
|
|
1441
|
+
let hasChanged = false;
|
|
1442
|
+
for (let i = 0; i < sys.circuits.length; i++) {
|
|
1443
|
+
let circuit = sys.circuits.getItemByIndex(i);
|
|
1444
|
+
if (!circuit.isActive) continue;
|
|
1445
|
+
if (circuit.connectionId !== this.pump.connectionId || circuit.deviceBinding !== this.pump.deviceBinding) continue;
|
|
1446
|
+
let cstate = state.circuits.find(elem => elem.id === circuit.id);
|
|
1447
|
+
if (typeof cstate === 'undefined' || cstate.isOn === isOn) continue;
|
|
1448
|
+
sys.board.circuits.setEndTime(circuit, cstate, isOn);
|
|
1449
|
+
cstate.isOn = isOn;
|
|
1450
|
+
hasChanged = true;
|
|
1451
|
+
}
|
|
1452
|
+
if (hasChanged) {
|
|
1453
|
+
sys.board.valves.syncValveStates();
|
|
1454
|
+
ncp.pumps.syncPumpStates();
|
|
1455
|
+
state.emitEquipmentChanges();
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
catch (err) { logger.warn(`chemController.pump.syncBoundCircuitStatesAsync: ${err.message}`); }
|
|
1459
|
+
}
|
|
1428
1460
|
}
|
|
1429
1461
|
export class NixieChemChlor extends NixieChildEquipment {
|
|
1430
1462
|
public get chlor(): Chlorinator { return sys.chlorinators.getItemById((this.getParent() as NixieChemicalORP).orp.chlorId); }
|
|
@@ -28,7 +28,16 @@ export class NixieHeaterCollection extends NixieEquipmentCollection<NixieHeaterB
|
|
|
28
28
|
try {
|
|
29
29
|
let h: NixieHeaterBase = this.find(elem => elem.id === hstate.id) as NixieHeaterBase;
|
|
30
30
|
if (typeof h === 'undefined') {
|
|
31
|
-
|
|
31
|
+
// Auto-initialize if heater exists in config with master=1 but hasn't been
|
|
32
|
+
// added to NCP yet (e.g. syncHeaterStates runs before ncp.initAsync completes).
|
|
33
|
+
let heater = sys.heaters.getItemById(hstate.id);
|
|
34
|
+
if (typeof heater !== 'undefined' && heater.master === 1 && heater.isActive !== false) {
|
|
35
|
+
logger.info(`NCP: Auto-initializing heater ${hstate.id}-${heater.name}`);
|
|
36
|
+
h = await this.initHeaterAsync(heater);
|
|
37
|
+
}
|
|
38
|
+
if (typeof h === 'undefined') {
|
|
39
|
+
return Promise.reject(new Error(`NCP: Heater ${hstate.id}-${hstate.name} could not be found to set the state to ${val}.`));
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
await h.setHeaterStateAsync(hstate, val, isCooling);
|
|
34
43
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { EquipmentNotFoundError, InvalidEquipmentDataError, InvalidEquipmentIdError, ParameterOutOfRangeError } from '../../Errors';
|
|
2
2
|
import { utils, Timestamp } from '../../Constants';
|
|
3
3
|
import { logger } from '../../../logger/Logger';
|
|
4
4
|
|
|
@@ -11,6 +11,7 @@ import { webApp, InterfaceServerResponse } from "../../../web/Server";
|
|
|
11
11
|
import { Outbound, Protocol, Response } from '../../comms/messages/Messages';
|
|
12
12
|
import { conn } from '../../comms/Comms';
|
|
13
13
|
import { setTimeout } from 'timers/promises';
|
|
14
|
+
import { NeptuneModbusStateMessage } from '../../comms/messages/status/NeptuneModbusStateMessage';
|
|
14
15
|
|
|
15
16
|
export class NixiePumpCollection extends NixieEquipmentCollection<NixiePump> {
|
|
16
17
|
public async deletePumpAsync(id: number) {
|
|
@@ -139,6 +140,8 @@ export class NixiePumpCollection extends NixieEquipmentCollection<NixiePump> {
|
|
|
139
140
|
return new NixiePumpHWRLY(this.controlPanel, pump);
|
|
140
141
|
case 'regalmodbus':
|
|
141
142
|
return new NixiePumpRegalModbus(this.controlPanel, pump);
|
|
143
|
+
case 'neptunemodbus':
|
|
144
|
+
return new NixiePumpNeptuneModbus(this.controlPanel, pump);
|
|
142
145
|
default:
|
|
143
146
|
throw new EquipmentNotFoundError(`NCP: Cannot create pump ${pump.name}.`, type);
|
|
144
147
|
}
|
|
@@ -238,6 +241,7 @@ export class NixiePump extends NixieEquipment {
|
|
|
238
241
|
case 'vssvrs':
|
|
239
242
|
case 'vs':
|
|
240
243
|
case 'regalmodbus':
|
|
244
|
+
case 'neptunemodbus':
|
|
241
245
|
c.units = sys.board.valueMaps.pumpUnits.getValue('rpm');
|
|
242
246
|
break;
|
|
243
247
|
case 'ss':
|
|
@@ -285,7 +289,9 @@ export class NixiePump extends NixieEquipment {
|
|
|
285
289
|
// for the 112 address prevented that previously, but now is just a final fail safe.
|
|
286
290
|
if (typeof this._pollTimer !== 'undefined' || this._pollTimer) clearTimeout(this._pollTimer);
|
|
287
291
|
this._pollTimer = null;
|
|
288
|
-
|
|
292
|
+
const pumpTypeName = sys.board.valueMaps.pumpTypes.getName(this.pump.type);
|
|
293
|
+
const supportsHighAddress = pumpTypeName === 'regalmodbus' || pumpTypeName === 'neptunemodbus';
|
|
294
|
+
if (this.suspendPolling || this.closing || (!supportsHighAddress && this.pump.address > 112)) {
|
|
289
295
|
if (this.suspendPolling) logger.info(`Pump ${this.id} Polling Suspended`);
|
|
290
296
|
if (this.closing) logger.info(`Pump ${this.id} is closing`);
|
|
291
297
|
return;
|
|
@@ -1191,4 +1197,141 @@ export class NixiePumpRegalModbus extends NixiePump {
|
|
|
1191
1197
|
catch (err) { logger.error(`Nixie Pump closeAsync: ${err.message}`); return Promise.reject(err); }
|
|
1192
1198
|
finally { this.suspendPolling = false; }
|
|
1193
1199
|
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export class NixiePumpNeptuneModbus extends NixiePump {
|
|
1203
|
+
private static readonly REG_MOTOR_ON_OFF = 0; // 40001 address offset
|
|
1204
|
+
private static readonly REG_MANUAL_SPEED = 1; // 40002 address offset
|
|
1205
|
+
|
|
1206
|
+
constructor(ncp: INixieControlPanel, pump: Pump) {
|
|
1207
|
+
super(ncp, pump);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
public setTargetSpeed(pumpState: PumpState) {
|
|
1211
|
+
let newSpeed = 0;
|
|
1212
|
+
if (!pumpState.pumpOnDelay) {
|
|
1213
|
+
const circuitConfigs = this.pump.circuits.get();
|
|
1214
|
+
for (let i = 0; i < circuitConfigs.length; i++) {
|
|
1215
|
+
const circuitConfig = circuitConfigs[i];
|
|
1216
|
+
const circ = state.circuits.getInterfaceById(circuitConfig.circuit);
|
|
1217
|
+
if (circ.isOn) newSpeed = Math.max(newSpeed, circuitConfig.speed);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (isNaN(newSpeed)) newSpeed = 0;
|
|
1221
|
+
this._targetSpeed = newSpeed;
|
|
1222
|
+
if (this._targetSpeed !== 0) Math.min(Math.max(this.pump.minSpeed, this._targetSpeed), this.pump.maxSpeed);
|
|
1223
|
+
if (this._targetSpeed !== newSpeed) logger.info(`NCP: Setting Pump ${this.pump.name} to ${newSpeed} RPM.`);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
public async setServiceModeAsync() {
|
|
1227
|
+
this._targetSpeed = 0;
|
|
1228
|
+
await this.setDriveStateAsync(false);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
public async setPumpStateAsync(pstate: PumpState) {
|
|
1232
|
+
this.suspendPolling = true;
|
|
1233
|
+
try {
|
|
1234
|
+
const pt = sys.board.valueMaps.pumpTypes.get(this.pump.type);
|
|
1235
|
+
if (state.mode === 0) {
|
|
1236
|
+
if (!this.closing && this._targetSpeed >= pt.minSpeed && this._targetSpeed <= pt.maxSpeed) {
|
|
1237
|
+
await this.setPumpRPMAsync();
|
|
1238
|
+
}
|
|
1239
|
+
if (!this.closing) await this.setDriveStateAsync();
|
|
1240
|
+
if (!this.closing) await setTimeout(500);
|
|
1241
|
+
if (!this.closing) await this.requestPumpStatusAsync();
|
|
1242
|
+
}
|
|
1243
|
+
return new InterfaceServerResponse(200, 'Success');
|
|
1244
|
+
}
|
|
1245
|
+
catch (err) {
|
|
1246
|
+
logger.error(`Error running pump sequence for ${this.pump.name}: ${err.message}`);
|
|
1247
|
+
return Promise.reject(err);
|
|
1248
|
+
}
|
|
1249
|
+
finally { this.suspendPolling = false; }
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
private toWord(value: number): [number, number] {
|
|
1253
|
+
return [(value >> 8) & 0xFF, value & 0xFF];
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
private async writeSingleRegisterAsync(registerAddr: number, value: number, retries: number = 1) {
|
|
1257
|
+
if (!conn.isPortEnabled(this.pump.portId || 0)) return;
|
|
1258
|
+
const [regHi, regLo] = this.toWord(registerAddr);
|
|
1259
|
+
const [valHi, valLo] = this.toWord(value & 0xFFFF);
|
|
1260
|
+
const out = Outbound.create({
|
|
1261
|
+
portId: this.pump.portId || 0,
|
|
1262
|
+
protocol: Protocol.NeptuneModbus,
|
|
1263
|
+
dest: this.pump.address,
|
|
1264
|
+
action: 0x06,
|
|
1265
|
+
payload: [regHi, regLo, valHi, valLo],
|
|
1266
|
+
retries,
|
|
1267
|
+
response: true,
|
|
1268
|
+
});
|
|
1269
|
+
try {
|
|
1270
|
+
await out.sendAsync();
|
|
1271
|
+
}
|
|
1272
|
+
catch (err) {
|
|
1273
|
+
logger.error(`Error writing Neptune register ${registerAddr} for ${this.pump.name}: ${err.message}`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
private async readInputRegistersAsync(startAddr: number, quantity: number, retries: number = 2) {
|
|
1278
|
+
if (!conn.isPortEnabled(this.pump.portId || 0)) return;
|
|
1279
|
+
const [startHi, startLo] = this.toWord(startAddr);
|
|
1280
|
+
const [qtyHi, qtyLo] = this.toWord(quantity);
|
|
1281
|
+
const out = Outbound.create({
|
|
1282
|
+
portId: this.pump.portId || 0,
|
|
1283
|
+
protocol: Protocol.NeptuneModbus,
|
|
1284
|
+
dest: this.pump.address,
|
|
1285
|
+
action: 0x04,
|
|
1286
|
+
payload: [startHi, startLo, qtyHi, qtyLo],
|
|
1287
|
+
retries,
|
|
1288
|
+
response: true,
|
|
1289
|
+
});
|
|
1290
|
+
NeptuneModbusStateMessage.enqueueReadRequest(this.pump.address, startAddr, quantity);
|
|
1291
|
+
try {
|
|
1292
|
+
await out.sendAsync();
|
|
1293
|
+
}
|
|
1294
|
+
catch (err) {
|
|
1295
|
+
NeptuneModbusStateMessage.clearReadRequests(this.pump.address);
|
|
1296
|
+
logger.error(`Error reading Neptune registers ${startAddr}-${startAddr + quantity - 1} for ${this.pump.name}: ${err.message}`);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
protected async setDriveStateAsync(isRunning: boolean = true) {
|
|
1301
|
+
const shouldRun = isRunning && this._targetSpeed > 0;
|
|
1302
|
+
logger.debug(`NixiePumpNeptuneModbus: setDriveStateAsync ${this.pump.name} ${shouldRun ? 'RUN' : 'STOP'}`);
|
|
1303
|
+
await this.writeSingleRegisterAsync(NixiePumpNeptuneModbus.REG_MOTOR_ON_OFF, shouldRun ? 1 : 0);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
protected async requestPumpStatusAsync() {
|
|
1307
|
+
// Block 30001-30007 (speed/power/fault summary).
|
|
1308
|
+
await this.readInputRegistersAsync(0, 7);
|
|
1309
|
+
// Block 30031-30033 (interface fault state/code).
|
|
1310
|
+
await this.readInputRegistersAsync(30, 3);
|
|
1311
|
+
// Block 30114-30128 (stopped state, line volts, temps, target speed, etc.).
|
|
1312
|
+
await this.readInputRegistersAsync(113, 15);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
protected async setPumpRPMAsync() {
|
|
1316
|
+
logger.debug(`NixiePumpNeptuneModbus: setPumpRPMAsync ${this.pump.name} ${this._targetSpeed}`);
|
|
1317
|
+
await this.writeSingleRegisterAsync(NixiePumpNeptuneModbus.REG_MANUAL_SPEED, Math.round(this._targetSpeed));
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
public async closeAsync() {
|
|
1321
|
+
try {
|
|
1322
|
+
this.suspendPolling = true;
|
|
1323
|
+
logger.info(`Nixie Pump closing ${this.pump.name}.`)
|
|
1324
|
+
if (typeof this._pollTimer !== 'undefined' || this._pollTimer) clearTimeout(this._pollTimer);
|
|
1325
|
+
this._pollTimer = null;
|
|
1326
|
+
this.closing = true;
|
|
1327
|
+
const pumpState = state.pumps.getItemById(this.pump.id);
|
|
1328
|
+
this._targetSpeed = 0;
|
|
1329
|
+
await this.setDriveStateAsync(false);
|
|
1330
|
+
if (typeof this._pollTimer !== 'undefined' || this._pollTimer) clearTimeout(this._pollTimer);
|
|
1331
|
+
this._pollTimer = null;
|
|
1332
|
+
pumpState.emitEquipmentChange();
|
|
1333
|
+
}
|
|
1334
|
+
catch (err) { logger.error(`Nixie Pump closeAsync: ${err.message}`); return Promise.reject(err); }
|
|
1335
|
+
finally { this.suspendPolling = false; }
|
|
1336
|
+
}
|
|
1194
1337
|
}
|