UNPKG

@runnane/node-red-contrib-easee

Version:

Module for streaming Easee data. Based on node-red-contrib-signalrcore

394 lines (359 loc) 12.2 kB
/** * MIT License * * Copyright (c) 2025 Jon Tungland * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * * This project was initially forked from node-red-contrib-signalrcore * by Scott Page (Apache License 2.0). **/ module.exports = function(RED) { "use strict"; const signalR = require("@microsoft/signalr"); class ChargerStreamingClientNode { constructor(n) { RED.nodes.createNode(this, n); const node = this; node.charger = n.charger; node.configurationNode = n.configuration; node.responses = n.responses; node.skipNegotiation = n.skipNegotiation !== undefined ? n.skipNegotiation : true; node.connectionConfig = RED.nodes.getNode(node.configurationNode); node.responses = n.responses; // Use configuration node's logging if available, fallback to console node.logInfo = node.connectionConfig?.logInfo || function(msg, data) { console.log(`[easee] ${msg}`, data || ""); }; node.logDebug = node.connectionConfig?.logDebug || function(msg, data) { if (node.connectionConfig?.debugLogging) { console.log(`[easee] DEBUG: ${msg}`, data || ""); } }; node.logError = node.connectionConfig?.logError || function(msg, error) { console.error(`[easee] ERROR: ${msg}`, error || ""); }; node.logWarn = node.connectionConfig?.logWarn || function(msg, data) { console.warn(`[easee] WARN: ${msg}`, data || ""); }; node.options = {}; node.reconnectInterval = 3000; node.closing = false; if (!this.connectionConfig) { node.emit("erro", { err: "[easee] Missing easee account configuration node" }); node.status({ fill: "red", shape: "ring", text: "Missing configuration" }); return; } // Check if the configuration node has valid credentials if (!node.connectionConfig.isConfigurationValid || !node.connectionConfig.isConfigurationValid()) { node.emit("erro", { err: "[easee] Configuration node is invalid - missing username or password" }); node.status({ fill: "red", shape: "ring", text: "Invalid configuration - missing credentials" }); return; } node.fullReconnect = () => { node.connectionConfig .ensureAuthentication() .then((isAuthenticated) => { if (isAuthenticated) { node.startconn(); } else { node.emit("erro", { err: "Authentication failed during fullReconnect()" }); } }) .catch((e) => { node.emit("erro", { err: "Error during fullReconnect(): " + e.message }); }); }; this.connectionConfig.on("update", (msg) => { node.status({ fill: "green", shape: "dot", text: msg.update }); //console.log(msg); }); this.on("input", (msg, send, done) => { node.fullReconnect(); if (done) { done(); } }); this.on("opened", (event) => { node.status({ fill: "green", shape: "dot", text: RED._("node-red:common.status.connected"), event: "connect", _session: { type: "signalr", id: event.id } }); // send the connected msg node.send([ { _connectionId: event.id, payload: "Connected" }, null, null ]); try { node.connection.send("SubscribeWithCurrentState", node.charger, true); } catch (error) { easeeClient.logger.error("Error sending SubscribeWithCurrentState:", error); node.emit("erro", { err: `Failed to subscribe to charger updates: ${error.message}`, id: event.id }); } node.connection.on("ProductUpdate", (data) => { try { // Use the configuration node's parseObservation method data = node.connectionConfig.parseObservation(data); node.send([null, null, null, { payload: data }, null, null]); } catch (error) { easeeClient.logger.error("Error parsing ProductUpdate:", error); // Send raw data if parsing fails node.send([null, null, null, { payload: data }, null, null]); } }); node.connection.on("ChargerUpdate", (data) => { try { // Use the configuration node's parseObservation method data = node.connectionConfig.parseObservation(data); node.send([null, null, null, null, { payload: data }, null]); } catch (error) { easeeClient.logger.error("Error parsing ChargerUpdate:", error); // Send raw data if parsing fails node.send([null, null, null, null, { payload: data }, null]); } }); node.connection.on("CommandResponse", (data) => { node.send([null, null, null, null, null, { payload: data }]); }); }); /** * Error event */ this.on("erro", (event) => { node.logError("Error in easee-streaming-client:", event.err); node.warn(event.err); node.status({ fill: "red", shape: "ring", text: event.err, event: "error", _session: { type: "signalr", id: event.id } }); const errMsg = { payload: event.err }; if (event.id) { errMsg._connectionId = event.id; } node.send([null, errMsg, null]); }); this.on("closed", (event) => { let status; if (event.count > 0) { status = { fill: "green", shape: "dot", text: RED._("node-red:common.status.connected") }; } else { status = { fill: "red", shape: "ring", text: RED._("node-red:common.status.disconnected") }; } status.event = "disconnect"; status._session = { type: "signalr", id: event.id }; node.status(status); node.send([ null, null, { _connectionId: event.id, payload: "Disconnected" } ]); }); this.on("close", (removed, done) => { node.closing = true; node.connection.stop(); if (node.reconnectTimoutHandle) { clearTimeout(node.reconnectTimoutHandle); node.reconnectTimoutHandle = null; } if (removed) { node.removeInputNode(node); } else { // This node is being restarted } node.emit("erro", { err: "Disconnected" }); if (done) { done(); } }); // Connect to remote endpoint node.startconn = () => { node.closing = false; if (node.reconnectTimoutHandle) { clearTimeout(node.reconnectTimoutHandle); } node.reconnectTimoutHandle = null; if (!node.charger) { node.emit("erro", { err: "No charger, exiting" }); return; } if (!node.connectionConfig.accessToken) { node.emit("erro", { err: "No accessToken, waiting" }); node.reconnectTimoutHandle = setTimeout( () => node.startconn(), node.reconnectInterval ); return; } node.logDebug("Establishing easee SignalR connection..."); node.logDebug("For hub:", node.connectionConfig.signalRpath); node.logDebug("For charger:", node.charger); // Configure SignalR options properly for v8+ const signalROptions = { accessTokenFactory: () => { const token = node.connectionConfig.accessToken; node.logDebug("Providing access token for SignalR, length:", token ? token.length : 0); return token; } }; // Add skipNegotiation option if enabled - requires WebSocket transport if (node.skipNegotiation) { signalROptions.skipNegotiation = true; signalROptions.transport = signalR.HttpTransportType.WebSockets; node.logDebug("SignalR negotiation disabled - forcing direct WebSocket connection"); } // Some SignalR hubs require query parameters for authorization context const signalRUrl = node.connectionConfig.signalRpath; node.logDebug("Using SignalR URL:", signalRUrl); node.logDebug("Skip negotiation:", node.skipNegotiation); let connection; try { connection = new signalR.HubConnectionBuilder() .withUrl(signalRUrl, signalROptions) .configureLogging(signalR.LogLevel.Debug) .build(); } catch (error) { node.logError("Error creating SignalR connection:", error); node.emit("erro", { err: `[easee] Error creating SignalR connection: ${error.message}` }); node.status({ fill: "red", shape: "ring", text: "SignalR connection error" }); return; } node.connection = connection; // keep for closing node.handleConnection(connection); }; node.reconnect = () => { if (node.reconnectTimoutHandle) { clearTimeout(node.reconnectTimoutHandle); } if (node.closing) { return; } node.connectionConfig.ensureAuthentication().then((isAuthenticated) => { if (isAuthenticated) { node.reconnectTimoutHandle = setTimeout( () => node.startconn(), node.reconnectInterval ); } else { node.logError("Authentication failed during reconnect"); } }).catch((error) => { node.logError("Error during reconnect:", error); }); }; node.notifyOnError = (err, id) => { if (!err) { return; } node.emit("erro", { err: err, id: id }); }; node.handleConnection = async(connection) => { let id = ""; try { await connection.start(); // We're connected id = connection.connectionId; node.emit("opened", { count: "", id: id }); connection.onclose((err) => { node.emit("closed", { count: "", id: id }); node.notifyOnError(err, id); node.reconnect(); }); } catch (err) { node.notifyOnError(err, id); node.reconnect(); } }; node.closing = false; // Start in 2 sec setTimeout(() => node.fullReconnect(), 2000); } } RED.nodes.registerType( "charger-streaming-client", ChargerStreamingClientNode ); };