/* 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 { logger } from '../../logger/Logger'; import { Message, Outbound, Protocol, Response } from '../comms/messages/Messages'; import { BodyCommands, byteValueMap, ChemControllerCommands, ChlorinatorCommands, CircuitCommands, ConfigQueue, ConfigRequest, EquipmentIdRange, FeatureCommands, HeaterCommands, PumpCommands, ScheduleCommands, SystemBoard, SystemCommands } from './SystemBoard'; import { BodyTempState, ChlorinatorState, ICircuitGroupState, ICircuitState, LightGroupState, state } from '../State'; import { Body, ChemController, ConfigVersion, EggTimer, Feature, Heater, ICircuit, LightGroup, LightGroupCircuit, PoolSystem, Pump, Schedule, sys } from '../Equipment'; import { EquipmentTimeoutError, InvalidEquipmentDataError, InvalidEquipmentIdError, InvalidOperationError } from '../Errors'; import { conn } from '../comms/Comms'; import { ncp } from "../nixie/Nixie"; import { utils } from '../Constants'; const IAQUALINK_SENDCMD_DEST = 0x00; const IAQUALINK_SENDCMD_SOURCE = 0x24; const IAQUALINK_SENDCMD_ACTION = 0x73; const IAQUALINK_SENDCMD_PREFIX = 0x01; const IAQUALINK_SENDCMD_PAYLOAD_LEN = 12; const IAQUALINK_OPCODE_THEME = 0x61; const IAQUALINK_OPCODE_RGB = 0x63; const IAQUALINK_OPCODE_SYNC = 0x65; const IAQUALINK_WATERCOLORS_CIRCUIT_TYPE = 18; export class AquaLinkBoard extends SystemBoard { constructor(system: PoolSystem) { super(system); this.equipmentIds.features.start = 41; this.equipmentIds.features.end = 50; this.valueMaps.expansionBoards = new byteValueMap([ [0, { name: 'IT5', part: 'i5+3', desc: 'IntelliTouch i5+3', circuits: 6, shared: true }], [1, { name: 'IT7', part: 'i7+3', desc: 'IntelliTouch i7+3', circuits: 8, shared: true }], [2, { name: 'IT9', part: 'i9+3', desc: 'IntelliTouch i9+3', circuits: 10, shared: true }], [3, { name: 'IT5S', part: 'i5+3S', desc: 'IntelliTouch i5+3S', circuits: 5, shared: false, bodies: 1, intakeReturnValves: false }], [4, { name: 'IT9S', part: 'i9+3S', desc: 'IntelliTouch i9+3S', circuits: 9, shared: false, bodies: 1, intakeReturnValves: false }], [5, { name: 'IT10D', part: 'i10D', desc: 'IntelliTouch i10D', circuits: 10, shared: false, dual: true }], [32, { name: 'IT5X', part: 'i5X', desc: 'IntelliTouch i5X', circuits: 5 }], [33, { name: 'IT10X', part: 'i10X', desc: 'IntelliTouch i10X', circuits: 10 }] ]); this.valueMaps.circuitFunctions.merge([ [IAQUALINK_WATERCOLORS_CIRCUIT_TYPE, { name: 'watercolors', desc: 'Infinite WaterColors', isLight: true, theme: 'watercolors', supportsBrightness: true, supportsCustomColor: true }] ]); this.valueMaps.lightThemes.merge([ [56, { name: 'alpinewhite', desc: 'Alpine White', types: ['watercolors'], sequence: 1 }], [57, { name: 'skyblue', desc: 'Sky Blue', types: ['watercolors'], sequence: 2 }], [58, { name: 'cobaltblue', desc: 'Cobalt Blue', types: ['watercolors'], sequence: 3 }], [59, { name: 'caribbeanblue', desc: 'Caribbean Blue', types: ['watercolors'], sequence: 4 }], [60, { name: 'springgreen', desc: 'Spring Green', types: ['watercolors'], sequence: 5 }], [61, { name: 'emeraldgreen', desc: 'Emerald Green', types: ['watercolors'], sequence: 6 }], [62, { name: 'emeraldrose', desc: 'Emerald Rose', types: ['watercolors'], sequence: 7 }], [63, { name: 'magenta', desc: 'Magenta', types: ['watercolors'], sequence: 8 }], [64, { name: 'violet', desc: 'Violet', types: ['watercolors'], sequence: 9 }], [65, { name: 'slowcolorsplash', desc: 'Slow Color Splash', types: ['watercolors'], sequence: 10 }], [66, { name: 'fastcolorsplash', desc: 'Fast Color Splash', types: ['watercolors'], sequence: 11 }], [67, { name: 'americathebeautiful', desc: 'America Beautiful', types: ['watercolors'], sequence: 12 }], [68, { name: 'fattuesday', desc: 'Fat Tuesday', types: ['watercolors'], sequence: 13 }], [69, { name: 'discotech', desc: 'Disco Tech', types: ['watercolors'], sequence: 14 }] ]); } public initExpansionModules(byte1: number, byte2: number) { console.log(`Jandy AquaLink System Detected!`); state.emitControllerChange(); } public bodies: AquaLinkBodyCommands = new AquaLinkBodyCommands(this); public system: AquaLinkSystemCommands = new AquaLinkSystemCommands(this); public circuits: AquaLinkCircuitCommands = new AquaLinkCircuitCommands(this); public features: AquaLinkFeatureCommands = new AquaLinkFeatureCommands(this); public chlorinator: AquaLinkChlorinatorCommands = new AquaLinkChlorinatorCommands(this); public pumps: AquaLinkPumpCommands = new AquaLinkPumpCommands(this); public schedules: AquaLinkScheduleCommands = new AquaLinkScheduleCommands(this); public heaters: AquaLinkHeaterCommands = new AquaLinkHeaterCommands(this); protected _configQueue: AquaLinkConfigQueue = new AquaLinkConfigQueue(); } class AquaLinkConfigQueue extends ConfigQueue { //protected _configQueueTimer: NodeJS.Timeout; //public clearTimer(): void { clearTimeout(this._configQueueTimer); } protected queueRange(cat: number, start: number, end: number) {} protected queueItems(cat: number, items: number[] = [0]) { } public queueChanges() { this.reset(); logger.info(`Requesting ${sys.controllerType} configuration`); if (this.remainingItems > 0) { var self = this; setTimeout(() => { self.processNext(); }, 50); } else { state.status = 1; } state.emitControllerChange(); } // TODO: RKS -- Investigate why this is needed. Me thinks that there really is no difference once the whole thing is optimized. With a little // bit of work I'll bet we can eliminate these extension objects altogether. public processNext(msg?: Outbound) { if (this.closed) return; if (typeof msg !== "undefined" && msg !== null) if (!msg.failed) { // Remove all references to future items. We got it so we don't need it again. this.removeItem(msg.action, msg.payload[0]); if (this.curr && this.curr.isComplete) { if (!this.curr.failed) { // Call the identified callback. This may add additional items. if (typeof this.curr.oncomplete === 'function') { this.curr.oncomplete(this.curr); this.curr.oncomplete = undefined; } } } } else this.curr.failed = true; if (!this.curr && this.queue.length > 0) this.curr = this.queue.shift(); if (!this.curr) { // There never was anything for us to do. We will likely never get here. state.status = 1; state.emitControllerChange(); return; } else { state.status = sys.board.valueMaps.controllerStatus.transform(2, this.percent); } // Shift to the next config queue item. logger.verbose(`Config Queue Completed... ${this.percent}% (${this.remainingItems} remaining)`); while ( this.queue.length > 0 && this.curr.isComplete) { this.curr = this.queue.shift() || null; } let itm = 0; const self = this; if (this.curr && !this.curr.isComplete) { itm = this.curr.items.shift(); } else { // Now that we are done check the configuration a final time. If we have anything outstanding // it will get picked up. state.status = 1; this.curr = null; sys.configVersion.lastUpdated = new Date(); // set a timer for 20 mins; if we don't get the config request it again. This most likely happens if there is no other indoor/outdoor remotes or ScreenLogic. // this._configQueueTimer = setTimeout(()=>{sys.board.checkConfiguration();}, 20 * 60 * 1000); logger.info(`AquaLink system config complete.`); state.cleanupState(); ncp.initAsync(sys); } // Notify all the clients of our processing status. state.emitControllerChange(); } } class AquaLinkScheduleCommands extends ScheduleCommands { public async setScheduleAsync(data: any): Promise { let id = typeof data.id === 'undefined' ? -1 : parseInt(data.id, 10); if (id <= 0) id = sys.schedules.getNextEquipmentId(new EquipmentIdRange(1, sys.equipment.maxSchedules)); if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid schedule id: ${data.id}`, data.id, 'Schedule')); let sched = sys.schedules.getItemById(id, id > 0); let ssched = state.schedules.getItemById(id, id > 0); let schedType = typeof data.scheduleType !== 'undefined' ? data.scheduleType : sched.scheduleType; if (typeof schedType === 'undefined') schedType = sys.board.valueMaps.scheduleTypes.getValue('repeat'); // Repeats let startTimeType = typeof data.startTimeType !== 'undefined' ? data.startTimeType : sched.startTimeType; let endTimeType = typeof data.endTimeType !== 'undefined' ? data.endTimeType : sched.endTimeType; // let startDate = typeof data.startDate !== 'undefined' ? data.startDate : sched.startDate; // if (typeof startDate.getMonth !== 'function') startDate = new Date(startDate); let heatSource = typeof data.heatSource !== 'undefined' && data.heatSource !== null ? data.heatSource : sched.heatSource || 32; let heatSetpoint = typeof data.heatSetpoint !== 'undefined' ? data.heatSetpoint : sched.heatSetpoint; let circuit = typeof data.circuit !== 'undefined' ? data.circuit : sched.circuit; let startTime = typeof data.startTime !== 'undefined' ? data.startTime : sched.startTime; let endTime = typeof data.endTime !== 'undefined' ? data.endTime : sched.endTime; let schedDays = sys.board.schedules.transformDays(typeof data.scheduleDays !== 'undefined' ? data.scheduleDays : sched.scheduleDays || 255); // default to all days let changeHeatSetpoint = typeof (data.changeHeatSetpoint !== 'undefined') ? utils.makeBool(data.changeHeatSetpoint) : sched.changeHeatSetpoint; let display = typeof data.display !== 'undefined' ? data.display : sched.display || 0; // Ensure all the defaults. // if (isNaN(startDate.getTime())) startDate = new Date(); if (typeof startTime === 'undefined') startTime = 480; // 8am if (typeof endTime === 'undefined') endTime = 1020; // 5pm if (typeof startTimeType === 'undefined') startTimeType = 0; // Manual if (typeof endTimeType === 'undefined') endTimeType = 0; // Manual if (typeof circuit === 'undefined') circuit = 6; // pool if (typeof heatSource !== 'undefined' && typeof heatSetpoint === 'undefined') heatSetpoint = state.temps.units === sys.board.valueMaps.tempUnits.getValue('C') ? 26 : 80; if (typeof changeHeatSetpoint === 'undefined') changeHeatSetpoint = false; // At this point we should have all the data. Validate it. if (!sys.board.valueMaps.scheduleTypes.valExists(schedType)) { sys.schedules.removeItemById(id); state.schedules.removeItemById(id); return Promise.reject(new InvalidEquipmentDataError(`Invalid schedule type; ${schedType}`, 'Schedule', schedType)); } if (!sys.board.valueMaps.scheduleTimeTypes.valExists(startTimeType)) { sys.schedules.removeItemById(id); state.schedules.removeItemById(id); return Promise.reject(new InvalidEquipmentDataError(`Invalid start time type; ${startTimeType}`, 'Schedule', startTimeType)); } if (!sys.board.valueMaps.scheduleTimeTypes.valExists(endTimeType)) { sys.schedules.removeItemById(id); state.schedules.removeItemById(id); return Promise.reject(new InvalidEquipmentDataError(`Invalid end time type; ${endTimeType}`, 'Schedule', endTimeType)); } if (!sys.board.valueMaps.heatSources.valExists(heatSource)) { sys.schedules.removeItemById(id); state.schedules.removeItemById(id); return Promise.reject(new InvalidEquipmentDataError(`Invalid heat source: ${heatSource}`, 'Schedule', heatSource)); } if (heatSetpoint < 0 || heatSetpoint > 104) { sys.schedules.removeItemById(id); state.schedules.removeItemById(id); return Promise.reject(new InvalidEquipmentDataError(`Invalid heat setpoint: ${heatSetpoint}`, 'Schedule', heatSetpoint)); } if (sys.board.circuits.getCircuitReferences(true, true, false, true).find(elem => elem.id === circuit) === undefined) { sys.schedules.removeItemById(id); state.schedules.removeItemById(id); return Promise.reject(new InvalidEquipmentDataError(`Invalid circuit reference: ${circuit}`, 'Schedule', circuit)); } if (typeof heatSource !== 'undefined' && !sys.circuits.getItemById(circuit).hasHeatSource) heatSource = undefined; // If we make it here we can make it anywhere. // let runOnce = (schedDays || (schedType !== 0 ? 0 : 0x80)); if (schedType === sys.board.valueMaps.scheduleTypes.getValue('runonce')) { // make sure only 1 day is selected let scheduleDays = sys.board.valueMaps.scheduleDays.transform(schedDays); let s2 = sys.board.valueMaps.scheduleDays.toArray(); if (scheduleDays.days.length > 1) { schedDays = scheduleDays.days[scheduleDays.days.length - 1].val; // get the earliest day in the week } else if (scheduleDays.days.length === 0) { for (let i = 0; i < s2.length; i++) { if (s2[i].days[0].name === 'sun') schedDays = s2[i].val; } } // update end time incase egg timer changed const eggTimer = sys.circuits.getInterfaceById(circuit).eggTimer || 720; endTime = (startTime + eggTimer) % 1440; // remove days if we go past midnight } // If we have sunrise/sunset then adjust for the values; if heliotrope isn't set just ignore if (state.heliotrope.isCalculated) { const sunrise = state.heliotrope.sunrise.getHours() * 60 + state.heliotrope.sunrise.getMinutes(); const sunset = state.heliotrope.sunset.getHours() * 60 + state.heliotrope.sunset.getMinutes(); if (startTimeType === sys.board.valueMaps.scheduleTimeTypes.getValue('sunrise')) startTime = sunrise; else if (startTimeType === sys.board.valueMaps.scheduleTimeTypes.getValue('sunset')) startTime = sunset; if (endTimeType === sys.board.valueMaps.scheduleTimeTypes.getValue('sunrise')) endTime = sunrise; else if (endTimeType === sys.board.valueMaps.scheduleTimeTypes.getValue('sunset')) endTime = sunset; } return new Promise((resolve, reject) => { resolve(sys.schedules.getItemById(id)); }); } public async deleteScheduleAsync(data: any): Promise { let id = typeof data.id === 'undefined' ? -1 : parseInt(data.id, 10); if (isNaN(id) || id < 0) return Promise.reject(new InvalidEquipmentIdError(`Invalid schedule id: ${data.id}`, data.id, 'Schedule')); let sched = sys.schedules.getItemById(id); let ssched = state.schedules.getItemById(id); return new Promise((resolve, reject) => { resolve(sched); }); } public async setEggTimerAsync(data?: any): Promise { let id = typeof data.id === 'undefined' ? -1 : parseInt(data.id, 10); if (id <= 0) id = sys.schedules.getNextEquipmentId(new EquipmentIdRange(1, sys.equipment.maxSchedules)); if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid schedule/eggTimer id: ${data.id} or all schedule/eggTimer ids filled (${sys.eggTimers.length + sys.schedules.length} used out of ${sys.equipment.maxSchedules})`, data.id, 'Schedule')); let circuit = sys.circuits.getInterfaceById(data.circuit); if (typeof circuit === 'undefined') return Promise.reject(new InvalidEquipmentIdError(`Invalid circuit id: ${data.circuit} for schedule id ${data.id}`, data.id, 'Schedule')); return new Promise((resolve, reject) => { resolve(sys.eggTimers.getItemById(id)); }); } public async deleteEggTimerAsync(data: any): Promise { return new Promise((resolve, reject) => { let id = typeof data.id === 'undefined' ? -1 : parseInt(data.id, 10); if (isNaN(id) || id < 0) reject(new InvalidEquipmentIdError(`Invalid eggTimer id: ${data.id}`, data.id, 'Schedule')); let eggTimer = sys.eggTimers.getItemById(id); resolve(eggTimer); }); } } class AquaLinkSystemCommands extends SystemCommands { public async cancelDelay() { return new Promise((resolve, reject) => { resolve(state.data.delay); }); } public async setDateTimeAsync(obj: any): Promise { let dayOfWeek = function (): number { // for IntelliTouch set date/time if (state.time.toDate().getUTCDay() === 0) return 0; else return Math.pow(2, state.time.toDate().getUTCDay() - 1); } return new Promise((resolve, reject) => { resolve({ time: state.time.format(), adjustDST: sys.general.options.adjustDST, clockSource: sys.general.options.clockSource }); }); } } class AquaLinkBodyCommands extends BodyCommands { public async setBodyAsync(obj: any): Promise { try { return new Promise((resolve, reject) => { let manualHeat = sys.general.options.manualHeat; if (typeof obj.manualHeat !== 'undefined') manualHeat = utils.makeBool(obj.manualHeat); let body = sys.bodies.getItemById(obj.id, false); let intellichemInstalled = sys.chemControllers.getItemByAddress(144, false).isActive; resolve(body); }); } catch (err) { return Promise.reject(err); } } public async setHeatModeAsync(body: Body, mode: number): Promise { return new Promise((resolve, reject) => { const body1 = sys.bodies.getItemById(1); const body2 = sys.bodies.getItemById(2); const temp1 = body1.setPoint || 100; const temp2 = body2.setPoint || 100; let cool = body1.coolSetpoint || 0; let mode1 = body1.heatMode; let mode2 = body2.heatMode; body.id === 1 ? mode1 = mode : mode2 = mode; let bstate = state.temps.bodies.getItemById(body.id); resolve(bstate); }); } public async setSetpoints(body: Body, obj: any): Promise { return new Promise((resolve, reject) => { let setPoint = typeof obj.setPoint !== 'undefined' ? parseInt(obj.setPoint, 10) : parseInt(obj.heatSetpoint, 10); let coolSetPoint = typeof obj.coolSetPoint !== 'undefined' ? parseInt(obj.coolSetPoint, 10) : 0; if (isNaN(setPoint)) return Promise.reject(new InvalidEquipmentDataError(`Invalid ${body.name} setpoint ${obj.setPoint || obj.heatSetpoint}`, 'body', obj)); const tempUnits = state.temps.units; switch (tempUnits) { case 0: // fahrenheit { if (setPoint < 40 || setPoint > 104) { logger.warn(`Setpoint of ${setPoint} is outside acceptable range.`); } if (coolSetPoint < 40 || coolSetPoint > 104) { logger.warn(`Cool Setpoint of ${setPoint} is outside acceptable range.`); return; } break; } case 1: // celsius { if (setPoint < 4 || setPoint > 40) { logger.warn( `Setpoint of ${setPoint} is outside of acceptable range.` ); return; } if (coolSetPoint < 4 || coolSetPoint > 40) { logger.warn(`Cool SetPoint of ${coolSetPoint} is outside of acceptable range.` ); return; } break; } } const body1 = sys.bodies.getItemById(1); const body2 = sys.bodies.getItemById(2); let temp1 = body1.setPoint || tempUnits === 0 ? 40 : 4; let temp2 = body2.setPoint || tempUnits === 0 ? 40 : 4; let cool = coolSetPoint || body1.setPoint + 1; body.id === 1 ? temp1 = setPoint : temp2 = setPoint; const mode1 = body1.heatMode; const mode2 = body2.heatMode; let bstate = state.temps.bodies.getItemById(body.id); resolve(bstate); }); } public async setHeatSetpointAsync(body: Body, setPoint: number): Promise { return new Promise((resolve, reject) => { const tempUnits = state.temps.units; switch (tempUnits) { case 0: // fahrenheit if (setPoint < 40 || setPoint > 104) { logger.warn(`Setpoint of ${setPoint} is outside acceptable range.`); return; } break; case 1: // celsius if (setPoint < 4 || setPoint > 40) { logger.warn( `Setpoint of ${setPoint} is outside of acceptable range.` ); return; } break; } const body1 = sys.bodies.getItemById(1); const body2 = sys.bodies.getItemById(2); let temp1 = body1.setPoint || 100; let temp2 = body2.setPoint || 100; body.id === 1 ? temp1 = setPoint : temp2 = setPoint; const mode1 = body1.heatMode || 0; const mode2 = body2.heatMode || 0; let cool = body1.coolSetpoint || (body1.setPoint + 1); let bstate = state.temps.bodies.getItemById(body.id); resolve(bstate); }); } public async setCoolSetpointAsync(body: Body, setPoint: number): Promise { return new Promise((resolve, reject) => { // [16,34,136,4],[POOL HEAT Temp,SPA HEAT Temp,Heat Mode,Cool,2,56] // 165,33,16,34,136,4,89,99,7,0,2,71 Request // 165,33,34,16,1,1,136,1,130 Controller Response const tempUnits = state.temps.units; switch (tempUnits) { case 0: // fahrenheit if (setPoint < 40 || setPoint > 104) { logger.warn(`Setpoint of ${setPoint} is outside acceptable range.`); return; } break; case 1: // celsius if (setPoint < 4 || setPoint > 40) { logger.warn( `Setpoint of ${setPoint} is outside of acceptable range.` ); return; } break; } const body1 = sys.bodies.getItemById(1); const body2 = sys.bodies.getItemById(2); let temp1 = body1.setPoint || 100; let temp2 = body2.setPoint || 100; const mode1 = body1.heatMode || 0; const mode2 = body2.heatMode || 0; const out = Outbound.create({ dest: 16, action: 136, payload: [temp1, temp2, mode2 << 2 | mode1, setPoint], retries: 3, response: true, onComplete: (err, msg) => { if (err) reject(err); let bstate = state.temps.bodies.getItemById(body.id); body.coolSetpoint = bstate.coolSetpoint = setPoint; state.temps.emitEquipmentChange(); resolve(bstate); } }); //conn.queueSendMessage(out); }); } } class AquaLinkCircuitCommands extends CircuitCommands { private isWaterColorsCircuit(circuit: ICircuit): boolean { const type = sys.board.valueMaps.circuitFunctions.transform(circuit.type); return typeof type !== 'undefined' && type.isLight === true && type.theme === 'watercolors'; } private getWaterColorsTheme(theme: number | any) { const thm = sys.board.valueMaps.lightThemes.findItem(theme); if (typeof thm === 'undefined' || !Array.isArray(thm.types) || !thm.types.includes('watercolors')) { throw new InvalidOperationError(`Theme ${theme} is not valid for Infinite WaterColors`, 'setLightThemeAsync'); } return thm; } private normalizeWaterColorsBrightness(level: number): number { const parsed = parseInt(level as any, 10); if (isNaN(parsed) || parsed < 0 || parsed > 100) { throw new InvalidEquipmentDataError(`Invalid WaterColors brightness ${level}`, 'Circuit', level); } return parsed; } private normalizeWaterColorsComponent(value: number, component: string): number { const parsed = parseInt(value as any, 10); if (isNaN(parsed) || parsed < 0 || parsed > 255) { throw new InvalidEquipmentDataError(`Invalid WaterColors ${component} component ${value}`, 'Circuit', value); } return parsed; } private async sendIAquaLinkLightCommandAsync(opcode: number, payload: number[], scope: string): Promise { const cmdPayload = payload.slice(0, IAQUALINK_SENDCMD_PAYLOAD_LEN); while (cmdPayload.length < IAQUALINK_SENDCMD_PAYLOAD_LEN) cmdPayload.push(0); await Outbound.create({ protocol: Protocol.AquaLink, dest: IAQUALINK_SENDCMD_DEST, source: IAQUALINK_SENDCMD_SOURCE, action: IAQUALINK_SENDCMD_ACTION, payload: [IAQUALINK_SENDCMD_PREFIX, opcode, ...cmdPayload], retries: 2, scope }).sendAsync(); } private async sendWaterColorsOnCommandAsync(id: number): Promise { await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_SYNC, [0x01, 0x00], `aqualink-watercolors-on-${id}`); } private async sendWaterColorsOffCommandAsync(id: number): Promise { await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_THEME, [0x00, 0xFF, 0x00], `aqualink-watercolors-off-${id}`); } private setWaterColorsState(circuit: ICircuit, cstate: any, isOn: boolean): void { sys.board.circuits.setEndTime(circuit, cstate, isOn); cstate.isOn = isOn; } private getWaterColorsLevel(circuit: ICircuit, cstate: any): number { if (typeof circuit.level !== 'undefined' && circuit.level > 0) return circuit.level; if (typeof cstate.level !== 'undefined' && cstate.level > 0) return cstate.level; return 100; } public async setCircuitAsync(data: any): Promise { try { 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 = 3; // set default `Aux 1` if (typeof data.nameId !== 'undefined') nameByte = data.nameId; else if (typeof circuit.name !== 'undefined') nameByte = circuit.nameId; return new Promise(async (resolve, reject) => { let circuit = sys.circuits.getInterfaceById(data.id); let cstate = state.circuits.getInterfaceById(data.id); circuit.nameId = cstate.nameId = nameByte; circuit.name = cstate.name = sys.board.valueMaps.circuitNames.transform(nameByte).desc; circuit.showInFeatures = cstate.showInFeatures = typeof data.showInFeatures !== 'undefined' ? data.showInFeatures : circuit.showInFeatures || true; 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; state.emitEquipmentChanges(); resolve(circuit); }); } catch (err) { logger.error(`setCircuitAsync error setting circuit ${JSON.stringify(data)}: ${err}`); return Promise.reject(err); } } public async deleteCircuitAsync(data: any): Promise { let circuit = sys.circuits.getItemById(data.id); if (circuit.master === 1) return await super.deleteCircuitAsync(data); data.nameId = 0; data.functionId = sys.board.valueMaps.circuitFunctions.getValue('notused'); return this.setCircuitAsync(data); } 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 (this.isWaterColorsCircuit(c)) { const cstate = state.circuits.getInterfaceById(id); if (val) await this.sendWaterColorsOnCommandAsync(id); else await this.sendWaterColorsOffCommandAsync(id); this.setWaterColorsState(c, cstate, val); if (val) { const level = this.getWaterColorsLevel(c, cstate); c.level = level; cstate.level = level; } state.emitEquipmentChanges(); return cstate; } 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; } return new Promise((resolve, reject) => { let cstate = state.circuits.getInterfaceById(id); sys.board.circuits.setEndTime(c, cstate, val); cstate.isOn = val; state.emitEquipmentChanges(); resolve(cstate); }); } public async setLightGroupStateAsync(id: number, val: boolean): Promise { return this.setCircuitGroupStateAsync(id, val); } public async toggleCircuitStateAsync(id: number) { let cstate = state.circuits.getInterfaceById(id); if (cstate instanceof LightGroupState) { return await this.setLightGroupThemeAsync(id, sys.board.valueMaps.lightThemes.getValue(cstate.isOn ? 'off' : 'on')); } return await this.setCircuitStateAsync(id, !cstate.isOn); } public async setLightGroupAsync(obj: any): Promise { let group: LightGroup = null; let id = typeof obj.id !== 'undefined' ? parseInt(obj.id, 10) : -1; if (id <= 0) { // We are adding a circuit group. id = sys.circuitGroups.getNextEquipmentId(sys.board.equipmentIds.circuitGroups); } if (typeof id === 'undefined') return Promise.reject(new InvalidEquipmentIdError(`Max circuit light group id exceeded`, id, 'LightGroup')); if (isNaN(id) || !sys.board.equipmentIds.circuitGroups.isInRange(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid circuit group id: ${obj.id}`, obj.id, 'LightGroup')); group = sys.lightGroups.getItemById(id, true); if (typeof obj.name !== 'undefined') group.name = obj.name; if (typeof obj.eggTimer !== 'undefined') group.eggTimer = Math.min(Math.max(parseInt(obj.eggTimer, 10), 0), 1440); group.dontStop = (group.eggTimer === 1440); group.isActive = true; if (typeof obj.circuits !== 'undefined') { for (let i = 0; i < obj.circuits.length; i++) { let cobj = obj.circuits[i]; let c: LightGroupCircuit; if (typeof cobj.id !== 'undefined') c = group.circuits.getItemById(parseInt(cobj.id, 10), true); else if (typeof cobj.circuit !== 'undefined') c = group.circuits.getItemByCircuitId(parseInt(cobj.circuit, 10), true); else c = group.circuits.getItemByIndex(i, true, { id: i + 1 }); if (typeof cobj.circuit !== 'undefined') c.circuit = cobj.circuit; //if (typeof cobj.lightingTheme !== 'undefined') c.lightingTheme = parseInt(cobj.lightingTheme, 10); // does this belong here? if (typeof cobj.color !== 'undefined') c.color = parseInt(cobj.color, 10); if (typeof cobj.swimDelay !== 'undefined') c.swimDelay = parseInt(cobj.swimDelay, 10); if (typeof cobj.position !== 'undefined') c.position = parseInt(cobj.position, 10); } } return new Promise(async (resolve, reject) => { try { resolve(group); } catch (err) { reject(err); } }); } public async setLightThemeAsync(id: number, theme: number): Promise { const circ = sys.circuits.getItemById(id); if (!this.isWaterColorsCircuit(circ)) { // Re-route this as we cannot set individual circuit themes in *Touch. return this.setLightGroupThemeAsync(id, theme); } const cstate = state.circuits.getItemById(id); const thm = this.getWaterColorsTheme(theme); const nop = sys.board.valueMaps.circuitActions.getValue('lighttheme'); cstate.action = nop; cstate.emitEquipmentChange(); try { if (!cstate.isOn) await this.sendWaterColorsOnCommandAsync(id); await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_THEME, [0x00, thm.sequence, 0x64], `aqualink-watercolors-theme-${id}`); this.setWaterColorsState(circ, cstate, true); const level = this.getWaterColorsLevel(circ, cstate); circ.level = level; cstate.level = level; circ.lightingTheme = thm.val; cstate.lightingTheme = thm.val; cstate.color = undefined; state.emitEquipmentChanges(); return cstate; } catch (err) { return Promise.reject(new InvalidOperationError(err.message, 'setLightThemeAsync')); } finally { cstate.action = 0; cstate.emitEquipmentChange(); } } public async setDimmerLevelAsync(id: number, level: number): Promise { const circ = sys.circuits.getItemById(id); if (!this.isWaterColorsCircuit(circ)) return super.setDimmerLevelAsync(id, level); const cstate = state.circuits.getItemById(id); const brightness = this.normalizeWaterColorsBrightness(level); const nop = sys.board.valueMaps.circuitActions.getValue('lighttheme'); cstate.action = nop; cstate.emitEquipmentChange(); try { if (brightness > 0 && !cstate.isOn) await this.sendWaterColorsOnCommandAsync(id); await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_THEME, [0x00, 0xFF, brightness], `aqualink-watercolors-brightness-${id}`); circ.level = brightness; cstate.level = brightness; this.setWaterColorsState(circ, cstate, brightness > 0); state.emitEquipmentChanges(); return cstate; } catch (err) { return Promise.reject(new InvalidOperationError(err.message, 'setDimmerLevelAsync')); } finally { cstate.action = 0; cstate.emitEquipmentChange(); } } public async setLightColorAsync(id: number, color: { red: number; green: number; blue: number }): Promise { const circ = sys.circuits.getItemById(id); if (!this.isWaterColorsCircuit(circ)) return super.setLightColorAsync(id, color); const cstate = state.circuits.getItemById(id); const red = this.normalizeWaterColorsComponent(color.red, 'red'); const green = this.normalizeWaterColorsComponent(color.green, 'green'); const blue = this.normalizeWaterColorsComponent(color.blue, 'blue'); const nop = sys.board.valueMaps.circuitActions.getValue('lighttheme'); cstate.action = nop; cstate.emitEquipmentChange(); try { if (!cstate.isOn) await this.sendWaterColorsOnCommandAsync(id); await this.sendIAquaLinkLightCommandAsync(IAQUALINK_OPCODE_RGB, [0x00, red, green, blue, 0x00], `aqualink-watercolors-rgb-${id}`); const level = this.getWaterColorsLevel(circ, cstate); circ.level = level; cstate.level = level; this.setWaterColorsState(circ, cstate, true); circ.lightingTheme = sys.board.valueMaps.lightThemes.getValue('none'); cstate.lightingTheme = sys.board.valueMaps.lightThemes.getValue('none'); cstate.color = { red, green, blue }; state.emitEquipmentChanges(); return cstate; } catch (err) { return Promise.reject(new InvalidOperationError(err.message, 'setLightColorAsync')); } finally { cstate.action = 0; cstate.emitEquipmentChange(); } } public async runLightGroupCommandAsync(obj: any): Promise { // Do all our validation. try { let id = parseInt(obj.id, 10); let cmd = typeof obj.command !== 'undefined' ? sys.board.valueMaps.lightGroupCommands.findItem(obj.command) : { val: 0, name: 'undefined' }; if (cmd.val === 0) return Promise.reject(new InvalidOperationError(`Light group command ${cmd.name} does not exist`, 'runLightGroupCommandAsync')); if (isNaN(id)) return Promise.reject(new InvalidOperationError(`Light group ${id} does not exist`, 'runLightGroupCommandAsync')); let grp = sys.lightGroups.getItemById(id); let nop = sys.board.valueMaps.circuitActions.getValue(cmd.name); let sgrp = state.lightGroups.getItemById(grp.id); sgrp.action = nop; sgrp.emitEquipmentChange(); switch (cmd.name) { case 'colorset': await this.sequenceLightGroupAsync(id, 'colorset'); break; case 'colorswim': await this.sequenceLightGroupAsync(id, 'colorswim'); break; case 'colorhold': await this.setLightGroupThemeAsync(id, 190); break; case 'colorrecall': await this.setLightGroupThemeAsync(id, 191); break; case 'lightthumper': await this.setLightGroupThemeAsync(id, 208); break; } sgrp.action = 0; sgrp.emitEquipmentChange(); return sgrp; } catch (err) { return Promise.reject(`Error runLightGroupCommandAsync ${err.message}`); } } public async runLightCommandAsync(obj: any): Promise { // Do all our validation. try { let id = parseInt(obj.id, 10); let cmd = typeof obj.command !== 'undefined' ? sys.board.valueMaps.lightCommands.findItem(obj.command) : { val: 0, name: 'undefined' }; if (cmd.val === 0) return Promise.reject(new InvalidOperationError(`Light command ${cmd.name} does not exist`, 'runLightCommandAsync')); if (isNaN(id)) return Promise.reject(new InvalidOperationError(`Light ${id} does not exist`, 'runLightCommandAsync')); let circ = sys.circuits.getItemById(id); if (!circ.isActive) return Promise.reject(new InvalidOperationError(`Light circuit #${id} is not active`, 'runLightCommandAsync')); let type = sys.board.valueMaps.circuitFunctions.transform(circ.type); if (!type.isLight) return Promise.reject(new InvalidOperationError(`Circuit #${id} is not a light`, 'runLightCommandAsync')); let nop = sys.board.valueMaps.circuitActions.getValue(cmd.name); let slight = state.circuits.getItemById(circ.id); slight.action = nop; slight.emitEquipmentChange(); // Touch boards cannot change the theme or color of a single light. slight.action = 0; slight.emitEquipmentChange(); return slight; } catch (err) { return Promise.reject(`Error runLightCommandAsync ${err.message}`); } } public async setLightGroupThemeAsync(id = sys.board.equipmentIds.circuitGroups.start, theme: number): Promise { return new Promise((resolve, reject) => { const grp = sys.lightGroups.getItemById(id); const sgrp = state.lightGroups.getItemById(id); grp.lightingTheme = sgrp.lightingTheme = theme; sgrp.action = sys.board.valueMaps.circuitActions.getValue('lighttheme'); sgrp.emitEquipmentChange(); try { // Let everyone know we turned these on. The theme messages will come later. for (let i = 0; i < grp.circuits.length; i++) { let c = grp.circuits.getItemByIndex(i); let cstate = state.circuits.getItemById(c.circuit); // if theme is 'off' light groups should not turn on } let isOn = sys.board.valueMaps.lightThemes.getName(theme) === 'off' ? false : true; sys.board.circuits.setEndTime(grp, sgrp, isOn); sgrp.isOn = isOn; switch (theme) { case 0: // off case 1: // on break; case 128: // sync setImmediate(function () { sys.board.circuits.sequenceLightGroupAsync(grp.id, 'sync'); }); break; case 144: // swim setImmediate(function () { sys.board.circuits.sequenceLightGroupAsync(grp.id, 'swim'); }); break; case 160: // swim setImmediate(function () { sys.board.circuits.sequenceLightGroupAsync(grp.id, 'set'); }); break; case 190: // save case 191: // recall setImmediate(function () { sys.board.circuits.sequenceLightGroupAsync(grp.id, 'other'); }); break; default: setImmediate(function () { sys.board.circuits.sequenceLightGroupAsync(grp.id, 'color'); }); // other themes for magicstream? } sgrp.action = 0; sgrp.hasChanged = true; // Say we are dirty but we really are pure as the driven snow. state.emitEquipmentChanges(); resolve(sgrp); } catch (err) { logger.error(`error setting intellibrite theme: ${err.message}`); reject(err); } }); } } class AquaLinkFeatureCommands extends FeatureCommands { // todo: remove this in favor of setCircuitState only? public async setFeatureStateAsync(id: number, val: boolean): Promise { // Route this to the circuit state since this is the same call // and the interface takes care of it all. return this.board.circuits.setCircuitStateAsync(id, val); } public async toggleFeatureStateAsync(id: number) { // Route this to the circuit state since this is the same call // and the interface takes care of it all. return this.board.circuits.toggleCircuitStateAsync(id); } public async setFeatureAsync(data: any): Promise { return new Promise((resolve, reject) => { let id = parseInt(data.id, 10); let feature: Feature; if (id <= 0) { id = sys.features.getNextEquipmentId(sys.board.equipmentIds.features); feature = sys.features.getItemById(id, false, { isActive: true, freeze: false }); } else feature = sys.features.getItemById(id, false); if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('feature Id has not been defined', data.id, 'Feature')); if (!sys.board.equipmentIds.features.isInRange(id)) return Promise.reject(new InvalidEquipmentIdError(`feature Id ${id}: is out of range.`, id, 'Feature')); let typeByte = data.type || feature.type || sys.board.valueMaps.circuitFunctions.getValue('generic'); let nameByte = 3; // set default `Aux 1` if (typeof data.nameId !== 'undefined') nameByte = data.nameId; else if (typeof feature.name !== 'undefined') nameByte = feature.nameId; feature = sys.features.getItemById(id); let fstate = state.features.getItemById(data.id); feature.nameId = fstate.nameId = nameByte; // circuit.name = cstate.name = sys.board.valueMaps.circuitNames.get(nameByte).desc; feature.name = fstate.name = sys.board.valueMaps.circuitNames.transform(nameByte).desc; feature.type = fstate.type = typeByte; feature.freeze = (typeof data.freeze !== 'undefined' ? utils.makeBool(data.freeze) : feature.freeze); fstate.showInFeatures = feature.showInFeatures = (typeof data.showInFeatures !== 'undefined' ? utils.makeBool(data.showInFeatures) : feature.showInFeatures); feature.eggTimer = typeof data.eggTimer !== 'undefined' ? parseInt(data.eggTimer, 10) : feature.eggTimer || 720; feature.dontStop = (typeof data.dontStop !== 'undefined') ? utils.makeBool(data.dontStop) : feature.eggTimer === 1620; let eggTimer = sys.eggTimers.find(elem => elem.circuit === id); state.emitEquipmentChanges(); resolve(feature); }); } } class AquaLinkChlorinatorCommands extends ChlorinatorCommands { public async setChlorAsync(obj: any): Promise { let id = parseInt(obj.id, 10); let isAdd = false; let chlor = sys.chlorinators.getItemById(id); if (id <= 0 || isNaN(id)) { isAdd = true; chlor.master = utils.makeBool(obj.master) ? 1 : 0; // Calculate an id for the chlorinator. The messed up part is that if a chlorinator is not attached to the OCP, its address // cannot be set by the MUX. This will have to wait. id = 1; } // If this is a Nixie chlorinator then go to the base class and handle it from there. if (chlor.master === 1) return super.setChlorAsync(obj); // RKS: I am not even sure this can be done with Touch as the master on the RS485 bus. if (typeof chlor.master === 'undefined') chlor.master = 0; let name = obj.name || chlor.name || 'IntelliChlor' + id; let superChlorHours = parseInt(obj.superChlorHours, 10); if (typeof obj.superChlorinate !== 'undefined') obj.superChlor = utils.makeBool(obj.superChlorinate); let superChlorinate = typeof obj.superChlor === 'undefined' ? undefined : utils.makeBool(obj.superChlor); let isDosing = typeof obj.isDosing !== 'undefined' ? utils.makeBool(obj.isDosing) : chlor.isDosing; let disabled = typeof obj.disabled !== 'undefined' ? utils.makeBool(obj.disabled) : chlor.disabled; let poolSetpoint = typeof obj.poolSetpoint !== 'undefined' ? parseInt(obj.poolSetpoint, 10) : chlor.poolSetpoint; let spaSetpoint = typeof obj.spaSetpoint !== 'undefined' ? parseInt(obj.spaSetpoint, 10) : chlor.spaSetpoint; let model = typeof obj.model !== 'undefined' ? obj.model : chlor.model; let portId = typeof obj.portId !== 'undefined' ? parseInt(obj.portId, 10) : chlor.portId; if (portId !== chlor.portId && sys.chlorinators.count(elem => elem.id !== chlor.id && elem.portId === portId && elem.master !== 2) > 0) return Promise.reject(new InvalidEquipmentDataError(`Another chlorinator is installed on port #${portId}. Only one chlorinator can be installed per port.`, 'Chlorinator', portId)); let saltTarget = typeof obj.saltTarget === 'number' ? parseInt(obj.saltTarget, 10) : chlor.saltTarget; let chlorType = typeof obj.type !== 'undefined' ? sys.board.valueMaps.chlorinatorType.encode(obj.type) : chlor.type || 0; if (isAdd) { if (isNaN(poolSetpoint)) poolSetpoint = 50; if (isNaN(spaSetpoint)) spaSetpoint = 10; if (isNaN(superChlorHours)) superChlorHours = 8; if (typeof superChlorinate === 'undefined') superChlorinate = false; } else { if (isNaN(poolSetpoint)) poolSetpoint = chlor.poolSetpoint || 0; if (isNaN(spaSetpoint)) spaSetpoint = chlor.spaSetpoint || 0; if (isNaN(superChlorHours)) superChlorHours = chlor.superChlorHours; if (typeof superChlorinate === 'undefined') superChlorinate = utils.makeBool(chlor.superChlor); } if (typeof obj.disabled !== 'undefined') chlor.disabled = utils.makeBool(obj.disabled); if (typeof chlor.body === 'undefined') chlor.body = parseInt(obj.body, 10) || 32; // Verify the data. let body = sys.board.bodies.mapBodyAssociation(chlor.body); if (typeof body === 'undefined') { if (sys.equipment.shared) body = 32; else if (!sys.equipment.dual) body = 1; else return Promise.reject(new InvalidEquipmentDataError(`Chlorinator body association is not valid: ${body}`, 'chlorinator', body)); } if (poolSetpoint > 100 || poolSetpoint < 0) return Promise.reject(new InvalidEquipmentDataError(`Chlorinator poolSetpoint is out of range: ${chlor.poolSetpoint}`, 'chlorinator', chlor.poolSetpoint)); if (spaSetpoint > 100 || spaSetpoint < 0) return Promise.reject(new InvalidEquipmentDataError(`Chlorinator spaSetpoint is out of range: ${chlor.poolSetpoint}`, 'chlorinator', chlor.spaSetpoint)); if (typeof obj.ignoreSaltReading !== 'undefined') chlor.ignoreSaltReading = utils.makeBool(obj.ignoreSaltReading); let _timeout: NodeJS.Timeout; try { let schlor = state.chlorinators.getItemById(id, true); chlor.disabled = disabled; chlor.saltTarget = saltTarget; schlor.isActive = chlor.isActive = true; schlor.superChlor = chlor.superChlor = superChlorinate; schlor.poolSetpoint = chlor.poolSetpoint = poolSetpoint; schlor.spaSetpoint = chlor.spaSetpoint = spaSetpoint; schlor.superChlorHours = chlor.superChlorHours = superChlorHours; schlor.body = chlor.body = body; if (typeof chlor.address === 'undefined') chlor.address = 80; // 79 + id; chlor.name = schlor.name = name; schlor.model = chlor.model = model; schlor.type = chlor.type = chlorType; chlor.isDosing = isDosing; chlor.portId = portId; state.emitEquipmentChanges(); return state.chlorinators.getItemById(id); } catch (err) { logger.error(`AquaLink setChlorAsync Error: ${err.message}`); return Promise.reject(err); } } public async deleteChlorAsync(obj: any): Promise { let id = parseInt(obj.id, 10); if (isNaN(id)) return Promise.reject(new InvalidEquipmentDataError(`Chlorinator id is not valid: ${obj.id}`, 'chlorinator', obj.id)); let chlor = sys.chlorinators.getItemById(id); if (chlor.master === 1) return await super.deleteChlorAsync(obj); return new Promise((resolve, reject) => { ncp.chlorinators.deleteChlorinatorAsync(id).then(() => { }); let cstate = state.chlorinators.getItemById(id, true); chlor = sys.chlorinators.getItemById(id, true); chlor.isActive = cstate.isActive = false; sys.chlorinators.removeItemById(id); state.chlorinators.removeItemById(id); resolve(cstate); }); } } class AquaLinkPumpCommands extends PumpCommands { public async setPumpAsync(data: any): Promise { let pump: Pump; let ntype; let type; let isAdd = false; let id = (typeof data.id === 'undefined') ? -1 : parseInt(data.id, 10); if (typeof data.id === 'undefined' || isNaN(id) || id <= 0) { // We are adding a new pump ntype = parseInt(data.type, 10); type = sys.board.valueMaps.pumpTypes.transform(ntype); // If this is one of the pumps that are not supported by touch send it to system board. if (type.equipmentMaster === 1) return super.setPumpAsync(data); if (typeof data.type === 'undefined' || isNaN(ntype) || typeof type.name === 'undefined') return Promise.reject(new InvalidEquipmentDataError('You must supply a pump type when creating a new pump', 'Pump', data)); isAdd = true; pump = sys.pumps.getItemById(id, true); } else { pump = sys.pumps.getItemById(id, false); if (data.master > 0 || pump.master > 0) return await super.setPumpAsync(data); ntype = typeof data.type === 'undefined' ? pump.type : parseInt(data.type, 10); if (isNaN(ntype)) return Promise.reject(new InvalidEquipmentDataError(`Pump type ${data.type} is not valid`, 'Pump', data)); type = sys.board.valueMaps.pumpTypes.transform(ntype); // changing type? clear out all props and add as new if (ntype !== pump.type) { isAdd = true; //super.setType(pump, ntype); pump = sys.pumps.getItemById(id, false); // refetch pump with new value } } // Validate all the ids since in *Touch the address is determined from the id. if (!isAdd) isAdd = sys.pumps.find(elem => elem.id === id) === undefined; // Now lets validate the ids related to the type. if (id === 9 && type.name !== 'ds') return Promise.reject(new InvalidEquipmentDataError(`The id for a ${type.desc} pump must be 9`, 'Pump', data)); else if (id === 10 && type.name !== 'ss') return Promise.reject(new InvalidEquipmentDataError(`The id for a ${type.desc} pump must be 10`, 'Pump', data)); else if (id > sys.equipment.maxPumps) return Promise.reject(new InvalidEquipmentDataError(`The id for a ${type.desc} must be less than ${sys.equipment.maxPumps}`, 'Pump', data)); // Need to do a check here if we are clearing out the circuits; id data.circuits === [] // extend will keep the original array let bClearPumpCircuits = typeof data.circuits !== 'undefined' && data.circuits.length === 0; if (!isAdd) data = extend(true, {}, pump.get(true), data, { id: id, type: ntype }); else data = extend(false, {}, data, { id: id, type: ntype }); if (!isAdd && bClearPumpCircuits) data.circuits = []; data.name = data.name || pump.name || type.desc; // We will not be sending message for ss type pumps. if (type.name === 'ss') { // The OCP doesn't deal with single speed pumps. Simply add it to the config. data.circuits = []; pump.set(pump); let spump = state.pumps.getItemById(id, true); for (let prop in spump) { if (typeof data[prop] !== 'undefined') spump[prop] = data[prop]; } spump.emitEquipmentChange(); return Promise.resolve(pump); } else if (type.name === 'ds') { // We are going to set all the high speed circuits. // RSG: TODO I don't know what the message is to set the high speed circuits. The following should // be moved into the onComplete for the outbound message to set high speed circuits. for (let prop in pump) { if (typeof data[prop] !== 'undefined') pump[prop] = data[prop]; } let spump = state.pumps.getItemById(id, true); for (let prop in spump) { if (typeof data[prop] !== 'undefined') spump[prop] = data[prop]; } spump.emitEquipmentChange(); return Promise.resolve(pump); } else { let arr = []; return new Promise((resolve, reject) => { pump = sys.pumps.getItemById(id, true); pump.set(data); // Sets all the data back to the pump. let spump = state.pumps.getItemById(id, true); spump.name = pump.name; spump.type = pump.type; spump.emitEquipmentChange(); resolve(pump); }); } } public async deletePumpAsync(data: any): Promise { let id = parseInt(data.id, 10); if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`deletePumpAsync: Pump ${id} is not valid.`, 0, `pump`)); let pump = sys.pumps.getItemById(id, false); if (pump.master === 1) return super.deletePumpAsync(data); return new Promise((resolve, reject) => { resolve(sys.pumps.getItemById(id)); }); } } class AquaLinkHeaterCommands extends HeaterCommands { public getInstalledHeaterTypes(body?: number): any { let heaters = sys.heaters.get(); let types = sys.board.valueMaps.heaterTypes.toArray(); let inst = { total: 0 }; for (let i = 0; i < types.length; i++) if (types[i].name !== 'none') inst[types[i].name] = 0; for (let i = 0; i < heaters.length; i++) { let heater = heaters[i]; if (typeof body !== 'undefined' && heater.body !== 'undefined') { if ((heater.body !== 32 && body !== heater.body + 1) || (heater.body === 32 && body > 2)) continue; } let type = types.find(elem => elem.val === heater.type); if (typeof type !== 'undefined') { if (inst[type.name] === 'undefined') inst[type.name] = 0; inst[type.name] = inst[type.name] + 1; if (heater.coolingEnabled === true && type.hasCoolSetpoint === true) inst['hasCoolSetpoint'] = true; inst.total++; } } return inst; } public isSolarInstalled(body?: number): boolean { let heaters = sys.heaters.get(); let types = sys.board.valueMaps.heaterTypes.toArray(); for (let i = 0; i < heaters.length; i++) { let heater = heaters[i]; if (typeof body !== 'undefined' && body !== heater.body) continue; let type = types.find(elem => elem.val === heater.type); if (typeof type !== 'undefined') { switch (type.name) { case 'solar': return true; } } } } public isHeatPumpInstalled(body?: number): boolean { let heaters = sys.heaters.get(); let types = sys.board.valueMaps.heaterTypes.toArray(); for (let i = 0; i < heaters.length; i++) { let heater = heaters[i]; if (typeof body !== 'undefined' && body !== heater.body) continue; let type = types.find(elem => elem.val === heater.type); if (typeof type !== 'undefined') { switch (type.name) { case 'heatpump': return true; } } } } public async setHeaterAsync(obj: any): Promise { if (obj.master === 1 || parseInt(obj.id, 10) > 255) return super.setHeaterAsync(obj); return new Promise((resolve, reject) => { let id = typeof obj.id === 'undefined' ? -1 : parseInt(obj.id, 10); if (isNaN(id)) return reject(new InvalidEquipmentIdError('Heater Id is not valid.', obj.id, 'Heater')); let heater: Heater; let address: number; let htype; heater.address = address; heater.master = 0; heater.body = sys.equipment.shared ? 32 : 0; sys.board.heaters.updateHeaterServices(); sys.board.heaters.syncHeaterStates(); resolve(heater); }); } public async deleteHeaterAsync(obj: any): Promise { if (utils.makeBool(obj.master === 1 || parseInt(obj.id, 10) > 255)) return super.deleteHeaterAsync(obj); return new Promise((resolve, reject) => { let id = parseInt(obj.id, 10); if (isNaN(id)) return reject(new InvalidEquipmentIdError('Cannot delete. Heater Id is not valid.', obj.id, 'Heater')); let heater = sys.heaters.getItemById(id); heater.isActive = false; sys.heaters.removeItemById(id); state.heaters.removeItemById(id); sys.board.heaters.updateHeaterServices(); sys.board.heaters.syncHeaterStates(); resolve(heater); }); } public updateHeaterServices() { let htypes = sys.board.heaters.getInstalledHeaterTypes(); let solarInstalled = htypes.solar > 0; let heatPumpInstalled = htypes.heatpump > 0; let ultratempInstalled = htypes.ultratemp > 0; let gasHeaterInstalled = htypes.gas > 0; let hybridInstalled = htypes.hybrid > 0; sys.board.valueMaps.heatModes.set(0, { name: 'off', desc: 'Off' }); sys.board.valueMaps.heatSources.set(0, { name: 'off', desc: 'Off' }); if (hybridInstalled) { // Source Issue #390 // 1 = Heat Pump // 2 = Gas Heater // 3 = Hybrid // 16 = Dual sys.board.valueMaps.heatModes.set(1, { name: 'heatpump', desc: 'Heat Pump' }); sys.board.valueMaps.heatModes.set(2, { name: 'heater', desc: 'Gas Heat' }); sys.board.valueMaps.heatModes.set(3, { name: 'heatpumppref', desc: 'Hybrid' }); sys.board.valueMaps.heatModes.set(16, { name: 'dual', desc: 'Dual Heat' }); sys.board.valueMaps.heatSources.set(2, { name: 'heater', desc: 'Gas Heat' }); sys.board.valueMaps.heatSources.set(5, { name: 'heatpumppref', desc: 'Hybrid' }); sys.board.valueMaps.heatSources.set(20, { name: 'dual', desc: 'Dual Heat' }); sys.board.valueMaps.heatSources.set(21, { name: 'heatpump', desc: 'Heat Pump' }); } else { if (gasHeaterInstalled) { sys.board.valueMaps.heatModes.set(1, { name: 'heater', desc: 'Heater' }); sys.board.valueMaps.heatSources.set(2, { name: 'heater', desc: 'Heater' }); } else { // no heaters (virtual controller) sys.board.valueMaps.heatModes.delete(1); sys.board.valueMaps.heatSources.delete(2); } if (solarInstalled && gasHeaterInstalled) { sys.board.valueMaps.heatModes.set(2, { name: 'solarpref', desc: 'Solar Preferred' }); sys.board.valueMaps.heatModes.set(3, { name: 'solar', desc: 'Solar Only' }); sys.board.valueMaps.heatSources.set(5, { name: 'solarpref', desc: 'Solar Preferred' }); sys.board.valueMaps.heatSources.set(21, { name: 'solar', desc: 'Solar Only' }); } else if (heatPumpInstalled && gasHeaterInstalled) { sys.board.valueMaps.heatModes.set(2, { name: 'heatpumppref', desc: 'Heat Pump Preferred' }); sys.board.valueMaps.heatModes.set(3, { name: 'heatpump', desc: 'Heat Pump Only' }); sys.board.valueMaps.heatSources.set(5, { name: 'heatpumppref', desc: 'Heat Pump Preferred' }); sys.board.valueMaps.heatSources.set(21, { name: 'heatpump', desc: 'Heat Pump Only' }); } else if (ultratempInstalled && gasHeaterInstalled) { sys.board.valueMaps.heatModes.merge([ [2, { name: 'ultratemppref', desc: 'UltraTemp Pref' }], [3, { name: 'ultratemp', desc: 'UltraTemp Only' }] ]); sys.board.valueMaps.heatSources.merge([ [5, { name: 'ultratemppref', desc: 'Ultratemp Pref', hasCoolSetpoint: htypes.hasCoolSetpoint }], [21, { name: 'ultratemp', desc: 'Ultratemp Only', hasCoolSetpoint: htypes.hasCoolSetpoint }] ]) } else { // only gas sys.board.valueMaps.heatModes.delete(2); sys.board.valueMaps.heatModes.delete(3); sys.board.valueMaps.heatSources.delete(5); sys.board.valueMaps.heatSources.delete(21); } } sys.board.valueMaps.heatSources.set(32, { name: 'nochange', desc: 'No Change' }); this.setActiveTempSensors(); } }