UNPKG

rainbird

Version:

The Rainbird library allows you to access your RainBird Controller.

407 lines 16.4 kB
import { Buffer } from 'node:buffer'; import crypto from 'node:crypto'; import * as events from 'node:events'; import aesjs from 'aes-js'; import axios from 'axios'; import PQueue from 'p-queue'; import encoder from 'text-encoder'; import { AdvanceZoneRequest } from './requests/AdvanceZoneRequest.js'; import { AvailableZonesRequest } from './requests/AvailableZonesRequest.js'; import { ControllerDateGetRequest } from './requests/ControllerDateGetRequest.js'; import { ControllerDateSetRequest } from './requests/ControllerDateSetRequest.js'; import { ControllerStateRequest } from './requests/ControllerStateRequest.js'; import { ControllerTimeGetRequest } from './requests/ControllerTimeGetRequest.js'; import { ControllerTimeSetRequest } from './requests/ControllerTimeSetRequest.js'; import { CurrentZoneRequest } from './requests/CurrentZoneRequest.js'; import { IrrigationDelayGetRequest } from './requests/IrrigationDelayGetRequest.js'; import { IrrigationDelaySetRequest } from './requests/IrrigationDelaySetRequest.js'; import { IrrigationStateRequest } from './requests/IrrigationStateRequest.js'; import { ModelAndVersionRequest } from './requests/ModelAndVersionRequest.js'; import { ProgramZoneStateRequest } from './requests/ProgramZoneStateRequest.js'; import { RainSensorStateRequest } from './requests/RainSensorStateRequest.js'; import { RawRequest } from './requests/RawRequest.js'; import { RunProgramRequest } from './requests/RunProgramRequest.js'; import { RunZoneRequest } from './requests/RunZoneRequest.js'; import { SerialNumberRequest } from './requests/SerialNumberRequest.js'; import { StopIrrigationRequest } from './requests/StopIrrigationRequest.js'; import { AcknowledgedResponse } from './responses/AcknowledgedResponse.js'; import { AvailableZonesResponse } from './responses/AvailableZonesResponse.js'; import { ControllerDateGetResponse } from './responses/ControllerDateGetResponse.js'; import { ControllerStateResponse } from './responses/ControllerStateResponse.js'; import { ControllerTimeGetResponse } from './responses/ControllerTimeGetResponse.js'; import { CurrentZoneResponse } from './responses/CurrentZoneResponse.js'; import { IrrigationDelayGetResponse } from './responses/IrrigationDelayGetResponse.js'; import { IrrigationStateResponse } from './responses/IrrigationStateResponse.js'; import { ModelAndVersionResponse } from './responses/ModelAndVersionResponse.js'; import { NotAcknowledgedResponse } from './responses/NotAcknowledgedResponse.js'; import { ProgramZoneStateResponse } from './responses/ProgramZoneStateResponse.js'; import { RainSensorStateResponse } from './responses/RainSensorStateResponse.js'; import { RawResponse } from './responses/RawResponse.js'; import { SerialNumberResponse } from './responses/SerialNumberResponse.js'; export class RainBirdClient extends events.EventEmitter { address; password; showRequestResponse; RETRY_DELAY = 60; /* private requestQueue = cq() .limit({ concurrency: 1 }) .process(this.sendRequest.bind(this)); */ queue = new PQueue({ concurrency: 1, }); constructor(address, password, showRequestResponse) { super(); this.address = address; this.password = password; this.showRequestResponse = showRequestResponse; } /** * Emit a log event. * @param level The log level. * @param message The log message. */ emitLog(level, message) { if (message !== undefined) { this.emit('log', { level, message }); } } async getModelAndVersion() { const request = { type: new ModelAndVersionRequest(), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as ModelAndVersionResponse; } async getAvailableZones() { const request = { type: new AvailableZonesRequest(), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as AvailableZonesResponse; } async getSerialNumber() { const request = { type: new SerialNumberRequest(), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as SerialNumberResponse; } async runProgram(program) { const request = { type: new RunProgramRequest(program), retry: true, postDelay: 1, }; const response = await this.queue.add(() => this.sendRequest(request)); // const response = await this.requestQueue(request); return response.type === 0 ? response : response; } async runZone(zone, duration) { const request = { type: new RunZoneRequest(zone, Math.round(duration / 60)), retry: true, postDelay: 1, }; const response = await this.queue.add(() => this.sendRequest(request)); // const response = await this.requestQueue(request); return response.type === 0 ? response : response; } async advanceZone() { const request = { type: new AdvanceZoneRequest(), retry: true, postDelay: 1, }; const response = await this.queue.add(() => this.sendRequest(request)); // const response = await this.requestQueue(request); return response.type === 0 ? response : response; } async stopIrrigation() { const request = { type: new StopIrrigationRequest(), retry: true, postDelay: 1, }; const response = await this.queue.add(() => this.sendRequest(request)); // const response = await this.requestQueue(request); return response.type === 0 ? response : response; } async getControllerState() { const request = { type: new ControllerStateRequest(), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as ControllerStateResponse; } async getControllerDate() { const request = { type: new ControllerDateGetRequest(), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as ControllerDateGetResponse; } async setControllerDate(day, month, year) { const request = { type: new ControllerDateSetRequest(day, month, year), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as AcknowledgedResponse; } async getControllerTime() { const request = { type: new ControllerTimeGetRequest(), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as ControllerTimeGetResponse; } async setControllerTime(hour, minute, second) { const request = { type: new ControllerTimeSetRequest(hour, minute, second), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as AcknowledgedResponse; } async getIrrigationState() { const request = { type: new IrrigationStateRequest(), retry: true, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as IrrigationStateResponse; } async getRainSensorState() { const request = { type: new RainSensorStateRequest(), retry: false, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as RainSensorStateResponse; } async getCurrentZone() { const request = { type: new CurrentZoneRequest(), retry: false, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as CurrentZoneResponse; } async getProgramZoneState(page = 0) { const request = { type: new ProgramZoneStateRequest(page), retry: false, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as ProgramZoneStateResponse; } async getRaw(type, page = 0) { const request = { type: new RawRequest(type, page), retry: false, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as RawResponse; } async getIrrigationDelay() { const request = { type: new IrrigationDelayGetRequest(), retry: false, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as IrrigationDelayGetResponse; } async setIrrigstionDelay(days) { days = Math.max(Math.min(Math.round(days), 14), 0); const request = { type: new IrrigationDelaySetRequest(days), retry: false, postDelay: 0, }; return await this.queue.add(() => this.sendRequest(request)); // return await this.requestQueue(request) as AcknowledgedResponse; } async sendRequest(request) { if (this.showRequestResponse) { this.emitLog('warn', `[${this.address}] Request: ${request.type}`); } while (true) { try { const url = `http://${this.address}/stick`; const data = this.encrypt(request.type); const config = this.createRequestConfig(); const resp = await axios.post(url, data, config); if (!resp.statusText || resp.status !== 200) { throw new Error(`Invalid Response [Status: ${resp.status}, Text: ${resp.statusText}]`); } const response = this.getResponse(resp.data); await this.delay(request.postDelay); return response; } catch (error) { this.emitLog('error', `RainBird controller request failed. [${error}]`); this.emitLog('error', `Failed Request: ${request.type}`); if (!request.retry) { break; } this.emitLog('warn', `Will retry in ${this.RETRY_DELAY} seconds`); await this.delay(this.RETRY_DELAY); } } } getResponse(encryptedResponse) { // eslint-disable-next-line no-control-regex const decryptedResponse = JSON.parse(this.decrypt(encryptedResponse).replace(/[\x10\n\0]/g, '')); if (!decryptedResponse) { this.emitLog('error', 'No response received'); return; } if (decryptedResponse.error) { this.emitLog('error', `Received error from Rainbird controller ${decryptedResponse.error.code}: ${decryptedResponse.error.message}`); return; } if (!decryptedResponse.result) { this.emitLog('error', 'Invalid response received'); return; } const data = Buffer.from(decryptedResponse.result.data, 'hex'); let response; switch (data[0]) { case 0x00: response = new NotAcknowledgedResponse(data); break; case 0x01: response = new AcknowledgedResponse(data); break; case 0x82: response = new ModelAndVersionResponse(data); break; case 0x83: response = new AvailableZonesResponse(data); break; case 0x85: response = new SerialNumberResponse(data); break; case 0x90: response = new ControllerTimeGetResponse(data); break; case 0x92: response = new ControllerDateGetResponse(data); break; case 0xB6: response = new IrrigationDelayGetResponse(data); break; case 0xBB: response = new ProgramZoneStateResponse(data); break; case 0xBE: response = new RainSensorStateResponse(data); break; case 0xBF: response = new CurrentZoneResponse(data); break; case 0xC8: response = new IrrigationStateResponse(data); break; case 0xCC: response = new ControllerStateResponse(data); break; default: response = new RawResponse(data); } if (this.showRequestResponse) { this.emitLog('warn', `[${this.address}] Response: ${response ?? 'Unknown'}`); } return response; } encrypt(request) { const formattedRequest = this.formatRequest(request); const passwordHash = crypto.createHash('sha256').update(this.toBytes(this.password)).digest(); const randomBytes = crypto.randomBytes(16); const packedRequest = this.toBytes(this.addPadding(`${formattedRequest}\x00\x10`)); const hashedRequest = crypto.createHash('sha256').update(this.toBytes(formattedRequest)).digest(); // eslint-disable-next-line new-cap const easEncryptor = new aesjs.ModeOfOperation.cbc(passwordHash, randomBytes); const encryptedRequest = Buffer.from(easEncryptor.encrypt(packedRequest)); return Buffer.concat([hashedRequest, randomBytes, encryptedRequest]); } decrypt(data) { const passwordHash = crypto.createHash('sha256').update(this.toBytes(this.password)).digest().subarray(0, 32); const randomBytes = data.subarray(32, 48); const encryptedBody = data.subarray(48, data.length); // eslint-disable-next-line new-cap const aesDecryptor = new aesjs.ModeOfOperation.cbc(passwordHash, randomBytes); return new encoder.TextDecoder().decode(aesDecryptor.decrypt(encryptedBody)); } formatRequest(request) { const data = request.toBuffer(); return JSON.stringify({ id: 9, jsonrpc: '2.0', method: 'tunnelSip', params: { data: data.toString('hex'), length: data.length, }, }); } createRequestConfig() { return { responseType: 'arraybuffer', headers: { 'Accept-Language': 'en', 'Accept-Encoding': 'gzip, deflate', 'User-Agent': 'RainBird/2.0 CFNetwork/811.5.4 Darwin/16.7.0', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Type': 'application/octet-stream', }, }; } toBytes(str) { return new encoder.TextEncoder('utf-8').encode(str); } addPadding(data) { const BLOCK_SIZE = 16; const dataLength = data.length; const charsToAdd = (dataLength + BLOCK_SIZE) - (dataLength % BLOCK_SIZE) - dataLength; const pad_string = Array.from({ length: charsToAdd + 1 }).join('\x10'); return [data, pad_string].join(''); } async delay(sec) { await new Promise((resolve) => { setTimeout(() => { resolve(''); }, sec * 1000); }); } } //# sourceMappingURL=RainBirdClient.js.map