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.
@@ -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
- let env = process.env;
44
- // check local git version
57
+ const env = process.env;
58
+ // Branch
45
59
  try {
46
60
  let out: string;
47
- if (typeof env.SOURCE_BRANCH !== 'undefined')
48
- {
49
- out = env.SOURCE_BRANCH // check for docker variable
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
- case 'fatal':
58
- case 'command':
59
- state.appVersion.gitLocalBranch = '--';
60
- break;
61
- default:
62
- state.appVersion.gitLocalBranch = out;
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
- logger.warn(`Unable to retrieve local git branch. ${err}`);
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
- out = env.SOURCE_COMMIT; // check for docker variable
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
- logger.warn(`Unable to retrieve local git commit. ${err}`);
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) await this.getLatestRelease(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
- let jdata = JSON.parse(data);
134
- if (typeof jdata.tag_name !== 'undefined')
135
- resolve(jdata.tag_name.replace('v', ''));
136
- else
137
- reject(`No data returned.`)
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();
@@ -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 || this.data.id + 95; }
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); }
@@ -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
- self.heliotrope.longitude = sys.general.location.longitude;
397
- self.heliotrope.latitude = sys.general.location.latitude;
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 || this.data.id + 95; }
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 bhs.processing = false;
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 EquipmentNotFoundError(`Invalid group id: ${obj.id}`, 'CircuitGroup'));
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 EquipmentNotFoundError(`Invalid group id: ${obj.id}`, 'LightGroup'));
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) { logger.error(`Nixie: Error removing valve from system ${obj.id}: ${err.message}`); return Promise.reject(new Error(`Nixie: Error removing valve from system ${ obj.id }: ${ err.message }`)); }
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', dest: '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`, 'setPortAsync'));
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); if (sys.isReady) setImmediate(() => { this.processPackets(); });
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
- // Come back later as we still have items to send.
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(), 100);
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
- this.counter.bytesSent += bytes.length;
1026
- msg.timestamp = new Date();
1027
- logger.packet(msg);
1028
- this.write(msg, (err) => {
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) {