UNPKG

victron-vrm-api

Version:
617 lines (537 loc) 16.4 kB
'use strict' const axios = require('axios') const http = require('http') const https = require('https') const debug = require('debug')('victron-vrm-api:service') const path = require('path') // Get package version for User-Agent const packageJson = require(path.join(__dirname, '../../', 'package.json')) /** * VRM API Service - Extracted logic from Node-RED node for testability */ class VRMAPIService { constructor (apiToken, options = {}) { this.apiToken = apiToken this.baseUrl = options.baseUrl || 'https://vrmapi.victronenergy.com/v2' this.dynamicEssUrl = options.dynamicEssUrl || 'https://vrm-dynamic-ess-api.victronenergy.com' this.userAgent = options.userAgent || `nrc-vrm-api/${packageJson.version}` this.forceIpv4 = options.forceIpv4 || false // Configure axios to force IPv4 if requested if (this.forceIpv4) { debug('Configuring axios to force IPv4 connections') axios.defaults.httpAgent = new http.Agent({ family: 4 }) axios.defaults.httpsAgent = new https.Agent({ family: 4 }) } } /** * Build standard headers for VRM API requests */ _buildHeaders (additionalHeaders = {}) { return { 'X-Authorization': `Token ${this.apiToken}`, accept: 'application/json', 'User-Agent': this.userAgent, ...additionalHeaders } } /** * Handle installations API calls */ async callInstallationsAPI (siteId, endpoint, method = 'GET', payload = null, options = {}) { let url = `${this.baseUrl}/installations/${siteId}` let actualMethod = method.toLowerCase() let actualEndpoint = endpoint // Handle special endpoint transformations if (endpoint === 'post-alarms') { actualEndpoint = 'alarms' actualMethod = 'post' } else if (endpoint === 'patch-dynamic-ess-settings') { actualEndpoint = 'dynamic-ess-settings' actualMethod = 'patch' } else if (endpoint === 'post-adjust-consumption') { actualEndpoint = 'adjust-consumption' actualMethod = 'post' } else if (endpoint === 'fetch-dynamic-ess-schedules') { // NEW: Use the correct schedule-dynamic-ess endpoint actualEndpoint = 'schedule-dynamic-ess' actualMethod = 'get' // This endpoint doesn't need the complex stats parameters // It just needs async=0 options.parameters = { async: 0 } } url += `/${actualEndpoint}` // Add query parameters based on endpoint type let queryParams = null if (actualEndpoint === 'stats' && options.parameters) { // Handle stats endpoint parameters queryParams = new URLSearchParams() Object.entries(options.parameters).forEach(([key, value]) => { if (Array.isArray(value)) { value.forEach(v => queryParams.append(key, v)) } else { queryParams.append(key, value) } }) } else if (actualEndpoint === 'schedule-dynamic-ess' && options.parameters) { // Handle schedule-dynamic-ess endpoint parameters queryParams = new URLSearchParams() Object.entries(options.parameters).forEach(([key, value]) => { queryParams.append(key, value) }) } if (queryParams) { const queryString = queryParams.toString() if (queryString) { url += `?${queryString}` } } const headers = this._buildHeaders() debug(`${actualMethod.toUpperCase()} ${url}`, payload ? { payload } : '') try { let response switch (actualMethod) { case 'get': response = await axios.get(url, { headers }) break case 'post': response = await axios.post(url, payload, { headers }) break case 'patch': response = await axios.patch(url, payload, { headers }) break default: throw new Error(`Unsupported method: ${actualMethod}`) } debug(`Response ${response.status}:`, response.data) return { success: true, status: response.status, data: response.data, url, method: actualMethod } } catch (error) { debug('API Error:', error.response?.status, error.response?.data) return { success: false, status: error.response?.status, data: error.response?.data, error: error.message, url, method: actualMethod } } } /** * Handle users API calls * * Note: VRM API returns user data in this structure: * { * "success": true, * "user": { * "id": 123456, * "name": "User Name", * "email": "user@example.com", * "country": "Country", * "idAccessToken": 1234, * "accessLevel": 1 * } * } */ async callUsersAPI (endpoint, userId = null) { let url = `${this.baseUrl}/users` if (endpoint === 'installations') { if (userId) { url += `/${userId}/installations` } else { url += '/me/installations' } } else if (endpoint === 'me') { url += '/me' } else { throw new Error(`Unknown users endpoint: ${endpoint}`) } const headers = this._buildHeaders() debug(`GET ${url}`) try { const response = await axios.get(url, { headers }) debug(`Response ${response.status}:`, response.data) return { success: true, status: response.status, data: response.data, url, method: 'get' } } catch (error) { debug('API Error:', error.response?.status, error.response?.data) return { success: false, status: error.response?.status, data: error.response?.data, error: error.message, url, method: 'get' } } } /** * Handle widgets API calls */ async callWidgetsAPI (siteId, widgetType, instance = null) { let url = `${this.baseUrl}/installations/${siteId}/widgets/${widgetType}` if (instance) { url += `?instance=${instance}` } const headers = this._buildHeaders() debug(`GET ${url}`) try { const response = await axios.get(url, { headers }) debug(`Response ${response.status}:`, response.data) return { success: true, status: response.status, data: response.data, url, method: 'get' } } catch (error) { debug('API Error:', error.response?.status, error.response?.data) return { success: false, status: error.response?.status, data: error.response?.data, error: error.message, url, method: 'get' } } } /** * Generic method to make custom API calls (for advanced usage) */ async makeCustomCall (url, method = 'GET', payload = null, customHeaders = {}) { const headers = this._buildHeaders(customHeaders) debug(`${method.toUpperCase()} ${url}`, payload ? { payload } : '') try { let response switch (method.toLowerCase()) { case 'get': response = await axios.get(url, { headers }) break case 'post': response = await axios.post(url, payload, { headers }) break case 'patch': response = await axios.patch(url, payload, { headers }) break default: throw new Error(`Unsupported method: ${method}`) } debug(`Response ${response.status}:`, response.data) return { success: true, status: response.status, data: response.data, url, method: method.toLowerCase() } } catch (error) { debug('API Error:', error.response?.status, error.response?.data) return { success: false, status: error.response?.status, data: error.response?.data, error: error.message, url, method: method.toLowerCase() } } } /** * Helper method to extract user data from VRM API response * Handles the nested structure where user data is in response.user */ extractUserData (apiResponse) { if (!apiResponse || !apiResponse.user) { return null } return { id: apiResponse.user.id, email: apiResponse.user.email, name: apiResponse.user.name, country: apiResponse.user.country, accessLevel: apiResponse.user.accessLevel, idAccessToken: apiResponse.user.idAccessToken, raw: apiResponse } } /** * Interpret users API response for status display */ interpretUsersStatus (responseData, endpoint) { if (!responseData) { return { text: 'No user data found', color: 'yellow', raw: responseData } } if (endpoint === 'me') { const user = responseData.user if (!user) { return { text: 'No user data found', color: 'yellow', raw: responseData } } const text = `${user.name} (ID: ${user.id})` return { text, color: 'green', userId: user.id, userName: user.name, userEmail: user.email, userCountry: user.country, accessLevel: user.accessLevel, raw: responseData } } if (endpoint === 'installations') { const records = responseData.records if (!Array.isArray(records)) { return { text: 'No installations data found', color: 'yellow', raw: responseData } } const count = records.length const text = `${count} installation${count === 1 ? '' : 's'}` return { text, color: 'green', installationCount: count, raw: responseData } } // Default for other users endpoints return { text: 'Users data received', color: 'green', raw: responseData } } /** * Interpret stats API response for status display */ interpretStatsStatus (responseData) { if (!responseData || !responseData.totals) { return { text: 'No stats data', color: 'yellow', totals: null, raw: responseData } } const key = Object.keys(responseData.totals)[0] if (!key) { return { text: 'No totals', color: 'yellow', totals: responseData.totals, raw: responseData } } const value = responseData.totals[key] const formatNumber = (value) => typeof value === 'number' ? value.toFixed(1) : value const attributeLabels = { dhE: 'Heating', evE: 'EV charging', daE: 'AC load' } const label = attributeLabels[key] || key.replace(/_/g, ' ') const text = `${label}: ${formatNumber(value)}` return { text, color: 'green', key, value, formattedValue: formatNumber(value), totals: responseData.totals, raw: responseData } } /** * Interpret dynamic ESS settings response for status display */ interpretDynamicEssStatus (responseData) { const data = responseData?.data const hasValidMode = data?.mode !== undefined && data?.mode !== null const hasValidOperatingMode = data?.operatingMode !== undefined && data?.operatingMode !== null if (!hasValidMode || !hasValidOperatingMode) { return { text: 'No data', color: 'yellow', mode: null, operatingMode: null, raw: responseData } } const modeNames = { 0: 'Off', 1: 'Auto', 2: 'Buy (deprecated)', 3: 'Sell (deprecated)', 4: 'Local' } const operationModeNames = ['Trade', 'Green'] const currentMode = data.mode const currentOpMode = data.operatingMode const text = `${modeNames[currentMode] || 'Unknown'} - ${operationModeNames[currentOpMode] || 'Unknown'} mode` const color = currentMode === 0 ? 'blue' : 'green' return { text, color, mode: currentMode, operatingMode: currentOpMode, modeName: modeNames[currentMode], operatingModeName: operationModeNames[currentOpMode], isGreenModeOn: data.isGreenModeOn, raw: responseData } } /** * Interpret widgets API response for status display */ interpretWidgetsStatus (responseData, widgetType, instance) { if (!responseData?.records?.data) { return { text: 'No widget data', color: 'yellow', hasData: false, raw: responseData } } const data = responseData.records.data // Check if we have actual device data (not just metadata) const hasActualData = Object.keys(data).some(key => key !== 'hasOldData' && key !== 'secondsAgo' && typeof data[key] === 'object' && data[key].value !== undefined ) if (!hasActualData) { return { text: 'No data - incorrect instance?', color: 'yellow', hasData: false, instance, raw: responseData } } // Widget configuration lookup table const widgetConfig = { EvChargerSummary: { lookupKey: '824', lookupCode: 'evs', lookupDataAttribute: 'Status', fallbackText: 'EV Charger', valueProperty: 'evChargerStatus' }, TempSummaryAndGraph: { lookupKey: '450', lookupCode: 'tsT', fallbackText: 'Temperature sensor', valueProperty: 'temperatureValue', includeInstanceInText: true // Show instance info for temperature } } // Get configuration for this widget type const config = widgetConfig[widgetType] if (config) { // Try different lookup strategies in order of preference let targetData = null // 1. Try lookup by specific key (e.g., data["450"]) if (config.lookupKey && data[config.lookupKey]) { targetData = data[config.lookupKey] } // 2. Try lookup by code (e.g., code === 'evs') if (!targetData && config.lookupCode) { targetData = Object.values(data).find(item => item.code === config.lookupCode) } // 3. Try lookup by dataAttributeName (e.g., dataAttributeName === 'Status') if (!targetData && config.lookupDataAttribute) { targetData = Object.values(data).find(item => item.dataAttributeName === config.lookupDataAttribute ) } if (targetData && targetData.formattedValue) { // Check data validity first - applies to all widgets if (targetData.isValid === 0) { return { text: 'Invalid data', color: 'yellow', hasData: true, hasValidData: false, instance, raw: responseData } } if (targetData.hasOldData === true) { return { text: 'Stale data - check sensor', color: 'yellow', hasData: true, hasValidData: false, instance, raw: responseData } } // Data is valid - format display text based on widget type let displayText = targetData.formattedValue // Add instance info if configured for this widget type if (config.includeInstanceInText && instance) { if (widgetType === 'TempSummaryAndGraph') { displayText = `Temperature (inst. ${instance}): ${targetData.formattedValue}` } else { displayText = `${config.fallbackText} (inst. ${instance}): ${targetData.formattedValue}` } } const result = { text: displayText, color: 'green', hasData: true, hasValidData: true, instance, raw: responseData } // Add widget-specific property result[config.valueProperty] = targetData.formattedValue return result } // Has data but no target field found let fallbackText = config.fallbackText if (config.includeInstanceInText && instance) { fallbackText = `${config.fallbackText} (inst. ${instance})` } return { text: fallbackText, color: 'green', hasData: true, [config.valueProperty]: null, instance, raw: responseData } } // Default for unknown widget types - just show the widget type name return { text: widgetType, color: 'green', hasData: true, instance, raw: responseData } } } module.exports = VRMAPIService