victron-vrm-api
Version:
Interface with the Victron Energy VRM API
353 lines (315 loc) • 14.2 kB
JavaScript
module.exports = function (RED) {
'use strict'
const axios = require('axios')
const curlirize = require('axios-curlirize')
const path = require('path')
const packageJson = require(path.join(__dirname, '../../', 'package.json'))
const debug = require('debug')('victron-vrm-api')
function VRMAPI (config) {
RED.nodes.createNode(this, config)
debug('VRMAPI node created with config:', config)
this.vrm = RED.nodes.getNode(config.vrm)
const node = this
let lastValidUpdate = null
node.on('input', function (msg) {
let url = msg.url || 'https://vrmapi.victronenergy.com/v2'
let installations = config.installations
let method = 'get'
const currentTime = Date.now()
if (lastValidUpdate && (currentTime - lastValidUpdate) < 5000) {
node.status({ fill: 'yellow', shape: 'dot', text: 'Limit queries quickly' })
return
}
let options = {
}
const flowContext = this.context().flow
const headers = {
'X-Authorization': 'Token ' + (this.vrm ? this.vrm.credentials.token : flowContext.get('vrm_api.credentials.token')),
accept: 'application/json',
'User-Agent': 'nrc-vrm-api/' + packageJson.version
}
if (msg.query && /^(GET|POST|PATCH)$/i.test(msg.method)) {
url += '/' + msg.query
method = msg.method.toLowerCase()
msg.topic = msg.topic || 'custom'
} else {
const parameters = {}
const topic = [config.api_type]
switch (config.api_type) {
case 'installations': {
if (config.installations === 'post-alarms') {
installations = 'alarms'
method = 'post'
}
if (config.installations === 'post-dynamic-ess-settings') {
installations = 'dynamic-ess-settings'
method = 'post'
}
if (config.installations === 'patch-dynamic-ess-settings') {
installations = 'dynamic-ess-settings'
method = 'patch'
}
topic.push(installations)
url += '/' + config.api_type + '/'
const match = config.idSite.match(/^\{\{(node|flow|global)\.(.*)\}\}$/)
if (match) {
const Context = this.context()[match[1]]
if (Context.get([match[2]])[0] !== undefined) {
url += Context.get([match[2]]).toString()
topic.push(Context.get([match[2]]).toString())
} else {
node.status({ fill: 'red', shape: 'ring', text: `Unable to retrieve ${config.idSite} from context` })
return
}
} else {
url += (msg.siteId || config.idSite)
topic.push((msg.siteId || config.idSite))
}
url += '/' + installations
if (installations === 'stats') {
parameters.type = 'custom'
Object.assign(parameters,
config.attribute !== 'dynamic_ess'
? {
'attributeCodes[]': config.attribute,
...(config.stats_interval && { interval: config.stats_interval }),
...(config.show_instance === true && { show_instance: 1 })
}
: { type: config.attribute }
)
if (config.attribute === 'evcs') {
delete (parameters['attributeCodes[]'])
parameters.type = 'evcs'
}
// --- Start of Corrected Time Logic ---
const now = new Date()
const nowTs = Math.floor(now.getTime() / 1000) // Unix timestamp for now, in seconds
// Helper function to floor a Unix timestamp (in seconds) to the beginning of the hour
const floorToHour = (ts) => {
if (ts === undefined || ts === null) return ts
return ts - (ts % 3600)
}
const getStartOfDay = (date) => {
const start = new Date(date)
config.use_utc ? start.setUTCHours(0, 0, 0, 0) : start.setHours(0, 0, 0, 0)
return Math.floor(start.getTime() / 1000)
}
// --- Calculate Start Time ---
if (config.stats_start && config.stats_start !== 'undefined') {
const startInput = config.stats_start
let calculatedStart
if (!isNaN(Number(startInput))) {
// Handles numeric offsets like -86400 (from now)
calculatedStart = nowTs + Number(startInput)
} else if (startInput === 'bod') { // Beginning of Today
calculatedStart = getStartOfDay(now)
} else if (startInput === 'boy') { // Beginning of Yesterday
calculatedStart = getStartOfDay(now) - 86400
} else if (startInput === 'bot') { // Beginning of Tomorrow
calculatedStart = getStartOfDay(now) + 86400
}
// Assign the final floored value
parameters.start = floorToHour(calculatedStart)
}
// --- Calculate End Time ---
if (config.stats_end && config.stats_end !== 'undefined') {
const endInput = config.stats_end
let calculatedEnd
if (!isNaN(Number(endInput))) {
// Handles numeric offsets like 0 (for now) or +86400 (for tomorrow)
calculatedEnd = nowTs + Number(endInput)
} else if (endInput === 'eod') { // End of Today
const endOfDay = new Date(now)
config.use_utc ? endOfDay.setUTCHours(23, 59, 59, 999) : endOfDay.setHours(23, 59, 59, 999)
calculatedEnd = Math.floor(endOfDay.getTime() / 1000)
} else if (endInput === 'eoy') { // End of Yesterday
calculatedEnd = getStartOfDay(now) - 1 // 23:59:59 of yesterday
}
// Assign the final floored value
parameters.end = floorToHour(calculatedEnd)
}
// --- End of Corrected Time Logic ---
}
if (installations === 'gps-download') {
const gpsStart = new Date(config.gps_start + ' GMT+0000')
const gpsEnd = new Date(config.gps_end + ' GMT+0000')
parameters.start = Math.floor(gpsStart.getTime() / 1000)
parameters.end = Math.floor(gpsEnd.getTime() / 1000)
}
url += '?' + Object.entries(parameters)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&')
}
break
case 'users':
url += '/' + config.api_type
if (config.users === 'installations') {
url += '/' + config.idUser
topic.push(config.users, config.idUser)
}
url += '/' + config.users
break
case 'widgets': {
topic.push(config.widgets)
url += '/installations/'
const match = config.idSite.match(/^\{\{(node|flow|global)\.(.*)\}\}$/)
if (match) {
const Context = this.context()[match[1]]
if (Context.get([match[2]])[0] !== undefined) {
url += Context.get([match[2]]).toString()
topic.push(Context.get([match[2]]).toString())
} else {
node.status({ fill: 'red', shape: 'ring', text: `Unable to retrieve ${config.idSite} from context` })
return
}
} else {
url += (msg.siteId || config.idSite) + '/'
topic.push((msg.siteId || config.idSite))
}
url += config.api_type + '/' + config.widgets
// instance
if (config.instance) {
parameters.instance = config.instance
}
url += '?' + Object.entries(parameters)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
}
break
case 'dynamic-ess':
topic.push('dynamic-ess')
url = 'https://vrm-dynamic-ess-api.victronenergy.com'
options = {
vrm_id: (config.vrm_id).toString(),
b_max: (config.b_max).toString(),
tb_max: (config.tb_max).toString(),
fb_max: (config.fb_max).toString(),
tg_max: (config.tg_max).toString(),
fg_max: (config.fg_max).toString(),
b_cycle_cost: (config.b_cycle_cost).toString(),
buy_price_formula: (config.buy_price_formula).toString(),
sell_price_formula: (config.sell_price_formula).toString(),
green_mode_on: (config.green_mode_on || false).toString(),
feed_in_possible: (config.feed_in_possible || false).toString(),
feed_in_control_on: (config.feed_in_control_on || false).toString(),
country: (config.country).toUpperCase(),
b_goal_hour: (config.b_goal_hour).toString(),
b_goal_SOC: (config.b_goal_SOC).toString()
}
headers['User-Agent'] = 'dynamic-ess/0.1.20'
break
}
msg.topic = topic.join(' ')
}
url = url.replace(/&$/, '')
if (config.verbose === true) {
node.warn({
url,
options,
headers
})
}
node.status({ fill: 'yellow', shape: 'ring', text: 'Connecting to VRM API' })
switch (method) {
case 'patch':
axios.patch(url, msg.payload, { headers }).then(function (response) {
if (response.status === 200) {
msg.payload = response.data
msg.payload.options = options
if (msg.payload.success === false) {
node.status({ fill: 'yellow', shape: 'dot', text: msg.payload.error_code })
} else {
node.status({ fill: 'green', shape: 'dot', text: 'Ok' })
}
} else {
node.status({ fill: 'yellow', shape: 'dot', text: response.status })
}
if (config.store_in_global_context === true) {
const globalContext = node.context().global
globalContext.set(msg.topic.split(' ').join('.'), response.data)
}
node.send(msg)
}).catch(function (error) {
if (error.response && error.response.data && error.response.data.errors) {
node.status({ fill: 'red', shape: 'dot', text: error.response.data.errors })
} else {
node.status({ fill: 'red', shape: 'dot', text: 'Error fetching VRM data' })
}
if (error.response) {
node.send({ payload: error.response })
}
})
break
case 'post':
axios.post(url, msg.payload, { headers }).then(function (response) {
if (response.status === 200) {
msg.payload = response.data
msg.payload.options = options
if (msg.payload.success === false) {
node.status({ fill: 'yellow', shape: 'dot', text: msg.payload.error_code })
} else {
node.status({ fill: 'green', shape: 'dot', text: 'Ok' })
}
} else {
node.status({ fill: 'yellow', shape: 'dot', text: response.status })
}
if (config.store_in_global_context === true) {
const globalContext = node.context().global
globalContext.set(msg.topic.split(' ').join('.'), response.data)
}
node.send(msg)
}).catch(function (error) {
if (error.response && error.response.data && error.response.data.errors) {
node.status({ fill: 'red', shape: 'dot', text: error.response.data.errors })
} else {
node.status({ fill: 'red', shape: 'dot', text: 'Error fetching VRM data' })
}
if (error.response) {
node.send({ payload: error.response })
}
})
break
default: // get
axios.get(url, { params: options, headers }).then(function (response) {
if (response.status === 200) {
let text = 'Ok'
msg.payload = response.data
if (installations !== 'gps-download') {
msg.payload.options = options
}
if (response.data.totals) {
const key = Object.keys(response.data.totals)[0]
const isNumber = value => !isNaN(value) && typeof value === 'number'
const formatNumber = value => isNumber(value) ? value.toFixed(1) : value
text = `${key.replace(/_/g, ' ')}: ${formatNumber(response.data.totals[key])}`
}
node.status({ fill: 'green', shape: 'dot', text })
} else {
node.status({ fill: 'yellow', shape: 'dot', text: response.status })
}
if (config.store_in_global_context === true) {
const globalContext = node.context().global
globalContext.set(msg.topic.split(' ').join('.'), response.data)
}
lastValidUpdate = currentTime
node.send(msg)
}).catch(function (error) {
if (error.response && error.response.data && error.response.data.errors) {
node.status({ fill: 'red', shape: 'dot', text: error.response.data.errors })
} else {
node.status({ fill: 'red', shape: 'dot', text: 'Error fetching VRM data' })
}
if (error.response) {
node.send({ payload: error.response })
}
})
}
})
node.on('close', function () {
})
if (config.verbose === true) {
curlirize(axios)
}
}
RED.nodes.registerType('vrm-api', VRMAPI)
}