UNPKG

dbus-victron-virtual

Version:

Add interoperability with victron dbus to a given dbus interface

897 lines (820 loc) 28.8 kB
const debug = require("debug")("dbus-victron-virtual"); const debugS2 = require("debug")("dbus-victron-virtual:s2"); const path = require("path"); const packageJson = require(path.join(__dirname, "../", "package.json")); const products = { temperature: { id: 0xc060, name: 'temperature sensor' }, meteo: { id: 0xc061 }, grid: { id: 0xc062, name: 'grid meter' }, tank: { id: 0xc063, name: 'tank sensor' }, heatpump: { id: 0xc064 }, battery: { id: 0xc065 }, pvinverter: { id: 0xc066, name: 'PV inverter' }, ev: { id: 0xc067, name: 'EV' }, gps: { id: 0xc068, name: 'GPS' }, 'switch': { id: 0xc069 }, acload: { id: 0xc06a, name: 'AC load' }, genset: { id: 0xc06b }, motordrive: { id: 0xc06c, name: 'E-drive' }, dcgenset: { id: 0xc06d, name: 'DC genset' }, dcload: { id: 0xc06e, name: 'DC load' }, energymeter: { id: 0xc06f, name: 'energy meter' }, }; function getType(value) { return value === null ? "d" : typeof value === "undefined" ? (() => { throw new Error("Value cannot be undefined"); })() : typeof value === "string" ? "s" : typeof value === "number" ? isNaN(value) ? (() => { throw new Error("NaN is not a valid input"); })() : Number.isInteger(value) ? "i" : "d" : (() => { throw new Error("Unsupported type: " + typeof value); })(); } function wrapValue(t, v) { if (v === null) { return ["ai", []]; } switch (t) { case "b": return ["b", v]; case "s": return ["s", v]; case "i": return ["i", v]; case "d": return ["d", v]; case "ad": if (!Array.isArray(v)) { throw new Error('value must be an array for type "ad"'); } for (const item of v) { if (typeof item !== "number") { throw new Error('all items in array must be numbers for type "ad"'); } } return ["ad", v]; case "ai": if (!Array.isArray(v)) { throw new Error('value must be an array for type "ai"'); } for (const item of v) { if (!Number.isInteger(item)) { throw new Error('all items in array must be integers for type "ai"'); } } return ["ai", v]; case "as": if (!Array.isArray(v)) { throw new Error('value must be an array for type "as"'); } for (const item of v) { if (typeof item !== "string") { throw new Error('all items in array must be strings for type "as"'); } } return ["as", v]; default: return t.type ? wrapValue(t.type, v) : v; } } function unwrapValue([t, v]) { switch (t[0].type) { case "b": return !!v[0]; case "s": return v[0]; case "i": return Number(v[0]); case "d": return Number(v[0]); case "ad": return v[0]; // Return the array of doubles directly case "ai": if (v.length === 1 && v[0].length === 0) { return null; } return v[0]; // Return the array of integers directly case "as": for (const item of v[0]) { if (typeof item !== "string") { throw new Error('All items in string array must be strings'); } } return v[0]; case "a": try { if (!t[0].child || !t[0].child[0] || !t[0].child[0].type) { throw new Error('Array type information missing'); } const valueType = t[0].child[0].type; const arrayLength = (v.length === 1 && v[0]) ? v[0].length : 0; if (v.length === 1 && arrayLength === 0 && valueType === 'i') { return null; } if (v.length === 1 && arrayLength > 0 && (valueType === 'i' || valueType === 'd')) { return v[0]; } throw new Error(`Unsupported array type. ValueType: ${valueType}, length: ${arrayLength}`); } catch (e) { console.error(e); throw new Error( 'Unable to unwrap array value: ' + e ) } default: throw new Error(`Unsupported value type: ${JSON.stringify(t)}`); } } /** validate and possibly convert a new number, received through SetValue or otherwise */ function validateNewNumber(name, declaration, value) { const number = Number(value); if (isNaN(number)) { throw new Error(`value for ${name} is not a number.`); } if (declaration.max !== undefined && number > declaration.max) { throw new Error(`value for ${name} is too large`); } if (declaration.min !== undefined && number < declaration.min) { throw new Error(`value for ${name} is too small`); } if (declaration.type === "i") { return Math.floor(number); } else { return number; } } /** validate and possibly convert a new value (received through SetValue or otherwise) */ function validateNewValue(name, declaration, value) { debug('validateNewValue called, name:', name, 'declaration:', declaration, 'value:', value); // we allow the declaration to be just a type ('s' or 'i'), or an object with a 'type'property, e.g. { type: 's' }. const type = declaration.type === undefined ? declaration : declaration.type; // we always allow a null value if (value === null) { return null; } try { switch (type) { case 'b': // we allow boolean values to be set as strings or numbers as well if (value === true || value == 'true' || value == '1') { return true } else if (value === false || value == 'false' || value == '0') { return false } throw new Error(`validation failed for ${name}, type ${declaration.type}, check logs for details.`) case 'i': case 'd': if (Array.isArray(value) && value.length > 0) { throw new Error(`value for ${name} cannot be an array`); } return validateNewNumber(name, declaration, value); case 'ad': if (!Array.isArray(value)) { throw new Error(`value for ${name} must be an array`); } return value.map((item) => validateNewNumber(name, { type: 'd', min: declaration.min, max: declaration.max }, item) ); case 'ai': if (!Array.isArray(value)) { throw new Error(`value for ${name} must be an array`); } return value.map((item) => validateNewNumber(name, { type: 'i', min: declaration.min, max: declaration.max }, item) ); case 'as': if (!Array.isArray(value)) { throw new Error(`value for ${name} must be an array`); } for (const item of value) { if (typeof item !== "string") { throw new Error(`all items in array for ${name} must be strings`); } } return value; case 's': if (Array.isArray(value) && value.length > 0) { throw new Error(`value for ${name} cannot be an array`); } return '' + value; default: return '' + value; } } catch (e) { console.warn( `validation failed for property ${name}, value:`, value ) throw e } } async function addSettings(bus, settings) { const body = [ settings.map((setting) => [ ["path", wrapValue("s", setting.path)], [ "default", wrapValue( typeof setting.type !== "undefined" ? setting.type : getType(setting.default), setting.default, ), ], ["min", wrapValue(setting.type || "d", setting.min !== undefined ? setting.min : null)], ["max", wrapValue(setting.type || "d", setting.max !== undefined ? setting.max : null)], ]), ]; return await new Promise((resolve, reject) => { bus.invoke( { interface: "com.victronenergy.Settings", path: "/", member: "AddSettings", destination: "com.victronenergy.settings", type: undefined, signature: "aa{sv}", body: body, }, function(err, result) { if (err) { return reject(err); } return resolve(result); }, ); }); } async function removeSettings(bus, settings) { const body = [settings.map((setting) => setting.path)]; return new Promise((resolve, reject) => { bus.invoke( { interface: "com.victronenergy.Settings", path: "/", member: "RemoveSettings", destination: "com.victronenergy.settings", type: undefined, signature: "as", body: body, }, function(err, result) { if (err) { return reject(err); } return resolve(result); }, ); }); } async function setValue(bus, { path, interface_, destination, value, type }) { return await new Promise((resolve, reject) => { if (path === "/DeviceInstance") { console.warn( "setValue called for path /DeviceInstance, this will be ignored by Victron services.", ); } bus.invoke( { interface: interface_, path: path || "/", member: "SetValue", destination, signature: "v", body: [ wrapValue(typeof type !== "undefined" ? type : getType(value), value), ], }, function(err, result) { if (err) { return reject(err); } resolve(result); }, ); }); } async function getValue(bus, { path, interface_, destination }) { return await new Promise((resolve, reject) => { bus.invoke( { interface: interface_, path: path || "/", member: "GetValue", destination, }, function(err, result) { if (err) { return reject(err); } resolve(result); }, ); }); } async function getMin(bus, { path, interface_, destination }) { return await new Promise((resolve, reject) => { bus.invoke( { interface: interface_, path: path || "/", member: "GetMin", destination, }, function(err, result) { if (err) { return reject(err); } resolve(result); }, ); }); } async function getMax(bus, { path, interface_, destination }) { return await new Promise((resolve, reject) => { bus.invoke( { interface: interface_, path: path || "/", member: "GetMax", destination, }, function(err, result) { if (err) { return reject(err); } resolve(result); }, ); }); } function defaultOnPropertiesChanged({ changes }) { return changes; // NOOP } function addVictronInterfaces( bus, declaration, definition, add_defaults = true, emitCallback = null, onPropertiesChanged = defaultOnPropertiesChanged ) { const warnings = []; if (!declaration.name) { throw new Error("Interface name is required"); } if (!declaration.name.match(/^[a-zA-Z0-9_.]+$/)) { warnings.push( `Interface name contains problematic characters, only a-zA-Z0-9_ allowed.`, ); } if (!declaration.name.match(/^com.victronenergy/)) { warnings.push("Interface name should start with com.victronenergy"); } debug(`addVictronInterfaces:`, declaration, definition, add_defaults); function addDefaults() { debug("addDefaults, declaration.name:", declaration.name); const productInName = declaration.productType || declaration.name.split(".")[2]; if (!productInName) { console.warn( `Unable to extract product from name, ensure name is of the form 'com.victronenergy.product.my_name', declaration.name=${declaration.name}` ); return; } const product = products[productInName]; if (!product) { const productNames = Object.keys(products); console.warn( `Invalid product ${productInName}, ensure product name is in ${productNames.join(", ")}`, ); return; } declaration["properties"]["Mgmt/Connection"] = { type: "s", readonly: true }; definition["Mgmt/Connection"] = "Node-RED"; declaration["properties"]["Mgmt/ProcessName"] = { type: "s", readonly: true }; definition["Mgmt/ProcessName"] = packageJson.name; declaration["properties"]["Mgmt/ProcessVersion"] = { type: "s", readonly: true }; definition["Mgmt/ProcessVersion"] = packageJson.version; declaration["properties"]["ProductId"] = { type: "i", format: (/* v */) => product['id'].toString(16), readonly: true }; definition["ProductId"] = product['id']; declaration["properties"]["ProductName"] = { type: "s", readonly: true }; definition["ProductName"] = 'Virtual ' + (product.name ? product.name : declaration["name"].split(".")[2]); } if (add_defaults == true) { addDefaults(); } const getFormatFunction = (v) => { if (v.format && typeof v.format === "function") { // Wrap the custom format function to ensure it always returns a string return (value) => { const formatted = v.format(value); return formatted != null ? String(formatted) : ""; }; } else { return (value) => { if (value == null) return ""; let stringValue = String(value); // Handle potential type mismatches switch (v.type) { case "d": // double/float return isNaN(parseFloat(stringValue)) ? "" : stringValue; case "i": // integer return isNaN(parseInt(stringValue, 10)) ? "" : stringValue; case "s": // string return stringValue; default: return stringValue; } }; } }; function processPropertyChanges({ changes: values }) { const changes = {} debug("processPropertyChanges called with values:", values); for (const [k, value] of Object.entries(values)) { changes[k] = validateNewValue(k, declaration.properties[k], value); } const changedProperties = onPropertiesChanged({ changes, instance: definition }); for (const k of Object.keys(changedProperties)) { if (!declaration.properties || !declaration.properties[k]) { throw new Error(`Property ${k} not found in properties.`); } // we allow readonly properties to be changed through onPropertiesChanged, but // not through SetValue, so we don't check readonly here. } return changedProperties; } // we use this for GetItems and ItemsChanged. function getProperties(limitToPropertyNames = [], prependSlash = false) { // Filter entries based on specificItem if provided const entries = Object.entries(declaration.properties || {}); const filteredEntries = (limitToPropertyNames || []).length > 0 ? entries.filter(([k,]) => limitToPropertyNames.includes(k)) : entries; return filteredEntries.map(([k, v]) => { debug("getProperties, entries, (k,v):", k, v); const format = getFormatFunction(v); return [ // Add leading slash only if we're filtering for a specific item prependSlash ? k.replace(/^(?!\/)/, "/") : k, [ ["Value", wrapValue(v, definition[k])], ["Text", ["s", format(definition[k])]], ], ]; }); } const iface = { GetItems: function() { return getProperties(null, true); }, GetValue: function() { return Object.entries(declaration.properties || {}).map(([k, v]) => { debug("GetValue, definition[k] and v:", definition[k], v); return [k.replace(/^(?!\/)/, ""), wrapValue(v, definition[k])]; }); }, SetValues: function(values /* msg */) { debug(`SetValues called with values:`, values); const changes = {} for (const [k, value] of values) { if (!declaration.properties || !declaration.properties[k]) { throw new Error(`Property ${k} not found in properties.`); } if ((declaration.properties[k] || {}).readonly) { return -1; } changes[k] = validateNewValue(k, declaration.properties[k], unwrapValue(value)); } const changedProperties = processPropertyChanges({ changes }); for (const k of Object.keys(changedProperties)) { definition[k] = changedProperties[k]; } debug(`SetValues updated properties:`, changedProperties); // TODO: we must include changed values only. iface.emit("ItemsChanged", getProperties(Object.keys(changedProperties), true)); return 0; }, emit: function(name, args) { debug("emit called, name:", name, "args:", args); if (emitCallback) { emitCallback(name, args); } }, }; function setValuesLocally(values) { debug(`setValuesLocally called with values:`, values); if (Object.keys(values).length === 0) { throw new Error("No values provided to setValuesLocally."); } const sanitizedValues = {}; for (const [key, value] of Object.entries(values)) { const cleanKey = key.startsWith('/') ? key.substring(1) : key; sanitizedValues[cleanKey] = value; } // first, check if any of the values are readonly, and if so, throw an error for (const k of Object.keys(sanitizedValues)) { if (!declaration.properties || !declaration.properties[k]) { throw new Error(`Property ${k} not found in properties.`); } if ((declaration.properties[k] || {}).readonly) { throw new Error(`Property ${k} is readonly and cannot be set.`); } } const changedProperties = processPropertyChanges({ changes: sanitizedValues }); for (const k of Object.keys(changedProperties)) { definition[k] = changedProperties[k]; } debug(`setValuesLocally updated definition:`, definition); iface.emit("ItemsChanged", getProperties(Object.keys(changedProperties), true)); } const ifaceDesc = { name: "com.victronenergy.BusItem", methods: { GetItems: ["", "a{sa{sv}}", [], ["items"]], GetValue: ["", "a{sv}", [], ["value"]], SetValues: ["a{sv}", "i", [], []], }, signals: { ItemsChanged: ["a{sa{sv}}", "", [], []], }, }; bus.exportInterface(iface, "/", ifaceDesc); let emitS2Signal = undefined; if (declaration.__enableS2) { console.warn("S2 support is experimental"); declaration.__s2state = { connectedCemId: null, lastSeen: 0, keepAliveInterval: 0 }; if (!declaration.__s2Handlers || !declaration.__s2Handlers.Connect || typeof declaration.__s2Handlers.Connect !== 'function') { throw new Error( "S2 support enabled, but no __s2Handlers.Connect function provided in declaration", ); } if (!declaration.__s2Handlers.Disconnect || typeof declaration.__s2Handlers.Disconnect !== 'function') { throw new Error( "S2 support enabled, but no __s2Handlers.Disconnect function provided in declaration", ); } if (!declaration.__s2Handlers.Message || typeof declaration.__s2Handlers.Message !== 'function') { throw new Error( "S2 support enabled, but no __s2Handlers.Message function provided in declaration", ); } if (!declaration.__s2Handlers.KeepAlive || typeof declaration.__s2Handlers.KeepAlive !== 'function') { throw new Error( "S2 support enabled, but no __s2Handlers.KeepAlive function provided in declaration", ); } function setKeepAliveTimer(state) { if (state.keepAliveTimer) { clearTimeout(state.keepAliveTimer); } state.keepAliveTimer = setTimeout(() => { console.warn('S2 KeepAlive timeout reached for CEM ID', state.connectedCemId, ', disconnecting.'); emitS2Signal('Disconnect', [state.connectedCemId, 'KeepAlive missed']); state.connectedCemId = null; state.keepAliveTimeout = 0; state.lastSeen = 0; }, state.keepAliveTimeout * 1.2 * 1000); // 20% grace period } const s2Iface = { Discover: function() { debugS2( `S2 "Discover" called, s2state:`, declaration.__s2state ) return true; }, Connect: function(cemId, keepAliveInterval) { debugS2( `S2 "Connect" called with cemId: ${cemId}, keepAliveInterval: ${keepAliveInterval}, s2state:`, declaration.__s2state ); if (typeof cemId !== 'string' || cemId.length === 0) { throw new Error('Invalid cemId provided to S2 Connect'); } if (typeof keepAliveInterval !== 'number' || keepAliveInterval <= 0) { throw new Error('Invalid keepAliveInterval provided to S2 Connect'); } let returnValue = true; const state = declaration.__s2state; function now() { return new Date().getTime(); } if (state.connectedCemId === null) { // first connection state.connectedCemId = cemId state.keepAliveTimeout = keepAliveInterval state.lastSeen = now() setKeepAliveTimer(state); debugS2('CEM ID', cemId, 'connected.') declaration.__s2Handlers.Connect(cemId, keepAliveInterval); } else if (state.connectedCemId === cemId) { // it's a reconnect, accept state.keepAliveTimeout = keepAliveInterval state.lastSeen = now() setKeepAliveTimer(state); debugS2('CEM ID', cemId, 're-connected.') } else { console.warn('CEM ID', cemId, 'is trying to connect, but CEM ID', state.connectedCemId, 'is already connected. Rejecting.') returnValue = false; } return returnValue; }, Disconnect: function(cemId) { // TODO: when called without cemId via dbus-send, we don't fail, but get an object instead of a cemId. We should handle that case. // if we are not connected, ignore. If we are connected with a different cemId, ignore. If we are connected with the same cemId, disconnect, i.e. reset internal state, and call __s2Handlers.Disconnect. const state = declaration.__s2state; if (state.connectedCemId === cemId) { debugS2(`S2 Disconnect called with matching cemId ${cemId}, disconnecting.`); state.connectedCemId = null; state.lastSeen = 0; state.keepAliveTimeout = 0; clearInterval(state.keepAliveTimer); declaration.__s2Handlers.Disconnect(cemId); } else { console.warn( `S2 Disconnect called with cemId ${cemId}, but connectedCemId is ${state.connectedCemId}, ignoring.`, ); } }, Message: function(cemId, message) { debugS2( `S2 "Message" called with cemId: ${cemId}, message: ${message}`, ); // only forward to the flow, if cemID matches connectedCemId // If cemID does not match, reply with a Disconnect signal. if (declaration.__s2state.connectedCemId === cemId) { declaration.__s2Handlers.Message(cemId, message); } else { console.warn( `S2 Message called with cemId ${cemId}, but connectedCemId is ${declaration.__s2state.connectedCemId}, ignoring and sending Disconnect signal back.`, ); emitS2Signal('Disconnect', [cemId, 'Not connected']); } }, KeepAlive: function(cemId) { debugS2( `S2 "KeepAlive" called with cemId: ${cemId}, s2state:`, declaration.__s2state ); if (declaration.__s2state.connectedCemId !== cemId) { console.warn( `S2 KeepAlive called with cemId ${cemId}, but connectedCemId is ${declaration.__s2state.connectedCemId}, ignoring.`, ); emitS2Signal('Disconnect', [cemId, 'Not connected']); return false; } // update lastSeen and reset timer declaration.__s2state.lastSeen = new Date().getTime(); setKeepAliveTimer(declaration.__s2state); declaration.__s2Handlers.KeepAlive(cemId); return true; }, emit: function(name, args) { debugS2("S2 emit called, name:", name, "args:", args); if (emitCallback) { emitCallback(name, args); } }, }; bus.exportInterface( s2Iface, "/S2/0/Rm", { name: "com.victronenergy.S2", methods: { Discover: ["", "b", [], ["success"]], Connect: ["si", "b", [], ["success"]], Disconnect: ["s", "", [], []], Message: ["ss", "", [], []], KeepAlive: ["s", "b", [], ["success"]], }, signals: { Message: ["ss", "", [], []], Disconnect: ["ss", "", [], []], } } ); delete declaration.__enableS2; emitS2Signal = function(name, args) { debugS2("emitS2Signal called, name:", name, "args:", args); const s2SignalNames = ['Message', 'Disconnect']; if (!s2SignalNames.includes(name)) { throw new Error(`Unsupported S2 signal name: ${name}, supported names: ${s2SignalNames.join(", ")}`); } const { connectedCemId } = declaration.__s2state; if (!connectedCemId) { console.warn( `emitS2Signal called for signal ${name}, but no CEM is connected, ignoring.`, ); return; } const actualArgs = args.length > 1 ? args : [connectedCemId, args[0]]; s2Iface.emit(name, ...actualArgs); } } // support GetValue, SetValue, GetMin, and GetMax for each property for (const [k] of Object.entries(declaration.properties || {})) { bus.exportInterface( { GetValue: function(/* value, msg */) { const v = (declaration.properties || {})[k]; debug("GetValue, definition[k] and v:", definition[k], v); return wrapValue(v, definition[k]); }, GetText: function() { const v = (declaration.properties || {})[k]; const format = getFormatFunction(v); return format(definition[k]); }, SetValue: function(value /* msg */) { if ((declaration.properties[k] || {}).readonly) { return -1; } try { const changedProperties = processPropertyChanges({ changes: { [k]: validateNewValue(k, declaration.properties[k], unwrapValue(value)) } }) // validation done, update definition with all changed properties for (const changedKey of Object.keys(changedProperties)) { definition[changedKey] = validateNewValue(changedKey, declaration.properties[changedKey], changedProperties[changedKey]); } iface.emit("ItemsChanged", getProperties(Object.keys(changedProperties), true)); return 0; } catch (e) { console.error(e); return -1; } }, GetMin: function() { const v = (declaration.properties || {})[k]; // Ensure we return a wrapped null if min is undefined const minValue = (v && v.min !== undefined) ? v.min : null; return wrapValue(v.type || getType(minValue), minValue); }, GetMax: function() { const v = (declaration.properties || {})[k]; // Ensure we return a wrapped null if max is undefined const maxValue = (v && v.max !== undefined) ? v.max : null; return wrapValue(v.type || getType(maxValue), maxValue); }, }, `/${k}`, { name: "com.victronenergy.BusItem", methods: { GetValue: ["", "v", [], ["value"]], GetText: ["", "s", [], ["text"]], SetValue: ["v", "i", [], []], GetMin: ["", "v", [], ["min"]], GetMax: ["", "v", [], ["max"]], }, }, ); } return { emitItemsChanged: () => iface.emit("ItemsChanged", getProperties()), emitS2Signal, setValuesLocally, addSettings: (settings) => addSettings(bus, settings), removeSettings: (settings) => removeSettings(bus, settings), setValue: ({ path, interface_, destination, value, type }) => setValue(bus, { path, interface_, destination, value, type }), getValue: ({ path, interface_, destination }) => getValue(bus, { path, interface_, destination }), getMin: ({ path, interface_, destination }) => getMin(bus, { path, interface_, destination }), getMax: ({ path, interface_, destination }) => getMax(bus, { path, interface_, destination }), warnings, }; } module.exports = { addVictronInterfaces, addSettings, removeSettings, getValue, setValue, getMin, getMax, // we export private functions for unit-testing __private__: { validateNewValue, wrapValue, unwrapValue, getType } };