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
@@ -20,20 +20,44 @@ import { sys, SecurityRole } from "../../../Equipment";
20
20
 
21
21
  export class SecurityMessage {
22
22
  public static process(msg: Inbound): void {
23
- var role: SecurityRole;
24
- switch (msg.extractPayloadByte(1)) {
25
- case 0:
26
- sys.security.enabled = (msg.extractPayloadByte(3) & 1) === 1;
27
- sys.security.roles.clear();
28
- break;
23
+ const item = msg.extractPayloadByte(1, 0);
24
+ if (item === 0) {
25
+ // Item 0 is always the admin role and marks a full role block refresh.
26
+ sys.security.roles.clear();
29
27
  }
30
- if ((msg.extractPayloadByte(3) & 2) === 2) {
31
- role = sys.security.roles.getItemById(msg.extractPayloadByte(1) + 1, true);
32
- role.name = msg.extractPayloadString(4, 16);
33
- role.timeout = msg.extractPayloadByte(20);
34
- role.flag1 = msg.extractPayloadByte(2);
35
- role.flag2 = msg.extractPayloadByte(3);
36
- role.pin = msg.extractPayloadByte(21).toString() + msg.extractPayloadByte(22).toString() + msg.extractPayloadByte(23).toString() + msg.extractPayloadByte(24).toString();
28
+
29
+ const roleId = item + 1;
30
+ const roleName = msg.extractPayloadString(5, 16).trim();
31
+ const pinNumber = ((msg.extractPayloadByte(3, 0) & 0xFF) << 8) | (msg.extractPayloadByte(4, 0) & 0xFF);
32
+ const timeout = msg.extractPayloadByte(25, 0);
33
+ const permissionsBytes = [
34
+ msg.extractPayloadByte(21, 0),
35
+ msg.extractPayloadByte(22, 0),
36
+ msg.extractPayloadByte(23, 0),
37
+ msg.extractPayloadByte(24, 0)
38
+ ];
39
+ const permissionsMask =
40
+ ((permissionsBytes[0] & 0xFF) * 16777216) +
41
+ ((permissionsBytes[1] & 0xFF) * 65536) +
42
+ ((permissionsBytes[2] & 0xFF) * 256) +
43
+ (permissionsBytes[3] & 0xFF);
44
+ const hasRoleData = roleName.length > 0 || pinNumber > 0 || timeout > 0 || permissionsMask > 0;
45
+
46
+ if (hasRoleData) {
47
+ const role: SecurityRole = sys.security.roles.getItemById(roleId, true);
48
+ role.name = roleName;
49
+ role.timeout = timeout;
50
+ role.flag1 = msg.extractPayloadByte(2, 0);
51
+ role.flag2 = msg.extractPayloadByte(24, 0);
52
+ role.pin = pinNumber.toString().padStart(4, '0');
53
+ role.permissionsMask = permissionsMask;
54
+ role.permissionsBytes = permissionsBytes;
55
+ if (item === 0) {
56
+ sys.security.enabledByte = permissionsBytes[3];
57
+ sys.security.enabled = (permissionsBytes[3] & 0x80) === 0x80;
58
+ }
59
+ } else {
60
+ sys.security.roles.removeItemById(roleId);
37
61
  }
38
62
  msg.isProcessed = true;
39
63
  }
@@ -27,61 +27,7 @@ import { BodyTempState, ScheduleState, State, state } from '../../../State';
27
27
  import { ExternalMessage } from '../config/ExternalMessage';
28
28
  import { Inbound, Message, Outbound } from '../Messages';
29
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
30
  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
31
  private static initIntelliCenter(msg: Inbound) {
86
32
  sys.controllerType = ControllerType.IntelliCenter;
87
33
  sys.equipment.maxSchedules = 100;
@@ -216,11 +162,6 @@ export class EquipmentStateMessage {
216
162
  pc & 0x02 ? msg.extractPayloadByte(17) : 0x00, pc & 0x02 ? msg.extractPayloadByte(18) : 0x00,
217
163
  pc & 0x04 ? msg.extractPayloadByte(19) : 0x00, pc & 0x04 ? msg.extractPayloadByte(20) : 0x00);
218
164
  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
165
  }
225
166
  else return;
226
167
  }
@@ -679,154 +620,6 @@ export class EquipmentStateMessage {
679
620
  break;
680
621
  }
681
622
  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
623
  msg.isProcessed = true;
831
624
  break;
832
625
  }
@@ -863,10 +656,6 @@ export class EquipmentStateMessage {
863
656
  state.time.month = msg.extractPayloadByte(7);
864
657
  state.time.date = msg.extractPayloadByte(6);
865
658
  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
659
  // v3.004 adds 4 additional bytes (44-46) that are the time of day
871
660
  // Byte 44: Hour (0-23)
872
661
  // Byte 45: Minute (0-59)
@@ -947,13 +736,6 @@ export class EquipmentStateMessage {
947
736
  let isOn = ((circuitId === 6 || circuitId === 1) && sys.equipment.dual === true) ? cstate.isOn : (byte & (1 << j)) > 0;
948
737
  //let isOn = (byte & (1 << j)) > 0;
949
738
  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
739
  cstate.name = circuit.name;
958
740
  cstate.nameId = circuit.nameId;
959
741
  cstate.showInFeatures = circuit.showInFeatures;
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
18
18
  import { Inbound, Protocol } from "../Messages";
19
19
  import { state, BodyTempState, HeaterState } from "../../../State";
20
20
  import { sys, ControllerType, Heater } from "../../../Equipment";
21
+ import { logger } from '../../../../logger/Logger';
21
22
 
22
23
  export class HeaterStateMessage {
23
24
  public static process(msg: Inbound) {
@@ -85,33 +86,44 @@ export class HeaterStateMessage {
85
86
  msg.isProcessed = true;
86
87
  }
87
88
  public static processUltraTempStatus(msg: Inbound) {
88
- // RKS: 07-03-21 - We only know byte 2 at this point for Ultratemp for the 115 message we are processing here. The
89
+ // RKS: 07-03-21 - UltraTemp RS-485 protocol reverse engineering notes.
90
+ // The heat pump communicates via Action 114 (command) / 115 (response) messages.
91
+ //
92
+ // Action 115 - inbound response (heat pump -> controller, heartbeat every ~1s)
93
+ // [165, 0, 16, 112, 115, 10][160, 1, 0, 3, 0, 0, 0, 0, 0, 0][2, 70]
89
94
  // byte description
90
95
  // ------------------------------------------------
91
- // 0 Unknown (always seems to be 160 for response)
92
- // 1 Unknown (always 1)
93
- // 2 Current heater status 0=off, 1=heat, 2=cool
94
- // 3-9 Unknown
95
-
96
- // 114 message - outbound response
97
- //[165, 0, 112, 16, 114, 10][144, 0, 0, 0, 0, 0, 0, 0, 0, 0][2, 49] // OCP to Heater
96
+ // 0 Always 160 for response
97
+ // 1 Always 1
98
+ // 2 Current heater status: 0=off, 1=heat, 2=cool
99
+ // 3 Believed to be offset temp
100
+ // 4-9 Unknown
101
+ //
102
+ // Action 114 - outbound command (controller -> heat pump)
103
+ // [165, 0, 112, 16, 114, 10][144, 0, 0, 0, 0, 0, 0, 0, 0, 0][2, 49]
98
104
  // byte description
99
105
  // ------------------------------------------------
100
- // 0 Unknown (always seems to be 144 for request)
101
- // 1 Current heater status 0=off, 1=heat, 2=cool
102
- // 3 Believed to be ofset temp
106
+ // 0 Always 144 for request
107
+ // 1 Sets heater mode: 0=off, 1=heat, 2=cool
108
+ // 3 Believed to be offset temp
103
109
  // 4-9 Unknown
104
-
105
- // byto 0: always seems to be 144 for outbound
106
- // byte 1: Sets heater mode to 0 = Off 1 = Heat 2 = Cool
107
- //[165, 0, 16, 112, 115, 10][160, 1, 0, 3, 0, 0, 0, 0, 0, 0][2, 70] // Heater Reply
108
110
  let heater: Heater = sys.heaters.getItemByAddress(msg.source);
111
+ if (typeof heater === 'undefined' || !heater.isActive) {
112
+ // Heat pump not configured for this address
113
+ msg.isProcessed = true;
114
+ return;
115
+ }
109
116
  let sheater = state.heaters.getItemById(heater.id);
110
117
  let byte = msg.extractPayloadByte(2);
118
+ let prevOn = sheater.isOn;
119
+ let prevCooling = sheater.isCooling;
111
120
  sheater.isOn = byte >= 1;
112
121
  sheater.isCooling = byte === 2;
113
122
  sheater.commStatus = 0;
114
123
  state.equipment.messages.removeItemByCode(`heater:${heater.id}:comms`);
124
+ if (prevOn !== sheater.isOn || prevCooling !== sheater.isCooling) {
125
+ logger.info(`UltraTemp heartbeat: src=${msg.source} status=${byte} (${byte === 0 ? 'OFF' : byte === 1 ? 'HEAT' : 'COOL'}) heater=${heater.name}`);
126
+ }
115
127
  msg.isProcessed = true;
116
128
  }
117
129
  public static processMasterTempStatus(msg: Inbound) {
@@ -0,0 +1,217 @@
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 { Inbound } from "../Messages";
19
+ import { state, PumpState } from "../../../State";
20
+ import { sys, Pump } from "../../../Equipment";
21
+ import { logger } from "../../../../logger/Logger";
22
+
23
+ type PendingRead = {
24
+ startAddr: number;
25
+ quantity: number;
26
+ requestedAt: number;
27
+ };
28
+
29
+ export class NeptuneModbusStateMessage {
30
+ private static pendingReads: Map<number, PendingRead[]> = new Map();
31
+
32
+ public static enqueueReadRequest(address: number, startAddr: number, quantity: number) {
33
+ const queue = this.pendingReads.get(address) || [];
34
+ queue.push({ startAddr, quantity, requestedAt: Date.now() });
35
+ this.pendingReads.set(address, queue);
36
+ }
37
+
38
+ public static clearReadRequests(address: number) {
39
+ this.pendingReads.delete(address);
40
+ }
41
+
42
+ private static dequeueReadRequest(address: number): PendingRead {
43
+ const queue = this.pendingReads.get(address);
44
+ if (!queue || queue.length === 0) return undefined;
45
+ const request = queue.shift();
46
+ if (queue.length === 0) this.pendingReads.delete(address);
47
+ else this.pendingReads.set(address, queue);
48
+ return request;
49
+ }
50
+
51
+ private static getNeptunePumpByAddress(address: number): Pump {
52
+ for (let i = 0; i < sys.pumps.length; i++) {
53
+ const pump = sys.pumps.getItemByIndex(i);
54
+ const typeName = sys.board.valueMaps.pumpTypes.getName(pump.type);
55
+ if (typeName === 'neptunemodbus' && pump.address === address) return pump;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ private static toSigned16(value: number): number {
61
+ return value > 0x7FFF ? value - 0x10000 : value;
62
+ }
63
+
64
+ private static decodeModbusException(code: number): string {
65
+ const modbusExceptions = {
66
+ 0x01: 'Illegal function',
67
+ 0x02: 'Illegal data address',
68
+ 0x03: 'Illegal data value',
69
+ 0x04: 'Server device failure',
70
+ 0x05: 'Acknowledge',
71
+ 0x06: 'Server device busy',
72
+ 0x08: 'Memory parity error',
73
+ 0x0A: 'Gateway path unavailable',
74
+ 0x0B: 'Gateway target failed to respond',
75
+ };
76
+ return modbusExceptions[code] || `Unknown Modbus exception ${code}`;
77
+ }
78
+
79
+ private static processReadInput(msg: Inbound, address: number, pumpState: PumpState) {
80
+ const request = this.dequeueReadRequest(address);
81
+ const byteCount = msg.extractPayloadByte(0, 0);
82
+ if (byteCount <= 0 || (byteCount % 2) !== 0) {
83
+ logger.debug(`NeptuneModbusStateMessage.processReadInput invalid byte count ${byteCount} (Address: ${address})`);
84
+ return;
85
+ }
86
+ if (msg.payload.length < byteCount + 1) {
87
+ logger.debug(`NeptuneModbusStateMessage.processReadInput short payload (Address: ${address})`);
88
+ return;
89
+ }
90
+
91
+ let motorFaultCode = 0;
92
+ let interfaceFaultCode = 0;
93
+ let stoppedState: number = undefined;
94
+
95
+ for (let i = 0; i < byteCount; i += 2) {
96
+ const value = (msg.payload[i + 1] << 8) | msg.payload[i + 2];
97
+ const offset = i / 2;
98
+ const registerAddr = request ? request.startAddr + offset : -1;
99
+ switch (registerAddr) {
100
+ case 0: // 30001 Current Speed
101
+ pumpState.rpm = value;
102
+ break;
103
+ case 3: // 30004 Motor Power
104
+ pumpState.watts = value;
105
+ break;
106
+ case 5: // 30006 Motor Fault Status
107
+ logger.debug(`Neptune motor fault status ${value} (Address: ${address})`);
108
+ break;
109
+ case 6: // 30007 Motor Fault Code
110
+ motorFaultCode = value;
111
+ break;
112
+ case 30: // 30031 Interface Fault State
113
+ logger.debug(`Neptune interface fault state ${value} (Address: ${address})`);
114
+ break;
115
+ case 31: // 30032 Interface Fault Code
116
+ interfaceFaultCode = value;
117
+ break;
118
+ case 113: // 30114 Stopped State bit image
119
+ stoppedState = value;
120
+ break;
121
+ case 119: // 30120 Target shaft speed (signed)
122
+ pumpState.targetSpeed = Math.abs(this.toSigned16(value));
123
+ break;
124
+ default:
125
+ // Keep MVP mapping focused on existing pump state fields.
126
+ break;
127
+ }
128
+ }
129
+
130
+ const hasFault = motorFaultCode > 0 || interfaceFaultCode > 0;
131
+ if (hasFault) {
132
+ logger.warn(`Neptune fault detected (Address: ${address}) motorFault=${motorFaultCode} interfaceFault=${interfaceFaultCode}`);
133
+ pumpState.status = 16;
134
+ pumpState.driveState = 4;
135
+ pumpState.command = 4;
136
+ return;
137
+ }
138
+
139
+ if (typeof stoppedState !== 'undefined') {
140
+ const isStopped = (stoppedState & 0x01) === 1;
141
+ pumpState.driveState = isStopped ? 0 : 2;
142
+ pumpState.command = isStopped ? 4 : 10;
143
+ pumpState.status = isStopped ? 0 : 1;
144
+ }
145
+ else if (pumpState.rpm > 0) {
146
+ pumpState.driveState = 2;
147
+ pumpState.command = 10;
148
+ pumpState.status = 1;
149
+ }
150
+ else {
151
+ pumpState.driveState = 0;
152
+ pumpState.command = 4;
153
+ pumpState.status = 0;
154
+ }
155
+ }
156
+
157
+ private static processWriteSingle(msg: Inbound, address: number, pumpState: PumpState) {
158
+ if (msg.payload.length < 4) return;
159
+ const registerAddr = (msg.payload[0] << 8) | msg.payload[1];
160
+ const registerValue = (msg.payload[2] << 8) | msg.payload[3];
161
+ switch (registerAddr) {
162
+ case 0: // 40001 Motor On/Off
163
+ if (registerValue === 0) {
164
+ pumpState.driveState = 0;
165
+ pumpState.command = 4;
166
+ pumpState.status = 0;
167
+ }
168
+ else if (registerValue === 1) {
169
+ pumpState.driveState = 2;
170
+ pumpState.command = 10;
171
+ pumpState.status = 1;
172
+ }
173
+ break;
174
+ case 1: // 40002 Manual speed RPM
175
+ pumpState.rpm = registerValue;
176
+ break;
177
+ default:
178
+ logger.debug(`Neptune write ack for unhandled register ${registerAddr}=${registerValue} (Address: ${address})`);
179
+ break;
180
+ }
181
+ }
182
+
183
+ public static process(msg: Inbound) {
184
+ const address = msg.dest;
185
+ const functionCode = msg.action;
186
+ const pumpCfg = this.getNeptunePumpByAddress(address);
187
+ if (typeof pumpCfg === 'undefined') {
188
+ logger.debug(`NeptuneModbusStateMessage.process ignored unconfigured address ${address}`);
189
+ return;
190
+ }
191
+ const pumpState = state.pumps.getItemById(pumpCfg.id, pumpCfg.isActive === true);
192
+
193
+ if ((functionCode & 0x80) === 0x80) {
194
+ const exceptionCode = msg.extractPayloadByte(0, 0);
195
+ logger.warn(`Neptune Modbus exception response fn=0x${functionCode.toString(16)} code=${exceptionCode} (${this.decodeModbusException(exceptionCode)}) address=${address}`);
196
+ pumpState.status = 16;
197
+ state.emitEquipmentChanges();
198
+ return;
199
+ }
200
+
201
+ switch (functionCode) {
202
+ case 0x04: // Read input registers
203
+ this.processReadInput(msg, address, pumpState);
204
+ break;
205
+ case 0x06: // Write single register
206
+ this.processWriteSingle(msg, address, pumpState);
207
+ break;
208
+ case 0x10: // Write multiple registers
209
+ logger.debug(`Neptune write-multiple response (Address: ${address})`);
210
+ break;
211
+ default:
212
+ logger.debug(`NeptuneModbusStateMessage.process unhandled function 0x${functionCode.toString(16)} (Address: ${address})`);
213
+ break;
214
+ }
215
+ state.emitEquipmentChanges();
216
+ }
217
+ }