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
@@ -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
- if (now - this.lastConfigRefreshTime < this.CONFIG_REFRESH_DEBOUNCE_MS) {
34
- logger.silly(`v3.004+ ${source}: Skipping config refresh (debounced, last was ${now - this.lastConfigRefreshTime}ms ago)`);
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
- response: Response.create({ action: 164 })
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: When OCP ACKs a Wireless device's Action 168,
66
- * trigger a config refresh. OCP doesn't send Action 228 after Wireless changes,
67
- * so we must detect the ACK and request config ourselves.
68
- * See AGENTS.md for protocol details.
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 processAction168Ack(msg: Inbound): void {
71
- // Only for v3.004+ when OCP (src=16) ACKs a non-njsPC device's 168
72
- if (sys.equipment.isIntellicenterV3 &&
73
- msg.source === 16 && // From OCP
74
- msg.dest !== Message.pluginAddress && // Not to us
75
- msg.dest !== 16 && // Not to OCP itself
76
- msg.payload.length > 0 &&
77
- msg.payload[0] === 168) { // ACKing Action 168
78
- this.triggerConfigRefresh(`ACK Trigger (device ${msg.dest})`);
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
- import { clearTimeout, setTimeout } from 'timers';
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
- // If the body isn't on then we won't communicate with the chem controller. There is no need
238
- // since most of the time these are attached to the filter relay.
239
- if (this.isBodyOn() && !this.closing) {
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}`); return Promise.reject(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
- this.configSent = false;
352
- let out = Outbound.create({
353
- protocol: Protocol.IntelliChem,
354
- source: 16,
355
- dest: this.chem.address,
356
- action: 146,
357
- payload: [],
358
- retries: 3, // We are going to try 4 times.
359
- response: Response.create({ protocol: Protocol.IntelliChem, action: 1 }),
360
- onAbort: () => { }
361
- });
362
- out.insertPayloadBytes(0, 0, 21);
363
- out.setPayloadByte(0, Math.floor((this.chem.ph.setpoint * 100) / 256) || 0);
364
- out.setPayloadByte(1, Math.round((this.chem.ph.setpoint * 100) % 256) || 0);
365
- out.setPayloadByte(2, Math.floor(this.chem.orp.setpoint / 256) || 0);
366
- out.setPayloadByte(3, Math.round(this.chem.orp.setpoint % 256) || 0);
367
- out.setPayloadByte(4, schem.ph.enabled ? schem.ph.tank.level + 1 : 0);
368
- out.setPayloadByte(5, schem.orp.enabled ? schem.orp.tank.level + 1 : 0);
369
- out.setPayloadByte(6, Math.floor(this.chem.calciumHardness / 256) || 0);
370
- out.setPayloadByte(7, Math.round(this.chem.calciumHardness % 256) || 0);
371
- out.setPayloadByte(9, this.chem.cyanuricAcid);
372
- out.setPayloadByte(10, Math.floor(this.chem.alkalinity / 256) || 0);
373
- out.setPayloadByte(12, Math.round(this.chem.alkalinity % 256) || 0);
374
- logger.verbose(`Nixie: ${this.chem.name} sending IntelliChem settings action 146`);
375
- out.sendAsync();
376
- this.configSent = true;
377
- return true;
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
- return Promise.reject(new Error(`NCP: Heater ${hstate.id}-${hstate.name} could not be found to set the state to ${val}.`));
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
- import { EquipmentNotFoundError, InvalidEquipmentDataError, InvalidEquipmentIdError, ParameterOutOfRangeError } from '../../Errors';
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
- if (this.suspendPolling || this.closing || this.pump.address > 112) {
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
  }
@@ -1,5 +1,6 @@
1
1
  services:
2
2
  njspc:
3
+ # Local dev compose: build from current workspace instead of pulling remote image.
3
4
  build:
4
5
  context: .
5
6
  dockerfile: Dockerfile