dbus-victron-virtual
Version:
Add interoperability with victron dbus to a given dbus interface
519 lines (485 loc) • 14.4 kB
JavaScript
const debug = require("debug")("dbus-victron-virtual");
const path = require("path");
const packageJson = require(path.join(__dirname, "../", "package.json"));
const products = {
temperature: 0xc060,
meteo: 0xc061,
grid: 0xc062,
tank: 0xc063,
heatpump: 0xc064,
battery: 0xc065,
pvinverter: 0xc066,
ev: 0xc067,
gps: 0xc068,
'switch': 0xc069,
acload: 0xc06a,
genset: 0xc06b,
motordrive: 0xc06c,
dcgenset: 0xc06d
};
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];
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 "ai":
if (v.length === 1 && v[0].length === 0) {
return null;
}
throw new Error(
'Unsupported value type "ai", only supported as empty array',
);
case "a":
try {
const valueType = t[0].child[0].type;
if (v.length === 1 && v[0].length === 0 && valueType === 'i') {
// represents a null value
return null;
}
} catch (e) {
console.error(e);
throw new Error(
'Unable to unwrap array value: ' + e
)
}
throw new Error('array value, only empty i value supported, to represent null')
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) {
// we always allow a null value
if (value === null) {
return null;
}
try {
switch (declaration.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':
return validateNewNumber(name, declaration, value);
case 's':
default:
// we treat any other type as a string as well
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 addVictronInterfaces(
bus,
declaration,
definition,
add_defaults = true,
emitCallback = null
) {
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");
}
function addDefaults() {
debug("addDefaults, declaration.name:", declaration.name);
const productInName = 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"] = "s";
definition["Mgmt/Connection"] = "Virtual";
declaration["properties"]["Mgmt/ProcessName"] = "s";
definition["Mgmt/ProcessName"] = packageJson.name;
declaration["properties"]["Mgmt/ProcessVersion"] = "s";
definition["Mgmt/ProcessVersion"] = packageJson.version;
declaration["properties"]["ProductId"] = {
type: "i",
format: (/* v */) => product.toString(16),
};
definition["ProductId"] = products[declaration["name"].split(".")[2]];
declaration["properties"]["ProductName"] = "s";
definition["ProductName"] = `Virtual ${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;
}
};
}
};
// we use this for GetItems and ItemsChanged.
function getProperties(specificItem = null, prependSlash = false) {
// Filter entries based on specificItem if provided
const entries = Object.entries(declaration.properties || {});
const filteredEntries = specificItem
? entries.filter(([k, ]) => k === specificItem)
: 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])];
});
},
emit: function(name, args) {
debug("emit called, name:", name, "args:", args);
if (emitCallback) {
emitCallback(name, args);
}
},
};
const ifaceDesc = {
name: "com.victronenergy.BusItem",
methods: {
GetItems: ["", "a{sa{sv}}", [], ["items"]],
GetValue: ["", "a{sv}", [], ["value"]],
},
signals: {
ItemsChanged: ["a{sa{sv}}", "", [], []],
},
};
bus.exportInterface(iface, "/", ifaceDesc);
// 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 */) {
try {
definition[k] = validateNewValue(k, declaration.properties[k], unwrapValue(value));
iface.emit("ItemsChanged", getProperties(k, 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()),
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
}
};