nodejs-poolcontroller 8.3.0 → 8.4.0

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 (105) hide show
  1. package/.eslintrc.json +36 -36
  2. package/.github/ISSUE_TEMPLATE/1-bug-report.yml +84 -84
  3. package/.github/ISSUE_TEMPLATE/2-docs.md +12 -12
  4. package/.github/ISSUE_TEMPLATE/3-proposal.md +28 -28
  5. package/.github/ISSUE_TEMPLATE/config.yml +8 -8
  6. package/.github/copilot-instructions.md +63 -63
  7. package/.github/workflows/ghcr-publish.yml +67 -67
  8. package/AGENTS.md +597 -0
  9. package/CONTRIBUTING.md +74 -74
  10. package/Changelog +292 -284
  11. package/Dockerfile +62 -62
  12. package/Gruntfile.js +40 -40
  13. package/LICENSE +661 -661
  14. package/README.md +318 -309
  15. package/anslq25/MessagesMock.ts +221 -221
  16. package/anslq25/boards/MockBoardFactory.ts +49 -49
  17. package/anslq25/boards/MockEasyTouchBoard.ts +696 -696
  18. package/anslq25/boards/MockSystemBoard.ts +216 -216
  19. package/anslq25/chemistry/MockChlorinator.ts +98 -98
  20. package/anslq25/pumps/MockPump.ts +83 -83
  21. package/app.ts +115 -115
  22. package/config/Config.ts +0 -0
  23. package/config/VersionCheck.ts +0 -0
  24. package/controller/Constants.ts +809 -805
  25. package/controller/Equipment.ts +2688 -2664
  26. package/controller/Errors.ts +181 -181
  27. package/controller/Lockouts.ts +549 -549
  28. package/controller/State.ts +3738 -3701
  29. package/controller/boards/AquaLinkBoard.ts +1003 -1003
  30. package/controller/boards/BoardFactory.ts +53 -53
  31. package/controller/boards/EasyTouchBoard.ts +3202 -3202
  32. package/controller/boards/IntelliCenterBoard.ts +4393 -3899
  33. package/controller/boards/IntelliComBoard.ts +69 -69
  34. package/controller/boards/IntelliTouchBoard.ts +382 -382
  35. package/controller/boards/NixieBoard.ts +1944 -1944
  36. package/controller/boards/SunTouchBoard.ts +400 -400
  37. package/controller/boards/SystemBoard.ts +5268 -5268
  38. package/controller/comms/Comms.ts +1272 -1255
  39. package/controller/comms/ScreenLogic.ts +1665 -1665
  40. package/controller/comms/messages/Messages.ts +1433 -1406
  41. package/controller/comms/messages/config/ChlorinatorMessage.ts +5 -0
  42. package/controller/comms/messages/config/CircuitGroupMessage.ts +0 -0
  43. package/controller/comms/messages/config/CircuitMessage.ts +0 -0
  44. package/controller/comms/messages/config/ConfigMessage.ts +6 -0
  45. package/controller/comms/messages/config/CoverMessage.ts +0 -0
  46. package/controller/comms/messages/config/CustomNameMessage.ts +31 -31
  47. package/controller/comms/messages/config/EquipmentMessage.ts +216 -210
  48. package/controller/comms/messages/config/ExternalMessage.ts +96 -10
  49. package/controller/comms/messages/config/FeatureMessage.ts +0 -0
  50. package/controller/comms/messages/config/GeneralMessage.ts +0 -0
  51. package/controller/comms/messages/config/HeaterMessage.ts +0 -0
  52. package/controller/comms/messages/config/IntellichemMessage.ts +0 -0
  53. package/controller/comms/messages/config/OptionsMessage.ts +194 -174
  54. package/controller/comms/messages/config/PumpMessage.ts +0 -0
  55. package/controller/comms/messages/config/RemoteMessage.ts +0 -0
  56. package/controller/comms/messages/config/ScheduleMessage.ts +401 -390
  57. package/controller/comms/messages/config/SecurityMessage.ts +0 -0
  58. package/controller/comms/messages/config/ValveMessage.ts +0 -0
  59. package/controller/comms/messages/status/ChlorinatorStateMessage.ts +0 -0
  60. package/controller/comms/messages/status/EquipmentStateMessage.ts +1158 -822
  61. package/controller/comms/messages/status/HeaterStateMessage.ts +135 -135
  62. package/controller/comms/messages/status/IntelliChemStateMessage.ts +448 -448
  63. package/controller/comms/messages/status/IntelliValveStateMessage.ts +36 -36
  64. package/controller/comms/messages/status/PumpStateMessage.ts +0 -0
  65. package/controller/comms/messages/status/RegalModbusStateMessage.ts +410 -410
  66. package/controller/comms/messages/status/VersionMessage.ts +103 -41
  67. package/controller/nixie/Nixie.ts +173 -173
  68. package/controller/nixie/NixieEquipment.ts +104 -104
  69. package/controller/nixie/bodies/Body.ts +120 -120
  70. package/controller/nixie/bodies/Filter.ts +135 -135
  71. package/controller/nixie/chemistry/ChemController.ts +2724 -2724
  72. package/controller/nixie/chemistry/ChemDoser.ts +806 -806
  73. package/controller/nixie/chemistry/Chlorinator.ts +367 -367
  74. package/controller/nixie/circuits/Circuit.ts +478 -478
  75. package/controller/nixie/heaters/Heater.ts +834 -834
  76. package/controller/nixie/pumps/Pump.ts +1193 -1193
  77. package/controller/nixie/schedules/Schedule.ts +401 -401
  78. package/controller/nixie/valves/Valve.ts +170 -170
  79. package/defaultConfig.json +352 -352
  80. package/docker-compose.yml +31 -31
  81. package/logger/DataLogger.ts +448 -448
  82. package/logger/Logger.ts +448 -436
  83. package/package.json +58 -58
  84. package/sendSocket.js +32 -32
  85. package/tsconfig.json +25 -25
  86. package/types/express-multer.d.ts +32 -32
  87. package/web/Server.ts +1937 -1927
  88. package/web/bindings/aqualinkD.json +559 -559
  89. package/web/bindings/influxDB.json +1066 -1066
  90. package/web/bindings/mqtt.json +721 -721
  91. package/web/bindings/mqttAlt.json +746 -746
  92. package/web/bindings/rulesManager.json +54 -54
  93. package/web/bindings/smartThings-Hubitat.json +31 -31
  94. package/web/bindings/valveRelays.json +20 -20
  95. package/web/bindings/vera.json +25 -25
  96. package/web/interfaces/baseInterface.ts +188 -188
  97. package/web/interfaces/httpInterface.ts +148 -148
  98. package/web/interfaces/influxInterface.ts +283 -283
  99. package/web/interfaces/mqttInterface.ts +695 -695
  100. package/web/interfaces/ruleInterface.ts +101 -87
  101. package/web/services/config/Config.ts +1063 -1053
  102. package/web/services/config/ConfigSocket.ts +0 -0
  103. package/web/services/state/State.ts +0 -0
  104. package/web/services/state/StateSocket.ts +0 -0
  105. package/web/services/utilities/Utilities.ts +233 -233
@@ -1,822 +1,1158 @@
1
- /* nodejs-poolController. An application to control pool equipment.
2
- Copyright (C) 2016, 2017, 2018, 2019, 2020, 2021, 2022.
3
- Russell Goldin, tagyoureit. russ.goldin@gmail.com
4
-
5
- This program is free software: you can redistribute it and/or modify
6
- it under the terms of the GNU Affero General Public License as
7
- published by the Free Software Foundation, either version 3 of the
8
- License, or (at your option) any later version.
9
-
10
- This program is distributed in the hope that it will be useful,
11
- but WITHOUT ANY WARRANTY; without even the implied warranty of
12
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
- GNU Affero General Public License for more details.
14
-
15
- You should have received a copy of the GNU Affero General Public License
16
- along with this program. If not, see <http://www.gnu.org/licenses/>.
17
- */
18
- import { IntelliCenterBoard } from 'controller/boards/IntelliCenterBoard';
19
- import { EasyTouchBoard } from 'controller/boards/EasyTouchBoard';
20
- import { IntelliTouchBoard } from 'controller/boards/IntelliTouchBoard';
21
- import { SunTouchBoard } from "controller/boards/SunTouchBoard";
22
-
23
- import { logger } from '../../../../logger/Logger';
24
- import { ControllerType } from '../../../Constants';
25
- import { Body, Circuit, ExpansionPanel, Feature, Heater, sys } from '../../../Equipment';
26
- import { BodyTempState, ScheduleState, State, state } from '../../../State';
27
- import { ExternalMessage } from '../config/ExternalMessage';
28
- import { Inbound, Message } from '../Messages';
29
-
30
- export class EquipmentStateMessage {
31
- private static initIntelliCenter(msg: Inbound) {
32
- sys.controllerType = ControllerType.IntelliCenter;
33
- sys.equipment.maxSchedules = 100;
34
- sys.equipment.maxFeatures = 32;
35
- // Always get equipment since this is volatile between loads. Everything else takes care of itself.
36
- sys.configVersion.equipment = 0;
37
- }
38
- public static initDefaults() {
39
- // defaults; set to lowest possible values. Each *Touch will extend this once we know the model.
40
- sys.equipment.maxBodies = 1;
41
- sys.equipment.maxCircuits = 6;
42
- sys.equipment.maxSchedules = 12;
43
- sys.equipment.maxPumps = 2;
44
- sys.equipment.maxSchedules = 12;
45
- sys.equipment.maxValves = 2;
46
- sys.equipment.maxCircuitGroups = 0;
47
- sys.equipment.maxLightGroups = 1;
48
- sys.equipment.maxIntelliBrites = 8;
49
- sys.equipment.maxChemControllers = sys.equipment.maxChlorinators = 1;
50
- sys.equipment.maxCustomNames = 10;
51
- sys.equipment.maxChemControllers = 4;
52
- sys.equipment.maxFeatures = 8;
53
- sys.equipment.model = 'Unknown';
54
- }
55
- private static initTouch(msg: Inbound) {
56
- let model1 = msg.extractPayloadByte(27);
57
- let model2 = msg.extractPayloadByte(28);
58
- switch (model2) {
59
- case 0:
60
- case 1:
61
- case 2:
62
- case 3:
63
- case 4:
64
- case 5:
65
- logger.info(`Found IntelliTouch Controller`);
66
- sys.controllerType = ControllerType.IntelliTouch;
67
- model1 = msg.extractPayloadByte(28);
68
- model2 = msg.extractPayloadByte(9);
69
- (sys.board as IntelliTouchBoard).initExpansionModules(model1, model2);
70
- break;
71
- case 11:
72
- logger.info(`Found SunTouch Controller`);
73
- sys.controllerType = ControllerType.SunTouch;
74
- (sys.board as SunTouchBoard).initExpansionModules(model1, model2);
75
- break;
76
- case 13:
77
- case 14:
78
- logger.info(`Found EasyTouch Controller`);
79
- sys.controllerType = ControllerType.EasyTouch;
80
- (sys.board as EasyTouchBoard).initExpansionModules(model1, model2);
81
- break;
82
- default:
83
- logger.error(`Unknown Touch Controller ${msg.extractPayloadByte(28)}:${msg.extractPayloadByte(27)}`);
84
- break;
85
- }
86
- //let board = sys.board as EasyTouchBoard;
87
- //board.initExpansionModules(model1, model2);
88
- }
89
- private static initController(msg: Inbound) {
90
- state.status = 1;
91
- const model1 = msg.extractPayloadByte(27);
92
- const model2 = msg.extractPayloadByte(28);
93
- // RKS: 06-15-20 -- While this works for now the way we are detecting seems a bit dubious. First, the 2 status message
94
- // contains two model bytes. Right now the ones witness in the wild include 23 = fw1.023, 40 = fw1.040, 47 = fw1.047.
95
- // RKS: 07-21-22 -- Pentair is about to release fw1.232. Unfortunately, the byte mapping for this has changed such that
96
- // the bytes [27,28] are [0,2] respectively. This looks like it might be in conflict with IntelliTouch but it is not. Below
97
- // are the combinations of 27,28 we have seen for IntelliTouch
98
- // [1,0] = i5+3
99
- // [0,1] = i7+3
100
- // [1,3] = i5+3s
101
- // [1,4] = i9+3s
102
- // [1,5] = i10+3d
103
- if ((model2 === 0 && (model1 === 23 || model1 >= 40)) ||
104
- (model2 === 2 && model1 == 0)) {
105
- state.equipment.controllerType = 'intellicenter';
106
- sys.board.modulesAcquired = false;
107
- sys.controllerType = ControllerType.IntelliCenter;
108
- logger.info(`Found Controller Board ${state.equipment.model || 'IntelliCenter'}, awaiting installed modules.`);
109
- EquipmentStateMessage.initIntelliCenter(msg);
110
- }
111
- else {
112
- EquipmentStateMessage.initTouch(msg);
113
- sys.board.needsConfigChanges = true;
114
- setTimeout(function () { sys.checkConfiguration(); }, 300);
115
- }
116
- }
117
- public static process(msg: Inbound) {
118
- Message.headerSubByte = msg.header[1];
119
- //console.log(process.memoryUsage());
120
- if (msg.action === 2 && state.isInitialized && sys.controllerType === ControllerType.Nixie) {
121
- // Start over because we didn't have communication before but we now do. This will fall into the if
122
- // below so that it goes through the intialization process. In this case we didn't see an OCP when we started
123
- // but there clearly is one now.
124
- (async () => {
125
- await sys.board.closeAsync();
126
- logger.info(`Closed ${sys.controllerType} board`);
127
- sys.controllerType = ControllerType.Unknown;
128
- state.status = 0;
129
- })();
130
- }
131
- if (!state.isInitialized) {
132
- msg.isProcessed = true;
133
- if (msg.action === 2) EquipmentStateMessage.initController(msg);
134
- else return;
135
- }
136
- else if (!sys.board.modulesAcquired) {
137
- msg.isProcessed = true;
138
- if (msg.action === 204) {
139
- let board = sys.board as IntelliCenterBoard;
140
- // We have determined that the 204 message now contains the information
141
- // related to the installed expansion boards.
142
- console.log(`INTELLICENTER MODULES DETECTED, REQUESTING STATUS!`);
143
- // Master = 13-14
144
- // EXP1 = 15-16
145
- // EXP2 = 17-18
146
- let pc = msg.extractPayloadByte(40);
147
- board.initExpansionModules(msg.extractPayloadByte(13), msg.extractPayloadByte(14),
148
- pc & 0x01 ? msg.extractPayloadByte(15) : 0x00, pc & 0x01 ? msg.extractPayloadByte(16) : 0x00,
149
- pc & 0x02 ? msg.extractPayloadByte(17) : 0x00, pc & 0x02 ? msg.extractPayloadByte(18) : 0x00,
150
- pc & 0x04 ? msg.extractPayloadByte(19) : 0x00, pc & 0x04 ? msg.extractPayloadByte(20) : 0x00);
151
- sys.equipment.setEquipmentIds();
152
- }
153
- else return;
154
- }
155
- switch (msg.action) {
156
- case 2:
157
- {
158
- let fnTempFromByte = function (byte) {
159
- return byte;
160
- //return (byte & 0x007F) * (((byte & 0x0080) > 0) ? -1 : 1); // RKS: 09-26-20 Not sure how negative temps are represented but this aint it. Temps > 127 have been witnessed.
161
- }
162
-
163
- // Shared
164
- let dt = new Date();
165
- // RKS: This was moved to the ChemControllerState message. This is flawed in that it incorrectly sets IntelliChem to no comms.
166
- //if (state.chemControllers.length > 0) {
167
- // // TODO: move this to chemController when we understand the packets better
168
- // for (let i = 0; i < state.chemControllers.length; i++) {
169
- // let ccontroller = state.chemControllers.getItemByIndex(i);
170
- // if (sys.board.valueMaps.chemControllerTypes.getName(ccontroller.type) === 'intellichem') {
171
- // if (dt.getTime() - ccontroller.lastComm > 60000) ccontroller.status = 1;
172
- // }
173
- // }
174
- //}
175
- state.time.hours = msg.extractPayloadByte(0);
176
- state.time.minutes = msg.extractPayloadByte(1);
177
- state.time.seconds = dt.getSeconds();
178
- state.mode = sys.controllerType !== ControllerType.IntelliCenter ? (msg.extractPayloadByte(9) & 0x81) : (msg.extractPayloadByte(9) & 0x01);
179
-
180
- // RKS: The units have been normalized for English and Metric for the overall panel. It is important that the val numbers match for at least the temp units since
181
- // the only unit of measure native to the Touch controllers is temperature they chose to name these C or F. However, with the njsPC extensions this is non-semantic
182
- // since pressure, volume, and length have been introduced.
183
- sys.general.options.units = state.temps.units = msg.extractPayloadByte(9) & 0x04;
184
- state.valve = msg.extractPayloadByte(10);
185
-
186
-
187
- // RSG - added 7/8/2020
188
- // Every 30 mins, check the timezone and adjust DST settings
189
- if (dt.getMinutes() % 30 === 0) {
190
- sys.board.system.setTZ();
191
- sys.board.schedules.updateSunriseSunsetAsync().then((updated: boolean)=>{
192
- if (updated) {logger.debug(`Sunrise/sunset times updated on schedules.`);}
193
- });
194
- }
195
- // Check and update clock when it is off by >5 mins (just for a small buffer) and:
196
- // 1. IntelliCenter has "manual" time set (Internet will automatically adjust) and autoAdjustDST is enabled
197
- // 2. *Touch is "manual" (only option) and autoAdjustDST is enabled - (same as #1)
198
- // 3. clock source is "server" isn't an OCP option but can be enabled on the clients
199
- if (dt.getMinutes() % 5 === 0 && dt.getSeconds() <= 10 && sys.general.options.clockSource === 'server') {
200
- if ((Math.abs(dt.getTime() - state.time.getTime()) > 60 * 2 * 1000) && !state.time.isUpdating) {
201
- state.time.isUpdating = true;
202
- sys.board.system.setDateTimeAsync({ dt, dst: sys.general.options.adjustDST || 0, })
203
- .then(() => {
204
- logger.info(`njsPC automatically updated OCP time. You're welcome.`);
205
- })
206
- .catch((err) => {
207
- logger.error(`Error automatically setting system time. ${JSON.stringify(err)}`)
208
- })
209
- .finally(() => {
210
- state.time.isUpdating = false;
211
- })
212
- }
213
- }
214
- state.delay = msg.extractPayloadByte(12) & 63; // not sure what 64 val represents
215
- state.freeze = (msg.extractPayloadByte(9) & 0x08) === 0x08;
216
- if (sys.controllerType === ControllerType.IntelliCenter) {
217
- state.temps.waterSensor1 = fnTempFromByte(msg.extractPayloadByte(14));
218
- if (sys.bodies.length > 2 || sys.equipment.dual) state.temps.waterSensor2 = fnTempFromByte(msg.extractPayloadByte(15));
219
- // We are making an assumption here in that the circuits are always labeled the same.
220
- // 1=Spa/Body2
221
- // 6=Pool/Body1
222
- // 12=Body3
223
- // 22=Body4 -- Really not sure about this one.
224
- if (sys.bodies.length > 0) {
225
- // We will not go in here if this is not a shared body.
226
- const tbody: BodyTempState = state.temps.bodies.getItemById(1, true);
227
- const cbody: Body = sys.bodies.getItemById(1);
228
- tbody.heatMode = cbody.heatMode;
229
- tbody.setPoint = cbody.setPoint;
230
- tbody.name = cbody.name;
231
- tbody.circuit = cbody.circuit = 6;
232
- tbody.heatStatus = msg.extractPayloadByte(11) & 0x0F;
233
- // With the IntelliCenter i10D, bit 6 is not reliable. It is not set properly and requires the 204 message
234
- // to process the data.
235
- if (!sys.equipment.dual) {
236
- if ((msg.extractPayloadByte(2) & 0x20) === 32) {
237
- tbody.temp = state.temps.waterSensor1;
238
- tbody.isOn = true;
239
- } else tbody.isOn = false;
240
- }
241
- else if (state.circuits.getItemById(6).isOn === true) {
242
- tbody.temp = state.temps.waterSensor1;
243
- tbody.isOn = true;
244
- }
245
- else tbody.isOn = false;
246
- }
247
- if (sys.bodies.length > 1) {
248
- const tbody: BodyTempState = state.temps.bodies.getItemById(2, true);
249
- const cbody: Body = sys.bodies.getItemById(2);
250
- tbody.heatMode = cbody.heatMode;
251
- tbody.setPoint = cbody.setPoint;
252
- tbody.name = cbody.name;
253
- tbody.circuit = cbody.circuit = 1;
254
- tbody.heatStatus = (msg.extractPayloadByte(11) & 0xF0) >> 4;
255
- if (!sys.equipment.dual) {
256
- if ((msg.extractPayloadByte(2) & 0x01) === 1) {
257
- tbody.temp = sys.equipment.shared ? state.temps.waterSensor1 : state.temps.waterSensor2;
258
- tbody.isOn = true;
259
- } else tbody.isOn = false;
260
- } else if (state.circuits.getItemById(1).isOn === true) {
261
- tbody.temp = sys.equipment.shared ? state.temps.waterSensor1 : state.temps.waterSensor2;
262
- tbody.isOn = true;
263
- }
264
- else tbody.isOn = false;
265
- }
266
- if (sys.bodies.length > 2) {
267
- state.temps.waterSensor3 = fnTempFromByte(msg.extractPayloadByte(20));
268
- // const tbody: BodyTempState = state.temps.bodies.getItemById(10, true);
269
- const tbody: BodyTempState = state.temps.bodies.getItemById(3, true);
270
- const cbody: Body = sys.bodies.getItemById(3);
271
- tbody.name = cbody.name;
272
- tbody.heatMode = cbody.heatMode;
273
- tbody.setPoint = cbody.setPoint;
274
- tbody.heatStatus = msg.extractPayloadByte(11) & 0x0F;
275
- tbody.circuit = cbody.circuit = 12;
276
- if ((msg.extractPayloadByte(3) & 0x08) === 8) {
277
- // This is the first circuit on the second body.
278
- tbody.temp = state.temps.waterSensor3;
279
- tbody.isOn = true;
280
- } else tbody.isOn = false;
281
- }
282
- if (sys.bodies.length > 3) {
283
- state.temps.waterSensor4 = fnTempFromByte(msg.extractPayloadByte(21));
284
- // const tbody: BodyTempState = state.temps.bodies.getItemById(19, true);
285
- const tbody: BodyTempState = state.temps.bodies.getItemById(4, true);
286
- const cbody: Body = sys.bodies.getItemById(4);
287
- tbody.name = cbody.name;
288
- tbody.heatMode = cbody.heatMode;
289
- tbody.setPoint = cbody.setPoint;
290
- tbody.heatStatus = (msg.extractPayloadByte(11) & 0xF0) >> 4;
291
- tbody.circuit = cbody.circuit = 22;
292
- if ((msg.extractPayloadByte(5) & 0x20) === 32) {
293
- // This is the first circuit on the third body or the first circuit on the second expansion.
294
- tbody.temp = state.temps.waterSensor2;
295
- tbody.isOn = true;
296
- } else tbody.isOn = false;
297
- }
298
- state.temps.air = fnTempFromByte(msg.extractPayloadByte(18)); // 18
299
- state.temps.solarSensor1 = fnTempFromByte(msg.extractPayloadByte(19)); // 19
300
- if (sys.bodies.length > 2 || sys.equipment.dual)
301
- state.temps.solarSensor2 = fnTempFromByte(msg.extractPayloadByte(17));
302
- if ((sys.bodies.length > 2))
303
- state.temps.solarSensor3 = fnTempFromByte(msg.extractPayloadByte(22));
304
- if ((sys.bodies.length > 3))
305
- state.temps.solarSensor4 = fnTempFromByte(msg.extractPayloadByte(23));
306
-
307
- if (sys.general.options.clockSource !== 'server' || typeof sys.general.options.adjustDST === 'undefined') sys.general.options.adjustDST = (msg.extractPayloadByte(23) & 0x01) === 0x0; //23
308
- }
309
- else {
310
- state.temps.waterSensor1 = fnTempFromByte(msg.extractPayloadByte(14));
311
- state.temps.air = fnTempFromByte(msg.extractPayloadByte(18));
312
- let solar: Heater = sys.heaters.getItemById(2);
313
- if (solar.isActive) state.temps.solar = fnTempFromByte(msg.extractPayloadByte(19));
314
- //[15, 34, 32, 0, 0, 0, 0, 0, 0, 0, 83, 0, 0, 0, 81, 81, 32, 91, 82, 91, 0, 0, 7, 4, 0, 77, 163, 1, 0][4, 78]
315
- // byte | val |
316
- // 0 | 15 | Hours
317
- // 1 | 34 | Minutes
318
- // 2 | 32 | Circuits 1-8 bit 6 = Pool on.
319
- // 3 | 0 | Circuits 9-16
320
- // 4 | 0 | Circuits 17-24
321
- // 5 | 0 | Circuits 24-32
322
- // 6 | 0 | Circuits 33-40
323
- // 7 | 0 | Unknown
324
- // 8 | 0 | Unknown
325
- // 9 | 0 | Panel Mode bit flags
326
- // 10 | 83 | Heat status for body 1 & 2 (This says solar is on for the pool and spa because this is the body that is running)
327
- // 11 | 0 | Unknown (This could be the heat status for body 3 & 4)
328
- // 12 | 0 | Unknown
329
- // 13 | 0 | Unknown
330
- // 14 | 81 | Water sensor 1 temperature
331
- // 15 | 81 | Water sensor 2 temperature (This mirrors water sensor 1 in shared system)
332
- // 16 | 32 | Unknown
333
- // 17 | 91 | Solar sensor 1 temperature
334
- // 18 | 82 | Air temp
335
- // 19 | 91 | Solar sensor 2 temperature (this mirrors solar sensor 1 in shared system)
336
- // 20 | 0 | Unknown (this could be water sensor 3)
337
- // 21 | 0 | Unknown (this could be water sensor 4)
338
- // 22 | 7 | Body 1 & 2 heat mode (body 1 = Solar Only body 2 = Heater)
339
- // 23 | 4 | Body 3 & 4 heat mode
340
- // 24 | 0 | Unknown
341
- // 25 | 77 | Unknown
342
- // 26 | 163 | Unknown
343
- // 27 | 1 | Byte 2 of OCP identifier
344
- // 28 | 0 | Byte 1 of OCP identifier
345
-
346
-
347
- // Heat Modes
348
- // 1 = Heater
349
- // 2 = Solar Preferred
350
- // 3 = Solar Only
351
-
352
- // Heat Status
353
- // 0 = Off
354
- // 1 = Heater
355
- // 2 = Cooling
356
- // 3 = Solar/Heat Pump
357
-
358
- // Pool Heat Mode/Status.
359
- // When temp setpoint and pool in heater mode went above the current pool temp byte 10 went from 67 to 71. The upper two bits of the
360
- // lower nibble changed on bit 3. So 0100 0111 from 0100 0011
361
-
362
- // Spa Heat Mode/Status
363
- // When switching from pool to spa with both heat modes set to off byte 10 went from 67 to 75 and byte(16) changed from 0 to 32. The upper two bits of the lower nibble
364
- // changed on byte(10) bit 4. So to 0100 1011 from 0100 0011. Interestingly this seems to indicate that the spa turned on. This almost appears as if the heater engaged
365
- // automatically like the spa has manual heat turned off.
366
- // When the heat mode was changed to solar only byte 10 went to 75 from 67 so bit 4 switched off and byte(16) changed to 0. At this point the water temp is 86 and the
367
- // solar temp is 79 so the solar should not be coming on.
368
- // When the setpoint was dropped below the water temp bit 5 on byte(10) swiched back off and byte(16) remained at 0. I think there is no bearing here on this.
369
- // When the heat mode was changed to solar preferred and the setpoint was raised to 104F the heater kicked on and bit 5 changed from 0 to 1. So byte(10) went from
370
- // 0100 0011 to 0100 1011 this is consistent with the heater coming on for the spa. In this instance byte(16) also changed back to 32 which would be consistent with
371
- // an OCP where the manual heat was turned off.
372
-
373
- // RKS: Added check for i10d for water sensor 2.
374
- if (sys.bodies.length > 2 || sys.equipment.dual) state.temps.waterSensor2 = fnTempFromByte(msg.extractPayloadByte(15));
375
- if (sys.bodies.length > 0) {
376
- // const tbody: BodyTempState = state.temps.bodies.getItemById(6, true);
377
- const tbody: BodyTempState = state.temps.bodies.getItemById(1, true);
378
- const cbody: Body = sys.bodies.getItemById(1);
379
- if ((msg.extractPayloadByte(2) & 0x20) === 32) {
380
- tbody.temp = state.temps.waterSensor1;
381
- tbody.isOn = true;
382
- } else tbody.isOn = false;
383
- tbody.setPoint = cbody.setPoint;
384
- tbody.name = cbody.name;
385
- tbody.circuit = cbody.circuit = 6;
386
-
387
- //RKS: This heat mode did not include all the bits necessary for hybrid heaters
388
- //tbody.heatMode = cbody.heatMode = msg.extractPayloadByte(22) & 0x03;
389
- tbody.heatMode = cbody.heatMode = msg.extractPayloadByte(22) & 0x33;
390
- let heatStatus = sys.board.valueMaps.heatStatus.getValue('off');
391
- if (tbody.isOn) {
392
- if (tbody.heaterOptions.hybrid > 0) {
393
- // ETi When heating with
394
- // Heatpump (1) = 12 H:true S:false C:false
395
- // Gas (2) = 48 H:false S:true C:false
396
- // Hybrid (3) = 48 H:true S:false C:false
397
- // Dual (16) = 60 H:true S:true C:false
398
- // What this means is that Touch actually treats the heat status as either heating with
399
- // the primary heater for the body or the secondary. In the case of a hybrid heater
400
- // the primary is a heatpump and the secondary is gas. In the case of gas + solar or gas + heatpump
401
- // the gas heater is the primary and solar or heatpump is the secondary. So we need to dance a little bit
402
- // here. We do this by checking the heater options.
403
- if (tbody.heatMode > 0) { // Turns out that ET sometimes reports the last heat status when off.
404
- // This can be the only heater solar cannot be installed with this.
405
- let byte = msg.extractPayloadByte(10);
406
- // Either the primary, secondary, or both is engaged.
407
- if ((byte & 0x14) === 0x14) heatStatus = sys.board.valueMaps.heatStatus.getValue('dual');
408
- // else if ((byte & 0x0c) === 0x0c) heatStatus = sys.board.valueMaps.heatStatus.getValue('off'); // don't need since we test for heatMode>0
409
- else if (byte & 0x10) heatStatus = sys.board.valueMaps.heatStatus.getValue('heater');
410
- else if (byte & 0x04) heatStatus = sys.board.valueMaps.heatStatus.getValue('hpheat');
411
- }
412
- }
413
- else {
414
- //const heaterActive = (msg.extractPayloadByte(10) & 0x0C) === 12;
415
- //const solarActive = (msg.extractPayloadByte(10) & 0x30) === 48;
416
- const heaterActive = (msg.extractPayloadByte(10) & 0x04) === 0x04;
417
- const solarActive = (msg.extractPayloadByte(10) & 0x10) === 0x10;
418
- const cooling = solarActive && tbody.temp > tbody.setPoint;
419
- if (heaterActive) heatStatus = sys.board.valueMaps.heatStatus.getValue('heater');
420
- if (cooling) heatStatus = sys.board.valueMaps.heatStatus.getValue('cooling');
421
- else if (solarActive) heatStatus = sys.board.valueMaps.heatStatus.getValue('solar');
422
- }
423
- }
424
- tbody.heatStatus = heatStatus;
425
- sys.board.schedules.syncScheduleHeatSourceAndSetpoint(cbody, tbody);
426
- }
427
- if (sys.bodies.length > 1) {
428
- // const tbody: BodyTempState = state.temps.bodies.getItemById(1, true);
429
- const tbody: BodyTempState = state.temps.bodies.getItemById(2, true);
430
- const cbody: Body = sys.bodies.getItemById(2);
431
- if ((msg.extractPayloadByte(2) & 0x01) === 1) {
432
- tbody.temp = sys.equipment.shared ? state.temps.waterSensor1 : state.temps.waterSensor2;
433
- tbody.isOn = true;
434
- } else tbody.isOn = false;
435
- //RKS: This heat mode did not include all the bits necessary for hybrid heaters
436
- //tbody.heatMode = cbody.heatMode = (msg.extractPayloadByte(22) & 0x0C) >> 2;
437
- tbody.heatMode = cbody.heatMode = (msg.extractPayloadByte(22) & 0xCC) >> 2;
438
- tbody.setPoint = cbody.setPoint;
439
- tbody.name = cbody.name;
440
- tbody.circuit = cbody.circuit = 1;
441
- let heatStatus = sys.board.valueMaps.heatStatus.getValue('off');
442
- if (tbody.isOn) {
443
- if (tbody.heaterOptions.hybrid > 0) {
444
- // This can be the only heater solar cannot be installed with this.
445
- if (tbody.heatMode > 0) {
446
- let byte = msg.extractPayloadByte(10);
447
- // Either the primary, secondary, or both is engaged.
448
- if ((byte & 0x28) === 0x28) heatStatus = sys.board.valueMaps.heatStatus.getValue('dual');
449
- else if (byte & 0x20) heatStatus = sys.board.valueMaps.heatStatus.getValue('heater');
450
- else if (byte & 0x08) heatStatus = sys.board.valueMaps.heatStatus.getValue('hpheat');
451
- }
452
- }
453
- else {
454
- //const heaterActive = (msg.extractPayloadByte(10) & 0x0C) === 12;
455
- //const solarActive = (msg.extractPayloadByte(10) & 0x30) === 48;
456
- const heaterActive = (msg.extractPayloadByte(10) & 0x08) === 0x08;
457
- const solarActive = (msg.extractPayloadByte(10) & 0x20) === 0x20;
458
- const cooling = solarActive && tbody.temp > tbody.setPoint;
459
- if (heaterActive) heatStatus = sys.board.valueMaps.heatStatus.getValue('heater');
460
- if (cooling) heatStatus = sys.board.valueMaps.heatStatus.getValue('cooling');
461
- else if (solarActive) heatStatus = sys.board.valueMaps.heatStatus.getValue('solar');
462
- }
463
- }
464
- tbody.heatStatus = heatStatus;
465
- sys.board.schedules.syncScheduleHeatSourceAndSetpoint(cbody, tbody);
466
- }
467
- }
468
- switch (sys.controllerType) {
469
- case ControllerType.IntelliCenter:
470
- {
471
- EquipmentStateMessage.processCircuitState(msg);
472
- // RKS: As of 1.04 the entire feature state is emitted on 204. This message
473
- // used to contain the first 4 feature states starting in byte 8 upper 4 bits
474
- // and as of 1.047 release this was no longer reliable. Macro circuits only appear
475
- // to be available on message 30-15 and 168-15.
476
- //EquipmentStateMessage.processFeatureState(msg);
477
- sys.board.circuits.syncCircuitRelayStates();
478
- sys.board.circuits.syncVirtualCircuitStates();
479
- sys.board.valves.syncValveStates();
480
- sys.board.filters.syncFilterStates();
481
- state.emitControllerChange();
482
- state.emitEquipmentChanges();
483
- sys.board.heaters.syncHeaterStates();
484
- break;
485
- }
486
- case ControllerType.SunTouch:
487
- EquipmentStateMessage.processSunTouchCircuits(msg);
488
- sys.board.circuits.syncCircuitRelayStates();
489
- sys.board.features.syncGroupStates();
490
- sys.board.circuits.syncVirtualCircuitStates();
491
- sys.board.valves.syncValveStates();
492
- sys.board.filters.syncFilterStates();
493
- state.emitControllerChange();
494
- state.emitEquipmentChanges();
495
- sys.board.heaters.syncHeaterStates();
496
- sys.board.schedules.syncScheduleStates();
497
- break;
498
- case ControllerType.EasyTouch:
499
- case ControllerType.IntelliCom:
500
- case ControllerType.IntelliTouch:
501
- {
502
- EquipmentStateMessage.processTouchCircuits(msg);
503
- // This will toggle the group states depending on the state of the individual circuits.
504
- sys.board.circuits.syncCircuitRelayStates();
505
- sys.board.features.syncGroupStates();
506
- sys.board.circuits.syncVirtualCircuitStates();
507
- sys.board.valves.syncValveStates();
508
- sys.board.filters.syncFilterStates();
509
- state.emitControllerChange();
510
- state.emitEquipmentChanges();
511
- sys.board.heaters.syncHeaterStates();
512
- sys.board.schedules.syncScheduleStates();
513
- break;
514
- }
515
- }
516
- }
517
- break;
518
- case 5: // Intellitouch only. Date/Time packet
519
- // [255,0,255][165,1,15,16,5,8][15,10,8,1,8,18,0,1][1,15]
520
- state.time.hours = msg.extractPayloadByte(0);
521
- state.time.minutes = msg.extractPayloadByte(1);
522
- // state.time.dayOfWeek = msg.extractPayloadByte(2);
523
- state.time.date = msg.extractPayloadByte(3);
524
- state.time.month = msg.extractPayloadByte(4);
525
- state.time.year = msg.extractPayloadByte(5);
526
- if (sys.general.options.clockSource !== 'server' || typeof sys.general.options.adjustDST === 'undefined') sys.general.options.adjustDST = msg.extractPayloadByte(7) === 0x01;
527
- setTimeout(function () { sys.board.checkConfiguration(); }, 100);
528
- msg.isProcessed = true;
529
- break;
530
- case 8: {
531
- // IntelliTouch only. Heat status
532
- // [165,x,15,16,8,13],[75,75,64,87,101,11,0, 0 ,62 ,0 ,0 ,0 ,0] ,[2,190]
533
- // Heat Modes
534
- // 1 = Heater
535
- // 2 = Solar Preferred
536
- // 3 = Solar Only
537
- //[81, 81, 82, 85, 97, 7, 0, 0, 0, 100, 100, 4, 0][3, 87]
538
- // byte | val |
539
- // 0 | 81 | Water sensor 1
540
- // 1 | 81 | Unknown (Probably water sensor 2 on a D)
541
- // 2 | 82 | Air sensor
542
- // 3 | 85 | Body 1 setpoint
543
- // 4 | 97 | Body 2 setpoint
544
- // 5 | 7 | Body 1 & 2 heat mode. (0111) (Pool = 11 Solar only/Spa = 01 Heater)
545
- // 6 | 0 | Unknown (Water Sensor 3)
546
- // 7 | 0 | Unknown (Water Sensor 4)
547
- // 8 | 0 | Unknown -- Reserved air sensor
548
- // 9 | 100 | Unknown (Body 3 setpoint)
549
- // 10 | 100 | Unknown (Body 4 setpoint)
550
- // 11 | 4 | Unknown (Body 3 & 4 head mode. (0010) (Pool = 00 = Off/ 10 = Solar Preferred)
551
- // 12 | 0 | Unknown
552
- // There are two messages sent when the OCP tries to tse a heat mode in IntelliTouch. The first one on the action 136 is for the first 2 bodies and the second
553
- // is for the remaining 2 bodies. The second half of this message mirrors the values for the second 136 message.
554
- // [255, 0, 255][165, 1, 16, 32, 136, 4][100, 100, 4, 1][2, 47]
555
- state.temps.waterSensor1 = msg.extractPayloadByte(0);
556
- state.temps.air = msg.extractPayloadByte(2);
557
- let solar: Heater = sys.heaters.getItemById(2);
558
- // RKS: 05-18-22 - This is not correct the solar temp is not stored on this message. It is always 0
559
- // on an intelliTouch system with solar.
560
- //if (solar.isActive) state.temps.solar = msg.extractPayloadByte(8);
561
- // pool
562
- let tbody: BodyTempState = state.temps.bodies.getItemById(1, true);
563
- let cbody: Body = sys.bodies.getItemById(1);
564
- // RKS: 02-26-22 - See communications doc for explanation of bits. This needs to support UltraTemp ETi heatpumps.
565
- tbody.heatMode = cbody.heatMode = msg.extractPayloadByte(5) & 0x33;
566
- tbody.setPoint = cbody.setPoint = msg.extractPayloadByte(3);
567
- tbody.coolSetpoint = cbody.coolSetpoint = msg.extractPayloadByte(9);
568
- if (tbody.isOn) tbody.temp = state.temps.waterSensor1;
569
- cbody = sys.bodies.getItemById(2);
570
- if (cbody.isActive) {
571
- // spa
572
- tbody = state.temps.bodies.getItemById(2, true);
573
- tbody.heatMode = cbody.heatMode = (msg.extractPayloadByte(5) & 0xCC) >> 2;
574
- //tbody.heatMode = cbody.heatMode = (msg.extractPayloadByte(5) & 12) >> 2;
575
- tbody.setPoint = cbody.setPoint = msg.extractPayloadByte(4);
576
- if (tbody.isOn) tbody.temp = state.temps.waterSensor2 = msg.extractPayloadByte(1);
577
- }
578
- state.emitEquipmentChanges();
579
- msg.isProcessed = true;
580
- break;
581
- }
582
- case 96:
583
- EquipmentStateMessage.processIntelliBriteMode(msg);
584
- break;
585
- case 197: {
586
- // request for date/time on *Touch. Use this as an indicator
587
- // that SL has requested config and update lastUpdated date/time
588
- /* let ver: ConfigVersion =
589
- typeof (sys.configVersion) === 'undefined' ? new ConfigVersion({}) : sys.configVersion;
590
- ver.lastUpdated = new Date();
591
- sys.processVersionChanges(ver); */
592
- sys.configVersion.lastUpdated = new Date();
593
- msg.isProcessed = true;
594
- break;
595
- }
596
- case 204: // IntelliCenter only.
597
- state.batteryVoltage = msg.extractPayloadByte(2) / 50;
598
- state.comms.keepAlives = msg.extractPayloadInt(4);
599
- state.time.year = msg.extractPayloadByte(8);
600
- state.time.month = msg.extractPayloadByte(7);
601
- state.time.date = msg.extractPayloadByte(6);
602
- sys.equipment.controllerFirmware = (msg.extractPayloadByte(42) + (msg.extractPayloadByte(43) / 1000)).toString();
603
- if (sys.chlorinators.length > 0) {
604
- if (msg.extractPayloadByte(37, 255) !== 255) {
605
- const chlor = state.chlorinators.getItemById(1);
606
- chlor.superChlorRemaining = msg.extractPayloadByte(37) * 3600 + msg.extractPayloadByte(38) * 60;
607
- } else {
608
- const chlor = state.chlorinators.getItemById(1);
609
- chlor.superChlorRemaining = 0;
610
- chlor.superChlor = false;
611
- }
612
- }
613
- ExternalMessage.processFeatureState(9, msg);
614
- //if (sys.equipment.dual === true) {
615
- // // For IntelliCenter i10D the body state is on byte 26 of the 204. This impacts circuit 6.
616
- // let byte = msg.extractPayloadByte(26);
617
- // let pstate = state.circuits.getItemById(6, true);
618
- // let oldstate = pstate.isOn;
619
- // pstate.isOn = ((byte & 0x0010) === 0x0010);
620
- // logger.info(`Checking i10D pool state ${byte} old:${oldstate} new: ${pstate.isOn}`);
621
- // //if (oldstate !== pstate.isOn) {
622
- // state.temps.bodies.getItemById(1, true).isOn = pstate.isOn;
623
- // sys.board.circuits.syncCircuitRelayStates();
624
- // sys.board.circuits.syncVirtualCircuitStates();
625
- // sys.board.valves.syncValveStates();
626
- // sys.board.filters.syncFilterStates();
627
- // sys.board.heaters.syncHeaterStates();
628
- // //}
629
- // if (oldstate !== pstate.isOn) pstate.emitEquipmentChange();
630
- //}
631
- // At this point normally on is ignored. Not sure what this does.
632
- let cover1 = sys.covers.getItemById(1);
633
- let cover2 = sys.covers.getItemById(2);
634
- if (cover1.isActive) {
635
- let scover1 = state.covers.getItemById(1, true);
636
- scover1.name = cover1.name;
637
- state.temps.bodies.getItemById(cover1.body + 1).isCovered = scover1.isClosed = (msg.extractPayloadByte(30) & 0x0001) > 0;
638
- }
639
- if (cover2.isActive) {
640
- let scover2 = state.covers.getItemById(2, true);
641
- scover2.name = cover2.name;
642
- state.temps.bodies.getItemById(cover2.body + 1).isCovered = scover2.isClosed = (msg.extractPayloadByte(30) & 0x0002) > 0;
643
- }
644
- sys.board.schedules.syncScheduleStates();
645
- msg.isProcessed = true;
646
- state.emitEquipmentChanges();
647
- break;
648
- }
649
- }
650
- private static processCircuitState(msg: Inbound) {
651
- // The way this works is that there is one byte per 8 circuits for a total of 5 bytes or 40 circuits. The
652
- // configuration already determined how many available circuits we have by querying the model of the panel
653
- // and any installed expansion panel models. Only the number of available circuits will appear in this
654
- // array.
655
- let circuitId = 1;
656
- let maxCircuitId = sys.board.equipmentIds.circuits.end;
657
- for (let i = 2; i < msg.payload.length && circuitId <= maxCircuitId; i++) {
658
- const byte = msg.extractPayloadByte(i);
659
- // Shift each bit getting the circuit identified by each value.
660
- for (let j = 0; j < 8; j++) {
661
- let circuit = sys.circuits.getItemById(circuitId, false, { isActive: false });
662
- if (circuit.isActive !== false) {
663
- let cstate = state.circuits.getItemById(circuitId, circuit.isActive);
664
- // For IntelliCenter i10D body circuits are not reported here.
665
- let isOn = ((circuitId === 6 || circuitId === 1) && sys.equipment.dual === true) ? cstate.isOn : (byte & (1 << j)) > 0;
666
- //let isOn = (byte & (1 << j)) > 0;
667
- cstate.isOn = isOn;
668
- cstate.name = circuit.name;
669
- cstate.nameId = circuit.nameId;
670
- cstate.showInFeatures = circuit.showInFeatures;
671
- cstate.type = circuit.type;
672
- sys.board.circuits.setEndTime(circuit, cstate, isOn);
673
- if (sys.controllerType === ControllerType.IntelliCenter) {
674
- // intellitouch sends a separate msg with themes
675
- switch (circuit.type) {
676
- case 6: // Globrite
677
- case 5: // Magicstream
678
- case 8: // Intellibrite
679
- case 10: // Colorcascade
680
- cstate.lightingTheme = circuit.lightingTheme;
681
- break;
682
- case 9:
683
- cstate.level = circuit.level || 0;
684
- break;
685
- }
686
- }
687
- }
688
- circuitId++;
689
- }
690
- }
691
- msg.isProcessed = true;
692
- }
693
- private static processSunTouchCircuits(msg: Inbound) {
694
- // SunTouch has really twisted bit mapping for its
695
- // circuit states. Features are intertwined within the
696
- // features.
697
- let byte = msg.extractPayloadByte(2);
698
- for (let i = 0; i < 8; i++) {
699
- let id = i === 4 ? 7 : i > 5 ? i + 2 : i + 1;
700
- let circ = sys.circuits.getInterfaceById(id, false, { isActive: false });
701
- if (circ.isActive) {
702
- let isOn = ((1 << i) & byte) > 0;
703
- let cstate = state.circuits.getInterfaceById(id, circ.isActive);
704
- if (isOn !== cstate.isOn) {
705
- sys.board.circuits.setEndTime(circ, cstate, isOn);
706
- cstate.isOn = isOn;
707
- }
708
- }
709
- }
710
- byte = msg.extractPayloadByte(3);
711
- {
712
- let circ = sys.circuits.getInterfaceById(10, false, { isActive: false });
713
- if (circ.isActive) {
714
- let isOn = (byte & 1) > 0;
715
- let cstate = state.circuits.getInterfaceById(circ.id, circ.isActive);
716
- if (isOn !== cstate.isOn) {
717
- sys.board.circuits.setEndTime(circ, cstate, isOn);
718
- cstate.isOn = isOn;
719
- }
720
- }
721
- }
722
- state.emitEquipmentChanges();
723
- msg.isProcessed = true;
724
- }
725
-
726
- private static processTouchCircuits(msg: Inbound) {
727
- let circuitId = 1;
728
- let maxCircuitId = sys.board.equipmentIds.features.end;
729
- for (let i = 2; i < msg.payload.length && circuitId <= maxCircuitId; i++) {
730
- const byte = msg.extractPayloadByte(i);
731
- // Shift each bit getting the circuit identified by each value.
732
- for (let j = 0; j < 8; j++) {
733
- const circ = sys.circuits.getInterfaceById(circuitId, false, { isActive: false });
734
- if (!sys.board.equipmentIds.invalidIds.isValidId(circuitId)) {
735
- circ.isActive = false;
736
- }
737
- if (circ.isActive) {
738
- const cstate = state.circuits.getInterfaceById(circuitId, circ.isActive);
739
- cstate.showInFeatures = circ.showInFeatures;
740
- let isOn = (byte & 1 << j) >> j > 0;
741
- if (isOn !== cstate.isOn) {
742
- sys.board.circuits.setEndTime(circ, cstate, isOn);
743
- cstate.isOn = isOn;
744
- }
745
- cstate.name = circ.name;
746
- cstate.type = circ.type;
747
- cstate.nameId = circ.nameId;
748
- }
749
- else {
750
- if (circ instanceof Circuit) {
751
- sys.circuits.removeItemById(circuitId);
752
- // don't forget to remove from state #257
753
- state.circuits.removeItemById(circuitId);
754
- }
755
- else if (circ instanceof Feature) {
756
- sys.features.removeItemById(circuitId);
757
- // don't forget to remove from state #257
758
- state.features.removeItemById(circuitId);
759
- }
760
- }
761
- circuitId++;
762
- }
763
- }
764
- // state.body = body;
765
- //state.emitControllerChange();
766
- state.emitEquipmentChanges();
767
- msg.isProcessed = true;
768
- }
769
-
770
- private static processIntelliBriteMode(msg: Inbound) {
771
- // eg RED: [165,16,16,34,96,2],[195,0],[2,12]
772
- // data[0] = color
773
- const theme = msg.extractPayloadByte(0);
774
- switch (theme) {
775
- case 0: // off
776
- case 1: // on
777
- case 190: // save
778
- // case 191: // recall
779
- // RKS: TODO hold may be in this list since I see the all on and all off command here. Sync is probably in the colorset message that includes the timings.
780
- // do nothing as these don't actually change the state.
781
- break;
782
-
783
- default:
784
- {
785
- // intellibrite themes
786
- // This is an observed message in that no-one asked for it. *Touch does not report the theme and in fact, it is not even
787
- // stored. Once the message is sent then it throws away the data. When you turn the light
788
- // on again it will be on at whatever theme happened to be set at the time it went off. We keep this
789
- // as a best guess so when the user turns on the light it will likely be the last theme observed.
790
- const grp = sys.lightGroups.getItemById(sys.board.equipmentIds.circuitGroups.start);
791
- const sgrp = state.lightGroups.getItemById(sys.board.equipmentIds.circuitGroups.start);
792
- grp.lightingTheme = sgrp.lightingTheme = theme;
793
- for (let i = 0; i < grp.circuits.length; i++) {
794
- let c = grp.circuits.getItemByIndex(i);
795
- let cstate = state.circuits.getItemById(c.circuit);
796
- let circuit = sys.circuits.getInterfaceById(c.circuit);
797
- if (cstate.isOn) cstate.lightingTheme = circuit.lightingTheme = theme;
798
- }
799
- switch (theme) {
800
- case 128: // sync
801
- sys.board.circuits.sequenceLightGroupAsync(grp.id, 'sync');
802
- break;
803
- case 144: // swim
804
- sys.board.circuits.sequenceLightGroupAsync(grp.id, 'swim');
805
- break;
806
- case 160: // set
807
- sys.board.circuits.sequenceLightGroupAsync(grp.id, 'set');
808
- break;
809
- case 190: // save
810
- case 191: // recall
811
- sys.board.circuits.sequenceLightGroupAsync(grp.id, 'other');
812
- break;
813
- default:
814
- sys.board.circuits.sequenceLightGroupAsync(grp.id, 'color');
815
- // other themes for magicstream?
816
- }
817
- break;
818
- }
819
- }
820
- msg.isProcessed = true;
821
- }
822
- }
1
+ /* nodejs-poolController. An application to control pool equipment.
2
+ Copyright (C) 2016, 2017, 2018, 2019, 2020, 2021, 2022.
3
+ Russell Goldin, tagyoureit. russ.goldin@gmail.com
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+ */
18
+ import { IntelliCenterBoard } from 'controller/boards/IntelliCenterBoard';
19
+ import { EasyTouchBoard } from 'controller/boards/EasyTouchBoard';
20
+ import { IntelliTouchBoard } from 'controller/boards/IntelliTouchBoard';
21
+ import { SunTouchBoard } from "controller/boards/SunTouchBoard";
22
+
23
+ import { logger } from '../../../../logger/Logger';
24
+ import { ControllerType } from '../../../Constants';
25
+ import { Body, Circuit, ExpansionPanel, Feature, Heater, sys } from '../../../Equipment';
26
+ import { BodyTempState, ScheduleState, State, state } from '../../../State';
27
+ import { ExternalMessage } from '../config/ExternalMessage';
28
+ import { Inbound, Message, Outbound } from '../Messages';
29
+
30
+ // Cache for pending Wireless Action 184 commands to correlate with state changes
31
+ // Key: targetId, Value: { state: 0|1, timestamp: number }
32
+ const pendingAction184Commands: Map<number, { state: number, timestamp: number }> = new Map();
33
+ const PENDING_COMMAND_TTL_MS = 10000; // 10 second TTL for pending commands
34
+
35
+ function findActiveCircuitTargetIdOwner(targetId: number, excludeCircuitId?: number): Circuit | null {
36
+ if (typeof targetId !== 'number' || targetId <= 0) return null;
37
+ for (let i = 0; i < sys.circuits.length; i++) {
38
+ const circ = sys.circuits.getItemByIndex(i);
39
+ if (!circ || !circ.isActive) continue;
40
+ if (typeof excludeCircuitId === 'number' && circ.id === excludeCircuitId) continue;
41
+ // `targetId` may be undefined on circuits that haven't learned it yet.
42
+ if (typeof (circ as any).targetId === 'number' && (circ as any).targetId === targetId) return circ;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export class EquipmentStateMessage {
48
+ // Called when Action 2 status shows a circuit state change
49
+ // Checks if there's a pending Wireless command that matches
50
+ public static checkPendingAction184Learning(circuitId: number, isOn: boolean): void {
51
+ const now = Date.now();
52
+ const targetState = isOn ? 1 : 0;
53
+
54
+ // Clean up expired entries
55
+ for (const [targetId, entry] of pendingAction184Commands.entries()) {
56
+ if (now - entry.timestamp > PENDING_COMMAND_TTL_MS) {
57
+ pendingAction184Commands.delete(targetId);
58
+ }
59
+ }
60
+
61
+ // Look for pending commands that match this state
62
+ for (const [targetId, entry] of pendingAction184Commands.entries()) {
63
+ if (entry.state === targetState) {
64
+ // This pending command matches the state change
65
+ // Check if this circuit needs a targetId
66
+ const circ = sys.circuits.getItemById(circuitId, false);
67
+ if (circ && circ.isActive && (typeof circ.targetId === 'undefined' || circ.targetId === 0)) {
68
+ const owner = findActiveCircuitTargetIdOwner(targetId, circuitId);
69
+ if (owner) {
70
+ logger.warn(
71
+ `v3.004+ Action 184: Refusing to learn duplicate targetId ${targetId} for circuit ${circuitId} (${circ.name || 'unnamed'}); ` +
72
+ `already owned by circuit ${owner.id} (${owner.name || 'unnamed'}).`
73
+ );
74
+ pendingAction184Commands.delete(targetId);
75
+ return;
76
+ }
77
+ logger.debug(`v3.004+ Action 184: Learned Target ID ${targetId} for circuit ${circuitId} (${circ.name || 'unnamed'}) [Wireless command correlation]`);
78
+ circ.targetId = targetId;
79
+ pendingAction184Commands.delete(targetId);
80
+ return;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ private static initIntelliCenter(msg: Inbound) {
86
+ sys.controllerType = ControllerType.IntelliCenter;
87
+ sys.equipment.maxSchedules = 100;
88
+ sys.equipment.maxFeatures = 32;
89
+ // Always get equipment since this is volatile between loads. Everything else takes care of itself.
90
+ sys.configVersion.equipment = 0;
91
+ }
92
+ public static initDefaults() {
93
+ // defaults; set to lowest possible values. Each *Touch will extend this once we know the model.
94
+ sys.equipment.maxBodies = 1;
95
+ sys.equipment.maxCircuits = 6;
96
+ sys.equipment.maxSchedules = 12;
97
+ sys.equipment.maxPumps = 2;
98
+ sys.equipment.maxSchedules = 12;
99
+ sys.equipment.maxValves = 2;
100
+ sys.equipment.maxCircuitGroups = 0;
101
+ sys.equipment.maxLightGroups = 1;
102
+ sys.equipment.maxIntelliBrites = 8;
103
+ sys.equipment.maxChemControllers = sys.equipment.maxChlorinators = 1;
104
+ sys.equipment.maxCustomNames = 10;
105
+ sys.equipment.maxChemControllers = 4;
106
+ sys.equipment.maxFeatures = 8;
107
+ sys.equipment.model = 'Unknown';
108
+ }
109
+ private static initTouch(msg: Inbound) {
110
+ let model1 = msg.extractPayloadByte(27);
111
+ let model2 = msg.extractPayloadByte(28);
112
+ switch (model2) {
113
+ case 0:
114
+ case 1:
115
+ case 2:
116
+ case 3:
117
+ case 4:
118
+ case 5:
119
+ logger.info(`Found IntelliTouch Controller`);
120
+ sys.controllerType = ControllerType.IntelliTouch;
121
+ model1 = msg.extractPayloadByte(28);
122
+ model2 = msg.extractPayloadByte(9);
123
+ (sys.board as IntelliTouchBoard).initExpansionModules(model1, model2);
124
+ break;
125
+ case 11:
126
+ logger.info(`Found SunTouch Controller`);
127
+ sys.controllerType = ControllerType.SunTouch;
128
+ (sys.board as SunTouchBoard).initExpansionModules(model1, model2);
129
+ break;
130
+ case 13:
131
+ case 14:
132
+ logger.info(`Found EasyTouch Controller`);
133
+ sys.controllerType = ControllerType.EasyTouch;
134
+ (sys.board as EasyTouchBoard).initExpansionModules(model1, model2);
135
+ break;
136
+ default:
137
+ logger.error(`Unknown Touch Controller ${msg.extractPayloadByte(28)}:${msg.extractPayloadByte(27)}`);
138
+ break;
139
+ }
140
+ }
141
+ private static initController(msg: Inbound) {
142
+ const model1 = msg.extractPayloadByte(27);
143
+ const model2 = msg.extractPayloadByte(28);
144
+ // RKS: 06-15-20 -- While this works for now the way we are detecting seems a bit dubious. First, the 2 status message
145
+ // contains two model bytes. Right now the ones witness in the wild include 23 = fw1.023, 40 = fw1.040, 47 = fw1.047.
146
+ // RKS: 07-21-22 -- Pentair is about to release fw1.232. Unfortunately, the byte mapping for this has changed such that
147
+ // the bytes [27,28] are [0,2] respectively. This looks like it might be in conflict with IntelliTouch but it is not. Below
148
+ // are the combinations of 27,28 we have seen for IntelliTouch
149
+ // [1,0] = i5+3
150
+ // [0,1] = i7+3
151
+ // [1,3] = i5+3s
152
+ // [1,4] = i9+3s
153
+ // [1,5] = i10+3d
154
+ // IntelliCenter v3.004 reports [3,2] for bytes [27,28]
155
+ if ((model2 === 0 && (model1 === 23 || model1 >= 40)) ||
156
+ (model2 === 2 && model1 == 0) ||
157
+ (model2 === 2 && model1 == 3)) {
158
+ state.equipment.controllerType = 'intellicenter';
159
+ sys.board.modulesAcquired = false;
160
+ sys.controllerType = ControllerType.IntelliCenter;
161
+ logger.info(`Found Controller Board ${state.equipment.model || 'IntelliCenter'}, awaiting installed modules.`);
162
+ EquipmentStateMessage.initIntelliCenter(msg);
163
+ }
164
+ else {
165
+ EquipmentStateMessage.initTouch(msg);
166
+ sys.board.needsConfigChanges = true;
167
+ setTimeout(function () { sys.checkConfiguration(); }, 300);
168
+ }
169
+ // Set status = 1 AFTER controllerType change, because the controllerType setter
170
+ // resets state.status = 0 during RESETTING DATA
171
+ state.status = 1;
172
+ }
173
+ public static process(msg: Inbound) {
174
+ Message.headerSubByte = msg.header[1];
175
+ //console.log(process.memoryUsage());
176
+ if (msg.action === 2 && state.isInitialized && sys.controllerType === ControllerType.Nixie) {
177
+ // Start over because we didn't have communication before but we now do.
178
+ // Close the nixie board first, then initialize with the new controller type.
179
+ // Fix: Call initController AFTER the async close completes to avoid race condition.
180
+ (async () => {
181
+ await sys.board.closeAsync();
182
+ logger.info(`Closed ${sys.controllerType} board`);
183
+ sys.controllerType = ControllerType.Unknown;
184
+ state.status = 0;
185
+ // Now initialize the correct controller type after nixie is closed
186
+ EquipmentStateMessage.initController(msg);
187
+ })();
188
+ return; // Don't continue processing until async close/init completes
189
+ }
190
+ // If controller type is unknown (e.g., after a replay/system reset), we must re-detect the controller on Action 2
191
+ // even if state has been initialized from disk.
192
+ if (!state.isInitialized || sys.controllerType === ControllerType.Unknown) {
193
+ msg.isProcessed = true;
194
+ if (msg.action === 2) EquipmentStateMessage.initController(msg);
195
+ else return;
196
+ }
197
+ else if (!sys.board.modulesAcquired) {
198
+ msg.isProcessed = true;
199
+ if (msg.action === 204) {
200
+ // We have determined that the 204 message now contains the information
201
+ // related to the installed expansion boards.
202
+ logger.info(`INTELLICENTER MODULES DETECTED, REQUESTING STATUS!`);
203
+
204
+ // IMPORTANT: v3 module decoding depends on `sys.equipment.isIntellicenterV3`, which is gated by controller firmware.
205
+ // During the "modules not acquired yet" bootstrap we must set firmware BEFORE calling `initExpansionModules()`
206
+ // so v3 systems (e.g. i10PS shared) are decoded with the correct nibble order.
207
+ if (msg.payload.length >= 44) {
208
+ sys.equipment.controllerFirmware = (msg.extractPayloadByte(42) + (msg.extractPayloadByte(43) / 1000)).toString();
209
+ }
210
+ // Master = 13-14
211
+ // EXP1 = 15-16
212
+ // EXP2 = 17-18
213
+ let pc = msg.extractPayloadByte(40);
214
+ (sys.board as IntelliCenterBoard).initExpansionModules(msg.extractPayloadByte(13), msg.extractPayloadByte(14),
215
+ pc & 0x01 ? msg.extractPayloadByte(15) : 0x00, pc & 0x01 ? msg.extractPayloadByte(16) : 0x00,
216
+ pc & 0x02 ? msg.extractPayloadByte(17) : 0x00, pc & 0x02 ? msg.extractPayloadByte(18) : 0x00,
217
+ pc & 0x04 ? msg.extractPayloadByte(19) : 0x00, pc & 0x04 ? msg.extractPayloadByte(20) : 0x00);
218
+ sys.equipment.setEquipmentIds();
219
+ // v3.004+: As soon as firmware>=3.0 is known, seed known targetIds so the UI has stable defaults
220
+ // before any user toggles occur. These are best-effort and will be overridden by learned values.
221
+ if (sys.equipment.isIntellicenterV3 && typeof (sys.board.circuits as any)?.seedKnownV3TargetIds === 'function') {
222
+ (sys.board.circuits as any).seedKnownV3TargetIds();
223
+ }
224
+ }
225
+ else return;
226
+ }
227
+ switch (msg.action) {
228
+ case 2:
229
+ {
230
+ let fnTempFromByte = function (byte) {
231
+ return byte;
232
+ //return (byte & 0x007F) * (((byte & 0x0080) > 0) ? -1 : 1); // RKS: 09-26-20 Not sure how negative temps are represented but this aint it. Temps > 127 have been witnessed.
233
+ }
234
+
235
+ // Shared
236
+ let dt = new Date();
237
+ // RKS: This was moved to the ChemControllerState message. This is flawed in that it incorrectly sets IntelliChem to no comms.
238
+ //if (state.chemControllers.length > 0) {
239
+ // // TODO: move this to chemController when we understand the packets better
240
+ // for (let i = 0; i < state.chemControllers.length; i++) {
241
+ // let ccontroller = state.chemControllers.getItemByIndex(i);
242
+ // if (sys.board.valueMaps.chemControllerTypes.getName(ccontroller.type) === 'intellichem') {
243
+ // if (dt.getTime() - ccontroller.lastComm > 60000) ccontroller.status = 1;
244
+ // }
245
+ // }
246
+ //}
247
+ state.time.hours = msg.extractPayloadByte(0);
248
+ state.time.minutes = msg.extractPayloadByte(1);
249
+ state.time.seconds = dt.getSeconds();
250
+ state.mode = sys.controllerType !== ControllerType.IntelliCenter ? (msg.extractPayloadByte(9) & 0x81) : (msg.extractPayloadByte(9) & 0x01);
251
+
252
+ // RKS: The units have been normalized for English and Metric for the overall panel. It is important that the val numbers match for at least the temp units since
253
+ // the only unit of measure native to the Touch controllers is temperature they chose to name these C or F. However, with the njsPC extensions this is non-semantic
254
+ // since pressure, volume, and length have been introduced.
255
+ sys.general.options.units = state.temps.units = msg.extractPayloadByte(9) & 0x04;
256
+ state.valve = msg.extractPayloadByte(10);
257
+
258
+
259
+ // RSG - added 7/8/2020
260
+ // Every 30 mins, check the timezone and adjust DST settings
261
+ if (dt.getMinutes() % 30 === 0) {
262
+ sys.board.system.setTZ();
263
+ sys.board.schedules.updateSunriseSunsetAsync().then((updated: boolean)=>{
264
+ if (updated) {logger.debug(`Sunrise/sunset times updated on schedules.`);}
265
+ });
266
+ }
267
+ // Check and update clock when it is off by >5 mins (just for a small buffer) and:
268
+ // 1. IntelliCenter has "manual" time set (Internet will automatically adjust) and autoAdjustDST is enabled
269
+ // 2. *Touch is "manual" (only option) and autoAdjustDST is enabled - (same as #1)
270
+ // 3. clock source is "server" isn't an OCP option but can be enabled on the clients
271
+ if (dt.getMinutes() % 5 === 0 && dt.getSeconds() <= 10 && sys.general.options.clockSource === 'server') {
272
+ if ((Math.abs(dt.getTime() - state.time.getTime()) > 60 * 2 * 1000) && !state.time.isUpdating) {
273
+ state.time.isUpdating = true;
274
+ sys.board.system.setDateTimeAsync({ dt, dst: sys.general.options.adjustDST || 0, })
275
+ .then(() => {
276
+ logger.info(`njsPC automatically updated OCP time. You're welcome.`);
277
+ })
278
+ .catch((err) => {
279
+ logger.error(`Error automatically setting system time. ${JSON.stringify(err)}`)
280
+ })
281
+ .finally(() => {
282
+ state.time.isUpdating = false;
283
+ })
284
+ }
285
+ }
286
+ state.delay = msg.extractPayloadByte(12) & 63; // not sure what 64 val represents
287
+ state.freeze = (msg.extractPayloadByte(9) & 0x08) === 0x08;
288
+ if (sys.controllerType === ControllerType.IntelliCenter) {
289
+ state.temps.waterSensor1 = fnTempFromByte(msg.extractPayloadByte(14));
290
+ // IntelliCenter: for 2-body non-shared systems, byte(15) is Body2 (Spa) water sensor.
291
+ // Previously gated behind (>2 bodies || dual), which left Spa temp undefined and rendered as "--" in dashPanel.
292
+ if (sys.bodies.length > 1 || sys.equipment.dual) state.temps.waterSensor2 = fnTempFromByte(msg.extractPayloadByte(15));
293
+ // We are making an assumption here in that the circuits are always labeled the same.
294
+ // 1=Spa/Body2
295
+ // 6=Pool/Body1
296
+ // 12=Body3
297
+ // 22=Body4 -- Really not sure about this one.
298
+ if (sys.bodies.length > 0) {
299
+ // We will not go in here if this is not a shared body.
300
+ const tbody: BodyTempState = state.temps.bodies.getItemById(1, true);
301
+ const cbody: Body = sys.bodies.getItemById(1);
302
+ tbody.heatMode = cbody.heatMode;
303
+ tbody.setPoint = cbody.setPoint;
304
+ tbody.name = cbody.name;
305
+ tbody.circuit = cbody.circuit = 6;
306
+ tbody.heatStatus = msg.extractPayloadByte(11) & 0x0F;
307
+ // With the IntelliCenter i10D, bit 6 is not reliable. It is not set properly and requires the 204 message
308
+ // to process the data.
309
+ if (!sys.equipment.dual) {
310
+ if ((msg.extractPayloadByte(2) & 0x20) === 32) {
311
+ tbody.temp = state.temps.waterSensor1;
312
+ tbody.isOn = true;
313
+ } else tbody.isOn = false;
314
+ }
315
+ else if (state.circuits.getItemById(6).isOn === true) {
316
+ tbody.temp = state.temps.waterSensor1;
317
+ tbody.isOn = true;
318
+ }
319
+ else tbody.isOn = false;
320
+ }
321
+ if (sys.bodies.length > 1) {
322
+ const tbody: BodyTempState = state.temps.bodies.getItemById(2, true);
323
+ const cbody: Body = sys.bodies.getItemById(2);
324
+ tbody.heatMode = cbody.heatMode;
325
+ tbody.setPoint = cbody.setPoint;
326
+ tbody.name = cbody.name;
327
+ tbody.circuit = cbody.circuit = 1;
328
+ tbody.heatStatus = (msg.extractPayloadByte(11) & 0xF0) >> 4;
329
+ if (!sys.equipment.dual) {
330
+ if ((msg.extractPayloadByte(2) & 0x01) === 1) {
331
+ tbody.temp = sys.equipment.shared ? state.temps.waterSensor1 : state.temps.waterSensor2;
332
+ tbody.isOn = true;
333
+ } else tbody.isOn = false;
334
+ } else if (state.circuits.getItemById(1).isOn === true) {
335
+ tbody.temp = sys.equipment.shared ? state.temps.waterSensor1 : state.temps.waterSensor2;
336
+ tbody.isOn = true;
337
+ }
338
+ else tbody.isOn = false;
339
+ }
340
+ if (sys.bodies.length > 2) {
341
+ state.temps.waterSensor3 = fnTempFromByte(msg.extractPayloadByte(20));
342
+ // const tbody: BodyTempState = state.temps.bodies.getItemById(10, true);
343
+ const tbody: BodyTempState = state.temps.bodies.getItemById(3, true);
344
+ const cbody: Body = sys.bodies.getItemById(3);
345
+ tbody.name = cbody.name;
346
+ tbody.heatMode = cbody.heatMode;
347
+ tbody.setPoint = cbody.setPoint;
348
+ tbody.heatStatus = msg.extractPayloadByte(11) & 0x0F;
349
+ tbody.circuit = cbody.circuit = 12;
350
+ if ((msg.extractPayloadByte(3) & 0x08) === 8) {
351
+ // This is the first circuit on the second body.
352
+ tbody.temp = state.temps.waterSensor3;
353
+ tbody.isOn = true;
354
+ } else tbody.isOn = false;
355
+ }
356
+ if (sys.bodies.length > 3) {
357
+ state.temps.waterSensor4 = fnTempFromByte(msg.extractPayloadByte(21));
358
+ // const tbody: BodyTempState = state.temps.bodies.getItemById(19, true);
359
+ const tbody: BodyTempState = state.temps.bodies.getItemById(4, true);
360
+ const cbody: Body = sys.bodies.getItemById(4);
361
+ tbody.name = cbody.name;
362
+ tbody.heatMode = cbody.heatMode;
363
+ tbody.setPoint = cbody.setPoint;
364
+ tbody.heatStatus = (msg.extractPayloadByte(11) & 0xF0) >> 4;
365
+ tbody.circuit = cbody.circuit = 22;
366
+ if ((msg.extractPayloadByte(5) & 0x20) === 32) {
367
+ // This is the first circuit on the third body or the first circuit on the second expansion.
368
+ tbody.temp = state.temps.waterSensor2;
369
+ tbody.isOn = true;
370
+ } else tbody.isOn = false;
371
+ }
372
+ state.temps.air = fnTempFromByte(msg.extractPayloadByte(18)); // 18
373
+ state.temps.solarSensor1 = fnTempFromByte(msg.extractPayloadByte(19)); // 19
374
+ if (sys.bodies.length > 2 || sys.equipment.dual)
375
+ state.temps.solarSensor2 = fnTempFromByte(msg.extractPayloadByte(17));
376
+ if ((sys.bodies.length > 2))
377
+ state.temps.solarSensor3 = fnTempFromByte(msg.extractPayloadByte(22));
378
+ if ((sys.bodies.length > 3))
379
+ state.temps.solarSensor4 = fnTempFromByte(msg.extractPayloadByte(23));
380
+
381
+ if (sys.general.options.clockSource !== 'server' || typeof sys.general.options.adjustDST === 'undefined') sys.general.options.adjustDST = (msg.extractPayloadByte(23) & 0x01) === 0x0; //23
382
+ }
383
+ else {
384
+ state.temps.waterSensor1 = fnTempFromByte(msg.extractPayloadByte(14));
385
+ state.temps.air = fnTempFromByte(msg.extractPayloadByte(18));
386
+ let solar: Heater = sys.heaters.getItemById(2);
387
+ if (solar.isActive) state.temps.solar = fnTempFromByte(msg.extractPayloadByte(19));
388
+ //[15, 34, 32, 0, 0, 0, 0, 0, 0, 0, 83, 0, 0, 0, 81, 81, 32, 91, 82, 91, 0, 0, 7, 4, 0, 77, 163, 1, 0][4, 78]
389
+ // byte | val |
390
+ // 0 | 15 | Hours
391
+ // 1 | 34 | Minutes
392
+ // 2 | 32 | Circuits 1-8 bit 6 = Pool on.
393
+ // 3 | 0 | Circuits 9-16
394
+ // 4 | 0 | Circuits 17-24
395
+ // 5 | 0 | Circuits 24-32
396
+ // 6 | 0 | Circuits 33-40
397
+ // 7 | 0 | Unknown
398
+ // 8 | 0 | Unknown
399
+ // 9 | 0 | Panel Mode bit flags
400
+ // 10 | 83 | Heat status for body 1 & 2 (This says solar is on for the pool and spa because this is the body that is running)
401
+ // 11 | 0 | Unknown (This could be the heat status for body 3 & 4)
402
+ // 12 | 0 | Unknown
403
+ // 13 | 0 | Unknown
404
+ // 14 | 81 | Water sensor 1 temperature
405
+ // 15 | 81 | Water sensor 2 temperature (This mirrors water sensor 1 in shared system)
406
+ // 16 | 32 | Unknown
407
+ // 17 | 91 | Solar sensor 1 temperature
408
+ // 18 | 82 | Air temp
409
+ // 19 | 91 | Solar sensor 2 temperature (this mirrors solar sensor 1 in shared system)
410
+ // 20 | 0 | Unknown (this could be water sensor 3)
411
+ // 21 | 0 | Unknown (this could be water sensor 4)
412
+ // 22 | 7 | Body 1 & 2 heat mode (body 1 = Solar Only body 2 = Heater)
413
+ // 23 | 4 | Body 3 & 4 heat mode
414
+ // 24 | 0 | Unknown
415
+ // 25 | 77 | Unknown
416
+ // 26 | 163 | Unknown
417
+ // 27 | 1 | Byte 2 of OCP identifier
418
+ // 28 | 0 | Byte 1 of OCP identifier
419
+
420
+
421
+ // Heat Modes
422
+ // 1 = Heater
423
+ // 2 = Solar Preferred
424
+ // 3 = Solar Only
425
+
426
+ // Heat Status
427
+ // 0 = Off
428
+ // 1 = Heater
429
+ // 2 = Cooling
430
+ // 3 = Solar/Heat Pump
431
+
432
+ // Pool Heat Mode/Status.
433
+ // When temp setpoint and pool in heater mode went above the current pool temp byte 10 went from 67 to 71. The upper two bits of the
434
+ // lower nibble changed on bit 3. So 0100 0111 from 0100 0011
435
+
436
+ // Spa Heat Mode/Status
437
+ // When switching from pool to spa with both heat modes set to off byte 10 went from 67 to 75 and byte(16) changed from 0 to 32. The upper two bits of the lower nibble
438
+ // changed on byte(10) bit 4. So to 0100 1011 from 0100 0011. Interestingly this seems to indicate that the spa turned on. This almost appears as if the heater engaged
439
+ // automatically like the spa has manual heat turned off.
440
+ // When the heat mode was changed to solar only byte 10 went to 75 from 67 so bit 4 switched off and byte(16) changed to 0. At this point the water temp is 86 and the
441
+ // solar temp is 79 so the solar should not be coming on.
442
+ // When the setpoint was dropped below the water temp bit 5 on byte(10) swiched back off and byte(16) remained at 0. I think there is no bearing here on this.
443
+ // When the heat mode was changed to solar preferred and the setpoint was raised to 104F the heater kicked on and bit 5 changed from 0 to 1. So byte(10) went from
444
+ // 0100 0011 to 0100 1011 this is consistent with the heater coming on for the spa. In this instance byte(16) also changed back to 32 which would be consistent with
445
+ // an OCP where the manual heat was turned off.
446
+
447
+ // RKS: Added check for i10d for water sensor 2.
448
+ if (sys.bodies.length > 2 || sys.equipment.dual) state.temps.waterSensor2 = fnTempFromByte(msg.extractPayloadByte(15));
449
+ if (sys.bodies.length > 0) {
450
+ // const tbody: BodyTempState = state.temps.bodies.getItemById(6, true);
451
+ const tbody: BodyTempState = state.temps.bodies.getItemById(1, true);
452
+ const cbody: Body = sys.bodies.getItemById(1);
453
+ if ((msg.extractPayloadByte(2) & 0x20) === 32) {
454
+ tbody.temp = state.temps.waterSensor1;
455
+ tbody.isOn = true;
456
+ } else tbody.isOn = false;
457
+ tbody.setPoint = cbody.setPoint;
458
+ tbody.name = cbody.name;
459
+ tbody.circuit = cbody.circuit = 6;
460
+
461
+ //RKS: This heat mode did not include all the bits necessary for hybrid heaters
462
+ //tbody.heatMode = cbody.heatMode = msg.extractPayloadByte(22) & 0x03;
463
+ tbody.heatMode = cbody.heatMode = msg.extractPayloadByte(22) & 0x33;
464
+ let heatStatus = sys.board.valueMaps.heatStatus.getValue('off');
465
+ if (tbody.isOn) {
466
+ if (tbody.heaterOptions.hybrid > 0) {
467
+ // ETi When heating with
468
+ // Heatpump (1) = 12 H:true S:false C:false
469
+ // Gas (2) = 48 H:false S:true C:false
470
+ // Hybrid (3) = 48 H:true S:false C:false
471
+ // Dual (16) = 60 H:true S:true C:false
472
+ // What this means is that Touch actually treats the heat status as either heating with
473
+ // the primary heater for the body or the secondary. In the case of a hybrid heater
474
+ // the primary is a heatpump and the secondary is gas. In the case of gas + solar or gas + heatpump
475
+ // the gas heater is the primary and solar or heatpump is the secondary. So we need to dance a little bit
476
+ // here. We do this by checking the heater options.
477
+ if (tbody.heatMode > 0) { // Turns out that ET sometimes reports the last heat status when off.
478
+ // This can be the only heater solar cannot be installed with this.
479
+ let byte = msg.extractPayloadByte(10);
480
+ // Either the primary, secondary, or both is engaged.
481
+ if ((byte & 0x14) === 0x14) heatStatus = sys.board.valueMaps.heatStatus.getValue('dual');
482
+ // else if ((byte & 0x0c) === 0x0c) heatStatus = sys.board.valueMaps.heatStatus.getValue('off'); // don't need since we test for heatMode>0
483
+ else if (byte & 0x10) heatStatus = sys.board.valueMaps.heatStatus.getValue('heater');
484
+ else if (byte & 0x04) heatStatus = sys.board.valueMaps.heatStatus.getValue('hpheat');
485
+ }
486
+ }
487
+ else {
488
+ //const heaterActive = (msg.extractPayloadByte(10) & 0x0C) === 12;
489
+ //const solarActive = (msg.extractPayloadByte(10) & 0x30) === 48;
490
+ const heaterActive = (msg.extractPayloadByte(10) & 0x04) === 0x04;
491
+ const solarActive = (msg.extractPayloadByte(10) & 0x10) === 0x10;
492
+ const cooling = solarActive && tbody.temp > tbody.setPoint;
493
+ if (heaterActive) heatStatus = sys.board.valueMaps.heatStatus.getValue('heater');
494
+ if (cooling) heatStatus = sys.board.valueMaps.heatStatus.getValue('cooling');
495
+ else if (solarActive) heatStatus = sys.board.valueMaps.heatStatus.getValue('solar');
496
+ }
497
+ }
498
+ tbody.heatStatus = heatStatus;
499
+ sys.board.schedules.syncScheduleHeatSourceAndSetpoint(cbody, tbody);
500
+ }
501
+ if (sys.bodies.length > 1) {
502
+ // const tbody: BodyTempState = state.temps.bodies.getItemById(1, true);
503
+ const tbody: BodyTempState = state.temps.bodies.getItemById(2, true);
504
+ const cbody: Body = sys.bodies.getItemById(2);
505
+ if ((msg.extractPayloadByte(2) & 0x01) === 1) {
506
+ tbody.temp = sys.equipment.shared ? state.temps.waterSensor1 : state.temps.waterSensor2;
507
+ tbody.isOn = true;
508
+ } else tbody.isOn = false;
509
+ //RKS: This heat mode did not include all the bits necessary for hybrid heaters
510
+ //tbody.heatMode = cbody.heatMode = (msg.extractPayloadByte(22) & 0x0C) >> 2;
511
+ tbody.heatMode = cbody.heatMode = (msg.extractPayloadByte(22) & 0xCC) >> 2;
512
+ tbody.setPoint = cbody.setPoint;
513
+ tbody.name = cbody.name;
514
+ tbody.circuit = cbody.circuit = 1;
515
+ let heatStatus = sys.board.valueMaps.heatStatus.getValue('off');
516
+ if (tbody.isOn) {
517
+ if (tbody.heaterOptions.hybrid > 0) {
518
+ // This can be the only heater solar cannot be installed with this.
519
+ if (tbody.heatMode > 0) {
520
+ let byte = msg.extractPayloadByte(10);
521
+ // Either the primary, secondary, or both is engaged.
522
+ if ((byte & 0x28) === 0x28) heatStatus = sys.board.valueMaps.heatStatus.getValue('dual');
523
+ else if (byte & 0x20) heatStatus = sys.board.valueMaps.heatStatus.getValue('heater');
524
+ else if (byte & 0x08) heatStatus = sys.board.valueMaps.heatStatus.getValue('hpheat');
525
+ }
526
+ }
527
+ else {
528
+ //const heaterActive = (msg.extractPayloadByte(10) & 0x0C) === 12;
529
+ //const solarActive = (msg.extractPayloadByte(10) & 0x30) === 48;
530
+ const heaterActive = (msg.extractPayloadByte(10) & 0x08) === 0x08;
531
+ const solarActive = (msg.extractPayloadByte(10) & 0x20) === 0x20;
532
+ const cooling = solarActive && tbody.temp > tbody.setPoint;
533
+ if (heaterActive) heatStatus = sys.board.valueMaps.heatStatus.getValue('heater');
534
+ if (cooling) heatStatus = sys.board.valueMaps.heatStatus.getValue('cooling');
535
+ else if (solarActive) heatStatus = sys.board.valueMaps.heatStatus.getValue('solar');
536
+ }
537
+ }
538
+ tbody.heatStatus = heatStatus;
539
+ sys.board.schedules.syncScheduleHeatSourceAndSetpoint(cbody, tbody);
540
+ }
541
+ }
542
+ switch (sys.controllerType) {
543
+ case ControllerType.IntelliCenter:
544
+ {
545
+ EquipmentStateMessage.processCircuitState(msg);
546
+ // v3.004+: DISABLED - Action 2 bytes 7-8 use non-bitmask encoding
547
+ // Feature state for v3.004+ comes from Action 30 case 15 responses only
548
+ // See: https://github.com/tagyoureit/nodejs-poolController/issues/XXX
549
+ // if (sys.equipment.isIntellicenterV3) {
550
+ // EquipmentStateMessage.processFeatureStateV3(msg);
551
+ // }
552
+ sys.board.circuits.syncCircuitRelayStates();
553
+ sys.board.circuits.syncVirtualCircuitStates();
554
+ sys.board.valves.syncValveStates();
555
+ sys.board.filters.syncFilterStates();
556
+ state.emitControllerChange();
557
+ state.emitEquipmentChanges();
558
+ sys.board.heaters.syncHeaterStates();
559
+ break;
560
+ }
561
+ case ControllerType.SunTouch:
562
+ EquipmentStateMessage.processSunTouchCircuits(msg);
563
+ sys.board.circuits.syncCircuitRelayStates();
564
+ sys.board.features.syncGroupStates();
565
+ sys.board.circuits.syncVirtualCircuitStates();
566
+ sys.board.valves.syncValveStates();
567
+ sys.board.filters.syncFilterStates();
568
+ state.emitControllerChange();
569
+ state.emitEquipmentChanges();
570
+ sys.board.heaters.syncHeaterStates();
571
+ sys.board.schedules.syncScheduleStates();
572
+ break;
573
+ case ControllerType.EasyTouch:
574
+ case ControllerType.IntelliCom:
575
+ case ControllerType.IntelliTouch:
576
+ {
577
+ EquipmentStateMessage.processTouchCircuits(msg);
578
+ // This will toggle the group states depending on the state of the individual circuits.
579
+ sys.board.circuits.syncCircuitRelayStates();
580
+ sys.board.features.syncGroupStates();
581
+ sys.board.circuits.syncVirtualCircuitStates();
582
+ sys.board.valves.syncValveStates();
583
+ sys.board.filters.syncFilterStates();
584
+ state.emitControllerChange();
585
+ state.emitEquipmentChanges();
586
+ sys.board.heaters.syncHeaterStates();
587
+ sys.board.schedules.syncScheduleStates();
588
+ break;
589
+ }
590
+ }
591
+ }
592
+ break;
593
+ case 5: // Intellitouch only. Date/Time packet
594
+ // [255,0,255][165,1,15,16,5,8][15,10,8,1,8,18,0,1][1,15]
595
+ state.time.hours = msg.extractPayloadByte(0);
596
+ state.time.minutes = msg.extractPayloadByte(1);
597
+ // state.time.dayOfWeek = msg.extractPayloadByte(2);
598
+ state.time.date = msg.extractPayloadByte(3);
599
+ state.time.month = msg.extractPayloadByte(4);
600
+ state.time.year = msg.extractPayloadByte(5);
601
+ if (sys.general.options.clockSource !== 'server' || typeof sys.general.options.adjustDST === 'undefined') sys.general.options.adjustDST = msg.extractPayloadByte(7) === 0x01;
602
+ setTimeout(function () { sys.board.checkConfiguration(); }, 100);
603
+ msg.isProcessed = true;
604
+ break;
605
+ case 8: {
606
+ // IntelliTouch only. Heat status
607
+ // [165,x,15,16,8,13],[75,75,64,87,101,11,0, 0 ,62 ,0 ,0 ,0 ,0] ,[2,190]
608
+ // Heat Modes
609
+ // 1 = Heater
610
+ // 2 = Solar Preferred
611
+ // 3 = Solar Only
612
+ //[81, 81, 82, 85, 97, 7, 0, 0, 0, 100, 100, 4, 0][3, 87]
613
+ // byte | val |
614
+ // 0 | 81 | Water sensor 1
615
+ // 1 | 81 | Unknown (Probably water sensor 2 on a D)
616
+ // 2 | 82 | Air sensor
617
+ // 3 | 85 | Body 1 setpoint
618
+ // 4 | 97 | Body 2 setpoint
619
+ // 5 | 7 | Body 1 & 2 heat mode. (0111) (Pool = 11 Solar only/Spa = 01 Heater)
620
+ // 6 | 0 | Unknown (Water Sensor 3)
621
+ // 7 | 0 | Unknown (Water Sensor 4)
622
+ // 8 | 0 | Unknown -- Reserved air sensor
623
+ // 9 | 100 | Unknown (Body 3 setpoint)
624
+ // 10 | 100 | Unknown (Body 4 setpoint)
625
+ // 11 | 4 | Unknown (Body 3 & 4 head mode. (0010) (Pool = 00 = Off/ 10 = Solar Preferred)
626
+ // 12 | 0 | Unknown
627
+ // There are two messages sent when the OCP tries to tse a heat mode in IntelliTouch. The first one on the action 136 is for the first 2 bodies and the second
628
+ // is for the remaining 2 bodies. The second half of this message mirrors the values for the second 136 message.
629
+ // [255, 0, 255][165, 1, 16, 32, 136, 4][100, 100, 4, 1][2, 47]
630
+ state.temps.waterSensor1 = msg.extractPayloadByte(0);
631
+ state.temps.air = msg.extractPayloadByte(2);
632
+ let solar: Heater = sys.heaters.getItemById(2);
633
+ // RKS: 05-18-22 - This is not correct the solar temp is not stored on this message. It is always 0
634
+ // on an intelliTouch system with solar.
635
+ //if (solar.isActive) state.temps.solar = msg.extractPayloadByte(8);
636
+ // pool
637
+ let tbody: BodyTempState = state.temps.bodies.getItemById(1, true);
638
+ let cbody: Body = sys.bodies.getItemById(1);
639
+ // RKS: 02-26-22 - See communications doc for explanation of bits. This needs to support UltraTemp ETi heatpumps.
640
+ tbody.heatMode = cbody.heatMode = msg.extractPayloadByte(5) & 0x33;
641
+ tbody.setPoint = cbody.setPoint = msg.extractPayloadByte(3);
642
+ tbody.coolSetpoint = cbody.coolSetpoint = msg.extractPayloadByte(9);
643
+ if (tbody.isOn) tbody.temp = state.temps.waterSensor1;
644
+ cbody = sys.bodies.getItemById(2);
645
+ if (cbody.isActive) {
646
+ // spa
647
+ tbody = state.temps.bodies.getItemById(2, true);
648
+ tbody.heatMode = cbody.heatMode = (msg.extractPayloadByte(5) & 0xCC) >> 2;
649
+ //tbody.heatMode = cbody.heatMode = (msg.extractPayloadByte(5) & 12) >> 2;
650
+ tbody.setPoint = cbody.setPoint = msg.extractPayloadByte(4);
651
+ if (tbody.isOn) tbody.temp = state.temps.waterSensor2 = msg.extractPayloadByte(1);
652
+ }
653
+ state.emitEquipmentChanges();
654
+ msg.isProcessed = true;
655
+ break;
656
+ }
657
+ case 96:
658
+ EquipmentStateMessage.processIntelliBriteMode(msg);
659
+ break;
660
+ case 179: {
661
+ // v3.004+ Action 179 - Heartbeat REQUEST from OCP
662
+ // OCP sends Action 179 TO specific device (dest=33 for njsPC, dest=36 for wireless)
663
+ // Device must respond with Action 180 TO OCP (dest=16)
664
+ if (msg.dest === Message.pluginAddress) {
665
+ // OCP is pinging us specifically - respond with Action 180
666
+ logger.silly(`Received heartbeat request (Action 179) from OCP, responding with Action 180`);
667
+ const response: Outbound = Outbound.create({
668
+ dest: 16, // Respond to OCP (16)
669
+ action: 180, // Action 180 = heartbeat response
670
+ payload: Array(16).fill(0), // 16 zeros (observed from wireless remote)
671
+ retries: 0 // Don't retry heartbeat responses
672
+ });
673
+ response.sendAsync().catch(err => {
674
+ // Log but don't fail on heartbeat errors
675
+ logger.silly(`Heartbeat response error: ${err.message}`);
676
+ });
677
+ }
678
+ msg.isProcessed = true;
679
+ break;
680
+ }
681
+ case 184: {
682
+ // v3.004+ Action 184 - Circuit state broadcast / control
683
+ // OCP broadcasts this for circuit state changes AND periodically for status.
684
+ // Wireless remote sends this to control circuits.
685
+ //
686
+ // Payload structure (10 bytes):
687
+ // Bytes 0-1: Channel/context ID (104,143 = default, or circuit-specific like 108,225)
688
+ // Byte 2: Sequence number
689
+ // Byte 3: Format (255 = command, 0 = status)
690
+ // Bytes 4-5: Target ID (unique circuit identifier, e.g., 168,237 = Spa, 108,225 = Pool)
691
+ // Byte 6: State (0=OFF, 1=ON, 255=idle for body status)
692
+ // Bytes 7-9: Additional data (usually 0)
693
+ //
694
+ // KEY PATTERN: When channel ID (bytes 0-1) equals Target ID (bytes 4-5),
695
+ // this identifies a specific circuit (e.g., Pool uses 108,225 for both).
696
+ //
697
+ // Learning strategies:
698
+ // 1. Channel = Target pattern: strong identification
699
+ // 2. State correlation: match broadcast state with circuit states
700
+ // 3. Only ONE circuit matches: definitive mapping
701
+ if (sys.controllerType === ControllerType.IntelliCenter &&
702
+ sys.equipment.isIntellicenterV3 &&
703
+ msg.payload.length >= 10) {
704
+
705
+ const channelIdHi = msg.extractPayloadByte(0);
706
+ const channelIdLo = msg.extractPayloadByte(1);
707
+ const channelId = channelIdHi * 256 + channelIdLo;
708
+ const targetIdHi = msg.extractPayloadByte(4);
709
+ const targetIdLo = msg.extractPayloadByte(5);
710
+ const targetId = targetIdHi * 256 + targetIdLo;
711
+ const circuitState = msg.extractPayloadByte(6);
712
+
713
+ // Process from OCP broadcasts (source=16) AND Wireless commands (source=36)
714
+ // Skip idle status (byte 6 = 255) and body status target (212,182 = 0xD4B6 = 54454)
715
+ // Learning from Wireless: When Wireless sends targetId X to OCP with state Y,
716
+ // and OCP ACKs, we know targetId X controls the circuit that changed to state Y.
717
+ const isFromOCP = msg.source === 16;
718
+ const isFromWireless = msg.source === 36 && msg.dest === 16; // Wireless→OCP command
719
+
720
+ // Cache Wireless commands for later correlation with state changes
721
+ // This helps learn targetId when OCP doesn't broadcast the state=1 message
722
+ if (isFromWireless && circuitState !== 255 && targetId !== 54454) {
723
+ pendingAction184Commands.set(targetId, { state: circuitState, timestamp: Date.now() });
724
+ logger.debug(`v3.004+ Action 184: Cached pending Wireless command - Target ${targetId}, State=${circuitState === 1 ? 'ON' : 'OFF'}`);
725
+ }
726
+
727
+ if ((isFromOCP || isFromWireless) && circuitState !== 255 && targetId !== 54454) {
728
+ const sourceDesc = isFromOCP ? 'OCP broadcast' : 'Wireless command';
729
+ const isOn = circuitState === 1;
730
+
731
+ // Strategy 1: Channel = Target pattern (e.g., Pool circuit 6 uses 108,225 for both)
732
+ // This is a strong signal - the circuit "owns" this channel
733
+ if (channelId === targetId) {
734
+ // Find body circuit with this pattern
735
+ for (let i = 0; i < sys.bodies.length; i++) {
736
+ const body = sys.bodies.getItemByIndex(i);
737
+ if (body.isActive && typeof body.circuit === 'number' && body.circuit > 0) {
738
+ const sbody = state.temps.bodies.getItemById(body.id);
739
+ // For channel=target, trust it even if states don't match perfectly
740
+ // (OCP might broadcast before state is updated)
741
+ const circ = sys.circuits.getItemById(body.circuit, false);
742
+ if (circ && circ.isActive && (typeof circ.targetId === 'undefined' || circ.targetId === 0)) {
743
+ // Only learn if we don't have one yet - channel=target is reliable
744
+ if (sbody && sbody.isOn === isOn) {
745
+ const owner = findActiveCircuitTargetIdOwner(targetId, body.circuit);
746
+ if (!owner) {
747
+ logger.silly(`v3.004+ Action 184: Learned Target ID ${targetId} (${targetIdHi},${targetIdLo}) for body ${body.id} circuit ${body.circuit} (${circ.name || 'unnamed'}) [channel=target pattern]`);
748
+ circ.targetId = targetId;
749
+ } else {
750
+ logger.warn(
751
+ `v3.004+ Action 184: Refusing to learn duplicate targetId ${targetId} for body circuit ${body.circuit} (${circ.name || 'unnamed'}); ` +
752
+ `already owned by circuit ${owner.id} (${owner.name || 'unnamed'}).`
753
+ );
754
+ }
755
+ }
756
+ }
757
+ }
758
+ }
759
+ }
760
+
761
+ // Strategy 2: State correlation - find circuits matching this state
762
+ // Count how many circuits match to avoid ambiguity
763
+ // PRIORITY: Circuits without targetId that match state
764
+ let matchingCircuitsNoTargetId: { id: number, name: string }[] = [];
765
+ let matchingCircuitsWithTargetId: { id: number, name: string }[] = [];
766
+
767
+ // Check body circuits first (Spa=circuit 1, Pool=circuit 6 in shared systems)
768
+ for (let i = 0; i < sys.bodies.length; i++) {
769
+ const body = sys.bodies.getItemByIndex(i);
770
+ if (body.isActive && typeof body.circuit === 'number' && body.circuit > 0) {
771
+ const sbody = state.temps.bodies.getItemById(body.id);
772
+ const circ = sys.circuits.getItemById(body.circuit, false);
773
+ if (sbody && sbody.isOn === isOn && circ && circ.isActive) {
774
+ if (typeof circ.targetId === 'undefined' || circ.targetId === 0) {
775
+ matchingCircuitsNoTargetId.push({ id: body.circuit, name: circ.name || `Body ${body.id}` });
776
+ } else if (circ.targetId !== targetId) {
777
+ matchingCircuitsWithTargetId.push({ id: body.circuit, name: circ.name || `Body ${body.id}` });
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ // Also check regular circuits
784
+ for (let i = 0; i < sys.circuits.length; i++) {
785
+ const circ = sys.circuits.getItemByIndex(i);
786
+ if (circ.isActive) {
787
+ const cstate = state.circuits.getItemById(circ.id);
788
+ if (cstate && cstate.isOn === isOn) {
789
+ // Avoid duplicate if already counted as body circuit
790
+ if (!matchingCircuitsNoTargetId.find(m => m.id === circ.id) &&
791
+ !matchingCircuitsWithTargetId.find(m => m.id === circ.id)) {
792
+ if (typeof circ.targetId === 'undefined' || circ.targetId === 0) {
793
+ matchingCircuitsNoTargetId.push({ id: circ.id, name: circ.name || `Circuit ${circ.id}` });
794
+ } else if (circ.targetId !== targetId) {
795
+ matchingCircuitsWithTargetId.push({ id: circ.id, name: circ.name || `Circuit ${circ.id}` });
796
+ }
797
+ }
798
+ }
799
+ }
800
+ }
801
+
802
+ // Strategy 3: Only ONE circuit without targetId matches - definitive mapping
803
+ // This is the best case: we know exactly which circuit needs learning
804
+ if (matchingCircuitsNoTargetId.length === 1) {
805
+ const match = matchingCircuitsNoTargetId[0];
806
+ const circ = sys.circuits.getItemById(match.id, false);
807
+ if (circ) {
808
+ const owner = findActiveCircuitTargetIdOwner(targetId, match.id);
809
+ if (!owner) {
810
+ logger.silly(`v3.004+ Action 184: Learned Target ID ${targetId} (${targetIdHi},${targetIdLo}) for circuit ${match.id} (${match.name}) [unique unassigned match]`);
811
+ circ.targetId = targetId;
812
+ } else {
813
+ logger.warn(
814
+ `v3.004+ Action 184: Refusing to learn duplicate targetId ${targetId} for circuit ${match.id} (${match.name}); ` +
815
+ `already owned by circuit ${owner.id} (${owner.name || 'unnamed'}).`
816
+ );
817
+ }
818
+ }
819
+ } else if (matchingCircuitsNoTargetId.length > 1) {
820
+ // Multiple unassigned circuits match - can't determine which
821
+ logger.silly(`v3.004+ Action 184: Target ${targetId} (state=${isOn ? 'ON' : 'OFF'}) matches ${matchingCircuitsNoTargetId.length} unassigned circuits (${matchingCircuitsNoTargetId.map(m => m.name).join(', ')}) - waiting for unique match`);
822
+ } else if (matchingCircuitsNoTargetId.length === 0 && matchingCircuitsWithTargetId.length === 0) {
823
+ // No matching circuits - might be a feature or virtual circuit
824
+ logger.debug(`v3.004+ Action 184: Target ${targetId} (state=${isOn ? 'ON' : 'OFF'}) has no matching circuits - possibly feature or group`);
825
+ }
826
+
827
+ logger.debug(`v3.004+ Action 184 (${sourceDesc}): Channel=${channelId}, Target=${targetId} (${targetIdHi},${targetIdLo}), State=${isOn ? 'ON' : 'OFF'}`);
828
+ }
829
+ }
830
+ msg.isProcessed = true;
831
+ break;
832
+ }
833
+ case 217: {
834
+ // v3.004+ Action 217 - Device list broadcast
835
+ // OCP broadcasts registered devices after Action 251→253 handshake
836
+ // Each packet contains info for ONE device
837
+ // Check if this packet is for njsPC (device 33) and update registration status
838
+ if (msg.payload.length > 2 && msg.extractPayloadByte(0) === Message.pluginAddress) {
839
+ const registrationStatus = msg.extractPayloadByte(2);
840
+ // status: 0=unknown, 1=registered, 4=stale/needs-reauth (NOT rejection)
841
+ if (sys.controllerType === ControllerType.IntelliCenter) {
842
+ (sys.board as IntelliCenterBoard).setRegistrationStatus(registrationStatus);
843
+ }
844
+ }
845
+ msg.isProcessed = true;
846
+ break;
847
+ }
848
+ case 197: {
849
+ // request for date/time on *Touch. Use this as an indicator
850
+ // that SL has requested config and update lastUpdated date/time
851
+ /* let ver: ConfigVersion =
852
+ typeof (sys.configVersion) === 'undefined' ? new ConfigVersion({}) : sys.configVersion;
853
+ ver.lastUpdated = new Date();
854
+ sys.processVersionChanges(ver); */
855
+ sys.configVersion.lastUpdated = new Date();
856
+ msg.isProcessed = true;
857
+ break;
858
+ }
859
+ case 204: // IntelliCenter only.
860
+ state.batteryVoltage = msg.extractPayloadByte(2) / 50;
861
+ state.comms.keepAlives = msg.extractPayloadInt(4);
862
+ state.time.year = msg.extractPayloadByte(8);
863
+ state.time.month = msg.extractPayloadByte(7);
864
+ state.time.date = msg.extractPayloadByte(6);
865
+ sys.equipment.controllerFirmware = (msg.extractPayloadByte(42) + (msg.extractPayloadByte(43) / 1000)).toString();
866
+ // v3.004+: Seed known targetIds immediately once firmware>=3.0 is confirmed (before UI interaction).
867
+ if (sys.equipment.isIntellicenterV3 && typeof (sys.board.circuits as any)?.seedKnownV3TargetIds === 'function') {
868
+ (sys.board.circuits as any).seedKnownV3TargetIds();
869
+ }
870
+ // v3.004 adds 4 additional bytes (44-46) that are the time of day
871
+ // Byte 44: Hour (0-23)
872
+ // Byte 45: Minute (0-59)
873
+ // Byte 46: Second (0-59)
874
+ // Byte 47: Unknown - possibly DST indicator or status flag
875
+ if (sys.chlorinators.length > 0) {
876
+ if (msg.extractPayloadByte(37, 255) !== 255) {
877
+ const chlor = state.chlorinators.getItemById(1);
878
+ chlor.superChlorRemaining = msg.extractPayloadByte(37) * 3600 + msg.extractPayloadByte(38) * 60;
879
+ } else {
880
+ const chlor = state.chlorinators.getItemById(1);
881
+ chlor.superChlorRemaining = 0;
882
+ chlor.superChlor = false;
883
+ }
884
+ }
885
+ // v3.004+: Do NOT process feature states from Action 204!
886
+ // Evidence from packet captures shows Action 204 byte 19 contains STALE feature state
887
+ // that doesn't update when features change. The authoritative source for v3 feature
888
+ // state is Action 30 case 15 (config response to Action 222 [15,0] request).
889
+ // Action 204 continuously broadcasts stale data and overwrites the correct state.
890
+ //
891
+ // v1.x: Feature states at offset 9 - this was deemed reliable in 2020.
892
+ if (!sys.equipment.isIntellicenterV3) {
893
+ ExternalMessage.processFeatureState(9, msg);
894
+ }
895
+ //if (sys.equipment.dual === true) {
896
+ // // For IntelliCenter i10D the body state is on byte 26 of the 204. This impacts circuit 6.
897
+ // let byte = msg.extractPayloadByte(26);
898
+ // let pstate = state.circuits.getItemById(6, true);
899
+ // let oldstate = pstate.isOn;
900
+ // pstate.isOn = ((byte & 0x0010) === 0x0010);
901
+ // logger.info(`Checking i10D pool state ${byte} old:${oldstate} new: ${pstate.isOn}`);
902
+ // //if (oldstate !== pstate.isOn) {
903
+ // state.temps.bodies.getItemById(1, true).isOn = pstate.isOn;
904
+ // sys.board.circuits.syncCircuitRelayStates();
905
+ // sys.board.circuits.syncVirtualCircuitStates();
906
+ // sys.board.valves.syncValveStates();
907
+ // sys.board.filters.syncFilterStates();
908
+ // sys.board.heaters.syncHeaterStates();
909
+ // //}
910
+ // if (oldstate !== pstate.isOn) pstate.emitEquipmentChange();
911
+ //}
912
+ // At this point normally on is ignored. Not sure what this does.
913
+ let cover1 = sys.covers.getItemById(1);
914
+ let cover2 = sys.covers.getItemById(2);
915
+ if (cover1.isActive) {
916
+ let scover1 = state.covers.getItemById(1, true);
917
+ scover1.name = cover1.name;
918
+ state.temps.bodies.getItemById(cover1.body + 1).isCovered = scover1.isClosed = (msg.extractPayloadByte(30) & 0x0001) > 0;
919
+ }
920
+ if (cover2.isActive) {
921
+ let scover2 = state.covers.getItemById(2, true);
922
+ scover2.name = cover2.name;
923
+ state.temps.bodies.getItemById(cover2.body + 1).isCovered = scover2.isClosed = (msg.extractPayloadByte(30) & 0x0002) > 0;
924
+ }
925
+ sys.board.schedules.syncScheduleStates();
926
+ msg.isProcessed = true;
927
+ state.emitEquipmentChanges();
928
+ break;
929
+ }
930
+ }
931
+ private static processCircuitState(msg: Inbound) {
932
+ // The way this works is that there is one byte per 8 circuits for a total of 5 bytes or 40 circuits. The
933
+ // configuration already determined how many available circuits we have by querying the model of the panel
934
+ // and any installed expansion panel models. Only the number of available circuits will appear in this
935
+ // array.
936
+ let circuitId = 1;
937
+ let maxCircuitId = sys.board.equipmentIds.circuits.end;
938
+ for (let i = 2; i < msg.payload.length && circuitId <= maxCircuitId; i++) {
939
+ const byte = msg.extractPayloadByte(i);
940
+ // Shift each bit getting the circuit identified by each value.
941
+ for (let j = 0; j < 8; j++) {
942
+ let circuit = sys.circuits.getItemById(circuitId, false, { isActive: false });
943
+ if (circuit.isActive !== false) {
944
+ let cstate = state.circuits.getItemById(circuitId, circuit.isActive);
945
+ const wasOn = cstate.isOn;
946
+ // For IntelliCenter i10D body circuits are not reported here.
947
+ let isOn = ((circuitId === 6 || circuitId === 1) && sys.equipment.dual === true) ? cstate.isOn : (byte & (1 << j)) > 0;
948
+ //let isOn = (byte & (1 << j)) > 0;
949
+ cstate.isOn = isOn;
950
+ // v3.004+ learning: When circuit state changes, check for pending Wireless commands
951
+ if (sys.controllerType === ControllerType.IntelliCenter &&
952
+ sys.equipment.isIntellicenterV3 &&
953
+ wasOn !== isOn &&
954
+ (typeof circuit.targetId === 'undefined' || circuit.targetId === 0)) {
955
+ EquipmentStateMessage.checkPendingAction184Learning(circuitId, isOn);
956
+ }
957
+ cstate.name = circuit.name;
958
+ cstate.nameId = circuit.nameId;
959
+ cstate.showInFeatures = circuit.showInFeatures;
960
+ cstate.type = circuit.type;
961
+ sys.board.circuits.setEndTime(circuit, cstate, isOn);
962
+ if (sys.controllerType === ControllerType.IntelliCenter) {
963
+ // intellitouch sends a separate msg with themes
964
+ switch (circuit.type) {
965
+ case 6: // Globrite
966
+ case 5: // Magicstream
967
+ case 8: // Intellibrite
968
+ case 10: // Colorcascade
969
+ cstate.lightingTheme = circuit.lightingTheme;
970
+ break;
971
+ case 9:
972
+ cstate.level = circuit.level || 0;
973
+ break;
974
+ }
975
+ }
976
+ }
977
+ circuitId++;
978
+ }
979
+ }
980
+ msg.isProcessed = true;
981
+ }
982
+ private static processSunTouchCircuits(msg: Inbound) {
983
+ // SunTouch has really twisted bit mapping for its
984
+ // circuit states. Features are intertwined within the
985
+ // features.
986
+ let byte = msg.extractPayloadByte(2);
987
+ for (let i = 0; i < 8; i++) {
988
+ let id = i === 4 ? 7 : i > 5 ? i + 2 : i + 1;
989
+ let circ = sys.circuits.getInterfaceById(id, false, { isActive: false });
990
+ if (circ.isActive) {
991
+ let isOn = ((1 << i) & byte) > 0;
992
+ let cstate = state.circuits.getInterfaceById(id, circ.isActive);
993
+ if (isOn !== cstate.isOn) {
994
+ sys.board.circuits.setEndTime(circ, cstate, isOn);
995
+ cstate.isOn = isOn;
996
+ }
997
+ }
998
+ }
999
+ byte = msg.extractPayloadByte(3);
1000
+ {
1001
+ let circ = sys.circuits.getInterfaceById(10, false, { isActive: false });
1002
+ if (circ.isActive) {
1003
+ let isOn = (byte & 1) > 0;
1004
+ let cstate = state.circuits.getInterfaceById(circ.id, circ.isActive);
1005
+ if (isOn !== cstate.isOn) {
1006
+ sys.board.circuits.setEndTime(circ, cstate, isOn);
1007
+ cstate.isOn = isOn;
1008
+ }
1009
+ }
1010
+ }
1011
+ state.emitEquipmentChanges();
1012
+ msg.isProcessed = true;
1013
+ }
1014
+
1015
+ private static processFeatureStateV3(msg: Inbound) {
1016
+ // DISABLED: v3.004+ Action 2 bytes 7-8 do NOT use simple bitmask encoding!
1017
+ //
1018
+ // Analysis from replay 76 (Dec 2024):
1019
+ // - F1 on → byte7=16 (bit 4), byte8=0
1020
+ // - F1+F2 → byte7=32 (bit 5), byte8=1
1021
+ // - F1+F2+F3 → byte7=64 (bit 6), byte8=2
1022
+ //
1023
+ // This is NOT a bitmask - it appears to be some kind of encoded state.
1024
+ // Using this data corrupts feature state and causes wrong features to display.
1025
+ //
1026
+ // For v3.004+, feature state must come from:
1027
+ // 1. Action 30 case 15 responses (when njsPC requests via Action 222)
1028
+ // 2. TODO: Snoop on Action 30/15 going to Wireless (dest=36) for real-time updates
1029
+ //
1030
+ // DO NOT ENABLE THIS FUNCTION until the encoding is fully understood.
1031
+ msg.isProcessed = true;
1032
+ return;
1033
+
1034
+ // Original broken code kept for reference:
1035
+ /*
1036
+ const byte7 = msg.extractPayloadByte(7);
1037
+ const byte8 = msg.extractPayloadByte(8);
1038
+ const featureStateBits = byte7 | (byte8 << 8);
1039
+
1040
+ let featureId = sys.board.equipmentIds.features.start;
1041
+ let maxFeatureId = sys.features.getMaxId(true, 0);
1042
+
1043
+ for (let j = 0; featureId <= maxFeatureId && j < 16; j++) {
1044
+ let feature = sys.features.getItemById(featureId, false, { isActive: false });
1045
+ if (feature.isActive !== false) {
1046
+ let fstate = state.features.getItemById(featureId, true);
1047
+ let isOn = (featureStateBits & (1 << j)) > 0;
1048
+ sys.board.circuits.setEndTime(feature, fstate, isOn);
1049
+ fstate.isOn = isOn;
1050
+ fstate.name = feature.name;
1051
+ }
1052
+ else {
1053
+ state.features.removeItemById(featureId);
1054
+ }
1055
+ featureId++;
1056
+ }
1057
+ state.emitEquipmentChanges();
1058
+ */
1059
+ msg.isProcessed = true;
1060
+ }
1061
+
1062
+ private static processTouchCircuits(msg: Inbound) {
1063
+ let circuitId = 1;
1064
+ let maxCircuitId = sys.board.equipmentIds.features.end;
1065
+ for (let i = 2; i < msg.payload.length && circuitId <= maxCircuitId; i++) {
1066
+ const byte = msg.extractPayloadByte(i);
1067
+ // Shift each bit getting the circuit identified by each value.
1068
+ for (let j = 0; j < 8; j++) {
1069
+ const circ = sys.circuits.getInterfaceById(circuitId, false, { isActive: false });
1070
+ if (!sys.board.equipmentIds.invalidIds.isValidId(circuitId)) {
1071
+ circ.isActive = false;
1072
+ }
1073
+ if (circ.isActive) {
1074
+ const cstate = state.circuits.getInterfaceById(circuitId, circ.isActive);
1075
+ cstate.showInFeatures = circ.showInFeatures;
1076
+ let isOn = (byte & 1 << j) >> j > 0;
1077
+ if (isOn !== cstate.isOn) {
1078
+ sys.board.circuits.setEndTime(circ, cstate, isOn);
1079
+ cstate.isOn = isOn;
1080
+ }
1081
+ cstate.name = circ.name;
1082
+ cstate.type = circ.type;
1083
+ cstate.nameId = circ.nameId;
1084
+ }
1085
+ else {
1086
+ if (circ instanceof Circuit) {
1087
+ sys.circuits.removeItemById(circuitId);
1088
+ // don't forget to remove from state #257
1089
+ state.circuits.removeItemById(circuitId);
1090
+ }
1091
+ else if (circ instanceof Feature) {
1092
+ sys.features.removeItemById(circuitId);
1093
+ // don't forget to remove from state #257
1094
+ state.features.removeItemById(circuitId);
1095
+ }
1096
+ }
1097
+ circuitId++;
1098
+ }
1099
+ }
1100
+ // state.body = body;
1101
+ //state.emitControllerChange();
1102
+ state.emitEquipmentChanges();
1103
+ msg.isProcessed = true;
1104
+ }
1105
+
1106
+ private static processIntelliBriteMode(msg: Inbound) {
1107
+ // eg RED: [165,16,16,34,96,2],[195,0],[2,12]
1108
+ // data[0] = color
1109
+ const theme = msg.extractPayloadByte(0);
1110
+ switch (theme) {
1111
+ case 0: // off
1112
+ case 1: // on
1113
+ case 190: // save
1114
+ // case 191: // recall
1115
+ // RKS: TODO hold may be in this list since I see the all on and all off command here. Sync is probably in the colorset message that includes the timings.
1116
+ // do nothing as these don't actually change the state.
1117
+ break;
1118
+
1119
+ default:
1120
+ {
1121
+ // intellibrite themes
1122
+ // This is an observed message in that no-one asked for it. *Touch does not report the theme and in fact, it is not even
1123
+ // stored. Once the message is sent then it throws away the data. When you turn the light
1124
+ // on again it will be on at whatever theme happened to be set at the time it went off. We keep this
1125
+ // as a best guess so when the user turns on the light it will likely be the last theme observed.
1126
+ const grp = sys.lightGroups.getItemById(sys.board.equipmentIds.circuitGroups.start);
1127
+ const sgrp = state.lightGroups.getItemById(sys.board.equipmentIds.circuitGroups.start);
1128
+ grp.lightingTheme = sgrp.lightingTheme = theme;
1129
+ for (let i = 0; i < grp.circuits.length; i++) {
1130
+ let c = grp.circuits.getItemByIndex(i);
1131
+ let cstate = state.circuits.getItemById(c.circuit);
1132
+ let circuit = sys.circuits.getInterfaceById(c.circuit);
1133
+ if (cstate.isOn) cstate.lightingTheme = circuit.lightingTheme = theme;
1134
+ }
1135
+ switch (theme) {
1136
+ case 128: // sync
1137
+ sys.board.circuits.sequenceLightGroupAsync(grp.id, 'sync');
1138
+ break;
1139
+ case 144: // swim
1140
+ sys.board.circuits.sequenceLightGroupAsync(grp.id, 'swim');
1141
+ break;
1142
+ case 160: // set
1143
+ sys.board.circuits.sequenceLightGroupAsync(grp.id, 'set');
1144
+ break;
1145
+ case 190: // save
1146
+ case 191: // recall
1147
+ sys.board.circuits.sequenceLightGroupAsync(grp.id, 'other');
1148
+ break;
1149
+ default:
1150
+ sys.board.circuits.sequenceLightGroupAsync(grp.id, 'color');
1151
+ // other themes for magicstream?
1152
+ }
1153
+ break;
1154
+ }
1155
+ }
1156
+ msg.isProcessed = true;
1157
+ }
1158
+ }