Implemented:
- MMPT class for data store - SerialPort library for USB-CAN connection - Stream to Singnlak's DataBrowser 3
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@
|
||||
|
||||
node_modules/
|
||||
.claude
|
||||
|
||||
*.md
|
||||
16
package.json
16
package.json
@@ -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"
|
||||
}
|
||||
|
||||
215
src/index.js
215
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) {
|
||||
const plugin = {
|
||||
id: 'meb-solars',
|
||||
name: 'MEB Solar Panels (Poweren MPPT)',
|
||||
description: 'Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-CAN',
|
||||
};
|
||||
|
||||
const plugin = {
|
||||
id: 'meb-solars',
|
||||
name: "MEB Solary Panels",
|
||||
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 = {}) {
|
||||
|
||||
};
|
||||
|
||||
plugin.
|
||||
|
||||
|
||||
return plugin;
|
||||
}
|
||||
return plugin;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user