/* 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 { Inbound } from '../Messages'; import { sys, Equipment, ExpansionPanel, Body } from '../../../Equipment'; import { state, BodyTempState } from '../../../State'; import { ControllerType } from '../../../Constants'; import { logger } from "../../../../logger/Logger"; export class EquipmentMessage { public static process(msg: Inbound): void { let pnl: ExpansionPanel; let bodyId: number; let body: Body; let sbody: BodyTempState; switch (sys.controllerType) { case ControllerType.IntelliCenter: { // v3.004+ encodes body capacity as a 16-bit big-endian value (×1000 gal) at [hi, lo]. // v1.x encodes it as a single byte (×1000 gal). See ISSUE-073. const isIntellicenterV3 = sys.equipment.isIntellicenterV3 === true; const readCapacity = (hiIdx: number, loIdx: number): number => { const hi = msg.extractPayloadByte(hiIdx, 0); const lo = msg.extractPayloadByte(loIdx, 0); return (isIntellicenterV3 ? ((hi << 8) | lo) : hi) * 1000; }; switch (msg.extractPayloadByte(1)) { case 0: sys.equipment.name = msg.extractPayloadString(2, 16); sys.equipment.type = msg.extractPayloadByte(35); pnl = sys.equipment.expansions.getItemById(1, true); pnl.type = msg.extractPayloadByte(36); pnl.name = msg.extractPayloadString(18, 16); pnl.isActive = false; //pnl.type !== 0 && pnl.type !== 255; RKS: We will have to see what a system looks like with an expansion panel installed. // A system withouth any expansion panels installed has been shown to have a 1 in byte(38) i10PS. pnl = sys.equipment.expansions.getItemById(2, true); pnl.type = msg.extractPayloadByte(37); pnl.isActive = false; //pnl.type !== 0 && pnl.type !== 255; pnl = sys.equipment.expansions.getItemById(3, true); pnl.type = msg.extractPayloadByte(38); pnl.isActive = false; //pnl.type !== 0 && pnl.type !== 255; body = sys.bodies.getItemById(1, sys.equipment.maxBodies >= 1); sbody = state.temps.bodies.getItemById(1, sys.equipment.maxBodies >= 1); sbody.type = body.type = msg.extractPayloadByte(39); body.capacity = readCapacity(34, 35); if (body.isActive && sys.equipment.maxBodies === 0) sys.bodies.removeItemById(1); body.isActive = sys.equipment.maxBodies > 0; msg.isProcessed = true; break; case 1: pnl = sys.equipment.expansions.getItemById(2); pnl.name = msg.extractPayloadString(2, 16); bodyId = 2; if (sys.equipment.maxBodies >= bodyId) { body = sys.bodies.getItemById(bodyId, true); sbody = state.temps.bodies.getItemById(bodyId, true); sbody.type = body.type = msg.extractPayloadByte(35); body.capacity = readCapacity(34, 35); body.isActive = true; } else { sys.bodies.removeItemById(bodyId); state.temps.bodies.removeItemById(bodyId); } pnl = sys.equipment.expansions.getItemById(3); pnl.name = msg.extractPayloadString(18, 16); msg.isProcessed = true; break; case 2: // The first name is the first body in this packet and the second is the third. Go figure. bodyId = 1; body = sys.bodies.getItemById(bodyId, bodyId <= sys.equipment.maxBodies); sbody = state.temps.bodies.getItemById(bodyId, bodyId <= sys.equipment.maxBodies); sbody.name = body.name = msg.extractPayloadString(2, 16); // IntelliCenter shared-body systems (e.g. i10PS) have Pool+Spa (Body1+Body2). In those systems, // the second string in this packet is Body2 (Spa). Non-shared multi-body systems keep the legacy mapping. const secondBodyId = (sys.equipment.shared === true && sys.equipment.dual !== true) ? 2 : 3; if (sys.equipment.maxBodies >= secondBodyId) { body = sys.bodies.getItemById(secondBodyId, secondBodyId <= sys.equipment.maxBodies); sbody = state.temps.bodies.getItemById(secondBodyId, secondBodyId <= sys.equipment.maxBodies); // Only body3+ packets include type/capacity bytes here; avoid corrupting 2-body systems. if (secondBodyId >= 3) { sbody.type = body.type = msg.extractPayloadByte(35); body.capacity = readCapacity(34, 35); } body.isActive = secondBodyId <= sys.equipment.maxBodies; sbody.name = body.name = msg.extractPayloadString(18, 16); } else { sys.bodies.removeItemById(secondBodyId); state.temps.bodies.removeItemById(secondBodyId); } msg.isProcessed = true; break; case 3: // The first name is the second body and the 2nd is the 4th. This packet also contains // any additional information related to bodies 3 & 4 that were not previously included. bodyId = 2; if (sys.equipment.maxBodies >= bodyId) { body = sys.bodies.getItemById(bodyId, bodyId <= sys.equipment.maxBodies); sbody = state.temps.bodies.getItemById(bodyId, bodyId <= sys.equipment.maxBodies); const firstName = msg.extractPayloadString(2, 16); const secondName = msg.extractPayloadString(18, 16); if (sys.equipment.shared === true && sys.equipment.dual !== true) { // Shared-body systems often carry placeholder names in case 3 (for body 3/4 slots). // Do not overwrite a valid Spa name with "UNCONFIGURED". const resolvedName = EquipmentMessage.resolveSharedBody2Name(firstName, secondName, body.name); if (typeof resolvedName !== 'undefined') sbody.name = body.name = resolvedName; } else { sbody.name = body.name = firstName; } } else { sys.bodies.removeItemById(bodyId); state.temps.bodies.removeItemById(bodyId); } bodyId = 4; if (sys.equipment.maxBodies >= bodyId) { body = sys.bodies.getItemById(bodyId, bodyId <= sys.equipment.maxBodies); sbody = state.temps.bodies.getItemById(bodyId, bodyId <= sys.equipment.maxBodies); sbody.name = body.name = msg.extractPayloadString(18, 16); sbody.type = body.type = msg.extractPayloadByte(37); body.capacity = readCapacity(36, 37); if (body.isActive && bodyId > sys.equipment.maxBodies) sys.bodies.removeItemById(bodyId); body.isActive = bodyId <= sys.equipment.maxBodies; } else { sys.bodies.removeItemById(bodyId); state.temps.bodies.removeItemById(bodyId); } bodyId = 3; if (sys.equipment.maxBodies >= bodyId) { body = sys.bodies.getItemById(bodyId, bodyId <= sys.equipment.maxBodies); sbody = state.temps.bodies.getItemById(bodyId, bodyId <= sys.equipment.maxBodies); sbody.type = body.type = msg.extractPayloadByte(35); body.capacity = readCapacity(34, 35); if (body.isActive && bodyId > sys.equipment.maxBodies) sys.bodies.removeItemById(bodyId); body.isActive = bodyId <= sys.equipment.maxBodies; } else { sys.bodies.removeItemById(bodyId); state.temps.bodies.removeItemById(bodyId); } state.equipment.single = sys.equipment.single = sys.equipment.shared == false && sys.equipment.dual === false; state.equipment.shared = sys.equipment.shared; state.equipment.dual = sys.equipment.dual; state.equipment.model = sys.equipment.model; state.equipment.controllerType = sys.controllerType; state.equipment.maxBodies = sys.equipment.maxBodies; state.equipment.maxCircuits = sys.equipment.maxCircuits; state.equipment.maxValves = sys.equipment.maxValves; state.equipment.maxSchedules = sys.equipment.maxSchedules; state.equipment.maxPumps = sys.equipment.maxPumps; msg.isProcessed = true; break; case 12: case 13: case 14: case 15: case 16: case 17: case 18: { const selector = msg.extractPayloadByte(1); const raw = EquipmentMessage.extractAlertRaw(msg); sys.alerts.setRaw(selector, raw); switch (selector) { case 12: sys.alerts.circuitNotifications = raw.length > 0 ? raw[0] : 0; break; case 13: sys.alerts.pumpNotifications = EquipmentMessage.extractAlertMask(raw); break; case 14: sys.alerts.ultratempNotifications = EquipmentMessage.extractAlertMask(raw); break; case 15: sys.alerts.chlorinatorNotifications = EquipmentMessage.extractAlertMask(raw); break; case 16: sys.alerts.intellichemNotifications = EquipmentMessage.extractAlertMask(raw); break; case 17: sys.alerts.hybridNotifications = EquipmentMessage.extractAlertMask(raw); break; case 18: sys.alerts.connectedGasNotifications = EquipmentMessage.extractAlertMask(raw); break; } msg.isProcessed = true; break; } default: logger.debug(`Unprocessed Config Message ${msg.toPacket()}`) break; } break; } case ControllerType.IntelliCom: case ControllerType.EasyTouch: case ControllerType.IntelliTouch: case ControllerType.SunTouch: switch (msg.action) { case 252: EquipmentMessage.processSoftwareVersion(msg); break; } break; } } private static processSoftwareVersion(msg: Inbound) { // sample packet // [165,33,15,16,252,17],0,{2,90},0,0,{1,10},0,0,0,0,0,0,0,0,0,0],[2,89] sys.equipment.bootloaderVersion = `${msg.extractPayloadByte(5)}.${msg.extractPayloadByte(6) < 100 ? '0' + msg.extractPayloadByte(6) : msg.extractPayloadByte(6)}`; sys.equipment.controllerFirmware = `${msg.extractPayloadByte(1)}.${msg.extractPayloadByte(2) < 100 ? '0' + msg.extractPayloadByte(2) : msg.extractPayloadByte(2)}`; } private static extractAlertRaw(msg: Inbound): number[] { const raw: number[] = []; for (let i = 2; i < msg.payload.length; i++) raw.push(msg.extractPayloadByte(i, 0)); return raw; } private static extractAlertMask(raw: number[]): number { if (raw.length === 0) return 0; let mask = 0; if (raw.length <= 2) { for (let i = 0; i < raw.length; i++) { mask = (mask << 8) | (raw[i] & 0xFF); } } else { for (let i = 0; i < raw.length; i++) { mask |= (raw[i] & 0xFF) << (i * 8); } } return mask >>> 0; } private static isPlaceholderBodyName(name: string): boolean { const normalized = (name || '').replace(/\u0000/g, '').trim().toUpperCase(); return normalized.length === 0 || normalized === 'UNCONFIGURED'; } private static resolveSharedBody2Name(firstName: string, secondName: string, currentName: string): string | undefined { if (!EquipmentMessage.isPlaceholderBodyName(secondName)) return secondName; if (!EquipmentMessage.isPlaceholderBodyName(firstName)) return firstName; if (EquipmentMessage.isPlaceholderBodyName(currentName)) return secondName; return undefined; } //private static calcModel(eq: Equipment) { // eq.shared = (eq.type & 8) === 8; // eq.maxPumps = 16; // eq.maxLightGroups = 40; // eq.maxCircuitGroups = 16; // eq.maxValves = EquipmentMessage.calcMaxValves(eq); // eq.maxCircuits = EquipmentMessage.calcMaxCircuits(eq); // eq.maxBodies = EquipmentMessage.calcMaxBodies(eq); // eq.model = 'IntelliCenter i' + (eq.maxCircuits + (eq.shared ? -1 : 0)).toString() + 'P' + (eq.shared ? 'S' : ''); //} //private static calcMaxBodies(eq: Equipment): number { // let max: number = eq.shared ? 2 : 1; // for (let i = 0; i < eq.expansions.length; i++) { // const exp: ExpansionPanel = eq.expansions.getItemById(i + 1); // if (exp.type === 0) continue; // max += (exp.type & 8) === 8 ? 2 : 1; // } // return max; //} //private static calcMaxValves(eq: Equipment): number { // let max: number = 4; // max += (eq.type & 1) === 1 ? 6 : 0; // for (let i = 0; i < eq.expansions.length; i++) { // const exp: ExpansionPanel = eq.expansions.getItemById(i + 1); // max += (exp.type & 1) === 1 ? 6 : 0; // } // return max; //} //private static calcMaxCircuits(eq: Equipment): number { // let max: number = 6; // max += (eq.type & 2) === 2 ? 2 : 0; // max += (eq.type & 4) === 4 ? 2 : 0; // max += eq.shared ? 1 : 0; // for (let i = 0; i < eq.expansions.length; i++) { // const exp: ExpansionPanel = eq.expansions.getItemById(i + 1); // max += (exp.type & 2) === 2 ? 5 : 0; // max += (exp.type & 4) === 2 ? 5 : 0; // } // return max; //} }