nodejs-poolcontroller 8.1.1 → 8.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +63 -0
- package/.github/workflows/ghcr-publish.yml +67 -0
- package/Changelog +27 -0
- package/Dockerfile +52 -9
- package/README.md +127 -9
- package/config/Config.ts +57 -7
- package/config/VersionCheck.ts +63 -35
- package/controller/Equipment.ts +1 -1
- package/controller/State.ts +14 -3
- package/controller/boards/EasyTouchBoard.ts +10 -10
- package/controller/boards/IntelliCenterBoard.ts +20 -3
- package/controller/boards/NixieBoard.ts +31 -16
- package/controller/boards/SunTouchBoard.ts +2 -0
- package/controller/boards/SystemBoard.ts +1 -1
- package/controller/comms/Comms.ts +55 -14
- package/controller/comms/messages/Messages.ts +169 -6
- package/controller/comms/messages/status/RegalModbusStateMessage.ts +411 -0
- package/controller/nixie/pumps/Pump.ts +198 -0
- package/defaultConfig.json +5 -0
- package/docker-compose.yml +32 -0
- package/package.json +23 -25
- package/types/express-multer.d.ts +32 -0
- package/.github/workflows/docker-publish-njsPC-linux.yml +0 -50
package/config/VersionCheck.ts
CHANGED
|
@@ -28,10 +28,24 @@ class VersionCheck {
|
|
|
28
28
|
private gitApiHost: string;
|
|
29
29
|
private gitLatestReleaseJSONPath: string;
|
|
30
30
|
private redirects: number;
|
|
31
|
+
private gitAvailable: boolean;
|
|
32
|
+
private warnedBranch: boolean = false;
|
|
33
|
+
private warnedCommit: boolean = false;
|
|
31
34
|
constructor() {
|
|
32
35
|
this.userAgent = 'tagyoureit-nodejs-poolController-app';
|
|
33
36
|
this.gitApiHost = 'api.github.com';
|
|
34
37
|
this.gitLatestReleaseJSONPath = '/repos/tagyoureit/nodejs-poolController/releases/latest';
|
|
38
|
+
this.gitAvailable = this.detectGit();
|
|
39
|
+
// NOTE:
|
|
40
|
+
// * SOURCE_BRANCH / SOURCE_COMMIT env vars (if present) override git commands. These are expected in container builds where .git may be absent.
|
|
41
|
+
// * If git is not available (no binary or not a repo) we suppress repeated warnings after the first occurrence.
|
|
42
|
+
// * Version comparison is rate-limited via nextCheckTime (every 2 days) to avoid Github API throttling.
|
|
43
|
+
}
|
|
44
|
+
private detectGit(): boolean {
|
|
45
|
+
try {
|
|
46
|
+
execSync('git --version', { stdio: 'ignore' });
|
|
47
|
+
return true;
|
|
48
|
+
} catch { return false; }
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
public checkGitRemote() {
|
|
@@ -40,43 +54,46 @@ class VersionCheck {
|
|
|
40
54
|
if (typeof state.appVersion.nextCheckTime === 'undefined' || new Date() > new Date(state.appVersion.nextCheckTime)) setTimeout(() => { this.checkAll(); }, 100);
|
|
41
55
|
}
|
|
42
56
|
public checkGitLocal() {
|
|
43
|
-
|
|
44
|
-
//
|
|
57
|
+
const env = process.env;
|
|
58
|
+
// Branch
|
|
45
59
|
try {
|
|
46
60
|
let out: string;
|
|
47
|
-
if (typeof env.SOURCE_BRANCH !== 'undefined')
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
else {
|
|
52
|
-
let res = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' });
|
|
61
|
+
if (typeof env.SOURCE_BRANCH !== 'undefined') {
|
|
62
|
+
out = env.SOURCE_BRANCH;
|
|
63
|
+
} else if (this.gitAvailable) {
|
|
64
|
+
const res = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' });
|
|
53
65
|
out = res.toString().trim();
|
|
66
|
+
} else {
|
|
67
|
+
out = '--';
|
|
54
68
|
}
|
|
55
|
-
logger.info(`The current git branch output is ${out}`);
|
|
69
|
+
if (out !== '--') logger.info(`The current git branch output is ${out}`);
|
|
56
70
|
switch (out) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
catch (err) {
|
|
71
|
+
case 'fatal':
|
|
72
|
+
case 'command':
|
|
73
|
+
state.appVersion.gitLocalBranch = '--';
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
state.appVersion.gitLocalBranch = out;
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
66
79
|
state.appVersion.gitLocalBranch = '--';
|
|
67
|
-
|
|
80
|
+
if (!this.warnedBranch) {
|
|
81
|
+
logger.warn(`Unable to retrieve local git branch (git missing or not a repo). Further branch warnings suppressed.`);
|
|
82
|
+
this.warnedBranch = true;
|
|
83
|
+
}
|
|
68
84
|
}
|
|
85
|
+
// Commit
|
|
69
86
|
try {
|
|
70
87
|
let out: string;
|
|
71
|
-
if (typeof env.SOURCE_COMMIT !== 'undefined')
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
else {
|
|
76
|
-
let res = execSync('git rev-parse HEAD', { stdio: 'pipe' });
|
|
88
|
+
if (typeof env.SOURCE_COMMIT !== 'undefined') {
|
|
89
|
+
out = env.SOURCE_COMMIT;
|
|
90
|
+
} else if (this.gitAvailable) {
|
|
91
|
+
const res = execSync('git rev-parse HEAD', { stdio: 'pipe' });
|
|
77
92
|
out = res.toString().trim();
|
|
93
|
+
} else {
|
|
94
|
+
out = '--';
|
|
78
95
|
}
|
|
79
|
-
logger.info(`The current git commit output is ${out}`);
|
|
96
|
+
if (out !== '--') logger.info(`The current git commit output is ${out}`);
|
|
80
97
|
switch (out) {
|
|
81
98
|
case 'fatal':
|
|
82
99
|
case 'command':
|
|
@@ -85,10 +102,12 @@ class VersionCheck {
|
|
|
85
102
|
default:
|
|
86
103
|
state.appVersion.gitLocalCommit = out;
|
|
87
104
|
}
|
|
88
|
-
}
|
|
89
|
-
catch (err) {
|
|
105
|
+
} catch (err) {
|
|
90
106
|
state.appVersion.gitLocalCommit = '--';
|
|
91
|
-
|
|
107
|
+
if (!this.warnedCommit) {
|
|
108
|
+
logger.warn(`Unable to retrieve local git commit (git missing or not a repo). Further commit warnings suppressed.`);
|
|
109
|
+
this.warnedCommit = true;
|
|
110
|
+
}
|
|
92
111
|
}
|
|
93
112
|
}
|
|
94
113
|
private checkAll() {
|
|
@@ -126,15 +145,24 @@ class VersionCheck {
|
|
|
126
145
|
return new Promise<string>((resolve, reject) => {
|
|
127
146
|
try {
|
|
128
147
|
let req = https.request(url, options, async res => {
|
|
129
|
-
if (res.statusCode > 300 && res.statusCode < 400 && res.headers.location)
|
|
148
|
+
if (res.statusCode > 300 && res.statusCode < 400 && res.headers.location) {
|
|
149
|
+
try {
|
|
150
|
+
const redirected = await this.getLatestRelease(res.headers.location);
|
|
151
|
+
return resolve(redirected);
|
|
152
|
+
} catch (e) { return reject(e); }
|
|
153
|
+
}
|
|
130
154
|
let data = '';
|
|
131
155
|
res.on('data', d => { data += d; });
|
|
132
156
|
res.on('end', () => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
157
|
+
try {
|
|
158
|
+
let jdata = JSON.parse(data);
|
|
159
|
+
if (typeof jdata.tag_name !== 'undefined')
|
|
160
|
+
resolve(jdata.tag_name.replace('v', ''));
|
|
161
|
+
else
|
|
162
|
+
reject(`No data returned.`)
|
|
163
|
+
} catch(parseErr: any){
|
|
164
|
+
reject(`Error parsing Github response: ${ parseErr.message }`);
|
|
165
|
+
}
|
|
138
166
|
})
|
|
139
167
|
})
|
|
140
168
|
.end();
|
package/controller/Equipment.ts
CHANGED
|
@@ -1478,7 +1478,7 @@ export class Pump extends EqItem {
|
|
|
1478
1478
|
public set id(val: number) { this.setDataVal('id', val); }
|
|
1479
1479
|
public get portId(): number { return this.data.portId; }
|
|
1480
1480
|
public set portId(val: number) { this.setDataVal('portId', val); }
|
|
1481
|
-
public get address(): number { return this.data.address
|
|
1481
|
+
public get address(): number { return this.data.address; }
|
|
1482
1482
|
public set address(val: number) { this.setDataVal('address', val); }
|
|
1483
1483
|
public get name(): string { return this.data.name; }
|
|
1484
1484
|
public set name(val: string) { this.setDataVal('name', val); }
|
package/controller/State.ts
CHANGED
|
@@ -393,8 +393,18 @@ export class State implements IState {
|
|
|
393
393
|
self.data.time = self._dt.format();
|
|
394
394
|
self.hasChanged = true;
|
|
395
395
|
self.heliotrope.date = self._dt.toDate();
|
|
396
|
-
|
|
397
|
-
|
|
396
|
+
// Provide safe access & environment fallback for coordinates
|
|
397
|
+
const loc = sys?.general?.location || {} as any;
|
|
398
|
+
let lon = loc.longitude;
|
|
399
|
+
let lat = loc.latitude;
|
|
400
|
+
if (typeof lon !== 'number' || typeof lat !== 'number') {
|
|
401
|
+
const envLat = process.env.POOL_LATITUDE ? parseFloat(process.env.POOL_LATITUDE) : undefined;
|
|
402
|
+
const envLon = process.env.POOL_LONGITUDE ? parseFloat(process.env.POOL_LONGITUDE) : undefined;
|
|
403
|
+
if (typeof lon !== 'number' && typeof envLon === 'number' && !isNaN(envLon)) lon = envLon;
|
|
404
|
+
if (typeof lat !== 'number' && typeof envLat === 'number' && !isNaN(envLat)) lat = envLat;
|
|
405
|
+
}
|
|
406
|
+
self.heliotrope.longitude = lon;
|
|
407
|
+
self.heliotrope.latitude = lat;
|
|
398
408
|
let times = self.heliotrope.calculatedTimes;
|
|
399
409
|
self.data.sunrise = times.isValid ? Timestamp.toISOLocal(times.sunrise) : '';
|
|
400
410
|
self.data.sunset = times.isValid ? Timestamp.toISOLocal(times.sunset) : '';
|
|
@@ -947,7 +957,7 @@ export class PumpState extends EqState {
|
|
|
947
957
|
}
|
|
948
958
|
public get id(): number { return this.data.id; }
|
|
949
959
|
public set id(val: number) { this.data.id = val; }
|
|
950
|
-
public get address(): number { return this.data.address
|
|
960
|
+
public get address(): number { return this.data.address; }
|
|
951
961
|
public set address(val: number) { this.setDataVal('address', val); }
|
|
952
962
|
public get name(): string { return this.data.name; }
|
|
953
963
|
public set name(val: string) { this.setDataVal('name', val); }
|
|
@@ -1042,6 +1052,7 @@ export class PumpState extends EqState {
|
|
|
1042
1052
|
case 'hwvs':
|
|
1043
1053
|
case 'vssvrs':
|
|
1044
1054
|
case 'vs':
|
|
1055
|
+
case 'regalmodbus':
|
|
1045
1056
|
c.units = sys.board.valueMaps.pumpUnits.transformByName('rpm');
|
|
1046
1057
|
break;
|
|
1047
1058
|
case 'ss':
|
|
@@ -3030,8 +3030,8 @@ class TouchChemControllerCommands extends ChemControllerCommands {
|
|
|
3030
3030
|
if (isNaN(alkalinity)) return Promise.reject(new InvalidEquipmentDataError(`Invalid alkalinity`, 'chemController', alkalinity));
|
|
3031
3031
|
if (isNaN(borates)) return Promise.reject(new InvalidEquipmentDataError(`Invalid borates`, 'chemController', borates));
|
|
3032
3032
|
let schem = state.chemControllers.getItemById(chem.id, true);
|
|
3033
|
-
let pHSetpoint = typeof data.ph !== 'undefined' && typeof data.ph.setpoint !== 'undefined' ? parseFloat(data.ph.setpoint) : chem.ph.setpoint;
|
|
3034
|
-
let orpSetpoint = typeof data.orp !== 'undefined' && typeof data.orp.setpoint !== 'undefined' ? parseInt(data.orp.setpoint, 10) : chem.orp.setpoint;
|
|
3033
|
+
let pHSetpoint = (typeof data.ph !== 'undefined' && typeof data.ph.setpoint !== 'undefined') ? parseFloat(data.ph.setpoint) : chem.ph.setpoint;
|
|
3034
|
+
let orpSetpoint = (typeof data.orp !== 'undefined' && typeof data.orp.setpoint !== 'undefined') ? parseInt(data.orp.setpoint, 10) : chem.orp.setpoint;
|
|
3035
3035
|
let lsiRange = typeof data.lsiRange !== 'undefined' ? data.lsiRange : chem.lsiRange || {};
|
|
3036
3036
|
if (typeof data.lsiRange !== 'undefined') {
|
|
3037
3037
|
if (typeof data.lsiRange.enabled !== 'undefined') lsiRange.enabled = utils.makeBool(data.lsiRange.enabled);
|
|
@@ -3040,31 +3040,31 @@ class TouchChemControllerCommands extends ChemControllerCommands {
|
|
|
3040
3040
|
}
|
|
3041
3041
|
if (isNaN(pHSetpoint) || pHSetpoint > type.ph.max || pHSetpoint < type.ph.min) return Promise.reject(new InvalidEquipmentDataError(`Invalid pH setpoint`, 'ph.setpoint', pHSetpoint));
|
|
3042
3042
|
if (isNaN(orpSetpoint) || orpSetpoint > type.orp.max || orpSetpoint < type.orp.min) return Promise.reject(new InvalidEquipmentDataError(`Invalid orp setpoint`, 'orp.setpoint', orpSetpoint));
|
|
3043
|
-
let phTolerance = typeof data.ph.tolerance !== 'undefined' ? data.ph.tolerance : chem.ph.tolerance;
|
|
3044
|
-
let orpTolerance = typeof data.orp.tolerance !== 'undefined' ? data.orp.tolerance : chem.orp.tolerance;
|
|
3045
|
-
if (typeof data.ph.tolerance !== 'undefined') {
|
|
3043
|
+
let phTolerance = (typeof data.ph !== 'undefined' && typeof data.ph.tolerance !== 'undefined') ? data.ph.tolerance : chem.ph.tolerance;
|
|
3044
|
+
let orpTolerance = (typeof data.orp !== 'undefined' && typeof data.orp.tolerance !== 'undefined') ? data.orp.tolerance : chem.orp.tolerance;
|
|
3045
|
+
if (typeof data.ph !== 'undefined' && typeof data.ph.tolerance !== 'undefined') {
|
|
3046
3046
|
if (typeof data.ph.tolerance.enabled !== 'undefined') phTolerance.enabled = utils.makeBool(data.ph.tolerance.enabled);
|
|
3047
3047
|
if (typeof data.ph.tolerance.low !== 'undefined') phTolerance.low = parseFloat(data.ph.tolerance.low);
|
|
3048
3048
|
if (typeof data.ph.tolerance.high !== 'undefined') phTolerance.high = parseFloat(data.ph.tolerance.high);
|
|
3049
3049
|
if (isNaN(phTolerance.low)) phTolerance.low = type.ph.min;
|
|
3050
3050
|
if (isNaN(phTolerance.high)) phTolerance.high = type.ph.max;
|
|
3051
3051
|
}
|
|
3052
|
-
if (typeof data.orp.tolerance !== 'undefined') {
|
|
3052
|
+
if (typeof data.orp !== 'undefined' && typeof data.orp.tolerance !== 'undefined') {
|
|
3053
3053
|
if (typeof data.orp.tolerance.enabled !== 'undefined') orpTolerance.enabled = utils.makeBool(data.orp.tolerance.enabled);
|
|
3054
3054
|
if (typeof data.orp.tolerance.low !== 'undefined') orpTolerance.low = parseFloat(data.orp.tolerance.low);
|
|
3055
3055
|
if (typeof data.orp.tolerance.high !== 'undefined') orpTolerance.high = parseFloat(data.orp.tolerance.high);
|
|
3056
3056
|
if (isNaN(orpTolerance.low)) orpTolerance.low = type.orp.min;
|
|
3057
3057
|
if (isNaN(orpTolerance.high)) orpTolerance.high = type.orp.max;
|
|
3058
3058
|
}
|
|
3059
|
-
let phEnabled = typeof data.ph.enabled !== 'undefined' ? utils.makeBool(data.ph.enabled) : chem.ph.enabled;
|
|
3060
|
-
let orpEnabled = typeof data.orp.enabled !== 'undefined' ? utils.makeBool(data.orp.enabled) : chem.orp.enabled;
|
|
3059
|
+
let phEnabled = (typeof data.ph !== 'undefined' && typeof data.ph.enabled !== 'undefined') ? utils.makeBool(data.ph.enabled) : chem.ph.enabled;
|
|
3060
|
+
let orpEnabled = (typeof data.orp !== 'undefined' && typeof data.orp.enabled !== 'undefined') ? utils.makeBool(data.orp.enabled) : chem.orp.enabled;
|
|
3061
3061
|
let siCalcType = typeof data.siCalcType !== 'undefined' ? sys.board.valueMaps.siCalcTypes.encode(data.siCalcType, 0) : chem.siCalcType;
|
|
3062
3062
|
|
|
3063
3063
|
let saltLevel = (state.chlorinators.length > 0) ? state.chlorinators.getItemById(1).saltLevel || 1000 : 1000
|
|
3064
3064
|
chem.ph.tank.capacity = 6;
|
|
3065
3065
|
chem.orp.tank.capacity = 6;
|
|
3066
|
-
let acidTankLevel = typeof data.ph !== 'undefined' && typeof data.ph.tank !== 'undefined' && typeof data.ph.tank.level !== 'undefined' ? parseInt(data.ph.tank.level, 10) : schem.ph.tank.level;
|
|
3067
|
-
let orpTankLevel = typeof data.orp !== 'undefined' && typeof data.orp.tank !== 'undefined' && typeof data.orp.tank.level !== 'undefined' ? parseInt(data.orp.tank.level, 10) : schem.orp.tank.level;
|
|
3066
|
+
let acidTankLevel = (typeof data.ph !== 'undefined' && typeof data.ph.tank !== 'undefined' && typeof data.ph.tank.level !== 'undefined') ? parseInt(data.ph.tank.level, 10) : schem.ph.tank.level;
|
|
3067
|
+
let orpTankLevel = (typeof data.orp !== 'undefined' && typeof data.orp.tank !== 'undefined' && typeof data.orp.tank.level !== 'undefined') ? parseInt(data.orp.tank.level, 10) : schem.orp.tank.level;
|
|
3068
3068
|
// OCP needs to set the IntelliChem as active so it knows that it exists
|
|
3069
3069
|
if (sl.enabled && send) {
|
|
3070
3070
|
if (!schem.isActive) {
|
|
@@ -2825,7 +2825,8 @@ class IntelliCenterBodyCommands extends BodyCommands {
|
|
|
2825
2825
|
processing: boolean,
|
|
2826
2826
|
bytes: number[],
|
|
2827
2827
|
body1: { heatMode: number, heatSetpoint: number, coolSetpoint: number },
|
|
2828
|
-
body2: { heatMode: number, heatSetpoint: number, coolSetpoint: number }
|
|
2828
|
+
body2: { heatMode: number, heatSetpoint: number, coolSetpoint: number },
|
|
2829
|
+
_processingStartTime?: number
|
|
2829
2830
|
};
|
|
2830
2831
|
private async queueBodyHeatSettings(bodyId?: number, byte?: number, data?: any): Promise<Boolean> {
|
|
2831
2832
|
logger.debug(`queueBodyHeatSettings: ${JSON.stringify(this.bodyHeatSettings)}`); // remove this line if #848 is fixed
|
|
@@ -2837,9 +2838,18 @@ class IntelliCenterBodyCommands extends BodyCommands {
|
|
|
2837
2838
|
bytes: [],
|
|
2838
2839
|
body1: { heatMode: body1.heatMode || 1, heatSetpoint: body1.heatSetpoint || 78, coolSetpoint: body1.coolSetpoint || 100 },
|
|
2839
2840
|
body2: { heatMode: body2.heatMode || 1, heatSetpoint: body2.heatSetpoint || 78, coolSetpoint: body2.coolSetpoint || 100 }
|
|
2840
|
-
}
|
|
2841
|
+
};
|
|
2841
2842
|
}
|
|
2842
2843
|
let bhs = this.bodyHeatSettings;
|
|
2844
|
+
|
|
2845
|
+
// Reset processing state if it's been stuck for too long (more than 10 seconds)
|
|
2846
|
+
if (bhs.processing && bhs._processingStartTime && (Date.now() - bhs._processingStartTime > 10000)) {
|
|
2847
|
+
logger.warn(`Resetting stuck bodyHeatSettings processing state after timeout`);
|
|
2848
|
+
bhs.processing = false;
|
|
2849
|
+
bhs.bytes = [];
|
|
2850
|
+
delete bhs._processingStartTime;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2843
2853
|
if (typeof data !== 'undefined' && typeof bodyId !== 'undefined' && bodyId > 0) {
|
|
2844
2854
|
let body = bodyId === 2 ? bhs.body2 : bhs.body1;
|
|
2845
2855
|
if (!bhs.bytes.includes(byte) && byte) bhs.bytes.push(byte);
|
|
@@ -2849,6 +2859,7 @@ class IntelliCenterBodyCommands extends BodyCommands {
|
|
|
2849
2859
|
}
|
|
2850
2860
|
if (!bhs.processing && bhs.bytes.length > 0) {
|
|
2851
2861
|
bhs.processing = true;
|
|
2862
|
+
bhs._processingStartTime = Date.now();
|
|
2852
2863
|
let byte2 = bhs.bytes.shift();
|
|
2853
2864
|
let fnToByte = function (num) { return num < 0 ? Math.abs(num) | 0x80 : Math.abs(num) || 0; };
|
|
2854
2865
|
let payload = [0, 0, byte2, 1,
|
|
@@ -2890,13 +2901,16 @@ class IntelliCenterBodyCommands extends BodyCommands {
|
|
|
2890
2901
|
}
|
|
2891
2902
|
state.emitEquipmentChanges();
|
|
2892
2903
|
} catch (err) {
|
|
2904
|
+
logger.error(`Error in queueBodyHeatSettings: ${err.message}`);
|
|
2893
2905
|
bhs.processing = false;
|
|
2894
2906
|
bhs.bytes = [];
|
|
2907
|
+
delete bhs._processingStartTime;
|
|
2895
2908
|
throw (err);
|
|
2896
2909
|
}
|
|
2897
2910
|
finally {
|
|
2898
2911
|
bhs.processing = false;
|
|
2899
2912
|
bhs.bytes = [];
|
|
2913
|
+
delete bhs._processingStartTime;
|
|
2900
2914
|
}
|
|
2901
2915
|
return true;
|
|
2902
2916
|
}
|
|
@@ -2912,7 +2926,10 @@ class IntelliCenterBodyCommands extends BodyCommands {
|
|
|
2912
2926
|
}
|
|
2913
2927
|
}, 3000);
|
|
2914
2928
|
}
|
|
2915
|
-
else
|
|
2929
|
+
else {
|
|
2930
|
+
bhs.processing = false;
|
|
2931
|
+
delete bhs._processingStartTime;
|
|
2932
|
+
}
|
|
2916
2933
|
return true;
|
|
2917
2934
|
}
|
|
2918
2935
|
}
|
|
@@ -29,6 +29,12 @@ import { webApp } from "../../web/Server";
|
|
|
29
29
|
import { setTimeout } from 'timers/promises';
|
|
30
30
|
import { setTimeout as setTimeoutSync } from 'timers';
|
|
31
31
|
|
|
32
|
+
const addrsPentairPump = Object.freeze([96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111]);
|
|
33
|
+
const addrsRegalModbusPump = Object.freeze(
|
|
34
|
+
Array.from({ length: ((0xF7 - 0x15) / 2) + 1 }, (_, i) => 0x15 + i * 2) // Odd numbers fro 0x15 through 0xF7
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
|
|
32
38
|
export class NixieBoard extends SystemBoard {
|
|
33
39
|
constructor (system: PoolSystem){
|
|
34
40
|
super(system);
|
|
@@ -76,14 +82,15 @@ export class NixieBoard extends SystemBoard {
|
|
|
76
82
|
[17, { name: 'watercolors', desc: 'WaterColors', isLight: true, theme: 'watercolors' }],
|
|
77
83
|
]);
|
|
78
84
|
this.valueMaps.pumpTypes = new byteValueMap([
|
|
79
|
-
[1, { name: 'ss', desc: 'Single Speed', maxCircuits: 8, hasAddress: false, hasBody: false, maxRelays: 1, relays: [{ id: 1, name: 'Pump On/Off' }]}],
|
|
80
|
-
[2, { name: 'ds', desc: 'Two Speed', maxCircuits: 8, hasAddress: false, hasBody: false, maxRelays: 2, relays: [{ id: 1, name: 'Low Speed' }, { id: 2, name: 'High Speed' }]}],
|
|
81
|
-
[3, { name: 'vs', desc: 'Intelliflo VS', maxPrimingTime: 6, minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true }],
|
|
82
|
-
[4, { name: 'vsf', desc: 'Intelliflo VSF', minSpeed: 450, maxSpeed: 3450, minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true }],
|
|
83
|
-
[5, { name: 'vf', desc: 'Intelliflo VF', minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true }],
|
|
84
|
-
[6, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true }],
|
|
85
|
-
[7, { name: 'hwrly', desc: 'Hayward Relay VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, maxSpeeds: 8, relays: [{ id: 1, name: 'Step #1' }, { id: 2, name: 'Step #2'}, { id: 3, name: 'Step #3' }, { id: 4, name: 'Pump On' }] }],
|
|
86
|
-
[100, { name: 'sf', desc: 'SuperFlo VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 4, relays: [{ id: 1, name: 'Program #1' }, { id: 2, name: 'Program #2' }, { id: 3, name: 'Program #3' }, { id: 4, name: 'Program #4' }]}]
|
|
85
|
+
[1, { name: 'ss', desc: 'Single Speed', maxCircuits: 8, hasAddress: false, hasBody: false, maxRelays: 1, relays: [{ id: 1, name: 'Pump On/Off' }], addresses: []}],
|
|
86
|
+
[2, { name: 'ds', desc: 'Two Speed', maxCircuits: 8, hasAddress: false, hasBody: false, maxRelays: 2, relays: [{ id: 1, name: 'Low Speed' }, { id: 2, name: 'High Speed' }], addresses: []}],
|
|
87
|
+
[3, { name: 'vs', desc: 'Intelliflo VS', maxPrimingTime: 6, minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }],
|
|
88
|
+
[4, { name: 'vsf', desc: 'Intelliflo VSF', minSpeed: 450, maxSpeed: 3450, minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }],
|
|
89
|
+
[5, { name: 'vf', desc: 'Intelliflo VF', minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }],
|
|
90
|
+
[6, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }],
|
|
91
|
+
[7, { name: 'hwrly', desc: 'Hayward Relay VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, maxSpeeds: 8, relays: [{ id: 1, name: 'Step #1' }, { id: 2, name: 'Step #2'}, { id: 3, name: 'Step #3' }, { id: 4, name: 'Pump On' }], addresses: [] }],
|
|
92
|
+
[100, { name: 'sf', desc: 'SuperFlo VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 4, relays: [{ id: 1, name: 'Program #1' }, { id: 2, name: 'Program #2' }, { id: 3, name: 'Program #3' }, { id: 4, name: 'Program #4' }], addresses: [] }],
|
|
93
|
+
[200, { name: 'regalmodbus', desc: 'Regal Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsRegalModbusPump}],
|
|
87
94
|
]);
|
|
88
95
|
// RSG - same as systemBoard definition; can delete.
|
|
89
96
|
this.valueMaps.heatModes = new byteValueMap([
|
|
@@ -101,12 +108,20 @@ export class NixieBoard extends SystemBoard {
|
|
|
101
108
|
[6, { name: 'sat', desc: 'Saturday', dow: 6, bitval: 32 }],
|
|
102
109
|
[7, { name: 'sun', desc: 'Sunday', dow: 0, bitval: 64 }]
|
|
103
110
|
]);
|
|
111
|
+
/**
|
|
112
|
+
* groupCircuitStates value map:
|
|
113
|
+
* 1: 'on' - Circuit should be ON when group is ON, OFF when group is OFF.
|
|
114
|
+
* 2: 'off' - Circuit should be OFF when group is ON, ON when group is OFF.
|
|
115
|
+
* 3: 'ignore' - Circuit is ignored by group state changes.
|
|
116
|
+
* 4: 'on+ignore' - Circuit should be ON when group is ON, ignored when group is OFF.
|
|
117
|
+
* 5: 'off+ignore' - Circuit should be OFF when group is ON, ignored when group is OFF.
|
|
118
|
+
*/
|
|
104
119
|
this.valueMaps.groupCircuitStates = new byteValueMap([
|
|
105
|
-
[1, { name: 'on', desc: 'On/Off' }],
|
|
106
|
-
[2, { name: 'off', desc: 'Off/On' }],
|
|
107
|
-
[3, { name: 'ignore', desc: 'Ignore' }],
|
|
108
|
-
[4, { name: 'on+ignore', desc: 'On/Ignore' }],
|
|
109
|
-
[5, { name: 'off+ignore', desc: 'Off/Ignore' }]
|
|
120
|
+
[1, { name: 'on', desc: 'On/Off' }], // 1: ON when group ON, OFF when group OFF
|
|
121
|
+
[2, { name: 'off', desc: 'Off/On' }], // 2: OFF when group ON, ON when group OFF
|
|
122
|
+
[3, { name: 'ignore', desc: 'Ignore' }], // 3: Ignored by group state
|
|
123
|
+
[4, { name: 'on+ignore', desc: 'On/Ignore' }], // 4: ON when group ON, ignored when group OFF
|
|
124
|
+
[5, { name: 'off+ignore', desc: 'Off/Ignore' }] // 5: OFF when group ON, ignored when group OFF
|
|
110
125
|
]);
|
|
111
126
|
this.valueMaps.chlorinatorModel = new byteValueMap([
|
|
112
127
|
[0, { name: 'unknown', desc: 'unknown', capacity: 0, chlorinePerDay: 0, chlorinePerSec: 0 }],
|
|
@@ -1231,7 +1246,7 @@ export class NixieCircuitCommands extends CircuitCommands {
|
|
|
1231
1246
|
}
|
|
1232
1247
|
public async deleteCircuitGroupAsync(obj: any): Promise<CircuitGroup> {
|
|
1233
1248
|
let id = parseInt(obj.id, 10);
|
|
1234
|
-
if (isNaN(id)) return Promise.reject(new
|
|
1249
|
+
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid group id: ${obj.id}`, obj.id, 'CircuitGroup'));
|
|
1235
1250
|
if (!sys.board.equipmentIds.circuitGroups.isInRange(id)) return;
|
|
1236
1251
|
if (typeof obj.id !== 'undefined') {
|
|
1237
1252
|
let group = sys.circuitGroups.getItemById(id, false);
|
|
@@ -1249,7 +1264,7 @@ export class NixieCircuitCommands extends CircuitCommands {
|
|
|
1249
1264
|
}
|
|
1250
1265
|
public async deleteLightGroupAsync(obj: any): Promise<LightGroup> {
|
|
1251
1266
|
let id = parseInt(obj.id, 10);
|
|
1252
|
-
if (isNaN(id)) return Promise.reject(new
|
|
1267
|
+
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid group id: ${obj.id}`, obj.id, 'LightGroup'));
|
|
1253
1268
|
if (!sys.board.equipmentIds.circuitGroups.isInRange(id)) return;
|
|
1254
1269
|
if (typeof obj.id !== 'undefined') {
|
|
1255
1270
|
let group = sys.lightGroups.getItemById(id, false);
|
|
@@ -1762,7 +1777,7 @@ export class NixieValveCommands extends ValveCommands {
|
|
|
1762
1777
|
state.valves.removeItemById(id);
|
|
1763
1778
|
ncp.valves.removeById(id);
|
|
1764
1779
|
return valve;
|
|
1765
|
-
} catch (err) {
|
|
1780
|
+
} catch (err) { return Promise.reject(new BoardProcessError(err.message, 'deleteValveAsync')); }
|
|
1766
1781
|
}
|
|
1767
1782
|
public async setValveStateAsync(valve: Valve, vstate: ValveState, isDiverted: boolean) {
|
|
1768
1783
|
try {
|
|
@@ -31,6 +31,8 @@ export class SunTouchBoard extends EasyTouchBoard {
|
|
|
31
31
|
constructor(system: PoolSystem) {
|
|
32
32
|
super(system); // graph chain to EasyTouchBoard constructor.
|
|
33
33
|
this.valueMaps.expansionBoards = new byteValueMap([
|
|
34
|
+
// 33 added per #1108;
|
|
35
|
+
[33, { name: 'stshared', part: '520820', desc: 'Pool and Spa controller', bodies: 2, valves: 4, circuits: 5, single: false, shared: true, dual: false, features: 4, chlorinators: 1, chemControllers: 1 }],
|
|
34
36
|
[41, { name: 'stshared', part: '520820', desc: 'Pool and Spa controller', bodies: 2, valves: 4, circuits: 5, single: false, shared: true, dual: false, features: 4, chlorinators: 1, chemControllers: 1 }],
|
|
35
37
|
[40, { name: 'stsingle', part: '520819', desc: 'Pool or Spa controller', bodies: 2, valves: 4, circuits: 5, single: true, shared: true, dual: false, features: 4, chlorinators: 1, chemControllers: 1 }]
|
|
36
38
|
]);
|
|
@@ -582,7 +582,7 @@ export class byteValueMaps {
|
|
|
582
582
|
public valveModes: byteValueMap = new byteValueMap([
|
|
583
583
|
[0, { name: 'off', desc: 'Off' }],
|
|
584
584
|
[1, { name: 'pool', desc: 'Pool' }],
|
|
585
|
-
[2, { name: 'spa',
|
|
585
|
+
[2, { name: 'spa', desc: 'Spa' }],
|
|
586
586
|
[3, { name: 'spillway', desc: 'Spillway' }],
|
|
587
587
|
[4, { name: 'spadrain', desc: 'Spa Drain' }]
|
|
588
588
|
]);
|
|
@@ -139,7 +139,7 @@ export class Connection {
|
|
|
139
139
|
else {
|
|
140
140
|
if (!await existing.closeAsync()) {
|
|
141
141
|
existing.closing = false; // if closing fails, reset flag so user can try again
|
|
142
|
-
return Promise.reject(new InvalidOperationError(`Unable to close the current RS485 port
|
|
142
|
+
return Promise.reject(new InvalidOperationError(`Unable to close the current RS485 port (Try to save the port again as it usually works the second time).`, 'setPortAsync'));
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
config.setSection(section, pdata);
|
|
@@ -526,6 +526,8 @@ export class RS485Port {
|
|
|
526
526
|
private procTimer: NodeJS.Timeout;
|
|
527
527
|
public writeTimer: NodeJS.Timeout
|
|
528
528
|
private _processing: boolean = false;
|
|
529
|
+
private _lastTx: number = 0;
|
|
530
|
+
private _lastRx: number = 0;
|
|
529
531
|
private _inBytes: number[] = [];
|
|
530
532
|
private _inBuffer: number[] = [];
|
|
531
533
|
private _outBuffer: Outbound[] = [];
|
|
@@ -897,7 +899,9 @@ export class RS485Port {
|
|
|
897
899
|
}
|
|
898
900
|
// make public for now; should enable writing directly to mock port at Conn level...
|
|
899
901
|
public pushIn(pkt: Buffer) {
|
|
900
|
-
this._inBuffer.push.apply(this._inBuffer, pkt.toJSON().data);
|
|
902
|
+
this._inBuffer.push.apply(this._inBuffer, pkt.toJSON().data);
|
|
903
|
+
this._lastRx = Date.now();
|
|
904
|
+
if (sys.isReady) setImmediate(() => { this.processPackets(); });
|
|
901
905
|
}
|
|
902
906
|
private pushOut(msg) {
|
|
903
907
|
this._outBuffer.push(msg); setImmediate(() => { this.processPackets(); });
|
|
@@ -988,9 +992,11 @@ export class RS485Port {
|
|
|
988
992
|
// but this condition would be eval'd before the callback of port.write was calls and the outbound packet
|
|
989
993
|
// would be sitting idle for eternity.
|
|
990
994
|
if (this._outBuffer.length > 0 || typeof this._waitingPacket !== 'undefined' || this._waitingPacket || typeof msg !== 'undefined') {
|
|
991
|
-
//
|
|
995
|
+
// Configurable inter-frame delay (default 30ms) overrides fixed 100ms.
|
|
996
|
+
const dCfg = (config.getSection('controller').txDelays || {});
|
|
997
|
+
const interFrame = Math.max(0, Number(dCfg.interFrameDelayMs || 30));
|
|
992
998
|
let self = this;
|
|
993
|
-
this.procTimer = setTimeout(() => self.processPackets(),
|
|
999
|
+
this.procTimer = setTimeout(() => self.processPackets(), interFrame);
|
|
994
1000
|
}
|
|
995
1001
|
}
|
|
996
1002
|
private writeMessage(msg: Outbound) {
|
|
@@ -1022,10 +1028,44 @@ export class RS485Port {
|
|
|
1022
1028
|
this.isRTS = true;
|
|
1023
1029
|
return;
|
|
1024
1030
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1031
|
+
const dCfg = (config.getSection('controller').txDelays || {});
|
|
1032
|
+
const idleBeforeTx = Math.max(0, Number(dCfg.idleBeforeTxMs || 0));
|
|
1033
|
+
const interByte = Math.max(0, Number(dCfg.interByteDelayMs || 0));
|
|
1034
|
+
const now = Date.now();
|
|
1035
|
+
const idleElapsed = now - Math.max(this._lastTx, this._lastRx);
|
|
1036
|
+
const doWrite = () => {
|
|
1037
|
+
this.counter.bytesSent += bytes.length;
|
|
1038
|
+
msg.timestamp = new Date();
|
|
1039
|
+
logger.packet(msg);
|
|
1040
|
+
if (interByte > 0 && bytes.length > 1 && this._port && (this._port instanceof SerialPort || this._port instanceof SerialPortMock)) {
|
|
1041
|
+
// Manual inter-byte pacing
|
|
1042
|
+
let idx = 0;
|
|
1043
|
+
const writeNext = () => {
|
|
1044
|
+
if (idx >= bytes.length) {
|
|
1045
|
+
this._lastTx = Date.now();
|
|
1046
|
+
completeWrite(undefined);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const b = Buffer.from([bytes[idx++]]);
|
|
1050
|
+
(this._port as any).write(b, (err) => {
|
|
1051
|
+
if (err) {
|
|
1052
|
+
this._lastTx = Date.now();
|
|
1053
|
+
completeWrite(err);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
if (interByte > 0) setTimeout(writeNext, interByte);
|
|
1057
|
+
else setImmediate(writeNext);
|
|
1058
|
+
});
|
|
1059
|
+
};
|
|
1060
|
+
writeNext();
|
|
1061
|
+
} else {
|
|
1062
|
+
this.write(msg, (err) => {
|
|
1063
|
+
this._lastTx = Date.now();
|
|
1064
|
+
completeWrite(err);
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
const completeWrite = (err?: Error) => {
|
|
1029
1069
|
clearTimeout(this.writeTimer);
|
|
1030
1070
|
this.writeTimer = null;
|
|
1031
1071
|
msg.tries++;
|
|
@@ -1042,7 +1082,6 @@ export class RS485Port {
|
|
|
1042
1082
|
self._waitingPacket = null;
|
|
1043
1083
|
self.counter.sndAborted++;
|
|
1044
1084
|
}
|
|
1045
|
-
return;
|
|
1046
1085
|
}
|
|
1047
1086
|
else {
|
|
1048
1087
|
logger.verbose(`Wrote packet [Port ${this.portId} id: ${msg.id}] [${bytes}].Retries remaining: ${msg.remainingTries} `);
|
|
@@ -1053,15 +1092,17 @@ export class RS485Port {
|
|
|
1053
1092
|
self._waitingPacket = null;
|
|
1054
1093
|
self.counter.sndSuccess++;
|
|
1055
1094
|
if (typeof msg.onComplete === 'function') msg.onComplete(err, undefined);
|
|
1056
|
-
|
|
1057
|
-
}
|
|
1058
|
-
else if (msg.remainingTries >= 0) {
|
|
1059
|
-
self._waitingPacket = msg;
|
|
1060
1095
|
}
|
|
1096
|
+
else if (msg.remainingTries >= 0) self._waitingPacket = msg;
|
|
1061
1097
|
}
|
|
1062
1098
|
self.counter.updatefailureRate();
|
|
1063
1099
|
self.emitPortStats();
|
|
1064
|
-
}
|
|
1100
|
+
};
|
|
1101
|
+
// Honor idle-before-TX if not enough bus quiet time has elapsed
|
|
1102
|
+
if (idleBeforeTx > 0 && idleElapsed < idleBeforeTx) {
|
|
1103
|
+
const wait = idleBeforeTx - idleElapsed;
|
|
1104
|
+
setTimeout(doWrite, wait);
|
|
1105
|
+
} else doWrite();
|
|
1065
1106
|
}
|
|
1066
1107
|
}
|
|
1067
1108
|
catch (err) {
|