/* 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 { SystemBoard, byteValueMap, byteValueMaps, ConfigQueue, ConfigRequest, CircuitCommands, FeatureCommands, ChlorinatorCommands, PumpCommands, BodyCommands, ScheduleCommands, HeaterCommands, EquipmentIdRange, ValveCommands, SystemCommands, ChemControllerCommands, CoverCommands, RemoteCommands } from './SystemBoard';
import { PoolSystem, Body, Schedule, Pump, ConfigVersion, sys, Heater, ICircuitGroup, LightGroupCircuit, LightGroup, ExpansionPanel, ExpansionModule, ExpansionModuleCollection, Valve, General, Options, Location, Owner, ICircuit, Feature, CircuitGroup, ChemController, TempSensorCollection, Chlorinator, Cover, Remote } from '../Equipment';
import { Protocol, Outbound, Inbound, Message, Response } from '../comms/messages/Messages';
import { conn } from '../comms/Comms';
import { logger } from '../../logger/Logger';
import { state, ChlorinatorState, LightGroupState, VirtualCircuitState, ICircuitState, BodyTempState, CircuitGroupState, ICircuitGroupState, ChemControllerState } from '../State';
import { utils, ControllerType } from '../../controller/Constants';
import { InvalidEquipmentIdError, InvalidEquipmentDataError, EquipmentNotFoundError, MessageError, InvalidOperationError } from '../Errors';
import { ncp } from '../nixie/Nixie';
import { Timestamp } from "../Constants"
const INTELLICENTER_MAX_NAME_LENGTH = 15;
const normalizeIntelliCenterName = (name: any, fallback: string = ''): string => {
const source = typeof name !== 'undefined' ? name : fallback || '';
return source.toString().substring(0, INTELLICENTER_MAX_NAME_LENGTH);
};
export class IntelliCenterBoard extends SystemBoard {
private static readonly DEFAULT_REGISTRATION_DEVICE_ID = [2, 110, 106, 115, 80, 67];
private static readonly ICP_REGISTRATION_DEVICE_TYPE = 1;
private static readonly ICP_REGISTRATION_TRAILER = [1, 0, 10];
private static readonly UNREGISTERED_ANNOUNCE_INTERVAL_MS = 5000;
private static readonly REGISTERED_ANNOUNCE_INTERVAL_MS = 300000;
private static readonly REGISTRATION_STATUS_TIMEOUT_MS = 2500;
private static readonly REGISTRATION_STATUS_POLL_MS = 100;
private static readonly REGISTRATION_MAX_ATTEMPTS = 4;
private static readonly STATE_POLL_INTERVAL_MS = 4000;
public needsConfigChanges: boolean = false;
constructor(system: PoolSystem) {
super(system);
this._statusInterval = -1;
this._modulesAcquired = false; // Set us up so that we can wait for a 2 and a 204.
this.equipmentIds.circuits = new EquipmentIdRange(1, function () { return this.start + sys.equipment.maxCircuits - 1; });
this.equipmentIds.features = new EquipmentIdRange(function () { return 129; }, function () { return this.start + sys.equipment.maxFeatures - 1; });
this.equipmentIds.circuitGroups = new EquipmentIdRange(function () { return this.start; }, function () { return this.start + sys.equipment.maxCircuitGroups - 1; });
this.equipmentIds.virtualCircuits = new EquipmentIdRange(function () { return this.start; }, function () { return 254; });
this.equipmentIds.features.start = 129;
this.equipmentIds.circuitGroups.start = 193;
this.equipmentIds.virtualCircuits.start = 234;
this.valueMaps.panelModes = new byteValueMap([
[0, { val: 0, name: 'auto', desc: 'Auto' }],
[1, { val: 1, name: 'service', desc: 'Service' }],
[2, { val: 2, name: 'timeout', desc: 'Timeout' }],
[8, { val: 8, name: 'freeze', desc: 'Freeze' }],
[255, { name: 'error', desc: 'System Error' }]
]);
this.valueMaps.circuitFunctions = new byteValueMap([
[0, { name: 'generic', desc: 'Generic' }],
[1, { name: 'spillway', desc: 'Spillway' }],
[2, { name: 'mastercleaner', desc: 'Master Cleaner', body: 1 }],
[3, { name: 'chemrelay', desc: 'Chem Relay' }],
[4, { name: 'light', desc: 'Light', isLight: true }],
[5, { name: 'intellibrite', desc: 'Intellibrite', isLight: true, theme: 'intellibrite' }],
[6, { name: 'globrite', desc: 'GloBrite', isLight: true, theme: 'intellibrite' }],
[7, { name: 'globritewhite', desc: 'GloBrite White', isLight: true }],
[8, { name: 'magicstream', desc: 'Magicstream', isLight: true, theme: 'intellibrite' }],
[9, { name: 'dimmer', desc: 'Dimmer', isLight: true }],
[10, { name: 'colorcascade', desc: 'ColorCascade', isLight: true, theme: 'intellibrite' }],
[11, { name: 'mastercleaner2', desc: 'Master Cleaner 2', body: 2 }],
[12, { name: 'pool', desc: 'Pool', hasHeatSource: true, body: 1 }],
[13, { name: 'spa', desc: 'Spa', hasHeatSource: true, body: 2 }]
]);
this.valueMaps.pumpTypes = new byteValueMap([
[1, { name: 'ss', desc: 'Single Speed', maxCircuits: 0, hasAddress: false, hasBody: true }],
[2, { name: 'ds', desc: 'Two Speed', maxCircuits: 8, hasAddress: false, hasBody: true }],
[3, { name: 'vs', desc: 'Intelliflo VS', maxPrimingTime: 6, minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true }],
[4, { name: 'vsf', desc: 'Intelliflo VSF', minSpeed: 450, maxSpeed: 3450, minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true }],
[5, { name: 'vf', desc: 'Intelliflo VF', maxPrimingTime: 6, minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true }],
[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' }] }],
[101, { name: 'hwrly', desc: 'Hayward Relay VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 8, relays: [{ id: 1, name: 'Step #1' }, { id: 2, name: 'Step #2' }, { id: 3, name: 'Step #3' }, { id: 4, name: 'Pump On' }] }],
[102, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, equipmentMaster: 1 }]
]);
// RSG - same as systemBoard definition; can delete.
this.valueMaps.heatModes = new byteValueMap([
[1, { name: 'off', desc: 'Off' }],
[3, { name: 'heater', desc: 'Heater' }],
[5, { name: 'solar', desc: 'Solar Only' }],
[12, { name: 'solarpref', desc: 'Solar Preferred' }]
]);
this.valueMaps.scheduleDays = new byteValueMap([
[1, { name: 'mon', desc: 'Monday', dow: 1, bitval: 1 }],
[2, { name: 'tue', desc: 'Tuesday', dow: 2, bitval: 2 }],
[3, { name: 'wed', desc: 'Wednesday', dow: 3, bitval: 4 }],
[4, { name: 'thu', desc: 'Thursday', dow: 4, bitval: 8 }],
[5, { name: 'fri', desc: 'Friday', dow: 5, bitval: 16 }],
[6, { name: 'sat', desc: 'Saturday', dow: 6, bitval: 32 }],
[7, { name: 'sun', desc: 'Sunday', dow: 0, bitval: 64 }]
]);
this.valueMaps.groupCircuitStates = new byteValueMap([
[1, { name: 'on', desc: 'On' }],
[2, { name: 'off', desc: 'Off' }],
[3, { name: 'ignore', desc: 'Ignore' }]
]);
this.valueMaps.heaterTypes = new byteValueMap([
[1, { name: 'gas', desc: 'Gas Heater', hasAddress: false }],
[2, { name: 'solar', desc: 'Solar Heater', hasAddress: false, hasCoolSetpoint: true, hasPreference: true }],
[3, { name: 'heatpump', desc: 'Heat Pump', hasAddress: true, hasPreference: true }],
[4, { name: 'ultratemp', desc: 'UltraTemp', hasAddress: true, hasCoolSetpoint: true, hasPreference: true }],
[5, { name: 'hybrid', desc: 'Hybrid', hasAddress: true }],
[6, { name: 'mastertemp', desc: 'MasterTemp', hasAddress: true }],
[7, { name: 'maxetherm', desc: 'Max-E-Therm', hasAddress: true }],
[8, { name: 'eti250', desc: 'ETI250', hasAddress: true }],
]);
// Keep this around for now so I can fart with the custom names array.
//this.valueMaps.customNames = new byteValueMap(
// sys.customNames.get().map((el, idx) => {
// return [idx + 200, { name: el.name, desc: el.name }];
// })
//);
this.valueMaps.scheduleDays.toArray = function () {
let arrKeys = Array.from(this.keys());
let arr = [];
for (let i = 0; i < arrKeys.length; i++) arr.push(extend(true, { val: arrKeys[i] }, this.get(arrKeys[i])));
return arr;
}
this.valueMaps.scheduleDays.transform = function (byte) {
let days = [];
let b = byte & 0x007F;
for (let bit = 6; bit >= 0; bit--) {
if ((byte & (1 << bit)) > 0) days.push(extend(true, {}, this.get(bit + 1)));
}
return { val: b, days: days };
};
this.valueMaps.expansionBoards = new byteValueMap([
// There are just enough slots for accommodate all the supported hardware for the expansion modules. However, there are several that
// we do not have in the wild and cannot verify as of (03-25-2020) as to whether their id values are correct. I feel more confident
// with the i8P and i10P than I do with the others as this follows the pattern for the known personality cards. i10D and the order of the
// MUX and A/D modules don't seem to fit the pattern. If we ever see an i10D then this may be bit 3&4 set to 1. The theory here is that
// the first 5 bits indicate up to 16 potential personality cards with 0 being i5P.
[0, { name: 'i5P', part: '523125Z', desc: 'i5P Personality Card', bodies: 1, valves: 2, circuits: 5, single: true, shared: false, dual: false, chlorinators: 1, chemControllers: 1 }],
[1, { name: 'i5PS', part: '521936Z', desc: 'i5PS Personality Card', bodies: 2, valves: 4, circuits: 6, shared: true, dual: false, chlorinators: 1, chemControllers: 1 }],
[2, { name: 'i8P', part: '521977Z', desc: 'i8P Personality Card', bodies: 1, valves: 2, circuits: 8, single: true, shared: false, dual: false, chlorinators: 1, chemControllers: 1 }], // This is a guess
[3, { name: 'i8PS', part: '521968Z', desc: 'i8PS Personality Card', bodies: 2, valves: 4, circuits: 9, shared: true, dual: false, chlorinators: 1, chemControllers: 1 }],
[4, { name: 'i10P', part: '521993Z', desc: 'i10P Personality Card', bodies: 1, valves: 2, circuits: 10, single: true, shared: false, dual: false, chlorinators: 1, chemControllers: 1 }], // This is a guess
[5, { name: 'i10PS', part: '521873Z', desc: 'i10PS Personality Card', bodies: 2, valves: 4, circuits: 11, shared: true, dual: false, chlorinators: 1, chemControllers: 1 }],
[6, { name: 'i10x', part: '522997Z', desc: 'i10x Expansion Module', circuits: 10 }],
[7, { name: 'i10D', part: '523029Z', desc: 'i10D Personality Card', bodies: 2, valves: 2, circuits: 11, shared: false, dual: true, chlorinators: 1, chemControllers: 2 }], // We have witnessed this in the wild
[8, { name: 'Valve Exp', part: '522440', desc: 'Valve Expansion Module', valves: 6 }],
[9, { name: 'A/D Module', part: '522039', desc: 'A/D Cover Module', covers: 2 }], // Finally have a user with one of these
[10, { name: 'iChlor Mux', part: '522719', desc: 'iChlor MUX Card', chlorinators: 3 }], // This is a guess
[255, { name: 'i5x', part: '522033', desc: 'i5x Expansion Module', circuits: 5 }] // This does not actually map to a known value at this point but we do know it will be > 6.
]);
this.valueMaps.virtualCircuits = new byteValueMap([
[234, { name: 'heatPump', desc: 'Heat Pump', assignableToPumpCircuit: true }],
[235, { name: 'ultraTemp', desc: 'UltraTemp', assignableToPumpCircuit: true }],
[236, { name: 'hybrid', desc: 'Hybrid', assignableToPumpCircuit: true }],
[237, { name: 'heatBoost', desc: 'Heat Boost', assignableToPumpCircuit: false }],
[238, { name: 'heatEnable', desc: 'Heat Enable', assignableToPumpCircuit: false }],
[239, { name: 'pumpSpeedUp', desc: 'Pump Speed +', assignableToPumpCircuit: false }],
[240, { name: 'pumpSpeedDown', desc: 'Pump Speed -', assignableToPumpCircuit: false }],
[244, { name: 'poolHeater', desc: 'Pool Heater', assignableToPumpCircuit: true }],
[245, { name: 'spaHeater', desc: 'Spa Heater', assignableToPumpCircuit: true }],
[246, { name: 'freeze', desc: 'Freeze', assignableToPumpCircuit: true }],
[247, { name: 'poolSpa', desc: 'Pool/Spa', assignableToPumpCircuit: true }],
[248, { name: 'solarHeat', desc: 'Solar Heat', assignableToPumpCircuit: false }],
[251, { name: 'heater', desc: 'Heater', assignableToPumpCircuit: true }],
[252, { name: 'solar', desc: 'Solar', assignableToPumpCircuit: true }],
[255, { name: 'poolHeatEnable', desc: 'Pool Heat Enable', assignableToPumpCircuit: false }],
[258, { name: 'anyHeater', desc: 'Any Heater', assignableToPumpCircuit: false }]
]);
this.valueMaps.msgBroadcastActions.merge([
[1, { name: 'ack', desc: 'Command Ack' }],
[30, { name: 'config', desc: 'Configuration' }],
[164, { name: 'getconfig', desc: 'Get Configuration' }],
[168, { name: 'setdata', desc: 'Set Data' }],
[204, { name: 'stateext', desc: 'State Extension' }],
[222, { name: 'getdata', desc: 'Get Data' }],
[228, { name: 'getversions', desc: 'Get Versions' }]
]);
this.valueMaps.clockSources.merge([
[1, { name: 'manual', desc: 'Manual' }],
[2, { name: 'server', desc: 'Server' }],
[3, { name: 'internet', desc: 'Internet' }]
]);
this.valueMaps.scheduleTimeTypes.merge([
[1, { name: 'sunrise', desc: 'Sunrise' }],
[2, { name: 'sunset', desc: 'Sunset' }]
]);
this.valueMaps.lightThemes = new byteValueMap([
[0, { name: 'white', desc: 'White', sequence: 11, types: ['intellibrite', 'magicstream'] }],
[1, { name: 'green', desc: 'Green', sequence: 9, types: ['intellibrite', 'magicstream'] }],
[2, { name: 'blue', desc: 'Blue', sequence: 8, types: ['intellibrite', 'magicstream'] }],
[3, { name: 'magenta', desc: 'Magenta', sequence: 12, types: ['intellibrite', 'magicstream'] }],
[4, { name: 'red', desc: 'Red', sequence: 10, types: ['intellibrite', 'magicstream'] }],
[5, { name: 'sam', desc: 'SAm Mode', sequence: 1, types: ['intellibrite', 'magicstream'] }],
[6, { name: 'party', desc: 'Party', sequence: 2, types: ['intellibrite', 'magicstream'] }],
[7, { name: 'romance', desc: 'Romance', sequence: 3, types: ['intellibrite', 'magicstream'] }],
[8, { name: 'caribbean', desc: 'Caribbean', sequence: 4, types: ['intellibrite', 'magicstream'] }],
[9, { name: 'american', desc: 'American', sequence: 5, types: ['intellibrite', 'magicstream'] }],
[10, { name: 'sunset', desc: 'Sunset', sequence: 6, types: ['intellibrite', 'magicstream'] }],
[11, { name: 'royal', desc: 'Royal', sequence: 7, types: ['intellibrite', 'magicstream'] }],
[255, { name: 'none', desc: 'None' }]
]);
this.valueMaps.lightGroupCommands = new byteValueMap([
[1, { name: 'colorsync', desc: 'Sync', types: ['intellibrite'], command: 'colorSync', message: 'Synchronizing' }],
[2, { name: 'colorset', desc: 'Set', types: ['intellibrite'], command: 'colorSet', message: 'Sequencing Set Operation' }],
[3, { name: 'colorswim', desc: 'Swim', types: ['intellibrite'], command: 'colorSwim', message: 'Sequencing Swim Operation' }],
[12, { name: 'colorhold', desc: 'Hold', types: ['intellibrite', 'magicstream'], command: 'colorHold', message: 'Saving Current Colors', sequence: 13 }],
[13, { name: 'colorrecall', desc: 'Recall', types: ['intellibrite', 'magicstream'], command: 'colorRecall', message: 'Recalling Saved Colors', sequence: 14 }]
]);
this.valueMaps.lightCommands = new byteValueMap([
[12, { name: 'colorhold', desc: 'Hold', types: ['intellibrite'], sequence: 13 }],
[13, { name: 'colorrecall', desc: 'Recall', types: ['intellibrite'], sequence: 14 }],
[15, {
name: 'lightthumper', desc: 'Thumper', types: ['magicstream'], command: 'lightThumper', message: 'Toggling Thumper',
sequence: [ // Cycle party mode 3 times.
{ isOn: false, timeout: 100 },
{ isOn: true, timeout: 100 },
{ isOn: false, timeout: 100 },
{ isOn: true, timeout: 5000 },
{ isOn: false, timeout: 100 },
{ isOn: true, timeout: 100 },
{ isOn: false, timeout: 100 },
{ isOn: true, timeout: 5000 },
{ isOn: false, timeout: 100 },
{ isOn: true, timeout: 100 },
{ isOn: false, timeout: 100 },
{ isOn: true, timeout: 1000 },
]
}]
]);
this.valueMaps.lightColors = new byteValueMap([
[0, { name: 'white', desc: 'White' }],
[16, { name: 'lightgreen', desc: 'Light Green' }],
[32, { name: 'green', desc: 'Green' }],
[48, { name: 'cyan', desc: 'Cyan' }],
[64, { name: 'blue', desc: 'Blue' }],
[80, { name: 'lavender', desc: 'Lavender' }],
[96, { name: 'magenta', desc: 'Magenta' }],
[112, { name: 'lightmagenta', desc: 'Light Magenta' }]
]);
this.valueMaps.heatSources = new byteValueMap([
[1, { name: 'off', desc: 'Off' }],
[2, { name: 'heater', desc: 'Heater' }],
[3, { name: 'solar', desc: 'Solar Only' }],
[4, { name: 'solarpref', desc: 'Solar Preferred' }],
[5, { name: 'ultratemp', desc: 'Ultratemp Only' }],
[6, { name: 'ultratemppref', desc: 'Ultratemp Pref' }],
[9, { name: 'heatpump', desc: 'Heatpump Only' }],
[25, { name: 'heatpumppref', desc: 'Heatpump Pref' }],
[32, { name: 'nochange', desc: 'No Change' }]
]);
this.valueMaps.heatStatus = new byteValueMap([
[0, { name: 'off', desc: 'Off' }],
[1, { name: 'heater', desc: 'Heater' }],
[2, { name: 'solar', desc: 'Solar' }],
[3, { name: 'hpheat', desc: 'Heating' }],
[4, { name: 'utheat', desc: 'Heating' }],
[5, { name: 'hybheat', desc: 'Heating' }],
[6, { name: 'mtheat', desc: 'Heater' }],
[7, { name: 'meheat', desc: 'Heater' }],
[8, { name: 'eti250heat', desc: 'Heating' }],
[9, { name: 'utcool', desc: 'Cooling' }]
]);
this.valueMaps.scheduleTypes = new byteValueMap([
[0, { name: 'runonce', desc: 'Run Once', startDate: true, startTime: true, endTime: true, days: false, heatSource: true, heatSetpoint: true }],
[128, { name: 'repeat', desc: 'Repeats', startDate: false, startTime: true, endTime: true, days: 'multi', heatSource: true, heatSetpoint: true }]
]);
this.valueMaps.remoteTypes = new byteValueMap([
[0, { name: 'none', desc: 'Not Installed', maxButtons: 0 }],
[1, { name: 'is4', desc: 'iS4 Spa-Side Remote', maxButtons: 4 }],
[2, { name: 'is10', desc: 'iS10 Spa-Side Remote', maxButtons: 10 }],
[3, { name: 'quickTouch', desc: 'Quick Touch Remote', maxButtons: 4 }],
[4, { name: 'spaCommand', desc: 'Spa Command', maxButtons: 10 }]
]);
}
private _configQueue: IntelliCenterConfigQueue = new IntelliCenterConfigQueue();
private _announceDeviceInterval?: NodeJS.Timeout;
private _announceDeviceTickInFlight: boolean = false;
private _announceDeviceLastSentMs: number = 0;
private _registrationBootstrapStarted: boolean = false;
private _runtimeRegistrationAddress?: number;
private _statePollTimer?: NodeJS.Timeout;
private _statePollInFlight: boolean = false;
public system: IntelliCenterSystemCommands = new IntelliCenterSystemCommands(this);
public circuits: IntelliCenterCircuitCommands = new IntelliCenterCircuitCommands(this);
public features: IntelliCenterFeatureCommands = new IntelliCenterFeatureCommands(this);
public chlorinator: IntelliCenterChlorinatorCommands = new IntelliCenterChlorinatorCommands(this);
public bodies: IntelliCenterBodyCommands = new IntelliCenterBodyCommands(this);
public pumps: IntelliCenterPumpCommands = new IntelliCenterPumpCommands(this);
public schedules: IntelliCenterScheduleCommands = new IntelliCenterScheduleCommands(this);
public heaters: IntelliCenterHeaterCommands = new IntelliCenterHeaterCommands(this);
public valves: IntelliCenterValveCommands = new IntelliCenterValveCommands(this);
public covers: IntelliCenterCoverCommands = new IntelliCenterCoverCommands(this);
public remotes: IntelliCenterRemoteCommands = new IntelliCenterRemoteCommands(this);
public alerts: IntelliCenterAlertCommands = new IntelliCenterAlertCommands(this);
public chemControllers: IntelliCenterChemControllerCommands = new IntelliCenterChemControllerCommands(this);
public reloadConfig() {
//sys.resetSystem();
sys.configVersion.clear();
state.status = 0;
this.needsConfigChanges = true;
console.log('RESETTING THE CONFIGURATION');
this.modulesAcquired = false;
}
private startAnnounceDeviceInterval(): void {
// v3-only: Wireless re-announces aggressively during bootstrap, then settles once registered.
// Mirror that behavior by retrying every 5s until Action 217 shows status=1, then fall back
// to a long keepalive interval to avoid unnecessary bus noise once we're established.
if (!sys.equipment.isIntellicenterV3) return;
if (this._announceDeviceInterval) return;
this._announceDeviceInterval = setInterval(async () => {
if (this._announceDeviceTickInFlight) return;
const now = Date.now();
const minInterval = state.equipment.registration === 1
? IntelliCenterBoard.REGISTERED_ANNOUNCE_INTERVAL_MS
: IntelliCenterBoard.UNREGISTERED_ANNOUNCE_INTERVAL_MS;
if (now - this._announceDeviceLastSentMs < minInterval) return;
this._announceDeviceTickInFlight = true;
try {
await this.announceDevice();
} catch (err) {
logger.warn(`announceDevice interval tick failed: ${err?.message || err}`);
} finally {
this._announceDeviceTickInFlight = false;
}
}, IntelliCenterBoard.UNREGISTERED_ANNOUNCE_INTERVAL_MS);
}
private stopAnnounceDeviceInterval(): void {
if (this._announceDeviceInterval) {
clearInterval(this._announceDeviceInterval);
this._announceDeviceInterval = undefined;
}
this._announceDeviceTickInFlight = false;
this._announceDeviceLastSentMs = 0;
}
private startStatePoll(): void {
// v3.004+: Action 2 and Action 204 don't carry reliable feature/schedule state,
// so we poll the OCP for Action 30 [15,...] (circuit+feature+group+schedule state)
// at a rate matching v1.x's Action 204 frequency (~4s).
if (!sys.equipment.isIntellicenterV3) return;
if (this._statePollTimer) return;
this._statePollTimer = setInterval(async () => {
if (this._statePollInFlight) return;
if (this._configQueue._processing) return;
this._statePollInFlight = true;
try {
const source = this.getRegistrationAddress();
const out = Outbound.create({
source,
dest: 16,
action: 222,
payload: [15, 0],
retries: 0,
response: Response.create({ dest: source, action: 30, payload: [15] })
});
await out.sendAsync();
} catch (err) {
// Non-critical — next poll will retry
} finally {
this._statePollInFlight = false;
}
}, IntelliCenterBoard.STATE_POLL_INTERVAL_MS);
}
private stopStatePoll(): void {
if (this._statePollTimer) {
clearInterval(this._statePollTimer);
this._statePollTimer = undefined;
}
this._statePollInFlight = false;
}
private async sleepAsync(ms: number): Promise {
return await new Promise(resolve => setTimeout(resolve, ms));
}
private async waitForRegistrationStatusAsync(timeoutMs = IntelliCenterBoard.REGISTRATION_STATUS_TIMEOUT_MS): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (state.equipment.registration === 1) return true;
await this.sleepAsync(IntelliCenterBoard.REGISTRATION_STATUS_POLL_MS);
}
return state.equipment.registration === 1;
}
private async ensureRegisteredAsync(): Promise {
if (!sys.equipment.isIntellicenterV3) return;
state.equipment.registration = 0;
for (let attempt = 1; attempt <= IntelliCenterBoard.REGISTRATION_MAX_ATTEMPTS; attempt++) {
try {
await this.announceDevice();
} catch (err) {
logger.warn(`Action 251 registration attempt ${attempt}/${IntelliCenterBoard.REGISTRATION_MAX_ATTEMPTS} failed: ${err?.message || err}`);
}
if (await this.waitForRegistrationStatusAsync()) return;
const reg = state.equipment.registration;
const regLabel = reg === 4 ? 'status=4 (stale/needs-reauth)' : `status=${reg}`;
if (attempt < IntelliCenterBoard.REGISTRATION_MAX_ATTEMPTS) {
logger.info(`IntelliCenter v3 registration attempt ${attempt}/${IntelliCenterBoard.REGISTRATION_MAX_ATTEMPTS} did not reach Action 217 status=1 (${regLabel}); retrying Action 251`);
await this.sleepAsync(IntelliCenterBoard.UNREGISTERED_ANNOUNCE_INTERVAL_MS);
}
}
throw new Error(`IntelliCenter v3 registration never reached Action 217 status=1 (last status=${state.equipment.registration})`);
}
private startRegistrationBootstrapAsync(): void {
if (!sys.equipment.isIntellicenterV3) return;
if (this._registrationBootstrapStarted) return;
this._registrationBootstrapStarted = true;
this.ensureRegisteredAsync().catch((err) => {
logger.warn(`IntelliCenter v3 registration bootstrap failed: ${err?.message || err}`);
});
}
private shouldConvergeToFirstIcpAddress(profileAddress: number, profileDeviceType: number, deviceAddress: number, payloadDeviceType: number, status: number): boolean {
return profileAddress === 33 &&
deviceAddress === 32 &&
profileDeviceType === IntelliCenterBoard.ICP_REGISTRATION_DEVICE_TYPE &&
payloadDeviceType === IntelliCenterBoard.ICP_REGISTRATION_DEVICE_TYPE &&
status === 1;
}
private setRuntimeRegistrationAddress(address: number, reason: string): void {
const normalizedAddress = Math.max(0, Math.min(255, Math.trunc(address)));
if (this._runtimeRegistrationAddress === normalizedAddress && Message.pluginAddress === normalizedAddress) return;
this._runtimeRegistrationAddress = normalizedAddress;
Message.setPluginAddress(normalizedAddress, reason);
}
private getRegistrationProfile(): { address: number; deviceType: number; registrationIdentity: number[]; reserved: number[]; trailer: number[]; } {
return {
address: this._runtimeRegistrationAddress ?? Message.pluginAddress,
deviceType: IntelliCenterBoard.ICP_REGISTRATION_DEVICE_TYPE,
registrationIdentity: [...IntelliCenterBoard.DEFAULT_REGISTRATION_DEVICE_ID],
reserved: [0, 0, 0, 0],
trailer: [...IntelliCenterBoard.ICP_REGISTRATION_TRAILER]
};
}
public getRegistrationAddress(): number {
return this.getRegistrationProfile().address;
}
public isOwnRegistrationPayload(payload: number[], payloadOffset = 7): boolean {
const profile = this.getRegistrationProfile();
if (!Array.isArray(payload) || payload.length < payloadOffset + profile.registrationIdentity.length) return false;
for (let i = 0; i < profile.registrationIdentity.length; i++) {
if (payload[payloadOffset + i] !== profile.registrationIdentity[i]) return false;
}
return true;
}
public isOwnHeartbeatPayload(payload: number[]): boolean {
return this.isOwnRegistrationPayload(payload, 0);
}
public processRegistrationMessage(msg: Inbound): boolean {
if (!this.isOwnRegistrationPayload(msg.payload)) return false;
const profile = this.getRegistrationProfile();
const deviceAddress = msg.extractPayloadByte(0);
const payloadDeviceType = msg.extractPayloadByte(1, 0);
const registrationStatus = msg.payload.length > 2 ? msg.extractPayloadByte(2) : -1;
if (this.shouldConvergeToFirstIcpAddress(profile.address, profile.deviceType, deviceAddress, payloadDeviceType, registrationStatus)) {
logger.warn(`IntelliCenter v3 first ICP registration converged from device ${profile.address} to device ${deviceAddress}. Queue processing: ${this._configQueue._processing}`);
this.setRuntimeRegistrationAddress(deviceAddress, 'IntelliCenter first ICP registration');
if (this._configQueue._processing) {
logger.warn(`Address changed mid-config-load; resetting queue and restarting config with new address ${deviceAddress}`);
this._configQueue.abort();
state.status = 1;
state.emitControllerChange();
setTimeout(() => { this.checkConfiguration(); }, 500);
}
}
const expectedAddress = this.getRegistrationAddress();
if (deviceAddress !== expectedAddress) {
logger.warn(`Ignoring IntelliCenter v3 Action ${msg.action} for matching MAC on device ${deviceAddress}; expected device ${expectedAddress}.`);
return false;
}
if (msg.action === 217 && msg.payload.length > 2) {
this.setRegistrationStatus(registrationStatus);
}
return true;
}
public async announceDevice(): Promise {
// v3.004 registration (251→253/217) is needed for heartbeat identity/session health,
// but config bootstrap (228→164→222/30) can begin before registration fully settles.
// In mock mode we still want to "send" the packet so it is logged/emitted like a real write.
this._announceDeviceLastSentMs = Date.now();
logger.info('Announcing device to IntelliCenter v3...');
// Action 251 payload structure (22 bytes total) verified from wireless remote cradle reset:
// [0]: Device address (33 for njsPC)
// [1]: Device type (1=ICP/Wired panel profile, 0=wireless profile)
// [2]: Registration flag (njsPC still sends 0 as its registration request; live wireless
// boot also showed device-originated 251 packets with byte 2 = 1, so treat this field
// as device/session-specific rather than a universal "request vs response" switch)
// [3-6]: Reserved (zeros)
// [7-12]: Device identifier (6 bytes - must(?) be valid MAC address format!)
// Using locally-administered MAC: 02:6E:6A:73:50:43 = [2, 110, 106, 115, 80, 67]
// 0x02 prefix = locally-administered, unicast (IEEE standard)
// Remaining bytes = "njsPC" in ASCII for identification
// [13-16]: Reserved (zeros)
// [17-18]: Firmware version (major, minor)
// [19-21]: Device trailer bytes (live 3.008 captures show 1,0,10 for ICP/WL 251)
const profile = this.getRegistrationProfile();
const fwMajor = parseInt(sys.equipment.controllerFirmware || "3") || 3;
const fwMinor = Math.round((parseFloat(sys.equipment.controllerFirmware || "3.0") % 1) * 1000);
const out: Outbound = Outbound.create({
source: profile.address,
dest: 16, // MUST send to OCP (16), not broadcast (15)
action: 251,
scope: 'v3Registration',
payload: [
profile.address, // [0] Device address
profile.deviceType, // [1] Device type
0, // [2] Registration flag (0=requesting, 1=registered, 4=stale/needs-reauth)
...profile.reserved, // [3-6] Reserved / panel-type flags
...profile.registrationIdentity, // [7-12] Device identity bytes
0, 0, 0, 0, // [13-16] Reserved
fwMajor, fwMinor, // [17-18] Firmware version
...profile.trailer // [19-21] Device trailer bytes
],
retries: 0,
response: false
});
await out.sendAsync();
logger.silly('Device registration request sent, awaiting confirmation via Action 217');
}
public setRegistrationStatus(status: number) {
// Called when we receive Action 217 showing our registration status
// status: 0=unknown, 1=registered, 4=stale/needs-reauth
// NOTE: status=4 is NOT a rejection. OCP continues heartbeat with status=4 devices.
// Devices can transition from status=4 to status=1 on retry.
// Observed: Wireless remote sometimes shows status=4 then status=1 on next registration.
if (state.equipment.registration !== status) {
state.equipment.registration = status;
if (status === 1) {
logger.silly('Registration confirmed by OCP via Action 217 (status=1)');
} else if (status === 4) {
logger.info('Registration status=4 from OCP via Action 217 - device may need re-registration');
}
}
}
public async checkConfiguration() {
(sys.board as IntelliCenterBoard).needsConfigChanges = true;
try {
// v3.x: Wireless/ICP traffic is unicast to OCP (16) and includes 228→164 (version table) with ACK(164).
if (parseFloat(sys.equipment.controllerFirmware || "0") >= 3.0) {
// ISSUE-003: don't block startup config polling on registration completion.
// Start registration attempts in the background and send Action 228 immediately.
this.startRegistrationBootstrapAsync();
await this.requestVersionsAsync(16);
} else {
// v1.x: keep existing behavior unchanged
console.log('Checking IntelliCenter configuration...');
await this.requestVersionsAsync(15);
}
}
catch (err) {
logger.warn(`checkConfiguration failed: ${err.message}`);
}
}
public isConfigQueueProcessing(): boolean {
return this._configQueue._processing;
}
public signalConfigRefreshNeeded(): void {
this._configQueue._newRequest = true;
this.needsConfigChanges = true;
}
public requestConfiguration(ver: ConfigVersion) {
if (this.needsConfigChanges) {
logger.info(`Requesting IntelliCenter configuration`);
this._configQueue.queueChanges(ver);
this.needsConfigChanges = false;
} else {
logger.info(`Skipping configuration -- Just setting the versions`);
sys.configVersion.chlorinators = ver.chlorinators;
sys.configVersion.circuitGroups = ver.circuitGroups;
sys.configVersion.circuits = ver.circuits;
sys.configVersion.covers = ver.covers;
sys.configVersion.equipment = ver.equipment;
sys.configVersion.systemState = ver.systemState;
sys.configVersion.features = ver.features;
sys.configVersion.general = ver.general;
sys.configVersion.heaters = ver.heaters;
sys.configVersion.intellichem = ver.intellichem;
sys.configVersion.options = ver.options;
sys.configVersion.pumps = ver.pumps;
sys.configVersion.remotes = ver.remotes;
sys.configVersion.schedules = ver.schedules;
sys.configVersion.security = ver.security;
sys.configVersion.valves = ver.valves;
}
}
private async requestVersionsAsync(dest: number): Promise {
const registrationAddress = this.getRegistrationAddress();
const verReq = Outbound.create({
source: registrationAddress,
dest,
action: 228,
scope: sys.equipment.isIntellicenterV3 ? 'v3VersionSync' : undefined,
payload: [0],
retries: 3,
// v3.004+: require the version response (164) to be addressed to us (not to Wireless).
response: sys.equipment.isIntellicenterV3
? Response.create({ dest: registrationAddress, action: 164 })
: Response.create({ action: 164 })
});
await verReq.sendAsync();
if (sys.equipment.isIntellicenterV3) {
// For v3, wireless/ICP ACKs 164 back to OCP (always unicast to 16).
await Outbound.create({ source: registrationAddress, dest: 16, action: 1, payload: [164], retries: 0 }).sendAsync();
}
}
public async stopAsync() {
this.stopAnnounceDeviceInterval();
this.stopStatePoll();
this._registrationBootstrapStarted = false;
this._runtimeRegistrationAddress = undefined;
this._configQueue.close();
return super.stopAsync();
}
private _v3ValueMapsApplied = false;
public applyV3ValueMapOverrides(): void {
if (this._v3ValueMapsApplied) return;
if (!sys.equipment.isIntellicenterV3) return;
this._v3ValueMapsApplied = true;
this.valueMaps.circuitFunctions.merge([
[11, { name: 'floorcleaner', desc: 'Floor Cleaner 1', body: 2 }]
]);
}
public getAlertDefinitions(): { [key: string]: { bit: number; name: string; desc: string }[] } {
return {
circuits: [
{ bit: 0, name: 'valveRotationDelay', desc: 'Valve Rotation Delay' },
{ bit: 1, name: 'heaterCooldownDelay', desc: 'Heater Cooldown Delay' }
],
pumps: [
{ bit: 0, name: 'driveTemperature', desc: 'Drive Temperature' },
{ bit: 1, name: 'primingAlarm', desc: 'Priming Alarm' },
{ bit: 2, name: 'driveOverTemperature', desc: 'Drive Over Temperature' },
{ bit: 3, name: 'powerOutage', desc: 'Power Outage' },
{ bit: 4, name: 'overCurrent', desc: 'Over Current' },
{ bit: 5, name: 'overVoltage', desc: 'Over Voltage' },
{ bit: 6, name: 'communicationLost', desc: 'Communication Lost' },
{ bit: 7, name: 'rateAndPower', desc: 'Pentair VS/VF/VSF Rate and Power' }
],
ultratemp: [
{ bit: 0, name: 'brownout', desc: 'Brownout' },
{ bit: 1, name: 'highRefrigerantLevel', desc: 'High Refrigerant Level' },
{ bit: 2, name: 'lowRefrigerantLevel', desc: 'Low Refrigerant Level' },
{ bit: 3, name: 'fiveAlarmsInAnHour', desc: 'Five Alarms in an hour' },
{ bit: 4, name: 'lowAmbientTemperature', desc: 'Low Ambient Temperature' },
{ bit: 5, name: 'highWaterTemperature', desc: 'High Water Temperature' },
{ bit: 6, name: 'lowWaterTemperature', desc: 'Low Water Temperature' },
{ bit: 7, name: 'lowWaterFlow', desc: 'Low Water Flow' },
{ bit: 8, name: 'poolSpaRemoteInputsBothEnabled', desc: 'Pool and Spa remote inputs are both enabled' },
{ bit: 9, name: 'waterTempSensorOpen', desc: 'Water Temperature Sensor Open' },
{ bit: 10, name: 'waterTempSensorShorted', desc: 'Water Temperature Sensor shorted' },
{ bit: 11, name: 'defrostTempSensorOpen', desc: 'Defrost Temperature Sensor Open' },
{ bit: 12, name: 'defrostTempSensorShorted', desc: 'Defrost Temperature Sensor shorted' },
{ bit: 13, name: 'communicationLost', desc: 'Communication Lost' }
],
chlorinator: [
{ bit: 0, name: 'lowSaltWarning', desc: 'Low Salt Warning' },
{ bit: 1, name: 'veryLowSaltWarning', desc: 'Very Low Salt Warning' },
{ bit: 2, name: 'cleanAndInspectAlarm', desc: 'Clean and Inspect Alarm' },
{ bit: 3, name: 'coldWaterCutoffAlarm', desc: 'Cold Water Cutoff Alarm' },
{ bit: 4, name: 'communicationLost', desc: 'Communication Lost' },
{ bit: 5, name: 'noFlow', desc: 'No Flow' }
],
intellichem: [
{ bit: 0, name: 'noFlow', desc: 'No Flow' },
{ bit: 1, name: 'phHigh', desc: 'pH High' },
{ bit: 2, name: 'phLow', desc: 'pH Low' },
{ bit: 3, name: 'orpHigh', desc: 'ORP High' },
{ bit: 4, name: 'orpLow', desc: 'ORP Low' },
{ bit: 5, name: 'checkPhChemicalContainer', desc: 'Check pH Chemical Container' },
{ bit: 6, name: 'checkOrpChemicalContainer', desc: 'Check ORP Chemical Container' },
{ bit: 7, name: 'sanitizerLockedOut', desc: 'Sanitizer Locked Out' },
{ bit: 8, name: 'phAtFeedLimit', desc: 'pH at Feed Limit' },
{ bit: 9, name: 'orpAtFeedLimit', desc: 'ORP at Feed Limit' },
{ bit: 10, name: 'invalidSettings', desc: 'Invalid Settings' },
{ bit: 11, name: 'peripheralCommError', desc: 'Peripheral Comm Error' },
{ bit: 12, name: 'autoCalibrationFailed', desc: 'Auto Calibration Failed' },
{ bit: 13, name: 'communicationLost', desc: 'Communication Lost' },
{ bit: 14, name: 'flowDelayOn', desc: 'Flow Delay ON' },
{ bit: 15, name: 'phModeDoseMixMonitor', desc: 'pH Mode: Dose/Mix/Monitor' },
{ bit: 16, name: 'orpModeDoseMixMonitor', desc: 'ORP Mode: Dose/Mix/Monitor' }
],
hybrid: [
{ bit: 0, name: 'airFlowSwitch', desc: 'Air Flow Switch' },
{ bit: 1, name: 'icmFault', desc: 'ICM Fault' },
{ bit: 2, name: 'automaticGasShutOff', desc: 'Automatic Gas Shut Off' },
{ bit: 3, name: 'stackFlueHighTemp', desc: 'Stack Flue High Temp' },
{ bit: 4, name: 'stackFlueOpenShort', desc: 'Stack Flue Open/Short' },
{ bit: 5, name: 'stackFlueRunaway', desc: 'Stack Flue Runaway' },
{ bit: 6, name: 'freezeWarning', desc: 'Freeze Warning' },
{ bit: 7, name: 'condensateFilter', desc: 'Condensate Filter' },
{ bit: 8, name: 'brownout', desc: 'Brownout' },
{ bit: 9, name: 'highRefrigerantLevel', desc: 'High Refrigerant Level' },
{ bit: 10, name: 'lowRefrigerantLevel', desc: 'Low Refrigerant Level' },
{ bit: 11, name: 'fiveAlarmsInAnHour', desc: 'Five Alarms in an hour' },
{ bit: 12, name: 'lowAmbientTemperature', desc: 'Low Ambient Temperature' },
{ bit: 13, name: 'condensateFloatSwitch', desc: 'Condensate Float Switch' },
{ bit: 14, name: 'thermalFuse', desc: 'Thermal Fuse' },
{ bit: 15, name: 'highLimitSwitch', desc: 'High Limit Switch' },
{ bit: 16, name: 'highWaterTemperature', desc: 'High Water Temperature' },
{ bit: 17, name: 'lowWaterTemperature', desc: 'Low Water Temperature' },
{ bit: 18, name: 'lowWaterFlow', desc: 'Low Water Flow' },
{ bit: 19, name: 'waterTempSensorOpenShort', desc: 'Water Temperature Sensor Open/Short' },
{ bit: 20, name: 'suctionTempSensorOpenShort', desc: 'Suction Temperature Sensor Open/Short' },
{ bit: 21, name: 'communicationLost', desc: 'Communication Lost' }
],
connectedGas: [
{ bit: 0, name: 'waterPressureSwitch', desc: 'Water Pressure Switch' },
{ bit: 1, name: 'highLimitSwitch', desc: 'High Limit Switch' },
{ bit: 2, name: 'airFlowSwitch', desc: 'Air Flow Switch' },
{ bit: 3, name: 'autoGasShutoffSwitch', desc: 'Auto Gas Shutoff Switch' },
{ bit: 4, name: 'ignitionControlError', desc: 'Ignition Control Error' },
{ bit: 5, name: 'stackFlueSensorErrorAlarm', desc: 'Stack Flue Sensor Error Alarm' },
{ bit: 6, name: 'stackFlueSensorOpenAlarm', desc: 'Stack Flue Sensor Open Alarm' },
{ bit: 7, name: 'stackFlueSensorShortAlarm', desc: 'Stack Flue Sensor Short Alarm' },
{ bit: 8, name: 'waterSensorOpenAlarm', desc: 'Water Sensor Open Alarm' },
{ bit: 9, name: 'waterSensorShortAlarm', desc: 'Water Sensor Short Alarm' },
{ bit: 10, name: 'airFlowFaultAlarm', desc: 'Air Flow Fault Alarm' },
{ bit: 11, name: 'flameNoCallForHeatAlarm', desc: 'Flame No Call For Heat Alarm' },
{ bit: 12, name: 'ignitionLockoutAlarm', desc: 'Ignition Lockout Alarm' },
{ bit: 13, name: 'weakFlameAlarm', desc: 'Weak Flame Alarm' },
{ bit: 14, name: 'communicationLost', desc: 'Communication Lost' }
]
};
}
public initExpansionModules(ocp0A: number, ocp0B: number, xcp1A: number, xcp1B: number, xcp2A: number, xcp2B: number, xcp3A: number, xcp3B: number) {
state.equipment.controllerType = 'intellicenter';
let inv = { bodies: 0, circuits: 0, valves: 0, shared: false, dual: false, covers: 0, chlorinators: 0, chemControllers: 0 };
this.processMasterModules(sys.equipment.modules, ocp0A, ocp0B, inv);
// Here we need to set the start id should we have a single body system.
if (!inv.shared && !inv.dual) { sys.board.equipmentIds.circuits.start = 2; } // We are a single body system.
this.processExpansionModules(sys.equipment.expansions.getItemById(1, true), xcp1A, xcp1B, inv);
this.processExpansionModules(sys.equipment.expansions.getItemById(2, true), xcp2A, xcp2B, inv);
this.processExpansionModules(sys.equipment.expansions.getItemById(3, true), xcp3A, xcp3B, inv);
if (inv.bodies !== sys.equipment.maxBodies ||
inv.circuits !== sys.equipment.maxCircuits ||
inv.chlorinators !== sys.equipment.maxChlorinators ||
inv.chemControllers !== sys.equipment.maxChemControllers ||
inv.valves !== sys.equipment.maxValves) {
sys.resetData();
this.processMasterModules(sys.equipment.modules, ocp0A, ocp0B);
this.processExpansionModules(sys.equipment.expansions.getItemById(1, true), xcp1A, xcp1B);
this.processExpansionModules(sys.equipment.expansions.getItemById(2, true), xcp2A, xcp2B);
this.processExpansionModules(sys.equipment.expansions.getItemById(3, true), xcp3A, xcp3B);
}
sys.equipment.maxBodies = inv.bodies;
sys.equipment.maxValves = inv.valves;
sys.equipment.maxCircuits = inv.circuits;
sys.equipment.maxChlorinators = inv.chlorinators;
sys.equipment.maxChemControllers = inv.chemControllers;
sys.equipment.shared = inv.shared;
sys.equipment.dual = inv.dual;
sys.equipment.single = (inv.shared === false && inv.dual === false);
sys.equipment.maxPumps = 16;
sys.equipment.maxLightGroups = 40;
sys.equipment.maxCircuitGroups = 16;
sys.equipment.maxSchedules = 100;
sys.equipment.maxFeatures = 32;
state.equipment.maxBodies = sys.equipment.maxBodies;
state.equipment.maxCircuitGroups = sys.equipment.maxCircuitGroups;
state.equipment.maxCircuits = sys.equipment.maxCircuits;
state.equipment.maxFeatures = sys.equipment.maxFeatures;
state.equipment.maxHeaters = sys.equipment.maxHeaters;
state.equipment.maxLightGroups = sys.equipment.maxLightGroups;
state.equipment.maxPumps = sys.equipment.maxPumps;
state.equipment.maxSchedules = sys.equipment.maxSchedules;
state.equipment.maxValves = sys.equipment.maxValves;
state.equipment.single = sys.equipment.single;
state.equipment.shared = sys.equipment.shared;
state.equipment.dual = sys.equipment.dual;
//let pb = sys.equipment.modules.getItemById(0);
//if (pb.type === 0 || pb.type > 7)
// sys.equipment.model = 'IntelliCenter i5P';
//else
// sys.equipment.model = 'IntelliCenter ' + pb.name;
state.equipment.model = sys.equipment.model;
sys.equipment.shared || sys.equipment.dual ? sys.board.equipmentIds.circuits.start = 1 : sys.board.equipmentIds.circuits.start = 2;
// Ensure the body collections are materialized up to maxBodies so shared systems always have Body2 (Spa)
// even before we receive name/config payloads.
try {
for (let id = 1; id <= sys.equipment.maxBodies; id++) {
const body = sys.bodies.getItemById(id, true);
body.isActive = true;
state.temps.bodies.getItemById(id, true);
}
// For shared-body IntelliCenter, Spa is always circuit 1.
if (sys.equipment.shared === true && sys.equipment.maxBodies >= 2) {
const spa = sys.bodies.getItemById(2, true);
if (typeof spa.circuit !== 'number' || spa.circuit <= 0) spa.circuit = 1;
}
} catch (e) { /* best-effort */ }
sys.board.heaters.initTempSensors();
(async () => {
try { sys.board.bodies.initFilters(); } catch (err) {
logger.error(`Error initializing IntelliCenter Filters`);
}
})();
this.modulesAcquired = true;
sys.equipment.master = 0;
sys.general.master = 0;
sys.general.location.master = 0;
sys.general.owner.master = 0;
sys.general.options.master = 0;
for (let i = 0; i < sys.circuits.length; i++) {
let c = sys.circuits.getItemByIndex(i);
if (c.id <= 40) c.master = 0;
if (typeof sys.board.valueMaps.circuitFunctions.get(c.type).isLight) {
let s = state.circuits.getItemById(c.id);
if (s.action !== 0) s.action = 0;
}
}
for (let i = 0; i < sys.valves.length; i++) {
let v = sys.valves.getItemByIndex(i);
if (v.id < 50) v.master = 0;
}
for (let i = 0; i < sys.bodies.length; i++) {
let b = sys.bodies.getItemByIndex(i);
b.master = 0;
}
ncp.initAsync(sys);
// Update heater services BEFORE config loading so the heatModes valueMap has all heater types
// (UltraTemp, etc.) from cached poolConfig.json. This ensures OptionsMessage can correctly
// transform heat mode values when processing options config.
sys.board.heaters.updateHeaterServices();
// Clear options version so startup always requests fresh heat modes/setpoints.
// OCP may not increment options version when Wireless makes changes while njsPC is offline,
// so we force a refresh (same logic as triggerConfigRefresh in VersionMessage.ts).
sys.configVersion.options = 0;
if (sys.valves.length === 0 && sys.equipment.maxValves > 0) sys.configVersion.valves = 0;
if (sys.schedules.length === 0) sys.configVersion.schedules = 0;
// Defer to the next tick so that any state extracted from the same inbound packet
// (e.g., firmware bytes from Action 204) is available before we decide v1 vs v3 behavior.
setTimeout(() => this.checkConfiguration(), 0);
// Start v3 announce loop once we're initialized/running.
this.startAnnounceDeviceInterval();
this.startStatePoll();
}
public processMasterModules(modules: ExpansionModuleCollection, ocpA: number, ocpB: number, inv?) {
// Map the expansion panels to their specific types through the valuemaps. Sadly this means that
// we need to determine if anything needs to be removed or added before actually doing it.
if (typeof inv === 'undefined') inv = { bodies: 0, circuits: 0, valves: 0, shared: false, covers: 0, chlorinators: 0, chemControllers: 0 };
// v3.004+ moved slot encoding such that slot0 is the HIGH nibble and slot1 is the LOW nibble.
// v1.064: ocpA = 0x05 (0000 0101) → slot0 = 5 (low), slot1 = 0 (high)
// v3.004: ocpA = 0x50 (0101 0000) → slot0 = 5 (high), slot1 = 0 (low)
// v3.004: ocpA = 0x58 (0101 1000) → slot0 = 5 (high), slot1 = 8 (low)
// Prefer firmware-gated v3 decoding, but also auto-detect v3 encoding using the protocol constraint
// that master slot0 must be a personality card (1-7), while expansion boards like valve-exp (8) cannot be in slot0.
const hi = (ocpA & 0xF0) >> 4;
const lo = (ocpA & 0x0F);
let useV3Order = sys.equipment.isIntellicenterV3;
if (!useV3Order) {
// If HIGH nibble looks like a personality card and LOW nibble is either empty (0) or non-personality (>7),
// treat this as v3 encoding even if the firmware gate isn't established yet.
if (hi >= 1 && hi <= 7 && (lo === 0 || lo > 7)) useV3Order = true;
}
let slot0 = useV3Order ? hi : lo;
let slot1 = useV3Order ? lo : hi;
let slot2 = (ocpB & 0xF0) >> 4;
let slot3 = ocpB & 0xF;
// Slot 0 always has to have a personality card.
// This is an i5P. There is nothing here so the MB is the personality board.
let mod = modules.getItemById(0, true);
let mt = this.valueMaps.expansionBoards.transform(slot0);
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = slot0;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (mod.type === 0 || mod.type > 7)
sys.equipment.model = 'IntelliCenter i5P';
else
sys.equipment.model = 'IntelliCenter ' + mod.name;
state.equipment.model = sys.equipment.model;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
if (typeof mt.single !== 'undefined') inv.single = mt.single;
if (typeof mt.shared !== 'undefined') inv.shared = mt.shared;
if (typeof mt.dual !== 'undefined') inv.dual = mt.dual;
if (slot1 === 0) modules.removeItemById(1);
else {
let mod = modules.getItemById(1, true);
let mt = this.valueMaps.expansionBoards.transform(slot1);
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = slot1;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
}
if (slot2 === 0) modules.removeItemById(2);
else {
let mod = modules.getItemById(2, true);
let mt = this.valueMaps.expansionBoards.transform(slot2);
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = slot2;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
}
if (slot3 === 0) modules.removeItemById(3);
else {
let mod = modules.getItemById(3, true);
let mt = this.valueMaps.expansionBoards.transform(slot3);
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = slot3;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
}
}
public processExpansionModules(panel: ExpansionPanel, ocpA: number, ocpB: number, inv?) {
// Map the expansion panels to their specific types through the valuemaps. Sadly this means that
// we need to determine if anything needs to be removed or added before actually doing it.
let modules: ExpansionModuleCollection = panel.modules;
if (typeof inv === 'undefined') inv = { bodies: 0, circuits: 0, valves: 0, shared: false, covers: 0, chlorinators: 0, chemControllers: 0 };
// v3.008 uses a different expansion-panel encoding than v1/v2. Observed on Matthew's
// i10D + 2x i10X capture (Discussion #1171 / ISSUE-081):
// byte15 = 0x02 (EXP1 populated with i10X)
// byte17 = 0x00 (EXP2 empty)
// byte19 = 0x02 (EXP3 populated with i10X)
// The byte is NOT nibble-packed like the master byte — the LOW byte carries a single
// expansion-panel wire id where 0x02 = i10X (valueMap id 6, named 'i10x'). ocpB is
// currently unused on observed v3 captures (always 0x00). Route v3 through a dedicated
// decoder so we don't inherit the v1 nibble/slot shape that doesn't fit the wire data.
if (sys.equipment.isIntellicenterV3) {
this.processExpansionModulesV3(panel, ocpA, ocpB, inv);
return;
}
// v1/v2: expansion panel slot encoding matches the master panel (slot0 is HIGH nibble on v3 masters,
// but v1 had slot0 in the LOW nibble). Keep the original auto-detect so any captured v1 expansion
// traffic continues to decode exactly as before.
const hi = (ocpA & 0xF0) >> 4;
const lo = (ocpA & 0x0F);
let useV3Order = false;
// If HIGH nibble looks like a valid expansion card (3-7) and LOW nibble is either empty (0) or non-personality (>7),
// treat this as v3 encoding even if the firmware gate isn't established yet.
if (hi >= 3 && hi <= 7 && (lo === 0 || lo > 7)) useV3Order = true;
let slot0 = useV3Order ? hi : lo;
let slot1 = useV3Order ? lo : hi;
let slot2 = (ocpB & 0xF0) >> 4;
let slot3 = ocpB & 0xF;
// Slot 0 always has to have a personality card but on an expansion module it cannot be 0. At this point we only know that an i10x = 6 for slot 0.
if (slot0 <= 2) {
modules.removeItemById(0);
panel.isActive = false;
}
else {
let mod = modules.getItemById(0, true);
let mt = slot0 === 6 ? this.valueMaps.expansionBoards.transform(slot0) : this.valueMaps.expansionBoards.transform(255);
panel.isActive = true;
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = slot0;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.single !== 'undefined') inv.single = mt.single;
if (typeof mt.shared !== 'undefined') inv.shared = mt.shared;
if (typeof mt.dual !== 'undefined') inv.dual = mt.dual;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
}
if (slot1 === 0 || slot0 <= 2) modules.removeItemById(1);
else {
let mod = modules.getItemById(1, true);
let mt = this.valueMaps.expansionBoards.transform(slot1);
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = slot1;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
}
if (slot2 === 0 || slot0 <= 2) modules.removeItemById(2);
else {
let mod = modules.getItemById(2, true);
let mt = this.valueMaps.expansionBoards.transform(slot2);
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = slot2;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
}
if (slot3 === 0 || slot0 <= 2) modules.removeItemById(3);
else {
let mod = modules.getItemById(3, true);
let mt = this.valueMaps.expansionBoards.transform(slot3);
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = slot3;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
}
}
// v3.008+ expansion-panel decode. The wire layout on v3 is not nibble-packed: each expansion byte
// (bytes 15/17/19 of Action 204) carries a single expansion-panel id. Observed values so far:
// 0x00 = empty slot
// 0x02 = i10X expansion panel (valueMap id 6)
// Additional entries can be added as new hardware is observed in the wild. Unknown non-zero values
// are logged and the panel is deactivated so we never silently mis-identify an expansion.
// Discussion #1171 / ISSUE-081.
private static readonly V3_EXPANSION_WIRE_TO_MAP_ID: Record = {
0x02: 6 // i10X
};
private processExpansionModulesV3(panel: ExpansionPanel, ocpA: number, ocpB: number, inv: any) {
let modules: ExpansionModuleCollection = panel.modules;
if (typeof inv === 'undefined') inv = { bodies: 0, circuits: 0, valves: 0, shared: false, covers: 0, chlorinators: 0, chemControllers: 0 };
if (ocpB !== 0) {
logger.debug(`IntelliCenter v3 expansion panel reports ocpB=0x${ocpB.toString(16).padStart(2, '0')}; currently unmapped, ignoring.`);
}
if (ocpA === 0) {
// Empty slot — clear any previously seen modules.
modules.removeItemById(0);
modules.removeItemById(1);
modules.removeItemById(2);
modules.removeItemById(3);
panel.isActive = false;
return;
}
const mapId = IntelliCenterBoard.V3_EXPANSION_WIRE_TO_MAP_ID[ocpA];
if (typeof mapId === 'undefined') {
logger.warn(`IntelliCenter v3 expansion panel reports unknown wire id 0x${ocpA.toString(16).padStart(2, '0')}; deactivating panel. Please report this value so it can be catalogued.`);
modules.removeItemById(0);
modules.removeItemById(1);
modules.removeItemById(2);
modules.removeItemById(3);
panel.isActive = false;
return;
}
panel.isActive = true;
const mod = modules.getItemById(0, true);
const mt = this.valueMaps.expansionBoards.transform(mapId);
mod.name = mt.name;
mod.desc = mt.desc;
mod.type = mapId;
mod.part = mt.part;
mod.get().bodies = mt.bodies;
mod.get().circuits = mt.circuits;
mod.get().valves = mt.valves;
mod.get().covers = mt.covers;
mod.get().chlorinators = mt.chlorinators;
mod.get().chemControllers = mt.chemControllers;
if (typeof mt.bodies !== 'undefined') inv.bodies += mt.bodies;
if (typeof mt.circuits !== 'undefined') inv.circuits += mt.circuits;
if (typeof mt.valves !== 'undefined') inv.valves += mt.valves;
if (typeof mt.covers !== 'undefined') inv.covers += mt.covers;
if (typeof mt.chlorinators !== 'undefined') inv.chlorinators += mt.chlorinators;
if (typeof mt.chemControllers !== 'undefined') inv.chemControllers += mt.chemControllers;
// v3 layout does not currently populate slots 1-3 on expansion panels.
modules.removeItemById(1);
modules.removeItemById(2);
modules.removeItemById(3);
}
public async setSecurityRoleAsync(obj: any): Promise {
let roleId = typeof obj.id !== 'undefined' ? parseInt(obj.id, 10) : -1;
if (isNaN(roleId) || roleId < 1 || roleId > 9) return Promise.reject(new InvalidEquipmentIdError(`Invalid security role id: ${obj.id}`, obj.id, 'securityRole'));
let role = sys.security.roles.getItemById(roleId, false);
let item = roleId - 1;
let name = typeof obj.name !== 'undefined' ? obj.name.toString().substring(0, 16) : (role ? role.name : '');
let pin = typeof obj.pin !== 'undefined' ? obj.pin.toString().replace(/\D/g, '').padStart(4, '0').substring(0, 4) : (role ? role.pin : '0000');
let pinNum = parseInt(pin, 10) || 0;
let timeout = typeof obj.timeout !== 'undefined' ? Math.max(1, Math.min(10, parseInt(obj.timeout, 10) || 5)) : (role ? role.timeout : 5);
let permBytes = Array.isArray(obj.permissionsBytes) ? obj.permissionsBytes.slice(0, 4) : (role ? role.permissionsBytes : [0, 0, 0, 0]);
while (permBytes.length < 4) permBytes.push(0);
if (roleId === 1) {
if (typeof obj.enabled !== 'undefined') {
if (obj.enabled) permBytes[3] = permBytes[3] | 0x80;
else permBytes[3] = permBytes[3] & ~0x80;
}
if (typeof obj.guestEnabled !== 'undefined') {
if (obj.guestEnabled) permBytes[3] = permBytes[3] | 0x40;
else permBytes[3] = permBytes[3] & ~0x40;
}
}
let payload: number[] = [11, item, item, (pinNum >> 8) & 0xFF, pinNum & 0xFF];
let nameBytes: number[] = [];
for (let i = 0; i < 16; i++) nameBytes.push(i < name.length ? name.charCodeAt(i) : 0);
payload.push(...nameBytes);
payload.push(permBytes[0] & 0xFF, permBytes[1] & 0xFF, permBytes[2] & 0xFF, permBytes[3] & 0xFF);
payload.push(timeout & 0xFF);
payload.push(0xFF, 0xFF, 0xFF);
let out = Outbound.create({
action: 168,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
await out.sendAsync();
if (role) {
role.name = name;
role.pin = pin;
role.timeout = timeout;
role.permissionsBytes = permBytes;
role.permissionsMask = ((permBytes[0] & 0xFF) * 16777216) + ((permBytes[1] & 0xFF) * 65536) + ((permBytes[2] & 0xFF) * 256) + (permBytes[3] & 0xFF);
if (item === 0) {
sys.security.enabledByte = permBytes[3];
sys.security.enabled = (permBytes[3] & 0x80) === 0x80;
sys.security.guestEnabled = (permBytes[3] & 0x40) === 0x40;
}
}
try {
let verifyReq = Outbound.create({
action: 222,
payload: [11, item],
response: Response.create({ action: 168 }),
retries: 3
});
await verifyReq.sendAsync();
} catch (err) { logger.warn(`Security role verify read failed: ${err.message}`); }
return sys.security.get(true);
}
public get commandSourceAddress(): number { return this.getRegistrationAddress(); }
public get commandDestAddress(): number { return sys.equipment.isIntellicenterV3 ? 16 : 15; }
public static getAckResponse(action: number, source?: number, dest?: number): Response { return Response.create({ source: source, dest: dest || sys.board.commandSourceAddress, action: 1, payload: [action] }); }
}
class IntelliCenterConfigRequest extends ConfigRequest {
constructor(cat: number, ver: number, items?: number[], oncomplete?: Function) {
super();
this.category = cat;
this.version = ver;
if (typeof items !== 'undefined') this.items.push(...items);
this.oncomplete = oncomplete;
}
declare category: ConfigCategories;
}
class IntelliCenterConfigQueue extends ConfigQueue {
public _processing: boolean = false;
public _newRequest: boolean = false;
public _failed: boolean = false;
private _savedFirmwareVersion: string = sys.equipment.controllerFirmware || '';
private static readonly WATCHDOG_TIMEOUT_MS = 120000;
private static readonly WATCHDOG_POLL_MS = 5000;
private _watchdogTimer?: NodeJS.Timeout;
private _lastProgressMs: number = 0;
private _maxPercentEmitted: number = 0;
private _epoch: number = 0;
public close() {
this.stopWatchdog();
this._processing = false;
this._maxPercentEmitted = 0;
super.close();
}
public abort(): void {
this._epoch++;
this.queue.length = 0;
this.curr = null;
this.totalItems = 0;
this._processing = false;
this._newRequest = false;
this._failed = false;
this._maxPercentEmitted = 0;
this.stopWatchdog();
}
private getDisplayPercent(): number {
this._maxPercentEmitted = Math.max(this._maxPercentEmitted, this.percent);
return this._maxPercentEmitted;
}
private markProgress(): void {
if (!this._processing) return;
this._lastProgressMs = Date.now();
if (!this._watchdogTimer) {
this._watchdogTimer = setInterval(() => this.checkWatchdog(), IntelliCenterConfigQueue.WATCHDOG_POLL_MS);
}
}
private stopWatchdog(): void {
if (this._watchdogTimer) {
clearInterval(this._watchdogTimer);
this._watchdogTimer = undefined;
}
this._lastProgressMs = 0;
}
private checkWatchdog(): void {
if (!this._processing || this.closed) {
this.stopWatchdog();
return;
}
const elapsed = Date.now() - this._lastProgressMs;
if (elapsed < IntelliCenterConfigQueue.WATCHDOG_TIMEOUT_MS) return;
logger.warn(`Config queue watchdog timed out after ${elapsed}ms; forcing recovery (${this.remainingItems} items remaining)`);
this._epoch++;
this.queue.length = 0;
this.curr = null;
this.totalItems = 0;
this._processing = false;
this._failed = false;
this._newRequest = false;
this._maxPercentEmitted = 0;
this.stopWatchdog();
state.status = 1;
state.emitControllerChange();
setTimeout(() => { sys.board.checkConfiguration(); }, 250);
}
public processNext(msg?: Outbound, epoch?: number) {
if (this.closed) return;
if (typeof epoch === 'number' && epoch !== this._epoch) return;
let self = this;
if (typeof msg !== 'undefined' && msg !== null) {
this.markProgress();
if (!msg.failed) {
// Remove all references to future items. We got it so we don't need it again.
this.removeItem(msg.payload[0], msg.payload[1]);
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') {
const beforeCount = this.curr.items.length;
this.curr.oncomplete(this.curr);
const addedItems = this.curr.items.length - beforeCount;
if (addedItems > 0) {
this.totalItems += addedItems;
}
this.curr.oncomplete = undefined;
}
// Let the process add in any additional information we might need. When it does
// this it will set the isComplete flag to false.
if (this.curr.isComplete) {
sys.configVersion[ConfigCategories[this.curr.category]] = this.curr.version;
}
} else {
// We failed to get the data. Let the system retry when
// we are done with the queue.
sys.configVersion[ConfigCategories[this.curr.category]] = 0;
}
}
}
else this._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;
this._maxPercentEmitted = 0;
state.emitControllerChange();
return;
} else
state.status = sys.board.valueMaps.controllerStatus.transform(2, this.getDisplayPercent());
// Shift to the next config queue item.
while (
this.queue.length > 0 &&
this.curr.isComplete
) {
this.curr = this.queue.shift() || null;
}
let itm = 0;
if (this.curr && !this.curr.isComplete) {
itm = this.curr.items.shift();
// RKS: Acks can sometimes conflict if there is another panel at the plugin address
// this used to send a 30 Ack when it received its response but it appears that is
// any other panel is awake at the same address it may actually collide with it
// as both boards are processing at the same time and sending an outbound ack.
const dest = sys.equipment.isIntellicenterV3 ? 16 : 15;
let out = Outbound.create({
dest,
action: 222, payload: [this.curr.category, itm], retries: 3,
response: sys.equipment.isIntellicenterV3
? Response.create({ dest: -1, action: 30, payload: [this.curr.category, itm] })
: Response.create({ dest: -1, action: 30, payload: [this.curr.category, itm] })
});
logger.verbose(`Requesting config for: ${ConfigCategories[this.curr.category]} - Item: ${itm}`);
this.markProgress();
const runEpoch = this._epoch;
out.sendAsync()
.then(() => {
//logger.debug(`msg ${out.toShortPacket()} sent successfully`);
})
.catch((err) => {
logger.error(`Error sending configuration request message on port ${out.portId}: ${err.message};`);
})
.finally(() => {
setTimeout(() => { self.processNext(out, runEpoch); }, 10);
})
} 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;
this._processing = false;
this._maxPercentEmitted = 0;
this.stopWatchdog();
// ISSUE-121: Do NOT auto-retry on _failed for v3. Items that fail are typically
// unsupported by the v3 firmware (not transient errors), so re-running creates an
// endless re-queue cycle. The next legitimate version-change broadcast will pick
// up real changes via normal compareVersions flow. Reset _failed without retrying.
if (this._failed && !sys.equipment.isIntellicenterV3) {
setTimeout(() => { sys.checkConfiguration(); }, 100);
}
this._failed = false;
logger.info(`Configuration Complete`);
sys.board.heaters.updateHeaterServices();
// Re-apply current body heat modes through the normal setter so state re-transforms
// against the (possibly rebuilt) heatModes valueMap.
for (let i = 0; i < state.temps.bodies.length; i++) {
const b = state.temps.bodies.getItemByIndex(i);
const hm = b.heatMode;
// Startup-only: the numeric mode can be correct while the persisted {name,desc} is stale.
if (hm >= 0) {
// Don't touch internal state; force the setter to re-transform by toggling to a
// different value and then restoring.
const tmp = hm === 0 ? 1 : 0;
b.heatMode = tmp;
b.heatMode = hm;
}
}
state.cleanupState();
}
// Notify all the clients of our processing status.
state.emitControllerChange();
}
public queueChanges(ver: ConfigVersion) {
let curr: ConfigVersion = sys.configVersion;
// Detect firmware version change (e.g., v1→v3). The constructor captured
// the persisted equipment.softwareVersion before Action 204 overwrites it.
// If the live firmware differs, categories with version-specific parsing
// must be re-fetched even when their OCP version numbers haven't changed.
const currentFw = sys.equipment.controllerFirmware || '';
const fwChanged = currentFw !== this._savedFirmwareVersion && currentFw.length > 0;
if (fwChanged) {
logger.info(`Firmware version changed (${this._savedFirmwareVersion || 'unknown'} → ${currentFw}), forcing config refresh for affected categories`);
curr.circuits = 0;
curr.options = 0;
curr.general = 0;
curr.schedules = 0;
curr.pumps = 0;
this._savedFirmwareVersion = currentFw;
}
if (this._processing) {
if (curr.hasChanges(ver)) this._newRequest = true;
if (sys.configVersion.lastUpdated.getTime() > new Date().getTime() - 90000)
console.log('WE ARE ALREADY PROCESSING CHANGES...')
return;
}
// IMPORTANT: Only enter "processing" mode if there are actual version changes.
// If we set `_processing=true` and then return early, the UI can get stuck showing a partial
// percent (e.g., 87%) because no further progress/completion events will be emitted.
if (!curr.hasChanges(ver)) {
// Ensure controller status returns to ready and queue state is not wedged.
this._processing = false;
this._failed = false;
this._newRequest = false;
this._maxPercentEmitted = 0;
this.stopWatchdog();
state.status = 1;
state.emitControllerChange();
return;
}
// New run: reset per-run accounting so percent reflects ONLY this run.
// Do NOT call `ConfigQueue.reset()` here because it also mutates `closed`.
// We only want to reset per-run counters/queues.
this.queue.length = 0;
this.curr = null;
this.totalItems = 0;
this._maxPercentEmitted = 0;
this._processing = true;
this._failed = false;
this.markProgress();
let self = this;
sys.configVersion.lastUpdated = new Date();
// Tell the system we are loading.
state.status = sys.board.valueMaps.controllerStatus.transform(2, 0);
// Alert notification pages 12-15 (circuit/pump/heater/chlorinator) were added to
// OCP firmware in the v3.004+ line. IntelliCenter v1.x firmware (e.g. v1.064) does
// not respond to Action 222 requests for these pages, so polling them causes a
// retry/abort loop that resets `configVersion.equipment` to 0 and re-queues every
// cycle. On v1.x rely on the Action 168 push path in ExternalMessage instead. #1172
const equipmentItems = sys.equipment.isIntellicenterV3
? [0, 1, 2, 3]
: [0, 1, 2, 3];
this.maybeQueueItems(curr.equipment, ver.equipment, ConfigCategories.equipment, equipmentItems);
this.maybeQueueItems(curr.options, ver.options, ConfigCategories.options, [0, 1]);
if (this.compareVersions(curr.circuits, ver.circuits)) {
let req = new IntelliCenterConfigRequest(ConfigCategories.circuits, ver.circuits, [0, 1, 2],
function (req: IntelliCenterConfigRequest) {
// Only add in the items that we need.
req.fillRange(3, Math.min(Math.ceil(sys.equipment.maxCircuits / 2) + 3, 24));
req.fillRange(26, 29);
});
this.push(req);
}
if (this.compareVersions(curr.features, ver.features)) {
let req = new IntelliCenterConfigRequest(ConfigCategories.features, ver.features, [0, 1, 2, 3, 4, 5, 22]);
// Only add in the items that we need for now. We will queue the optional packets later. The first 6 packets and 22
// are required but we can reduce the number of names returned by only requesting the data after the names have been processed.
req.oncomplete = function (req: IntelliCenterConfigRequest) {
let maxId = sys.features.getMaxId(true, 0) - sys.board.equipmentIds.features.start + 1;
// We only need to get the feature names required. This will fill these after we know we have them.
if (maxId > 0) req.fillRange(6, Math.min(Math.ceil(maxId / 2) + 6, 21));
};
this.push(req);
}
if (this.compareVersions(curr.pumps, ver.pumps)) {
let req = new IntelliCenterConfigRequest(ConfigCategories.pumps, ver.pumps, [4],
function (req: IntelliCenterConfigRequest) {
// Get the pump names after we have acquire the active pumps. We only need
// the names of the active pumps.
let maxPumpId = sys.pumps.getMaxId(true, 0) - sys.board.equipmentIds.pumps.start + 1;
if (maxPumpId > 0) req.fillRange(19, Math.min(Math.ceil(maxPumpId / 2) + 19, 26));
});
req.fillRange(0, 3);
req.fillRange(5, Math.min(Math.ceil(sys.equipment.maxPumps / 2) + 5, 18));
this.push(req);
}
this.maybeQueueItems(curr.security, ver.security, ConfigCategories.security, [0, 1, 2, 3, 4, 5, 6, 7, 8]);
if (this.compareVersions(curr.remotes, ver.remotes)) {
let req = new IntelliCenterConfigRequest(ConfigCategories.remotes, ver.remotes, [0, 1], function (req: IntelliCenterConfigRequest) {
if (sys.remotes.length > 2) req.fillRange(2, sys.remotes.length - 1);
});
this.push(req);
}
if (this.compareVersions(curr.circuitGroups, ver.circuitGroups)) {
let req = new IntelliCenterConfigRequest(ConfigCategories.circuitGroups, ver.circuitGroups, [32, 33], function (req: IntelliCenterConfigRequest) {
// Only get group attributes for the ones we have defined. The total number of message for all potential groups exceeds 50.
if (sys.circuitGroups.length + sys.lightGroups.length > 0) {
let maxId = (Math.max(sys.circuitGroups.getMaxId(true, 0), sys.lightGroups.getMaxId(true, 0)) - sys.board.equipmentIds.circuitGroups.start) + 1;
req.fillRange(0, maxId); // Associated Circuits
req.fillRange(16, maxId + 16); // Group names and delay
req.fillRange(34, 35); // Egg timer and colors
req.fillRange(36, Math.min(36 + maxId, 50)); // Colors
}
});
this.push(req);
}
this.maybeQueueItems(curr.chlorinators, ver.chlorinators, ConfigCategories.chlorinators, [0]);
if (this.compareVersions(curr.valves, ver.valves)) {
let req = new IntelliCenterConfigRequest(ConfigCategories.valves, ver.valves, [0]);
let totalValves = sys.equipment.maxValves + (sys.equipment.shared ? 2 : 4);
req.fillRange(1, Math.min(Math.ceil(totalValves / 2) + 1, 14));
this.push(req);
}
if (this.compareVersions(curr.intellichem, ver.intellichem)) {
let req = new IntelliCenterConfigRequest(ConfigCategories.intellichem, ver.intellichem, [0, 1]);
this.push(req);
}
if (this.compareVersions(curr.heaters, ver.heaters)) {
let req = new IntelliCenterConfigRequest(ConfigCategories.heaters, ver.heaters, [0, 1, 2, 3, 4],
function (req: IntelliCenterConfigRequest) {
if (sys.heaters.length > 0) {
let maxId = sys.heaters.getMaxId(true, 0);
req.fillRange(5, Math.min(Math.ceil(sys.heaters.getMaxId(true, 0) / 2) + 5, 12)); // Heater names
}
req.fillRange(13, 14);
});
this.push(req);
}
// ISSUE-121: IntelliCenter v3 OCP firmware only responds to general items 0 and 1.
// Requesting items 2-7 burns retries each (~30s total) and sets _failed=true,
// which triggers sys.checkConfiguration() after "Configuration Complete" → endless
// re-queue cycle. Same pattern as ISSUE-077 (equipment items 12-15). Gate to 0-1 on v3.
// RE-CONFIRMED 2026-05-13: Even with internet enabled, Web & Mobile Interface on,
// Pentair account created, and owner data populated on OCP, sub-items 2-7 still
// get no response. Owner/personal info is no longer broadcast on RS-485 in v3.
const generalItems = sys.equipment.isIntellicenterV3 ? [0, 1] : [0, 1, 2, 3, 4, 5, 6, 7];
this.maybeQueueItems(curr.general, ver.general, ConfigCategories.general, generalItems);
this.maybeQueueItems(curr.covers, ver.covers, ConfigCategories.covers, [0, 1]);
if (this.compareVersions(curr.schedules, ver.schedules)) {
// Alright we used to think we could rely on the schedule start time as the trigger that identifies an active schedule. However, active
// schedules are actually determined by looking at the schedule type messages[8-10].
let req = new IntelliCenterConfigRequest(ConfigCategories.schedules, ver.schedules, [8, 9, 10], function (req: IntelliCenterConfigRequest) {
let maxSchedId = sys.schedules.getMaxId();
req.fillRange(5, 5 + Math.min(Math.ceil(maxSchedId / 40), 7)); // Circuits
req.fillRange(11, 11 + Math.min(Math.ceil(maxSchedId / 40), 13)); // Schedule days bitmask
req.fillRange(0, Math.min(Math.ceil(maxSchedId / 40), 4)); // Start Time
req.fillRange(23, 23 + Math.min(Math.ceil(maxSchedId / 20), 26)); // End Time
req.fillRange(14, 14 + Math.min(Math.ceil(maxSchedId / 40), 16)); // Start Month
req.fillRange(17, 17 + Math.min(Math.ceil(maxSchedId / 40), 19)); // Start Day
req.fillRange(20, 20 + Math.min(Math.ceil(maxSchedId / 40), 22)); // Start Year
req.fillRange(28, 28 + Math.min(Math.ceil(maxSchedId / 40), 30)); // Heat Mode
req.fillRange(31, 31 + Math.min(Math.ceil(maxSchedId / 40), 33)); // Heat Mode
req.fillRange(34, 34 + Math.min(Math.ceil(maxSchedId / 40), 36)); // Heat Mode
});
// DEPRECATED: 12-26-21 This was the old order of fetching the schedule. This did not work properly with start times of midnight since the start time of 0
// was previously being used to determine whether the schedule was active. The schedule/time type messages are now being used.
//let req = new IntelliCenterConfigRequest(ConfigCategories.schedules, ver.schedules, [0, 1, 2, 3, 4], function (req: IntelliCenterConfigRequest) {
// let maxSchedId = sys.schedules.getMaxId();
// req.fillRange(5, 5 + Math.min(Math.ceil(maxSchedId / 40), 7)); // Circuits
// req.fillRange(8, 8 + Math.min(Math.ceil(maxSchedId / 40), 10)); // Flags
// req.fillRange(11, 11 + Math.min(Math.ceil(maxSchedId / 40), 13)); // Schedule days bitmask
// req.fillRange(14, 14 + Math.min(Math.ceil(maxSchedId / 40), 16)); // Unknown (one byte per schedule)
// req.fillRange(17, 17 + Math.min(Math.ceil(maxSchedId / 40), 19)); // Unknown (one byte per schedule)
// req.fillRange(20, 20 + Math.min(Math.ceil(maxSchedId / 40), 22)); // Unknown (one byte per schedule)
// req.fillRange(23, 23 + Math.min(Math.ceil(maxSchedId / 20), 26)); // End Time
// req.fillRange(28, 28 + Math.min(Math.ceil(maxSchedId / 40), 30)); // Heat Mode
// req.fillRange(31, 31 + Math.min(Math.ceil(maxSchedId / 40), 33)); // Heat Mode
// req.fillRange(34, 34 + Math.min(Math.ceil(maxSchedId / 40), 36)); // Heat Mode
//});
this.push(req);
}
this.maybeQueueItems(curr.systemState, ver.systemState, ConfigCategories.systemState, [0]);
logger.info(`Queued ${this.remainingItems} configuration items`);
if (this.remainingItems > 0) setTimeout(() => { self.processNext(); }, 50);
else {
this._processing = false;
this.stopWatchdog();
if (this._newRequest) {
this._newRequest = false;
setTimeout(() => { sys.board.checkConfiguration(); }, 250);
}
state.status = 1;
state.equipment.single = sys.equipment.single;
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;
ncp.initAsync(sys);
}
state.emitControllerChange();
//this._needsChanges = false;
//this.data.controllerType = this.controllerType;
}
private compareVersions(curr: number, ver: number): boolean { return !curr || !ver || curr !== ver; }
private maybeQueueItems(curr: number, ver: number, cat: number, opts: number[]) {
if (this.compareVersions(curr, ver)) this.push(new IntelliCenterConfigRequest(cat, ver, opts));
}
}
class IntelliCenterSystemCommands extends SystemCommands {
public async setDateTimeAsync(obj: any): Promise {
if (obj.clockSource === 'internet' || obj.clockSource === 'server' || obj.clockSource === 'manual') sys.general.options.clockSource = obj.clockSource;
Promise.resolve({
time: state.time.format(),
adjustDST: sys.general.options.adjustDST,
clockSource: sys.general.options.clockSource
});
}
public async setGeneralAsync(obj?: any): Promise {
try {
if (typeof obj.alias === 'string' && obj.alias !== sys.general.alias) {
const alias = normalizeIntelliCenterName(obj.alias, sys.general.alias);
let out = Outbound.create({
action: 168,
payload: [12, 0, 0],
retries: 3
}).appendPayloadString(alias, 16);
await out.sendAsync();
sys.general.alias = alias;
}
if (typeof obj.options !== 'undefined') {
try {
if (typeof obj.options.vacation !== 'undefined') {
await (this as any).setVacationAsync(obj.options.vacation);
return sys.general;
}
await sys.board.system.setOptionsAsync(obj.options);
}
catch (err) {
logger.error(`Caught reject from setOptionsAsync`);
return Promise.reject(err);
}
}
if (typeof obj.location !== 'undefined') await sys.board.system.setLocationAsync(obj.location);
if (typeof obj.owner !== 'undefined') await sys.board.system.setOwnerAsync(obj.owner);
return sys.general;
}
catch (err) {
console.log(`Rejected setGeneralAsync: ${err.message}`);
return Promise.reject(err);
}
}
public async setTempSensorsAsync(obj?: any): Promise {
try {
let sensors = {
waterTempAdj1: obj.waterTempAdj1,
waterTempAdj2: obj.waterTempAdj2,
waterTempAdj3: obj.waterTempAdj3,
waterTempAdj4: obj.waterTempAdj4,
airTempAdj: obj.airTempAdj,
solarTempAdj1: obj.solarTempAdj1,
solarTempAdj2: obj.solarTempAdj2,
solarTempAdj3: obj.solarTempAdj3,
solarTempAdj4: obj.solarTempAdj4,
}
await this.setOptionsAsync(sensors); // Map this to the options message as these are one in the same.
return sys.equipment.tempSensors;
}
catch (err) { return Promise.reject(err); }
}
public async cancelDelay(): Promise {
if (sys.equipment.isIntellicenterV3) {
let out = Outbound.create({
action: 168,
retries: 3,
payload: [19, 0, 0],
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
}
state.delay = sys.board.valueMaps.delay.getValue('nodelay');
return state.data.delay;
}
public async setOptionsAsync(obj?: any): Promise {
let fnToByte = function (num) { return num < 0 ? Math.abs(num) | 0x80 : Math.abs(num) || 0; }
const isIntellicenterV3 = (sys.controllerType === ControllerType.IntelliCenter && sys.equipment.isIntellicenterV3);
const encodeFreezeOverride = (minutes: number): number => {
if (isNaN(minutes)) return 0;
// v3.008 appears to encode Frz Override as compact steps: 30 + (raw * 60).
if (minutes <= 30) return 0;
return Math.max(0, Math.min(3, Math.round((minutes - 30) / 60)));
};
const freezeCycleTime = parseInt((sys.general.options.freezeCycleTime || 15).toString(), 10) || 15;
const freezeOverrideRaw = encodeFreezeOverride(parseInt((sys.general.options.freezeOverride || 30).toString(), 10) || 30);
const pool = sys.bodies.getItemById(1, false);
const spa = sys.bodies.getItemById(2, false);
const manualPriorityPayloadIndex = isIntellicenterV3 ? 36 : 39;
const manualHeatPayloadIndex = isIntellicenterV3 ? 37 : 40;
const pumpDelayPayloadIndex = isIntellicenterV3 ? 30 : 30;
const cooldownDelayPayloadIndex = isIntellicenterV3 ? 28 : 31;
let payload = [0, 0, 0,
fnToByte(sys.equipment.tempSensors.getCalibration('water2')),
fnToByte(sys.equipment.tempSensors.getCalibration('water1')),
fnToByte(sys.equipment.tempSensors.getCalibration('solar1')),
fnToByte(sys.equipment.tempSensors.getCalibration('air')),
fnToByte(0), // This might actually be a secondary air sensor but it is not ever set on a shared body.
fnToByte(sys.equipment.tempSensors.getCalibration('solar2')), // 8
// The following contains the bytes for water3&4 and solar3&4. The reason for 5 bytes may be that
// the software jumps over a fake airTemp byte in the sensor arrays.
fnToByte(sys.equipment.tempSensors.getCalibration('solar3')),
fnToByte(sys.equipment.tempSensors.getCalibration('solar4')),
fnToByte(sys.equipment.tempSensors.getCalibration('water3')),
fnToByte(sys.equipment.tempSensors.getCalibration('water4')), 0,
0x10 | (sys.general.options.clockMode === 24 ? 0x40 : 0x00) | (sys.general.options.adjustDST ? 0x80 : 0x00) | (sys.general.options.clockSource === 'internet' ? 0x20 : 0x00), // 14
0, 0,
sys.general.options.clockSource === 'internet' ? 1 : 0, // 17
3, 0, 0,
// For v3.008+, Action 168 full options blocks place pool/spa setpoints at [20..23]
// and modes at [24..25]. Keep legacy layout for pre-v3 controllers.
...(isIntellicenterV3
? [
pool.setPoint || 100, pool.coolSetpoint || (pool.setPoint || 100),
spa.setPoint || 100, spa.coolSetpoint || (spa.setPoint || 100),
pool.heatMode || 0, spa.heatMode || 0,
freezeCycleTime,
sys.general.options.valveDelay ? 1 : 0,
sys.general.options.cooldownDelay ? 1 : 0,
0, 0, 0, 0, 0, 0,
sys.general.options.pumpDelay ? 1 : 0,
sys.general.options.manualPriority ? 1 : 0,
sys.general.options.manualHeat ? 1 : 0,
0, 0, 0
]
: [
sys.bodies.getItemById(1, false).setPoint || 100, // 21
sys.bodies.getItemById(3, false).setPoint || 100,
sys.bodies.getItemById(2, false).setPoint || 100,
sys.bodies.getItemById(4, false).setPoint || 100,
sys.bodies.getItemById(1, false).heatMode || 0,
sys.bodies.getItemById(2, false).heatMode || 0,
sys.bodies.getItemById(3, false).heatMode || 0,
sys.bodies.getItemById(4, false).heatMode || 0,
15,
sys.general.options.pumpDelay ? 1 : 0, // 30
sys.general.options.cooldownDelay ? 1 : 0,
0, 0, 100, 0, 0, 0, 0,
sys.general.options.manualPriority ? 1 : 0, // 39
sys.general.options.manualHeat ? 1 : 0
])];
let arr = [];
try {
if (typeof obj.waterTempAdj1 != 'undefined' && obj.waterTempAdj1 !== sys.equipment.tempSensors.getCalibration('water1')) {
payload[2] = 1;
payload[4] = fnToByte(parseInt(obj.waterTempAdj1, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('water1', parseInt(obj.waterTempAdj1, 10));
}
if (typeof obj.waterTempAdj2 != 'undefined' && obj.waterTempAdj2 !== sys.equipment.tempSensors.getCalibration('water2')) {
payload[2] = 4;
payload[7] = fnToByte(parseInt(obj.waterTempAdj2, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('water2', parseInt(obj.waterTempAdj2, 10));
}
if (typeof obj.waterTempAdj3 != 'undefined' && obj.waterTempAdj3 !== sys.equipment.tempSensors.getCalibration('water3')) {
payload[2] = 6;
payload[9] = fnToByte(parseInt(obj.waterTempAdj3, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('water3', parseInt(obj.waterTempAdj3, 10));
}
if (typeof obj.waterTempAdj4 != 'undefined' && obj.waterTempAdj4 !== sys.equipment.tempSensors.getCalibration('water4')) {
payload[2] = 8;
payload[11] = fnToByte(parseInt(obj.waterTempAdj4, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
response: IntelliCenterBoard.getAckResponse(168),
payload: payload
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('water4', parseInt(obj.waterTempAdj3, 10));
}
if (typeof obj.solarTempAdj1 != 'undefined' && obj.solarTempAdj1 !== sys.equipment.tempSensors.getCalibration('solar1')) {
payload[2] = 2;
payload[5] = fnToByte(parseInt(obj.solarTempAdj1, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('solar1', parseInt(obj.solarTempAdj1, 10));
}
if (typeof obj.solarTempAdj2 != 'undefined' && obj.solarTempAdj2 !== sys.equipment.tempSensors.getCalibration('solar2')) {
payload[2] = 5;
payload[8] = fnToByte(parseInt(obj.solarTempAdj2, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('solar2', parseInt(obj.solarTempAdj2, 10));
}
if (typeof obj.solarTempAdj3 != 'undefined' && obj.solarTempAdj3 !== sys.equipment.tempSensors.getCalibration('solar3')) {
payload[2] = 7;
payload[10] = fnToByte(parseInt(obj.solarTempAdj3, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('solar3', parseInt(obj.solarTempAdj3, 10));
}
if (typeof obj.solarTempAdj4 != 'undefined' && obj.solarTempAdj4 !== sys.equipment.tempSensors.getCalibration('solar4')) {
payload[2] = 8;
payload[12] = fnToByte(parseInt(obj.solarTempAdj4, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('solar3', parseInt(obj.solarTempAdj3, 10));
}
if (typeof obj.airTempAdj != 'undefined' && obj.airTempAdj !== sys.equipment.tempSensors.getCalibration('air')) {
payload[2] = 3;
payload[6] = fnToByte(parseInt(obj.airTempAdj, 10)) || 0;
let out = Outbound.create({
action: 168,
retries: 5,
response: IntelliCenterBoard.getAckResponse(168),
payload: payload
});
await out.sendAsync();
sys.equipment.tempSensors.setCalibration('air', parseInt(obj.airTempAdj, 10));
}
if ((typeof obj.clockMode !== 'undefined' && obj.clockMode !== sys.general.options.clockMode) ||
(typeof obj.adjustDST !== 'undefined' && obj.adjustDST !== sys.general.options.adjustDST)) {
const effectiveClockSource = (typeof obj.clockSource === 'string') ? obj.clockSource : sys.general.options.clockSource;
let byte = 0x10 | (effectiveClockSource === 'internet' ? 0x20 : 0x00);
if (typeof obj.clockMode === 'undefined') byte |= sys.general.options.clockMode === 24 ? 0x40 : 0x00;
else byte |= obj.clockMode === 24 ? 0x40 : 0x00;
if (typeof obj.adjustDST === 'undefined') byte |= sys.general.options.adjustDST ? 0x80 : 0x00;
else byte |= obj.adjustDST ? 0x80 : 0x00;
payload[2] = isIntellicenterV3 ? 0 : 11;
payload[14] = byte;
let out = Outbound.create({
action: 168,
retries: 5,
response: IntelliCenterBoard.getAckResponse(168),
payload: payload
});
await out.sendAsync();
if (typeof obj.clockMode !== 'undefined') sys.general.options.clockMode = obj.clockMode === 24 ? 24 : 12;
if (typeof obj.adjustDST !== 'undefined') sys.general.options.adjustDST = obj.adjustDST ? true : false;
}
if (typeof obj.clockSource != 'undefined' && obj.clockSource !== sys.general.options.clockSource) {
payload[2] = isIntellicenterV3 ? 0 : 11;
payload[17] = obj.clockSource === 'internet' ? 0x01 : 0x00;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
if (obj.clockSource === 'internet' || obj.clockSource === 'server' || obj.clockSource === 'manual')
sys.general.options.clockSource = obj.clockSource;
sys.board.system.setTZ();
}
if (typeof obj.units !== 'undefined') {
const requestedUnits = sys.board.valueMaps.tempUnits.encode(obj.units);
if (!isNaN(requestedUnits) && requestedUnits !== sys.general.options.units) {
// OCP encodes units as 0=English/Fahrenheit, 1=Metric/Celsius.
const unitsByte = requestedUnits === sys.board.valueMaps.tempUnits.getValue('C') ? 1 : 0;
if (isIntellicenterV3) {
// v3.008 full-options frame: OCP only accepts units changes via the wireless-remote
// A168 type=0 template (setpoints at [20..23], modes at [24..25], 15 at [26], units at [32]).
// The sensor-calibration payload above has a different byte layout and the OCP ignores it
// for units changes. Build a fresh frame here and send it to OCP (dest=16).
// Setpoints are converted to target units so byte[32] is internally consistent.
const fromUnitName = sys.board.valueMaps.tempUnits.getName(sys.general.options.units) || 'F';
const toUnitName = unitsByte === 1 ? 'C' : 'F';
const convertSetpoint = (val: number): number => {
if (typeof val !== 'number' || isNaN(val)) return 0;
if (fromUnitName === toUnitName) return val;
return Math.round(utils.convert.temperature.convertUnits(val, fromUnitName, toUnitName));
};
const poolHeat = convertSetpoint(pool.setPoint || (unitsByte === 1 ? 26 : 78));
const poolCool = convertSetpoint(pool.coolSetpoint || (pool.setPoint || (unitsByte === 1 ? 35 : 95)));
const spaHeat = convertSetpoint(spa.setPoint || (unitsByte === 1 ? 35 : 95));
const spaCool = convertSetpoint(spa.coolSetpoint || (spa.setPoint || (unitsByte === 1 ? 35 : 95)));
const dt = new Date();
const yy = dt.getFullYear() - 2000;
const mm = dt.getMonth() + 1;
const dd = dt.getDate();
const hh = dt.getHours();
const min = dt.getMinutes();
const v3UnitsPayload = [
0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0,
160, yy, mm, dd, hh, min,
poolHeat, poolCool, spaHeat, spaCool,
pool.heatMode || 0, spa.heatMode || 0,
15,
0, 0, 0, 0, 0,
unitsByte,
0, 0, 0, 0, 0, 0, 0, 0
];
let out = Outbound.create({
dest: 16,
action: 168,
retries: 5,
payload: v3UnitsPayload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
// Apply converted setpoints locally so state matches the frame we sent to OCP.
const sbody1 = state.temps.bodies.getItemById(1);
pool.setPoint = sbody1.setPoint = poolHeat;
pool.coolSetpoint = sbody1.coolSetpoint = poolCool;
if (sys.bodies.length > 1) {
const sbody2 = state.temps.bodies.getItemById(2);
spa.setPoint = sbody2.setPoint = spaHeat;
spa.coolSetpoint = sbody2.coolSetpoint = spaCool;
}
} else {
payload[2] = 29;
payload[31] = unitsByte;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
}
sys.general.options.units = requestedUnits;
state.temps.units = requestedUnits;
const bodyUnits = requestedUnits === sys.board.valueMaps.tempUnits.getValue('C') ? 2 : 1;
for (let i = 0; i < sys.bodies.length; i++) sys.bodies.getItemByIndex(i).capacityUnits = bodyUnits;
state.emitEquipmentChanges();
}
}
if (isIntellicenterV3) {
let delayRequested = typeof obj.freezeCycleTime !== 'undefined' || typeof obj.valveDelay !== 'undefined' || typeof obj.cooldownDelay !== 'undefined';
if (delayRequested) {
const requestedFreezeCycleTime = typeof obj.freezeCycleTime !== 'undefined'
? parseInt(obj.freezeCycleTime, 10) : sys.general.options.freezeCycleTime;
payload[26] = Math.max(1, Math.min(60, requestedFreezeCycleTime || 15));
payload[27] = (typeof obj.valveDelay !== 'undefined' ? obj.valveDelay : sys.general.options.valveDelay) ? 0x01 : 0x00;
payload[28] = (typeof obj.cooldownDelay !== 'undefined' ? obj.cooldownDelay : sys.general.options.cooldownDelay) ? 0x01 : 0x00;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.general.options.freezeCycleTime = payload[26];
sys.general.options.valveDelay = payload[27] === 1;
sys.general.options.cooldownDelay = payload[28] === 1;
}
}
if (typeof obj.pumpDelay !== 'undefined' && obj.pumpDelay !== sys.general.options.pumpDelay) {
payload[2] = isIntellicenterV3 ? 0 : 27;
payload[pumpDelayPayloadIndex] = obj.pumpDelay ? 0x01 : 0x00;
let out = Outbound.create({
action: 168,
retries: 5,
response: IntelliCenterBoard.getAckResponse(168),
payload: payload
});
await out.sendAsync();
sys.general.options.pumpDelay = obj.pumpDelay ? true : false;
}
if (typeof obj.cooldownDelay !== 'undefined' && obj.cooldownDelay !== sys.general.options.cooldownDelay) {
payload[2] = isIntellicenterV3 ? 0 : 28;
payload[cooldownDelayPayloadIndex] = obj.cooldownDelay ? 0x01 : 0x00;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.general.options.cooldownDelay = obj.cooldownDelay ? true : false;
}
if (typeof obj.manualPriority !== 'undefined' && obj.manualPriority !== sys.general.options.manualPriority) {
payload[2] = isIntellicenterV3 ? 0 : 36;
payload[manualPriorityPayloadIndex] = obj.manualPriority ? 0x01 : 0x00;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.general.options.manualPriority = obj.manualPriority ? true : false;
}
if (typeof obj.manualHeat !== 'undefined' && obj.manualHeat !== sys.general.options.manualHeat) {
payload[2] = isIntellicenterV3 ? 0 : 37;
payload[manualHeatPayloadIndex] = obj.manualHeat ? 0x01 : 0x00;
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.general.options.manualHeat = obj.manualHeat ? true : false;
}
if (typeof obj.solarAsHeatPump !== 'undefined' || typeof obj.showBadgeColors !== 'undefined') {
let opts = sys.general.options;
let solarHP = typeof obj.solarAsHeatPump !== 'undefined' ? (obj.solarAsHeatPump ? true : false) : opts.solarAsHeatPump;
let badgeColors = typeof obj.showBadgeColors !== 'undefined' ? (obj.showBadgeColors ? true : false) : opts.showBadgeColors;
let vac = opts.vacation;
let startDate = vac.startDate ? new Date(vac.startDate) : new Date();
let endDate = vac.endDate ? new Date(vac.endDate) : new Date();
let vacPayload = [0, 0, 64,
vac.enabled ? 1 : 0,
vac.useTimeframe ? 1 : 0,
startDate.getUTCFullYear() - 2000, startDate.getUTCMonth() + 1, startDate.getUTCDate(),
endDate.getUTCFullYear() - 2000, endDate.getUTCMonth() + 1, endDate.getUTCDate(),
0, 30,
badgeColors ? 1 : 0,
0,
solarHP ? 1 : 0,
5
];
let out = Outbound.create({
action: 168,
retries: 5,
payload: vacPayload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
opts.solarAsHeatPump = solarHP;
opts.showBadgeColors = badgeColors;
}
return Promise.resolve(sys.general.options);
}
catch (err) { return Promise.reject(err); }
}
public async setVacationAsync(obj?: any): Promise {
try {
let opts = sys.general.options;
let enabled = typeof obj.enabled !== 'undefined' ? (obj.enabled ? true : false) : opts.vacation.enabled;
let useTimeframe = typeof obj.useTimeframe !== 'undefined' ? (obj.useTimeframe ? true : false) : opts.vacation.useTimeframe;
let startDate = new Date(obj.startDate || opts.vacation.startDate);
let endDate = new Date(obj.endDate || opts.vacation.endDate);
let payload = [0, 0, 64,
enabled ? 1 : 0,
useTimeframe ? 1 : 0,
startDate.getUTCFullYear() - 2000, startDate.getUTCMonth() + 1, startDate.getUTCDate(),
endDate.getUTCFullYear() - 2000, endDate.getUTCMonth() + 1, endDate.getUTCDate(),
0, 30,
opts.showBadgeColors ? 1 : 0,
0,
opts.solarAsHeatPump ? 1 : 0,
5
];
let out = Outbound.create({
action: 168,
retries: 5,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
opts.vacation.enabled = enabled;
opts.vacation.useTimeframe = useTimeframe;
opts.vacation.startDate = startDate;
opts.vacation.endDate = endDate;
return Promise.resolve(opts);
}
catch (err) { return Promise.reject(err); }
}
public async setLocationAsync(obj?: any): Promise {
try {
let arr = [];
if (typeof obj.address === 'string' && obj.address !== sys.general.location.address) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 1],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(obj.address, 32);
await out.sendAsync();
sys.general.location.address = obj.address;
}
if (typeof obj.country === 'string' && obj.country !== sys.general.location.country) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 8],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(obj.country, 32);
await out.sendAsync();
sys.general.location.country = obj.country;
}
if (typeof obj.city === 'string' && obj.city !== sys.general.location.city) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 9],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(obj.city, 32);
await out.sendAsync();
sys.general.location.city = obj.city;
}
if (typeof obj.state === 'string' && obj.state !== sys.general.location.state) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 10],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(obj.state, 32);
await out.sendAsync();
sys.general.location.state = obj.state;
}
if (typeof obj.zip === 'string' && obj.zip !== sys.general.location.zip) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 7],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(obj.zip, 6);
await out.sendAsync();
sys.general.location.zip = obj.zip;
}
if (typeof obj.latitude === 'number' && obj.latitude !== sys.general.location.latitude) {
let lat = Math.round(Math.abs(obj.latitude) * 100);
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 11,
lat % 256,
Math.floor(lat / 256)],
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.general.location.latitude = Math.round(obj.latitude * 100) / 100;
}
if (typeof obj.longitude === 'number' && obj.longitude !== sys.general.location.longitude) {
let lon = Math.round(Math.abs(obj.longitude) * 100);
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 12,
lon % 256,
Math.floor(lon / 256)],
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.general.location.longitude = Math.round(obj.longitude * 100) / 100;
}
if (typeof obj.timeZone === 'number' && obj.timeZone !== sys.general.location.timeZone) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 13, parseInt(obj.timeZone, 10)],
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.general.location.timeZone = parseInt(obj.timeZone, 10);
}
return Promise.resolve(sys.general.location);
}
catch (err) { return Promise.reject(err); }
}
public async setOwnerAsync(obj?: any): Promise {
let arr = [];
try {
if (typeof obj.name === 'string' && obj.name !== sys.general.owner.name) {
const ownerName = normalizeIntelliCenterName(obj.name, sys.general.owner.name);
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 2],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(ownerName, 16);
await out.sendAsync();
sys.general.owner.name = ownerName;
}
if (typeof obj.email === 'string' && obj.email !== sys.general.owner.email) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 3],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(obj.email, 32);
await out.sendAsync();
sys.general.owner.email = obj.email;
}
if (typeof obj.email2 === 'string' && obj.email2 !== sys.general.owner.email2) {
let out = Outbound.create({
action: 168,
retries: 5,
response: IntelliCenterBoard.getAckResponse(168),
payload: [12, 0, 4]
});
out.appendPayloadString(obj.email2, 32);
await out.sendAsync();
sys.general.owner.email2 = obj.email2;
}
if (typeof obj.phone2 === 'string' && obj.phone2 !== sys.general.owner.phone2) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 6],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(obj.phone2, 16);
await out.sendAsync();
sys.general.owner.phone2 = obj.phone2;
}
if (typeof obj.phone === 'string' && obj.phone !== sys.general.owner.phone) {
let out = Outbound.create({
action: 168,
retries: 5,
payload: [12, 0, 5],
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(obj.phone, 16);
await out.sendAsync();
sys.general.owner.phone = obj.phone;
}
return Promise.resolve(sys.general.owner);
}
catch (err) { return Promise.reject(err); }
}
}
class IntelliCenterCircuitCommands extends CircuitCommands {
declare board: IntelliCenterBoard;
// Track pending circuit/feature state changes that have been sent but not yet confirmed by OCP.
// This prevents race conditions when multiple circuit toggles are sent in quick succession.
// Key: circuit/feature ID, Value: intended state (true=on, false=off)
private pendingStates: Map = new Map();
// Add a pending state change (called before sending command)
public addPendingState(id: number, isOn: boolean): void {
this.pendingStates.set(id, isOn);
}
// Clear a pending state (called after ACK received or timeout)
public clearPendingState(id: number): void {
this.pendingStates.delete(id);
}
// Get effective state: pending state takes precedence over confirmed state
public getEffectiveState(id: number, confirmedState: boolean): boolean {
if (this.pendingStates.has(id)) {
return this.pendingStates.get(id);
}
return confirmedState;
}
// Need to override this as IntelliCenter manages all the egg timers for all circuit types.
public async checkEggTimerExpirationAsync() {
try {
for (let i = 0; i < sys.circuits.length; i++) {
let c = sys.circuits.getItemByIndex(i);
let cstate = state.circuits.getItemByIndex(i);
if (!cstate.isActive || !cstate.isOn) continue;
if (c.master === 1) {
await ncp.circuits.checkCircuitEggTimerExpirationAsync(cstate);
}
}
} catch (err) { logger.error(`checkEggTimerExpiration: Error synchronizing circuit relays ${err.message}`); }
}
public async setCircuitAsync(data: any): Promise {
try {
let id = parseInt(data.id, 10);
let circuit = sys.circuits.getItemById(id, false);
// Alright check to see if we are adding a nixie circuit.
if (id === -1 || circuit.master !== 0)
return await super.setCircuitAsync(data);
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('Circuit Id has not been defined', data.id, 'Circuit'));
if (!sys.board.equipmentIds.circuits.isInRange(id)) return Promise.reject(new InvalidEquipmentIdError(`Circuit Id ${id}: is out of range.`, id, 'Circuit'));
let eggTimer = Math.min(typeof data.eggTimer !== 'undefined' ? parseInt(data.eggTimer, 10) : circuit.eggTimer, 1440);
if (isNaN(eggTimer)) eggTimer = circuit.eggTimer;
if (data.dontStop === true) eggTimer = 1440;
data.dontStop = (eggTimer === 1440);
let eggHrs = Math.floor(eggTimer / 60);
let eggMins = eggTimer - (eggHrs * 60);
let type = typeof data.type !== 'undefined' ? parseInt(data.type, 10) : circuit.type;
this.assertSinglePoolSpaType(id, type);
let theme = typeof data.lightingTheme !== 'undefined' ? data.lightingTheme : circuit.lightingTheme;
if (circuit.type === 9) theme = typeof data.level !== 'undefined' ? data.level : circuit.level;
if (typeof theme === 'undefined') theme = 0;
let out = Outbound.create({
action: 168,
payload: [1, 0, id - 1,
type,
(typeof data.freeze !== 'undefined' ? utils.makeBool(data.freeze) : circuit.freeze) ? 1 : 0,
(typeof data.showInFeatures !== 'undefined' ? utils.makeBool(data.showInFeatures) : circuit.showInFeatures) ? 1 : 0,
theme,
eggHrs, eggMins, data.dontStop ? 1 : 0]
});
let circuitNameStr = typeof data.name !== 'undefined' ? data.name.toString().substring(0, 15) : circuit.name;
out.appendPayloadString(circuitNameStr, 16);
out.retries = 5;
out.response = IntelliCenterBoard.getAckResponse(168);
await out.sendAsync();
let scircuit = state.circuits.getItemById(circuit.id, true);
circuit.eggTimer = eggTimer;
circuit.dontStop = data.dontStop;
circuit.freeze = (typeof data.freeze !== 'undefined' ? utils.makeBool(data.freeze) : circuit.freeze);
scircuit.showInFeatures = circuit.showInFeatures = (typeof data.showInFeatures !== 'undefined' ? utils.makeBool(data.showInFeatures) : circuit.showInFeatures);
if (type === 9) scircuit.level = circuit.level = theme;
else {
let t = sys.board.valueMaps.circuitFunctions.transform(type);
if (t.isLight == true) scircuit.lightingTheme = circuit.lightingTheme = theme;
else {
scircuit.lightingTheme = undefined;
circuit.lightingTheme = 0;
}
}
scircuit.name = circuit.name = circuitNameStr;
scircuit.type = circuit.type = type;
scircuit.isActive = circuit.isActive = true;
circuit.master = 0;
return circuit;
}
catch (err) {
return Promise.reject(err);
}
}
public async setCircuitGroupAsync(obj: any): Promise {
// When we save circuit groups we are going to reorder the whole mess. IntelliCenter does some goofy
// gap filling strategy where the circuits are added into the first empty slot. This makes for a
// strange configuration with empty slots. It even causes the mobile app to crash.
let group: CircuitGroup = null;
let sgroup: CircuitGroupState = null;
let id = typeof obj.id !== 'undefined' ? parseInt(obj.id, 10) : -1;
let type = 0;
let isAdd = false;
if (id <= 0) {
// We are adding a circuit group so we need to get the next equipment id. For circuit groups and light groups, they share ids in IntelliCenter.
let range = sys.board.equipmentIds.circuitGroups;
for (let i = range.start; i <= range.end; i++) {
if (!sys.lightGroups.find(elem => elem.id === i) && !sys.circuitGroups.find(elem => elem.id === i)) {
id = i;
break;
}
}
type = parseInt(obj.type, 10) || 2;
group = sys.circuitGroups.getItemById(id, true);
sgroup = state.circuitGroups.getItemById(id, true);
isAdd = true;
}
else {
group = sys.circuitGroups.getItemById(id, false);
sgroup = state.circuitGroups.getItemById(id, false);
type = group.type;
}
if (typeof id === 'undefined') return Promise.reject(new InvalidEquipmentIdError(`Max circuit group ids exceeded: ${id}`, id, 'circuitGroup'));
if (isNaN(id) || !sys.board.equipmentIds.circuitGroups.isInRange(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid circuit group id: ${obj.id}`, obj.id, 'circuitGroup'));
try {
let eggTimer = (typeof obj.eggTimer !== 'undefined') ? parseInt(obj.eggTimer, 10) : group.eggTimer;
if (isNaN(eggTimer)) eggTimer = 720;
eggTimer = Math.max(Math.min(1440, eggTimer), 1);
if (obj.dontStop === true) eggTimer = 1440;
let eggHours = Math.floor(eggTimer / 60);
let eggMins = eggTimer - (eggHours * 60);
obj.dontStop = (eggTimer === 1440);
let out = Outbound.create({
action: 168,
payload: [6, 0, id - sys.board.equipmentIds.circuitGroups.start, 2, 0, 0], // The last byte here should be don't stop but I believe this to be a current bug.
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
// Add in all the info for the circuits.
if (typeof obj.circuits === 'undefined')
for (let i = 0; i < 16; i++) {
let c = group.circuits.getItemByIndex(i, false);
out.payload.push(c.circuit ? c.circuit - 1 : 255);
}
else {
for (let i = 0; i < 16; i++)
(i < obj.circuits.length) ? out.payload.push(obj.circuits[i].circuit - 1) : out.payload.push(255);
}
for (let i = 0; i < 16; i++) out.payload.push(0);
if (sys.equipment.isIntellicenterV3) {
out.payload.push(0, 0, eggHours, eggMins, 0, 0, 0);
} else {
out.payload.push(eggHours);
out.payload.push(eggMins);
}
await out.sendAsync();
group.eggTimer = eggTimer;
group.dontStop = obj.dontStop;
sgroup.type = group.type = 2;
sgroup.isActive = group.isActive = true;
if (typeof obj.showInFeatures !== 'undefined') group.showInFeatures = utils.makeBool(obj.showInFeatures);
sgroup.showInFeatures = group.showInFeatures;
if (typeof obj.circuits !== 'undefined') {
for (let i = 0; i < obj.circuits.length; i++) {
let c = group.circuits.getItemByIndex(i, true);
c.id = i + 1;
c.circuit = obj.circuits[i].circuit;
}
for (let i = obj.circuits.length; i < group.circuits.length; i++)
group.circuits.removeItemByIndex(i);
}
out = Outbound.create({
action: 168,
payload: [6, 1, id - sys.board.equipmentIds.circuitGroups.start],
response: IntelliCenterBoard.getAckResponse(168),
retries: 3
});
for (let i = 0; i < 16; i++) out.payload.push(255);
const groupName = normalizeIntelliCenterName(obj.name, group.name);
out.appendPayloadString(groupName, 16);
await out.sendAsync();
if (typeof obj.name !== 'undefined') sgroup.name = group.name = groupName;
out = Outbound.create({
action: 168,
payload: [6, 2, id - sys.board.equipmentIds.circuitGroups.start],
response: IntelliCenterBoard.getAckResponse(168),
retries: 3
});
for (let i = 0; i < 16; i++) out.payload.push(0); // Push the 0s for the color
// Add in the desired State.
if (typeof obj.circuits === 'undefined')
for (let i = 0; i < 16; i++) {
let c = group.circuits.getItemByIndex(i, false);
typeof c.desiredState !== 'undefined' ? out.payload.push(c.desiredState) : out.payload.push(255);
}
else {
for (let i = 0; i < 16; i++)
(i < obj.circuits.length) ? out.payload.push(obj.circuits[i].desiredState || 1) : out.payload.push(255);
}
await out.sendAsync();
if (typeof obj.circuits !== 'undefined') {
for (let i = 0; i < obj.circuits.length; i++) {
let c = group.circuits.getItemByIndex(i);
c.desiredState = obj.circuits[i].desiredState || 1;
}
}
return group;
}
catch (err) {
return Promise.reject(err);
}
}
public async deleteCircuitGroupAsync(obj: any): Promise {
let group: CircuitGroup = null;
let id = parseInt(obj.id, 10);
if (isNaN(id) || !sys.board.equipmentIds.circuitGroups.isInRange(id)) return Promise.reject(new EquipmentNotFoundError(`Invalid group id: ${obj.id}`, 'CircuitGroup'));
group = sys.circuitGroups.getItemById(id);
try {
let out = Outbound.create({
action: 168,
payload: [6, 0, id - sys.board.equipmentIds.circuitGroups.start, 0, 0, 0],
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
for (let i = 0; i < 16; i++) i < group.circuits.length ? out.payload.push(group.circuits.getItemByIndex(i).circuit - 1) : out.payload.push(255);
for (let i = 0; i < 16; i++) out.payload.push(0);
out.payload.push(12);
out.payload.push(0);
await out.sendAsync();
let gstate = state.circuitGroups.getItemById(id);
gstate.isActive = false;
gstate.emitEquipmentChange();
sys.circuitGroups.removeItemById(id);
state.circuitGroups.removeItemById(id);
out = Outbound.create({
action: 168,
payload: [6, 1, id - sys.board.equipmentIds.circuitGroups.start],
response: IntelliCenterBoard.getAckResponse(168),
retries: 3
});
for (let i = 0; i < 16; i++) out.payload.push(255);
out.appendPayloadString(normalizeIntelliCenterName(group.name), 16);
await out.sendAsync();
out = Outbound.create({
action: 168,
payload: [6, 2, id - sys.board.equipmentIds.circuitGroups.start],
response: IntelliCenterBoard.getAckResponse(168),
retries: 3
});
for (let i = 0; i < 16; i++) out.payload.push(0);
await out.sendAsync();
return group;
}
catch (err) { return Promise.reject(err); }
}
public async setLightGroupAsync(obj: any): Promise {
let group: LightGroup = null;
let sgroup: LightGroupState = null;
let id = typeof obj.id !== 'undefined' ? parseInt(obj.id, 10) : -1;
if (id <= 0) {
// We are adding a light group.
let range = sys.board.equipmentIds.circuitGroups;
for (let i = range.start; i <= range.end; i++) {
if (!sys.lightGroups.find(elem => elem.id === i) && !sys.circuitGroups.find(elem => elem.id === i)) {
id = i;
break;
}
}
group = sys.lightGroups.getItemById(id, true);
}
else {
group = sys.lightGroups.getItemById(id, false);
}
if (typeof id === 'undefined') return Promise.reject(new Error(`Max light group ids exceeded`));
if (isNaN(id) || !sys.board.equipmentIds.circuitGroups.isInRange(id)) return Promise.reject(new Error(`Invalid light group id: ${obj.id}`));
try {
let eggTimer = (typeof obj.eggTimer !== 'undefined') ? parseInt(obj.eggTimer, 10) : group.eggTimer;
if (isNaN(eggTimer)) eggTimer = 720;
eggTimer = Math.max(Math.min(1440, eggTimer), 1);
if (obj.dontStop === true) eggTimer = 1440;
let eggHours = Math.floor(eggTimer / 60);
let eggMins = eggTimer - (eggHours * 60);
obj.dontStop = (eggTimer === 1440);
sgroup = state.lightGroups.getItemById(id, true);
let theme = typeof obj.lightingTheme === 'undefined' ? group.lightingTheme || 0 : obj.lightingTheme;
let out = Outbound.create({
action: 168,
payload: [6, 0, id - sys.board.equipmentIds.circuitGroups.start, 1, (theme << 2) + 1, 0], // The last byte here should be don't stop but I believe this to be a current bug.
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
// Add in all the info for the circuits.
if (typeof obj.circuits === 'undefined') {
// Circuits
for (let i = 0; i < 16; i++) {
let c = group.circuits.getItemByIndex(i, false);
out.payload.push(c.circuit ? c.circuit - 1 : 255);
}
// Swim Delay
for (let i = 0; i < 16; i++) {
let c = group.circuits.getItemByIndex(i, false);
out.payload.push(c.circuit ? c.swimDelay : 255);
}
}
else {
// Circuits
for (let i = 0; i < 16; i++) {
if (i < obj.circuits.length) {
let c = parseInt(obj.circuits[i].circuit, 10);
out.payload.push(!isNaN(c) ? c - 1 : 255);
}
else out.payload.push(255);
}
// Swim Delay
for (let i = 0; i < 16; i++) {
if (i < obj.circuits.length) {
let delay = parseInt(obj.circuits[i].swimDelay, 10);
out.payload.push(!isNaN(delay) ? delay : 10);
}
else out.payload.push(0);
}
}
if (sys.equipment.isIntellicenterV3) {
out.payload.push(0, 0, eggHours, eggMins, 0, 0, 0);
} else {
out.payload.push(eggHours);
out.payload.push(eggMins);
}
await out.sendAsync();
sgroup.type = group.type = 1;
sgroup.lightingTheme = group.lightingTheme = theme;
group.eggTimer = eggTimer;
group.dontStop = obj.dontStop;
if (typeof obj.circuits !== 'undefined') {
for (let i = 0; i < obj.circuits.length; i++) {
let c = group.circuits.getItemByIndex(i, true, { id: i + 1 });
c.circuit = obj.circuits[i].circuit;
c.swimDelay = obj.circuits[i].swimDelay;
if (typeof obj.circuits[i].color !== 'undefined') c.color = obj.circuits[i].color;
}
group.circuits.length = obj.circuits.length;
}
out = Outbound.create({
action: 168,
payload: [6, 1, id - sys.board.equipmentIds.circuitGroups.start],
response: IntelliCenterBoard.getAckResponse(168),
retries: 3
});
for (let i = 0; i < 16; i++) out.payload.push(255);
out.payload[3] = 10;
const groupName = normalizeIntelliCenterName(obj.name, group.name);
out.appendPayloadString(groupName, 16);
await out.sendAsync();
if (typeof obj.name !== 'undefined') sgroup.name = group.name = groupName;
out = Outbound.create({
action: 168,
payload: [6, 2, id - sys.board.equipmentIds.circuitGroups.start],
response: IntelliCenterBoard.getAckResponse(168),
retries: 3
});
if (typeof obj.circuits !== 'undefined') {
for (let i = 0; i < 16; i++) {
let color = 0;
if (i < obj.circuits.length) {
color = parseInt(obj.circuits[i].color, 10);
if (isNaN(color)) {
color = group.circuits.getItemByIndex(i, false).color;
}
}
out.payload.push(color);
}
}
else {
for (let i = 0; i < 16; i++) {
out.payload.push(group.circuits.getItemByIndex(i, false).color);
}
}
out.appendPayloadString(groupName, 16);
await out.sendAsync();
if (typeof obj.circuits !== 'undefined') {
for (let i = 0; i < obj.circuits.length; i++) {
let circ = group.circuits.getItemByIndex(i, true);
let color = 0;
if (i < obj.circuits.length) {
color = parseInt(obj.circuits[i].color, 10);
if (isNaN(color)) { color = circ.color || 0; }
//console.log(`Setting Color: {0}`, color);
}
circ.color = color;
}
}
return group;
}
catch (err) { return Promise.reject(err); }
}
public async deleteLightGroupAsync(obj: any): Promise {
let group: LightGroup = null;
let id = parseInt(obj.id, 10);
if (isNaN(id) || !sys.board.equipmentIds.circuitGroups.isInRange(id)) return Promise.reject(new Error(`Invalid light group id: ${obj.id}`));
group = sys.lightGroups.getItemById(id);
try {
let out = Outbound.create({
action: 168,
payload: [6, 0, id - sys.board.equipmentIds.circuitGroups.start, 0, 0, 0],
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
for (let i = 0; i < 16; i++) i < group.circuits.length ? out.payload.push(group.circuits.getItemByIndex(i).circuit - 1) : out.payload.push(255);
for (let i = 0; i < 16; i++) out.payload.push(0);
out.payload.push(12);
out.payload.push(0);
await out.sendAsync();
let gstate = state.lightGroups.getItemById(id);
gstate.isActive = false;
gstate.emitEquipmentChange();
sys.lightGroups.removeItemById(id);
state.lightGroups.removeItemById(id);
out = Outbound.create({
action: 168,
retries: 5,
response: IntelliCenterBoard.getAckResponse(168),
payload: [6, 1, id - sys.board.equipmentIds.circuitGroups.start]
});
for (let i = 0; i < 16; i++) out.payload.push(255);
out.appendPayloadString(group.name);
await out.sendAsync();
out = Outbound.create({
action: 168,
retries: 5,
response: IntelliCenterBoard.getAckResponse(168),
payload: [6, 2, id - sys.board.equipmentIds.circuitGroups.start]
});
for (let i = 0; i < 16; i++) out.payload.push(0);
await out.sendAsync();
return group;
}
catch (err) { return Promise.reject(err); }
}
public async setLightGroupAttribsAsync(group: LightGroup): Promise {
let grp = sys.lightGroups.getItemById(group.id);
try {
let msgs = this.createLightGroupMessages(grp);
// Set all the info in the messages.
for (let i = 0; i < 16; i++) {
let circuit = i < group.circuits.length ? group.circuits[i] : null;
if (circuit) {
circuit.circuit = parseInt(circuit.circuit, 10);
circuit.swimDelay = parseInt(circuit.swimDelay, 10) || 0;
circuit.color = parseInt(circuit.color, 10) || 0;
if (isNaN(circuit.circuit)) return Promise.reject(new InvalidEquipmentDataError(`Circuit id is not valid ${circuit.circuit}`, 'lightGroup', circuit));
}
msgs.msg0.payload[i + 6] = circuit ? circuit.circuit - 1 : 255;
msgs.msg0.payload[i + 22] = circuit ? circuit.swimDelay || 0 : 0;
msgs.msg1.payload[i + 3] = circuit ? circuit.color || 0 : 255;
msgs.msg2.payload[i + 3] = circuit ? circuit.color || 0 : 0;
}
msgs.msg0.response = IntelliCenterBoard.getAckResponse(168);
msgs.msg0.retries = 5;
await msgs.msg0.sendAsync();
for (let i = 0; i < group.circuits.length; i++) {
let c = group.circuits[i];
let circuit = grp.circuits.getItemByIndex(i, true);
circuit.circuit = parseInt(c.circuit, 10);
circuit.swimDelay = parseInt(c.swimDelay, 10);
circuit.color = parseInt(c.color, 10);
circuit.position = i + 1;
//grp.circuits.add({ id: i, circuit: circuit.circuit, color: circuit.color, position: i, swimDelay: circuit.swimDelay });
}
// Trim anything that was removed.
grp.circuits.length = group.circuits.length;
msgs.msg1.response = IntelliCenterBoard.getAckResponse(168);
msgs.msg1.retries = 5;
await msgs.msg1.sendAsync();
msgs.msg2.response = IntelliCenterBoard.getAckResponse(168);
msgs.msg2.retries = 5;
await msgs.msg2.sendAsync();
return grp;
}
catch (err) { return Promise.reject(err); }
}
public async runLightGroupCommandAsync(obj: any): Promise {
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 sgrp = state.lightGroups.getItemById(grp.id);
if (sys.equipment.isIntellicenterV3) {
let cmdByte = 0;
let actionName = '';
switch (cmd.name) {
case 'colorswim': cmdByte = 1; actionName = 'colorswim'; break;
case 'colorset': cmdByte = 2; actionName = 'colorset'; break;
case 'colorsync': cmdByte = 3; actionName = 'colorsync'; break;
default: return sgrp;
}
let nop = sys.board.valueMaps.circuitActions.getValue(actionName);
sgrp.action = nop;
sgrp.emitEquipmentChange();
for (let i = 0; i < grp.circuits.length; i++) {
let mc = grp.circuits.getItemByIndex(i);
if (mc.circuit) {
let cs = state.circuits.getItemById(mc.circuit);
if (cs) { cs.action = nop; cs.emitEquipmentChange(); }
}
}
let groupIdx = id - sys.board.equipmentIds.circuitGroups.start;
let out = Outbound.createMessage(184, [88, 163, groupIdx, 0, 138, 177, cmdByte, 0, 0, 0], 3);
out.dest = 16;
await out.sendAsync();
} else {
let nop = sys.board.valueMaps.circuitActions.getValue(cmd.name);
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, 12);
break;
case 'colorrecall':
await this.setLightGroupThemeAsync(id, 13);
break;
case 'lightthumper':
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();
switch (cmd.name) {
case 'colorhold':
await this.setLightThemeAsync(id, 12);
break;
case 'colorrecall':
await this.setLightThemeAsync(id, 13);
break;
case 'lightthumper':
// I do not know how to trigger the thumper.
break;
}
slight.action = 0;
slight.emitEquipmentChange();
return slight;
}
catch (err) { return Promise.reject(`Error runLightCommandAsync ${err.message}`); }
}
public async sequenceLightGroupAsync(id: number, operation: string): Promise {
let sgroup = state.lightGroups.getItemById(id);
try {
if (!sgroup.isActive) return Promise.reject(new InvalidEquipmentIdError(`An active light group could not be found with id ${id}`, id, 'lightGroup'));
let cmd = sys.board.valueMaps.lightGroupCommands.findItem(operation.toLowerCase());
let ndx = id - sys.board.equipmentIds.circuitGroups.start;
let byteNdx = Math.floor(ndx / 4);
let bitNdx = (ndx * 2) - (byteNdx * 8);
let out = this.createCircuitStateMessage(id, true);
let byte = out.payload[28 + byteNdx];
// Each light group is represented by two bits on the status byte. There are 3 status bytes that give us only 12 of the 16 on the config stream but the 168 message
// does acutally send 4 so all are represented there.
// [10] = Set
// [01] = Swim
// [00] = Sync
// [11] = No sequencing underway.
// In the end we are only trying to impact the specific bits in the middle of the byte that represent
// the light group we are dealing with.
switch (cmd.name) {
case 'colorsync':
byte &= ((0xFC << bitNdx) | (0xFF >> (8 - bitNdx)));
break;
case 'colorset':
byte &= ((0xFE << bitNdx) | (0xFF >> (8 - bitNdx)));
break;
case 'colorswim':
byte &= ((0xFD << bitNdx) | (0xFF >> (8 - bitNdx)));
break;
default:
return Promise.reject(new InvalidOperationError(`Invalid Light Group Sequence ${operation}`, 'sequenceLightGroupAsync'));
}
sgroup.emitEquipmentChange();
out.payload[28 + byteNdx] = byte;
// So now we have all the info we need to sequence the group.
out.retries = 5;
out.response = IntelliCenterBoard.getAckResponse(168);
await out.sendAsync();
sgroup.action = sys.board.valueMaps.circuitActions.getValue(cmd.name);
state.emitEquipmentChanges();
return sgroup;
} catch (err) {
sgroup.action = 0;
return Promise.reject(new InvalidOperationError(`Error Sequencing Light Group: ${err.message}`, 'sequenceLightGroupAsync'));
}
//let nop = sys.board.valueMaps.circuitActions.getValue(operation);
//if (nop > 0) {
// let out = this.createCircuitStateMessage(id, true);
// let ndx = id - sys.board.equipmentIds.circuitGroups.start;
// let byteNdx = Math.floor(ndx / 4);
// let bitNdx = (ndx * 2) - (byteNdx * 8);
// let byte = out.payload[28 + byteNdx];
// // Each light group is represented by two bits on the status byte. There are 3 status bytes that give us only 12 of the 16 on the config stream but the 168 message
// // does acutally send 4 so all are represented there.
// // [10] = Set
// // [01] = Swim
// // [00] = Sync
// // [11] = No sequencing underway.
// // In the end we are only trying to impact the specific bits in the middle of the byte that represent
// // the light group we are dealing with.
// switch (nop) {
// case 1: // Sync
// byte &= ((0xFC << bitNdx) | (0xFF >> (8 - bitNdx)));
// break;
// case 2: // Color Set
// byte &= ((0xFE << bitNdx) | (0xFF >> (8 - bitNdx)));
// break;
// case 3: // Color Swim
// byte &= ((0xFD << bitNdx) | (0xFF >> (8 - bitNdx)));
// break;
// }
// console.log({ groupNdx: ndx, action: nop, byteNdx: byteNdx, bitNdx: bitNdx, byte: byte })
// out.payload[28 + byteNdx] = byte;
// return new Promise((resolve, reject) => {
// out.retries = 5;
// out.response = IntelliCenterBoard.getAckResponse(168);
// out.onComplete = (err, msg) => {
// if (!err) {
// sgroup.action = nop;
// state.emitEquipmentChanges();
// resolve(sgroup);
// }
// else reject(err);
// };
// await out.sendAsync();
// });
//}
//return Promise.resolve(sgroup);
}
// 12-01-21 RKS: This has been deprecated. This allows for multiple vendor light themes driven by the metadata on the valuemaps.
//public getLightThemes(type: number): any[] {
// switch (type) {
// case 5: // Intellibrite
// case 6: // Globrite
// case 8: // Magicstream
// case 10: // ColorCascade
// return sys.board.valueMaps.lightThemes.toArray();
// default:
// return [];
// }
//}
private async getConfigAsync(payload: number[]): Promise {
const dest = sys.equipment.isIntellicenterV3 ? 16 : 15;
let out = Outbound.create({
dest,
action: 222,
scope: sys.equipment.isIntellicenterV3 ? 'v3CommandReadback' : undefined,
retries: 3,
payload: payload,
response: Response.create({ dest: -1, action: 30, payload: payload })
});
await out.sendAsync();
// Do NOT ACK(30). Wireless captures show config succeeds without ACK(30), and v1 queue avoids ACK(30).
return true;
}
public async setCircuitStateAsync(id: number, val: boolean, ignoreDelays?: boolean): Promise {
// v3.004+ features: dashPanel (and other callers) may route feature toggles through the "circuit" path.
// IntelliCenter features live in a different Action 184 channel (0xE89D), so delegate feature IDs here.
if (sys.equipment.isIntellicenterV3 && sys.board.equipmentIds.features.isInRange(id)) {
logger.info(`v3.004+ setCircuitStateAsync: ID ${id} is a feature; delegating to setFeatureStateAsync -> ${val ? 'ON' : 'OFF'}`);
return await this.board.features.setFeatureStateAsync(id, val, ignoreDelays);
}
if (sys.equipment.isIntellicenterV3 && sys.board.equipmentIds.circuitGroups.isInRange(id)) {
await this.setCircuitGroupStateAsync(id, val);
return state.circuitGroups.getInterfaceById(id);
}
let c = sys.circuits.getInterfaceById(id);
if (c.master !== 0) return await super.setCircuitStateAsync(id, val);
// As of 1.047 there is a sequence to this.
// 1. ICP Sends action 228 (Get versions)
// 2. OCP responds 164
// 3. ICP responds ACK(164)
// 4. ICP Sends action 222[15,0] (Get circuit config)
// 5. OCP responds 30[15,0] (Respond circuit config)
// 6. ICP responds ACK(30)
// NOT SURE IF COINCIDENTAL: The ICP seems to respond immediately after action 2.
// 7. ICP Sends 168[15,0,... new options, 0,0,0,0]
// 8. OCP responds ACK(168)
// i10D turn on pool
// OCP
// Schedule on
// [255, 0, 255][165, 1, 15, 16, 168, 36][15, 0, 0, 33, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 1][5, 226]
// No schedules
// [255, 0, 255][165, 1, 15, 16, 168, 36][15, 0, 0, 38, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, 1, 0][5, 195]
// njsPC
// [255, 0, 255][165, 1, 15, 33, 168, 36][15, 0, 0, 33, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 0][5, 216]
// The previous sequence is just additional noise on the bus. There is no need for it. We just
// need to send the set circuit message. It will reliably work 100% of the time but the ICP
// may set it back again. THIS HAS TO BE A 1.047 BUG!
// Add to pending states BEFORE building the message. This ensures that if multiple commands
// are sent in quick succession, subsequent commands will include this pending change.
// Fix for: "only last change takes effect when multiple circuits toggled quickly"
this.addPendingState(id, val);
try {
// v3.004+ body circuits (Spa=1, Pool=6): Wireless does NOT control bodies by sending the circuit's targetId directly.
// Captures show body control uses a "body toggle" primitive (target=168,237 state=0/1) plus
// additional body context/selection packets (notably targets 212,182 and 114,145).
// See .plan/202-intellicenter-bodies-temps.md for full protocol documentation.
if (sys.equipment.isIntellicenterV3 && this.isBodyCircuit(id)) {
const isPool = (id === 6);
const bodyName = isPool ? 'Pool' : 'Spa';
if (state.freeze) {
logger.info(`v3.004+ freeze override: Sending ON for ${bodyName} (freeze active, original request=${val ? 'ON' : 'OFF'})`);
val = true;
}
logger.debug(`v3.004+ setCircuitStateAsync: Using Wireless-style body control sequence for ${bodyName} (circuit ${id}) -> ${val ? 'ON' : 'OFF'}`);
await this.sendV3BodyControlSequenceAsync(isPool, val);
// Request updated config to confirm state change
await this.getConfigAsync([15, 0]);
let circ = state.circuits.getInterfaceById(id);
state.emitEquipmentChanges();
return circ;
}
// v3.004+ non-body circuits: Use indexed Action 184 (Wireless-style)
if (sys.equipment.isIntellicenterV3) {
const circuit = sys.circuits.getItemById(id, false);
logger.info(`v3.004+ setCircuitStateAsync: Using indexed Action 184 for circuit ${id} (${circuit?.name || 'unnamed'}) -> ${val ? 'ON' : 'OFF'}`);
/**
* v3.004+ Indexed Circuit Control (Wireless-style).
* Action 184 is the native circuit control message used by the Wireless remote.
*
* Payload structure (10 bytes):
* Bytes 0-1: Channel (0x688F for circuits)
* Byte 2: Index (circuitId - 1 or featureId - 1)
* Byte 3: Format (255 = command mode)
* Bytes 4-5: Target (0xA8ED = control primitive)
* Byte 6: State (0=OFF, 1=ON)
* Bytes 7-9: Reserved (0,0,0)
*/
const idx = Math.max(0, Math.min(255, (id | 0) - 1));
const out = Outbound.createMessage(184, [
104, 143, // Channel 0x688F (circuits)
idx, // Index (circuitId - 1)
255, // Format (command)
168, 237, // Target 0xA8ED (control primitive)
val ? 1 : 0, // State
0, 0, 0
], 3);
out.dest = 16; // Send to OCP
out.scope = `circuitState${id}`;
out.retries = 5;
out.response = IntelliCenterBoard.getAckResponse(184);
await out.sendAsync();
// Request updated config to confirm state change
await this.getConfigAsync([15, 0]);
let circ = state.circuits.getInterfaceById(id);
state.emitEquipmentChanges();
return circ;
}
// v1.x or v3.004+ without known targetId: Use Action 168 (original method)
// (deprecated) historically attempted to mimic an ICP sequence (228→164 ACK, then 222[15,0]→30)
// before sending circuit state. We no longer run that here; it added bus noise and wasn't
// required for reliable 168 writes (and we never ACK(30) due to collision risk).
//if (b) b = await this.getConfigAsync([15, 0]);
let out = this.createCircuitStateMessage(id, val);
//if (sys.equipment.dual && id === 6) out.setPayloadByte(35, 1);
out.setPayloadByte(34, 1);
// v3.004+: Do NOT spoof the OCP as the sender. Commands must originate from our plugin address and be sent to OCP (16).
// v1.x: historically spoofed `out.source = 16` as a workaround ("Trying a different source for setting circuits on IntelliCenter."
// commit cc123002fb, 2021-10-23). Keep that behavior only for pre-v3.
if (sys.equipment.isIntellicenterV3) {
out.dest = 16;
logger.warn(`v3.004+ setCircuitStateAsync: No targetId known for circuit ${id}, falling back to Action 168. Circuit control may not work until targetId is learned from OCP broadcasts.`);
} else {
out.source = 16;
}
out.scope = `circuitState${id}`;
out.retries = 5;
out.response = IntelliCenterBoard.getAckResponse(168);
await out.sendAsync();
// There is a current bug in 1.047 where one controller will reset the settings
// of another when they are not the controller that set it. Either this is a BS bug
// or there is some piece of information we do not have.
let b = await this.getConfigAsync([15, 0]);
let circ = state.circuits.getInterfaceById(id);
// This doesn't work to set it back because the ICP will set it back but often this
// can take several seconds to do so.
//if (circ.isOn !== utils.makeBool(val)) await this.setCircuitStateAsync(id, val);
state.emitEquipmentChanges();
return circ;
}
catch (err) { return Promise.reject(err); }
finally {
// Clear the pending state after command completes (success or failure).
// The getConfigAsync([15, 0]) above should have updated state from OCP response.
this.clearPendingState(id);
}
}
/**
* Determines if a circuit ID corresponds to a body circuit (Pool or Spa).
* Body circuits require special Wireless-style control sequences on v3.004+.
*
* On IntelliCenter, body circuits are:
* - Circuit 1 = Spa
* - Circuit 6 = Pool
*
* These IDs are standard across IntelliCenter installations.
*/
private isBodyCircuit(id: number): boolean {
return id === 1 || id === 6;
}
/**
* v3.004+ IntelliCenter body control sequence (Wireless-style).
*
* Evidence (Replay 68 "bodies-cycle-all"):
* - Body selection uses Action 184 target 114,145 with payload byte6=1 (Pool) or byte6=0 (Spa)
* - Body context uses Action 184 target 212,182 with body-specific bytes 6-9
* - Actual toggle uses Action 184 target 168,237 with state in byte6 (0/1)
*
* This sequence is used for both Pool (id=6) and Spa (id=1) on v3.004+.
* See .plan/202-intellicenter-bodies-temps.md for full protocol documentation.
*/
private async sendV3BodyControlSequenceAsync(isPool: boolean, isOn: boolean): Promise {
const bodyName = isPool ? 'Pool' : 'Spa';
const seq = isPool ? 5 : 0; // Sequence varies by body in captures
// 1) Body select (target 114,145 / 0x7291) — chooses Pool (byte6=1) vs Spa (byte6=0)
const select = Outbound.createMessage(184, [
128, 142, // Channel/context (observed)
0, // Sequence
0, // Format (observed)
114, 145, // Target 0x7291
isPool ? 1 : 0, // Select: 1=Pool, 0=Spa
0, 0, 0
], 3);
select.dest = 16;
select.scope = `bodySelect${bodyName}`;
select.retries = 5;
select.response = IntelliCenterBoard.getAckResponse(184);
await select.sendAsync();
// 2) Body context (target 212,182 / 0xD4B6) — body-specific bytes 6-9
// Observed: Spa ON=[0,101,4,0], Pool ON=[128,151,6,0], OFF=[255,255,255,255]
const ctxBytes = !isOn ? [255, 255, 255, 255] : (isPool ? [128, 151, 6, 0] : [0, 101, 4, 0]);
const ctx = Outbound.createMessage(184, [
104, 143, // Default channel
seq, // Sequence
255, // Format (command)
212, 182, // Target 0xD4B6
...ctxBytes
], 3);
ctx.dest = 16;
ctx.scope = `bodyContext${bodyName}`;
ctx.retries = 5;
ctx.response = IntelliCenterBoard.getAckResponse(184);
await ctx.sendAsync();
// 3) Body toggle (target 168,237 / 0xA8ED) — state in byte6
const toggle = Outbound.createMessage(184, [
104, 143, // Default channel
seq, // Sequence
255, // Format (command)
168, 237, // Target 0xA8ED
isOn ? 1 : 0, // State
0, 0, 0
], 3);
toggle.dest = 16;
toggle.scope = `bodyToggle${bodyName}`;
toggle.retries = 5;
toggle.response = IntelliCenterBoard.getAckResponse(184);
await toggle.sendAsync();
}
public async setCircuitGroupStateAsync(id: number, val: boolean): Promise {
let grp = sys.circuitGroups.getItemById(id, false, { isActive: false });
let gstate = (grp.dataName === 'circuitGroupConfig') ? state.circuitGroups.getItemById(grp.id, grp.isActive !== false) : state.lightGroups.getItemById(grp.id, grp.isActive !== false);
let isLightGroup = grp.dataName === 'lightGroupConfig';
if (isLightGroup && val) {
let nop = sys.board.valueMaps.circuitActions.getValue('settheme');
(gstate as LightGroupState).action = nop;
gstate.emitEquipmentChange();
}
try {
if (sys.equipment.isIntellicenterV3) {
let groupIdx = id - sys.board.equipmentIds.circuitGroups.start;
let out = Outbound.createMessage(184, [
88, 163, // Channel 0x58A3 (groups)
groupIdx, // Index (groupId - circuitGroups.start)
0, // Format
168, 237, // Primitive 0xA8ED (toggle)
val ? 1 : 0, // State
0, 0, 0
], 3);
out.dest = 16;
out.scope = `circuitGroupState${id}`;
out.retries = 5;
out.response = IntelliCenterBoard.getAckResponse(184);
await out.sendAsync();
} else {
await sys.board.circuits.setCircuitStateAsync(id, val);
}
if (isLightGroup && val) {
setTimeout(() => {
(gstate as LightGroupState).action = 0;
gstate.emitEquipmentChange();
}, 15000);
}
return state.circuitGroups.getInterfaceById(id);
}
catch (err) { return Promise.reject(err); }
}
public async setLightGroupStateAsync(id: number, val: boolean): Promise { return this.setCircuitGroupStateAsync(id, val); }
public async setLightGroupThemeAsync(id: number, theme: number): Promise {
try {
let group = sys.lightGroups.getItemById(id);
let sgroup = state.lightGroups.getItemById(id);
let nop = sys.board.valueMaps.circuitActions.getValue('settheme');
sgroup.action = nop;
sgroup.emitEquipmentChange();
let msgs = this.createLightGroupMessages(group);
msgs.msg0.payload[4] = (theme << 2) + 1;
msgs.msg0.response = IntelliCenterBoard.getAckResponse(168);
msgs.msg0.retries = 5;
await msgs.msg0.sendAsync();
group.lightingTheme = theme;
sgroup.lightingTheme = theme;
setTimeout(() => {
sgroup.action = 0;
sgroup.emitEquipmentChange();
}, 15000);
state.emitEquipmentChanges();
return sgroup;
}
catch (err) { return Promise.reject(err); }
}
public async setColorHoldAsync(id: number): Promise {
let circuit = sys.circuits.getInterfaceById(id);
if (circuit.master === 1) return await super.setColorHoldAsync(id);
try {
if (sys.board.equipmentIds.circuitGroups.isInRange(id)) {
await this.setLightGroupThemeAsync(id, 12);
return Promise.resolve(state.lightGroups.getItemById(id));
}
return await this.setLightThemeAsync(id, 12);
}
catch (err) { return Promise.reject(err); }
}
public async setColorRecallAsync(id: number): Promise {
let circuit = sys.circuits.getInterfaceById(id);
if (circuit.master === 1) return await super.setColorHoldAsync(id);
try {
if (sys.board.equipmentIds.circuitGroups.isInRange(id)) {
await this.setLightGroupThemeAsync(id, 13);
return Promise.resolve(state.lightGroups.getItemById(id));
}
return await this.setLightThemeAsync(id, 13);
}
catch (err) { return Promise.reject(err); }
}
public async setLightThemeAsync(id: number, theme: number): Promise {
let circuit = sys.circuits.getInterfaceById(id);
if (circuit.master === 1) return await super.setLightThemeAsync(id, theme);
try {
if (sys.board.equipmentIds.circuitGroups.isInRange(id)) {
await this.setLightGroupThemeAsync(id, theme);
return Promise.resolve(state.lightGroups.getItemById(id));
}
else {
let circuit = sys.circuits.getInterfaceById(id);
let cstate = state.circuits.getInterfaceById(id);
let out = Outbound.create({
action: 168, payload: [1, 0, id - 1, circuit.type, circuit.freeze ? 1 : 0, circuit.showInFeatures ? 1 : 0,
theme, Math.floor(circuit.eggTimer / 60), circuit.eggTimer - ((Math.floor(circuit.eggTimer) / 60) * 60), circuit.dontStop ? 1 : 0]
});
cstate.action = sys.board.valueMaps.circuitActions.getValue('lighttheme');
out.response = IntelliCenterBoard.getAckResponse(168);
out.retries = 5;
out.appendPayloadString(normalizeIntelliCenterName(circuit.name), 16);
await out.sendAsync();
circuit.lightingTheme = theme;
cstate.lightingTheme = theme;
cstate.action = 0;
if (!cstate.isOn) await this.setCircuitStateAsync(id, true);
state.emitEquipmentChanges();
return Promise.resolve(cstate);
}
}
catch (err) { return Promise.reject(err); }
}
public createLightGroupMessages(group: ICircuitGroup): { msg0?: Outbound, msg1?: Outbound, msg2?: Outbound } {
// Todo: add scope to outgoing messages
let msgs: { msg0?: Outbound, msg1?: Outbound, msg2?: Outbound } = {};
// Create the first message.
//[255, 0, 255][165, 63, 15, 16, 168, 40][6, 0, 0, 1, 41, 0, 4, 6, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 4, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0][16, 20]
msgs.msg0 = Outbound.create({
action: 168,
payload: [6, 0, group.id - sys.board.equipmentIds.circuitGroups.start, group.type,
typeof group.lightingTheme !== 'undefined' && group.lightingTheme ? (group.lightingTheme << 2) + 1 : 0, 0,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // Circuits
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // Swim Delay
Math.floor(group.eggTimer / 60), group.eggTimer - ((Math.floor(group.eggTimer) / 60) * 60)]
});
for (let i = 0; i < group.circuits.length; i++) {
// Set all the circuit info.
let circuit = group.circuits.getItemByIndex(i);
msgs.msg0.payload[i + 6] = circuit.circuit - 1;
if (group.type === 1) msgs.msg0.payload[i + 22] = (circuit as LightGroupCircuit).swimDelay;
}
// Create the second message
//[255, 0, 255][165, 63, 15, 16, 168, 35][6, 1, 0, 10, 10, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 80, 111, 111, 108, 32, 76, 105, 103, 104, 116, 115, 0, 0, 0, 0, 0][20, 0]
msgs.msg1 = Outbound.create({
action: 168, payload: [6, 1, group.id - sys.board.equipmentIds.circuitGroups.start,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255 // Colors
]
});
msgs.msg1.appendPayloadString(normalizeIntelliCenterName(group.name), 16);
if (group.type === 1) {
let lg = group as LightGroup;
for (let i = 0; i < group.circuits.length; i++)
msgs.msg1.payload[i + 3] = 10; // Really don't know what this is. Perhaps it is some indicator for color/swim/sync.
}
// Create the third message
//[255, 0, 255][165, 63, 15, 16, 168, 19][6, 2, 0, 16, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0][2, 6]
msgs.msg2 = Outbound.create({
action: 168, payload: [6, 2, group.id - sys.board.equipmentIds.circuitGroups.start,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // Colors
]
});
if (group.type === 1) {
let lg = group as LightGroup;
for (let i = 0; i < group.circuits.length; i++)
msgs.msg2.payload[i + 3] = lg.circuits.getItemByIndex(i).color;
}
return msgs;
}
public createCircuitStateMessage(id?: number, isOn?: boolean): Outbound {
let out = Outbound.createMessage(168, [15, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-9
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 10-19
0, 0, 0, 0, 0, 0, 0, 0, 255, 255, // 20-29
255, 255, 0, 0, 0, 0], // 30-35
3);
// Circuits are always contiguous so we don't have to worry about
// them having a strange offset like features and groups. However, in
// single body systems they start with 2.
for (let i = 0; i < state.data.circuits.length; i++) {
// We are using the index and setting the circuits based upon
// the index. This way it doesn't matter what the sort happens to
// be and whether there are gaps in the ids or not. The ordinal is the bit number.
let cstate = state.circuits.getItemByIndex(i);
let ordinal = cstate.id - 1;
if (ordinal >= 40) continue;
let ndx = Math.floor(ordinal / 8);
let byte = out.payload[ndx + 3];
let bit = ordinal - (ndx * 8);
// Use pending state if available to avoid race conditions when multiple commands sent quickly
let effectiveState = cstate.id === id ? isOn : this.getEffectiveState(cstate.id, cstate.isOn);
if (effectiveState) byte = byte | (1 << bit);
out.payload[ndx + 3] = byte;
}
// IntelliCenter has "special" circuits (not in equipmentIds.circuits range) used by bodies (e.g. Spa circuit 1 when circuits.start=2).
// Ensure the body circuit bit is represented so dashPanel can turn Spa ON (replay.27 shows Spa is body2.circuit=1).
try {
if (sys.bodies.length > 1) {
const spaState = state.temps.bodies.getItemById(2, false);
const spaCfg = sys.bodies.getItemById(2, false);
const spaCircuitId = spaCfg && typeof spaCfg.circuit === 'number' ? spaCfg.circuit : 1;
if (spaCircuitId > 0 && spaCircuitId < sys.board.equipmentIds.circuits.start) {
const ordinal = spaCircuitId - 1;
const ndx = Math.floor(ordinal / 8);
const bit = ordinal - (ndx * 8);
let byte = out.payload[ndx + 3] || 0;
const shouldBeOn = (typeof id !== 'undefined' && id === spaCircuitId) ? !!isOn : !!(spaState && spaState.isOn);
if (shouldBeOn) byte = byte | (1 << bit);
out.payload[ndx + 3] = byte;
}
}
} catch (e) { /* best-effort; do not block circuit toggles */ }
// Set the bits for the features.
for (let i = 0; i < state.data.features.length; i++) {
// We are using the index and setting the features based upon
// the index. This way it doesn't matter what the sort happens to
// be and whether there are gaps in the ids or not. The ordinal is the bit number.
let feature = state.features.getItemByIndex(i);
let ordinal = feature.id - sys.board.equipmentIds.features.start;
if (ordinal >= 32) continue;
let ndx = Math.floor(ordinal / 8);
let byte = out.payload[ndx + 9];
let bit = ordinal - (ndx * 8);
// Use pending state if available to avoid race conditions when multiple commands sent quickly
let effectiveState = feature.id === id ? isOn : this.getEffectiveState(feature.id, feature.isOn);
if (effectiveState) byte = byte | (1 << bit);
out.payload[ndx + 9] = byte;
}
// Set the bits for the circuit groups.
for (let i = 0; i < state.data.circuitGroups.length; i++) {
let group = state.circuitGroups.getItemByIndex(i);
let ordinal = group.id - sys.board.equipmentIds.circuitGroups.start;
if (ordinal >= 16) continue;
let ndx = Math.floor(ordinal / 8);
let byte = out.payload[ndx + 13];
let bit = ordinal - (ndx * 8);
// Use pending state if available to avoid race conditions when multiple commands sent quickly
let effectiveState = group.id === id ? isOn : this.getEffectiveState(group.id, group.isOn);
if (effectiveState) byte = byte | (1 << bit);
out.payload[ndx + 13] = byte;
}
// Set the bits for the light groups.
for (let i = 0; i < state.data.lightGroups.length; i++) {
let group = state.lightGroups.getItemByIndex(i);
let ordinal = group.id - sys.board.equipmentIds.circuitGroups.start;
if (ordinal >= 16) continue;
let ndx = Math.floor(ordinal / 8);
let byte = out.payload[ndx + 13];
let bit = ordinal - (ndx * 8);
// Use pending state if available to avoid race conditions when multiple commands sent quickly
let effectiveState = group.id === id ? isOn : this.getEffectiveState(group.id, group.isOn);
if (effectiveState) byte = byte | (1 << bit);
out.payload[ndx + 13] = byte;
if (group.action !== 0) {
let byteNdx = Math.floor(ordinal / 4);
let bitNdx = (ndx * 2);
let byte = out.payload[28 + byteNdx];
// Each light group is represented by two bits on the status byte. There are 3 status bytes that give us only 12 of the 16 on the config stream but the 168 message
// does acutally send 4 so all are represented there.
// [10] = Set
// [01] = Swim
// [00] = Sync
// [11] = No sequencing underway.
// Only affect the 2 bits related to the light group.
switch (group.action) {
case 1: // Sync
byte &= ((0xFC << bitNdx) | (0xFF >> (8 - bitNdx)));
break;
case 2: // Color Set
byte &= ((0xFE << bitNdx) | (0xFF >> (8 - bitNdx)));
break;
case 3: // Color Swim
byte &= ((0xFD << bitNdx) | (0xFF >> (8 - bitNdx)));
break;
}
out.payload[28 + byteNdx] = byte;
}
}
// Set the bits for the schedules.
for (let i = 0; i < state.data.schedules.length; i++) {
let sched = state.schedules.getItemByIndex(i);
let ordinal = sched.id - 1;
if (ordinal >= 100) continue;
let ndx = Math.floor(ordinal / 8);
let byte = out.payload[ndx + 15];
let bit = ordinal - (ndx * 8);
// Lets determine if this schedule should be on.
if (sched.circuit === id) {
if (isOn) {
let dt = state.time.toDate();
let dow = dt.getDay();
// Convert the dow to the bit value.
let sd = sys.board.valueMaps.scheduleDays.toArray().find(elem => elem.dow === dow);
//let dayVal = sd.bitVal || sd.val; // The bitval allows mask overrides.
let ts = dt.getHours() * 60 + dt.getMinutes();
if ((sched.scheduleDays & sd.bitval) > 0 && ts >= sched.startTime && ts <= sched.endTime) byte = byte | (1 << bit);
}
}
else if (sched.isOn) byte = byte | (1 << bit);
out.payload[ndx + 15] = byte;
}
return out;
}
public async setDimmerLevelAsync(id: number, level: number): Promise {
let circuit = sys.circuits.getItemById(id);
let cstate = state.circuits.getItemById(id);
let arr = [];
try {
if (!cstate.isOn)
await this.setCircuitStateAsync(id, true);
let out = Outbound.create({
action: 168, payload: [1, 0, id - 1, circuit.type, circuit.freeze ? 1 : 0, circuit.showInFeatures ? 1 : 0,
level, Math.floor(circuit.eggTimer / 60), circuit.eggTimer - ((Math.floor(circuit.eggTimer) / 60) * 60), circuit.dontStop ? 1 : 0],
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
out.appendPayloadString(normalizeIntelliCenterName(circuit.name), 16);
await out.sendAsync();
circuit.level = level;
cstate.level = level;
sys.board.circuits.setEndTime(circuit, cstate, true);
cstate.isOn = true;
state.emitEquipmentChanges();
return cstate;
}
catch (err) { return Promise.reject(err); }
}
public async toggleCircuitStateAsync(id: number): Promise {
// v3.004+ features: dashPanel may attempt to toggle features via the circuit endpoint.
if (sys.equipment.isIntellicenterV3 && sys.board.equipmentIds.features.isInRange(id)) {
return await this.board.features.toggleFeatureStateAsync(id);
}
let circ = state.circuits.getInterfaceById(id);
return sys.board.circuits.setCircuitStateAsync(id, !circ.isOn);
}
}
class IntelliCenterFeatureCommands extends FeatureCommands {
declare board: IntelliCenterBoard;
private async getConfigAsync(payload: number[]): Promise {
const dest = sys.equipment.isIntellicenterV3 ? 16 : 15;
let out = Outbound.create({
dest,
action: 222,
scope: sys.equipment.isIntellicenterV3 ? 'v3CommandReadback' : undefined,
retries: 3,
payload: payload,
response: Response.create({ dest: -1, action: 30, payload: payload })
});
await out.sendAsync();
// Do NOT ACK(30). Wireless captures show config succeeds without ACK(30), and v1 queue avoids ACK(30).
return true;
}
public async setFeatureStateAsync(id: number, val: boolean, ignoreDelays?: boolean): Promise {
// v3.004+: Features are controlled via Action 184 channel 0xE89D (232,157), not the circuits channel.
if (sys.equipment.isIntellicenterV3) {
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid feature id: ${id}`, id, 'Feature'));
if (!sys.board.equipmentIds.features.isInRange(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid feature id: ${id}`, id, 'Feature'));
const feature = sys.features.getItemById(id, false, { isActive: false });
logger.info(`v3.004+ setFeatureStateAsync: Using indexed Action 184 for feature ${id} (${feature?.name || 'unnamed'}) -> ${val ? 'ON' : 'OFF'}`);
/**
* v3.004+ Indexed Feature Control (Wireless-style).
* Action 184 is the native feature control message used by the Wireless remote (channel 0xE89D).
*
* Payload structure (10 bytes):
* Bytes 0-1: Channel (0xE89D for features)
* Byte 2: Index (featureId - 1)
* Byte 3: Format/mode (observed 0 in replays 132/138 feature toggles)
* Bytes 4-5: Target (0xA8ED = control primitive)
* Byte 6: State (0=OFF, 1=ON)
* Bytes 7-9: Reserved (0,0,0)
*/
const idx = Math.max(0, Math.min(255, (id | 0) - 1));
const out = Outbound.createMessage(184, [
232, 157, // Channel 0xE89D (features)
idx, // Index (featureId - 1)
0, // Format/mode (observed)
168, 237, // Target 0xA8ED (control primitive)
val ? 1 : 0, // State
0, 0, 0
], 3);
out.dest = 16; // Send to OCP
out.scope = `featureState${id}`;
out.retries = 5;
out.response = IntelliCenterBoard.getAckResponse(184);
await out.sendAsync();
// Request updated system state to confirm feature change (authoritative source for v3 features).
await this.getConfigAsync([15, 0]);
const fstate = state.features.getItemById(id, true);
state.emitEquipmentChanges();
return fstate;
}
// Legacy behavior (v1.x): delegate to circuit state setter.
return sys.board.circuits.setCircuitStateAsync(id, val);
}
public async toggleFeatureStateAsync(id: number): Promise {
const feat = state.features.getItemById(id);
return this.setFeatureStateAsync(id, !(feat.isOn || false));
}
public syncGroupStates() { } // Do nothing and let IntelliCenter do it.
public async setFeatureAsync(data: any): Promise {
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 eggTimer = Math.min(typeof data.eggTimer !== 'undefined' ? parseInt(data.eggTimer, 10) : feature.eggTimer, 1440);
if (isNaN(eggTimer)) eggTimer = feature.eggTimer;
if (data.dontStop === true) eggTimer = 1440;
data.dontStop = (eggTimer === 1440);
let eggHrs = Math.floor(eggTimer / 60);
let eggMins = eggTimer - (eggHrs * 60);
let out = Outbound.create({
action: 168,
response: IntelliCenterBoard.getAckResponse(168),
retries: 5,
payload: [2, 0, id - sys.board.equipmentIds.features.start,
typeof data.type !== 'undefined' ? parseInt(data.type, 10) : feature.type,
(typeof data.freeze !== 'undefined' ? utils.makeBool(data.freeze) : feature.freeze) ? 1 : 0,
(typeof data.showInFeatures !== 'undefined' ? utils.makeBool(data.showInFeatures) : feature.showInFeatures) ? 1 : 0,
eggHrs, eggMins, data.dontStop ? 1 : 0]
});
let nameStr = normalizeIntelliCenterName(data.name, feature.name);
out.appendPayloadString(nameStr, 16);
await out.sendAsync();
feature = sys.features.getItemById(id, true);
let fstate = state.features.getItemById(id, true);
feature.eggTimer = eggTimer;
feature.dontStop = data.dontStop;
fstate.freezeProtect = 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);
fstate.name = feature.name = nameStr;
fstate.type = feature.type = typeof data.type !== 'undefined' ? parseInt(data.type, 10) : feature.type;
fstate.emitEquipmentChange();
return feature;
}
public async deleteFeatureAsync(data: any): Promise {
let id = parseInt(data.id, 10);
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('feature Id has not been defined', data.id, 'Feature'));
let feature = sys.features.getItemById(id, false);
let out = Outbound.create({
action: 168,
payload: [2, 0, id - sys.board.equipmentIds.features.start,
255, // Delete the feature
0, 0, 12, 0, 0],
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
out.appendPayloadString(normalizeIntelliCenterName(data.name, feature.name), 16);
await out.sendAsync();
sys.features.removeItemById(id);
feature.isActive = false;
let fstate = state.features.getItemById(id, false);
fstate.showInFeatures = false;
state.features.removeItemById(id);
return feature;
}
}
class IntelliCenterChlorinatorCommands extends ChlorinatorCommands {
public async setChlorAsync(obj: any): Promise {
let id = parseInt(obj.id, 10);
// Bail out right away if this is not controlled by the OCP.
if (typeof obj.master !== 'undefined' && parseInt(obj.master, 10) !== 0) return super.setChlorAsync(obj);
let isAdd = false;
if (isNaN(id) || id <= 0) {
// We are adding so we need to see if there is another chlorinator that is not external.
if (sys.chlorinators.count(elem => elem.master !== 2) > sys.equipment.maxChlorinators) return Promise.reject(new InvalidEquipmentDataError(`The max number of chlorinators has been exceeded you may only add ${sys.equipment.maxChlorinators}`, 'chlorinator', sys.equipment.maxChlorinators));
id = 1;
isAdd = true;
}
let chlor = sys.chlorinators.getItemById(id);
if (chlor.master !== 0 && !isAdd) return super.setChlorAsync(obj);
let name = normalizeIntelliCenterName(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;
// This should never never never modify the setpoints based upon the disabled or isDosing flags.
//let poolSetpoint = isDosing ? 100 : disabled ? 0 : parseInt(obj.poolSetpoint, 10);
//let spaSetpoint = isDosing ? 100 : disabled ? 0 : parseInt(obj.spaSetpoint, 10);
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 saltTarget = typeof obj.saltTarget === 'number' ? parseInt(obj.saltTarget, 10) : chlor.saltTarget;
if (poolSetpoint === 0) console.log(obj);
let model = typeof obj.model !== 'undefined' ? sys.board.valueMaps.chlorinatorModel.encode(obj.model) : chlor.model || 0;
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 = obj.body || 32;
// Verify the data.
let body = sys.board.bodies.mapBodyAssociation(typeof obj.body === 'undefined' ? chlor.body || 0 : obj.body);
if (typeof body === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Chlorinator body association is not valid: ${chlor.body}`, 'chlorinator', chlor.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));
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));
if (typeof obj.ignoreSaltReading !== 'undefined') chlor.ignoreSaltReading = utils.makeBool(obj.ignoreSaltReading);
let out = Outbound.create({
action: 168,
payload: sys.equipment.isIntellicenterV3
? [7, 0, id - 1, body.val, 1,
disabled ? 0 : isDosing ? 100 : poolSetpoint,
disabled ? 0 : isDosing ? 100 : spaSetpoint,
0, 0, superChlorHours, 1, 20, 20]
: [7, 0, id - 1, body.val, 1,
disabled ? 0 : isDosing ? 100 : poolSetpoint,
disabled ? 0 : isDosing ? 100 : spaSetpoint,
superChlorinate ? 1 : 0, superChlorHours, 0, 1],
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
await out.sendAsync();
if (sys.equipment.isIntellicenterV3 && superChlorinate !== utils.makeBool(chlor.superChlor)) {
let scOut = Outbound.createMessage(184, [
128, 142, 0, 0, 236, 239, superChlorinate ? 1 : 0, 0, 0, 0
], 3);
scOut.dest = 16;
scOut.retries = 5;
scOut.response = IntelliCenterBoard.getAckResponse(184);
await scOut.sendAsync();
}
let schlor = state.chlorinators.getItemById(id, true);
let cchlor = sys.chlorinators.getItemById(id, true);
chlor.master = 0;
schlor.body = chlor.body = body.val;
chlor.disabled = disabled;
schlor.model = chlor.model = model;
schlor.type = chlor.type = chlorType;
chlor.name = schlor.name = name;
chlor.isDosing = isDosing;
chlor.saltTarget = saltTarget;
schlor.isActive = cchlor.isActive = true;
schlor.poolSetpoint = cchlor.poolSetpoint = poolSetpoint;
schlor.spaSetpoint = cchlor.spaSetpoint = spaSetpoint;
schlor.superChlorHours = cchlor.superChlorHours = superChlorHours;
schlor.superChlor = cchlor.superChlor = superChlorinate;
state.emitEquipmentChanges();
return schlor;
}
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);
let schlor = state.chlorinators.getItemById(id);
// Verify the data.
let out = Outbound.create({
action: 168,
payload: [7, 0, id - 1, schlor.body || 0, 0, schlor.poolSetpoint || 0, schlor.spaSetpoint || 0, 0, schlor.superChlorHours || 0, 0, 0],
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
await out.sendAsync();
ncp.chlorinators.deleteChlorinatorAsync(id).then(() => { });
schlor = state.chlorinators.getItemById(id, true);
state.chlorinators.removeItemById(id);
sys.chlorinators.removeItemById(id);
return schlor;
}
}
class IntelliCenterPumpCommands extends PumpCommands {
private createPumpConfigMessages(pump: Pump): Outbound[] {
let arr: Outbound[] = [];
let outSettings = Outbound.createMessage(
168, [4, 0, pump.id - 1, pump.type, 0, pump.address, pump.minSpeed - Math.floor(pump.minSpeed / 256) * 256, Math.floor(pump.minSpeed / 256), pump.maxSpeed - Math.floor(pump.maxSpeed / 256) * 256
, Math.floor(pump.maxSpeed / 256), pump.minFlow, pump.maxFlow, pump.flowStepSize, pump.primingSpeed - Math.floor(pump.primingSpeed / 256) * 256
, Math.floor(pump.primingSpeed / 256), pump.speedStepSize / 10, pump.primingTime
, 5, 255, 255, 255, 255, 255, 255, 255, 255
, 0, 0, 0, 0, 0, 0, 0, 0], 0); // All the circuits and units.
let outName = Outbound.createMessage(
168, [4, 1, pump.id - 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0);
for (let i = 0; i < 8; i++) {
let circuit = pump.circuits.getItemById(i + 1);
if (typeof circuit.circuit === 'undefined' || circuit.circuit === 255 || circuit.circuit === 0) {
outSettings.payload[i + 18] = 255;
// If this is a VF or VSF then we want to put these units in the minimum flow category.
switch (pump.type) {
case 1: // SS
case 2: // DS
outName.payload[i * 2 + 3] = 0;
outName.payload[i * 2 + 4] = 0;
break;
case 4: // VSF
case 5: // VF
outName.payload[i * 2 + 3] = pump.minSpeed - Math.floor(pump.minFlow / 256) * 256;
outName.payload[i * 2 + 4] = Math.floor(pump.minFlow / 256);
break;
default:
// VS
outName.payload[i * 2 + 3] = pump.minSpeed - Math.floor(pump.minSpeed / 256) * 256;
outName.payload[i * 2 + 4] = Math.floor(pump.minSpeed / 256);
break;
}
}
else {
outSettings.payload[i + 18] = circuit.circuit - 1; // Set this to the index not the id.
outSettings.payload[i + 26] = circuit.units;
switch (pump.type) {
case 1: // SS
outName.payload[i * 2 + 3] = 0;
outName.payload[i * 2 + 4] = 0;
break;
case 2: // DS
outName.payload[i * 2 + 3] = 1;
outName.payload[i * 2 + 4] = 0;
break;
case 4: // VSF
case 5: // VF
outName.payload[i * 2 + 3] = circuit.flow - Math.floor(circuit.flow / 256) * 256;
outName.payload[i * 2 + 4] = Math.floor(circuit.flow / 256);
break;
default:
// VS
outName.payload[i * 2 + 3] = circuit.speed - Math.floor(circuit.speed / 256) * 256;
outName.payload[i * 2 + 4] = Math.floor(circuit.speed / 256);
break;
}
}
}
outName.appendPayloadString(normalizeIntelliCenterName(pump.name), 16);
return [outSettings, outName];
}
/* public setPumpCircuit(pump: Pump, pumpCircuitDeltas: any) {
let { result, reason } = super.setPumpCircuit(pump, pumpCircuitDeltas);
if (result === 'OK') this.setPump(pump);
return { result: result, reason: reason };
}
public setPump(pump: Pump, obj?: any) {
super.setPump(pump, obj);
let msgs: Outbound[] = this.createPumpConfigMessages(pump);
for (let i = 0; i < msgs.length; i++){
conn.queueSendMessage(msgs[i]);
}
} */
public async setPumpAsync(data: any): Promise {
try {
let id = (typeof data.id === 'undefined' || data.id <= 0) ? sys.pumps.getNextEquipmentId(sys.board.equipmentIds.pumps) : parseInt(data.id, 10);
if (isNaN(id)) return Promise.reject(new Error(`Invalid pump id: ${data.id}`));
let pump = sys.pumps.getItemById(id, false);
if (data.master > 0 || pump.master > 0) return await super.setPumpAsync(data);
// 0 6 10 11 12 15
//[255, 0, 255][165, 63, 15, 16, 168, 34][4, 0, 0, 3, 0, 96, 194, 1, 122, 13, 15, 130, 1, 196, 9, 128, 2, 255, 5, 0, 251, 128, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0][11, 218]
//[255, 0, 255][165, 63, 15, 16, 168, 34][4, 0, 0, 3, 0, 96, 194, 1, 122, 13, 15, 130, 1, 196, 9, 1, 2, 255, 5, 0, 251, 128, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0][11, 91]
//[255, 0, 255][165, 63, 15, 16, 168, 34][4, 0, 0, 3, 0, 96, 194, 1, 122, 13, 15, 130, 1, 196, 9, 128, 2, 255, 5, 0, 251, 128, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0][11, 218]
//[255, 0, 255][165, 63, 15, 33, 168, 33][4, 0, 0, 3, 0, 96, 194, 1, 122, 13, 15, 130, 1, 196, 9, 640, 255, 255, 5, 0, 251, 128, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0][14, 231]
//[255, 0, 255][165, 63, 15, 33, 168, 34][4, 0, 0, 3, 0, 96, 194, 1, 122, 13, 15, 130, 1, 196, 9, 300, 255, 3, 5, 0, 251, 128, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0][12, 152]
if (isNaN(id)) return Promise.reject(new Error(`Invalid pump id: ${data.id}`));
// maxPumps is the count of pump slots (ids 1..maxPumps); reject only when strictly above that range
else if (id > sys.equipment.maxPumps) return Promise.reject(new Error(`Pump id out of range: ${id}`));
// We now need to get the type for the pump. If the incoming data doesn't include it then we need to
// get it from the current pump configuration.
let ntype = (typeof data.type === 'undefined' || isNaN(parseInt(data.type, 10))) ? pump.type : parseInt(data.type, 10);
// While we are dealing with adds in the setPumpConfig we are not dealing with deletes so this needs to be a value greater than nopump. If someone sends
// us a type that is <= 0 we need to throw an error. If they dont define it or give us an invalid number we can move on.
if (isNaN(ntype) || ntype <= 0) return Promise.reject(new Error(`Invalid pump type: ${data.id} - ${data.type}`));
let type = sys.board.valueMaps.pumpTypes.transform(ntype);
if (typeof type.name === 'undefined') return Promise.reject(new Error(`Invalid pump type: ${data.id} - ${ntype}`));
// Build out our messsages. We are merging data together so that the data items from the current config can be overridden. If they are not
// supplied then we will use what we already have. This will make sure the information is valid and any change can be applied without the complete
// definition of the pump. This is important since additional attributes may be added in the future and this keeps us current no matter what
// the endpoint capability is.
const isV3 = sys.equipment.isIntellicenterV3;
const dest = isV3 ? 16 : 15;
let outc = Outbound.create({ dest, action: 168, payload: [4, 0, id - 1, ntype, 0] });
const normalizeNumber = (value: any): number | undefined => {
const parsed = parseInt(value, 10);
return isNaN(parsed) ? undefined : parsed;
};
const pickNumber = (...candidates: any[]): number | undefined => {
for (const candidate of candidates) {
const parsed = normalizeNumber(candidate);
if (typeof parsed !== 'undefined') return parsed;
}
return undefined;
};
const toIntelliCenterAddress = (address: number | undefined): number | undefined => {
if (typeof address === 'undefined') return undefined;
// API/UI uses 1..16 slots; wire payload uses 96..111.
if (address > 0 && address <= 16) return address + 95;
return address;
};
const resolvedAddress = toIntelliCenterAddress(pickNumber(data.address, pump.address, id + 95));
const resolvedMinSpeed = pickNumber(data.minSpeed, pump.minSpeed, type.minSpeed, 450);
const resolvedMaxSpeed = pickNumber(data.maxSpeed, pump.maxSpeed, type.maxSpeed, 3450);
const resolvedMinFlow = pickNumber(data.minFlow, pump.minFlow, type.minFlow, 0);
const resolvedMaxFlow = pickNumber(data.maxFlow, pump.maxFlow, type.maxFlow, 130);
const resolvedFlowStepSize = pickNumber(data.flowStepSize, pump.flowStepSize, type.flowStepSize, 1);
const resolvedPrimingSpeed = pickNumber(data.primingSpeed, pump.primingSpeed, type.primingSpeed, 2500);
const resolvedSpeedStepSize = pickNumber(data.speedStepSize, pump.speedStepSize, type.speedStepSize, 10);
const resolvedPrimingTime = pickNumber(data.primingTime, pump.primingTime, type.maxPrimingTime, 0);
outc.appendPayloadByte(resolvedAddress, id + 95); // 5
// v3.004+ uses big-endian for 16-bit speed/flow values
if (isV3) {
outc.appendPayloadIntBE(resolvedMinSpeed, 450); // 6
outc.appendPayloadIntBE(resolvedMaxSpeed, 3450); // 8
} else {
outc.appendPayloadInt(resolvedMinSpeed, 450); // 6
outc.appendPayloadInt(resolvedMaxSpeed, 3450); // 8
}
outc.appendPayloadByte(resolvedMinFlow, 0); // 10
outc.appendPayloadByte(resolvedMaxFlow, 130); // 11
outc.appendPayloadByte(resolvedFlowStepSize, 1); // 12
if (isV3) {
outc.appendPayloadIntBE(resolvedPrimingSpeed, 2500); // 13
} else {
outc.appendPayloadInt(resolvedPrimingSpeed, 2500); // 13
}
outc.appendPayloadByte(Math.max(1, Math.floor((resolvedSpeedStepSize || 10) / 10)), 1); // 15
outc.appendPayloadByte(resolvedPrimingTime, 0); // 17
outc.appendPayloadByte(255); //
outc.appendPayloadBytes(255, 8); // 18
outc.appendPayloadBytes(0, 8); // 26
let outn = Outbound.create({ dest, action: 168, payload: [4, 1, id - 1] });
outn.appendPayloadBytes(0, 16);
const pumpName = normalizeIntelliCenterName(data.name, pump.name || type.name);
outn.appendPayloadString(pumpName, 16);
const isDualSpeed = type.name === 'ds';
const circuitPayloadNdx = isDualSpeed ? 19 : 18;
const unitsPayloadNdx = isDualSpeed ? 27 : 26;
const maxPayloadCircuits = isDualSpeed ? 7 : 8;
const poolBody = sys.board.valueMaps.pumpBodies.getValue('pool');
const spaBody = sys.board.valueMaps.pumpBodies.getValue('spa');
const poolSpaBody = sys.board.valueMaps.pumpBodies.getValue('poolspa');
const normalizeBody = (body: number) => {
if (body === 1) return poolBody;
if (body === 2) return spaBody;
if (body === 32) return poolSpaBody;
return body;
};
const toBodyWireCode = (body: number) => {
if (body === spaBody) return 1;
if (body === poolSpaBody) return 32;
return 0;
};
const requestedBody = normalizeBody(sys.board.valueMaps.pumpBodies.encode(data.body));
const currentBody = normalizeBody(sys.board.valueMaps.pumpBodies.encode(pump.body));
const bodyPayload = (!isNaN(requestedBody) && sys.board.valueMaps.pumpBodies.valExists(requestedBody))
? requestedBody
: ((!isNaN(currentBody) && sys.board.valueMaps.pumpBodies.valExists(currentBody)) ? currentBody : poolBody);
const bodyWireCode = toBodyWireCode(bodyPayload);
if (type.hasBody === true) outc.setPayloadByte(10, bodyWireCode);
if (isDualSpeed) outc.setPayloadByte(18, bodyPayload);
if (type.name === 'ss') {
outc.setPayloadByte(5, 0); // Clear the pump address
// At some point we may add these to the pump model.
// v3.004+ uses big-endian for 16-bit speed/flow values
if (isV3) {
outc.setPayloadIntBE(6, type.minSpeed, 450);
outc.setPayloadIntBE(8, type.maxSpeed, 3450);
} else {
outc.setPayloadInt(6, type.minSpeed, 450);
outc.setPayloadInt(8, type.maxSpeed, 3450);
}
outc.setPayloadByte(10, bodyWireCode);
outc.setPayloadByte(11, type.maxFlow, 130);
outc.setPayloadByte(12, 1);
if (isV3) {
outc.setPayloadIntBE(13, type.primingSpeed, 2500);
} else {
outc.setPayloadInt(13, type.primingSpeed, 2500);
}
outc.setPayloadByte(15, 10);
outc.setPayloadByte(16, 1);
outc.setPayloadByte(17, 5);
outc.setPayloadByte(18, bodyPayload);
outc.setPayloadByte(26, 0);
if (isV3) outn.setPayloadIntBE(3, 0); else outn.setPayloadInt(3, 0);
for (let i = 1; i < 8; i++) {
outc.setPayloadByte(i + 18, 255);
outc.setPayloadByte(i + 26, 0);
if (isV3) outn.setPayloadIntBE((i * 2) + 3, 1000); else outn.setPayloadInt((i * 2) + 3, 1000);
}
}
else {
// All of these pumps potentially have circuits.
// Add in all the circuits
if (typeof data.circuits === 'undefined') {
// The endpoint isn't changing the circuits and is just setting the attributes.
for (let i = 0; i < maxPayloadCircuits; i++) {
let circ = pump.circuits.getItemByIndex(i, false, { circuit: 255 });
circ.id = i + 1;
outc.setPayloadByte(i + circuitPayloadNdx, circ.circuit);
}
}
else {
if (typeof type.maxCircuits !== 'undefined' && type.maxCircuits > 0) {
for (let i = 0; i < maxPayloadCircuits; i++) {
let circ = pump.circuits.getItemByIndex(i, false, { circuit: 255 });
if (i >= data.circuits.length) {
// The incoming data does not include this circuit so we will set it to 255.
outc.setPayloadByte(i + circuitPayloadNdx, 255);
if (typeof type.minSpeed !== 'undefined')
isV3 ? outn.setPayloadIntBE((i * 2) + 3, type.minSpeed) : outn.setPayloadInt((i * 2) + 3, type.minSpeed);
else if (typeof type.minFlow !== 'undefined') {
isV3 ? outn.setPayloadIntBE((i * 2) + 3, type.minFlow) : outn.setPayloadInt((i * 2) + 3, type.minFlow);
outc.setPayloadByte(i + unitsPayloadNdx, 1);
}
else
isV3 ? outn.setPayloadIntBE((i * 2) + 3, 0) : outn.setPayloadInt((i * 2) + 3, 0);
}
else {
let c = data.circuits[i];
let speed = parseInt(c.speed, 10);
let flow = parseInt(c.flow, 10);
let existingSpeed = parseInt(circ.speed as any, 10);
let existingFlow = parseInt(circ.flow as any, 10);
let speedChanged = !isNaN(speed) && (isNaN(existingSpeed) || speed !== existingSpeed);
let flowChanged = !isNaN(flow) && (isNaN(existingFlow) || flow !== existingFlow);
let circuit = i < type.maxCircuits ? parseInt(c.circuit, 10) : 256;
let currentCircuit = parseInt(circ.circuit as any, 10);
let circuitByte = !isNaN(circuit) ? circuit - 1 : (!isNaN(currentCircuit) ? currentCircuit - 1 : 255);
if (isNaN(circuitByte) || circuitByte < 0 || circuitByte > 255) circuitByte = 255;
const rpmUnits = sys.board.valueMaps.pumpUnits.getValue('rpm');
const gpmUnits = sys.board.valueMaps.pumpUnits.getValue('gpm');
let units: number;
if (type.name === 'vf') units = gpmUnits;
else if (type.name === 'vs') units = rpmUnits;
else {
units = sys.board.valueMaps.pumpUnits.encode(c.units);
if (isNaN(units)) units = parseInt(circ.units as any, 10);
if (isNaN(units)) units = !isNaN(flow) && isNaN(speed) ? gpmUnits : rpmUnits;
}
// If the units marker and provided values disagree, prefer the populated value to avoid
// emitting NaN bytes in the outbound Action 168 payload.
if (units === rpmUnits && isNaN(speed) && !isNaN(flow)) units = gpmUnits;
else if (units === gpmUnits && isNaN(flow) && !isNaN(speed)) units = rpmUnits;
// If only one side changed, trust the side that changed even when units are stale.
else if (flowChanged && !speedChanged && typeof type.minFlow !== 'undefined') units = gpmUnits;
else if (speedChanged && !flowChanged && typeof type.minSpeed !== 'undefined') units = rpmUnits;
outc.setPayloadByte(i + circuitPayloadNdx, circuitByte, 255);
if (typeof type.minSpeed !== 'undefined' && units === rpmUnits) {
outc.setPayloadByte(i + unitsPayloadNdx, 0); // Set to rpm
const minSpeed = (typeof type.minSpeed === 'number' && !isNaN(type.minSpeed)) ? type.minSpeed : 450;
const speedCandidate = !isNaN(speed) ? speed : existingSpeed;
const safeSpeed = !isNaN(speedCandidate) ? Math.max(speedCandidate, minSpeed) : minSpeed;
isV3 ? outn.setPayloadIntBE((i * 2) + 3, safeSpeed, minSpeed) : outn.setPayloadInt((i * 2) + 3, safeSpeed, minSpeed);
}
else if (typeof type.minFlow !== 'undefined' && units === gpmUnits) {
outc.setPayloadByte(i + unitsPayloadNdx, 1); // Set to gpm
const minFlow = (typeof type.minFlow === 'number' && !isNaN(type.minFlow)) ? type.minFlow : 15;
const flowCandidate = !isNaN(flow) ? flow : existingFlow;
const safeFlow = !isNaN(flowCandidate) ? Math.max(flowCandidate, minFlow) : minFlow;
isV3 ? outn.setPayloadIntBE((i * 2) + 3, safeFlow, minFlow) : outn.setPayloadInt((i * 2) + 3, safeFlow, minFlow);
}
}
}
}
}
}
// We now have our messages. Let's send them off and update our values.
outc.response = IntelliCenterBoard.getAckResponse(168);
outc.retries = 5;
await outc.sendAsync();
// We have been successful so lets set our pump with the new data.
pump = sys.pumps.getItemById(id, true);
let spump = state.pumps.getItemById(id, true);
spump.type = pump.type = ntype;
if (typeof data.model !== 'undefined') pump.model = data.model;
if (type.name === 'ss') {
pump.address = undefined;
pump.primingTime = 0;
pump.primingSpeed = type.primingSpeed || 2500;
pump.minSpeed = type.minSpeed || 450;
pump.maxSpeed = type.maxSpeed || 3450;
pump.minFlow = type.minFlow, 0;
pump.maxFlow = type.maxFlow, 130;
pump.circuits.clear();
if (typeof data.body !== 'undefined') pump.body = bodyPayload;
}
else if (type.name === 'ds') {
pump.address = undefined;
pump.primingTime = 0;
pump.primingSpeed = type.primingSpeed || 2500;
pump.minSpeed = type.minSpeed || 450;
pump.maxSpeed = type.maxSpeed || 3450;
pump.minFlow = type.minFlow, 0;
pump.maxFlow = type.maxFlow, 130;
if (typeof data.body !== 'undefined') pump.body = bodyPayload;
}
else {
if (typeof data.address !== 'undefined') {
const parsedAddress = normalizeNumber(data.address);
const normalizedAddress = toIntelliCenterAddress(parsedAddress);
if (typeof normalizedAddress !== 'undefined') pump.address = normalizedAddress;
}
if (typeof data.primingTime !== 'undefined') pump.primingTime = parseInt(data.primingTime, 10);
if (typeof data.primingSpeed !== 'undefined') pump.primingSpeed = parseInt(data.primingSpeed, 10);
if (typeof data.minSpeed !== 'undefined') pump.minSpeed = parseInt(data.minSpeed, 10);
if (typeof data.maxSpeed !== 'undefined') pump.maxSpeed = parseInt(data.maxSpeed, 10);
if (typeof data.minFlow !== 'undefined') pump.minFlow = parseInt(data.minFlow, 10);
if (typeof data.maxFlow !== 'undefined') pump.maxFlow = parseInt(data.maxFlow, 10);
if (typeof data.flowStepSize !== 'undefined') pump.flowStepSize = parseInt(data.flowStepSize, 10);
if (typeof data.speedStepSize !== 'undefined') pump.speedStepSize = parseInt(data.speedStepSize, 10);
}
if (typeof data.circuits !== 'undefined' && type.name !== 'undefined') {
// Set all the circuits
let id = 1;
const maxConfigCircuits = type.name === 'ds' ? 7 : 8;
for (let i = 0; i < 8; i++) {
if (i >= maxConfigCircuits || i >= data.circuits.length) pump.circuits.removeItemByIndex(i);
else {
let c = data.circuits[i];
let circuitId = parseInt(c.circuit, 10);
if (isNaN(circuitId)) pump.circuits.removeItemByIndex(i);
else {
let circ = pump.circuits.getItemByIndex(i, true);
circ.circuit = circuitId;
circ.id = id++;
if (type.name === 'ds') circ.units = undefined;
else {
// Need to validate this earlier.
let units = c.units !== 'undefined' ? parseInt(c.units, 10) : 0
circ.units = units;
}
}
}
}
}
outn.response = IntelliCenterBoard.getAckResponse(168);
outn.retries = 5;
await outn.sendAsync();
// We have been successful so lets set our pump with the new data.
pump = sys.pumps.getItemById(id, true);
spump = state.pumps.getItemById(id, true);
if (typeof data.name !== 'undefined') spump.name = pump.name = pumpName;
spump.type = pump.type = ntype;
if (type.name !== 'ss') {
if (typeof data.circuits !== 'undefined') {
// Set all the circuits
const maxConfigCircuits = type.name === 'ds' ? 7 : 8;
for (let i = 0; i < 8; i++) {
if (i >= maxConfigCircuits || i >= data.circuits.length) pump.circuits.removeItemByIndex(i);
else {
let c = data.circuits[i];
let circuitId = typeof c.circuit !== 'undefined' ? parseInt(c.circuit, 10) : pump.circuits.getItemById(i, false).circuit;
let circ = pump.circuits.getItemByIndex(i, true);
circ.circuit = circuitId;
const rpmUnits = sys.board.valueMaps.pumpUnits.getValue('rpm');
const gpmUnits = sys.board.valueMaps.pumpUnits.getValue('gpm');
let speed = parseInt(c.speed, 10);
let flow = parseInt(c.flow, 10);
let existingSpeed = parseInt(circ.speed as any, 10);
let existingFlow = parseInt(circ.flow as any, 10);
let speedChanged = !isNaN(speed) && (isNaN(existingSpeed) || speed !== existingSpeed);
let flowChanged = !isNaN(flow) && (isNaN(existingFlow) || flow !== existingFlow);
let units: number;
if (type.name === 'vf') units = gpmUnits;
else if (type.name === 'vs') units = rpmUnits;
else {
units = sys.board.valueMaps.pumpUnits.encode(c.units);
if (isNaN(units)) units = parseInt(circ.units as any, 10);
if (isNaN(units)) units = !isNaN(flow) && isNaN(speed) ? gpmUnits : rpmUnits;
}
if (units === rpmUnits && isNaN(speed) && !isNaN(flow)) units = gpmUnits;
else if (units === gpmUnits && isNaN(flow) && !isNaN(speed)) units = rpmUnits;
else if (flowChanged && !speedChanged && typeof type.minFlow !== 'undefined') units = gpmUnits;
else if (speedChanged && !flowChanged && typeof type.minSpeed !== 'undefined') units = rpmUnits;
circ.units = units;
if (circ.units === gpmUnits && typeof type.minFlow !== 'undefined') {
const minFlow = (typeof type.minFlow === 'number' && !isNaN(type.minFlow)) ? type.minFlow : 15;
const flowCandidate = !isNaN(flow) ? flow : existingFlow;
circ.flow = !isNaN(flowCandidate) ? Math.max(flowCandidate, minFlow) : minFlow;
circ.speed = undefined;
}
else if (circ.units === rpmUnits && typeof type.minSpeed !== 'undefined') {
const minSpeed = (typeof type.minSpeed === 'number' && !isNaN(type.minSpeed)) ? type.minSpeed : 450;
const speedCandidate = !isNaN(speed) ? speed : existingSpeed;
circ.speed = !isNaN(speedCandidate) ? Math.max(speedCandidate, minSpeed) : minSpeed;
circ.flow = undefined;
}
}
}
}
}
state.emitEquipmentChanges();
return sys.pumps.getItemById(id);
}
catch (err) { return Promise.reject(err); }
}
public async deletePumpAsync(data: any): Promise {
let id = parseInt(data.id);
if (isNaN(id)) return Promise.reject(new Error(`Cannot Delete Pump, Invalid pump id: ${data.id}`));
// We now need to get the type for the pump. If the incoming data doesn't include it then we need to
// get it from the current pump configuration.
let pump = sys.pumps.getItemById(id, false);
// Check to see if this happens to be a Nixie Pump.
if (pump.master === 1) return super.deletePumpAsync(data);
if (typeof pump.type === 'undefined') return Promise.reject(new InvalidEquipmentIdError(`Pump #${data.id} does not exist in configuration`, data.id, 'Schedule'));
const isV3 = sys.equipment.isIntellicenterV3;
let outc = Outbound.create({ action: 168, payload: [4, 0, id - 1, 0, 0, id + 95] });
if (isV3) {
outc.appendPayloadIntBE(450); // 6
outc.appendPayloadIntBE(3450); // 8
} else {
outc.appendPayloadInt(450); // 6
outc.appendPayloadInt(3450); // 8
}
outc.appendPayloadByte(15); // 10
outc.appendPayloadByte(130); // 11
outc.appendPayloadByte(1); // 12
if (isV3) {
outc.appendPayloadIntBE(1000); // 13
outc.appendPayloadIntBE(10); // 15
} else {
outc.appendPayloadInt(1000); // 13
outc.appendPayloadInt(10); // 15
}
outc.appendPayloadByte(5); // 17
outc.appendPayloadBytes(255, 8); // 18
outc.appendPayloadBytes(0, 8); // 26
let outn = Outbound.create({ action: 168, payload: [4, 1, id - 1] });
outn.appendPayloadBytes(0, 16);
outn.appendPayloadString('Pump -' + (id + 1), 16);
// We now have our messages. Let's send them off and update our values.
try {
outc.retries = 5;
outc.response = IntelliCenterBoard.getAckResponse(168);
await outc.sendAsync();
let spump = state.pumps.getItemById(id);
sys.pumps.removeItemById(id);
state.pumps.removeItemById(id);
spump.isActive = false;
spump.emitEquipmentChange();
outn.response = IntelliCenterBoard.getAckResponse(168);
outn.retries = 2;
await outn.sendAsync();
state.emitEquipmentChanges();
return pump;
} catch (err) { return Promise.reject(err); }
}
}
class IntelliCenterBodyCommands extends BodyCommands {
private bodyHeatSettings: {
processing: boolean,
bytes: number[],
body1: { heatMode: number, heatSetpoint: number, coolSetpoint: number },
body2: { heatMode: number, heatSetpoint: number, coolSetpoint: number },
_processingStartTime?: number
};
private async queueBodyHeatSettings(bodyId?: number, byte?: number, data?: any): Promise {
logger.debug(`queueBodyHeatSettings: ${JSON.stringify(this.bodyHeatSettings)}`); // remove this line if #848 is fixed
if (typeof this.bodyHeatSettings === 'undefined') {
let body1 = sys.bodies.getItemById(1);
let body2 = sys.bodies.getItemById(2);
this.bodyHeatSettings = {
processing: false,
bytes: [],
body1: { heatMode: body1.heatMode || 1, heatSetpoint: body1.heatSetpoint || 78, coolSetpoint: body1.coolSetpoint || 100 },
body2: { heatMode: body2.heatMode || 1, heatSetpoint: body2.heatSetpoint || 78, coolSetpoint: body2.coolSetpoint || 100 }
};
}
let bhs = this.bodyHeatSettings;
// Reset processing state if it's been stuck for too long (more than 10 seconds)
if (bhs.processing && bhs._processingStartTime && (Date.now() - bhs._processingStartTime > 10000)) {
logger.warn(`Resetting stuck bodyHeatSettings processing state after timeout`);
bhs.processing = false;
bhs.bytes = [];
delete bhs._processingStartTime;
}
if (typeof data !== 'undefined' && typeof bodyId !== 'undefined' && bodyId > 0) {
let body = bodyId === 2 ? bhs.body2 : bhs.body1;
if (!bhs.bytes.includes(byte) && byte) bhs.bytes.push(byte);
if (typeof data.heatSetpoint !== 'undefined') body.heatSetpoint = data.heatSetpoint;
if (typeof data.coolSetpoint !== 'undefined') body.coolSetpoint = data.coolSetpoint;
if (typeof data.heatMode !== 'undefined') body.heatMode = data.heatMode;
}
if (!bhs.processing && bhs.bytes.length > 0) {
bhs.processing = true;
bhs._processingStartTime = Date.now();
let byte2 = bhs.bytes.shift();
// v3.004+: payload layout matches the wireless remote's Action 168 msgType 0 (Setpoints/HeatMode).
// Observed: [0,0,0,0,0,3,...,0xA0, YY,MM,DD,HH,MM, poolHeat,poolCool, spaHeat,spaCool, poolMode,spaMode, 15, ...zeros]
// v1.x: uses legacy sensor-calibration-heavy payload (kept for backward compatibility).
const isIntellicenterV3 = sys.equipment.isIntellicenterV3 === true;
let payload: number[];
if (isIntellicenterV3) {
const dt = new Date();
const yy = dt.getFullYear() - 2000;
const mm = dt.getMonth() + 1;
const dd = dt.getDate();
const hh = dt.getHours();
const min = dt.getMinutes();
payload = [
0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0,
160, yy, mm, dd, hh, min,
bhs.body1.heatSetpoint, bhs.body1.coolSetpoint, bhs.body2.heatSetpoint, bhs.body2.coolSetpoint,
bhs.body1.heatMode, bhs.body2.heatMode,
15,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
];
} else {
let fnToByte = function (num) { return num < 0 ? Math.abs(num) | 0x80 : Math.abs(num) || 0; };
payload = [0, 0, byte2, 1,
fnToByte(sys.equipment.tempSensors.getCalibration('water1')),
fnToByte(sys.equipment.tempSensors.getCalibration('solar1')),
fnToByte(sys.equipment.tempSensors.getCalibration('air')),
fnToByte(sys.equipment.tempSensors.getCalibration('water2')),
fnToByte(sys.equipment.tempSensors.getCalibration('solar2')),
fnToByte(sys.equipment.tempSensors.getCalibration('water3')),
fnToByte(sys.equipment.tempSensors.getCalibration('solar3')),
fnToByte(sys.equipment.tempSensors.getCalibration('water4')),
fnToByte(sys.equipment.tempSensors.getCalibration('solar4')),
0,
0x10 | (sys.general.options.clockMode === 24 ? 0x40 : 0x00) | (sys.general.options.adjustDST ? 0x80 : 0x00) | (sys.general.options.clockSource === 'internet' ? 0x20 : 0x00),
89, 27, 110, 3, 0, 0,
bhs.body1.heatSetpoint, bhs.body1.coolSetpoint, bhs.body2.heatSetpoint, bhs.body2.coolSetpoint, bhs.body1.heatMode, bhs.body2.heatMode, 0, 0, 15,
sys.general.options.pumpDelay ? 1 : 0, sys.general.options.cooldownDelay ? 1 : 0, 0, 100, 0, 0, 0, 0, sys.general.options.manualPriority ? 1 : 0, sys.general.options.manualHeat ? 1 : 0, 0
];
}
let out = Outbound.create({
// v3.004+: write must be sent to OCP (16); leaving dest undefined defaults to broadcast (15) and is ignored.
dest: isIntellicenterV3 ? 16 : undefined,
action: 168,
payload: payload,
retries: 2,
response: IntelliCenterBoard.getAckResponse(168)
});
try {
await out.sendAsync();
let body1 = sys.bodies.getItemById(1);
let sbody1 = state.temps.bodies.getItemById(1);
body1.heatMode = sbody1.heatMode = bhs.body1.heatMode;
body1.heatSetpoint = sbody1.heatSetpoint = bhs.body1.heatSetpoint;
body1.coolSetpoint = sbody1.coolSetpoint = bhs.body1.coolSetpoint;
// Body2 exists on many non-shared IntelliCenter systems (e.g. Pool+Spa). Keep state in sync regardless of shared/dual flags.
if (sys.bodies.length > 1) {
let body2 = sys.bodies.getItemById(2);
let sbody2 = state.temps.bodies.getItemById(2);
body2.heatMode = sbody2.heatMode = bhs.body2.heatMode;
body2.heatSetpoint = sbody2.heatSetpoint = bhs.body2.heatSetpoint;
body2.coolSetpoint = sbody2.coolSetpoint = bhs.body2.coolSetpoint;
}
state.emitEquipmentChanges();
} catch (err) {
logger.error(`Error in queueBodyHeatSettings: ${err.message}`);
bhs.processing = false;
bhs.bytes = [];
delete bhs._processingStartTime;
throw (err);
}
finally {
bhs.processing = false;
bhs.bytes = [];
delete bhs._processingStartTime;
}
return true;
}
else {
// Try every second to re-try if we have a bunch at once.
if (bhs.bytes.length > 0) {
setTimeout(async () => {
try {
await this.queueBodyHeatSettings();
} catch (err) {
logger.error(`Error sending queued body setpoint message: ${err.message}`);
throw (err);
}
}, 3000);
}
else {
bhs.processing = false;
delete bhs._processingStartTime;
}
return true;
}
}
public async setBodyAsync(obj: any): Promise {
let byte = 0;
let id = parseInt(obj.id, 10);
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('Body Id is not defined', obj.id, 'Body'));
let body = sys.bodies.getItemById(id, false);
switch (body.id) {
case 1:
byte = 0;
break;
case 2:
byte = 2;
break;
case 3:
byte = 1;
break;
case 4:
byte = 3;
break;
}
try {
if (typeof obj.name === 'string' && obj.name !== body.name) {
const bodyName = normalizeIntelliCenterName(obj.name, body.name);
let out = Outbound.create({
action: 168,
payload: [13, 0, byte],
retries: 5,
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString(bodyName, 16);
await out.sendAsync();
body.name = bodyName;
}
if (typeof obj.capacity !== 'undefined') {
let cap = parseInt(obj.capacity, 10);
if (cap !== body.capacity) {
let out = Outbound.create({
action: 168,
retries: 2,
response: IntelliCenterBoard.getAckResponse(168),
payload: [13, 0, byte + 4, Math.floor(cap / 1000)]
});
await out.sendAsync();
body.capacity = cap;
}
}
if (typeof obj.manualHeat !== 'undefined') {
let manHeat = utils.makeBool(obj.manualHeat);
if (manHeat !== body.manualHeat) {
let out = Outbound.create({
action: 168,
payload: [13, 0, byte + 8, manHeat ? 1 : 0]
});
await out.sendAsync();
body.manualHeat = manHeat;
}
}
if (typeof obj.showInDashboard !== 'undefined') {
let sbody = state.temps.bodies.getItemById(id, false);
body.showInDashboard = sbody.showInDashboard = utils.makeBool(obj.showInDashboard);
}
return body;
}
catch (err) { return Promise.reject(err); }
}
public async setHeatModeAsync(body: Body, mode: number): Promise {
let modes = sys.board.bodies.getHeatModesV2(body.id);
if (typeof modes.find(elem => elem.val === mode) === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Cannot set heat mode to ${mode} since this is not a valid mode for the ${body.name}`, 'Body', mode));
await this.queueBodyHeatSettings(body.id, body.id === 2 ? 23 : 22, { heatMode: mode });
return state.temps.bodies.getItemById(body.id);
/*
let byte2 = 22;
let body1 = sys.bodies.getItemById(1);
let body2 = sys.bodies.getItemById(2);
let heat1 = body1.heatSetpoint || 78;
let cool1 = body1.coolSetpoint || 100;
let heat2 = body2.heatSetpoint || 78;
let cool2 = body2.coolSetpoint || 103;
let mode1 = body1.heatMode || 1;
let mode2 = body2.heatMode || 1;
let bitopts = 0;
if (sys.general.options.clockSource) bitopts += 32;
if (sys.general.options.clockMode === 24) bitopts += 64;
if (sys.general.options.adjustDST) bitopts += 128;
switch (body.id) {
case 1:
byte2 = 22;
mode1 = mode;
break;
case 2:
byte2 = 23;
mode2 = mode;
break;
}
return new Promise((resolve, reject) => {
let out = Outbound.create({
action: 168,
payload: [0, 0, byte2, 1, 0, 0, 129, 0, 0, 0, 0, 0, 0, 0, bitopts, 89, 27, 110, 3, 0, 0,
heat1, cool1, heat2, cool2, mode1, mode2, 0, 0, 15,
sys.general.options.pumpDelay ? 1 : 0, sys.general.options.cooldownDelay ? 1 : 0, 0, 100, 0, 0, 0, 0, sys.general.options.manualPriority ? 1 : 0, sys.general.options.manualHeat ? 1 : 0, 0],
retries: 5,
response: IntelliCenterBoard.getAckResponse(168),
onComplete: (err, msg) => {
if (err) reject(err);
else {
body.heatMode = mode;
let bstate = state.temps.bodies.getItemById(body.id);
bstate.heatMode = mode;
state.emitEquipmentChanges();
resolve(bstate);
}
}
})
await out.sendAsync();
});
*/
}
public async setHeatSetpointAsync(body: Body, setPoint: number): Promise {
if (typeof setPoint === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Cannot set heat setpoint to undefined for the ${body.name}`, 'Body', setPoint));
else if (setPoint < 0 || setPoint > 110) return Promise.reject(new InvalidEquipmentDataError(`Cannot set heat setpoint to ${setPoint} for the ${body.name}`, 'Body', setPoint));
await this.queueBodyHeatSettings(body.id, body.id === 2 ? 20 : 18, { heatSetpoint: setPoint });
return state.temps.bodies.getItemById(body.id);
/*
let byte2 = 18;
let body1 = sys.bodies.getItemById(1);
let body2 = sys.bodies.getItemById(2);
let heat1 = body1.heatSetpoint || 78;
let cool1 = body1.coolSetpoint || 100;
let heat2 = body2.heatSetpoint || 78;
let cool2 = body2.coolSetpoint || 103;
switch (body.id) {
case 1:
byte2 = 18;
heat1 = setPoint;
break;
case 2:
byte2 = 20;
heat2 = setPoint;
break;
}
let bitopts = 0;
if (sys.general.options.clockSource) bitopts += 32;
if (sys.general.options.clockMode === 24) bitopts += 64;
if (sys.general.options.adjustDST) bitopts += 128;
// 6 15 17 18 21 22 24 25
//[255, 0, 255][165, 63, 15, 16, 168, 41][0, 0, 18, 1, 0, 0, 129, 0, 0, 0, 0, 0, 0, 0, 176, 89, 27, 110, 3, 0, 0, 89, 100, 98, 100, 0, 0, 0, 0, 15, 0, 0, 0, 0, 100, 0, 0, 0, 0, 0, 0][5, 243]
//[255, 0, 255][165, 63, 15, 16, 168, 41][0, 0, 18, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 176, 235, 27, 167, 1, 0, 0, 89, 81, 98, 103, 5, 0, 0, 0, 15, 0, 0, 0, 0, 100, 0, 0, 0, 0, 0, 0][6, 48]
let out = Outbound.create({
action: 168,
response: IntelliCenterBoard.getAckResponse(168),
retries: 5,
payload: [0, 0, byte2, 1, 0, 0, 129, 0, 0, 0, 0, 0, 0, 0, bitopts, 89, 27, 110, 3, 0, 0,
heat1, cool1, heat2, cool2, body1.heatMode || 1, body2.heatMode || 1, 0, 0, 15,
sys.general.options.pumpDelay ? 1 : 0, sys.general.options.cooldownDelay ? 1 : 0, 0, 100, 0, 0, 0, 0, sys.general.options.manualPriority ? 1 : 0, sys.general.options.manualHeat ? 1 : 0, 0]
});
return new Promise((resolve, reject) => {
out.onComplete = (err, msg) => {
if (err) reject(err);
else {
let bstate = state.temps.bodies.getItemById(body.id);
body.heatSetpoint = bstate.heatSetpoint = setPoint;
resolve(bstate);
}
};
await out.sendAsync();
});
*/
}
public async setCoolSetpointAsync(body: Body, setPoint: number): Promise {
if (typeof setPoint === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Cannot set cooling setpoint to undefined for the ${body.name}`, 'Body', setPoint));
else if (setPoint < 0 || setPoint > 110) return Promise.reject(new InvalidEquipmentDataError(`Cannot set cooling setpoint to ${setPoint} for the ${body.name}`, 'Body', setPoint));
await this.queueBodyHeatSettings(body.id, body.id === 2 ? 21 : 19, { coolSetpoint: setPoint });
return state.temps.bodies.getItemById(body.id);
/*
let byte2 = 19;
let body1 = sys.bodies.getItemById(1);
let body2 = sys.bodies.getItemById(2);
let heat1 = body1.heatSetpoint || 78;
let cool1 = body1.coolSetpoint || 100;
let heat2 = body2.heatSetpoint || 78;
let cool2 = body2.coolSetpoint || 103;
switch (body.id) {
case 1:
byte2 = 19;
cool1 = setPoint;
break;
case 2:
byte2 = 21;
cool2 = setPoint;
break;
}
let bitopts = 0;
if (sys.general.options.clockSource) bitopts += 32;
if (sys.general.options.clockMode === 24) bitopts += 64;
if (sys.general.options.adjustDST) bitopts += 128;
// 6 15 17 18 21 22 24 25
//[255, 0, 255][165, 63, 15, 16, 168, 41][0, 0, 18, 1, 0, 0, 129, 0, 0, 0, 0, 0, 0, 0, 176, 89, 27, 110, 3, 0, 0, 89, 100, 98, 100, 0, 0, 0, 0, 15, 0, 0, 0, 0, 100, 0, 0, 0, 0, 0, 0][5, 243]
//[255, 0, 255][165, 63, 15, 16, 168, 41][0, 0, 18, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 176, 235, 27, 167, 1, 0, 0, 89, 81, 98, 103, 5, 0, 0, 0, 15, 0, 0, 0, 0, 100, 0, 0, 0, 0, 0, 0][6, 48]
let out = Outbound.create({
action: 168,
response: IntelliCenterBoard.getAckResponse(168),
retries: 5,
payload: [0, 0, byte2, 1, 0, 0, 129, 0, 0, 0, 0, 0, 0, 0, bitopts, 89, 27, 110, 3, 0, 0,
heat1, cool1, heat2, cool2, body1.heatMode || 1, body2.heatMode || 1, 0, 0, 15,
sys.general.options.pumpDelay ? 1 : 0, sys.general.options.cooldownDelay ? 1 : 0, 0, 100, 0, 0, 0, 0, sys.general.options.manualPriority ? 1 : 0, sys.general.options.manualHeat ? 1 : 0, 0]
});
return new Promise((resolve, reject) => {
out.onComplete = (err, msg) => {
if (err) reject(err);
else {
let bstate = state.temps.bodies.getItemById(body.id);
body.coolSetpoint = bstate.coolSetpoint = setPoint;
resolve(bstate);
}
};
await out.sendAsync();
});
*/
}
// IntelliCenter: body heat modes are encoded using IntelliCenter heatSources values (1=off,3=solar,4=solarpref,5=ultratemp,6=ultratemppref,...),
// not the *Touch heatModes value map. Returning heatSources here fixes dashPanel's blank entries and makes validation accept the right values.
public getHeatSources(bodyId: number) {
const sources = super.getHeatSources(bodyId);
// IntelliCenter v3.004+: keep body-level picklists aligned with what the controller actually presents.
// Preferred modes are not reliably shown/used by Pentair clients on v3, so suppress them here (board-specific),
// rather than gating shared SystemBoard behavior.
if (sys.equipment.isIntellicenterV3) {
return sources.filter(s => {
const name = s && (s as any).name;
return name !== 'solarpref' && name !== 'ultratemppref' && name !== 'heatpumppref';
});
}
return sources;
}
public getHeatModes(bodyId: number) {
const sources = this.getHeatSources(bodyId);
// remove "nochange" which is not a valid body mode selection in dashPanel
return sources.filter(s => s && (s as any).name !== 'nochange');
}
public getHeatModesV2(bodyId: number) {
sys.board.heaters.updateHeaterServices();
let heatModes = [];
let heatTypes = (sys.board.heaters as IntelliCenterHeaterCommands).getInstalledHeaterTypesV2(bodyId);
let combustionInstalled = (heatTypes.gas > 0 || heatTypes.mastertemp > 0 || heatTypes.maxetherm > 0 || heatTypes.eti250 > 0);
heatModes.push(this.board.valueMaps.heatSources.transformByName('off'));
if (heatTypes.hybrid > 0) {
heatModes.push(this.board.valueMaps.heatSources.transformByName('hybheat'));
heatModes.push(this.board.valueMaps.heatSources.transformByName('hybheatpump'));
heatModes.push(this.board.valueMaps.heatSources.transformByName('hybhybrid'));
heatModes.push(this.board.valueMaps.heatSources.transformByName('hybdual'));
}
if (heatTypes.gas > 0) heatModes.push(this.board.valueMaps.heatSources.transformByName('heater'));
if (heatTypes.mastertemp > 0) heatModes.push(this.board.valueMaps.heatSources.transformByName('mtheater'));
if (heatTypes.maxetherm > 0) heatModes.push(this.board.valueMaps.heatSources.transformByName('maxetherm'));
if (heatTypes.eti250 > 0) heatModes.push(this.board.valueMaps.heatSources.transformByName('eti250'));
if (heatTypes.solar > 0) {
heatModes.push(this.board.valueMaps.heatSources.transformByName('solar'));
if (combustionInstalled) heatModes.push(this.board.valueMaps.heatSources.transformByName('solarpref'));
}
if (heatTypes.ultratemp > 0) {
heatModes.push(this.board.valueMaps.heatSources.transformByName('ultratemp'));
if (combustionInstalled) heatModes.push(this.board.valueMaps.heatSources.transformByName('ultratemppref'));
}
if (heatTypes.heatpump > 0) {
heatModes.push(this.board.valueMaps.heatSources.transformByName('heatpump'));
if (combustionInstalled) heatModes.push(this.board.valueMaps.heatSources.transformByName('heatpumppref'));
}
return heatModes;
}
}
class IntelliCenterScheduleCommands extends ScheduleCommands {
_lastScheduleCheck: number = 0;
public async setScheduleAsync(data: any): Promise {
if (typeof data.id !== 'undefined') {
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, data.id <= 0);
let ssched = state.schedules.getItemById(id, data.id <= 0);
let schedType = typeof data.scheduleType !== 'undefined' ? data.scheduleType : sched.scheduleType;
if (typeof schedType === 'undefined') schedType = 0; // 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 || 0;
let heatSetpoint = typeof data.heatSetpoint !== 'undefined' ? data.heatSetpoint : sched.heatSetpoint;
let coolSetpoint = typeof data.coolSetpoint !== 'undefined' ? data.coolSetpoint : sched.coolSetpoint || 100;
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);
let display = typeof data.display !== 'undefined' ? data.display : sched.display || 0;
let endTimeOffset = typeof data.endTimeOffset !== 'undefined' ? data.endTimeOffset : sched.endTimeOffset;
let startTimeOffset = typeof data.startTimeOffset !== 'undefined' ? data.startTimeOffset : sched.startTimeOffset;
// 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
// At this point we should have all the data. Validate it.
if (!sys.board.valueMaps.scheduleTypes.valExists(schedType)) return Promise.reject(new InvalidEquipmentDataError(`Invalid schedule type; ${schedType}`, 'Schedule', schedType));
if (!sys.board.valueMaps.scheduleTimeTypes.valExists(startTimeType)) return Promise.reject(new InvalidEquipmentDataError(`Invalid start time type; ${startTimeType}`, 'Schedule', startTimeType));
if (!sys.board.valueMaps.scheduleTimeTypes.valExists(endTimeType)) return Promise.reject(new InvalidEquipmentDataError(`Invalid end time type; ${endTimeType}`, 'Schedule', endTimeType));
if (!sys.board.valueMaps.heatSources.valExists(heatSource)) return Promise.reject(new InvalidEquipmentDataError(`Invalid heat source: ${heatSource}`, 'Schedule', heatSource));
// RKS: During the transition to 1.047 they invalidated the 32 heat source and 0 was turned into no change. This is no longer needed
// as we now have the correct mapping.
//if (sys.equipment.controllerFirmware === '1.047') {
// if (heatSource === 32 || heatSource === 0) heatSource = 1;
//}
if (heatSetpoint < 0 || heatSetpoint > 104) 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)
return Promise.reject(new InvalidEquipmentDataError(`Invalid circuit reference: ${circuit}`, 'Schedule', circuit));
// RKS: 06-28-20 -- Turns out a schedule without any days that it is to run is perfectly valid. The expectation is that it will never run.
//if (schedType === 128 && schedDays === 0) return Promise.reject(new InvalidEquipmentDataError(`Invalid schedule days: ${schedDays}. You must supply days that the schedule is to run.`, 'Schedule', schedDays));
// If we make it here we can make it anywhere.
let runOnce = schedType !== 128 ? 129 : 128;
if (startTimeType !== 0) runOnce |= (1 << (startTimeType + 1));
if (endTimeType !== 0) runOnce |= (1 << (endTimeType + 3));
let schedGroup = typeof data.schedGroup !== 'undefined' ? parseInt(data.schedGroup, 10) : sched.schedGroup || 0;
if (schedGroup === 1) runOnce |= 0x40;
// This was always the cooling setpoint for ultratemp.
//let flags = (circuit === 1 || circuit === 6) ? 81 : 100;
// v3.004+ uses big-endian for 16-bit time values
let startTimeLo = startTime - Math.floor(startTime / 256) * 256;
let startTimeHi = Math.floor(startTime / 256);
let endTimeLo = endTime - Math.floor(endTime / 256) * 256;
let endTimeHi = Math.floor(endTime / 256);
let isV3 = sys.equipment.isIntellicenterV3;
let out = Outbound.createMessage(168, [
3
, 0
, id - 1 // IntelliCenter schedules start at 0.
, isV3 ? startTimeHi : startTimeLo
, isV3 ? startTimeLo : startTimeHi
, isV3 ? endTimeHi : endTimeLo
, isV3 ? endTimeLo : endTimeHi
, circuit - 1
, runOnce
, schedDays
, startDate.getMonth() + 1
, startDate.getDate()
, startDate.getFullYear() - 2000
, heatSource
, heatSetpoint
, coolSetpoint
],
0
);
out.response = IntelliCenterBoard.getAckResponse(168);
out.retries = 5;
await out.sendAsync(); // Send it off in a letter to yourself.
sched = sys.schedules.getItemById(id, true);
ssched = state.schedules.getItemById(id, true);
sched.circuit = ssched.circuit = circuit;
sched.scheduleDays = ssched.scheduleDays = schedDays;
sched.scheduleType = ssched.scheduleType = schedType;
sched.schedGroup = ssched.schedGroup = schedGroup;
sched.heatSetpoint = ssched.heatSetpoint = heatSetpoint;
sched.coolSetpoint = ssched.coolSetpoint = coolSetpoint;
sched.heatSource = ssched.heatSource = heatSource;
sched.startTime = ssched.startTime = startTime;
sched.endTime = ssched.endTime = endTime;
sched.startTimeType = ssched.startTimeType = startTimeType;
sched.endTimeType = ssched.endTimeType = endTimeType;
sched.startDate = ssched.startDate = startDate;
sched.startMonth = startDate.getMonth() + 1;
sched.startYear = startDate.getFullYear();
sched.startDay = startDate.getDate();
ssched.startDate = startDate;
ssched.isActive = sched.isActive = true;
ssched.display = sched.display = display;
ssched.emitEquipmentChange();
ssched.startTimeOffset = sched.startTimeOffset = startTimeOffset;
ssched.endTimeOffset = sched.endTimeOffset = endTimeOffset;
return sched;
}
else
return Promise.reject(new InvalidEquipmentIdError('No schedule information provided', undefined, 'Schedule'));
}
public syncScheduleStates() {
// This is triggered from the 204 message in IntelliCenter. We will
// be checking to ensure it does not load the server so we only do this every 10 seconds.
if (this._lastScheduleCheck > new Date().getTime() - 10000) return;
try {
// The call below also calculates the schedule window either the current or next.
ncp.schedules.triggerSchedules(); // At this point we are not adding Nixie schedules to IntelliCenter but this will trigger
// the proper time windows if they exist.
// Check each running circuit/feature to see when it will be going off.
let scheds = state.schedules.getActiveSchedules();
let circs: { state: ICircuitState, endTime: number }[] = [];
for (let i = 0; i < scheds.length; i++) {
let ssched = scheds[i];
if (!ssched.isOn || ssched.disabled || !ssched.isActive) continue;
let c = circs.find(x => x.state.id === ssched.circuit);
if (typeof c === 'undefined') {
let cstate = state.circuits.getInterfaceById(ssched.circuit);
c = { state: cstate, endTime: ssched.scheduleTime.endTime.getTime() };
circs.push;
}
if (c.endTime < ssched.scheduleTime.endTime.getTime()) c.endTime = ssched.scheduleTime.endTime.getTime();
}
for (let i = 0; i < circs.length; i++) {
let c = circs[i];
if (c.state.endTime.getTime() !== c.endTime) {
c.state.endTime = new Timestamp(new Date(c.endTime));
c.state.emitEquipmentChange();
}
}
this._lastScheduleCheck = new Date().getTime();
} catch (err) { logger.error(`Error synchronizing schedule states`); }
}
public async deleteScheduleAsync(data: any): Promise {
if (typeof data.id !== 'undefined') {
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);
let startDate = sched.startDate;
if (typeof startDate === 'undefined' || isNaN(startDate.getTime())) startDate = new Date();
let out = Outbound.create({
action: 168,
payload: [
3
, 0
, id - 1 // IntelliCenter schedules start at 0.
, 0
, 0
, 0
, 0
, 255
, 0
, 0
, startDate.getMonth() + 1
, startDate.getDay() || 0
, startDate.getFullYear() - 2000
, 0 // This changed to 0 to mean no change in 1.047
, 78
, 100
],
retries: 5,
response: IntelliCenterBoard.getAckResponse(168)
});
await out.sendAsync();
sys.schedules.removeItemById(id);
state.schedules.removeItemById(id);
ssched.emitEquipmentChange();
ssched.isActive = sched.isActive = false;
return sched;
}
else
return Promise.reject(new InvalidEquipmentIdError('No schedule information provided', undefined, 'Schedule'));
}
// RKS: 06-24-20 - Need to talk to Russ. This needs to go away and reconstituted in the async.
public setSchedule(sched: Schedule, obj: any) { }
}
class IntelliCenterHeaterCommands extends HeaterCommands {
private createHeaterConfigMessage(heater: Heater): Outbound {
let out = Outbound.createMessage(
168, [10, 0, heater.id, heater.type, heater.body, heater.differentialTemp, heater.startTempDelta, heater.stopTempDelta, heater.coolingEnabled ? 1 : 0,
heater.cooldownDelay || 6, heater.address,
//, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // Name
heater.efficiencyMode, heater.maxBoostTemp, heater.economyTime], 0);
out.insertPayloadString(11, heater.name, 16);
return out;
}
public getInstalledHeaterTypes(body?: number): any {
const heaters = sys.heaters.get();
const types = sys.board.valueMaps.heaterTypes.toArray();
const inst: any = { total: 0 };
for (let i = 0; i < types.length; i++) if (types[i].name !== 'none') inst[types[i].name] = 0;
const matchesBody = (heaterBody: number, requestedBody: number): boolean => {
// Shared-body sentinel used in existing logic.
if (heaterBody === 32) return requestedBody <= 2;
// IntelliCenter reports body associations using body circuit IDs in config payloads.
// Map those IDs so per-body heater options stay accurate in runtime state/UI.
if (heaterBody === 6) return sys.equipment.shared ? requestedBody <= 2 : requestedBody === 1; // Pool circuit
if (heaterBody === 1) return sys.equipment.shared ? requestedBody <= 2 : requestedBody === 2; // Spa circuit
if (heaterBody === 12) return requestedBody === 3; // Body 3 circuit
if (heaterBody === 22) return requestedBody === 4; // Body 4 circuit
// Fallback to existing generic formats.
return requestedBody === heaterBody + 1 || requestedBody === heaterBody;
};
for (let i = 0; i < heaters.length; i++) {
const heater = heaters[i];
if (typeof body !== 'undefined' && typeof heater.body !== 'undefined') {
if (!matchesBody(heater.body, body)) continue;
}
const 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 getInstalledHeaterTypesV2(body?: number): any {
const heaters = sys.heaters.get();
const types = sys.board.valueMaps.heaterTypes.toArray();
const inst: any = { total: 0 };
for (let i = 0; i < types.length; i++) if (types[i].name !== 'none') inst[types[i].name] = 0;
const matchesBody = (heaterBody: number, requestedBody: number): boolean => {
if (heaterBody === 32) return requestedBody <= 2;
if (heaterBody === 6) return requestedBody === 1;
if (heaterBody === 1) return requestedBody === 2;
if (heaterBody === 12) return requestedBody === 3;
if (heaterBody === 22) return requestedBody === 4;
return requestedBody === heaterBody + 1 || requestedBody === heaterBody;
};
for (let i = 0; i < heaters.length; i++) {
const heater = heaters[i];
if (typeof body !== 'undefined' && typeof heater.body !== 'undefined') {
if (!matchesBody(heater.body, body)) continue;
}
const 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 async setHeater(heater: Heater, obj?: any) {
super.setHeater(heater, obj);
let out = this.createHeaterConfigMessage(heater);
await out.sendAsync();
}
public async setHeaterAsync(obj: any): Promise {
if (obj.master === 1 || parseInt(obj.id, 10) > 255) return super.setHeaterAsync(obj);
let id = typeof obj.id === 'undefined' ? -1 : parseInt(obj.id, 10);
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('Heater Id is not valid.', obj.id, 'Heater'));
let heater: Heater;
if (id <= 0) {
if (sys.heaters.length >= 5) return Promise.reject(new InvalidEquipmentDataError(`Maximum of 5 heaters allowed`, 'Heater', id));
id = sys.heaters.getNextEquipmentId(new EquipmentIdRange(1, 16));
}
heater = sys.heaters.getItemById(id, false);
let type = 0;
if (typeof obj.type === 'undefined') {
if (heater.type === 0 || typeof heater.type === 'undefined')
return Promise.reject(new InvalidEquipmentDataError(`Heater type was not specified for new heater`, 'Heater', obj.type));
type = heater.type;
} else {
if (typeof obj.type === 'string' && isNaN(parseInt(obj.type, 10)))
type = sys.board.valueMaps.heaterTypes.getValue(obj.type);
else
type = parseInt(obj.type, 10);
if (!sys.board.valueMaps.heaterTypes.valExists(type)) return Promise.reject(new InvalidEquipmentDataError(`Heater type ${obj.type} is not valid`, 'Heater', obj.type));
heater.type = type;
}
let htype = sys.board.valueMaps.heaterTypes.transform(type);
let address = heater.address || 112;
if (htype.hasAddress) {
if (typeof obj.address !== 'undefined') {
address = parseInt(obj.address, 10);
if (isNaN(address) || address < 112 || address > 128) return Promise.reject(new InvalidEquipmentDataError(`Invalid Heater address was specified`, 'Heater', obj.address));
}
}
let differentialTemp = heater.differentialTemp || 6;
if (typeof obj.differentialTemp !== 'undefined') {
differentialTemp = parseInt(obj.differentialTemp, 10);
if (isNaN(differentialTemp) || differentialTemp < 0) return Promise.reject(new InvalidEquipmentDataError(`Invalid Differential Temp was specified`, 'Heater', obj.differentialTemp));
}
let efficiencyMode = heater.efficiencyMode || 0;
if (typeof obj.efficiencyMode !== 'undefined') {
efficiencyMode = parseInt(obj.efficiencyMode, 10);
if (isNaN(efficiencyMode) || efficiencyMode < 0) return Promise.reject(new InvalidEquipmentDataError(`Invalid Efficiency Mode was specified`, 'Heater', obj.efficiencyMode));
}
let maxBoostTemp = heater.maxBoostTemp || 0;
if (typeof obj.maxBoostTemp !== 'undefined') {
maxBoostTemp = parseInt(obj.maxBoostTemp, 10);
if (isNaN(maxBoostTemp) || maxBoostTemp < 0) return Promise.reject(new InvalidEquipmentDataError(`Invalid Max Boost Temp was specified`, 'Heater', obj.maxBoostTemp));
}
let startTempDelta = heater.startTempDelta || 5;
if (typeof obj.startTempDelta !== 'undefined') {
startTempDelta = parseInt(obj.startTempDelta, 10);
if (isNaN(startTempDelta) || startTempDelta < 0) return Promise.reject(new InvalidEquipmentDataError(`Invalid Start Temp Delta was specified`, 'Heater', obj.startTempDelta));
}
let stopTempDelta = heater.stopTempDelta || 3;
if (typeof obj.stopTempDelta !== 'undefined') {
stopTempDelta = parseInt(obj.stopTempDelta, 10);
if (isNaN(stopTempDelta) || stopTempDelta < 0) return Promise.reject(new InvalidEquipmentDataError(`Invalid Stop Temp Delta was specified`, 'Heater', obj.stopTempDelta));
}
let economyTime = heater.economyTime || 1;
if (typeof obj.economyTime !== 'undefined') {
economyTime = parseInt(obj.economyTime, 10);
if (isNaN(economyTime) || economyTime < 0) return Promise.reject(new InvalidEquipmentDataError(`Invalid Economy Time was specified`, 'Heater', obj.economyTime));
}
let body = heater.body || 0;
if (typeof obj.body !== 'undefined') {
body = parseInt(obj.body, 10);
if (isNaN(obj.body) && typeof obj.body === 'string') body = sys.board.valueMaps.bodies.getValue(obj.body);
if (typeof body === 'undefined' || isNaN(body)) return Promise.reject(new InvalidEquipmentDataError(`Invalid Body was specified`, 'Heater', obj.body));
}
if (htype.hasAddress) {
if (isNaN(address) || address < 112 || address > 128) return Promise.reject(new InvalidEquipmentDataError(`Invalid Heater address was specified`, 'Heater', obj.address));
for (let i = 0; i < sys.heaters.length; i++) {
let h = sys.heaters.getItemByIndex(i);
if (h.id === id) continue;
let t = sys.board.valueMaps.heaterTypes.transform(h.type);
if (!t.hasAddress) continue;
if (h.address === address) return Promise.reject(new InvalidEquipmentDataError(`Heater id# ${h.id} ${t.desc} is already communicating on this address.`, 'Heater', obj.address));
}
}
let cooldownDelay = heater.cooldownDelay || 5;
if (typeof obj.cooldownDelay !== 'undefined') {
cooldownDelay = parseInt(obj.cooldownDelay, 10);
if (isNaN(cooldownDelay) || cooldownDelay < 0 || cooldownDelay > 20) return Promise.reject(new InvalidEquipmentDataError(`Invalid cooldown delay was specified`, 'Heater', obj.cooldownDelay));
}
let out = Outbound.create({
action: 168,
payload: [10, 0, heater.id - 1,
type,
body,
cooldownDelay,
startTempDelta,
stopTempDelta,
(typeof obj.coolingEnabled !== 'undefined' ? utils.makeBool(obj.coolingEnabled) : utils.makeBool(heater.coolingEnabled)) ? 1 : 0,
differentialTemp,
address
],
retries: 5,
response: IntelliCenterBoard.getAckResponse(168)
});
let nameStr = typeof obj.name !== 'undefined' ? obj.name.toString().substring(0, 15) : heater.name;
out.appendPayloadString(nameStr, 16);
out.appendPayloadByte(efficiencyMode);
out.appendPayloadByte(maxBoostTemp);
out.appendPayloadByte(economyTime);
await out.sendAsync();
heater = sys.heaters.getItemById(heater.id, true);
let hstate = state.heaters.getItemById(heater.id, true);
hstate.type = heater.type = type;
heater.body = body;
heater.address = address;
hstate.name = heater.name = nameStr;
heater.coolingEnabled = typeof obj.coolingEnabled !== 'undefined' ? utils.makeBool(obj.coolingEnabled) : utils.makeBool(heater.coolingEnabled);
heater.differentialTemp = differentialTemp;
heater.economyTime = economyTime;
heater.startTempDelta = startTempDelta;
heater.stopTempDelta = stopTempDelta;
heater.cooldownDelay = cooldownDelay;
sys.board.heaters.updateHeaterServices();
sys.board.heaters.syncHeaterStates();
return heater;
}
public async deleteHeaterAsync(obj): Promise {
if (obj.master === 1 || parseInt(obj.id, 10) > 255) return await super.deleteHeaterAsync(obj);
let id = parseInt(obj.id, 10);
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('Cannot delete. Heater Id is not valid.', obj.id, 'Heater'));
let heater = sys.heaters.getItemById(id);
let out = Outbound.create({
action: 168,
payload: [10, 0, heater.id - 1,
0,
1,
5,
5,
3,
0,
6,
112
],
retries: 5,
response: IntelliCenterBoard.getAckResponse(168)
});
out.appendPayloadString('', 16);
out.appendPayloadByte(3);
out.appendPayloadByte(5);
out.appendPayloadByte(1);
await out.sendAsync();
heater.isActive = false;
sys.heaters.removeItemById(id);
state.heaters.removeItemById(id);
return heater;
}
public updateHeaterServices() {
let htypes = sys.board.heaters.getInstalledHeaterTypes();
let solarInstalled = htypes.solar > 0;
let heatPumpInstalled = htypes.heatpump > 0;
let gasHeaterInstalled = htypes.gas > 0;
let ultratempInstalled = htypes.ultratemp > 0;
let mastertempInstalled = htypes.mastertemp > 0;
let maxethermInstalled = htypes.maxetherm > 0;
let eti250Installed = htypes.eti250 > 0;
let combustionHeaterInstalled = gasHeaterInstalled || mastertempInstalled || maxethermInstalled || eti250Installed;
// RKS: 09-26-20 This is a hack to maintain backward compatability with fw versions 1.04 and below. Ultratemp is not
// supported on 1.04 and below.
if (parseFloat(sys.equipment.controllerFirmware) > 1.04) {
// The heat mode options are
// 1 = Off
// 2 = Gas Heater
// 3 = Solar Heater
// 4 = Solar Preferred
// 5 = UltraTemp Only
// 6 = UltraTemp Preferred???? This might be 22
// 7 = Hybrid Gas Only
// 8 = Hybrid Heatpump Only
// 9 = Hybrid - Hybrid Mode
// 10 = Hybrid - Dual Heat
// 9 = Heat Pump
// 25 = Heat Pump Preferred
// ?? = Hybrid
// The heat source options are
// 0 = No Change
// 1 = Off
// 2 = Gas Heater
// 3 = Solar Heater
// 4 = Solar Preferred
// 5 = Heat Pump
if (sys.heaters.length > 0) sys.board.valueMaps.heatSources = new byteValueMap([[1, { name: 'off', desc: 'Off' }]]);
sys.board.valueMaps.heatModes = new byteValueMap([[1, { name: 'off', desc: 'Off' }]]);
if (htypes.hybrid > 0) {
sys.board.valueMaps.heatModes.merge([
[7, { name: 'hybheat', desc: 'Hybrid - Gas Only Mode' }],
[8, { name: 'hybheatpump', desc: 'Hybrid - Heat Pump Only Mode' }],
[9, { name: 'hybhybrid', desc: 'Hybrid - Hybrid Mode' }],
[10, { name: 'hybdual', desc: 'Hybrid - Dual Mode' }]
]);
sys.board.valueMaps.heatSources.merge([
[7, { name: 'hybheat', desc: 'Hybrid - Gas Only Mode' }],
[8, { name: 'hybheatpump', desc: 'Hybrid - Heat Pump Only Mode' }],
[9, { name: 'hybhybrid', desc: 'Hybrid - Hybrid Mode' }],
[10, { name: 'hybdual', desc: 'Hybrid - Dual Mode' }]
]);
}
if (gasHeaterInstalled) sys.board.valueMaps.heatSources.merge([[2, { name: 'heater', desc: 'Heater' }]]);
if (mastertempInstalled) sys.board.valueMaps.heatSources.merge([[11, { name: 'mtheater', desc: 'MasterTemp' }]]);
if (maxethermInstalled) sys.board.valueMaps.heatSources.merge([[12, { name: 'maxetherm', desc: 'Max-E-Therm' }]]);
if (eti250Installed) sys.board.valueMaps.heatSources.merge([[13, { name: 'eti250', desc: 'ETI250' }]]);
// "Preferred" modes only appear when a combustion heater (gas/mastertemp/maxetherm/eti250) is installed —
// "preferred" means "prefer this source, fall back to combustion heater."
if (solarInstalled && combustionHeaterInstalled) sys.board.valueMaps.heatSources.merge([[3, { name: 'solar', desc: 'Solar Only', hasCoolSetpoint: htypes.hasCoolSetpoint }], [4, { name: 'solarpref', desc: 'Solar Preferred', hasCoolSetpoint: htypes.hasCoolSetpoint }]]);
else if (solarInstalled && htypes.total > 1) sys.board.valueMaps.heatSources.merge([[3, { name: 'solar', desc: 'Solar Only', hasCoolSetpoint: htypes.hasCoolSetpoint }]]);
else if (solarInstalled) sys.board.valueMaps.heatSources.merge([[3, { name: 'solar', desc: 'Solar', hasCoolSetpoint: htypes.hasCoolSetpoint }]]);
// v3.004+ uses val=14 for heat pump (v1.x used val=9)
let hpVal = sys.equipment.isIntellicenterV3 ? 14 : 9;
let hpPrefVal = sys.equipment.isIntellicenterV3 ? 15 : 25;
if (heatPumpInstalled && combustionHeaterInstalled) sys.board.valueMaps.heatSources.merge([[hpVal, { name: 'heatpump', desc: 'Heat Pump Only' }], [hpPrefVal, { name: 'heatpumppref', desc: 'Heat Pump Preferred' }]]);
else if (heatPumpInstalled && htypes.total > 1) sys.board.valueMaps.heatSources.merge([[hpVal, { name: 'heatpump', desc: 'Heat Pump Only' }]]);
else if (heatPumpInstalled) sys.board.valueMaps.heatSources.merge([[hpVal, { name: 'heatpump', desc: 'Heat Pump' }]]);
if (ultratempInstalled && combustionHeaterInstalled) sys.board.valueMaps.heatSources.merge([[5, { name: 'ultratemp', desc: 'UltraTemp Only', hasCoolSetpoint: htypes.hasCoolSetpoint }], [6, { name: 'ultratemppref', desc: 'UltraTemp Preferred', hasCoolSetpoint: htypes.hasCoolSetpoint }]]);
else if (ultratempInstalled && htypes.total > 1) sys.board.valueMaps.heatSources.merge([[5, { name: 'ultratemp', desc: 'UltraTemp Only', hasCoolSetpoint: htypes.hasCoolSetpoint }]]);
else if (ultratempInstalled) sys.board.valueMaps.heatSources.merge([[5, { name: 'ultratemp', desc: 'UltraTemp', hasCoolSetpoint: htypes.hasCoolSetpoint }]]);
sys.board.valueMaps.heatSources.merge([[0, { name: 'nochange', desc: 'No Change' }]]);
if (gasHeaterInstalled) sys.board.valueMaps.heatModes.merge([[2, { name: 'heater', desc: 'Heater' }]]);
if (mastertempInstalled) sys.board.valueMaps.heatModes.merge([[11, { name: 'mtheater', desc: 'MasterTemp' }]]);
if (maxethermInstalled) sys.board.valueMaps.heatModes.merge([[12, { name: 'maxetherm', desc: 'Max-E-Therm' }]]);
if (eti250Installed) sys.board.valueMaps.heatModes.merge([[13, { name: 'eti250', desc: 'ETI250' }]]);
if (solarInstalled && combustionHeaterInstalled) sys.board.valueMaps.heatModes.merge([[3, { name: 'solar', desc: 'Solar Only' }], [4, { name: 'solarpref', desc: 'Solar Preferred' }]]);
else if (solarInstalled && htypes.total > 1) sys.board.valueMaps.heatModes.merge([[3, { name: 'solar', desc: 'Solar Only' }]]);
else if (solarInstalled) sys.board.valueMaps.heatModes.merge([[3, { name: 'solar', desc: 'Solar' }]]);
if (ultratempInstalled && combustionHeaterInstalled) sys.board.valueMaps.heatModes.merge([[5, { name: 'ultratemp', desc: 'UltraTemp Only' }], [6, { name: 'ultratemppref', desc: 'UltraTemp Preferred' }]]);
else if (ultratempInstalled && htypes.total > 1) sys.board.valueMaps.heatModes.merge([[5, { name: 'ultratemp', desc: 'UltraTemp Only' }]]);
else if (ultratempInstalled) sys.board.valueMaps.heatModes.merge([[5, { name: 'ultratemp', desc: 'UltraTemp' }]]);
if (heatPumpInstalled && combustionHeaterInstalled) sys.board.valueMaps.heatModes.merge([[hpVal, { name: 'heatpump', desc: 'Heat Pump Only' }], [hpPrefVal, { name: 'heatpumppref', desc: 'Heat Pump Preferred' }]]);
else if (heatPumpInstalled && htypes.total > 1) sys.board.valueMaps.heatModes.merge([[hpVal, { name: 'heatpump', desc: 'Heat Pump Only' }]]);
else if (heatPumpInstalled) sys.board.valueMaps.heatModes.merge([[hpVal, { name: 'heatpump', desc: 'Heat Pump' }]]);
}
else {
sys.board.valueMaps.heatSources = new byteValueMap([[0, { name: 'off', desc: 'Off' }]]);
if (gasHeaterInstalled) sys.board.valueMaps.heatSources.set(3, { name: 'heater', desc: 'Heater' });
if (solarInstalled && (gasHeaterInstalled || heatPumpInstalled)) sys.board.valueMaps.heatSources.merge([[5, { name: 'solar', desc: 'Solar Only' }], [21, { name: 'solarpref', desc: 'Solar Preferred' }]]);
else if (solarInstalled) sys.board.valueMaps.heatSources.set(5, { name: 'solar', desc: 'Solar' });
if (heatPumpInstalled && (gasHeaterInstalled || solarInstalled)) sys.board.valueMaps.heatSources.merge([[9, { name: 'heatpump', desc: 'Heatpump Only' }], [25, { name: 'heatpumppref', desc: 'Heat Pump Pref' }]]);
else if (heatPumpInstalled) sys.board.valueMaps.heatSources.set(9, { name: 'heatpump', desc: 'Heat Pump' });
if (sys.heaters.length > 0) sys.board.valueMaps.heatSources.set(32, { name: 'nochange', desc: 'No Change' });
sys.board.valueMaps.heatModes = new byteValueMap([[0, { name: 'off', desc: 'Off' }]]);
if (gasHeaterInstalled) sys.board.valueMaps.heatModes.set(3, { name: 'heater', desc: 'Heater' });
if (solarInstalled && (gasHeaterInstalled || heatPumpInstalled)) sys.board.valueMaps.heatModes.merge([[5, { name: 'solar', desc: 'Solar Only' }], [21, { name: 'solarpref', desc: 'Solar Preferred' }]]);
else if (solarInstalled) sys.board.valueMaps.heatModes.set(5, { name: 'solar', desc: 'Solar' });
if (heatPumpInstalled && (gasHeaterInstalled || solarInstalled)) sys.board.valueMaps.heatModes.merge([[9, { name: 'heatpump', desc: 'Heatpump Only' }], [25, { name: 'heatpumppref', desc: 'Heat Pump Preferred' }]]);
else if (heatPumpInstalled) sys.board.valueMaps.heatModes.set(9, { name: 'heatpump', desc: 'Heat Pump' });
}
// Now set the body data.
for (let i = 0; i < sys.bodies.length; i++) {
let body = sys.bodies.getItemByIndex(i);
let btemp = state.temps.bodies.getItemById(body.id, body.isActive !== false);
let opts = sys.board.heaters.getInstalledHeaterTypes(body.id);
btemp.heaterOptions = opts;
}
this.setActiveTempSensors();
}
}
class IntelliCenterValveCommands extends ValveCommands {
public async setValveAsync(obj?: any): Promise {
if (obj.master === 1) return super.setValveAsync(obj);
let id = parseInt(obj.id, 10);
if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError('Valve Id has not been defined', obj.id, 'Valve'));
let valve = sys.valves.getItemById(id);
// [255, 0, 255][165, 63, 15, 16, 168, 20][9, 0, 9, 2, 86, 97, 108, 118, 101, 32, 70, 0, 0, 0, 0, 0, 0, 0, 0, 0][4, 55]
// RKS: The valve messages are a bit unique since they are 0 based instead of 1s based. Our configuration includes
// the ability to set these valves appropriately via the interface by subtracting 1 from the circuit and the valve id. In
// shared body systems there is a gap for the additional intake/return valves that exist in i10d.
let v = extend(true, valve.get(true), obj);
const nameStr = normalizeIntelliCenterName(v.name, valve.name);
let out = Outbound.create({
action: 168,
payload: [9, 0, v.id - 1, v.circuit - 1],
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
}).appendPayloadString(nameStr, 16);
await out.sendAsync();
valve.name = nameStr;
valve.circuit = v.circuit;
valve.type = v.type;
return valve;
}
}
class IntelliCenterRemoteCommands extends RemoteCommands {
public async setRemoteAsync(obj: any): Promise {
let id = parseInt(obj.id, 10);
if (isNaN(id) || id < 1 || id > sys.equipment.maxRemotes) return Promise.reject(new InvalidEquipmentIdError('Remote Id is not valid', obj.id, 'Remote'));
let remote = sys.remotes.getItemById(id);
let v = extend(true, remote.get(true), obj);
const nameStr = normalizeIntelliCenterName(v.name, remote.name || `Remote ${id}`);
let type = typeof v.type !== 'undefined' ? parseInt(v.type, 10) : remote.type;
let isActive = typeof v.isActive !== 'undefined' ? utils.makeBool(v.isActive) : remote.isActive;
let pumpId = typeof v.pumpId !== 'undefined' ? parseInt(v.pumpId, 10) : (remote.pumpId !== undefined ? remote.pumpId : 255);
let address = typeof v.address !== 'undefined' ? parseInt(v.address, 10) : (remote.address || 0);
let body = typeof v.body !== 'undefined' ? parseInt(v.body, 10) : (remote.body || 0);
let payload = [5, 0, id - 1, type, isActive ? 1 : 0,
(pumpId !== undefined && pumpId < 255) ? pumpId : 255,
address > 0 ? address + 63 : 0,
body];
for (let b = 1; b <= 10; b++) {
let btn = typeof v['button' + b] !== 'undefined' ? parseInt(v['button' + b], 10) : (remote['button' + b] !== undefined ? remote['button' + b] : 255);
payload.push(isNaN(btn) || btn >= 255 ? 255 : btn);
}
let out = Outbound.create({
action: 168,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
out.appendPayloadString(nameStr, 16);
await out.sendAsync();
remote.type = type;
remote.name = nameStr;
remote.isActive = isActive;
remote.pumpId = pumpId;
remote.address = address;
remote.body = body;
for (let b = 1; b <= 10; b++) {
remote['button' + b] = payload[7 + b];
}
return remote;
}
}
export class IntelliCenterChemControllerCommands extends ChemControllerCommands {
protected async setIntelliChemAsync(data: any): Promise {
let chem = sys.board.chemControllers.findChemController(data);
let ichemType = sys.board.valueMaps.chemControllerTypes.encode('intellichem');
if (typeof chem === 'undefined') {
// We are adding an IntelliChem. Check to see how many intellichems we have.
let arr = sys.chemControllers.toArray();
let count = 0;
for (let i = 0; i < arr.length; i++) {
let cc: ChemController = arr[i];
if (cc.type === ichemType) count++;
}
if (count >= sys.equipment.maxChemControllers) return Promise.reject(new InvalidEquipmentDataError(`The max number of IntelliChem controllers has been reached: ${sys.equipment.maxChemControllers}`, 'chemController', sys.equipment.maxChemControllers));
chem = sys.chemControllers.getItemById(data.id);
}
let address = typeof data.address !== 'undefined' ? parseInt(data.address, 10) : chem.address;
if (typeof address === 'undefined' || isNaN(address) || (address < 144 || address > 158)) return Promise.reject(new InvalidEquipmentDataError(`Invalid IntelliChem address`, 'chemController', address));
if (typeof sys.chemControllers.find(elem => elem.id !== data.id && elem.type === ichemType && elem.address === address) !== 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Invalid IntelliChem address: Address is used on another IntelliChem`, 'chemController', address));
// Now lets do all our validation to the incoming chem controller data.
let name = normalizeIntelliCenterName(data.name, chem.name || `IntelliChem - ${address - 143}`);
let type = sys.board.valueMaps.chemControllerTypes.transformByName('intellichem');
// So now we are down to the nitty gritty setting the data for the REM Chem controller.
let calciumHardness = typeof data.calciumHardness !== 'undefined' ? parseInt(data.calciumHardness, 10) : chem.calciumHardness;
let cyanuricAcid = typeof data.cyanuricAcid !== 'undefined' ? parseInt(data.cyanuricAcid, 10) : chem.cyanuricAcid;
let alkalinity = typeof data.alkalinity !== 'undefined' ? parseInt(data.alkalinity, 10) : chem.alkalinity;
let borates = typeof data.borates !== 'undefined' ? parseInt(data.borates, 10) : chem.borates || 0;
let intellichemStandalone = sys.controllerType === ControllerType.Nixie
? (typeof data.intellichemStandalone !== 'undefined' ? utils.makeBool(data.intellichemStandalone) : chem.intellichemStandalone)
: false;
let body = sys.board.bodies.mapBodyAssociation(typeof data.body === 'undefined' ? chem.body : data.body);
if (typeof body === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Invalid body assignment`, 'chemController', data.body || chem.body));
// Do a final validation pass so we dont send this off in a mess.
if (isNaN(calciumHardness)) return Promise.reject(new InvalidEquipmentDataError(`Invalid calcium hardness`, 'chemController', calciumHardness));
if (isNaN(cyanuricAcid)) return Promise.reject(new InvalidEquipmentDataError(`Invalid cyanuric acid`, 'chemController', cyanuricAcid));
if (isNaN(alkalinity)) return Promise.reject(new InvalidEquipmentDataError(`Invalid alkalinity`, 'chemController', alkalinity));
if (isNaN(borates)) return Promise.reject(new InvalidEquipmentDataError(`Invalid borates`, 'chemController', borates));
let schem = state.chemControllers.getItemById(chem.id, true);
let pHSetpoint = typeof data.ph !== 'undefined' && typeof data.ph.setpoint !== 'undefined' ? parseFloat(data.ph.setpoint) : chem.ph.setpoint;
let orpSetpoint = typeof data.orp !== 'undefined' && typeof data.orp.setpoint !== 'undefined' ? parseInt(data.orp.setpoint, 10) : chem.orp.setpoint;
let lsiRange = typeof data.lsiRange !== 'undefined' ? data.lsiRange : chem.lsiRange || {};
if (typeof data.lsiRange !== 'undefined') {
if (typeof data.lsiRange.enabled !== 'undefined') lsiRange.enabled = utils.makeBool(data.lsiRange.enabled);
if (typeof data.lsiRange.low === 'number') lsiRange.low = parseFloat(data.lsiRange.low);
if (typeof data.lsiRange.high === 'number') lsiRange.high = parseFloat(data.lsiRange.high);
}
if (isNaN(pHSetpoint) || pHSetpoint > type.ph.max || pHSetpoint < type.ph.min) Promise.reject(new InvalidEquipmentDataError(`Invalid pH setpoint`, 'ph.setpoint', pHSetpoint));
if (isNaN(orpSetpoint) || orpSetpoint > type.orp.max || orpSetpoint < type.orp.min) Promise.reject(new InvalidEquipmentDataError(`Invalid orp setpoint`, 'orp.setpoint', orpSetpoint));
let phTolerance = typeof data.ph !== 'undefined' && typeof data.ph.tolerance !== 'undefined' ? data.ph.tolerance : chem.ph.tolerance;
let orpTolerance = typeof data.orp !== 'undefined' && typeof data.orp.tolerance !== 'undefined' ? data.orp.tolerance : chem.orp.tolerance;
if (typeof data.ph !== 'undefined' && typeof data.ph.tolerance !== 'undefined') {
if (typeof data.ph.tolerance.enabled !== 'undefined') phTolerance.enabled = utils.makeBool(data.ph.tolerance.enabled);
if (typeof data.ph.tolerance.low !== 'undefined') phTolerance.low = parseFloat(data.ph.tolerance.low);
if (typeof data.ph.tolerance.high !== 'undefined') phTolerance.high = parseFloat(data.ph.tolerance.high);
if (isNaN(phTolerance.low)) phTolerance.low = type.ph.min;
if (isNaN(phTolerance.high)) phTolerance.high = type.ph.max;
}
if (typeof data.orp !== 'undefined' && typeof data.orp.tolerance !== 'undefined') {
if (typeof data.orp.tolerance.enabled !== 'undefined') orpTolerance.enabled = utils.makeBool(data.orp.tolerance.enabled);
if (typeof data.orp.tolerance.low !== 'undefined') orpTolerance.low = parseFloat(data.orp.tolerance.low);
if (typeof data.orp.tolerance.high !== 'undefined') orpTolerance.high = parseFloat(data.orp.tolerance.high);
if (isNaN(orpTolerance.low)) orpTolerance.low = type.orp.min;
if (isNaN(orpTolerance.high)) orpTolerance.high = type.orp.max;
}
let phEnabled = typeof data.ph !== 'undefined' && typeof data.ph.enabled !== 'undefined' ? utils.makeBool(data.ph.enabled) : chem.ph.enabled;
let orpEnabled = typeof data.orp !== 'undefined' && typeof data.orp.enabled !== 'undefined' ? utils.makeBool(data.orp.enabled) : chem.orp.enabled;
let siCalcType = typeof data.siCalcType !== 'undefined' ? sys.board.valueMaps.siCalcTypes.encode(data.siCalcType, 0) : chem.siCalcType;
let saltLevel = (state.chlorinators.length > 0) ? state.chlorinators.getItemById(1).saltLevel || 1000 : 1000
chem.ph.tank.capacity = 6;
chem.orp.tank.capacity = 6;
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;
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;
//Them
//[255, 0, 255][165, 63, 15, 16, 168, 20][8, 0, 0, 32, 1, 144, 1, 248, 2, 144, 1, 1, 1, 29, 0, 0, 0, 100, 0, 0][4, 135]
//Us
//[255, 0, 255][165, 0, 15, 33, 168, 20][8, 0, 0, 32, 1, 144, 1, 248, 2, 144, 1, 1, 1, 33, 0, 0, 0, 100, 0, 0][4, 93]
let out = Outbound.create({
protocol: Protocol.Broadcast,
action: 168,
payload: [],
retries: 3, // We are going to try 4 times.
response: IntelliCenterBoard.getAckResponse(168),
//onAbort: () => { },
});
//[8, 0, chem.id - 1, body.val, 1, chem.address, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0]
out.insertPayloadBytes(0, 0, 20);
out.setPayloadByte(0, 8);
out.setPayloadByte(1, 0);
out.setPayloadByte(2, chem.id - 1);
out.setPayloadByte(3, body.val);
//out.setPayloadByte(4, acidTankLevel + 1);
out.setPayloadByte(4, 1);
out.setPayloadByte(5, address);
out.setPayloadByte(6, 1);
out.setPayloadInt(7, Math.round(pHSetpoint * 100), 700);
out.setPayloadInt(9, orpSetpoint, 400);
//out.setPayloadByte(11, 1);
//out.setPayloadByte(12, 1);
out.setPayloadByte(11, acidTankLevel + 1, 1);
out.setPayloadByte(12, orpTankLevel + 1, 1);
out.setPayloadInt(13, calciumHardness, 25);
out.setPayloadInt(15, cyanuricAcid, 0);
out.setPayloadInt(17, alkalinity, 25);
await out.sendAsync();
chem = sys.chemControllers.getItemById(data.id, true);
schem = state.chemControllers.getItemById(data.id, true);
chem.master = 0;
// Copy the data back to the chem object.
schem.name = chem.name = name;
schem.type = chem.type = sys.board.valueMaps.chemControllerTypes.encode('intellichem');
chem.calciumHardness = calciumHardness;
chem.cyanuricAcid = cyanuricAcid;
chem.alkalinity = alkalinity;
chem.borates = borates;
chem.body = schem.body = body;
chem.intellichemStandalone = intellichemStandalone;
schem.isActive = chem.isActive = true;
chem.lsiRange.enabled = lsiRange.enabled;
chem.lsiRange.low = lsiRange.low;
chem.lsiRange.high = lsiRange.high;
chem.ph.tolerance.enabled = phTolerance.enabled;
chem.ph.tolerance.low = phTolerance.low;
chem.ph.tolerance.high = phTolerance.high;
chem.orp.tolerance.enabled = orpTolerance.enabled;
chem.orp.tolerance.low = orpTolerance.low;
chem.orp.tolerance.high = orpTolerance.high;
chem.ph.setpoint = pHSetpoint;
chem.orp.setpoint = orpSetpoint;
schem.siCalcType = chem.siCalcType = siCalcType;
chem.address = schem.address = address;
chem.name = schem.name = name;
chem.flowSensor.enabled = false;
return chem;
}
public async deleteChemControllerAsync(data: any): Promise {
let id = typeof data.id !== 'undefined' ? parseInt(data.id, 10) : -1;
if (typeof id === 'undefined' || isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid Chem Controller Id`, id, 'chemController'));
let chem = sys.chemControllers.getItemById(id);
if (chem.master === 1) return super.deleteChemControllerAsync(data);
let out = Outbound.create({
action: 168,
response: IntelliCenterBoard.getAckResponse(168),
retries: 3,
payload: [8, 0, id - 1, 0, 1, chem.address || 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0]
});
out.setPayloadInt(7, Math.round(chem.ph.setpoint * 100), 700);
out.setPayloadInt(9, chem.orp.setpoint, 400);
out.setPayloadInt(13, chem.calciumHardness, 25);
out.setPayloadInt(15, chem.cyanuricAcid, 0);
await out.sendAsync();
let schem = state.chemControllers.getItemById(id);
chem.isActive = false;
chem.ph.tank.capacity = chem.orp.tank.capacity = 6;
chem.ph.tank.units = chem.orp.tank.units = '';
schem.isActive = false;
sys.chemControllers.removeItemById(id);
state.chemControllers.removeItemById(id);
return chem;
}
//public async setChemControllerAsync(data: any): Promise {
// // This is a combined chem config/state setter.
// let isVirtual = utils.makeBool(data.isVirtual);
// let type = parseInt(data.type, 10);
// if (isNaN(type) && typeof data.type === 'string')
// type = sys.board.valueMaps.chemControllerTypes.getValue(data.type);
// let isAdd = false;
// let chem: ChemController;
// let id = typeof data.id !== 'undefined' ? parseInt(data.id, 10) : -1;
// let address = typeof data.address !== 'undefined' ? parseInt(data.address, 10) : undefined;
// if (typeof id === 'undefined' || isNaN(id) || id <= 0) {
// id = sys.chemControllers.nextAvailableChemController();
// isAdd = true;
// }
// if (isAdd && sys.chemControllers.length >= sys.equipment.maxChemControllers) return Promise.reject(new InvalidEquipmentIdError(`Max chem controller id exceeded`, id, 'chemController'));
// chem = sys.chemControllers.getItemById(id, false); // Don't add it yet if it doesn't exist we will commit later after the OCP responds.
// if (isVirtual || chem.isVirtual || type !== 2) return super.setChemControllerAsync(data); // Fall back to the world of the virtual chem controller.
// if (isNaN(id)) return Promise.reject(new InvalidEquipmentIdError(`Invalid chemController id: ${data.id}`, data.id, 'ChemController'));
// let pHSetpoint = typeof data.pHSetpoint !== 'undefined' ? parseFloat(data.pHSetpoint) : chem.ph.setpoint;
// let orpSetpoint = typeof data.orpSetpoint !== 'undefined' ? parseInt(data.orpSetpoint, 10) : chem.orp.setpoint;
// let calciumHardness = typeof data.calciumHardness !== 'undefined' ? parseInt(data.calciumHardness, 10) : chem.calciumHardness;
// let cyanuricAcid = typeof data.cyanuricAcid !== 'undefined' ? parseInt(data.cyanuricAcid, 10) : chem.cyanuricAcid;
// let alkalinity = typeof data.alkalinity !== 'undefined' ? parseInt(data.alkalinity, 10) : chem.alkalinity;
// if (isAdd) { // Required fields and defaults.
// if (typeof type === 'undefined' || isNaN(type)) return Promise.reject(new InvalidEquipmentDataError(`A valid controller controller type was not supplied`, 'chemController', data.type));
// if (typeof address === 'undefined' || isNaN(address)) return Promise.reject(new InvalidEquipmentDataError(`A valid controller address was not supplied`, 'chemController', data.address));
// if (typeof pHSetpoint === 'undefined') pHSetpoint = 7;
// if (typeof orpSetpoint === 'undefined') orpSetpoint = 400;
// if (typeof calciumHardness === 'undefined') calciumHardness = 25;
// if (typeof cyanuricAcid === 'undefined') cyanuricAcid = 0;
// if (typeof data.body === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`The assigned body was not supplied`, 'chemController', data.body));
// }
// else {
// if (typeof address === 'undefined' || isNaN(address)) address = chem.address;
// if (typeof pHSetpoint === 'undefined') pHSetpoint = chem.ph.setpoint;
// if (typeof orpSetpoint === 'undefined') orpSetpoint = chem.orp.setpoint;
// if (typeof calciumHardness === 'undefined') calciumHardness = chem.calciumHardness;
// if (typeof cyanuricAcid === 'undefined') cyanuricAcid = chem.cyanuricAcid;
// }
// if (typeof address === 'undefined' || (address < 144 || address > 158)) return Promise.reject(new InvalidEquipmentDataError(`Invalid chem controller address`, 'chemController', address));
// if (typeof pHSetpoint === 'undefined' || (pHSetpoint > 7.6 || pHSetpoint < 7)) return Promise.reject(new InvalidEquipmentDataError(`Invalid pH setpoint (7 - 7.6)`, 'chemController', pHSetpoint));
// if (typeof orpSetpoint === 'undefined' || (orpSetpoint > 800 || orpSetpoint < 400)) return Promise.reject(new InvalidEquipmentDataError(`Invalid ORP setpoint (400 - 800)`, 'chemController', orpSetpoint));
// if (typeof calciumHardness === 'undefined' || (calciumHardness > 800 || calciumHardness < 25)) return Promise.reject(new InvalidEquipmentDataError(`Invalid Calcium Hardness (25 - 800)`, 'chemController', calciumHardness));
// if (typeof cyanuricAcid === 'undefined' || (cyanuricAcid > 201 || cyanuricAcid < 0)) return Promise.reject(new InvalidEquipmentDataError(`Invalid Cyanuric Acid (0 - 201)`, 'chemController', cyanuricAcid));
// let body = sys.board.bodies.mapBodyAssociation(typeof data.body === 'undefined' ? chem.body : data.body);
// if (typeof body === 'undefined') return Promise.reject(new InvalidEquipmentDataError(`Invalid body assignment`, 'chemController', data.body || chem.body));
// let name = (typeof data.name !== 'string') ? chem.name || 'IntelliChem' + id : data.name;
// return new Promise(async (resolve, reject) => {
// let out = Outbound.create({
// action: 168,
// response: IntelliCenterBoard.getAckResponse(168),
// retries: 3,
// payload: [8, 0, id - 1, body.val, 1, address, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0],
// onComplete: (err) => {
// if (err) { reject(err); }
// else {
// chem = sys.chemControllers.getItemById(id, true);
// let cstate = state.chemControllers.getItemById(id, true);
// chem.isActive = true;
// chem.isVirtual = false;
// chem.address = address;
// chem.body = body;
// chem.calciumHardness = calciumHardness;
// chem.orp.setpoint = orpSetpoint;
// chem.ph.setpoint = pHSetpoint;
// chem.cyanuricAcid = cyanuricAcid;
// chem.alkalinity = alkalinity;
// chem.type = 2;
// chem.name = name;
// chem.ph.tank.capacity = chem.orp.tank.capacity = 6;
// chem.ph.tank.units = chem.orp.tank.units = '';
// cstate.body = chem.body;
// cstate.address = chem.address;
// cstate.name = chem.name;
// cstate.type = chem.type;
// cstate.isActive = chem.isActive;
// resolve(chem);
// }
// }
// });
// out.setPayloadInt(7, Math.round(pHSetpoint * 100), 700);
// out.setPayloadInt(9, orpSetpoint, 400);
// out.setPayloadInt(13, calciumHardness, 25);
// out.setPayloadInt(15, cyanuricAcid, 0);
// out.setPayloadInt(17, alkalinity, 25);
// await out.sendAsync();
// });
//}
}
// ISSUE-080: Action 168 cat=14 outbound write path for IntelliCenter covers.
// Packet reference: .plan/v3.008/covers-packet-reference.md §2.2
//
// A168 cat=14 payload (30 bytes):
// [0]=14 (cat)
// [1]=0 (sub — always 0 observed)
// [2]=slot (0=Cover 1, 1=Cover 2)
// [3..18]=name (16 bytes, ASCII, null-padded) — hard-fixed to "Cover 1"/"Cover 2"
// [19..28]=circuits (10 bytes, 0xFF=empty)
// [29]=flags (bit 0=chlorActive, bit 1=normallyOn, bit 2=isActive, bit 3=Pool body)
//
// Body output caps (enforced server-side, mirrors OCP UI):
// Pool body: chlorOutput 0-50
// Spa body : chlorOutput 0-10
//
// Per-body routing: `chlorOutput` is applied via the chlorinator cat=7 piggyback, NOT cat=14.
// This method encodes the cover config itself; the output update flows through the chlorinator
// path on the next OCP rebroadcast (or user-driven OCP edit). Writing output from dashPanel is
// therefore a two-step OCP operation — note this in the UI.
class IntelliCenterCoverCommands extends CoverCommands {
public async setCoverAsync(obj: any): Promise {
const id = parseInt(obj.id, 10);
if (isNaN(id) || id < 1 || id > 2)
return Promise.reject(new InvalidEquipmentIdError('Cover Id is not valid (1 or 2).', obj.id, 'Cover'));
const cover = sys.covers.getItemById(id, false);
if (!cover || typeof cover.name === 'undefined')
return Promise.reject(new InvalidEquipmentIdError(`Cover ${id} does not exist. Enable it on the OCP first.`, obj.id, 'Cover'));
// Name is read-only per OCP behavior (no rename UI on the panel). Reject any attempt.
if (typeof obj.name !== 'undefined' && obj.name !== cover.name)
return Promise.reject(new InvalidEquipmentDataError(`Cover names cannot be changed — OCP does not expose a rename UI. Keep name: ${cover.name}`, 'Cover', obj.name));
const poolBodyId = sys.board.valueMaps.bodies.getValue('pool');
const spaBodyId = sys.board.valueMaps.bodies.getValue('spa');
let body: number;
if (typeof obj.body !== 'undefined') {
if (typeof obj.body === 'string' && isNaN(parseInt(obj.body, 10)))
body = sys.board.valueMaps.bodies.getValue(obj.body);
else body = parseInt(obj.body, 10);
if (body !== poolBodyId && body !== spaBodyId)
return Promise.reject(new InvalidEquipmentDataError(`Cover body must be Pool or Spa.`, 'Cover', obj.body));
} else {
body = sys.board.valueMaps.bodies.encode(cover.body);
}
const isActive = typeof obj.isActive !== 'undefined' ? utils.makeBool(obj.isActive) : cover.isActive;
const normallyOn = typeof obj.normallyOn !== 'undefined' ? utils.makeBool(obj.normallyOn) : cover.normallyOn;
const chlorActive = typeof obj.chlorActive !== 'undefined' ? utils.makeBool(obj.chlorActive) : cover.chlorActive;
// Circuits: up to 10; reject IDs that don't resolve to a real circuit/feature.
let circuits: number[] = Array.isArray(obj.circuits) ? obj.circuits.map((c: any) => parseInt(c, 10)).filter((n: number) => !isNaN(n)) : cover.circuits.slice();
if (circuits.length > 10)
return Promise.reject(new InvalidEquipmentDataError(`A cover can have at most 10 Affected Circuits; got ${circuits.length}.`, 'Cover', circuits));
const validRefs = sys.board.circuits.getCircuitReferences(true, true, false, false);
for (const cid of circuits) {
if (!validRefs.find((r: any) => r.id === cid))
return Promise.reject(new InvalidEquipmentDataError(`Affected Circuit id ${cid} is not a valid circuit or feature.`, 'Cover', cid));
}
// Body-aware output cap. OCP auto-disables chlorActive when body swap brings output
// out of range; mirror that here so the OCP and njsPC agree on post-swap state.
const capMax = body === spaBodyId ? 10 : 50;
let chlorOutput = typeof obj.chlorOutput !== 'undefined' ? parseInt(obj.chlorOutput, 10) : (cover.chlorOutput || 0);
if (isNaN(chlorOutput) || chlorOutput < 0)
return Promise.reject(new InvalidEquipmentDataError(`IntelliChlor Output must be between 0 and ${capMax}.`, 'Cover', obj.chlorOutput));
let postChlorActive = chlorActive;
if (chlorOutput > capMax) {
logger.info(`setCoverAsync: cover ${id} chlorOutput ${chlorOutput} exceeds ${body === spaBodyId ? 'Spa' : 'Pool'} max ${capMax}; clamping and disabling chlorActive.`);
chlorOutput = capMax;
postChlorActive = false;
}
// Build the flags byte from the semantic inputs.
const flags =
(postChlorActive ? 0x01 : 0) |
(normallyOn ? 0x02 : 0) |
(isActive ? 0x04 : 0) |
(body === spaBodyId ? 0x08 : 0);
const slot = id - 1;
const out = Outbound.create({
action: 168,
payload: [14, 0, slot],
retries: 5,
response: IntelliCenterBoard.getAckResponse(168)
});
// Name: preserve OCP-fixed value ("Cover 1"/"Cover 2"), 16 bytes, null-padded.
out.appendPayloadString(cover.name || `Cover ${id}`, 16);
// Circuits: 10 slots, unused filled with 0xFF.
// Wire protocol is 0-indexed (wire 0 = njsPC circuit id 1).
for (let i = 0; i < 10; i++) {
out.appendPayloadByte(i < circuits.length ? circuits[i] - 1 : 0xFF);
}
out.appendPayloadByte(flags);
await out.sendAsync();
// Commit the config — parser will overwrite on next OCP broadcast anyway, but
// dashPanel needs immediate values (Rule 18).
cover.body = body;
cover.isActive = isActive;
cover.normallyOn = normallyOn;
cover.chlorActive = postChlorActive;
cover.chlorOutput = chlorOutput;
cover.circuits = circuits;
const scover = state.covers.getItemById(cover.id, true);
scover.name = cover.name;
scover.body = cover.body;
scover.isActive = cover.isActive;
scover.normallyOn = cover.normallyOn;
scover.chlorActive = cover.chlorActive;
scover.chlorOutput = cover.chlorOutput;
state.emitEquipmentChanges();
return cover;
}
}
class IntelliCenterAlertCommands {
constructor(private board: IntelliCenterBoard) {}
private static readonly SELECTOR_BYTE_COUNTS: { [key: number]: number } = {
12: 1, 13: 2, 14: 2, 15: 1, 16: 4, 17: 4, 18: 2
};
private static readonly FIELD_TO_SELECTOR: { [key: string]: number } = {
circuits: 12, pumps: 13, ultratemp: 14, chlorinator: 15,
intellichem: 16, hybrid: 17, connectedGas: 18
};
private maskToBytes(mask: number, byteCount: number): number[] {
const bytes: number[] = [];
if (byteCount <= 2) {
for (let i = byteCount - 1; i >= 0; i--) {
bytes.push((mask >>> (i * 8)) & 0xFF);
}
} else {
for (let i = 0; i < byteCount; i++) {
bytes.push((mask >>> (i * 8)) & 0xFF);
}
}
return bytes;
}
public async setAlertNotificationsAsync(obj: any): Promise {
for (const [field, selector] of Object.entries(IntelliCenterAlertCommands.FIELD_TO_SELECTOR)) {
if (typeof obj[field] === 'undefined') continue;
const mask = parseInt(obj[field], 10) >>> 0;
const byteCount = IntelliCenterAlertCommands.SELECTOR_BYTE_COUNTS[selector];
const dataBytes = this.maskToBytes(mask, byteCount);
const payload = [13, 0, selector, ...dataBytes];
const out = Outbound.create({
action: 168,
payload: payload,
response: IntelliCenterBoard.getAckResponse(168),
retries: 5
});
await out.sendAsync();
switch (selector) {
case 12: sys.alerts.circuitNotifications = mask; break;
case 13: sys.alerts.pumpNotifications = mask; break;
case 14: sys.alerts.ultratempNotifications = mask; break;
case 15: sys.alerts.chlorinatorNotifications = mask; break;
case 16: sys.alerts.intellichemNotifications = mask; break;
case 17: sys.alerts.hybridNotifications = mask; break;
case 18: sys.alerts.connectedGasNotifications = mask; break;
}
sys.alerts.setRaw(selector, dataBytes);
}
return sys.alerts.get(true);
}
}
enum ConfigCategories {
options = 0,
circuits = 1,
features = 2,
schedules = 3,
pumps = 4,
remotes = 5,
circuitGroups = 6,
chlorinators = 7,
intellichem = 8,
valves = 9,
heaters = 10,
security = 11,
general = 12,
equipment = 13,
covers = 14,
systemState = 15
}