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,7 +23,7 @@ import { Protocol, Outbound, Inbound, Message, Response } from '../comms/message
23
23
  import { conn } from '../comms/Comms';
24
24
  import { logger } from '../../logger/Logger';
25
25
  import { state, ChlorinatorState, LightGroupState, VirtualCircuitState, ICircuitState, BodyTempState, CircuitGroupState, ICircuitGroupState, ChemControllerState } from '../State';
26
- import { utils } from '../../controller/Constants';
26
+ import { utils, ControllerType } from '../../controller/Constants';
27
27
  import { InvalidEquipmentIdError, InvalidEquipmentDataError, EquipmentNotFoundError, MessageError, InvalidOperationError } from '../Errors';
28
28
  import { ncp } from '../nixie/Nixie';
29
29
  import { Timestamp } from "../Constants"
@@ -340,6 +340,7 @@ export class IntelliCenterBoard extends SystemBoard {
340
340
  const out: Outbound = Outbound.create({
341
341
  dest: 16, // MUST send to OCP (16), not broadcast (15)
342
342
  action: 251,
343
+ scope: 'v3Registration',
343
344
  payload: [
344
345
  Message.pluginAddress, // [0] Device address (33)
345
346
  0, // [1] Reserved
@@ -351,7 +352,8 @@ export class IntelliCenterBoard extends SystemBoard {
351
352
  1, 7, 11 // [19-21] Unknown (copied from wireless remote)
352
353
  ],
353
354
  retries: 3,
354
- response: Response.create({ source: 15, dest: 16, action: 253 })
355
+ // Action 253 comes from OCP (src=16) to broadcast (dest=15).
356
+ response: Response.create({ source: 16, dest: 15, action: 253 })
355
357
  });
356
358
  await out.sendAsync();
357
359
  logger.silly('Device registration request sent, awaiting confirmation via Action 217');
@@ -389,6 +391,9 @@ export class IntelliCenterBoard extends SystemBoard {
389
391
  logger.warn(`checkConfiguration failed (port may not be open yet): ${err.message}`);
390
392
  }
391
393
  }
394
+ public isConfigQueueProcessing(): boolean {
395
+ return this._configQueue._processing;
396
+ }
392
397
  public requestConfiguration(ver: ConfigVersion) {
393
398
  if (this.needsConfigChanges) {
394
399
  logger.info(`Requesting IntelliCenter configuration`);
@@ -419,9 +424,13 @@ export class IntelliCenterBoard extends SystemBoard {
419
424
  const verReq = Outbound.create({
420
425
  dest,
421
426
  action: 228,
427
+ scope: sys.equipment.isIntellicenterV3 ? 'v3VersionSync' : undefined,
422
428
  payload: [0],
423
429
  retries: 3,
424
- response: Response.create({ action: 164 })
430
+ // v3.004+: require the version response (164) to be addressed to us (not to Wireless).
431
+ response: sys.equipment.isIntellicenterV3
432
+ ? Response.create({ dest: Message.pluginAddress, action: 164 })
433
+ : Response.create({ action: 164 })
425
434
  });
426
435
  await verReq.sendAsync();
427
436
  if (sys.equipment.isIntellicenterV3) {
@@ -776,7 +785,7 @@ export class IntelliCenterBoard extends SystemBoard {
776
785
  }
777
786
  }
778
787
  public get commandSourceAddress(): number { return Message.pluginAddress; }
779
- public get commandDestAddress(): number { return 15; }
788
+ public get commandDestAddress(): number { return sys.equipment.isIntellicenterV3 ? 16 : 15; }
780
789
  public static getAckResponse(action: number, source?: number, dest?: number): Response { return Response.create({ source: source, dest: dest || sys.board.commandSourceAddress, action: 1, payload: [action] }); }
781
790
  }
782
791
  class IntelliCenterConfigRequest extends ConfigRequest {
@@ -793,10 +802,53 @@ class IntelliCenterConfigQueue extends ConfigQueue {
793
802
  public _processing: boolean = false;
794
803
  public _newRequest: boolean = false;
795
804
  public _failed: boolean = false;
805
+ private static readonly WATCHDOG_TIMEOUT_MS = 120000;
806
+ private static readonly WATCHDOG_POLL_MS = 5000;
807
+ private _watchdogTimer?: NodeJS.Timeout;
808
+ private _lastProgressMs: number = 0;
809
+ public close() {
810
+ this.stopWatchdog();
811
+ this._processing = false;
812
+ super.close();
813
+ }
814
+ private markProgress(): void {
815
+ if (!this._processing) return;
816
+ this._lastProgressMs = Date.now();
817
+ if (!this._watchdogTimer) {
818
+ this._watchdogTimer = setInterval(() => this.checkWatchdog(), IntelliCenterConfigQueue.WATCHDOG_POLL_MS);
819
+ }
820
+ }
821
+ private stopWatchdog(): void {
822
+ if (this._watchdogTimer) {
823
+ clearInterval(this._watchdogTimer);
824
+ this._watchdogTimer = undefined;
825
+ }
826
+ this._lastProgressMs = 0;
827
+ }
828
+ private checkWatchdog(): void {
829
+ if (!this._processing || this.closed) {
830
+ this.stopWatchdog();
831
+ return;
832
+ }
833
+ const elapsed = Date.now() - this._lastProgressMs;
834
+ if (elapsed < IntelliCenterConfigQueue.WATCHDOG_TIMEOUT_MS) return;
835
+ logger.warn(`Config queue watchdog timed out after ${elapsed}ms; forcing recovery (${this.remainingItems} items remaining)`);
836
+ this.queue.length = 0;
837
+ this.curr = null;
838
+ this.totalItems = 0;
839
+ this._processing = false;
840
+ this._failed = false;
841
+ this._newRequest = false;
842
+ this.stopWatchdog();
843
+ state.status = 1;
844
+ state.emitControllerChange();
845
+ setTimeout(() => { sys.board.checkConfiguration(); }, 250);
846
+ }
796
847
  public processNext(msg?: Outbound) {
797
848
  if (this.closed) return;
798
849
  let self = this;
799
850
  if (typeof msg !== 'undefined' && msg !== null) {
851
+ this.markProgress();
800
852
  if (!msg.failed) {
801
853
  // Remove all references to future items. We got it so we don't need it again.
802
854
  this.removeItem(msg.payload[0], msg.payload[1]);
@@ -847,14 +899,16 @@ class IntelliCenterConfigQueue extends ConfigQueue {
847
899
  let out = Outbound.create({
848
900
  // v1: broadcast (15). v3: wireless/ICP unicasts to OCP (16).
849
901
  dest,
902
+ scope: sys.equipment.isIntellicenterV3 ? 'v3ConfigQueue' : undefined,
850
903
  action: 222, payload: [this.curr.category, itm], retries: 5,
851
904
  // v3.004+: some config requests can yield an Action 30 with an empty payload (length=0).
852
905
  // Those packets still indicate "done" for the requested item, but cannot be matched by payload prefix.
853
906
  response: sys.equipment.isIntellicenterV3
854
- ? Response.create({ dest: -1, action: 30 })
907
+ ? Response.create({ dest: Message.pluginAddress, action: 30 })
855
908
  : Response.create({ dest: -1, action: 30, payload: [this.curr.category, itm] })
856
909
  });
857
910
  logger.verbose(`Requesting config for: ${ConfigCategories[this.curr.category]} - Item: ${itm}`);
911
+ this.markProgress();
858
912
  // setTimeout(() => { conn.queueSendMessage(out) }, 50);
859
913
  out.sendAsync()
860
914
  .then(() => {
@@ -872,6 +926,7 @@ class IntelliCenterConfigQueue extends ConfigQueue {
872
926
  state.status = 1;
873
927
  this.curr = null;
874
928
  this._processing = false;
929
+ this.stopWatchdog();
875
930
  if (this._failed) setTimeout(() => { sys.checkConfiguration(); }, 100);
876
931
  logger.info(`Configuration Complete`);
877
932
  sys.board.heaters.updateHeaterServices();
@@ -903,14 +958,34 @@ class IntelliCenterConfigQueue extends ConfigQueue {
903
958
  console.log('WE ARE ALREADY PROCESSING CHANGES...')
904
959
  return;
905
960
  }
961
+ // IMPORTANT: Only enter "processing" mode if there are actual version changes.
962
+ // If we set `_processing=true` and then return early, the UI can get stuck showing a partial
963
+ // percent (e.g., 87%) because no further progress/completion events will be emitted.
964
+ if (!curr.hasChanges(ver)) {
965
+ // Ensure controller status returns to ready and queue state is not wedged.
966
+ this._processing = false;
967
+ this._failed = false;
968
+ this._newRequest = false;
969
+ this.stopWatchdog();
970
+ state.status = 1;
971
+ state.emitControllerChange();
972
+ return;
973
+ }
974
+
975
+ // New run: reset per-run accounting so percent reflects ONLY this run.
976
+ // Do NOT call `ConfigQueue.reset()` here because it also mutates `closed`.
977
+ // We only want to reset per-run counters/queues.
978
+ this.queue.length = 0;
979
+ this.curr = null;
980
+ this.totalItems = 0;
906
981
  this._processing = true;
907
982
  this._failed = false;
983
+ this.markProgress();
908
984
  let self = this;
909
- if (!curr.hasChanges(ver)) return;
910
985
  sys.configVersion.lastUpdated = new Date();
911
986
  // Tell the system we are loading.
912
987
  state.status = sys.board.valueMaps.controllerStatus.transform(2, 0);
913
- this.maybeQueueItems(curr.equipment, ver.equipment, ConfigCategories.equipment, [0, 1, 2, 3]);
988
+ this.maybeQueueItems(curr.equipment, ver.equipment, ConfigCategories.equipment, [0, 1, 2, 3, 12, 13, 14, 15]);
914
989
  this.maybeQueueItems(curr.options, ver.options, ConfigCategories.options, [0, 1]);
915
990
  if (this.compareVersions(curr.circuits, ver.circuits)) {
916
991
  let req = new IntelliCenterConfigRequest(ConfigCategories.circuits, ver.circuits, [0, 1, 2],
@@ -1028,6 +1103,7 @@ class IntelliCenterConfigQueue extends ConfigQueue {
1028
1103
  if (this.remainingItems > 0) setTimeout(() => { self.processNext(); }, 50);
1029
1104
  else {
1030
1105
  this._processing = false;
1106
+ this.stopWatchdog();
1031
1107
  if (this._newRequest) {
1032
1108
  this._newRequest = false;
1033
1109
  setTimeout(() => { sys.board.checkConfiguration(); }, 250);
@@ -1112,6 +1188,21 @@ class IntelliCenterSystemCommands extends SystemCommands {
1112
1188
  }
1113
1189
  public async setOptionsAsync(obj?: any): Promise<Options> {
1114
1190
  let fnToByte = function (num) { return num < 0 ? Math.abs(num) | 0x80 : Math.abs(num) || 0; }
1191
+ const isIntellicenterV3 = (sys.controllerType === ControllerType.IntelliCenter && sys.equipment.isIntellicenterV3);
1192
+ const encodeFreezeOverride = (minutes: number): number => {
1193
+ if (isNaN(minutes)) return 0;
1194
+ // v3.008 appears to encode Frz Override as compact steps: 30 + (raw * 60).
1195
+ if (minutes <= 30) return 0;
1196
+ return Math.max(0, Math.min(3, Math.round((minutes - 30) / 60)));
1197
+ };
1198
+ const freezeCycleTime = parseInt((sys.general.options.freezeCycleTime || 15).toString(), 10) || 15;
1199
+ const freezeOverrideRaw = encodeFreezeOverride(parseInt((sys.general.options.freezeOverride || 30).toString(), 10) || 30);
1200
+ const pool = sys.bodies.getItemById(1, false);
1201
+ const spa = sys.bodies.getItemById(2, false);
1202
+ const manualPriorityPayloadIndex = isIntellicenterV3 ? 28 : 39;
1203
+ const manualHeatPayloadIndex = isIntellicenterV3 ? 31 : 40;
1204
+ const pumpDelayPayloadIndex = isIntellicenterV3 ? 29 : 30;
1205
+ const cooldownDelayPayloadIndex = isIntellicenterV3 ? 30 : 31;
1115
1206
 
1116
1207
  let payload = [0, 0, 0,
1117
1208
  fnToByte(sys.equipment.tempSensors.getCalibration('water2')),
@@ -1130,20 +1221,36 @@ class IntelliCenterSystemCommands extends SystemCommands {
1130
1221
  0, 0,
1131
1222
  sys.general.options.clockSource === 'internet' ? 1 : 0, // 17
1132
1223
  3, 0, 0,
1133
- sys.bodies.getItemById(1, false).setPoint || 100, // 21
1134
- sys.bodies.getItemById(3, false).setPoint || 100,
1135
- sys.bodies.getItemById(2, false).setPoint || 100,
1136
- sys.bodies.getItemById(4, false).setPoint || 100,
1137
- sys.bodies.getItemById(1, false).heatMode || 0,
1138
- sys.bodies.getItemById(2, false).heatMode || 0,
1139
- sys.bodies.getItemById(3, false).heatMode || 0,
1140
- sys.bodies.getItemById(4, false).heatMode || 0,
1141
- 15,
1142
- sys.general.options.pumpDelay ? 1 : 0, // 30
1143
- sys.general.options.cooldownDelay ? 1 : 0,
1144
- 0, 0, 100, 0, 0, 0, 0,
1145
- sys.general.options.manualPriority ? 1 : 0, // 39
1146
- sys.general.options.manualHeat ? 1 : 0];
1224
+ // For v3.008+, Action 168 full options blocks place pool/spa setpoints at [20..23]
1225
+ // and modes at [24..25]. Keep legacy layout for pre-v3 controllers.
1226
+ ...(isIntellicenterV3
1227
+ ? [
1228
+ pool.setPoint || 100, pool.coolSetpoint || (pool.setPoint || 100),
1229
+ spa.setPoint || 100, spa.coolSetpoint || (spa.setPoint || 100),
1230
+ pool.heatMode || 0, spa.heatMode || 0,
1231
+ freezeCycleTime, freezeOverrideRaw,
1232
+ sys.general.options.manualPriority ? 1 : 0,
1233
+ sys.general.options.pumpDelay ? 1 : 0,
1234
+ sys.general.options.cooldownDelay ? 1 : 0,
1235
+ sys.general.options.manualHeat ? 1 : 0,
1236
+ 0, 0, 0, 0, 0, 0, 0, 0, 0
1237
+ ]
1238
+ : [
1239
+ sys.bodies.getItemById(1, false).setPoint || 100, // 21
1240
+ sys.bodies.getItemById(3, false).setPoint || 100,
1241
+ sys.bodies.getItemById(2, false).setPoint || 100,
1242
+ sys.bodies.getItemById(4, false).setPoint || 100,
1243
+ sys.bodies.getItemById(1, false).heatMode || 0,
1244
+ sys.bodies.getItemById(2, false).heatMode || 0,
1245
+ sys.bodies.getItemById(3, false).heatMode || 0,
1246
+ sys.bodies.getItemById(4, false).heatMode || 0,
1247
+ 15,
1248
+ sys.general.options.pumpDelay ? 1 : 0, // 30
1249
+ sys.general.options.cooldownDelay ? 1 : 0,
1250
+ 0, 0, 100, 0, 0, 0, 0,
1251
+ sys.general.options.manualPriority ? 1 : 0, // 39
1252
+ sys.general.options.manualHeat ? 1 : 0
1253
+ ])];
1147
1254
  let arr = [];
1148
1255
  try {
1149
1256
  if (typeof obj.waterTempAdj1 != 'undefined' && obj.waterTempAdj1 !== sys.equipment.tempSensors.getCalibration('water1')) {
@@ -1257,12 +1364,13 @@ class IntelliCenterSystemCommands extends SystemCommands {
1257
1364
  }
1258
1365
  if ((typeof obj.clockMode !== 'undefined' && obj.clockMode !== sys.general.options.clockMode) ||
1259
1366
  (typeof obj.adjustDST !== 'undefined' && obj.adjustDST !== sys.general.options.adjustDST)) {
1260
- let byte = 0x30; // These bits are always set.
1367
+ const effectiveClockSource = (typeof obj.clockSource === 'string') ? obj.clockSource : sys.general.options.clockSource;
1368
+ let byte = 0x10 | (effectiveClockSource === 'internet' ? 0x20 : 0x00);
1261
1369
  if (typeof obj.clockMode === 'undefined') byte |= sys.general.options.clockMode === 24 ? 0x40 : 0x00;
1262
1370
  else byte |= obj.clockMode === 24 ? 0x40 : 0x00;
1263
1371
  if (typeof obj.adjustDST === 'undefined') byte |= sys.general.options.adjustDST ? 0x80 : 0x00;
1264
1372
  else byte |= obj.adjustDST ? 0x80 : 0x00;
1265
- payload[2] = 11;
1373
+ payload[2] = isIntellicenterV3 ? 0 : 11;
1266
1374
  payload[14] = byte;
1267
1375
  let out = Outbound.create({
1268
1376
  action: 168,
@@ -1272,11 +1380,11 @@ class IntelliCenterSystemCommands extends SystemCommands {
1272
1380
  });
1273
1381
  await out.sendAsync();
1274
1382
  if (typeof obj.clockMode !== 'undefined') sys.general.options.clockMode = obj.clockMode === 24 ? 24 : 12;
1275
- if (typeof obj.adjustDST !== 'undefined' || sys.general.options.clockSource !== 'server') sys.general.options.adjustDST = obj.adjustDST ? true : false;
1383
+ if (typeof obj.adjustDST !== 'undefined') sys.general.options.adjustDST = obj.adjustDST ? true : false;
1276
1384
  }
1277
1385
 
1278
1386
  if (typeof obj.clockSource != 'undefined' && obj.clockSource !== sys.general.options.clockSource) {
1279
- payload[2] = 11;
1387
+ payload[2] = isIntellicenterV3 ? 0 : 11;
1280
1388
  payload[17] = obj.clockSource === 'internet' ? 0x01 : 0x00;
1281
1389
  let out = Outbound.create({
1282
1390
  action: 168,
@@ -1289,9 +1397,41 @@ class IntelliCenterSystemCommands extends SystemCommands {
1289
1397
  sys.general.options.clockSource = obj.clockSource;
1290
1398
  sys.board.system.setTZ();
1291
1399
  }
1400
+ if (isIntellicenterV3) {
1401
+ const requestedFreezeCycleTime = typeof obj.freezeCycleTime !== 'undefined'
1402
+ ? parseInt(obj.freezeCycleTime, 10)
1403
+ : (typeof obj.frzCycleTime !== 'undefined' ? parseInt(obj.frzCycleTime, 10) : undefined);
1404
+ if (typeof requestedFreezeCycleTime !== 'undefined' && !isNaN(requestedFreezeCycleTime) && requestedFreezeCycleTime !== sys.general.options.freezeCycleTime) {
1405
+ payload[2] = isIntellicenterV3 ? 0 : 26;
1406
+ payload[26] = Math.max(0, Math.min(255, requestedFreezeCycleTime));
1407
+ let out = Outbound.create({
1408
+ action: 168,
1409
+ retries: 5,
1410
+ payload: payload,
1411
+ response: IntelliCenterBoard.getAckResponse(168)
1412
+ });
1413
+ await out.sendAsync();
1414
+ sys.general.options.freezeCycleTime = payload[26];
1415
+ }
1416
+ const requestedFreezeOverride = typeof obj.freezeOverride !== 'undefined'
1417
+ ? parseInt(obj.freezeOverride, 10)
1418
+ : (typeof obj.frzOverride !== 'undefined' ? parseInt(obj.frzOverride, 10) : undefined);
1419
+ if (typeof requestedFreezeOverride !== 'undefined' && !isNaN(requestedFreezeOverride) && requestedFreezeOverride !== sys.general.options.freezeOverride) {
1420
+ payload[2] = isIntellicenterV3 ? 0 : 27;
1421
+ payload[27] = encodeFreezeOverride(requestedFreezeOverride);
1422
+ let out = Outbound.create({
1423
+ action: 168,
1424
+ retries: 5,
1425
+ payload: payload,
1426
+ response: IntelliCenterBoard.getAckResponse(168)
1427
+ });
1428
+ await out.sendAsync();
1429
+ sys.general.options.freezeOverride = requestedFreezeOverride;
1430
+ }
1431
+ }
1292
1432
  if (typeof obj.pumpDelay !== 'undefined' && obj.pumpDelay !== sys.general.options.pumpDelay) {
1293
- payload[2] = 27;
1294
- payload[30] = obj.pumpDelay ? 0x01 : 0x00;
1433
+ payload[2] = isIntellicenterV3 ? 0 : 27;
1434
+ payload[pumpDelayPayloadIndex] = obj.pumpDelay ? 0x01 : 0x00;
1295
1435
  let out = Outbound.create({
1296
1436
  action: 168,
1297
1437
  retries: 5,
@@ -1302,8 +1442,8 @@ class IntelliCenterSystemCommands extends SystemCommands {
1302
1442
  sys.general.options.pumpDelay = obj.pumpDelay ? true : false;
1303
1443
  }
1304
1444
  if (typeof obj.cooldownDelay !== 'undefined' && obj.cooldownDelay !== sys.general.options.cooldownDelay) {
1305
- payload[2] = 28;
1306
- payload[31] = obj.cooldownDelay ? 0x01 : 0x00;
1445
+ payload[2] = isIntellicenterV3 ? 0 : 28;
1446
+ payload[cooldownDelayPayloadIndex] = obj.cooldownDelay ? 0x01 : 0x00;
1307
1447
  let out = Outbound.create({
1308
1448
  action: 168,
1309
1449
  retries: 5,
@@ -1314,8 +1454,8 @@ class IntelliCenterSystemCommands extends SystemCommands {
1314
1454
  sys.general.options.cooldownDelay = obj.cooldownDelay ? true : false;
1315
1455
  }
1316
1456
  if (typeof obj.manualPriority !== 'undefined' && obj.manualPriority !== sys.general.options.manualPriority) {
1317
- payload[2] = 36;
1318
- payload[39] = obj.manualPriority ? 0x01 : 0x00;
1457
+ payload[2] = isIntellicenterV3 ? 0 : 36;
1458
+ payload[manualPriorityPayloadIndex] = obj.manualPriority ? 0x01 : 0x00;
1319
1459
  let out = Outbound.create({
1320
1460
  action: 168,
1321
1461
  retries: 5,
@@ -1326,8 +1466,8 @@ class IntelliCenterSystemCommands extends SystemCommands {
1326
1466
  sys.general.options.manualPriority = obj.manualPriority ? true : false;
1327
1467
  }
1328
1468
  if (typeof obj.manualHeat !== 'undefined' && obj.manualHeat !== sys.general.options.manualHeat) {
1329
- payload[2] = 37;
1330
- payload[40] = obj.manualHeat ? 0x01 : 0x00;
1469
+ payload[2] = isIntellicenterV3 ? 0 : 37;
1470
+ payload[manualHeatPayloadIndex] = obj.manualHeat ? 0x01 : 0x00;
1331
1471
  let out = Outbound.create({
1332
1472
  action: 168,
1333
1473
  retries: 5,
@@ -1406,12 +1546,12 @@ class IntelliCenterSystemCommands extends SystemCommands {
1406
1546
  action: 168,
1407
1547
  retries: 5,
1408
1548
  payload: [12, 0, 11,
1409
- Math.floor(lat / 256),
1410
- lat - Math.floor(lat / 256)],
1549
+ lat % 256,
1550
+ Math.floor(lat / 256)],
1411
1551
  response: IntelliCenterBoard.getAckResponse(168)
1412
1552
  });
1413
1553
  await out.sendAsync();
1414
- sys.general.location.longitude = lat / 100;
1554
+ sys.general.location.latitude = Math.round(obj.latitude * 100) / 100;
1415
1555
  }
1416
1556
  if (typeof obj.longitude === 'number' && obj.longitude !== sys.general.location.longitude) {
1417
1557
  let lon = Math.round(Math.abs(obj.longitude) * 100);
@@ -1419,12 +1559,12 @@ class IntelliCenterSystemCommands extends SystemCommands {
1419
1559
  action: 168,
1420
1560
  retries: 5,
1421
1561
  payload: [12, 0, 12,
1422
- Math.floor(lon / 256),
1423
- lon - Math.floor(lon / 256)],
1562
+ lon % 256,
1563
+ Math.floor(lon / 256)],
1424
1564
  response: IntelliCenterBoard.getAckResponse(168)
1425
1565
  });
1426
1566
  await out.sendAsync();
1427
- sys.general.location.longitude = -(lon / 100);
1567
+ sys.general.location.longitude = Math.round(obj.longitude * 100) / 100;
1428
1568
  }
1429
1569
  if (typeof obj.timeZone === 'number' && obj.timeZone !== sys.general.location.timeZone) {
1430
1570
  let out = Outbound.create({
@@ -1510,47 +1650,6 @@ class IntelliCenterCircuitCommands extends CircuitCommands {
1510
1650
  // Key: circuit/feature ID, Value: intended state (true=on, false=off)
1511
1651
  private pendingStates: Map<number, boolean> = new Map();
1512
1652
 
1513
- private findActiveTargetIdOwners(targetId: number, excludeCircuitId?: number): ICircuit[] {
1514
- const owners: ICircuit[] = [];
1515
- if (typeof targetId !== 'number' || targetId <= 0) return owners;
1516
- for (let i = 0; i < sys.circuits.length; i++) {
1517
- const circ = sys.circuits.getItemByIndex(i);
1518
- if (!circ || !circ.isActive) continue;
1519
- if (typeof excludeCircuitId === 'number' && circ.id === excludeCircuitId) continue;
1520
- // targetId may be undefined if not learned yet
1521
- if (typeof (circ as any).targetId === 'number' && (circ as any).targetId === targetId) owners.push(circ);
1522
- }
1523
- return owners;
1524
- }
1525
-
1526
- public seedKnownV3TargetIds(): void {
1527
- if (!sys.equipment.isIntellicenterV3) return;
1528
- // Known fixed body circuits on IntelliCenter:
1529
- // - Circuit ID 1 = Spa
1530
- // - Circuit ID 6 = Pool
1531
- //
1532
- // We seed these as defaults ONLY when missing, and we never overwrite an existing learned value.
1533
- // This also has a safety benefit: it prevents other circuits from accidentally learning/reusing
1534
- // the Spa/Pool targetIds.
1535
- // Additional observed mapping (NOT guaranteed across all installations):
1536
- // - Circuit ID 2 targetId observed as 0xC490 in captures. We seed it as a best-effort default
1537
- // for users without a Wireless/indoor panel, but it will be overridden when we learn the real
1538
- // mapping from the bus (and cleared quickly if readback indicates it’s wrong).
1539
- const seeds: Array<{ circuitId: number, targetId: number }> = [
1540
- { circuitId: 1, targetId: 0xA8ED }, // 168,237
1541
- { circuitId: 6, targetId: 0x6CE1 }, // 108,225
1542
- { circuitId: 2, targetId: 0xC490 } // 196,144
1543
- ];
1544
- for (const s of seeds) {
1545
- const circ = sys.circuits.getItemById(s.circuitId, false, { isActive: false });
1546
- if (!circ || circ.isActive === false) continue;
1547
- if (typeof (circ as any).targetId === 'number' && (circ as any).targetId > 0) continue;
1548
- const owners = this.findActiveTargetIdOwners(s.targetId, s.circuitId);
1549
- if (owners.length > 0) continue; // don't introduce duplicates
1550
- (circ as any).targetId = s.targetId;
1551
- logger.debug(`v3.004+ seedKnownV3TargetIds: Seeded circuitId=${s.circuitId} (index=${s.circuitId - 1}) with targetId=${s.targetId}`);
1552
- }
1553
- }
1554
1653
 
1555
1654
  // Add a pending state change (called before sending command)
1556
1655
  public addPendingState(id: number, isOn: boolean): void {
@@ -1600,6 +1699,7 @@ class IntelliCenterCircuitCommands extends CircuitCommands {
1600
1699
  let eggHrs = Math.floor(eggTimer / 60);
1601
1700
  let eggMins = eggTimer - (eggHrs * 60);
1602
1701
  let type = typeof data.type !== 'undefined' ? parseInt(data.type, 10) : circuit.type;
1702
+ this.assertSinglePoolSpaType(id, type);
1603
1703
  let theme = typeof data.lightingTheme !== 'undefined' ? data.lightingTheme : circuit.lightingTheme;
1604
1704
  if (circuit.type === 9) theme = typeof data.level !== 'undefined' ? data.level : circuit.level;
1605
1705
  if (typeof theme === 'undefined') theme = 0;
@@ -2200,6 +2300,7 @@ class IntelliCenterCircuitCommands extends CircuitCommands {
2200
2300
  let out = Outbound.create({
2201
2301
  dest,
2202
2302
  action: 222,
2303
+ scope: sys.equipment.isIntellicenterV3 ? 'v3CommandReadback' : undefined,
2203
2304
  retries: 3,
2204
2305
  payload: payload,
2205
2306
  response: Response.create({ dest: -1, action: 30, payload: payload })
@@ -2210,6 +2311,12 @@ class IntelliCenterCircuitCommands extends CircuitCommands {
2210
2311
 
2211
2312
  }
2212
2313
  public async setCircuitStateAsync(id: number, val: boolean, ignoreDelays?: boolean): Promise<ICircuitState> {
2314
+ // v3.004+ features: dashPanel (and other callers) may route feature toggles through the "circuit" path.
2315
+ // IntelliCenter features live in a different Action 184 channel (0xE89D), so delegate feature IDs here.
2316
+ if (sys.equipment.isIntellicenterV3 && sys.board.equipmentIds.features.isInRange(id)) {
2317
+ logger.info(`v3.004+ setCircuitStateAsync: ID ${id} is a feature; delegating to setFeatureStateAsync -> ${val ? 'ON' : 'OFF'}`);
2318
+ return await this.board.features.setFeatureStateAsync(id, val, ignoreDelays);
2319
+ }
2213
2320
  let c = sys.circuits.getInterfaceById(id);
2214
2321
  if (c.master !== 0) return await super.setCircuitStateAsync(id, val);
2215
2322
  // As of 1.047 there is a sequence to this.
@@ -2256,42 +2363,41 @@ class IntelliCenterCircuitCommands extends CircuitCommands {
2256
2363
  return circ;
2257
2364
  }
2258
2365
 
2259
- // v3.004+: Use Action 184 if we have a learned targetId for this circuit.
2260
- // Action 184 is the native circuit control message that the Wireless remote uses.
2261
- // OCP accepts this format and doesn't revert the state like it does with Action 168.
2262
- const circuit = sys.circuits.getItemById(id, false);
2263
- if (sys.equipment.isIntellicenterV3 && circuit && typeof circuit.targetId === 'number' && circuit.targetId > 0) {
2264
- // Safety: A targetId must be unique per circuit. If duplicates exist, Action 184 could toggle the wrong circuit.
2265
- const dupOwners = this.findActiveTargetIdOwners(circuit.targetId, id);
2266
- if (dupOwners.length > 0) {
2267
- logger.error(
2268
- `v3.004+ setCircuitStateAsync: Circuit ${id} (${circuit.name || 'unnamed'}) has duplicate targetId ${circuit.targetId} also used by ` +
2269
- dupOwners.map(o => `${o.id}(${o.name || 'unnamed'})`).join(', ') +
2270
- `. Clearing targetId and falling back to Action 168.`
2271
- );
2272
- circuit.targetId = 0;
2273
- } else {
2274
- logger.info(`v3.004+ setCircuitStateAsync: Using Action 184 with targetId ${circuit.targetId} for circuit ${id} (${circuit.name || 'unnamed'})`);
2275
- let out = this.createAction184Message(circuit.targetId, val);
2276
- out.dest = 16; // Send to OCP
2277
- out.scope = `circuitState${id}`;
2278
- out.retries = 5;
2279
- out.response = IntelliCenterBoard.getAckResponse(184);
2280
- await out.sendAsync();
2281
- // Request updated config to confirm state change
2282
- await this.getConfigAsync([15, 0]);
2283
- let circ = state.circuits.getInterfaceById(id);
2284
- // If readback doesn't match, assume this targetId is incorrect (or rejected) and clear it to prevent repeats.
2285
- if (typeof circ?.isOn === 'boolean' && circ.isOn !== val) {
2286
- logger.warn(
2287
- `v3.004+ setCircuitStateAsync: Action 184 readback mismatch for circuit ${id} (${circuit.name || 'unnamed'}). ` +
2288
- `Requested ${val ? 'ON' : 'OFF'} but OCP reports ${circ.isOn ? 'ON' : 'OFF'}. Clearing targetId ${circuit.targetId}.`
2289
- );
2290
- circuit.targetId = 0;
2291
- }
2292
- state.emitEquipmentChanges();
2293
- return circ;
2294
- }
2366
+ // v3.004+ non-body circuits: Use indexed Action 184 (Wireless-style)
2367
+ if (sys.equipment.isIntellicenterV3) {
2368
+ const circuit = sys.circuits.getItemById(id, false);
2369
+ logger.info(`v3.004+ setCircuitStateAsync: Using indexed Action 184 for circuit ${id} (${circuit?.name || 'unnamed'}) -> ${val ? 'ON' : 'OFF'}`);
2370
+ /**
2371
+ * v3.004+ Indexed Circuit Control (Wireless-style).
2372
+ * Action 184 is the native circuit control message used by the Wireless remote.
2373
+ *
2374
+ * Payload structure (10 bytes):
2375
+ * Bytes 0-1: Channel (0x688F for circuits)
2376
+ * Byte 2: Index (circuitId - 1 or featureId - 1)
2377
+ * Byte 3: Format (255 = command mode)
2378
+ * Bytes 4-5: Target (0xA8ED = control primitive)
2379
+ * Byte 6: State (0=OFF, 1=ON)
2380
+ * Bytes 7-9: Reserved (0,0,0)
2381
+ */
2382
+ const idx = Math.max(0, Math.min(255, (id | 0) - 1));
2383
+ const out = Outbound.createMessage(184, [
2384
+ 104, 143, // Channel 0x688F (circuits)
2385
+ idx, // Index (circuitId - 1)
2386
+ 255, // Format (command)
2387
+ 168, 237, // Target 0xA8ED (control primitive)
2388
+ val ? 1 : 0, // State
2389
+ 0, 0, 0
2390
+ ], 3);
2391
+ out.dest = 16; // Send to OCP
2392
+ out.scope = `circuitState${id}`;
2393
+ out.retries = 5;
2394
+ out.response = IntelliCenterBoard.getAckResponse(184);
2395
+ await out.sendAsync();
2396
+ // Request updated config to confirm state change
2397
+ await this.getConfigAsync([15, 0]);
2398
+ let circ = state.circuits.getInterfaceById(id);
2399
+ state.emitEquipmentChanges();
2400
+ return circ;
2295
2401
  }
2296
2402
 
2297
2403
  // v1.x or v3.004+ without known targetId: Use Action 168 (original method)
@@ -2672,37 +2778,6 @@ class IntelliCenterCircuitCommands extends CircuitCommands {
2672
2778
  }
2673
2779
  return out;
2674
2780
  }
2675
- /**
2676
- * Creates an Action 184 message for v3.004+ IntelliCenter circuit control.
2677
- * Action 184 is the native circuit control message used by the Wireless remote.
2678
- *
2679
- * Payload structure (10 bytes):
2680
- * Bytes 0-1: Channel ID (104,143 = 0x688F = default channel)
2681
- * Byte 2: Sequence number (0)
2682
- * Byte 3: Format (255 = command mode)
2683
- * Bytes 4-5: Target ID (circuit's unique identifier, learned from OCP broadcasts)
2684
- * Byte 6: State (0=OFF, 1=ON)
2685
- * Bytes 7-9: Reserved (0,0,0)
2686
- *
2687
- * @param targetId The circuit's unique Target ID (hi*256 + lo)
2688
- * @param isOn True to turn circuit ON, false for OFF
2689
- * @returns Outbound message ready to send
2690
- */
2691
- public createAction184Message(targetId: number, isOn: boolean): Outbound {
2692
- const targetIdHi = Math.floor(targetId / 256);
2693
- const targetIdLo = targetId % 256;
2694
- // Default channel 104,143 (0x688F), seq=0, format=255 (command)
2695
- let out = Outbound.createMessage(184, [
2696
- 104, 143, // Channel ID (default)
2697
- 0, // Sequence number
2698
- 255, // Format (command mode)
2699
- targetIdHi, // Target ID high byte
2700
- targetIdLo, // Target ID low byte
2701
- isOn ? 1 : 0, // State (1=ON, 0=OFF)
2702
- 0, 0, 0 // Reserved
2703
- ], 3);
2704
- return out;
2705
- }
2706
2781
 
2707
2782
  public async setDimmerLevelAsync(id: number, level: number): Promise<ICircuitState> {
2708
2783
  let circuit = sys.circuits.getItemById(id);
@@ -2729,14 +2804,84 @@ class IntelliCenterCircuitCommands extends CircuitCommands {
2729
2804
  catch (err) { return Promise.reject(err); }
2730
2805
  }
2731
2806
  public async toggleCircuitStateAsync(id: number): Promise<ICircuitState> {
2807
+ // v3.004+ features: dashPanel may attempt to toggle features via the circuit endpoint.
2808
+ if (sys.equipment.isIntellicenterV3 && sys.board.equipmentIds.features.isInRange(id)) {
2809
+ return await this.board.features.toggleFeatureStateAsync(id);
2810
+ }
2732
2811
  let circ = state.circuits.getInterfaceById(id);
2733
2812
  return sys.board.circuits.setCircuitStateAsync(id, !circ.isOn);
2734
2813
  }
2735
2814
  }
2736
2815
  class IntelliCenterFeatureCommands extends FeatureCommands {
2737
2816
  declare board: IntelliCenterBoard;
2738
- public async setFeatureStateAsync(id, val): Promise<ICircuitState> { return sys.board.circuits.setCircuitStateAsync(id, val); }
2739
- public async toggleFeatureStateAsync(id): Promise<ICircuitState> { return sys.board.circuits.toggleCircuitStateAsync(id); }
2817
+
2818
+ private async getConfigAsync(payload: number[]): Promise<boolean> {
2819
+ const dest = sys.equipment.isIntellicenterV3 ? 16 : 15;
2820
+ let out = Outbound.create({
2821
+ dest,
2822
+ action: 222,
2823
+ scope: sys.equipment.isIntellicenterV3 ? 'v3CommandReadback' : undefined,
2824
+ retries: 3,
2825
+ payload: payload,
2826
+ response: Response.create({ dest: -1, action: 30, payload: payload })
2827
+ });
2828
+ await out.sendAsync();
2829
+ // Do NOT ACK(30). Wireless captures show config succeeds without ACK(30), and v1 queue avoids ACK(30).
2830
+ return true;
2831
+ }
2832
+
2833
+ public async setFeatureStateAsync(id: number, val: boolean, ignoreDelays?: boolean): Promise<ICircuitState> {
2834
+ // v3.004+: Features are controlled via Action 184 channel 0xE89D (232,157), not the circuits channel.
2835
+ if (sys.equipment.isIntellicenterV3) {
2836
+ if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid feature id: ${id}`, id, 'Feature'));
2837
+ if (!sys.board.equipmentIds.features.isInRange(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid feature id: ${id}`, id, 'Feature'));
2838
+
2839
+ const feature = sys.features.getItemById(id, false, { isActive: false });
2840
+ logger.info(`v3.004+ setFeatureStateAsync: Using indexed Action 184 for feature ${id} (${feature?.name || 'unnamed'}) -> ${val ? 'ON' : 'OFF'}`);
2841
+
2842
+ /**
2843
+ * v3.004+ Indexed Feature Control (Wireless-style).
2844
+ * Action 184 is the native feature control message used by the Wireless remote (channel 0xE89D).
2845
+ *
2846
+ * Payload structure (10 bytes):
2847
+ * Bytes 0-1: Channel (0xE89D for features)
2848
+ * Byte 2: Index (featureId - 1)
2849
+ * Byte 3: Format/mode (observed 0 in replays 132/138 feature toggles)
2850
+ * Bytes 4-5: Target (0xA8ED = control primitive)
2851
+ * Byte 6: State (0=OFF, 1=ON)
2852
+ * Bytes 7-9: Reserved (0,0,0)
2853
+ */
2854
+ const idx = Math.max(0, Math.min(255, (id | 0) - 1));
2855
+ const out = Outbound.createMessage(184, [
2856
+ 232, 157, // Channel 0xE89D (features)
2857
+ idx, // Index (featureId - 1)
2858
+ 0, // Format/mode (observed)
2859
+ 168, 237, // Target 0xA8ED (control primitive)
2860
+ val ? 1 : 0, // State
2861
+ 0, 0, 0
2862
+ ], 3);
2863
+ out.dest = 16; // Send to OCP
2864
+ out.scope = `featureState${id}`;
2865
+ out.retries = 5;
2866
+ out.response = IntelliCenterBoard.getAckResponse(184);
2867
+ await out.sendAsync();
2868
+
2869
+ // Request updated system state to confirm feature change (authoritative source for v3 features).
2870
+ await this.getConfigAsync([15, 0]);
2871
+
2872
+ const fstate = state.features.getItemById(id, true);
2873
+ state.emitEquipmentChanges();
2874
+ return fstate;
2875
+ }
2876
+
2877
+ // Legacy behavior (v1.x): delegate to circuit state setter.
2878
+ return sys.board.circuits.setCircuitStateAsync(id, val);
2879
+ }
2880
+
2881
+ public async toggleFeatureStateAsync(id: number): Promise<ICircuitState> {
2882
+ const feat = state.features.getItemById(id);
2883
+ return this.setFeatureStateAsync(id, !(feat.isOn || false));
2884
+ }
2740
2885
  public syncGroupStates() { } // Do nothing and let IntelliCenter do it.
2741
2886
  public async setFeatureAsync(data: any): Promise<Feature> {
2742
2887
 
@@ -3010,42 +3155,64 @@ class IntelliCenterPumpCommands extends PumpCommands {
3010
3155
  // supplied then we will use what we already have. This will make sure the information is valid and any change can be applied without the complete
3011
3156
  // definition of the pump. This is important since additional attributes may be added in the future and this keeps us current no matter what
3012
3157
  // the endpoint capability is.
3013
- let outc = Outbound.create({ action: 168, payload: [4, 0, id - 1, ntype, 0] });
3158
+ const isV3 = sys.equipment.isIntellicenterV3;
3159
+ const dest = isV3 ? 16 : 15;
3160
+ let outc = Outbound.create({ dest, action: 168, payload: [4, 0, id - 1, ntype, 0] });
3014
3161
  outc.appendPayloadByte(parseInt(data.address, 10), id + 95); // 5
3015
- outc.appendPayloadInt(parseInt(data.minSpeed, 10), pump.minSpeed); // 6
3016
- outc.appendPayloadInt(parseInt(data.maxSpeed, 10), pump.maxSpeed); // 8
3162
+ // v3.004+ uses big-endian for 16-bit speed/flow values
3163
+ if (isV3) {
3164
+ outc.appendPayloadIntBE(parseInt(data.minSpeed, 10), pump.minSpeed); // 6
3165
+ outc.appendPayloadIntBE(parseInt(data.maxSpeed, 10), pump.maxSpeed); // 8
3166
+ } else {
3167
+ outc.appendPayloadInt(parseInt(data.minSpeed, 10), pump.minSpeed); // 6
3168
+ outc.appendPayloadInt(parseInt(data.maxSpeed, 10), pump.maxSpeed); // 8
3169
+ }
3017
3170
  outc.appendPayloadByte(parseInt(data.minFlow, 10), pump.minFlow); // 10
3018
3171
  outc.appendPayloadByte(parseInt(data.maxFlow, 10), pump.maxFlow); // 11
3019
3172
  outc.appendPayloadByte(parseInt(data.flowStepSize, 10), pump.flowStepSize || 1); // 12
3020
- outc.appendPayloadInt(parseInt(data.primingSpeed, 10), pump.primingSpeed || 2500); // 13
3173
+ if (isV3) {
3174
+ outc.appendPayloadIntBE(parseInt(data.primingSpeed, 10), pump.primingSpeed || 2500); // 13
3175
+ } else {
3176
+ outc.appendPayloadInt(parseInt(data.primingSpeed, 10), pump.primingSpeed || 2500); // 13
3177
+ }
3021
3178
  outc.appendPayloadByte(typeof data.speedStepSize !== 'undefined' ? parseInt(data.speedStepSize, 10) / 10 : pump.speedStepSize / 10, 1); // 15
3022
3179
  outc.appendPayloadByte(parseInt(data.primingTime, 10), pump.primingTime || 0); // 17
3023
3180
  outc.appendPayloadByte(255); //
3024
3181
  outc.appendPayloadBytes(255, 8); // 18
3025
3182
  outc.appendPayloadBytes(0, 8); // 26
3026
- let outn = Outbound.create({ action: 168, payload: [4, 1, id - 1] });
3183
+ let outn = Outbound.create({ dest, action: 168, payload: [4, 1, id - 1] });
3027
3184
  outn.appendPayloadBytes(0, 16);
3028
3185
  outn.appendPayloadString(data.name, 16, pump.name || type.name);
3029
3186
  if (type.name === 'ss') {
3030
3187
  outc.setPayloadByte(5, 0); // Clear the pump address
3031
3188
 
3032
3189
  // At some point we may add these to the pump model.
3033
- outc.setPayloadInt(6, type.minSpeed, 450);
3034
- outc.setPayloadInt(8, type.maxSpeed, 3450);
3190
+ // v3.004+ uses big-endian for 16-bit speed/flow values
3191
+ if (isV3) {
3192
+ outc.setPayloadIntBE(6, type.minSpeed, 450);
3193
+ outc.setPayloadIntBE(8, type.maxSpeed, 3450);
3194
+ } else {
3195
+ outc.setPayloadInt(6, type.minSpeed, 450);
3196
+ outc.setPayloadInt(8, type.maxSpeed, 3450);
3197
+ }
3035
3198
  outc.setPayloadByte(10, type.minFlow, 0);
3036
3199
  outc.setPayloadByte(11, type.maxFlow, 130);
3037
3200
  outc.setPayloadByte(12, 1);
3038
- outc.setPayloadInt(13, type.primingSpeed, 2500);
3201
+ if (isV3) {
3202
+ outc.setPayloadIntBE(13, type.primingSpeed, 2500);
3203
+ } else {
3204
+ outc.setPayloadInt(13, type.primingSpeed, 2500);
3205
+ }
3039
3206
  outc.setPayloadByte(15, 10);
3040
3207
  outc.setPayloadByte(16, 1);
3041
3208
  outc.setPayloadByte(17, 5);
3042
3209
  outc.setPayloadByte(18, data.body, pump.body);
3043
3210
  outc.setPayloadByte(26, 0);
3044
- outn.setPayloadInt(3, 0);
3211
+ if (isV3) outn.setPayloadIntBE(3, 0); else outn.setPayloadInt(3, 0);
3045
3212
  for (let i = 1; i < 8; i++) {
3046
3213
  outc.setPayloadByte(i + 18, 255);
3047
3214
  outc.setPayloadByte(i + 26, 0);
3048
- outn.setPayloadInt((i * 2) + 3, 1000);
3215
+ if (isV3) outn.setPayloadIntBE((i * 2) + 3, 1000); else outn.setPayloadInt((i * 2) + 3, 1000);
3049
3216
  }
3050
3217
  }
3051
3218
  else {
@@ -3067,13 +3234,13 @@ class IntelliCenterPumpCommands extends PumpCommands {
3067
3234
  // The incoming data does not include this circuit so we will set it to 255.
3068
3235
  outc.setPayloadByte(i + 18, 255);
3069
3236
  if (typeof type.minSpeed !== 'undefined')
3070
- outn.setPayloadInt((i * 2) + 3, type.minSpeed);
3237
+ isV3 ? outn.setPayloadIntBE((i * 2) + 3, type.minSpeed) : outn.setPayloadInt((i * 2) + 3, type.minSpeed);
3071
3238
  else if (typeof type.minFlow !== 'undefined') {
3072
- outn.setPayloadInt((i * 2) + 3, type.minFlow);
3239
+ isV3 ? outn.setPayloadIntBE((i * 2) + 3, type.minFlow) : outn.setPayloadInt((i * 2) + 3, type.minFlow);
3073
3240
  outc.setPayloadByte(i + 26, 1);
3074
3241
  }
3075
3242
  else
3076
- outn.setPayloadInt((i * 2) + 3, 0);
3243
+ isV3 ? outn.setPayloadIntBE((i * 2) + 3, 0) : outn.setPayloadInt((i * 2) + 3, 0);
3077
3244
  }
3078
3245
  else {
3079
3246
  let c = data.circuits[i];
@@ -3088,11 +3255,11 @@ class IntelliCenterPumpCommands extends PumpCommands {
3088
3255
  outc.setPayloadByte(i + 18, circuit - 1, circ.circuit - 1);
3089
3256
  if (typeof type.minSpeed !== 'undefined' && (parseInt(c.units, 10) === 0 || isNaN(parseInt(c.units, 10)))) {
3090
3257
  outc.setPayloadByte(i + 26, 0); // Set to rpm
3091
- outn.setPayloadInt((i * 2) + 3, Math.max(speed, type.minSpeed), circ.speed);
3258
+ isV3 ? outn.setPayloadIntBE((i * 2) + 3, Math.max(speed, type.minSpeed), circ.speed) : outn.setPayloadInt((i * 2) + 3, Math.max(speed, type.minSpeed), circ.speed);
3092
3259
  }
3093
3260
  else if (typeof type.minFlow !== 'undefined' && (parseInt(c.units, 10) === 1 || isNaN(parseInt(c.units, 10)))) {
3094
3261
  outc.setPayloadByte(i + 26, 1); // Set to gpm
3095
- outn.setPayloadInt((i * 2) + 3, Math.max(flow, type.minFlow), circ.flow);
3262
+ isV3 ? outn.setPayloadIntBE((i * 2) + 3, Math.max(flow, type.minFlow), circ.flow) : outn.setPayloadInt((i * 2) + 3, Math.max(flow, type.minFlow), circ.flow);
3096
3263
  }
3097
3264
  }
3098
3265
  }
@@ -3212,14 +3379,25 @@ class IntelliCenterPumpCommands extends PumpCommands {
3212
3379
  if (pump.master === 1) return super.deletePumpAsync(data);
3213
3380
 
3214
3381
  if (typeof pump.type === 'undefined') return Promise.reject(new InvalidEquipmentIdError(`Pump #${data.id} does not exist in configuration`, data.id, 'Schedule'));
3382
+ const isV3 = sys.equipment.isIntellicenterV3;
3215
3383
  let outc = Outbound.create({ action: 168, payload: [4, 0, id - 1, 0, 0, id + 95] });
3216
- outc.appendPayloadInt(450); // 6
3217
- outc.appendPayloadInt(3450); // 8
3384
+ if (isV3) {
3385
+ outc.appendPayloadIntBE(450); // 6
3386
+ outc.appendPayloadIntBE(3450); // 8
3387
+ } else {
3388
+ outc.appendPayloadInt(450); // 6
3389
+ outc.appendPayloadInt(3450); // 8
3390
+ }
3218
3391
  outc.appendPayloadByte(15); // 10
3219
3392
  outc.appendPayloadByte(130); // 11
3220
3393
  outc.appendPayloadByte(1); // 12
3221
- outc.appendPayloadInt(1000); // 13
3222
- outc.appendPayloadInt(10); // 15
3394
+ if (isV3) {
3395
+ outc.appendPayloadIntBE(1000); // 13
3396
+ outc.appendPayloadIntBE(10); // 15
3397
+ } else {
3398
+ outc.appendPayloadInt(1000); // 13
3399
+ outc.appendPayloadInt(10); // 15
3400
+ }
3223
3401
  outc.appendPayloadByte(5); // 17
3224
3402
  outc.appendPayloadBytes(255, 8); // 18
3225
3403
  outc.appendPayloadBytes(0, 8); // 26
@@ -3686,14 +3864,20 @@ class IntelliCenterScheduleCommands extends ScheduleCommands {
3686
3864
  if (endTimeType !== 0) runOnce |= (1 << (endTimeType + 3));
3687
3865
  // This was always the cooling setpoint for ultratemp.
3688
3866
  //let flags = (circuit === 1 || circuit === 6) ? 81 : 100;
3867
+ // v3.004+ uses big-endian for 16-bit time values
3868
+ let startTimeLo = startTime - Math.floor(startTime / 256) * 256;
3869
+ let startTimeHi = Math.floor(startTime / 256);
3870
+ let endTimeLo = endTime - Math.floor(endTime / 256) * 256;
3871
+ let endTimeHi = Math.floor(endTime / 256);
3872
+ let isV3 = sys.equipment.isIntellicenterV3;
3689
3873
  let out = Outbound.createMessage(168, [
3690
3874
  3
3691
3875
  , 0
3692
3876
  , id - 1 // IntelliCenter schedules start at 0.
3693
- , startTime - Math.floor(startTime / 256) * 256
3694
- , Math.floor(startTime / 256)
3695
- , endTime - Math.floor(endTime / 256) * 256
3696
- , Math.floor(endTime / 256)
3877
+ , isV3 ? startTimeHi : startTimeLo
3878
+ , isV3 ? startTimeLo : startTimeHi
3879
+ , isV3 ? endTimeHi : endTimeLo
3880
+ , isV3 ? endTimeLo : endTimeHi
3697
3881
  , circuit - 1
3698
3882
  , runOnce
3699
3883
  , schedDays
@@ -4149,6 +4333,9 @@ export class IntelliCenterChemControllerCommands extends ChemControllerCommands
4149
4333
  let cyanuricAcid = typeof data.cyanuricAcid !== 'undefined' ? parseInt(data.cyanuricAcid, 10) : chem.cyanuricAcid;
4150
4334
  let alkalinity = typeof data.alkalinity !== 'undefined' ? parseInt(data.alkalinity, 10) : chem.alkalinity;
4151
4335
  let borates = typeof data.borates !== 'undefined' ? parseInt(data.borates, 10) : chem.borates || 0;
4336
+ let intellichemStandalone = sys.controllerType === ControllerType.Nixie
4337
+ ? (typeof data.intellichemStandalone !== 'undefined' ? utils.makeBool(data.intellichemStandalone) : chem.intellichemStandalone)
4338
+ : false;
4152
4339
  let body = sys.board.bodies.mapBodyAssociation(typeof data.body === 'undefined' ? chem.body : data.body);
4153
4340
  if (typeof body === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Invalid body assignment`, 'chemController', data.body || chem.body));
4154
4341
  // Do a final validation pass so we dont send this off in a mess.
@@ -4237,6 +4424,7 @@ export class IntelliCenterChemControllerCommands extends ChemControllerCommands
4237
4424
  chem.alkalinity = alkalinity;
4238
4425
  chem.borates = borates;
4239
4426
  chem.body = schem.body = body;
4427
+ chem.intellichemStandalone = intellichemStandalone;
4240
4428
  schem.isActive = chem.isActive = true;
4241
4429
  chem.lsiRange.enabled = lsiRange.enabled;
4242
4430
  chem.lsiRange.low = lsiRange.low;