From 9d6766cda138cedc41f211e64813a6c1e79e9606 Mon Sep 17 00:00:00 2001 From: Giuseppe Raffa <77052701+sesee3@users.noreply.github.com> Date: Mon, 25 May 2026 13:13:24 +0200 Subject: [PATCH] Implemented: - MMPT class for data store - SerialPort library for USB-CAN connection - Stream to Singnlak's DataBrowser 3 --- .gitignore | 4 +- package.json | 16 ++-- src/index.js | 217 +++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 213 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 80f9d72..97871a5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ .env node_modules/ -.claude \ No newline at end of file +.claude + +*.md \ No newline at end of file diff --git a/package.json b/package.json index 90ef8f0..fe25b48 100644 --- a/package.json +++ b/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" } diff --git a/src/index.js b/src/index.js index bc994b2..871bdae 100644 --- a/src/index.js +++ b/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..* (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: "" + const plugin = { + id: 'meb-solars', + 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 = 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; -} \ No newline at end of file + return plugin; +};