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.
- package/.github/workflows/ghcr-publish.yml +1 -1
- package/157_issues.md +101 -0
- package/AGENTS.md +17 -1
- package/README.md +13 -2
- package/controller/Equipment.ts +49 -0
- package/controller/State.ts +8 -0
- package/controller/boards/AquaLinkBoard.ts +174 -2
- package/controller/boards/EasyTouchBoard.ts +44 -0
- package/controller/boards/IntelliCenterBoard.ts +360 -172
- package/controller/boards/NixieBoard.ts +7 -4
- package/controller/boards/SunTouchBoard.ts +1 -0
- package/controller/boards/SystemBoard.ts +39 -4
- package/controller/comms/Comms.ts +9 -3
- package/controller/comms/messages/Messages.ts +218 -24
- package/controller/comms/messages/config/EquipmentMessage.ts +34 -0
- package/controller/comms/messages/config/ExternalMessage.ts +1051 -989
- package/controller/comms/messages/config/GeneralMessage.ts +65 -0
- package/controller/comms/messages/config/OptionsMessage.ts +15 -2
- package/controller/comms/messages/config/PumpMessage.ts +427 -421
- package/controller/comms/messages/config/SecurityMessage.ts +37 -13
- package/controller/comms/messages/status/EquipmentStateMessage.ts +0 -218
- package/controller/comms/messages/status/HeaterStateMessage.ts +27 -15
- package/controller/comms/messages/status/NeptuneModbusStateMessage.ts +217 -0
- package/controller/comms/messages/status/VersionMessage.ts +67 -18
- package/controller/nixie/chemistry/ChemController.ts +65 -33
- package/controller/nixie/heaters/Heater.ts +10 -1
- package/controller/nixie/pumps/Pump.ts +145 -2
- package/docker-compose.yml +1 -0
- package/logger/Logger.ts +75 -64
- package/package.json +1 -1
- package/tsconfig.json +2 -1
- package/web/Server.ts +3 -1
- package/web/services/config/Config.ts +150 -1
- package/web/services/state/State.ts +21 -0
- 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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 -
|
|
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
|
|
92
|
-
// 1
|
|
93
|
-
// 2 Current heater status 0=off, 1=heat, 2=cool
|
|
94
|
-
// 3
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
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
|
|
101
|
-
// 1
|
|
102
|
-
// 3 Believed to be
|
|
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
|
+
}
|