node-rainbird
Version:
Node.js library for interacting with WiFi LNK module of the Rain Bird Irrigation system
434 lines (381 loc) • 16.5 kB
JavaScript
const fetch = require('node-fetch'),
crypto = require('crypto'),
TextEncoder = require('text-encoder').TextEncoder,
TextDecoder = require('text-encoder').TextDecoder,
aesjs = require('aes-js');
class RainBirdClass {
constructor(ipAddress, password) {
this.ip = ipAddress;
this.password = password;
this.debug = false;
}
setDebug() { this.debug = true; }
setIp(ip) { this.ip = ip; }
setPassword(password) { this.password = password; }
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Executable RainBird commands
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/*
Returns the Rainbird mode and firmware version
*/
getModelAndVersion() { return request(this, "ModelAndVersionRequest"); }
/*
Returns the currrent time of the Rainbird controller
*/
getTime() { return request(this, "CurrentTimeRequest"); }
/*
Returns the currrent date of the Rainbird controller
*/
getDate() { return request(this, "CurrentDateRequest"); }
/*
Returns the controller's serial number.
For ESP-RZXe this is always 0000000000000000
*/
getSerialNumber() { return request(this, "SerialNumberRequest"); }
/*
Returns the state of the rain sensor (true or false)
*/
getRainSensorState() { return request(this, "CurrentRainSensorStateRequest"); }
/*
Returns the watering delay in days.
*/
getRainDelay() { return request(this, "RainDelayGetRequest"); }
/*
Returns the number of zones/stations for the controller.
For ESP-RZXe this is always 3F000000 where 3F is binary 111111. Each bit is 1 zone.
*/
getAvailableZones() { return request(this, "AvailableStationsRequest", decToHex(0)); }
/*
Returns if the controller is active or irrigation is switched off I think (boolean)
*/
getIrrigationState() { return request(this, "CurrentIrrigationStateRequest"); }
/*
Returns the decimal number of the currently active zone, or 0 when no zones are active.
*/
getActiveZones() { return request(this, "CurrentStationsActiveRequest", decToHex(0)); }
/*
Stops all irrigation
*/
stopIrrigation() { return request(this, "StopIrrigationRequest"); }
/*
Sets the watering delay in days. Parse the delay as a decimal between 0 and 14
*/
setRainDelay(days) { return request(this, "RainDelaySetRequest", decToHex(days, 4)); }
/*
Manually activates a zone for x minutes. When another zone is active, it will be de-activated.
*/
startZone(zone, minutes) { return request(this, "ManuallyRunStationRequest", decToHex(zone, 4), decToHex(minutes)); }
/*
Manually activates all zones in chronological order with x minutes.
*/
startAllZones(minutes) { return request(this, "TestStationsRequest", decToHex(minutes)); }
/*
Manually start program x. Not supported on ESP-RZXe but might work on other controllers
*/
startProgram(programNr) { return request(this, "ManuallyRunProgramRequest", decToHex(programNr)); }
}
function log(rb, msg) {
rb.debug && console.log('DEBUG: ' + (typeof msg === 'object' ? JSON.stringify(msg) : msg));
}
function request(rb, command, ...params) {
return new Promise((resolve, reject) => {
log(rb, `Requesting ${command} from ${rb.ip}`);
let commandData = sipCommands.ControllerCommands[command] || null;
if (!commandData) {
log(rb, "Invalid command");
return reject(new Error("Invalid command"));
}
let url = `http://${rb.ip}/stick`,
body;
try {
body = encrypt(rb, makeBody(rb, commandData, params));
} catch (err) {
log(rb, "Error encrypting request body");
return reject(err);
}
fetch(url, makeRequestOptions(body))
.then(res => {
if (!res.ok || res.status !== 200) throw new Error(res.status + ": " + res.statusText);
return res.buffer();
})
.then(data => {
log(rb, "Received a response from Rainbird controller");
return processResponse(rb, data);
})
.then(response => {
log(rb, response);
resolve(response);
})
.catch(err => {
log(rb, "Error during request");
if (rb.debug) {
console.error(err);
reject(err);
} else {
reject(err.message);
}
});
});
}
function makeBody(rb, commandObj, params) {
let command = commandObj.command;
(params || []).forEach(param => { command += param; });
if (command.length / 2 !== commandObj.length) throw new Error("Invalid parameters");
log(rb, `Sending body: ${command}`);
return {
"id": 9,
"jsonrpc": "2.0",
"method": "tunnelSip",
"params": {"data": command, "length": commandObj.length}
};
}
function makeRequestOptions(body) {
return {
method: 'POST',
body: body,
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"
}
}
}
function processResponse(rb, data) {
let response = unpackResponse(rb, data);
if (!response) throw new Error("No response received");
if (response.error) throw new Error(`Received error from Rainbird controller ${response.error.code}: ${response.error.message}`);
if (!response.result) throw new Error("Invalid response received");
let resultLength = response.result.length,
resultData = response.result.data,
resultCode = resultData.substring(0,2),
resultObj = sipCommands.ControllerResponses[resultCode];
if (!resultObj) {
log(rb, "Response code not found, response: " + resultData);
throw new Error("Response code not found");
}
if (resultLength !== resultObj.length) {
log(rb, "Invalid response length received: " + resultData);
throw new Error("Invalid response length");
}
// Get the fields from the resultObject
let output = {};
Object.keys(resultObj).forEach(key => {
if (typeof resultObj[key] === 'object' &&
resultObj[key].hasOwnProperty('position') &&
resultObj[key].hasOwnProperty('length'))
{
output[key] = resultData.slice(resultObj[key].position, resultObj[key].position + resultObj[key].length);
}
});
// Do data conversion if needed
typeof resultObj.f === 'function' && resultObj.f(output);
output._type = resultObj.type;
return output;
}
function unpackResponse(rb, data) {
return JSON.parse(decrypt(data, rb.password).replace(/[\x10\x0A\x00]/g, ""));
}
function encrypt(rb, body) {
body = JSON.stringify(body);
let passwordHash = crypto.createHash('sha256').update(toBytes(rb.password)).digest(),
randomBytes = crypto.randomBytes(16),
packedBody = toBytes(addPadding(body + "\x00\x10")),
hashedBody = crypto.createHash('sha256').update(toBytes(body)).digest(),
easEncryptor = new aesjs.ModeOfOperation.cbc(passwordHash, randomBytes),
encryptedBody = Buffer.from(easEncryptor.encrypt(packedBody));
return Buffer.concat([hashedBody, randomBytes, encryptedBody]);
}
function decrypt(data, password) {
let passwordHash = crypto.createHash('sha256').update(toBytes(password)).digest().slice(0, 32),
randomBytes = data.slice(32, 48),
encryptedBody = data.slice(48, data.length),
aesDecryptor = new aesjs.ModeOfOperation.cbc(passwordHash, randomBytes);
return new TextDecoder().decode(aesDecryptor.decrypt(encryptedBody));
}
function toBytes(str) {
return new TextEncoder('utf-8').encode(str);
}
function addPadding(data) {
const BLOCK_SIZE = 16;
let dataLength = data.length;
let charsToAdd = (dataLength + BLOCK_SIZE) - (dataLength % BLOCK_SIZE) - dataLength;
let pad_string = Array(charsToAdd + 1).join("\x10");
return [data, pad_string].join("");
}
function decToHex(value, len) {
return Math.abs(value).toString(16).toUpperCase().padStart(len || 2, '0');
}
function hexToDec(hex) {
return parseInt('0x' + hex);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function outputAllToBoolean(o) {
outputSomeToBoolean(o, Object.keys(o));
}
function outputAllToDecimal(o) {
outputSomeToDecimal(o, Object.keys(o));
}
function outputSomeToBoolean(o, keys) {
outputSomeTo(o, keys, 'bool');
}
function outputSomeToDecimal(o, keys) {
outputSomeTo(o, keys, 'dec');
}
function outputSomeTo(o, keys, type) {
if (!keys) return o;
if (typeof keys === 'string') keys = [keys];
keys.forEach(k => {
if (o.hasOwnProperty(k)) {
switch (type) {
case 'dec': o[k] = hexToDec(o[k]); break;
case 'bool': o[k] = !!hexToDec(o[k]); break;
}
}
});
}
const sipCommands = {
"ControllerCommands": {
"ModelAndVersionRequest": {"command": "02", "response": "82", "length": 1},
"AvailableStationsRequest": {"command": "03", "parameter": 0, "response": "83", "length": 2},
"CommandSupportRequest": {"command": "04", "commandToTest": "02", "response": "84", "length": 2},
"SerialNumberRequest": {"command": "05", "response": "85", "length": 1},
"CurrentTimeRequest": {"command": "10", "response": "90", "length": 1},
"CurrentDateRequest": {"command": "12", "response": "92", "length": 1},
"WaterBudgetRequest": {"command": "30", "parameter": 0, "response": "B0", "length": 2},
"ZonesSeasonalAdjustFactorRequest": {"command": "32", "parameter": 0, "response": "B2", "length": 2},
"CurrentRainSensorStateRequest": {"command": "3E", "response": "BE", "length": 1},
"CurrentStationsActiveRequest": {"command": "3F", "parameter": 0, "response": "BF", "length": 2},
"ManuallyRunProgramRequest": {"command": "38", "parameter": 0, "response": "01", "length": 2},
"ManuallyRunStationRequest": { "command": "39", "parameterOne": 0, "parameterTwo": 0, "response": "01", "length": 4 },
"TestStationsRequest": {"command": "3A", "parameter": 0, "response": "01", "length": 2},
"StopIrrigationRequest": {"command": "40", "response": "01", "length": 1},
"RainDelayGetRequest": {"command": "36", "response": "B6", "length": 1},
"RainDelaySetRequest": {"command": "37", "parameter": 0, "response": "01", "length": 3},
"AdvanceStationRequest": {"command": "42", "parameter": 0, "response": "01", "length": 2},
"CurrentIrrigationStateRequest": {"command": "48", "response": "C8", "length": 1},
"CurrentControllerStateSet": {"command": "49", "parameter": 0, "response": "01", "length": 2},
"ControllerEventTimestampRequest": {"command": "4A", "parameter": 0, "response": "CA", "length": 2},
"StackManuallyRunStationRequest": { "command": "4B", "parameter": 0, "parameterTwo": 0, "parameterThree": 0, "response": "01", "length": 4 },
"CombinedControllerStateRequest": {"command": "4C", "response": "CC", "length": 1}
},
"ControllerResponses": {
"00": {
"length": 3,
"type": "NotAcknowledgeResponse",
"commandEcho": {"position": 2, "length": 2},
"NAKCode": {"position": 4, "length": 2},
"f": o => o.ack = false
},
"01": {
"length": 2, "type": "AcknowledgeResponse",
"commandEcho": {"position": 2, "length": 2},
"f": o => o.ack = true
},
"82": {
"length": 5,
"type": "ModelAndVersionResponse",
"modelID": {"position": 2, "length": 4},
"protocolRevisionMajor": {"position": 6, "length": 2},
"protocolRevisionMinor": {"position": 8, "length": 2}
},
"83": {
"length": 6,
"type": "AvailableStationsResponse",
"pageNumber": {"position": 2, "length": 2},
"setStations": {"position": 4, "length": 8}
},
"84": {
"length": 3,
"type": "CommandSupportResponse",
"commandEcho": {"position": 2, "length": 2},
"support": {"position": 4, "length": 2}
},
"85": {"length": 9, "type": "SerialNumberResponse", "serialNumber": {"position": 2, "length": 16}},
"90": {
"length": 4,
"type": "CurrentTimeResponse",
"hour": {"position": 2, "length": 2},
"minute": {"position": 4, "length": 2},
"second": {"position": 6, "length": 2},
"f": outputAllToDecimal
},
"92": {
"length": 4,
"type": "CurrentDateResponse",
"day": {"position": 2, "length": 2},
"month": {"position": 4, "length": 1},
"year": {"position": 5, "length": 3},
"f": outputAllToDecimal
},
"B0": {
"length": 4,
"type": "WaterBudgetResponse",
"programCode": {"position": 2, "length": 2},
"seasonalAdjust": {"position": 4, "length": 4}
},
"B2": {
"length": 18,
"type": "ZonesSeasonalAdjustFactorResponse",
"programCode": {"position": 2, "length": 2},
"stationsSA": {"position": 4, "length": 32}
},
"BE": {
"length": 2,
"type": "CurrentRainSensorStateResponse",
"sensorState": {"position": 2, "length": 2},
"f": outputAllToBoolean
},
"BF": {
"length": 6,
"type": "CurrentStationsActiveResponse",
"pageNumber": {"position": 2, "length": 2},
"activeStations": {"position": 4, "length": 8},
"f": o => o.activeZones = o.activeStations
.match(/.{1,2}/g)
.map(x => parseInt('0x' + x).toString(2).split('').reverse().join('').indexOf('1') + 1)
/* activeStations looks like 20000000 allowing for 4 sets of stations.
match makes array of 2 characters, map loops over array, parseInt converts hex to decimal giving 32.
toString converts to binary string giving 100000, split,revers,join reverses and makes 000001.
indexOf gives position of the 1 or -1. +1 gives value 6. Possible values are 0 (no zone) to 6
*/
},
"B6": {
"length": 3,
"type": "RainDelaySettingResponse",
"delaySetting": {"position": 2, "length": 4},
"f": outputAllToDecimal
},
"C8": {
"length": 2,
"type": "CurrentIrrigationStateResponse",
"irrigationState": {"position": 2, "length": 2},
"f": outputAllToBoolean
},
"CA": {
"length": 6,
"type": "ControllerEventTimestampResponse",
"eventId": {"position": 2, "length": 2},
"timestamp": {"position": 4, "length": 8}
},
"CC": {
"length": 16,
"type": "CombinedControllerStateResponse",
"hour": {"position": 2, "length": 2},
"minute": {"position": 4, "length": 2},
"second": {"position": 6, "length": 2},
"day": {"position": 8, "length": 2},
"month": {"position": 10, "length": 1},
"year": {"position": 11, "length": 3},
"delaySetting": {"position": 14, "length": 4},
"sensorState": {"position": 18, "length": 2},
"irrigationState": {"position": 20, "length": 2},
"seasonalAdjust": {"position": 22, "length": 4},
"remainingRuntime": {"position": 26, "length": 4},
"activeStation": {"position": 30, "length": 2}
}
}
};
module.exports = RainBirdClass;