UNPKG

@runnane/node-red-contrib-easee

Version:

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

1,603 lines (1,500 loc) 62.5 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). **/ // Import the modular Easee library const easee = require('../lib'); module.exports = function (RED) { "use strict"; class EaseeConfiguration { constructor(n) { RED.nodes.createNode(this, n); var node = this; // Debug logging configuration - set early for use in validation node.debugLogging = n.debugLogging || false; node.debugToNodeWarn = n.debugToNodeWarn || false; /** * Centralized logging helper functions following Node-RED standards * Define these early so they can be used in validation and throughout the node */ // Log info level messages (equivalent to console.log) node.logInfo = function(message, data = null) { const formattedMessage = `[easee] ${message}`; // Always log to console if (data !== null && typeof data === 'object') { console.log(formattedMessage, data); } else if (data !== null) { console.log(`${formattedMessage} ${data}`); } else { console.log(formattedMessage); } // Also log to node warn if configured if (node.debugToNodeWarn) { node.warn(data !== null ? `${formattedMessage} ${JSON.stringify(data)}` : formattedMessage); } }; // Log debug level messages (only when debug logging is enabled) node.logDebug = function(message, data = null) { if (!node.debugLogging) return; const formattedMessage = `[easee] DEBUG: ${message}`; // Log to console when debug is enabled if (data !== null && typeof data === 'object') { console.log(formattedMessage, data); } else if (data !== null) { console.log(`${formattedMessage} ${data}`); } else { console.log(formattedMessage); } // Also log to node warn if configured if (node.debugToNodeWarn) { node.warn(data !== null ? `${formattedMessage} ${JSON.stringify(data)}` : formattedMessage); } }; // Log error level messages (equivalent to console.error) node.logError = function(message, error = null) { const formattedMessage = `[easee] ERROR: ${message}`; // Always log errors to console if (error !== null) { console.error(formattedMessage, error); } else { console.error(formattedMessage); } // Also log to node error for Node-RED error handling if (error !== null) { node.error(`${formattedMessage} ${error.message || error}`); } else { node.error(formattedMessage); } }; // Log warning level messages node.logWarn = function(message, data = null) { const formattedMessage = `[easee] WARN: ${message}`; // Log to console if (data !== null && typeof data === 'object') { console.warn(formattedMessage, data); } else if (data !== null) { console.warn(`${formattedMessage} ${data}`); } else { console.warn(formattedMessage); } // Always log warnings to node warn node.warn(data !== null ? `${formattedMessage} ${JSON.stringify(data)}` : formattedMessage); }; // Validate credentials are provided during node creation node.validateCredentials = () => { if (!node.credentials) { return { valid: false, message: "No credentials object found" }; } if (!node.credentials.username || node.credentials.username.trim() === '') { return { valid: false, message: "Username is required" }; } if (!node.credentials.password || node.credentials.password.trim() === '') { return { valid: false, message: "Password is required" }; } return { valid: true, message: "Credentials are valid" }; }; // Check if this configuration node is ready for use by other nodes node.isConfigurationValid = () => { return node.validateCredentials().valid; }; // Perform initial validation const validation = node.validateCredentials(); if (!validation.valid) { node.logError(`Configuration node validation failed: ${validation.message}`); node.status({ fill: "red", shape: "ring", text: "Invalid configuration - missing credentials", }); node.error(`[easee] Configuration node is invalid: ${validation.message}. Please edit the configuration and provide both username and password.`); node.warn(`[easee] This node will not function until valid credentials are provided.`); // Don't return or throw - let the node exist but be non-functional } node.signalRpath = "https://streams.easee.com/hubs/chargers"; node.RestApipath = "https://api.easee.com/api"; node.accessToken = false; node.refreshToken = false; node.tokenExpires = new Date(); node.tokenIssuedAt = new Date(); node.tokenLifetime = 0; // Token lifetime in seconds node.checkTokenHandler = null; node.refreshRetryCount = 0; node.maxRefreshRetries = 5; node.loginRetryCount = 0; node.maxLoginRetries = 5; node.authenticationInProgress = false; // Prevent concurrent authentication attempts // Token renewal thresholds (best practices) node.RENEWAL_THRESHOLD_PERCENTAGE = 0.75; // Renew at 75% of lifetime node.MIN_BUFFER_TIME = 300; // Minimum 5 minutes buffer before expiration node.EARLY_RENEWAL_THRESHOLD = 600; // 10 minutes - for very short tokens /** * Stop running token refresh on closed */ node.on('close', function () { if (node.checkTokenHandler) { clearTimeout(node.checkTokenHandler); node.checkTokenHandler = null; } }); /** * Start running token refresh on start (event is emitted at end of constructor) */ this.on("start", (event) => { node.checkToken().catch((error) => { node.logError("Error in checkToken during start:", error); node.status({ fill: "red", shape: "ring", text: "Authentication error", }); }); }); /** * * @param {string} url * @param {string} method * @param {*} body * @returns */ node.genericCall = async (url, method = "GET", body = null) => { return node.doAuthRestCall(url, method, null, body).then((response) => { return response; }); }; /** * * @param {*} url * @param {*} method * @param {*} headers * @param {*} body * @returns */ node.doAuthRestCall = async ( url, method = "GET", headers = null, body = null ) => { // Ensure authentication is available before making the call const authAvailable = await node.ensureAuthentication(); if (!authAvailable) { const error = new Error("Authentication not available"); node.logError("Authentication not available for doAuthRestCall"); node.status({ fill: "red", shape: "ring", text: "Authentication failed", }); throw error; } headers = { ...headers, Accept: "application/json", "Content-Type": "application/json", Authorization: "Bearer " + node.accessToken, }; const bodyPayload = body ? JSON.stringify(body) : null; const response = await fetch(node.RestApipath + url, { method: method, headers: headers, body: bodyPayload, }).catch((error) => { node.logError("Critical error in doAuthRestCall() fetch, failing", error); node.error(error); return; }); const http_text = await response.text(); let http_json = null; try { http_json = JSON.parse(http_text); } catch (err) { } const http_status = response.status; const http_statusText = response.statusText; const is_ok = response.ok; const is_json = (typeof http_json === "object"); if (!is_ok) { // Do we have valid JSON and JSON message? if (is_json) { if (is_json?.message ?? false) { throw Error(`REST Command failed (${http_status}: ${http_statusText}) ${is_json.message}`); } else { throw Error(`REST Command failed (${http_status}: ${http_statusText}) ${http_text}`); } } // We do not have valid JSON throw Error(`REST Command failed (${http_status}: ${http_statusText}) ${http_text}`); } if (is_json && http_json !== null) { node.status({ fill: "green", shape: "dot", text: url, }); return http_json; } else { node.status({ fill: "green", shape: "dot", text: url, }); return { result: http_status, resultText: http_statusText }; } }; // node.doAuthRestCall() /** * * https://developer.easee.com/reference/get_api-resources-observation-properties * * @param {*} data * @param {*} mode * @returns */ node.parseObservation = (data, mode = "id") => { const observations = [ { observationId: 15, name: "LocalPreAuthorizeEnabled", dataType: 2, }, { observationId: 16, name: "LocalAuthorizeOfflineEnabled", dataType: 2, }, { observationId: 17, name: "AllowOfflineTxForUnknownId", dataType: 2, }, { observationId: 20, name: "SiteStructure", dataType: 6, }, { observationId: 21, name: "DetectedPowerGridType", dataType: 4, }, { observationId: 22, name: "CircuitMaxCurrentP1", dataType: 3, valueUnit: "A", }, { observationId: 23, name: "CircuitMaxCurrentP2", dataType: 3, valueUnit: "A", }, { observationId: 24, name: "CircuitMaxCurrentP3", dataType: 3, valueUnit: "A", }, { observationId: 25, name: "Location", dataType: 5, }, { observationId: 26, name: "SiteIDString", dataType: 6, }, { observationId: 27, name: "SiteIDNumeric", dataType: 4, }, { observationId: 30, name: "LockCablePermanently", dataType: 2, }, { observationId: 31, name: "IsEnabled", dataType: 2, }, { observationId: 33, name: "CircuitSequenceNumber", dataType: 4, }, { observationId: 34, name: "SinglePhaseNumber", dataType: 4, }, { observationId: 35, name: "Enable3Phases_DEPRECATED", dataType: 2, }, { observationId: 36, name: "WiFiSSID", dataType: 6, }, { observationId: 37, name: "EnableIdleCurrent", dataType: 2, }, { observationId: 38, name: "PhaseMode", dataType: 4, valueMapping: (val) => { // https://developer.easee.com/docs/enumerations#phasemode-38 const modes = { 0: "Ignore,no phase mode reported", 1: "Locked to 1-phase", 2: "Auto phase mode", 3: "Locked to 3-phase", }; return modes[val]; }, }, { observationId: 40, name: "LedStripBrightness", dataType: 4, }, { observationId: 41, name: "LocalAuthorizationRequired", dataType: 2, }, { observationId: 42, name: "AuthorizationRequired", dataType: 2, }, { observationId: 43, name: "RemoteStartRequired", dataType: 2, }, { observationId: 44, name: "SmartButtonEnabled", dataType: 2, }, { observationId: 45, name: "OfflineChargingMode", dataType: 4, valueMapping: (val) => { // https://developer.easee.com/docs/enumerations#offline-charging-mode-45 const modes = { 0: "Always allow charging if offline", 1: "Only allow charging if token is whitelisted in the local token cache", 2: "Never allow charging if offline", }; return modes[val]; }, }, { observationId: 46, name: "LEDMode", dataType: 4, valueMapping: (val) => { // https://developer.easee.com/docs/enumerations#led-mode-46 const modes = { 0: "Charger is disabled", 1: "Charger is updating", 2: "Charger is updating", 3: "Charger is updating", 4: "Charger is updating", 5: "Charger is updating", 6: "Charger is updating", 7: "Charger is updating", 8: "Charger is updating", 9: "Charger is updating", 10: "Charger is updating", 11: "Charger is updating", 12: "Charger is updating", 13: "Charger is updating", 14: "Charger is updating", 15: "Charger is updating", 16: "Charger is faulty", 17: "Charger is faulty", 18: "Standby Master", 19: "Standby Secondary", 20: "Secondary unit searching for master", 21: "Smart mode (Not charging)", 22: "Smart mode (Charging)", 23: "Normal mode (Not charging)", 24: "Normal mode (Charging)", 25: "Waiting for authorization", 26: "Verifying with backend", 27: "Check configuration (Backplate chip defect)", 29: "Pairing RFID Keys", 43: "Self test mode", 44: "Self test mode", }; return modes[val]; }, }, { observationId: 47, name: "MaxChargerCurrent", dataType: 3, valueUnit: "A", }, { observationId: 48, name: "DynamicChargerCurrent", dataType: 3, valueUnit: "A", }, { observationId: 50, name: "MaxCurrentOfflineFallback_P1", dataType: 4, }, { observationId: 51, name: "MaxCurrentOfflineFallback_P2", dataType: 4, }, { observationId: 52, name: "MaxCurrentOfflineFallback_P3", dataType: 4, }, { observationId: 62, name: "ChargingSchedule", dataType: 6, }, { observationId: 68, name: "WiFiAPEnabled", dataType: 2, }, { observationId: 69, name: "PairedUserIDToken", dataType: 6, }, { observationId: 70, name: "CircuitTotalAllocatedPhaseConductorCurrent_L1", dataType: 3, valueUnit: "A", }, { observationId: 71, name: "CircuitTotalAllocatedPhaseConductorCurrent_L2", dataType: 3, valueUnit: "A", }, { observationId: 72, name: "CircuitTotalAllocatedPhaseConductorCurrent_L3", dataType: 3, }, { observationId: 73, name: "CircuitTotalPhaseConductorCurrent_L1", dataType: 3, valueUnit: "A", }, { observationId: 74, name: "CircuitTotalPhaseConductorCurrent_L2", dataType: 3, valueUnit: "A", }, { observationId: 75, name: "CircuitTotalPhaseConductorCurrent_L3", dataType: 3, valueUnit: "A", }, { observationId: 80, name: "SoftwareRelease", dataType: 4, }, { observationId: 81, name: "ICCID", dataType: 6, }, { observationId: 82, name: "ModemFwId", dataType: 6, }, { observationId: 83, name: "OTAErrorCode", dataType: 4, }, { observationId: 89, name: "RebootReason", dataType: 4, valueMapping: (val) => { // https://developer.easee.com/docs/enumerations#rebootreason-89 const modes = { 0: "FirewallReset", 1: "OptionByteLoaderReset", 2: "PinReset", 3: "BOR", 4: "SoftwareReset", 5: "IndependentWindowWatchdogReset", 6: "WindowWatchdogReset", 7: "LowPowerReset", 12: "Brownout", 20: "Reboot", }; return modes[val]; }, }, { observationId: 90, name: "PowerPCBVersion", dataType: 4, }, { observationId: 91, name: "ComPCBVersion", dataType: 4, }, { observationId: 96, name: "ReasonForNoCurrent", dataType: 4, valueMapping: (val) => { // https://developer.easee.com/docs/enumerations#reasonfornocurrent-96 const modes = { 0: "Charger Fine - Charger is OK, use main charger status", 1: "Loadbalancing - Max circuit current too low, adjust power circuit up.", 2: "Loadbalancing - Max dynamic circuit current too low (Partner Loadbalancing)", 3: "Loadbalancing - Max dynamic offline fallback circuit current too low", 4: "Loadbalancing - Circuit fuse too low", 5: "Loadbalancing - Waiting in queue", 6: "Loadbalancing - Waiting in fully charged queue (Assumes a connected EV uses delated charging, EV Charging complete", 7: "Error - illegal grid type (Error - Fault in automatic grid type detection)", 8: "Error - primary unit has not received current request from secondary unit (car)", 9: "Error - Master communication lost (Error)", 10: "Error - No current from equalizer to low", 11: "Error - No current, phase not connected", 25: "Error - Current limited by circuit fuse", 26: "Error - Current limited by circuit max current", 27: "Error - Current limited by dynamic circuit current", 28: "Error - Current limited by equalizer", 29: "Error - Current limited by circuit load balancing", 50: "Load balancing circuit - Secondary unit not requesting current (No car connected)", 51: "Load balancing circuit - Max charger current too low", 52: "Load balancing circuit - Max Dynamic charger current too low", 53: "Informational - Charger disabled", 54: "Waiting - Pending scheduled charging", 55: "Waiting - Pending authorization", 56: "Error - Charger in error state", 57: "Error - Erratic EV", 75: "Cable - Current limited by cable rating", 76: "Schedule - Current limited by schedule", 77: "Charger Limit - Current limited by charger max current", 78: "Charger Limit - Current limited by dynamic charger current", 79: "Car Limit - Current limited by car not charging", 80: "??? - Current limited by local adjustment", 81: "Car Limit - Current limited by car", 100: "UndefinedError", }; return modes[val]; }, }, { observationId: 97, name: "LoadBalancingNumberOfConnectedChargers", dataType: 4, }, { observationId: 98, name: "UDPNumOfConnectedNodes", dataType: 4, }, { observationId: 99, name: "LocalConnection", dataType: 4, }, { observationId: 100, name: "PilotMode", dataType: 6, valueMapping: (val) => { // https://developer.easee.com/docs/enumerations#pilotmode-100 const modes = { A: "Car disconnected", B: "Car connected", C: "Car charging", D: "Car needs ventilation", F: "Fault detected (LED goes Red and charging stops)", }; return modes[val]; }, }, { observationId: 101, name: "CarConnected_DEPRECATED", dataType: 2, }, { observationId: 102, name: "SmartCharging", dataType: 2, }, { observationId: 103, name: "CableLocked", dataType: 2, }, { observationId: 104, name: "CableRating", dataType: 3, }, { observationId: 105, name: "PilotHigh", dataType: 3, }, { observationId: 106, name: "PilotLow", dataType: 3, }, { observationId: 107, name: "BackPlateID", dataType: 6, }, { observationId: 108, name: "UserIDTokenReversed", dataType: 6, }, { observationId: 109, name: "ChargerOpMode", dataType: 4, valueMapping: (val) => { // https://developer.easee.com/docs/enumerations#op-mode-109 const modes = { 0: "Offline - Offline.", 1: "Disconnected - No car connected.", 2: "AwaitingStart - Car connected, charger is waiting for EV or load balancing. SuspendedEVSE.", 3: "Charging - Charging.", 4: "Completed - Car has paused/stopped charging.", 5: "Error - Error in charger.", 6: "ReadyToCharge - Charger is waiting for car to take energy. SuspendedEV.", 7: "Awaiting Authentication - Charger is waiting for authentication.", 8: "De-authenticating - Charger is de-authenticating.", }; return modes[val]; }, }, { observationId: 110, name: "OutputPhase", dataType: 4, valueMapping: (val) => { // https://developer.easee.com/docs/enumerations#output-phase-110 const modes = { 0: "Unassigned", 10: "1-phase (N+L1)", 11: "1-phase (L1+L2)", 12: "1-phase (N+L2)", 13: "1-phase (L1+L3)", 14: "1-phase (N+L3)", 15: "1-phase (L2+L3)", 20: "2-phases on TN (N+L1, N+L2)", 21: "2-phases on TN (N+L2, N+L3)", 22: "2-phases on IT (L1+L2, L2+L3)", 30: "3-phases (N+L1, N+L2, N+L3)", }; return modes[val]; }, /* public enum OutputPhaseType { UNASSIGNED = 0, // Unassigned P1_T2_T3_TN = 10, // 1-phase (N+L1) P1_T2_T3_IT = 11, // 1-phase (L1+L2) P1_T2_T4_TN = 12, // 1-phase (N+L2) P1_T2_T4_IT = 13, // 1-phase (L1+L3) P1_T2_T5_TN = 14, // 1-phase (N+L3) P1_T3_T4_IT = 15, // 1-phase (L2+L3) P2_T2_T3_T4_TN = 20, // 2-phases on TN (N+L1, N+L2) P2_T2_T4_T5_TN = 21, // 2-phases on TN (N+L2, N+L3) P2_T2_T3_T4_IT = 22, // 2-phases on IT (L1+L2, L2+L3) P3_T2_T3_T4_T5_TN = 30 // 3-phases (N+L1, N+L2, N+L3) } */ }, { observationId: 111, name: "DynamicCircuitCurrentP1", dataType: 3, valueUnit: "A", }, { observationId: 112, name: "DynamicCircuitCurrentP2", dataType: 3, valueUnit: "A", }, { observationId: 113, name: "DynamicCircuitCurrentP3", dataType: 3, valueUnit: "A", }, { observationId: 114, name: "OutputCurrent", dataType: 3, valueUnit: "A", }, { observationId: 115, name: "DeratedCurrent", dataType: 3, valueUnit: "A", }, { observationId: 116, name: "DeratingActive", dataType: 2, }, { observationId: 117, name: "DebugString", dataType: 6, }, { observationId: 118, name: "ErrorString", dataType: 6, }, { observationId: 119, name: "ErrorCode", dataType: 4, }, { observationId: 120, name: "TotalPower", dataType: 3, valueUnit: "W", }, { observationId: 121, name: "SessionEnergy", dataType: 3, valueUnit: "kWh", }, { observationId: 122, name: "EnergyPerHour", dataType: 3, valueUnit: "kWh", }, { observationId: 123, name: "LegacyEvStatus", dataType: 4, }, { observationId: 124, name: "LifetimeEnergy", dataType: 3, valueUnit: "kWh", }, { observationId: 125, name: "LifetimeRelaySwitches", dataType: 4, }, { observationId: 126, name: "LifetimeHours", dataType: 4, }, { observationId: 127, name: "DynamicCurrentOfflineFallback_DEPRICATED", dataType: 4, }, { observationId: 128, name: "UserIDToken", dataType: 6, }, { observationId: 129, name: "ChargingSession", dataType: 6, }, { observationId: 130, name: "CellRSSI", dataType: 4, }, { observationId: 131, name: "CellRAT", dataType: 4, }, { observationId: 132, name: "WiFiRSSI", dataType: 4, }, { observationId: 133, name: "CellAddress", dataType: 6, }, { observationId: 134, name: "WiFiAddress", dataType: 6, }, { observationId: 135, name: "WiFiType", dataType: 6, }, { observationId: 136, name: "LocalRSSI", dataType: 4, }, { observationId: 137, name: "MasterBackPlateID", dataType: 6, }, { observationId: 138, name: "LocalTxPower", dataType: 4, }, { observationId: 139, name: "LocalState", dataType: 6, }, { observationId: 140, name: "FoundWiFi", dataType: 6, }, { observationId: 141, name: "ChargerRAT", dataType: 4, }, { observationId: 142, name: "CellularInterfaceErrorCount", dataType: 4, }, { observationId: 143, name: "CellularInterfaceResetCount", dataType: 4, }, { observationId: 144, name: "WifiInterfaceErrorCount", dataType: 4, }, { observationId: 145, name: "WifiInterfaceResetCount", dataType: 4, }, { observationId: 146, name: "LocalNodeType", dataType: 4, }, { observationId: 147, name: "LocalRadioChannel", dataType: 4, }, { observationId: 148, name: "LocalShortAddress", dataType: 4, }, { observationId: 149, name: "LocalParentAddrOrNumOfNodes", dataType: 4, }, { observationId: 150, name: "TempMax", dataType: 3, valueUnit: "°C", }, { observationId: 151, name: "TempAmbientPowerBoard", dataType: 3, valueUnit: "°C", }, { observationId: 152, name: "TempInputT2", dataType: 3, valueUnit: "°C", }, { observationId: 153, name: "TempInputT3", dataType: 3, valueUnit: "°C", }, { observationId: 154, name: "TempInputT4", dataType: 3, valueUnit: "°C", }, { observationId: 155, name: "TempInputT5", dataType: 3, valueUnit: "°C", }, { observationId: 160, name: "TempOutputN", dataType: 3, valueUnit: "°C", }, { observationId: 161, name: "TempOutputL1", dataType: 3, valueUnit: "°C", }, { observationId: 162, name: "TempOutputL2", dataType: 3, valueUnit: "°C", }, { observationId: 163, name: "TempOutputL3", dataType: 3, valueUnit: "°C", }, { observationId: 170, name: "TempAmbient", dataType: 3, valueUnit: "°C", }, { observationId: 171, name: "LightAmbient", dataType: 4, }, { observationId: 172, name: "IntRelHumidity", dataType: 4, }, { observationId: 173, name: "BackPlateLocked", dataType: 2, }, { observationId: 174, name: "CurrentMotor", dataType: 3, }, { observationId: 175, name: "BackPlateHallSensor", dataType: 4, }, { observationId: 182, name: "InCurrent_T2", dataType: 3, valueUnit: "A", }, { observationId: 183, name: "InCurrent_T3", dataType: 3, valueUnit: "A", }, { observationId: 184, name: "InCurrent_T4", dataType: 3, valueUnit: "A", }, { observationId: 185, name: "InCurrent_T5", dataType: 3, valueUnit: "V", }, { observationId: 190, name: "InVolt_T1_T2", dataType: 3, valueUnit: "V", altName: "inVoltageT1T2", }, { observationId: 191, name: "InVolt_T1_T3", dataType: 3, valueUnit: "V", altName: "inVoltageT1T3", }, { observationId: 192, name: "InVolt_T1_T4", dataType: 3, valueUnit: "V", altName: "inVoltageT1T4", }, { observationId: 193, name: "InVolt_T1_T5", dataType: 3, valueUnit: "V", altName: "inVoltageT1T5", }, { observationId: 194, name: "InVolt_T2_T3", dataType: 3, valueUnit: "V", altName: "inVoltageT2T3", }, { observationId: 195, name: "InVolt_T2_T4", dataType: 3, valueUnit: "V", altName: "inVoltageT2T4", }, { observationId: 196, name: "InVolt_T2_T5", dataType: 3, valueUnit: "V", altName: "inVoltageT2T5", }, { observationId: 197, name: "InVolt_T3_T4", dataType: 3, valueUnit: "V", altName: "inVoltageT3T4", }, { observationId: 198, name: "InVolt_T3_T5", dataType: 3, valueUnit: "V", altName: "inVoltageT3T5", }, { observationId: 199, name: "InVolt_T4_T5", dataType: 3, valueUnit: "V", altName: "inVoltageT4T5", }, { observationId: 202, name: "OutVoltPin1_2", dataType: 3, valueUnit: "V", }, { observationId: 203, name: "OutVoltPin1_3", dataType: 3, valueUnit: "V", }, { observationId: 204, name: "OutVoltPin1_4", dataType: 3, valueUnit: "V", }, { observationId: 205, name: "OutVoltPin1_5", dataType: 3, valueUnit: "V", }, { observationId: 210, name: "VoltLevel33", dataType: 3, valueUnit: "V", }, { observationId: 211, name: "VoltLevel5", dataType: 3, valueUnit: "V", }, { observationId: 212, name: "VoltLevel12", dataType: 3, valueUnit: "V", }, { observationId: 230, name: "EqAvailableCurrentP1", dataType: 3, valueUnit: "A", }, { observationId: 231, name: "EqAvailableCurrentP2", dataType: 3, valueUnit: "A", }, { observationId: 232, name: "EqAvailableCurrentP3", dataType: 3, valueUnit: "A", }, ]; data.valueText = ""; data.valueUnit = ""; for (const idx in observations) { if (mode == "id" && observations[idx].observationId == data.id) { // Id match } else if ( mode == "name" && observations[idx].name.toLowerCase() == data.dataName.toLowerCase() ) { // Name match } else if ( mode == "name" && "altName" in observations[idx] && observations[idx].altName.toLowerCase() == data.dataName.toLowerCase() ) { // Altname match } else if ( mode == "name" && observations[idx].name.replace(/\_/g, "").toLowerCase() == data.dataName.toLowerCase() ) { // Altname match } else { continue; } data.dataName = observations[idx].name; data.observationId = observations[idx].observationId; if ( "valueUnit" in observations[idx] && observations[idx].valueUnit != undefined ) { data.valueUnit = observations[idx].valueUnit; } const valueTypes = { 1: "Binary", 2: "Boolean", 3: "Double", 4: "Integer", 5: "Position", 6: "String", 7: "Statistics", }; data.dataType = observations[idx].dataType; data.dataTypeName = valueTypes[observations[idx].dataType]; if (data.value !== null) { switch (data.dataTypeName) { case "Double": data.value = parseFloat(data.value); break; case "Integer": data.value = parseInt(data.value); break; } } if ( "valueMapping" in observations[idx] && observations[idx].valueMapping != undefined ) { data.valueText = observations[idx].valueMapping(data.value); } break; } if (!data.observationId) { // console.error(`Unknown observation id ${data.id}:`); // console.error(data); } return data; }; // node.parseObservation() /** * Ensures authentication is available, using existing token or triggering refresh if needed * This is the preferred method for consumer nodes to ensure authentication. * * IMPORTANT: This method prevents double login issues by checking token validity * before triggering authentication, unlike doLogin() which always performs a fresh login. * Consumer nodes (REST client, streaming client) should use this instead of doLogin(). * * @returns {Promise<boolean>} true if authentication is available, false otherwise */ node.ensureAuthentication = async () => { // Validate credentials first const credentialsCheck = node.validateCredentials(); if (!credentialsCheck.valid) { node.logDebug(`Cannot ensure authentication: ${credentialsCheck.message}`); return false; } // If authentication is already in progress, wait for it to complete if (node.authenticationInProgress) { node.logDebug("Authentication already in progress, waiting..."); // Wait for current authentication to complete (max 30 seconds) let waitCount = 0; while (node.authenticationInProgress && waitCount < 300) { await new Promise(resolve => setTimeout(resolve, 100)); waitCount++; } // Return current authentication status return !!node.accessToken; } // If we have a valid token, use it if (node.accessToken) { const now = new Date(); const timeToExpire = Math.floor((node.tokenExpires - now) / 1000); // If token is still valid (more than 1 minute remaining), use it if (timeToExpire > 60) { return true; } } // Token is missing or expiring soon, trigger a check which will refresh/login as needed try { await node.checkToken(); return !!node.accessToken; } catch (error) { node.logError("Error ensuring authentication:", error); return false; } }; /** * Check token expiration and refresh if needed using best practices */ node.checkToken = async () => { // Validate credentials before attempting any authentication const credentialsCheck = node.validateCredentials(); if (!credentialsCheck.valid) { node.logDebug(`Cannot authenticate: ${credentialsCheck.message}`); node.status({ fill: "red", shape: "ring", text: "Invalid configuration - edit to add credentials", }); // Don't schedule another check if credentials are invalid return; } // Prevent concurrent authentication attempts if (node.authenticationInProgress) { node.logDebug("Authentication already in progress, skipping duplicate checkToken call"); return; } // Set authentication flag node.authenticationInProgress = true; try { const now = new Date(); const timeToExpire = Math.floor((node.tokenExpires - now) / 1000); const tokenAge = Math.floor((now - node.tokenIssuedAt) / 1000); // Determine if we need to refresh the token based on best practices let shouldRefresh = false; let reason = ""; if (!node.accessToken) { shouldRefresh = true; reason = "No access token"; } else if (timeToExpire <= 0) { shouldRefresh = true; reason = "Token expired"; } else if (timeToExpire <= node.MIN_BUFFER_TIME) { shouldRefresh = true; reason = `Token expires in ${timeToExpire}s (within buffer time)`; } else if (node.tokenLifetime > 0) { // Use percentage-based renewal for tokens with known lifetime const renewalThreshold = node.tokenLifetime * node.RENEWAL_THRESHOLD_PERCENTAGE; if (tokenAge >= renewalThreshold) { shouldRefresh = true; reason = `Token age ${tokenAge}s exceeds ${Math.floor(node.RENEWAL_THRESHOLD_PERCENTAGE * 100)}% of lifetime (${renewalThreshold}s)`; } } else if (timeToExpire <= node.EARLY_RENEWAL_THRESHOLD) { // Fallback for tokens without known lifetime - renew if less than 10 minutes remain shouldRefresh = true; reason = `Token expires in ${timeToExpire}s (early renewal threshold)`; } if (shouldRefresh) { node.logInfo(`Token refresh needed: ${reason}`); node.status({ fill: "yellow", shape: "ring", text: "Refreshing token...", }); const refreshResult = await node.doRefreshToken(); // If refresh failed (returned null), try fresh login if (refreshResult === null) { node.status({ fill: "yellow", shape: "ring", text: "Token expired, re-authenticating...", }); try { await node.doLogin(); // Reset retry counters on successful login node.refreshRetryCount = 0; node.loginRetryCount = 0; } catch (loginError) { node.logError("Fresh login also failed:", loginError); node.loginRetryCount++; if (node.loginRetryCount >= node.maxLoginRetries) { node.status({ fill: "red", shape: "ring", text: "Authentication failed - check credentials", }); node.error("[easee] Authentication failed after maximum retries. Please check credentials and reconfigure the node."); // Clear all tokens to force reconfiguration node.accessToken = false; node.refreshToken = false; node.tokenExpires = new Date(); node.tokenIssuedAt = new Date(); node.tokenLifetime = 0; node.refreshRetryCount = 0; node.loginRetryCount = 0; // Stop the token check cycle if (node.checkTokenHandler) { clearTimeout(node.checkTokenHandler); node.checkTokenHandler = null; } return; } else { node.status({ fill: "yellow", shape: "ring", text: `Login retry ${node.loginRetryCount}/${node.maxLoginRetries}`, }); } } } } // Calculate adaptive check interval based on token lifetime and expiration let checkInterval; const credentialsValid = node.validateCredentials(); if (!credentialsValid.valid) { checkInterval = 300 * 1000; // 5 minutes for invalid credentials } else if (!node.accessToken) { checkInterval = 60 * 1000; // 1 minute if no token } else { const currentTime = new Date(); const timeToExpireMs = (node.tokenExpires - currentTime); const timeToRenewal = timeToExpireMs - (node.MIN_BUFFER_TIME * 1000); const currentTokenAge = Math.floor((currentTime - node.tokenIssuedAt) / 1000); if (node.tokenLifetime > 0) { // For tokens with known lifetime, check at strategic intervals const renewalTime = (node.tokenLifetime * node.RENEWAL_THRESHOLD_PERCENTAGE * 1000) - (currentTokenAge * 1000); checkInterval = Math.max(Math.min(renewalTime / 4, 300 * 1000), 30 * 1000); // Between 30s and 5min } else if (timeToRenewal > 0) { // For tokens without known lifetime, check based on time to renewal checkInterval = Math.max(Math.min(timeToRenewal / 3, 300 * 1000), 30 * 1000); // Between 30s and 5min } else { // Token needs attention soon checkInterval = 30 * 1000; // 30 seconds } } // Recalculate current timing for accurate logging const logTime = new Date(); const logTimeToExpire = Math.floor((node.tokenExpires - logTime) / 1000); const logTokenAge = Math.floor((logTime - node.tokenIssuedAt) / 1000); node.logDebug(`Next token check in ${Math.floor(checkInterval / 1000)}s (time to expire: ${logTimeToExpire}s, token age: ${logTokenAge}s)`); // Schedule next token check node.checkTokenHandler = setTimeout(() => { node.checkToken().catch((error) => { node.logError("Error in checkToken during scheduled check:", error); node.status({ fill: "red", shape: "ring", text: "Authentication error", }); }); }, checkInterval); } finally { // Always clear the authentication flag node.authenticationInProgress = false; } }; /** * Refresh the access token using the refresh token * @returns {Promise<Object|null>} The refresh response or null if refresh failed */ node.doRefreshToken = async () => { if (!