Implemented:

- MMPT class for data store
- SerialPort library for USB-CAN connection
- Stream to Singnlak's DataBrowser 3
This commit is contained in:
Giuseppe Raffa
2026-05-25 13:13:24 +02:00
parent 4fa8923cff
commit 9d6766cda1
3 changed files with 213 additions and 24 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
node_modules/
.claude
*.md

View File

@@ -1,6 +1,7 @@
{
"name": "meb-solars",
"version": "1.0.0",
"description": "Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-CAN (slcan)",
"main": "src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -11,16 +12,17 @@
},
"keywords": [
"signalk-node-server-plugin",
"signalk-category-utility",
"signalk-category-charging",
"signalk-plugin",
"daly",
"bms",
"battery"
"mppt",
"poweren",
"solar",
"can"
],
"dependencies": {
"serialport": "^10.2.2"
"serialport": "^12.0.0",
"@serialport/parser-readline": "^12.0.0"
},
"author": "MEB Team",
"license": "ISC",
"description": "Un plugin per ottenere dati dai pannelli solari posti sulla barca"
"license": "ISC"
}

View File

@@ -1,21 +1,206 @@
module.exports = function (app) {
/*
Plugin SignalK: MEB Solars - Poweren MPPT Boost (CAN).
Legge i dati dai controller MPPT Poweren Boost collegati via convertitore
USB-CAN (slcan) e li pubblica nei path SignalK sotto il namespace
meb.solar.<id>.* (port / starboard / total).
Pattern di pubblicazione: gli update arrivano dal driver in modo asincrono
ad ogni frame, vengono bufferizzati e inviati a SignalK in blocco con
cadenza configurabile (publishIntervalMs) per non saturare i WebSocket.
*/
const { MPPTReader } = require('./core/reader');
module.exports = function (app) {
const plugin = {
id: 'meb-solars',
name: "MEB Solary Panels",
description: ""
name: 'MEB Solar Panels (Poweren MPPT)',
description: 'Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-CAN',
};
let reader = null;
let pollInterval = null;
let publishInterval = null;
let pendingUpdatesByPath = new Map(); // path -> value (deduplicazione)
// Schema configurazione mostrato nella UI di SignalK
plugin.schema = {
type: 'object',
required: ['device', 'canBitrate'],
properties: {
device: {
type: 'string',
title: 'Path del dispositivo seriale USB-CAN',
default: '/dev/ttyUSB1',
description: 'Es. /dev/ttyUSB1 o symlink udev /dev/mppt-can',
},
canBitrate: {
type: 'number',
title: 'Bitrate del bus CAN',
default: 250000,
enum: [125000, 250000, 500000],
},
publishIntervalMs: {
type: 'number',
title: 'Intervallo di pubblicazione SignalK (ms)',
default: 1000,
minimum: 200,
},
pollExtraIntervalMs: {
type: 'number',
title: 'Intervallo polling registri extra (ms)',
default: 10000,
minimum: 2000,
description: 'Polling di temperature e potenze pre-calcolate (registri 20,21,35,36)',
},
mppts: {
type: 'array',
title: 'Controller MPPT',
items: {
type: 'object',
properties: {
id: { type: 'string', title: 'ID logico (usato nei path SignalK)', default: 'port' },
rxId: { type: 'number', title: 'CAN Rx ID (registro Addr)', default: 50 },
txIdBase: { type: 'number', title: 'CAN Tx ID base (registro CAN_TxId)', default: 51 },
},
},
default: [
{ id: 'port', rxId: 50, txIdBase: 51 },
{ id: 'starboard', rxId: 60, txIdBase: 61 },
],
},
},
};
plugin.start = async function(options = {}) {
plugin.start = function (options) {
app.debug('Avvio plugin meb-solars con opzioni:', options);
reader = new MPPTReader({
device: options.device,
canBitrate: options.canBitrate,
mppts: options.mppts,
log: app.debug ? app.debug.bind(app) : console.log,
});
// Bufferizza gli update in arrivo dal driver: pubblicazione periodica
reader.on('updates', (updates) => {
for (const update of updates) {
pendingUpdatesByPath.set(update.path, update.value);
}
});
reader.on('open', () => {
app.setPluginStatus(`Connesso al bus CAN (${options.device} @ ${options.canBitrate} bps)`);
});
reader.on('error', (err) => {
app.error(`Errore driver CAN: ${err.message}`);
app.setPluginError(err.message);
});
reader.on('close', () => {
app.setPluginStatus('Bus CAN disconnesso, tentativo di riconnessione in corso');
});
reader.open()
.then(() => {
publishMetadata();
// Polling ciclico dei registri extra (temperature, potenze calcolate)
pollInterval = setInterval(() => {
const targets = reader.getPollTargets();
targets.forEach(({ rxId, regAddr }) => {
reader.sendReadRequest(rxId, regAddr).catch(() => { /* best effort */ });
});
}, options.pollExtraIntervalMs);
// Pubblicazione periodica degli update bufferizzati + aggregati
publishInterval = setInterval(() => {
const aggregateUpdates = reader.buildAggregateUpdates();
for (const update of aggregateUpdates) {
pendingUpdatesByPath.set(update.path, update.value);
}
if (pendingUpdatesByPath.size === 0) return;
const values = Array.from(pendingUpdatesByPath.entries()).map(([path, value]) => ({ path, value }));
pendingUpdatesByPath.clear();
publishUpdates(values);
}, options.publishIntervalMs);
})
.catch((err) => {
app.error(`Impossibile aprire il driver CAN: ${err.message}`);
app.setPluginError(err.message);
});
};
plugin.
plugin.stop = function () {
app.debug('Arresto plugin meb-solars');
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
if (publishInterval) { clearInterval(publishInterval); publishInterval = null; }
if (reader) {
reader.close().catch((err) => app.error(`Errore in chiusura: ${err.message}`));
reader = null;
}
pendingUpdatesByPath.clear();
app.setPluginStatus('Arrestato');
};
// Pubblica un set di update come delta SignalK
function publishUpdates(values) {
const delta = {
updates: [
{
source: { label: plugin.id },
timestamp: new Date().toISOString(),
values,
},
],
};
app.handleMessage(plugin.id, delta);
}
// Pubblica i metadati (unita' di misura, displayName) per il data browser
function publishMetadata() {
if (!reader) return;
const meta = [];
for (const mpptName of reader.mppts.keys()) {
const base = `meb.solar.${mpptName}`;
meta.push(
{ path: `${base}.panelVoltage`, value: { units: 'V', displayName: `Panel Voltage ${mpptName}` } },
{ path: `${base}.panelCurrent`, value: { units: 'A', displayName: `Panel Current ${mpptName}` } },
{ path: `${base}.panelPower`, value: { units: 'W', displayName: `Panel Power ${mpptName}` } },
{ path: `${base}.voltage`, value: { units: 'V', displayName: `Battery Voltage ${mpptName}` } },
{ path: `${base}.current`, value: { units: 'A', displayName: `Battery Current ${mpptName}` } },
{ path: `${base}.power`, value: { units: 'W', displayName: `Battery Power ${mpptName}` } },
{ path: `${base}.efficiency`, value: { units: 'ratio', displayName: `Efficiency ${mpptName}` } },
{ path: `${base}.controllerTemperature.phase1`, value: { units: 'K', displayName: `Temp Phase1 ${mpptName}` } },
{ path: `${base}.controllerTemperature.phase2`, value: { units: 'K', displayName: `Temp Phase2 ${mpptName}` } },
{ path: `${base}.chargedCapacity`, value: { units: 'Ah', displayName: `Charged Capacity ${mpptName}` } },
{ path: `${base}.chargedEnergy`, value: { units: 'J', displayName: `Charged Energy ${mpptName}` } },
{
path: `${base}.chargingMode`,
value: {
displayName: `Charging Mode ${mpptName}`,
possibleValues: [
{ value: 'off', title: 'Off' },
{ value: 'bulk', title: 'Bulk charging' },
{ value: 'float', title: 'Float' },
{ value: 'storage', title: 'Storage' },
],
},
},
);
}
meta.push(
{ path: 'meb.solar.total.panelPower', value: { units: 'W', displayName: 'Total Panel Power' } },
{ path: 'meb.solar.total.power', value: { units: 'W', displayName: 'Total Battery Power' } },
{ path: 'meb.solar.total.current', value: { units: 'A', displayName: 'Total Battery Current' } },
);
app.handleMessage(plugin.id, {
updates: [{ meta, timestamp: new Date().toISOString() }],
});
}
return plugin;
}
};