nodejs-poolcontroller 8.4.0 → 8.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.github/workflows/ghcr-publish.yml +1 -1
  2. package/157_issues.md +101 -0
  3. package/AGENTS.md +17 -1
  4. package/README.md +13 -2
  5. package/controller/Equipment.ts +49 -0
  6. package/controller/State.ts +8 -0
  7. package/controller/boards/AquaLinkBoard.ts +174 -2
  8. package/controller/boards/EasyTouchBoard.ts +44 -0
  9. package/controller/boards/IntelliCenterBoard.ts +360 -172
  10. package/controller/boards/NixieBoard.ts +7 -4
  11. package/controller/boards/SunTouchBoard.ts +1 -0
  12. package/controller/boards/SystemBoard.ts +39 -4
  13. package/controller/comms/Comms.ts +9 -3
  14. package/controller/comms/messages/Messages.ts +218 -24
  15. package/controller/comms/messages/config/EquipmentMessage.ts +34 -0
  16. package/controller/comms/messages/config/ExternalMessage.ts +1051 -989
  17. package/controller/comms/messages/config/GeneralMessage.ts +65 -0
  18. package/controller/comms/messages/config/OptionsMessage.ts +15 -2
  19. package/controller/comms/messages/config/PumpMessage.ts +427 -421
  20. package/controller/comms/messages/config/SecurityMessage.ts +37 -13
  21. package/controller/comms/messages/status/EquipmentStateMessage.ts +0 -218
  22. package/controller/comms/messages/status/HeaterStateMessage.ts +27 -15
  23. package/controller/comms/messages/status/NeptuneModbusStateMessage.ts +217 -0
  24. package/controller/comms/messages/status/VersionMessage.ts +67 -18
  25. package/controller/nixie/chemistry/ChemController.ts +65 -33
  26. package/controller/nixie/heaters/Heater.ts +10 -1
  27. package/controller/nixie/pumps/Pump.ts +145 -2
  28. package/docker-compose.yml +1 -0
  29. package/logger/Logger.ts +75 -64
  30. package/package.json +1 -1
  31. package/tsconfig.json +2 -1
  32. package/web/Server.ts +3 -1
  33. package/web/services/config/Config.ts +150 -1
  34. package/web/services/state/State.ts +21 -0
  35. package/web/services/state/StateSocket.ts +28 -0
@@ -33,7 +33,7 @@ jobs:
33
33
  type=sha
34
34
  type=raw,value=latest,enable={{is_default_branch}}
35
35
  labels: |
36
- org.opencontainers.image.source=${{ github.repository }}
36
+ org.opencontainers.image.source=https://github.com/${{ github.repository }}
37
37
  org.opencontainers.image.revision=${{ github.sha }}
38
38
  org.opencontainers.image.title=njspc
39
39
  org.opencontainers.image.description=Pentair pool controller bridge (GHCR image)
package/157_issues.md ADDED
@@ -0,0 +1,101 @@
1
+ # IntelliCenter v3 Config Triage Summary
2
+
3
+ ## Initial issues reported
4
+
5
+ - General -> Personal Information:
6
+ - City/State/Zip not syncing between dashPanel and WCP/OCP.
7
+ - Pool Alias/Owner/Zip values overwriting each other.
8
+ - Latitude blank in dashPanel; longitude not editable/syncing.
9
+ - General -> Timezone & Locality:
10
+ - Switching Internet/12h <-> Manual/24h caused bogus Pool/Spa setpoints and heat mode in dashPanel.
11
+ - General -> Delays:
12
+ - Delays not linking correctly.
13
+ - Missing v3 fields: Frz Cycle Time (min) and Frz Override (min).
14
+ - Manual Operation Priority in dashPanel does not clearly map to WCP.
15
+ - Sensor Calibration:
16
+ - Generally working; occasional +/-1 variance (likely timing/read cadence).
17
+ - Alerts/Security:
18
+ - Tabs empty.
19
+ - Loading stuck at 0%:
20
+ - Intermittent post-change loading hang (notably replay 157).
21
+
22
+ ## What is fixed
23
+
24
+ - v3 Action 168 body temp/settings parsing hardened:
25
+ - Added handling for payload variants so Pool/Spa setpoint/heat-mode values are parsed correctly.
26
+ - Latitude/Longitude outbound mapping corrected:
27
+ - Fixed wrong latitude assignment target.
28
+ - Corrected byte math/order for lat/lon packet payload.
29
+ - Normalized coordinate assignment precision.
30
+ - General config refresh reliability improved:
31
+ - Forced v3 refresh path to include general category refresh to reduce stale General-tab data.
32
+ - Country field inbound parse width increased:
33
+ - Expanded from 16 to 32 bytes to avoid truncation/misalignment.
34
+
35
+ ## What is not fully fixed yet
36
+
37
+ - Personal Information end-to-end mapping:
38
+ - Alias/Owner/City/State/Zip still need definitive byte-level inbound/outbound confirmation.
39
+ - Delays v3 fields:
40
+ - Frz Cycle Time (min) and Frz Override (min) offsets not yet identified.
41
+ - Manual Operation Priority mapping still unconfirmed.
42
+ - Alerts/Security mapping:
43
+ - Need packet-level evidence to confirm source actions/offsets and parser wiring.
44
+ - Loading stuck:
45
+ - Root cause identified as RS-485 comm instability + frequent v3 refresh retriggers under noisy conditions; mitigation implementation still pending.
46
+
47
+ ## Root cause of loading stuck
48
+
49
+ - In replay 157, poolState ends with:
50
+ - status.name = "loading"
51
+ - percent = 0
52
+ - Same run shows elevated comm issues:
53
+ - unrecoverable collisions
54
+ - invalid packets
55
+ - outbound retries
56
+ - repeated config refresh triggers (ACK(168), ACK(184), piggyback)
57
+ - Net effect:
58
+ - refresh cycles are retriggered/churned during unstable traffic and can fail to settle cleanly.
59
+
60
+ ## Information needed to finish remaining work
61
+
62
+ - Capture one setting change at a time (with timestamp notes), then wait a few seconds before next change.
63
+
64
+ ### Personal Information
65
+
66
+ - Pool Alias
67
+ - Owner
68
+ - City
69
+ - State
70
+ - Zip
71
+ - Latitude
72
+ - Longitude
73
+
74
+ ### Time/Locality
75
+
76
+ - 12/24-hour switch
77
+ - Internet/manual clock source
78
+ - DST toggle
79
+ - Time zone change
80
+
81
+ ### Delays (especially v3-specific)
82
+
83
+ - Frz Cycle Time (min)
84
+ - Frz Override (min)
85
+ - Any setting suspected to map to Manual Operation Priority
86
+
87
+ ### Alerts/Security
88
+
89
+ - Toggle each available option individually (if present on WCP/OCP)
90
+
91
+ ### Packet evidence needed for each run
92
+
93
+ - Action 30 (config responses)
94
+ - Action 168 (external/config broadcasts)
95
+ - Related request/ack flow (Action 222, Action 1, piggyback trigger context)
96
+
97
+ ## Current status
98
+
99
+ - DONE: High-confidence parsing and lat/lon write-path fixes applied.
100
+ - PENDING: Remaining fields require targeted packet captures for accurate mapping.
101
+ - PENDING: Loading-hang mitigation implementation.
package/AGENTS.md CHANGED
@@ -123,6 +123,17 @@
123
123
  **Generalization:** This applies to *anything* that is overridden by controller boards (IntelliCenter, EasyTouch, IntelliTouch, AquaLink, SunTouch, Nixie, etc.).
124
124
  If you find yourself writing `if (sys.controllerType === ...)` inside `SystemBoard.ts`, that is almost always a design smell—create/extend the controller-specific command class in the appropriate board file and keep `SystemBoard.ts` purely generic.
125
125
 
126
+ ### 7.1 Nixie Chemistry Relay UI Sync: Circuits, Not Features (CRITICAL)
127
+ **Rule:** For Nixie/REM chemistry relay status sync, treat **circuits** as the hardware-bound source of truth. Do **not** implement hardware binding logic on `Feature`.
128
+
129
+ - `Feature` is logical/virtual state in this context; relay bindings (`connectionId`, `deviceBinding`) belong on `Circuit`.
130
+ - When chemistry pump state changes (`NixieChemPump.turnOn/turnOff`), locate matching entries in `sys.circuits` by `connectionId` + `deviceBinding`.
131
+ - Reflect state in `state.circuits` (including `setEndTime` semantics) and emit changes; do not bind/sync through `state.features`.
132
+ - Avoid duplicate relay writes: if the chemistry code already sent the hardware command, sync in-memory circuit state instead of issuing an additional toggle command for that same relay.
133
+ - If the UI "Features" panel must show the relay, use a **circuit** with `showInFeatures=true` (presentation concern), not a bound `Feature`.
134
+
135
+ **Context:** Added after investigation/fix for discussion #1159 (acid feeder relay dot not reflecting REM dosing state in dashPanel).
136
+
126
137
  ## Analysis Patterns
127
138
 
128
139
  ### 6. Examine Working State, Not Just Failures
@@ -458,6 +469,11 @@ Always prefix with `#` and packet ID.
458
469
  4. Look for request/response pairs (outbound `dir":"out"` → inbound `dir":"in"`)
459
470
  5. **Always extract and display packet IDs** in any analysis output
460
471
 
472
+ **Replay caveat (API lines):**
473
+ - Some replay packet logs include captured API requests (for example `/app/config/stopPacketCapture` in `v3.008_replay.157_config1` and `v3.008_replay.160_config2`).
474
+ - Treat these as tooling/control-plane events, not protocol source-of-truth packets.
475
+ - If replaying API lines out-of-order causes app errors, classify separately from RS-485 protocol regressions.
476
+
461
477
  **Decoding times (v3.004 big-endian):**
462
478
  - Two bytes `[hi, lo]` → `hi * 256 + lo` = minutes since midnight
463
479
  - Example: `[2, 33]` → `2*256 + 33 = 545` → 9:05 AM
@@ -592,6 +608,6 @@ Each protocol file should include:
592
608
 
593
609
  ---
594
610
 
595
- **Last Updated:** December 16, 2025
611
+ **Last Updated:** March 13, 2026
596
612
  **Source:** nodejs-poolController IntelliCenter v3.004 compatibility work
597
613
 
package/README.md CHANGED
@@ -100,6 +100,14 @@ Assuming you cloned the repo, the following are easy steps to get the latest ver
100
100
 
101
101
  See the [wiki](https://github.com/tagyoureit/nodejs-poolController/wiki/Docker). Thanks @wurmr @andylippitt @emes.
102
102
 
103
+ #### Image channels (important)
104
+
105
+ The project has multiple image channels. `latest` only means latest within that specific channel.
106
+
107
+ * `ghcr.io/tagyoureit/njspc` - official controller image published from this repository's GitHub Actions (tracks upstream `master`).
108
+ * `msmi/nodejs-poolcontroller` - legacy Docker Hub controller image (can lag upstream).
109
+ * `ghcr.io/rstrouse/njspc-dash` - dashPanel image currently published separately.
110
+
103
111
  ### Docker Compose (Controller + Optional dashPanel UI)
104
112
 
105
113
  Below is an example `docker-compose.yml` snippet showing this controller (`njspc`) and an OPTIONAL dashPanel UI service (`njspc-dash`). The dashPanel image is published separately; uncomment if you want a built-in web dashboard on port 5150.
@@ -107,7 +115,7 @@ Below is an example `docker-compose.yml` snippet showing this controller (`njspc
107
115
  ```yaml
108
116
  services:
109
117
  njspc:
110
- image: ghcr.io/sam2kb/njspc
118
+ image: ${NJSPC_IMAGE:-ghcr.io/tagyoureit/njspc}
111
119
  container_name: njspc
112
120
  restart: unless-stopped
113
121
  environment:
@@ -137,7 +145,7 @@ services:
137
145
  # user: "0:0"
138
146
 
139
147
  njspc-dash:
140
- image: ghcr.io/sam2kb/njspc-dash
148
+ image: ${NJSPC_DASH_IMAGE:-ghcr.io/rstrouse/njspc-dash}
141
149
  container_name: njspc-dash
142
150
  restart: unless-stopped
143
151
  depends_on:
@@ -172,6 +180,9 @@ Quick start:
172
180
 
173
181
  Notes:
174
182
  * Provide either RS-485 device OR enable network (ScreenLogic) connection.
183
+ * `latest` is channel-specific; use image labels to verify exact code revision:
184
+ * `docker image inspect ghcr.io/tagyoureit/njspc:latest --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'`
185
+ * `docker image inspect msmi/nodejs-poolcontroller:latest --format '{{ index .Config.Labels "git-commit" }}'`
175
186
  * Coordinates env vars prevent heliotrope warnings before the panel reports location.
176
187
  * Persistence (controller):
177
188
  * `./server-config.json:/app/config.json` main runtime config. You can either:
@@ -59,6 +59,7 @@ interface IPoolSystem {
59
59
  remotes: RemoteCollection;
60
60
  eggTimers: EggTimerCollection;
61
61
  security: Security;
62
+ alerts: Alerts;
62
63
  chemControllers: ChemControllerCollection;
63
64
  board: SystemBoard;
64
65
  updateControllerDateTimeAsync(
@@ -202,6 +203,7 @@ export class PoolSystem implements IPoolSystem {
202
203
  this.lightGroups = new LightGroupCollection(this.data, 'lightGroups');
203
204
  this.remotes = new RemoteCollection(this.data, 'remotes');
204
205
  this.security = new Security(this.data, 'security');
206
+ this.alerts = new Alerts(this.data, 'alerts');
205
207
  this.customNames = new CustomNameCollection(this.data, 'customNames');
206
208
  this.eggTimers = new EggTimerCollection(this.data, 'eggTimers');
207
209
  this.chemControllers = new ChemControllerCollection(this.data, 'chemControllers');
@@ -322,6 +324,7 @@ export class PoolSystem implements IPoolSystem {
322
324
  this.remotes.clear(0);
323
325
  this.schedules.clear(0);
324
326
  this.security.clear();
327
+ this.alerts.clear();
325
328
  this.valves.clear(0);
326
329
  this.chemControllers.clear(0);
327
330
  this.filters.clear(0);
@@ -376,6 +379,7 @@ export class PoolSystem implements IPoolSystem {
376
379
  public lightGroups: LightGroupCollection;
377
380
  public remotes: RemoteCollection;
378
381
  public security: Security;
382
+ public alerts: Alerts;
379
383
  public customNames: CustomNameCollection;
380
384
  public chemControllers: ChemControllerCollection;
381
385
  public chemDosers: ChemDoserCollection;
@@ -479,6 +483,8 @@ export class PoolSystem implements IPoolSystem {
479
483
  remotes: self.data.remotes || [],
480
484
  heaters: self.data.heaters || [],
481
485
  covers: self.data.covers || [],
486
+ security: self.data.security || {},
487
+ alerts: self.data.alerts || {},
482
488
  appVersion: self.data.appVersion || '0.0.0'
483
489
  };
484
490
  }
@@ -835,6 +841,8 @@ export class Options extends EqItem {
835
841
  if (typeof this.data.heaterStartDelayTime === 'undefined') this.data.heaterStartDelayTime = 10;
836
842
  if (typeof this.data.cleanerStartDelayTime === 'undefined') this.data.cleanerStartDelayTime = 300; // 5min
837
843
  if (typeof this.data.cleanerSolarDelayTime === 'undefined') this.data.cleanerSolarDelayTime = 300; // 5min
844
+ if (typeof this.data.freezeCycleTime === 'undefined') this.data.freezeCycleTime = 15;
845
+ if (typeof this.data.freezeOverride === 'undefined') this.data.freezeOverride = 30;
838
846
  }
839
847
  public get clockMode(): number | any { return this.data.clockMode; }
840
848
  public set clockMode(val: number | any) { this.setDataVal('clockMode', sys.board.valueMaps.clockModes.encode(val)); }
@@ -857,6 +865,10 @@ export class Options extends EqItem {
857
865
  public set cooldownDelay(val: boolean) { this.setDataVal('cooldownDelay', val); }
858
866
  public get freezeThreshold(): number { return this.data.freezeThreshold; }
859
867
  public set freezeThreshold(val: number) { this.setDataVal('freezeThreshold', val); }
868
+ public get freezeCycleTime(): number { return this.data.freezeCycleTime; }
869
+ public set freezeCycleTime(val: number) { this.setDataVal('freezeCycleTime', val); }
870
+ public get freezeOverride(): number { return this.data.freezeOverride; }
871
+ public set freezeOverride(val: number) { this.setDataVal('freezeOverride', val); }
860
872
  public get heaterStartDelay(): boolean { return this.data.heaterStartDelay; }
861
873
  public set heaterStartDelay(val: boolean) { this.setDataVal('heaterStartDelay', val); }
862
874
  public get heaterStartDelayTime(): number { return this.data.heaterStartDelayTime; }
@@ -1453,6 +1465,7 @@ export interface ICircuit {
1453
1465
  freeze?: boolean;
1454
1466
  isActive: boolean;
1455
1467
  lightingTheme?: number;
1468
+ level?: number;
1456
1469
  //showInCircuits?: boolean;
1457
1470
  showInFeatures?: boolean;
1458
1471
  macro?: boolean;
@@ -2173,13 +2186,46 @@ export class SecurityRole extends EqItem {
2173
2186
  public set flag2(val: number) { this.setDataVal('flag2', val); }
2174
2187
  public get pin(): string { return this.data.pin; }
2175
2188
  public set pin(val: string) { this.setDataVal('pin', val); }
2189
+ public get permissionsMask(): number { return this.data.permissionsMask; }
2190
+ public set permissionsMask(val: number) { this.setDataVal('permissionsMask', val); }
2191
+ public get permissionsBytes(): number[] { return Array.isArray(this.data.permissionsBytes) ? this.data.permissionsBytes : []; }
2192
+ public set permissionsBytes(val: number[]) { this.setDataVal('permissionsBytes', Array.isArray(val) ? [...val] : []); }
2176
2193
  }
2177
2194
  export class Security extends EqItem {
2178
2195
  public dataName = 'securityConfig';
2196
+ public initData() {
2197
+ if (typeof this.data.enabled === 'undefined') this.data.enabled = false;
2198
+ if (typeof this.data.enabledByte === 'undefined') this.data.enabledByte = 0;
2199
+ }
2179
2200
  public get enabled(): boolean { return this.data.enabled; }
2180
2201
  public set enabled(val: boolean) { this.setDataVal('enabled', val); }
2202
+ public get enabledByte(): number { return this.data.enabledByte; }
2203
+ public set enabledByte(val: number) { this.setDataVal('enabledByte', val); }
2181
2204
  public get roles(): SecurityRoleCollection { return new SecurityRoleCollection(this.data, "roles"); }
2182
2205
  }
2206
+ export class Alerts extends EqItem {
2207
+ public dataName = 'alertsConfig';
2208
+ public initData() {
2209
+ if (typeof this.data.circuitNotifications === 'undefined') this.data.circuitNotifications = 0;
2210
+ if (typeof this.data.pumpNotifications === 'undefined') this.data.pumpNotifications = 0;
2211
+ if (typeof this.data.heaterNotifications === 'undefined') this.data.heaterNotifications = 0;
2212
+ if (typeof this.data.chlorinatorNotifications === 'undefined') this.data.chlorinatorNotifications = 0;
2213
+ if (typeof this.data.raw !== 'object' || this.data.raw === null) this.data.raw = {};
2214
+ }
2215
+ public get circuitNotifications(): number { return this.data.circuitNotifications; }
2216
+ public set circuitNotifications(val: number) { this.setDataVal('circuitNotifications', val); }
2217
+ public get pumpNotifications(): number { return this.data.pumpNotifications; }
2218
+ public set pumpNotifications(val: number) { this.setDataVal('pumpNotifications', val); }
2219
+ public get heaterNotifications(): number { return this.data.heaterNotifications; }
2220
+ public set heaterNotifications(val: number) { this.setDataVal('heaterNotifications', val); }
2221
+ public get chlorinatorNotifications(): number { return this.data.chlorinatorNotifications; }
2222
+ public set chlorinatorNotifications(val: number) { this.setDataVal('chlorinatorNotifications', val); }
2223
+ public setRaw(selector: number, raw: number[]) {
2224
+ if (typeof this.data.raw !== 'object' || this.data.raw === null) this.data.raw = {};
2225
+ this.data.raw[`selector${selector}`] = Array.isArray(raw) ? [...raw] : [];
2226
+ this.hasChanged = true;
2227
+ }
2228
+ }
2183
2229
  export class ChemControllerCollection extends EqItemCollection<ChemController> {
2184
2230
  constructor(data: any, name?: string) { super(data, name || "chemControllers"); }
2185
2231
  public createItem(data: any): ChemController { return new ChemController(data); }
@@ -2238,6 +2284,7 @@ export class ChemController extends EqItem implements IChemController {
2238
2284
  if (typeof this.data.lsiRange === 'undefined') this.data.lsiRange = { low: -.5, high: .5, enabled: true };
2239
2285
  if (typeof this.data.borates === 'undefined') this.data.borates = 0;
2240
2286
  if (typeof this.data.siCalcType === 'undefined') this.data.siCalcType = 0;
2287
+ if (typeof this.data.intellichemStandalone === 'undefined') this.data.intellichemStandalone = false;
2241
2288
  super.initData();
2242
2289
  }
2243
2290
  public dataName = 'chemControllerConfig';
@@ -2253,6 +2300,8 @@ export class ChemController extends EqItem implements IChemController {
2253
2300
  public set address(val: number) { this.setDataVal('address', val); }
2254
2301
  public get isActive(): boolean { return this.data.isActive; }
2255
2302
  public set isActive(val: boolean) { this.setDataVal('isActive', val); }
2303
+ public get intellichemStandalone(): boolean { return utils.makeBool(this.data.intellichemStandalone); }
2304
+ public set intellichemStandalone(val: boolean) { this.setDataVal('intellichemStandalone', val); }
2256
2305
  public get calciumHardness(): number { return this.data.calciumHardness; }
2257
2306
  public set calciumHardness(val: number) { this.setDataVal('calciumHardness', val); }
2258
2307
  public get cyanuricAcid(): number { return this.data.cyanuricAcid; }
@@ -542,6 +542,8 @@ export interface ICircuitState {
542
542
  get(bCopy?: boolean);
543
543
  showInFeatures?: boolean;
544
544
  isActive?: boolean;
545
+ level?: number;
546
+ color?: { red: number; green: number; blue: number };
545
547
  startDelay?: boolean;
546
548
  stopDelay?: boolean;
547
549
  manualPriorityActive?: boolean;
@@ -1090,6 +1092,7 @@ export class PumpState extends EqState {
1090
1092
  case 'vssvrs':
1091
1093
  case 'vs':
1092
1094
  case 'regalmodbus':
1095
+ case 'neptunemodbus':
1093
1096
  c.units = sys.board.valueMaps.pumpUnits.transformByName('rpm');
1094
1097
  break;
1095
1098
  case 'ss':
@@ -2073,6 +2076,11 @@ export class CircuitState extends EqState implements ICircuitState {
2073
2076
  }
2074
2077
  public get level(): number { return this.data.level; }
2075
2078
  public set level(val: number) { this.setDataVal('level', val); }
2079
+ public get color(): { red: number; green: number; blue: number } { return this.data.color; }
2080
+ public set color(val: { red: number; green: number; blue: number }) {
2081
+ if (typeof val === 'undefined' || val === null) this.setDataVal('color', undefined);
2082
+ else this.setDataVal('color', { red: val.red, green: val.green, blue: val.blue });
2083
+ }
2076
2084
  public get commStatus(): number { return this.data.commStatus; }
2077
2085
  public set commStatus(val: number) {
2078
2086
  if (this.commStatus !== val) {
@@ -26,6 +26,16 @@ import { conn } from '../comms/Comms';
26
26
  import { ncp } from "../nixie/Nixie";
27
27
  import { utils } from '../Constants';
28
28
 
29
+ const IAQUALINK_SENDCMD_DEST = 0x00;
30
+ const IAQUALINK_SENDCMD_SOURCE = 0x24;
31
+ const IAQUALINK_SENDCMD_ACTION = 0x73;
32
+ const IAQUALINK_SENDCMD_PREFIX = 0x01;
33
+ const IAQUALINK_SENDCMD_PAYLOAD_LEN = 12;
34
+ const IAQUALINK_OPCODE_THEME = 0x61;
35
+ const IAQUALINK_OPCODE_RGB = 0x63;
36
+ const IAQUALINK_OPCODE_SYNC = 0x65;
37
+ const IAQUALINK_WATERCOLORS_CIRCUIT_TYPE = 18;
38
+
29
39
  export class AquaLinkBoard extends SystemBoard {
30
40
  constructor(system: PoolSystem) {
31
41
  super(system);
@@ -41,6 +51,32 @@ export class AquaLinkBoard extends SystemBoard {
41
51
  [32, { name: 'IT5X', part: 'i5X', desc: 'IntelliTouch i5X', circuits: 5 }],
42
52
  [33, { name: 'IT10X', part: 'i10X', desc: 'IntelliTouch i10X', circuits: 10 }]
43
53
  ]);
54
+ this.valueMaps.circuitFunctions.merge([
55
+ [IAQUALINK_WATERCOLORS_CIRCUIT_TYPE, {
56
+ name: 'watercolors',
57
+ desc: 'Infinite WaterColors',
58
+ isLight: true,
59
+ theme: 'watercolors',
60
+ supportsBrightness: true,
61
+ supportsCustomColor: true
62
+ }]
63
+ ]);
64
+ this.valueMaps.lightThemes.merge([
65
+ [56, { name: 'alpinewhite', desc: 'Alpine White', types: ['watercolors'], sequence: 1 }],
66
+ [57, { name: 'skyblue', desc: 'Sky Blue', types: ['watercolors'], sequence: 2 }],
67
+ [58, { name: 'cobaltblue', desc: 'Cobalt Blue', types: ['watercolors'], sequence: 3 }],
68
+ [59, { name: 'caribbeanblue', desc: 'Caribbean Blue', types: ['watercolors'], sequence: 4 }],
69
+ [60, { name: 'springgreen', desc: 'Spring Green', types: ['watercolors'], sequence: 5 }],
70
+ [61, { name: 'emeraldgreen', desc: 'Emerald Green', types: ['watercolors'], sequence: 6 }],
71
+ [62, { name: 'emeraldrose', desc: 'Emerald Rose', types: ['watercolors'], sequence: 7 }],
72
+ [63, { name: 'magenta', desc: 'Magenta', types: ['watercolors'], sequence: 8 }],
73
+ [64, { name: 'violet', desc: 'Violet', types: ['watercolors'], sequence: 9 }],
74
+ [65, { name: 'slowcolorsplash', desc: 'Slow Color Splash', types: ['watercolors'], sequence: 10 }],
75
+ [66, { name: 'fastcolorsplash', desc: 'Fast Color Splash', types: ['watercolors'], sequence: 11 }],
76
+ [67, { name: 'americathebeautiful', desc: 'America Beautiful', types: ['watercolors'], sequence: 12 }],
77
+ [68, { name: 'fattuesday', desc: 'Fat Tuesday', types: ['watercolors'], sequence: 13 }],
78
+ [69, { name: 'discotech', desc: 'Disco Tech', types: ['watercolors'], sequence: 14 }]
79
+ ]);
44
80
  }
45
81
  public initExpansionModules(byte1: number, byte2: number) {
46
82
  console.log(`Jandy AquaLink System Detected!`);
@@ -401,6 +437,59 @@ class AquaLinkBodyCommands extends BodyCommands {
401
437
  }
402
438
  }
403
439
  class AquaLinkCircuitCommands extends CircuitCommands {
440
+ private isWaterColorsCircuit(circuit: ICircuit): boolean {
441
+ const type = sys.board.valueMaps.circuitFunctions.transform(circuit.type);
442
+ return typeof type !== 'undefined' && type.isLight === true && type.theme === 'watercolors';
443
+ }
444
+ private getWaterColorsTheme(theme: number | any) {
445
+ const thm = sys.board.valueMaps.lightThemes.findItem(theme);
446
+ if (typeof thm === 'undefined' || !Array.isArray(thm.types) || !thm.types.includes('watercolors')) {
447
+ throw new InvalidOperationError(`Theme ${theme} is not valid for Infinite WaterColors`, 'setLightThemeAsync');
448
+ }
449
+ return thm;
450
+ }
451
+ private normalizeWaterColorsBrightness(level: number): number {
452
+ const parsed = parseInt(level as any, 10);
453
+ if (isNaN(parsed) || parsed < 0 || parsed > 100) {
454
+ throw new InvalidEquipmentDataError(`Invalid WaterColors brightness ${level}`, 'Circuit', level);
455
+ }
456
+ return parsed;
457
+ }
458
+ private normalizeWaterColorsComponent(value: number, component: string): number {
459
+ const parsed = parseInt(value as any, 10);
460
+ if (isNaN(parsed) || parsed < 0 || parsed > 255) {
461
+ throw new InvalidEquipmentDataError(`Invalid WaterColors ${component} component ${value}`, 'Circuit', value);
462
+ }
463
+ return parsed;
464
+ }
465
+ private async sendIAquaLinkLightCommandAsync(opcode: number, payload: number[], scope: string): Promise<void> {
466
+ const cmdPayload = payload.slice(0, IAQUALINK_SENDCMD_PAYLOAD_LEN);
467
+ while (cmdPayload.length < IAQUALINK_SENDCMD_PAYLOAD_LEN) cmdPayload.push(0);
468
+ await Outbound.create({
469
+ protocol: Protocol.AquaLink,
470
+ dest: IAQUALINK_SENDCMD_DEST,
471
+ source: IAQUALINK_SENDCMD_SOURCE,
472
+ action: IAQUALINK_SENDCMD_ACTION,
473
+ payload: [IAQUALINK_SENDCMD_PREFIX, opcode, ...cmdPayload],
474
+ retries: 2,
475
+ scope
476
+ }).sendAsync();
477
+ }
478
+ private async sendWaterColorsOnCommandAsync(id: number): Promise<void> {
479
+ await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_SYNC, [0x01, 0x00], `aqualink-watercolors-on-${id}`);
480
+ }
481
+ private async sendWaterColorsOffCommandAsync(id: number): Promise<void> {
482
+ await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_THEME, [0x00, 0xFF, 0x00], `aqualink-watercolors-off-${id}`);
483
+ }
484
+ private setWaterColorsState(circuit: ICircuit, cstate: any, isOn: boolean): void {
485
+ sys.board.circuits.setEndTime(circuit, cstate, isOn);
486
+ cstate.isOn = isOn;
487
+ }
488
+ private getWaterColorsLevel(circuit: ICircuit, cstate: any): number {
489
+ if (typeof circuit.level !== 'undefined' && circuit.level > 0) return circuit.level;
490
+ if (typeof cstate.level !== 'undefined' && cstate.level > 0) return cstate.level;
491
+ return 100;
492
+ }
404
493
  public async setCircuitAsync(data: any): Promise<ICircuit> {
405
494
  try {
406
495
  let id = parseInt(data.id, 10);
@@ -413,6 +502,7 @@ class AquaLinkCircuitCommands extends CircuitCommands {
413
502
  return circ;
414
503
  }
415
504
  let typeByte = parseInt(data.type, 10) || circuit.type || sys.board.valueMaps.circuitFunctions.getValue('generic');
505
+ this.assertSinglePoolSpaType(id, typeByte);
416
506
  let nameByte = 3; // set default `Aux 1`
417
507
  if (typeof data.nameId !== 'undefined') nameByte = data.nameId;
418
508
  else if (typeof circuit.name !== 'undefined') nameByte = circuit.nameId;
@@ -445,6 +535,19 @@ class AquaLinkCircuitCommands extends CircuitCommands {
445
535
  if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('Circuit or Feature id not valid', id, 'Circuit'));
446
536
  let c = sys.circuits.getInterfaceById(id);
447
537
  if (c.master !== 0) return await super.setCircuitStateAsync(id, val);
538
+ if (this.isWaterColorsCircuit(c)) {
539
+ const cstate = state.circuits.getInterfaceById(id);
540
+ if (val) await this.sendWaterColorsOnCommandAsync(id);
541
+ else await this.sendWaterColorsOffCommandAsync(id);
542
+ this.setWaterColorsState(c, cstate, val);
543
+ if (val) {
544
+ const level = this.getWaterColorsLevel(c, cstate);
545
+ c.level = level;
546
+ cstate.level = level;
547
+ }
548
+ state.emitEquipmentChanges();
549
+ return cstate;
550
+ }
448
551
  if (id === 192 || c.type === 3) return await sys.board.circuits.setLightGroupThemeAsync(id - 191, val ? 1 : 0);
449
552
  if (id >= 192) return await sys.board.circuits.setCircuitGroupStateAsync(id, val);
450
553
 
@@ -507,8 +610,77 @@ class AquaLinkCircuitCommands extends CircuitCommands {
507
610
  });
508
611
  }
509
612
  public async setLightThemeAsync(id: number, theme: number): Promise<ICircuitState> {
510
- // Re-route this as we cannot set individual circuit themes in *Touch.
511
- return this.setLightGroupThemeAsync(id, theme);
613
+ const circ = sys.circuits.getItemById(id);
614
+ if (!this.isWaterColorsCircuit(circ)) {
615
+ // Re-route this as we cannot set individual circuit themes in *Touch.
616
+ return this.setLightGroupThemeAsync(id, theme);
617
+ }
618
+ const cstate = state.circuits.getItemById(id);
619
+ const thm = this.getWaterColorsTheme(theme);
620
+ const nop = sys.board.valueMaps.circuitActions.getValue('lighttheme');
621
+ cstate.action = nop;
622
+ cstate.emitEquipmentChange();
623
+ try {
624
+ if (!cstate.isOn) await this.sendWaterColorsOnCommandAsync(id);
625
+ await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_THEME, [0x00, thm.sequence, 0x64], `aqualink-watercolors-theme-${id}`);
626
+ this.setWaterColorsState(circ, cstate, true);
627
+ const level = this.getWaterColorsLevel(circ, cstate);
628
+ circ.level = level;
629
+ cstate.level = level;
630
+ circ.lightingTheme = thm.val;
631
+ cstate.lightingTheme = thm.val;
632
+ cstate.color = undefined;
633
+ state.emitEquipmentChanges();
634
+ return cstate;
635
+ }
636
+ catch (err) { return Promise.reject(new InvalidOperationError(err.message, 'setLightThemeAsync')); }
637
+ finally { cstate.action = 0; cstate.emitEquipmentChange(); }
638
+ }
639
+ public async setDimmerLevelAsync(id: number, level: number): Promise<ICircuitState> {
640
+ const circ = sys.circuits.getItemById(id);
641
+ if (!this.isWaterColorsCircuit(circ)) return super.setDimmerLevelAsync(id, level);
642
+ const cstate = state.circuits.getItemById(id);
643
+ const brightness = this.normalizeWaterColorsBrightness(level);
644
+ const nop = sys.board.valueMaps.circuitActions.getValue('lighttheme');
645
+ cstate.action = nop;
646
+ cstate.emitEquipmentChange();
647
+ try {
648
+ if (brightness > 0 && !cstate.isOn) await this.sendWaterColorsOnCommandAsync(id);
649
+ await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_THEME, [0x00, 0xFF, brightness], `aqualink-watercolors-brightness-${id}`);
650
+ circ.level = brightness;
651
+ cstate.level = brightness;
652
+ this.setWaterColorsState(circ, cstate, brightness > 0);
653
+ state.emitEquipmentChanges();
654
+ return cstate;
655
+ }
656
+ catch (err) { return Promise.reject(new InvalidOperationError(err.message, 'setDimmerLevelAsync')); }
657
+ finally { cstate.action = 0; cstate.emitEquipmentChange(); }
658
+ }
659
+ public async setLightColorAsync(id: number, color: { red: number; green: number; blue: number }): Promise<ICircuitState> {
660
+ const circ = sys.circuits.getItemById(id);
661
+ if (!this.isWaterColorsCircuit(circ)) return super.setLightColorAsync(id, color);
662
+ const cstate = state.circuits.getItemById(id);
663
+ const red = this.normalizeWaterColorsComponent(color.red, 'red');
664
+ const green = this.normalizeWaterColorsComponent(color.green, 'green');
665
+ const blue = this.normalizeWaterColorsComponent(color.blue, 'blue');
666
+ const nop = sys.board.valueMaps.circuitActions.getValue('lighttheme');
667
+ cstate.action = nop;
668
+ cstate.emitEquipmentChange();
669
+ try {
670
+ if (!cstate.isOn) await this.sendWaterColorsOnCommandAsync(id);
671
+ await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_RGB, [0x00, red, green, blue, 0x00], `aqualink-watercolors-rgb-${id}`);
672
+ const level = this.getWaterColorsLevel(circ, cstate);
673
+ circ.level = level;
674
+ cstate.level = level;
675
+ this.setWaterColorsState(circ, cstate, true);
676
+ circ.lightingTheme = sys.board.valueMaps.lightThemes.getValue('none');
677
+ cstate.lightingTheme = sys.board.valueMaps.lightThemes.getValue('none');
678
+ cstate.color = { red, green, blue };
679
+ state.emitEquipmentChanges();
680
+ return cstate;
681
+ }
682
+ catch (err) { return Promise.reject(new InvalidOperationError(err.message, 'setLightColorAsync')); }
683
+ finally { cstate.action = 0; cstate.emitEquipmentChange(); }
512
684
  }
513
685
  public async runLightGroupCommandAsync(obj: any): Promise<ICircuitState> {
514
686
  // Do all our validation.
@@ -1218,6 +1218,26 @@ class TouchSystemCommands extends SystemCommands {
1218
1218
  }
1219
1219
  }
1220
1220
  class TouchBodyCommands extends BodyCommands {
1221
+ public getHeatSources(bodyId: number) {
1222
+ let heatSources = super.getHeatSources(bodyId);
1223
+ for (let i = 0; i < heatSources.length; i++) {
1224
+ let hm = heatSources[i];
1225
+ if (hm?.name === 'ultratemp' && typeof hm.val === 'undefined') {
1226
+ heatSources[i] = this.board.valueMaps.heatSources.transformByName('heatpump');
1227
+ }
1228
+ }
1229
+ return heatSources;
1230
+ }
1231
+ public getHeatModes(bodyId: number) {
1232
+ let heatModes = super.getHeatModes(bodyId);
1233
+ for (let i = 0; i < heatModes.length; i++) {
1234
+ let hm = heatModes[i];
1235
+ if (hm?.name === 'ultratemp' && typeof hm.val === 'undefined') {
1236
+ heatModes[i] = this.board.valueMaps.heatModes.transformByName('heatpump');
1237
+ }
1238
+ }
1239
+ return heatModes;
1240
+ }
1221
1241
  public async setBodyAsync(obj: any): Promise<Body> {
1222
1242
  // The 168 is a funky packet in *Touch because it can set:
1223
1243
  // * Intellichem Installed (byte 3, bit 1)
@@ -1499,6 +1519,7 @@ export class TouchCircuitCommands extends CircuitCommands {
1499
1519
  let cstate = state.circuits.getInterfaceById(data.id, true);
1500
1520
  let showInFeatures = cstate.showInFeatures = typeof data.showInFeatures !== 'undefined' ? utils.makeBool(data.showInFeatures) : circuit.showInFeatures;
1501
1521
  let typeByte = parseInt(data.type, 10) === 0 ? 0 : parseInt(data.type, 10) || circuit.type || sys.board.valueMaps.circuitFunctions.getValue('generic');
1522
+ this.assertSinglePoolSpaType(id, typeByte);
1502
1523
  let freeze = typeof data.freeze !== 'undefined' ? utils.makeBool(data.freeze) : circuit.freeze;
1503
1524
  let nameByte = 3; // set default `Aux 1`
1504
1525
  if (typeof data.nameId !== 'undefined') nameByte = data.nameId;
@@ -2701,6 +2722,18 @@ class TouchHeaterCommands extends HeaterCommands {
2701
2722
  let heaters = sys.heaters.get();
2702
2723
  let types = sys.board.valueMaps.heaterTypes.toArray();
2703
2724
  let inst = { total: 0 };
2725
+ // If NCP directly controls an ultratemp/heatpump for the same body, suppress
2726
+ // corresponding OCP ghost entries for that body only.
2727
+ let hasNcpUltratempForBody = (ocpHeater: Heater): boolean => {
2728
+ let ocpBody = typeof ocpHeater.body === 'number' ? ocpHeater.body : 32;
2729
+ return heaters.some(h => {
2730
+ if (h.master !== 1 || h.isActive === false) return false;
2731
+ let t = types.find(elem => elem.val === h.type);
2732
+ if (!t || (t.name !== 'ultratemp' && t.name !== 'heatpump')) return false;
2733
+ let ncpBody = typeof h.body === 'number' ? h.body : 32;
2734
+ return ncpBody === 32 || ocpBody === 32 || ncpBody === ocpBody;
2735
+ });
2736
+ };
2704
2737
  for (let i = 0; i < types.length; i++) if (types[i].name !== 'none') inst[types[i].name] = 0;
2705
2738
  for (let i = 0; i < heaters.length; i++) {
2706
2739
  let heater = heaters[i];
@@ -2709,6 +2742,9 @@ class TouchHeaterCommands extends HeaterCommands {
2709
2742
  }
2710
2743
  let type = types.find(elem => elem.val === heater.type);
2711
2744
  if (typeof type !== 'undefined') {
2745
+ // Skip OCP ghost heaters only when there is a matching NCP-controlled
2746
+ // ultratemp/heatpump for this heater's body.
2747
+ if (heater.master === 0 && (type.name === 'hybrid' || type.name === 'ultratemp' || type.name === 'solar') && hasNcpUltratempForBody(heater)) continue;
2712
2748
  if (inst[type.name] === 'undefined') inst[type.name] = 0;
2713
2749
  inst[type.name] = inst[type.name] + 1;
2714
2750
  if (heater.coolingEnabled === true && type.hasCoolSetpoint === true) inst['hasCoolSetpoint'] = true;
@@ -2971,6 +3007,10 @@ class TouchHeaterCommands extends HeaterCommands {
2971
3007
  [21, { name: 'ultratemp', desc: 'Ultratemp Only', hasCoolSetpoint: htypes.hasCoolSetpoint }]
2972
3008
  ])
2973
3009
  }
3010
+ else if (ultratempInstalled) {
3011
+ sys.board.valueMaps.heatModes.set(1, { name: 'heatpump', desc: 'Heat Pump' });
3012
+ sys.board.valueMaps.heatSources.set(2, { name: 'heatpump', desc: 'Heat Pump', hasCoolSetpoint: htypes.hasCoolSetpoint });
3013
+ }
2974
3014
  else {
2975
3015
  // only gas
2976
3016
  sys.board.valueMaps.heatModes.delete(2);
@@ -3022,6 +3062,9 @@ class TouchChemControllerCommands extends ChemControllerCommands {
3022
3062
  let cyanuricAcid = typeof data.cyanuricAcid !== 'undefined' ? parseInt(data.cyanuricAcid, 10) : chem.cyanuricAcid;
3023
3063
  let alkalinity = typeof data.alkalinity !== 'undefined' ? parseInt(data.alkalinity, 10) : chem.alkalinity;
3024
3064
  let borates = typeof data.borates !== 'undefined' ? parseInt(data.borates, 10) : chem.borates || 0;
3065
+ let intellichemStandalone = sys.controllerType === ControllerType.Nixie
3066
+ ? (typeof data.intellichemStandalone !== 'undefined' ? utils.makeBool(data.intellichemStandalone) : chem.intellichemStandalone)
3067
+ : false;
3025
3068
  let body = sys.board.bodies.mapBodyAssociation(typeof data.body === 'undefined' ? chem.body : data.body);
3026
3069
  if (typeof body === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Invalid body assignment`, 'chemController', data.body || chem.body));
3027
3070
  // Do a final validation pass so we dont send this off in a mess.
@@ -3107,6 +3150,7 @@ class TouchChemControllerCommands extends ChemControllerCommands {
3107
3150
  chem.alkalinity = alkalinity;
3108
3151
  chem.borates = borates;
3109
3152
  chem.body = schem.body = body.val;
3153
+ chem.intellichemStandalone = intellichemStandalone;
3110
3154
  schem.isActive = chem.isActive = true;
3111
3155
  chem.lsiRange.enabled = lsiRange.enabled;
3112
3156
  chem.lsiRange.low = lsiRange.low;