rainbird
Version:
The Rainbird library allows you to access your RainBird Controller.
407 lines • 16.4 kB
JavaScript
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