/* nodejs-poolController. An application to control pool equipment.
Copyright (C) 2016, 2017, 2018, 2019, 2020, 2021, 2022.
Russell Goldin, tagyoureit. russ.goldin@gmail.com
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
*/
import * as extend from 'extend';
import { EventEmitter } from 'events';
import { EasyTouchBoard, TouchConfigQueue, GetTouchConfigCategories, TouchCircuitCommands } from './EasyTouchBoard';
import { sys, PoolSystem, Circuit, ICircuit } from '../Equipment';
import { byteValueMap, EquipmentIdRange } from './SystemBoard';
import { state, ICircuitState } from '../State';
import { logger } from '../../logger/Logger';
import { conn } from '../comms/Comms';
import { Outbound } from "../comms/messages/Messages";
import { InvalidEquipmentIdError } from "../Errors";
import { utils } from "../Constants";
export class SunTouchBoard extends EasyTouchBoard {
constructor(system: PoolSystem) {
super(system); // graph chain to EasyTouchBoard constructor.
this.valueMaps.expansionBoards = new byteValueMap([
// 33 added per #1108;
[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 }],
[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 }],
[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 }]
]);
this._statusInterval = -1;
this.equipmentIds.circuits = new EquipmentIdRange(1, 6);
this.equipmentIds.features = new EquipmentIdRange(7, 10);
this.equipmentIds.virtualCircuits = new EquipmentIdRange(128, 136);
this.equipmentIds.circuitGroups = new EquipmentIdRange(192, function () { return this.start + sys.equipment.maxCircuitGroups - 1; });
this.equipmentIds.circuits.start = 1;
this.equipmentIds.circuits.isInRange = (id: number) => { return [1, 2, 3, 4, 6].includes(id); };
this.equipmentIds.features.isInRange = (id: number) => { return [7, 8, 9, 10].includes(id); };
if (typeof sys.configVersion.equipment === 'undefined') { sys.configVersion.equipment = 0; }
this.valueMaps.heatSources = new byteValueMap([
[0, { name: 'off', desc: 'Off' }],
[32, { name: 'nochange', desc: 'No Change' }]
]);
this.valueMaps.heatStatus = new byteValueMap([
[0, { name: 'off', desc: 'Off' }],
[1, { name: 'heater', desc: 'Heater' }],
[2, { name: 'cooling', desc: 'Cooling' }],
[3, { name: 'solar', desc: 'Solar' }],
[4, { name: 'hpheat', desc: 'Heatpump' }],
[5, { name: 'dual', desc: 'Dual' }]
]);
this.valueMaps.circuitFunctions = new byteValueMap([
[0, { name: 'generic', desc: 'Generic' }],
[1, { name: 'spa', desc: 'Spa', hasHeatSource: true, body: 2 }],
[2, { name: 'pool', desc: 'Pool', hasHeatSource: true, body: 1 }],
[5, { name: 'mastercleaner', desc: 'Master Cleaner', body: 1 }],
[7, { name: 'light', desc: 'Light', isLight: true }],
[9, { name: 'samlight', desc: 'SAM Light', isLight: true }],
[10, { name: 'sallight', desc: 'SAL Light', isLight: true }],
[11, { name: 'photongen', desc: 'Photon Gen', isLight: true }],
[12, { name: 'colorwheel', desc: 'Color Wheel', isLight: true }],
[13, { name: 'valve', desc: 'Valve' }],
[14, { name: 'spillway', desc: 'Spillway' }],
[15, { name: 'floorcleaner', desc: 'Floor Cleaner', body: 1 }], // This circuit function does not seem to exist in IntelliTouch.
[19, { name: 'notused', desc: 'Not Used' }],
[63, { name: 'cleaner', desc: 'Cleaner' }],
]);
this.valueMaps.virtualCircuits = new byteValueMap([
[20, { name: 'solar', desc: 'Solar', assignableToPumpCircuit: true }],
[129, { name: 'poolspa', desc: 'Pool/Spa' }],
[130, { name: 'poolHeater', desc: 'Pool Heater', assignableToPumpCircuit: true }],
[131, { name: 'spaHeater', desc: 'Spa Heater', assignableToPumpCircuit: true }],
[132, { name: 'freeze', desc: 'Freeze', assignableToPumpCircuit: true }],
[258, { name: 'anyHeater', desc: 'Any Heater' }]
]);
this.valueMaps.circuitNames = new byteValueMap([
[3, { name: 'aux1', desc: 'AUX 1' }],
[4, { name: 'aux2', desc: 'AUX 2' }],
[5, { name: 'aux3', desc: 'AUX 3' }],
[6, { name: 'feature1', desc: 'FEATURE 1' }],
[7, { name: 'feature2', desc: 'FEATURE 2' }],
[8, { name: 'feature3', desc: 'FEATURE 3' }],
[9, { name: 'feature4', desc: 'FEATURE 4' }],
[61, { name: 'pool', desc: 'Pool' }],
[72, { name: 'spa', desc: 'Spa' }]
]);
this._configQueue = new SunTouchConfigQueue();
}
public initExpansionModules(byte1: number, byte2: number) {
console.log(`Pentair SunTouch System Detected!`);
sys.equipment.model = 'Suntouch';
// Initialize the installed personality board.
let mt = this.valueMaps.expansionBoards.transform(byte1); // Only have one example of SunTouch and it is a single body system (40).
let mod = sys.equipment.modules.getItemById(0, true);
if (mod.name !== mt.name) {
logger.info(`Clearing SunTouch configuration...`);
sys.bodies.removeItemById(1);
sys.bodies.removeItemById(2);
sys.bodies.removeItemById(3);
sys.bodies.removeItemById(4);
sys.circuits.clear(0);
sys.circuits.removeItemById(1);
sys.circuits.removeItemById(6);
sys.features.clear(0);
state.circuits.clear();
state.temps.clear();
sys.filters.clear(0);
state.filters.clear();
}
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = byte1;
mod.part = mt.part;
let eq = sys.equipment;
let md = mod.get();
eq.maxBodies = md.bodies = typeof mt.bodies !== 'undefined' ? mt.bodies : mt.shared ? 2 : 1;
eq.maxCircuits = md.circuits = typeof mt.circuits !== 'undefined' ? mt.circuits : 3;
eq.maxFeatures = md.features = typeof mt.features !== 'undefined' ? mt.features : 0
eq.maxValves = md.valves = typeof mt.valves !== 'undefined' ? mt.valves : 2;
eq.maxPumps = md.maxPumps = typeof mt.pumps !== 'undefined' ? mt.pumps : 2;
eq.shared = mt.shared || false;
eq.dual = mt.dual || false;
eq.single = mt.single || false;
eq.maxChlorinators = md.chlorinators = 1;
eq.maxChemControllers = md.chemControllers = 1;
eq.maxCustomNames = 0;
eq.maxSchedules = 6;
if (sys.equipment.single) {
sys.board.valueMaps.circuitNames.merge([[61, { name: 'pool', desc: 'LO-Temp' }], [72, { name: 'spa', desc: 'HI-Temp' }]]);
sys.board.valueMaps.circuitFunctions.merge([[1, { name: 'pool', desc: 'LO-Temp', hasHeatSource: true }], [2, { name: 'spa', desc: 'HI-Temp', hasHeatSource: true }]]);
sys.board.valueMaps.virtualCircuits.merge([[130, { name: 'poolHeater', desc: 'LO-Temp Heater' }], [131, { name: 'spaHeater', desc: 'HI-Temp Heater' }]]);
sys.board.valueMaps.bodyTypes.merge([[0, { name: 'pool', desc: 'LO-Temp' }], [1, { name: 'spa', desc: 'HI-Temp' }]]);
}
else {
sys.board.valueMaps.circuitNames.merge([[61, { name: 'pool', desc: 'Pool' }], [72, { name: 'spa', desc: 'Spa' }]]);
sys.board.valueMaps.circuitFunctions.merge([[1, { name: 'pool', desc: 'Pool', hasHeatsource: true }], [2, { name: 'spa', desc: 'Pool', hasHeatSource: true }]]);
sys.board.valueMaps.virtualCircuits.merge([[130, { name: 'poolHeater', desc: 'Pool Heater' }], [131, { name: 'spaHeater', desc: 'Spa Heater' }]]);
sys.board.valueMaps.bodyTypes.merge([[0, { name: 'pool', desc: 'Pool' }], [1, { name: 'spa', desc: 'Spa' }]]);
}
// Calculate out the invalid ids.
sys.board.equipmentIds.invalidIds.set([]);
// SunTouch bit mapping for circuits and features
// Bit Mask Circuit/Feature id
// 1 = 0x01 Spa 1
// 2 = 0x02 Aux 1 2
// 3 = 0x04 Aux 2 3
// 4 = 0x08 Aux 3 4
// 5 = 0x10 Feature 1 7
// 6 = 0x20 Pool 6
// 7 = 0x40 Feature 2 8
// 8 = 0x80 Feature 3 9
// 9 = 0x01 Feature 4 10
sys.board.equipmentIds.invalidIds.merge([5]);
state.equipment.model = sys.equipment.model = 'SunTouch';
sys.equipment.setEquipmentIds();
this.initBodyDefaults();
state.emitControllerChange();
}
public initBodyDefaults() {
// Initialize the bodies. We will need these very soon.
for (let i = 1; i <= sys.equipment.maxBodies; i++) {
// Add in the bodies for the configuration. These need to be set.
let cbody = sys.bodies.getItemById(i, true);
let tbody = state.temps.bodies.getItemById(i, true);
cbody.isActive = true;
tbody.circuit = cbody.circuit = i === 1 ? 1 : 6;
tbody.type = cbody.type = i - 1; // This will set the first body to pool/Lo-Temp and the second body to spa/Hi-Temp.
if (typeof cbody.name === 'undefined') {
if (sys.equipment.single) {
tbody.name = cbody.name = i === 1 ? 'LO' : 'HI';
}
else {
let bt = sys.board.valueMaps.bodyTypes.transform(cbody.type);
tbody.name = cbody.name = bt.desc;
}
}
let c = sys.circuits.getItemById(tbody.circuit, true, { isActive: false });
c.master = 0;
let cstate = state.circuits.getItemById(c.id, true);
cstate.type = c.type = tbody.circuit === 6 ? sys.board.valueMaps.circuitFunctions.encode('pool') : sys.board.valueMaps.circuitFunctions.encode('spa');
let name = sys.board.valueMaps.circuitNames.transform(c.id === 6 ? 61 : 72);
cstate.nameId = c.nameId = name.val;
// Check to see if the body circuit exists. We are going to create these so that they start
// out with the proper type.
if (!c.isActive) {
cstate.showInFeatures = c.showInFeatures = false;
c.isActive = cstate.isActive = true;
console.log(name);
cstate.name = c.name = name.desc;
}
}
sys.bodies.removeItemById(3);
sys.bodies.removeItemById(4);
state.temps.bodies.removeItemById(3);
state.temps.bodies.removeItemById(4);
sys.board.heaters.initTempSensors();
sys.general.options.clockMode = sys.general.options.clockMode || 12;
sys.general.options.clockSource = sys.general.options.clockSource || 'manual';
// We are going to intialize the pool circuits
let filter = sys.filters.getItemById(1, true);
if (typeof filter.name === 'undefined') filter.name = 'Filter';
state.filters.getItemById(1, true).name = filter.name;
}
public circuits: SunTouchCircuitCommands = new SunTouchCircuitCommands(this);
}
class SunTouchConfigQueue extends TouchConfigQueue {
public queueChanges() {
this.reset();
logger.info(`Requesting ${sys.controllerType} configuration`);
// Config categories that do nothing
// 195 - [0-2]
// 196 - [0-2]
// 198 - [0-2]
// 199 - [0-2]
// 200 - Heat/Temperature Status
// 201 - [0-2]
// 202 - [0-2] - Custom Names
// 203 - Circuit Functions
// 204 - [0-2]
// 205 - [0-2]
// 206 - [0-2]
// 207 - [0-2]
// 208 - [0-2]
// 209 - [0-10] - This returns invalid data about schedules. It is simply not correct
// 212 - [0-2]
// 213 - [0-2]
// 214 - [0]
// 215 - [0-2]
// 216 - [0-4] - This does not return anything about the pumps
// 218 - [0-2]
// 219 - [0-2]
// 220 - [0-2]
// 221 - Valve Assignments
// 223 - [0-2]
// 224 - [1-2]
// 225 - Spa side remote
// 226 - [0] - Solar/HeatPump config
// 228 - [0-2]
// 229 - [0-2]
// 230 - [0-2]
// 231 - [0-2]
// 232 - Settings (Amazed that there is none of this)
// 233 - [0-2]
// 234 - [0-2]
// 235 - [0-2]
// 236 - [0-2]
// 237 - [0-2]
// 238 - [0-2]
// 239 - [0-2]
// 240 - [0-2]
// 241 - [0-2]
// 242 - [0-2]
// 243 - [0-2]
// 244 - [0-2]
// 245 - [0-2]
// 246 - [0-2]
// 247 - [0-2]
// 248 - [0-2]
// 249 - [0-2]
// 250 - [0-2]
// 251 - [0-2]
// 253 - Software Version
this.queueItems(GetTouchConfigCategories.version); // 252
this.queueItems(GetTouchConfigCategories.dateTime, [0]); //197
this.queueItems(GetTouchConfigCategories.heatTemperature, [0]); // 200
//this.queueRange(GetTouchConfigCategories.customNames, 0, sys.equipment.maxCustomNames - 1); 202 SunTouch does not appear to support custom names. No responses
this.queueItems(GetTouchConfigCategories.solarHeatPump, [0]); // 208
this.queueRange(GetTouchConfigCategories.circuits, 1, sys.board.equipmentIds.features.end); // 203 circuits & Features
//this.queueRange(GetTouchConfigCategories.schedules, 1, sys.equipment.maxSchedules); // 209 This return is worthless in SunTouch
this.queueItems(GetTouchConfigCategories.delays, [0]); // 227
this.queueItems(GetTouchConfigCategories.settings, [0]); // 232
this.queueItems(GetTouchConfigCategories.intellifloSpaSideRemotes, [0]); // 225 QuickTouch
this.queueItems(GetTouchConfigCategories.valves, [0]); // 221
// Check for these positions to see if we can get it to spit out all the schedules.
this.queueItems(222, [0]); // First 2 schedules. This request ignores the payload and does not return additional items.
this.queueItems(211, [0]);
this.queueItems(19, [0]); // If we send this request it will respond with a valid 147. The correct request however should be 211.
//this.queueRange(GetTouchConfigCategories.circuitGroups, 0, sys.equipment.maxFeatures - 1); SunTouch does not support macros
this.queueItems(GetTouchConfigCategories.intellichlor, [0]); // 217
//let test = [195, 196, 208, 214, 218, 219, 220, 226, 228, 229, 230, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251];
//for (let i = 0; i < test.length; i++) {
// let cat = test[i];
// this.queueRange(cat, 0, 2);
//}
if (this.remainingItems > 0) {
var self = this;
setTimeout(() => { self.processNext(); }, 50);
} else state.status = 1;
state.emitControllerChange();
}
}
class SunTouchCircuitCommands extends TouchCircuitCommands {
public async setCircuitStateAsync(id: number, val: boolean, ignoreDelays?: boolean): Promise {
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('Circuit or Feature id not valid', id, 'Circuit'));
let c = sys.circuits.getInterfaceById(id);
if (c.master !== 0) return await super.setCircuitStateAsync(id, val);
if (id === 192 || c.type === 3) return await sys.board.circuits.setLightGroupThemeAsync(id - 191, val ? 1 : 0);
if (id >= 192) return await sys.board.circuits.setCircuitGroupStateAsync(id, val);
// for some dumb reason, if the spa is on and the pool circuit is desired to be on,
// it will ignore the packet.
// We can override that by emulating a click to turn off the spa instead of turning
// on the pool
if (sys.equipment.maxBodies > 1 && id === 6 && val && state.circuits.getItemById(1).isOn) {
id = 1;
val = false;
}
let mappedId = id;
if (id === 7) mappedId = 5;
else if (id > 6) mappedId = id - 1;
let cstate = state.circuits.getInterfaceById(id);
let out = Outbound.create({
action: 134,
payload: [mappedId, val ? 1 : 0],
retries: 3,
response: true,
scope: `circuitState${id}`
});
await out.sendAsync();
sys.board.circuits.setEndTime(c, cstate, val);
cstate.isOn = val;
state.emitEquipmentChanges();
return cstate;
}
public async setCircuitAsync(data: any): Promise {
try {
// example [255,0,255][165,33,16,34,139,5][17,14,209,0,0][2,120]
// set circuit 17 to function 14 and name 209
// response: [255,0,255][165,33,34,16,1,1][139][1,133]
let id = parseInt(data.id, 10);
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('Circuit Id is invalid', data.id, 'Feature'));
if (id >= 255 || data.master === 1) return super.setCircuitAsync(data);
let circuit = sys.circuits.getInterfaceById(id);
// Alright check to see if we are adding a nixie circuit.
if (id === -1 || circuit.master !== 0) {
let circ = await super.setCircuitAsync(data);
return circ;
}
let typeByte = parseInt(data.type, 10) || circuit.type || sys.board.valueMaps.circuitFunctions.getValue('generic');
this.assertSinglePoolSpaType(id, typeByte);
let nameByte = circuit.nameId; // You cannot change the Name Id in SunTouch.
if (typeof data.nameId !== 'undefined') nameByte = data.nameId;
let mappedId = id;
if (id === 7) mappedId = 5;
else if (id > 6) mappedId = id - 1;
let out = Outbound.create({
action: 139,
payload: [mappedId, typeByte | (utils.makeBool(data.freeze) ? 64 : 0), nameByte, 0, 0],
retries: 3,
response: true
});
await out.sendAsync();
circuit = sys.circuits.getInterfaceById(data.id);
let cstate = state.circuits.getInterfaceById(data.id);
circuit.nameId = cstate.nameId = nameByte;
circuit.name = typeof data.name !== 'undefined' ? data.name.toString() : circuit.name;
circuit.showInFeatures = cstate.showInFeatures = typeof data.showInFeatures !== 'undefined' ? data.showInFeatures : circuit.showInFeatures;
circuit.freeze = typeof data.freeze !== 'undefined' ? utils.makeBool(data.freeze) : circuit.freeze;
circuit.type = cstate.type = typeByte;
circuit.eggTimer = typeof data.eggTimer !== 'undefined' ? parseInt(data.eggTimer, 10) : circuit.eggTimer || 720;
circuit.dontStop = (typeof data.dontStop !== 'undefined') ? utils.makeBool(data.dontStop) : circuit.eggTimer === 1620;
cstate.isActive = circuit.isActive = true;
circuit.master = 0;
let eggTimer = sys.eggTimers.find(elem => elem.circuit === parseInt(data.id, 10));
try {
if (circuit.eggTimer === 720) {
if (typeof eggTimer !== 'undefined') await sys.board.schedules.deleteEggTimerAsync({ id: eggTimer.id });
}
else {
await sys.board.schedules.setEggTimerAsync({ id: typeof eggTimer !== 'undefined' ? eggTimer.id : -1, runTime: circuit.eggTimer, dontStop: circuit.dontStop, circuit: circuit.id });
}
}
catch (err) {
// fail silently if there are no slots to fill in the schedules
logger.info(`Cannot set/delete eggtimer on circuit ${circuit.id}. Error: ${err.message}`);
circuit.eggTimer = 720;
circuit.dontStop = false;
}
state.emitEquipmentChanges();
return circuit;
}
catch (err) { logger.error(`setCircuitAsync error setting circuit ${JSON.stringify(data)}: ${err}`); return Promise.reject(err); }
}
}