/* 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 { Direction, Inbound, Outbound, Protocol } from '../../comms/messages/Messages'; import { ChlorinatorStateMessage } from '../../comms/messages/status/ChlorinatorStateMessage'; import { conn } from '../../comms/Comms'; import { logger } from '../../../logger/Logger'; const LOOPBACK_ACTIONS = new Set([18, 3]); export interface VirtualChlorinatorOptions { address: number; portId?: number; enabled?: boolean; autoDisabled?: boolean; autoDisabledAt?: string | null; autoDisabledReason?: string | null; saltLevel?: number; modelName?: string; } export class VirtualChlorinator { public readonly address: number; public portId: number; public saltLevel: number; public modelName: string; private _enabled: boolean; private _autoDisabled: boolean; private _autoDisabledAt: string | null; private _autoDisabledReason: string | null; protected _targetOutput = 0; protected _enabledAt: number = Date.now(); protected _lastPacketAt: number | null = null; protected _packetCount = 0; private _recentEchoes: number[] = []; constructor(opts: VirtualChlorinatorOptions) { this.address = opts.address; this.portId = opts.portId ?? 0; this.saltLevel = opts.saltLevel ?? 3400; this.modelName = opts.modelName || 'Intellichlor--40'; this._enabled = opts.enabled === true; this._autoDisabled = opts.autoDisabled === true; this._autoDisabledAt = opts.autoDisabledAt ?? null; this._autoDisabledReason = opts.autoDisabledReason ?? null; } public get enabled(): boolean { return this._enabled; } public get autoDisabled(): boolean { return this._autoDisabled; } public get autoDisabledAt(): string | null { return this._autoDisabledAt; } public get autoDisabledReason(): string | null { return this._autoDisabledReason; } public get isEffective(): boolean { return this._enabled && !this._autoDisabled; } public get recentEchoes(): number[] { return this._recentEchoes; } public supportsAction(action: number): boolean { return action === 0 || action === 17 || action === 20 || action === 21; } public pushRecentInboundEcho(ts: number): void { this._recentEchoes.push(ts); if (this._recentEchoes.length > 8) this._recentEchoes.splice(0, this._recentEchoes.length - 8); } public setAutoDisabled(v: boolean, reason?: string): void { this._autoDisabled = v; this._autoDisabledAt = v ? new Date().toISOString() : null; this._autoDisabledReason = v ? (reason || 'Collision detected on bus') : null; if (v) this._resetRuntime(); } public clearAutoDisabled(): void { this._autoDisabled = false; this._autoDisabledAt = null; this._autoDisabledReason = null; } public applyUserConfig(opts: Partial): void { const wasEffective = this.isEffective; if (typeof opts.enabled === 'boolean') this._enabled = opts.enabled; if (typeof opts.portId === 'number') this.portId = opts.portId; if (typeof opts.saltLevel === 'number') this.saltLevel = opts.saltLevel; if (typeof opts.modelName === 'string' && opts.modelName.length > 0) this.modelName = opts.modelName; if (opts.enabled === true) this._enabledAt = Date.now(); if (wasEffective && !this.isEffective) this._resetRuntime(); } private _resetRuntime(): void { this._targetOutput = 0; } public process(msg: Inbound): void { this._lastPacketAt = Date.now(); this._packetCount++; const response = Outbound.create({ portId: msg.portId, protocol: Protocol.Chlorinator, source: 0, dest: 0, action: 0, payload: [], retries: 0, response: false }); switch (msg.action) { case 0: { response.action = 1; response.appendPayloadByte(0); response.appendPayloadByte(0); break; } case 17: { this._targetOutput = msg.extractPayloadByte(0); response.action = 18; response.appendPayloadByte(Math.round(this.saltLevel / 50)); response.appendPayloadByte(0); break; } case 20: { response.action = 3; response.appendPayloadByte(0); const nameBytes = this._getModelNameBytes(); for (const b of nameBytes) response.appendPayloadByte(b); break; } case 21: { this._targetOutput = msg.extractPayloadByte(0) / 10; response.action = 18; response.appendPayloadByte(Math.round(this.saltLevel / 50)); response.appendPayloadByte(0); break; } default: //logger.verbose(`VirtualChlorinator ${this.address}: ignoring unsupported action ${msg.action}`); return; } try { let port = conn.findPortById(response.portId); if (port) port.emitter.emit('messagewritepriority', response); else conn.queueSendMessage(response); //logger.verbose(`VirtualChlorinator ${this.address}: answered action ${msg.action} with response action ${response.action}`); } catch (err) { logger.error(`VirtualChlorinator ${this.address}: failed to queue response for action ${msg.action}: ${(err as Error).message}`); } if (LOOPBACK_ACTIONS.has(response.action)) { this._loopbackAsInbound(response); } } private _loopbackAsInbound(out: Outbound): void { try { const inbound = new Inbound(); inbound.protocol = Protocol.Chlorinator; inbound.direction = Direction.In; inbound.portId = out.portId; inbound.preamble = []; inbound.header = out.header.slice(); inbound.payload = out.payload.slice(); inbound.term = out.term.slice(); inbound.isValid = true; inbound.timestamp = new Date(); ChlorinatorStateMessage.process(inbound); } catch (err) { logger.error(`VirtualChlorinator ${this.address}: loopback to ChlorinatorStateMessage failed: ${(err as Error).message}`); } } private _getModelNameBytes(): number[] { const bytes: number[] = []; const name = this.modelName.padEnd(16, '\0').substring(0, 16); for (let i = 0; i < 16; i++) { bytes.push(name.charCodeAt(i)); } return bytes; } public toPersisted(): any { return { address: this.address, portId: this.portId, enabled: this._enabled, autoDisabled: this._autoDisabled, autoDisabledAt: this._autoDisabledAt, autoDisabledReason: this._autoDisabledReason, saltLevel: this.saltLevel, modelName: this.modelName }; } public toSnapshot(): any { return { ...this.toPersisted(), isEffective: this.isEffective, runtime: { targetOutput: this._targetOutput, enabledAt: new Date(this._enabledAt).toISOString(), lastPacketAt: this._lastPacketAt ? new Date(this._lastPacketAt).toISOString() : null, packetCount: this._packetCount } }; } }