UNPKG

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
/** * 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;