Implemented:
- MMPT class for data store - SerialPort library for USB-CAN connection - Stream to Singnlak's DataBrowser 3
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,4 +2,6 @@
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
*.md
|
||||||
16
package.json
16
package.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meb-solars",
|
"name": "meb-solars",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"description": "Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-CAN (slcan)",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
@@ -11,16 +12,17 @@
|
|||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"signalk-node-server-plugin",
|
"signalk-node-server-plugin",
|
||||||
"signalk-category-utility",
|
"signalk-category-charging",
|
||||||
"signalk-plugin",
|
"signalk-plugin",
|
||||||
"daly",
|
"mppt",
|
||||||
"bms",
|
"poweren",
|
||||||
"battery"
|
"solar",
|
||||||
|
"can"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"serialport": "^10.2.2"
|
"serialport": "^12.0.0",
|
||||||
|
"@serialport/parser-readline": "^12.0.0"
|
||||||
},
|
},
|
||||||
"author": "MEB Team",
|
"author": "MEB Team",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"description": "Un plugin per ottenere dati dai pannelli solari posti sulla barca"
|
|
||||||
}
|
}
|
||||||
|
|||||||
217
src/index.js
217
src/index.js
@@ -1,21 +1,206 @@
|
|||||||
|
/*
|
||||||
|
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) {
|
module.exports = function (app) {
|
||||||
|
const plugin = {
|
||||||
const plugin = {
|
id: 'meb-solars',
|
||||||
id: 'meb-solars',
|
name: 'MEB Solar Panels (Poweren MPPT)',
|
||||||
name: "MEB Solary Panels",
|
description: 'Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-CAN',
|
||||||
description: ""
|
};
|
||||||
|
|
||||||
|
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 = 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.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);
|
||||||
|
}
|
||||||
|
|
||||||
plugin.schema = {
|
// 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() }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
plugin.start = async function(options = {}) {
|
return plugin;
|
||||||
|
};
|
||||||
};
|
|
||||||
|
|
||||||
plugin.
|
|
||||||
|
|
||||||
|
|
||||||
return plugin;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user