UNPKG

rainbird

Version:

The Rainbird library allows you to access your RainBird Controller.

531 lines 21.2 kB
import { Buffer } from 'node:buffer'; import * as events from 'node:events'; import PQueue from 'p-queue'; import { debounceTime, fromEvent, Subject, timer } from 'rxjs'; import { EventType } from './EventType.js'; import { RainBirdClient } from './RainBirdClient.js'; import { AcknowledgedResponse } from './responses/AcknowledgedResponse.js'; export class RainBirdService extends events.EventEmitter { options; _client; _metadata = { modelNumber: 0, model: 'Unknown', version: 'Unknown', serialNumber: 'Unknown', zones: [], }; _currentZoneStateSupported = true; _advanceZoneSupported = true; _currentZoneId = 0; _currentProgramId; _zones = {}; _rainSetPointReached = false; _statusObsersable = fromEvent(this, 'status'); _statusTimerSubscription; _statusRefreshSubject = new Subject(); _syncTime = false; _lastSupportWarning = 0; zoneQueue = new PQueue({ concurrency: 1, timeout: 3600000, autoStart: true, }); ESP_ME3 = 0x0009; constructor(options) { super(); this.options = options; this.setMaxListeners(50); this._syncTime = options.syncTime; this._client = new RainBirdClient(options.address, options.password, options.showRequestResponse); this._client.on(EventType.LOG, (level, message) => { if (message !== undefined) { this.emitLog(level, message); } }); this._statusRefreshSubject .pipe(debounceTime(1000)) .subscribe(async () => await this.performStatusRefresh()); } /** * Emit a log event. * @param level The log level. * @param message The log message. */ emitLog(level, message) { this.emit('log', { level, message }); } async init() { this.emitLog('debug', 'Init'); const respModelAndVersion = await this._client.getModelAndVersion(); const respSerialNumber = await this._client.getSerialNumber(); const respZones = await this._client.getAvailableZones(); this._metadata = { modelNumber: respModelAndVersion.modelNumber, model: respModelAndVersion.modelName, version: respModelAndVersion.version, serialNumber: respSerialNumber.serialNumber, zones: respZones.zones, }; // Initialise zones for (const zone of respZones.zones) { this._zones[zone] = { active: false, queued: false, running: false, remainingDuration: 0, durationTime: undefined, }; } const irrigationState = (await this._client.getIrrigationState()).irrigationState; if (!irrigationState) { this.emitLog('warn', 'RainBird controller is currently OFF. Please turn ON so plugin can control it'); } // Sync time if (this._syncTime) { await this.setControllerDateTime(); setInterval(async () => { await this.setControllerDateTime(); }, 3600000); // every hour } await this.updateStatus(); this.setStatusTimer(); return this._metadata; } get model() { return this._metadata.model; } get version() { return this._metadata.version; } get serialNumber() { return this._metadata.serialNumber; } get zones() { return this._metadata.zones; } get rainSetPointReached() { return this._rainSetPointReached; } isActive(zone) { return zone === undefined ? Object.values(this._zones).some(z => z.active || z.queued) : this._zones[zone].active || this._zones[zone].queued; } isInUse(zone) { return zone === undefined ? Object.values(this._zones).some(z => z.running) : this._zones[zone].running; } remainingDuration(zone) { if (zone === undefined) { let remaining = 0; for (const zone of this.zones) { remaining += this.calcRemainingDuration(zone); } return remaining; } return this.calcRemainingDuration(zone); } calcRemainingDuration(zone) { if (!this._zones[zone].active && !this._zones[zone].queued) { return 0; } const remaining = this._zones[zone].durationTime === undefined ? this._zones[zone].remainingDuration : this._zones[zone].remainingDuration - Math.round(((new Date()).getTime() - this._zones[zone].durationTime.getTime()) / 1000); return Math.max(remaining, 0); } activateZone(zone, duration) { this.emitLog('debug', `Zone ${zone}: Activate for ${duration} seconds`); this._zones[zone].queued = true; this._zones[zone].remainingDuration = duration; this.zoneQueue.add(this.startZone.bind(this, zone, duration)); } async deactivateZone(zone) { this.emitLog('debug', `Zone ${zone}: Deactivate`); this._zones[zone].active = false; this._zones[zone].queued = false; if (this.isInUse(zone)) { if (this._advanceZoneSupported) { const response = await this._client.advanceZone(); this._advanceZoneSupported = response instanceof AcknowledgedResponse; } if (!this._advanceZoneSupported) { await this._client.stopIrrigation(); } this._statusRefreshSubject.next(); } } deactivateAllZones() { for (const zone of this.zones) { this._zones[zone].active = false; this._zones[zone].queued = false; } } enableZone(zone, enabled) { this.emit(EventType.ZONE_ENABLE, zone, enabled); } async startProgram(programId) { this.emitLog('info', `Program ${programId}: Start`); const programNumber = this.getProgramNumber(programId); await this._client.runProgram(programNumber); await this.updateStatus(); } isProgramRunning(programId) { // NOTE: If plugin is not able to determine if program is running then return undefined return this._currentProgramId === undefined ? undefined : this._currentProgramId === programId && this.isInUse(); } getProgramNumber(programId) { return programId.charCodeAt(0) - 65; } getProgramId(programNumber) { if (programNumber === undefined) { return undefined; } return String.fromCharCode(programNumber + 65); } async stopIrrigation() { this.emitLog('info', 'Stop Irrigation'); await this._client.stopIrrigation(); await this.updateStatus(); } async startZone(zone, duration) { this.emitLog('debug', `Zone ${zone}: Start for ${duration} seconds`); try { this._statusTimerSubscription?.unsubscribe(); await this.updateStatus(); if (!this.isActive(zone)) { this.emitLog('info', `Zone ${zone}: Skipped as it is not active`); return; } if (this._currentZoneId !== 0) { this.setStatusTimer(); let status; await new Promise((resolve) => { status = this._statusObsersable.subscribe(() => { if (this._currentZoneId === 0) { resolve(''); } }); }); status?.unsubscribe(); this._statusTimerSubscription?.unsubscribe(); } if (!this.isActive(zone)) { this.emitLog('info', `Zone ${zone}: Skipped as it is not active`); return; } if (this.isInUse(zone)) { this.emitLog('info', `Zone ${zone}: Skipped as it is already in use`); return; } this.emitLog('info', `Zone ${zone}: Start [Duration: ${this.formatTime(duration)}]`); await this._client.runZone(zone, duration); this._zones[zone].queued = false; if (!this._currentZoneStateSupported) { this._zones[zone].remainingDuration = duration; this._zones[zone].durationTime = new Date(); } } catch (error) { this.emitLog('warn', `Zone ${zone}: Failed to start [${error}]`); } finally { this._statusRefreshSubject.next(); } } setStatusTimer() { this._statusTimerSubscription?.unsubscribe(); let timerDuration = this.options.refreshRate ?? 0; if (this._currentZoneId !== 0) { const remainingDuration = this._zones[this._currentZoneId].remainingDuration; if (remainingDuration > 0) { timerDuration = timerDuration === 0 ? remainingDuration : Math.min(timerDuration, remainingDuration); } } if (timerDuration > 0) { this.emitLog('debug', `Status timer set for ${timerDuration} secs`); this._statusTimerSubscription = timer(timerDuration * 1000) .subscribe(async () => await this.performStatusRefresh()); } } async performStatusRefresh() { try { this._statusTimerSubscription?.unsubscribe(); await this.updateStatus(); this.setStatusTimer(); } catch (error) { this.emitLog('debug', `Failed to get status: ${error}`); } } async getControllerDateTime() { const respDate = await this._client.getControllerDate(); const respTime = await this._client.getControllerTime(); return new Date(respDate.year, respDate.month - 1, respDate.day, respTime.hour, respTime.minute, respTime.second); } async setControllerDateTime() { const host = new Date(); const controller = await this.getControllerDateTime(); if (Math.abs(controller.getTime() - host.getTime()) <= 60000) { return; } this.emitLog('info', `Adjusting Rainbird Controller Date/Time from ${controller.toLocaleString()} to ${host.toLocaleString()}`); await this._client.setControllerDate(host.getDate(), host.getMonth() + 1, host.getFullYear()); await this._client.setControllerTime(host.getHours(), host.getMinutes(), host.getSeconds()); } async getIrrigationDelay() { try { const response = await this._client.getIrrigationDelay(); if (!response || typeof response.days !== 'number') { this.emitLog('warn', 'Failed to get irrigation delay: Invalid response'); return 0; // Return a default value or handle it as needed } return response.days; } catch (e) { this.emitLog('error', `Failed to get irrigation delay: ${e.message ?? e}`); return 0; // Return a default value or handle it as needed } } async setIrrigationDelay(days) { try { this.emitLog('info', `Set Irrigation Delay: ${days} days`); await this._client.setIrrigstionDelay(days); } catch (e) { this.emitLog('error', `Failed to set irrigation delay: ${e.message ?? e}`); } } async updateStatus() { const status = await this.getRainBirdState(); const currentZone = status.runningZoneIndex !== undefined ? status.zones[status.runningZoneIndex] : undefined; const previousZoneId = this._currentZoneId; this._currentZoneId = currentZone?.id ?? 0; if (previousZoneId !== 0 && this._zones[previousZoneId].running && previousZoneId !== currentZone?.id) { this.emitLog('info', `Zone ${previousZoneId}: Complete`); } const previousProgramId = this._currentProgramId; this._currentProgramId = status.program !== undefined ? this.getProgramId(status.program.id) : undefined; if (previousProgramId !== undefined && previousProgramId !== '' && previousProgramId !== this._currentProgramId) { this.emitLog('info', `Program ${previousProgramId}: Complete`); } if (this._currentProgramId !== undefined && this._currentProgramId !== '' && previousProgramId !== this._currentProgramId) { this.emitLog('info', `Program ${this._currentProgramId}: Running [Time Remaining: ${this.formatTime(status.program?.timeRemaining)}]`); } if (currentZone !== undefined && currentZone.running && previousZoneId !== currentZone.id) { this.emitLog('info', `Zone ${currentZone.id}: Running [Time Remaining: ${this.formatTime(currentZone.timeRemaining)}]`); } for (const [id, zone] of Object.entries(this._zones)) { const statusZoneIndex = status.zones.findIndex(zone => zone.id === Number(id)); if (statusZoneIndex < 0) { zone.running = false; zone.remainingDuration = 0; zone.durationTime = undefined; zone.active = false; continue; } zone.running = status.zones[statusZoneIndex].running; zone.remainingDuration = status.zones[statusZoneIndex].timeRemaining ?? 0; zone.durationTime = zone.running ? new Date() : undefined; zone.active = zone.remainingDuration > 0; zone.queued = false; } this.emit(EventType.STATUS); if (this._rainSetPointReached !== status.rainSensorSetPointReached) { this._rainSetPointReached = status.rainSensorSetPointReached; this.emit(EventType.RAIN_SENSOR_STATE); this.emitLog('info', `Rain Sensor: ${status.rainSensorSetPointReached ? 'SetPoint reached' : 'Clear'}`); } } formatTime(seconds) { if (seconds === undefined) { return 'unknown'; } const date = new Date(seconds * 1000); return date.toISOString().substring(11, 19); } async getRainBirdState() { const page0 = await this._client.getProgramZoneState(0); const rainSensorState = await this._client.getRainSensorState(); if (page0.toBuffer().length === 12) { // ESP-TM2 return await this.getRainBirdStateTM2(page0.toBuffer(), rainSensorState.setPointReached); } if (page0.toBuffer().length === 7) { // ESP-ME3 return await this.getRainBirdStateME3(page0.toBuffer(), rainSensorState.setPointReached); } if (page0.toBuffer().length === 10) { // ESP-RZXe & ESP-Me series return this.getRainBirdStateRZXe(page0.toBuffer(), rainSensorState.setPointReached); } // Other models this._currentZoneStateSupported = false; return await this.getRainBirdStateDefault(page0.toBuffer(), rainSensorState.setPointReached); } async getRainBirdStateTM2(page0, setPointReached) { const state = { program: undefined, zones: [], runningZoneIndex: undefined, rainSensorSetPointReached: setPointReached, }; const isRunning = page0[11] !== 0; if (!isRunning) { return state; } const page1 = (await this._client.getProgramZoneState(1)).toBuffer(); let offset = 2; let index = 0; while (page1[offset] > 0) { const zoneId = page1[offset] & 31; const zoneRunning = zoneId === page0[8]; state.zones.push({ id: zoneId, timeRemaining: page1.readUInt16BE(offset + 1), running: zoneRunning, }); if (zoneRunning) { state.runningZoneIndex = index; } index++; offset += 3; } if (page0[9] > 2) { return state; } const totalTimeRemaining = state.zones.reduce((total, zone) => total + zone.timeRemaining, 0); state.program = { id: page0[9], timeRemaining: totalTimeRemaining, running: page0[11] !== 0, }; return state; } async getRainBirdStateME3(page0, setPointReached) { const state = { program: undefined, zones: [], runningZoneIndex: undefined, rainSensorSetPointReached: setPointReached, }; const isRunning = page0[3] !== 0; if (!isRunning) { return state; } const page1 = (await this._client.getProgramZoneState(1)).toBuffer(); state.zones.push({ id: page1[3], timeRemaining: page1.readUInt16LE(4), running: true, }); state.runningZoneIndex = 0; const pendingZones = await this.getRainBirdStateME3PendingZones(page0[4]); for (const pendingZone of pendingZones) { state.zones.push({ id: pendingZone[1], timeRemaining: pendingZone.readUInt16LE(2), running: false, }); } if (page0[2] > 3) { return state; } const totalTimeRemaining = state.zones.reduce((total, zone) => total + zone.timeRemaining, 0); state.program = { id: page1[2] - 1, timeRemaining: totalTimeRemaining, running: isRunning, }; return state; } async getRainBirdStateME3PendingZones(pendingZones) { const zones = []; if (pendingZones === 0) { return zones; } let pageId = 2; while (pageId >= 2) { const page = (await this._client.getProgramZoneState(pageId)).toBuffer(); const pageZones = Math.min(pendingZones, 8); let offset = 2; for (let i = 0; i < pageZones; i++) { const zone = Buffer.from(page.subarray(offset, offset + 6)); zones.push(zone); offset += 6; } pendingZones -= 8; if (pendingZones > 0) { pageId++; } else { pageId = 0; } } return zones; } async getRainBirdStateRZXe(page0, setPointReached) { const state = { program: undefined, zones: [], runningZoneIndex: undefined, rainSensorSetPointReached: setPointReached, }; if (page0[6] === 0) { return state; } state.zones.push({ id: page0[6], timeRemaining: page0.readUInt16BE(8), running: page0[3] !== 0, }); state.runningZoneIndex = 0; await this.displaySupportWarning(page0); return state; } async getRainBirdStateDefault(page0, setPointReached) { const state = { program: undefined, zones: [], runningZoneIndex: undefined, rainSensorSetPointReached: setPointReached, }; const currentZone = await this._client.getCurrentZone(); if (currentZone.zoneId === 0) { return state; } state.zones.push({ id: currentZone.zoneId, timeRemaining: 0, running: true, }); state.runningZoneIndex = 0; await this.displaySupportWarning(page0); return state; } async displaySupportWarning(page0) { const now = (new Date()).getTime(); if (now - this._lastSupportWarning < 24 * 60 * 60 * 1000) { return; } this._lastSupportWarning = now; const page1 = (await this._client.getProgramZoneState(1)).toBuffer(); const page2 = (await this._client.getProgramZoneState(2)).toBuffer(); this.emitLog('warn', 'This plugin does not fully support your RainBird model and may not not correctly show the zone\'s state such as time remaining'); this.emitLog('warn', 'If you would like better support please create a GitHub issue [https://github.com/donavanbecker/rainbird/issues]'); this.emitLog('warn', 'and supply the following details:'); this.emitLog('warn', ` Model: ${this.model}, Zones: ${[...this.zones.keys()]}`); this.emitLog('warn', ` ProgramZoneState Page 0: ${[...page0.values()]}`); this.emitLog('warn', ` ProgramZoneState Page 1: ${[...page1.values()]}`); this.emitLog('warn', ` ProgramZoneState Page 2: ${[...page2.values()]}`); this.emitLog('warn', 'Also include your model (if different to the one above), which program is running and'); this.emitLog('warn', 'the time remaining for the currently running zone as well as for the other idle/waiting zones'); } refreshStatus() { this._statusRefreshSubject.next(); } } //# sourceMappingURL=RainBirdService.js.map