node-red-contrib-rainbird
Version:
Node-RED nodes for controlling RainBird sprinkler controller (like: ESP-ME3) via local LNK2 wifi module connection. This wraps the RainBird SIP protocol using AES encryption. It uses a slightly modified version of node-rainbird originally developed by @bb
605 lines (538 loc) • 17.4 kB
JavaScript
/**
* This file is based on code from [node-rainbird](https://github.com/bbreukelen/node-rainbird)
* by @bbreukelen, licensed under the GNU GPL v3.
* Original source: https://github.com/bbreukelen/node-rainbird
*
* Modifications by Maciej Szulc
*/
const fetch = require("node-fetch"),
crypto = require("crypto"),
{ TextEncoder, TextDecoder } = require("util"),
{ AbortController } = require("abort-controller"),
aesjs = require("aes-js");
class RainBirdClass {
constructor(ipAddress, password) {
this.ip = ipAddress;
this.password = password;
this.debug = false;
this.timeout = 7000; // timeout - 7 seconds by default
this.retryCount = 0; // no call retry by default
this.retryDelay = 1000; //default delay for retries
this._lock = Promise.resolve(); // mutex
this._pendingRequests = 0;
this.logger = null;
}
setDebug() {
this.debug = true;
}
setIp(ip) {
this.ip = ip;
}
setPassword(password) {
this.password = password;
}
setTimeout(ms) {
this.timeout = ms;
}
setRetryCount(count) {
this.retryCount = Math.max(0, parseInt(count, 10));
}
setRetryDelay(ms) {
this.retryDelay = ms;
}
setLogger(node) {
if (!node || typeof node !== "object") {
console.error("Invalid node provided to setLogger: not an object.");
return;
}
const requiredMethods = ["log", "warn", "error"];
const missing = requiredMethods.filter((method) => typeof node[method] !== "function");
if (missing.length > 0) {
console.warn("Provided node is missing required methods: " + missing.join(", "));
} else {
this.logger = node;
// this.logger.warn("RainBirdClass logger initialized.");
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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 log(rb, msg, level = "debug") {
if (!rb.debug && level === "debug") return;
const pending = typeof rb._pendingRequests === "number" ? rb._pendingRequests : "?";
const message = `[pending: ${pending}] ${typeof msg === "object" ? JSON.stringify(msg) : msg}`;
if (rb.logger) {
switch (level) {
case "warn":
rb.logger.warn("WARN:" + message);
break;
case "error":
rb.logger.error("ERROR:" + message);
break;
default:
rb.logger.log("LOG:" + message);
}
} else {
// Fallback na console
switch (level) {
case "warn":
console.warn("WARN:", message);
break;
case "error":
console.error("ERROR:", message);
break;
default:
console.log("LOG:", message);
}
}
}
function request(rb, command, ...params) {
if (!rb._lock) rb._lock = Promise.resolve();
const maxAttempts = rb.retryCount || 1;
const retryDelay = rb.retryDelay || 300;
rb._pendingRequests++;
rb._lock = rb._lock.then(() => {
return new Promise((resolve, reject) => {
const attemptRequest = (attempt = 1) => {
log(rb, `Requesting ${command} from ${rb.ip} (attempt ${attempt})`);
const commandData = sipCommands.ControllerCommands[command];
if (!commandData) {
log(rb, "Invalid command");
return reject(new Error("Invalid command"));
}
let url = `http://${rb.ip}/stick`;
let body;
try {
body = encrypt(rb, makeBody(rb, commandData, params));
log(rb, `Sending body: ${body.toString("hex").toUpperCase()}`);
} catch (err) {
log(rb, "Error encrypting request body");
return reject(err);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), rb.timeout);
fetch(url, {
...makeRequestOptions(body),
signal: controller.signal,
})
.then((res) => {
if (!res.ok) 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) => {
clearTimeout(timeout);
const isTimeout = err.name === "AbortError";
const isRetryable = isTimeout || ["ECONNRESET", "ECONNREFUSED"].includes(err.code);
if (isTimeout) {
log(rb, `No response from (${rb.ip}) in ${rb.timeout}ms`);
err = new Error("Request timed out (AbortError)");
}
if (isRetryable && attempt < maxAttempts) {
log(rb, `Retrying request due to error: ${err.message}`);
setTimeout(() => {
// ❗ Retry logic — poprawne, nie używamy .then
attemptRequest(attempt + 1);
}, retryDelay);
} else {
log(rb, "Request failed");
if (rb.debug) console.error(err);
reject(err);
}
})
.finally(() => {
clearTimeout(timeout);
rb._pendingRequests--;
});
};
attemptRequest();
});
});
return rb._lock;
}
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;