Switched from CAN connection to UART for correctly fetch the datas.

This commit is contained in:
Giuseppe Raffa
2026-06-06 13:42:32 +02:00
parent 91161d2d2c
commit 1d5bb340d9
6 changed files with 420 additions and 285 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "meb-solars",
"version": "1.0.0",
"description": "Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-CAN (slcan)",
"description": "Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-UART",
"main": "src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -17,11 +17,10 @@
"mppt",
"poweren",
"solar",
"can"
"uart"
],
"dependencies": {
"serialport": "^12.0.0",
"@serialport/parser-readline": "^12.0.0"
"serialport": "^12.0.0"
},
"author": "MEB Team",
"license": "ISC"

View File

@@ -1,12 +1,10 @@
/*
Costanti del protocollo Poweren MPPT Boost.
Riferimento: Manuale Doc. 40.0000.206, Hw v1.2 Rev 01.
Il microcontrollore degli MPPT (PIC24/dsPIC33 a virgola fissa) trasmette interi a 16 bit.
Per ottenere il valore fisico bisogna dividere il raw per il fattore di conversione.
Il microcontrollore degli MPPT (PIC24/dsPIC33 a virgola fissa) trasmette interi a 16 bit. Per
inviare dati decimali piu precisi, il dato viene moltiplicato per un valore definito.
conversionFactors mappa i valori di conversione usati, in modo tale che il codice riceve i dati e li divide il fattore di conversione.
*/
// Fattori di conversione (valore_fisico = raw / factor)
const conversionsFactors = {
voltageInput: 10, // tensione ingresso [V] es: 296 -> 29.6 V
currentInput: 100, // corrente ingresso [A] es: 1100 -> 11.00 A
@@ -50,25 +48,87 @@ const errorsFlags = {
10: 'memory_flash_read_error',
};
// Indirizzi dei registri richiesti via polling attivo
/*
Indirizzi dei registri Poweren da leggere via UART (comando READ singolo registro).
ATTENZIONE: i valori marcati con "(da verificare)" sono dedotti dalla struttura del
protocollo e NON sono confermati dal manuale fornito. Per ottenere la mappa completa e
certa, interrogare l'MPPT con i comandi GET_REG_NAMES (102) / GET_REG_VALUES (101) tramite
DataCOM, oppure consultare la tabella registri del manuale (Doc. 40.0000.206) e correggere
qui i numeri. I valori confermati dalla documentazione sono indicati come "(confermato)".
*/
const registerAddresses = {
powerInput: 20,
powerOutput: 21,
temperature1: 35,
temperature2: 36,
status1: 4, // St1 - flag di stato (confermato: "registro 4")
warning: 6, // Warn - flag di warning/errore (confermato: "registro 6")
chargeCapacity: 7, // Chg_Cap - Ah caricati (da verificare)
voltageInput: 16, // Vi - tensione ingresso (da verificare)
currentInput: 17, // Ii - corrente ingresso (da verificare)
voltageOutput: 18, // Vo - tensione uscita (da verificare)
currentOutput: 19, // Io - corrente uscita (da verificare)
powerInput: 20, // Pi - potenza ingresso (confermato)
powerOutput: 21, // Po - potenza uscita (confermato)
temperature1: 35, // T1 - temperatura fase 1 (confermato)
temperature2: 36, // T2 - temperatura fase 2 (confermato)
};
// Mapping dei bitrate CAN al comando slcan corrispondente (Lawicel CAN232/CANUSB)
const slcanBitrateCommands = {
10000: 'S0',
20000: 'S1',
50000: 'S2',
100000: 'S3',
125000: 'S4',
250000: 'S5',
500000: 'S6',
800000: 'S7',
1000000: 'S8',
/*
Elenco ordinato dei registri da interrogare ad ogni ciclo di polling per ciascun MPPT.
Si leggono solo i registri che alimentano i path SignalK; Pi/Po non sono inclusi perche'
la potenza viene calcolata come Vi*Ii e Vo*Io (come nella versione CAN).
*/
const pollRegisters = [
registerAddresses.status1,
registerAddresses.warning,
registerAddresses.voltageInput,
registerAddresses.currentInput,
registerAddresses.voltageOutput,
registerAddresses.currentOutput,
registerAddresses.chargeCapacity,
registerAddresses.temperature1,
registerAddresses.temperature2,
];
/*
Costanti del frame del protocollo seriale Poweren (UART).
Struttura pacchetto:
[SOF][DST][SRC][0x00][DLEN][DATA...][CHK_HI][CHK_LO][EOF]
- SOF = 0x42 ('B')
- SRC della GUI/host = 0xFF
- DST = indirizzo MPPT (0x00 = qualsiasi, es. 50 = MPPT di default)
- CHK = sum(DATA) & 0xFFFF in big-endian
- EOF = 0x0D
*/
const serialProtocol = {
startOfFrame: 0x42, // SOF
sourceHost: 0xFF, // SRC host/GUI
endOfFrame: 0x0D, // EOF
headerLength: 5, // SOF + DST + SRC + 0x00 + DLEN
tailLength: 3, // CHK_HI + CHK_LO + EOF
// Comandi (scritti nel registro 2)
commands: {
reset: 99,
defaultValues: 100,
getRegValues: 101,
getRegNames: 102,
getRegUnits: 103,
getRegTypes: 104,
getFirmwareVersion: 120,
},
};
// Parametri di default della porta seriale (Waveshare USB-UART, 8N1)
const serialDefaults = {
baudRate: 115200,
dataBits: 8,
parity: 'none',
stopBits: 1,
};
// Timeout e retry per le transazioni sincrone (richiesta -> risposta)
const transactionTiming = {
timeoutMs: 500,
retries: 5,
retryDelayMs: 100,
};
module.exports = {
@@ -76,5 +136,8 @@ module.exports = {
status1Flags,
errorsFlags,
registerAddresses,
slcanBitrateCommands,
pollRegisters,
serialProtocol,
serialDefaults,
transactionTiming,
};

View File

@@ -3,36 +3,35 @@
Gli errori vengono propagati al SignalK app tramite app.error / app.setPluginError.
*/
// Errore specifico per il driver slcan (connessione USB-CAN)
class SlcanDriverError extends Error {
// Errore specifico per il driver seriale UART (apertura/scrittura porta)
class SerialDriverError extends Error {
constructor(message, cause) {
super(message);
this.name = 'SlcanDriverError';
this.name = 'SerialDriverError';
if (cause) this.cause = cause;
}
}
// Errore di parsing dei frame CAN ricevuti
class CanFrameParseError extends Error {
constructor(message, rawLine) {
// Errore di parsing/validazione di un frame seriale Poweren ricevuto
class SerialFrameParseError extends Error {
constructor(message) {
super(message);
this.name = 'CanFrameParseError';
this.rawLine = rawLine;
this.name = 'SerialFrameParseError';
}
}
// Errore di timeout su una richiesta di lettura registro
// Errore di timeout su una transazione di lettura registro
class RegisterReadTimeoutError extends Error {
constructor(rxId, regAddr) {
super(`Timeout su lettura registro ${regAddr} dal nodo CAN 0x${rxId.toString(16)}`);
constructor(address, regAddr) {
super(`Timeout su lettura registro ${regAddr} dall'MPPT con indirizzo ${address}`);
this.name = 'RegisterReadTimeoutError';
this.rxId = rxId;
this.address = address;
this.regAddr = regAddr;
}
}
module.exports = {
SlcanDriverError,
CanFrameParseError,
SerialDriverError,
SerialFrameParseError,
RegisterReadTimeoutError,
};

View File

@@ -1,6 +1,4 @@
/*
Modello di un singolo controller MPPT Poweren Boost.
Ogni istanza mantiene lo stato corrente del dispositivo (tensioni, correnti,
temperature, flag operativi, warnings) aggiornato dai frame CAN broadcast
(MSG1 status, MSG2 power) e dalle risposte alle richieste di lettura registro.
@@ -15,7 +13,25 @@ const {
registerAddresses,
} = require('./constants');
// Arrotondamenti convenzionali per esporre dati leggibili nei getter
/*
Mappa indirizzo registro -> campo di stato e fattore di conversione.
Usata da updateRegister per convertire il valore raw a 16 bit nel valore fisico.
I registri di flag (status1, warning) sono gestiti a parte perche' vanno decodificati a bit.
signed=true indica un intero a 16 bit con segno (complemento a 2), usato per le temperature.
*/
const numericRegisterMap = {
[registerAddresses.voltageInput]: { field: 'voltageInput', factor: conversionsFactors.voltageInput, signed: false },
[registerAddresses.currentInput]: { field: 'currentInput', factor: conversionsFactors.currentInput, signed: false },
[registerAddresses.voltageOutput]: { field: 'voltageOutput', factor: conversionsFactors.voltageOutput, signed: false },
[registerAddresses.currentOutput]: { field: 'currentOutput', factor: conversionsFactors.currentOutput, signed: false },
[registerAddresses.powerInput]: { field: 'powerInput', factor: conversionsFactors.powerInput, signed: false },
[registerAddresses.powerOutput]: { field: 'powerOutput', factor: conversionsFactors.powerOutput, signed: false },
[registerAddresses.chargeCapacity]: { field: 'chargeCapacity', factor: conversionsFactors.chargeCapacity, signed: false },
[registerAddresses.temperature1]: { field: 'temperature1', factor: conversionsFactors.temperaturePhase1, signed: true },
[registerAddresses.temperature2]: { field: 'temperature2', factor: conversionsFactors.temperaturePhase2, signed: true },
};
// Arrotondamenti
const roundedTo1 = (v) => Number(v.toFixed(1));
const roundedTo2 = (v) => Number(v.toFixed(2));
const roundedTo3 = (v) => Number(v.toFixed(3));
@@ -32,27 +48,14 @@ const decodeFlags = (value, map) => {
return active;
};
// Parsing del payload broadcast: 8 byte -> 4 uint16 big-endian
function parsePayload(data) {
if (!data || data.length < 8) return null;
return [
(data[0] << 8) | data[1],
(data[2] << 8) | data[3],
(data[4] << 8) | data[5],
(data[6] << 8) | data[7],
];
}
class MPPT {
constructor({
name,
rxID,
txIdBase,
address,
log = () => {},
} = {}) {
this.name = name; // identificativo logico (es. 'port', 'starboard')
this.rxID = rxID; // indirizzo CAN del dispositivo (registro Addr)
this.txIdBase = txIdBase; // ID base di trasmissione (broadcast su +1 e +2)
this.address = address; // indirizzo UART/DST del dispositivo (registro Addr)
this.log = log;
this.state = {
@@ -95,51 +98,34 @@ class MPPT {
return 'bulk';
}
// Aggiorna lo stato da un frame broadcast (MSG1=status, MSG2=power)
updateFromBroadcast(type, data) {
const regs = parsePayload(data);
if (!regs) return;
if (type === 'status') {
const [st1, , warn, chgCap] = regs;
this.state.status1Raw = st1;
this.state.warningRaw = warn;
this.state.chargeCapacity = chgCap / conversionsFactors.chargeCapacity;
this.state.flags = decodeFlags(st1, status1Flags);
this.state.warnings = decodeFlags(warn, errorsFlags);
/*
Aggiorna lo stato a partire dalla lettura UART di un singolo registro.
regAddr = indirizzo del registro letto
rawValue = valore raw a 16 bit ricevuto nella risposta (big-endian, gia' decodificato)
*/
updateRegister(regAddr, rawValue) {
if (regAddr === registerAddresses.status1) {
// Registro di stato St1: decodifica i bit in flag operativi
this.state.status1Raw = rawValue;
this.state.flags = decodeFlags(rawValue, status1Flags);
} else if (regAddr === registerAddresses.warning) {
// Registro Warning: decodifica i bit in codici di errore/warning
this.state.warningRaw = rawValue;
this.state.warnings = decodeFlags(rawValue, errorsFlags);
if (this.state.warnings.length) {
this.log(`[${this.name}] WARN: ${this.state.warnings.join(',')}`);
}
} else if (type === 'power') {
const [vi, ii, vo, io] = regs;
this.state.voltageInput = vi / conversionsFactors.voltageInput;
this.state.currentInput = ii / conversionsFactors.currentInput;
this.state.voltageOutput = vo / conversionsFactors.voltageOutput;
this.state.currentOutput = io / conversionsFactors.currentOutput;
} else {
// Registri numerici (tensioni, correnti, temperature, capacita')
const target = numericRegisterMap[regAddr];
if (!target) return;
let value = rawValue;
if (target.signed && value >= 0x8000) value -= 0x10000; // intero con segno
this.state[target.field] = value / target.factor;
}
this.state.lastUpdate = Date.now();
}
// Aggiorna lo stato a partire da una risposta a richiesta di registro
// Payload reply: [reg_addr, value_high, value_low]
updateFromRegisterReply(data) {
if (!data || data.length < 3) return;
const regAddr = data[0];
const rawValue = (data[1] << 8) | data[2];
const mapping = {
[registerAddresses.powerInput]: { field: 'powerInput', factor: conversionsFactors.powerInput },
[registerAddresses.powerOutput]: { field: 'powerOutput', factor: conversionsFactors.powerOutput },
[registerAddresses.temperature1]: { field: 'temperature1', factor: conversionsFactors.temperaturePhase1 },
[registerAddresses.temperature2]: { field: 'temperature2', factor: conversionsFactors.temperaturePhase2 },
};
const target = mapping[regAddr];
if (!target) return;
this.state[target.field] = rawValue / target.factor;
this.state.lastUpdate = Date.now();
}
// Costruisce gli update SignalK (path + value) a partire dallo stato corrente
// Tutti i valori sono in unita' SI (V, A, W, K, ratio, Ah)
buildSignalKUpdates() {
@@ -200,6 +186,5 @@ class MPPT {
module.exports = {
MPPT,
parsePayload,
decodeFlags,
};

View File

@@ -1,15 +1,20 @@
/*
Driver per convertitore USB-CAN in modalita' slcan (Lawicel CAN232 / CANUSB).
Driver UART per i controller MPPT Poweren Boost collegati tramite convertitore
USB-UART (Waveshare USB TO RS232/485/TTL).
Apre la porta seriale a 115200 bps, esegue la sequenza di init
(C\r chiudi, S<n>\r bitrate, O\r apri) e parsea le righe in arrivo
terminate da CR. Mantiene una collezione di istanze MPPT e instrada
ad esse i frame ricevuti in base al CAN ID, esponendo gli update
in formato SignalK delta.
A differenza della versione CAN (broadcast asincrono), il protocollo UART Poweren e'
SINCRONO: per ogni dato si invia un pacchetto di lettura registro e si attende subito la
risposta. Non esistono notifiche spontanee. Il driver esegue quindi un loop di polling che,
ad ogni ciclo, interroga in sequenza i registri necessari di ciascun MPPT configurato.
Formato pacchetto Poweren:
[SOF=0x42][DST][SRC=0xFF][0x00][DLEN][DATA...][CHK_HI][CHK_LO][EOF=0x0D]
- Richiesta lettura registro: DATA = [reg] (DLEN=1)
- Risposta: DATA = [val_hi, val_lo] (valore a 16 bit big-endian)
- CHK = sum(DATA) & 0xFFFF in big-endian
Eventi emessi:
'open' -> bus CAN aperto
'frame' -> { id, data: Buffer } per ogni frame valido
'open' -> porta seriale aperta
'updates' -> array di {path, value} pronti per la pubblicazione SignalK
'error' -> Error
'close' -> chiusura porta
@@ -17,11 +22,19 @@
const EventEmitter = require('events');
const { SerialPort } = require('serialport');
const { ReadlineParser } = require('@serialport/parser-readline');
const { MPPT } = require('./mpptscore');
const { slcanBitrateCommands, registerAddresses } = require('./constants');
const { SlcanDriverError, CanFrameParseError } = require('./errors');
const {
pollRegisters,
serialProtocol,
serialDefaults,
transactionTiming,
} = require('./constants');
const {
SerialDriverError,
SerialFrameParseError,
RegisterReadTimeoutError,
} = require('./errors');
// Riconnessione automatica con backoff esponenziale (clamp 30s)
const RECONNECT_BASE_DELAY_MS = 1000;
@@ -30,136 +43,116 @@ const RECONNECT_MAX_DELAY_MS = 30000;
class MPPTReader extends EventEmitter {
constructor({
device,
canBitrate = 250000,
baudRate = serialDefaults.baudRate,
mppts = [],
pollIntervalMs = 1000,
timeoutMs = transactionTiming.timeoutMs,
retries = transactionTiming.retries,
log = () => {},
} = {}) {
super();
this.device = device;
this.canBitrate = canBitrate;
this.baudRate = baudRate;
this.pollIntervalMs = pollIntervalMs;
this.timeoutMs = timeoutMs;
this.retries = retries;
this.log = log;
this.port = null;
this.parser = null;
this.isOpen = false;
this.shouldReconnect = true;
this.reconnectAttempts = 0;
this.reconnectTimer = null;
this.pollTimer = null;
this.isPolling = false;
// Istanzia gli MPPT e costruisce la routing table CAN_ID -> {mppt, type}
this.mppts = new Map(); // name -> MPPT
this.routing = new Map(); // canId -> { mppt, type: 'status'|'power'|'reply' }
// Buffer di ricezione e transazione pendente (protocollo sincrono: 1 alla volta)
this.rxBuffer = Buffer.alloc(0);
this.pendingTransaction = null;
// Istanzia gli MPPT configurati (indicizzati per nome logico)
this.mppts = new Map();
for (const config of mppts) {
const mppt = new MPPT({
name: config.id,
rxID: config.rxId,
txIdBase: config.txIdBase,
address: config.address,
log: this.log,
});
this.mppts.set(config.id, mppt);
// Broadcast MSG1 (status) e MSG2 (power)
this.routing.set(config.txIdBase + 1, { mppt, type: 'status' });
this.routing.set(config.txIdBase + 2, { mppt, type: 'power' });
// Risposta a richiesta di lettura registro (txIdBase)
this.routing.set(config.txIdBase, { mppt, type: 'reply' });
}
}
// Apre la porta seriale ed esegue la sequenza di init slcan
// Apre la porta seriale e avvia il loop di polling
async open() {
return new Promise((resolve, reject) => {
try {
this.port = new SerialPort({
path: this.device,
baudRate: 115200,
baudRate: this.baudRate,
dataBits: serialDefaults.dataBits,
parity: serialDefaults.parity,
stopBits: serialDefaults.stopBits,
autoOpen: false,
});
this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\r' }));
this.parser.on('data', (line) => this._onLine(line));
// Protocollo binario: si leggono i byte grezzi (l'EOF 0x0D puo' comparire nei dati)
this.port.on('data', (chunk) => this._onData(chunk));
this.port.on('error', (err) => {
this.log(`[reader] errore porta seriale: ${err.message}`);
this.emit('error', new SlcanDriverError(err.message, err));
this.emit('error', new SerialDriverError(err.message, err));
});
this.port.on('close', () => {
this.isOpen = false;
this._stopPolling();
this.emit('close');
if (this.shouldReconnect) this._scheduleReconnect();
});
this.port.open(async (err) => {
if (err) {
reject(new SlcanDriverError(`Impossibile aprire ${this.device}: ${err.message}`, err));
reject(new SerialDriverError(`Impossibile aprire ${this.device}: ${err.message}`, err));
return;
}
try {
await this._initSlcan();
// Disattiva DTR/RTS per non tenere l'MPPT in reset (alcuni adapter lo resettano)
await this._setControlLines(false, false);
this.isOpen = true;
this.reconnectAttempts = 0;
this.emit('open');
this._startPolling();
resolve();
} catch (initErr) {
reject(initErr);
}
});
} catch (err) {
reject(new SlcanDriverError(err.message, err));
reject(new SerialDriverError(err.message, err));
}
});
}
// Sequenza di inizializzazione slcan: C (close), S<n> (bitrate), O (open)
async _initSlcan() {
const bitrateCommand = slcanBitrateCommands[this.canBitrate];
if (!bitrateCommand) {
throw new SlcanDriverError(`Bitrate CAN non supportato: ${this.canBitrate}`);
}
await this._writeRaw('C\r');
await this._delay(50);
await this._writeRaw(`${bitrateCommand}\r`);
await this._delay(50);
await this._writeRaw('O\r');
await this._delay(50);
}
// Chiude il canale e la porta
// Chiude la porta e ferma il polling
async close() {
this.shouldReconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this._stopPolling();
if (this.pendingTransaction) {
clearTimeout(this.pendingTransaction.timer);
this.pendingTransaction.reject(new SerialDriverError('Porta in chiusura'));
this.pendingTransaction = null;
}
if (this.port && this.port.isOpen) {
try { await this._writeRaw('C\r'); } catch (_) { /* best effort */ }
await new Promise((resolve) => this.port.close(() => resolve()));
}
this.isOpen = false;
}
// Invia una richiesta di lettura registro: t<rxId 3 hex><len=1><regAddr 2 hex>\r
async sendReadRequest(rxId, regAddr) {
if (!this.isOpen) return;
const idHex = rxId.toString(16).toUpperCase().padStart(3, '0');
const addrHex = regAddr.toString(16).toUpperCase().padStart(2, '0');
const command = `t${idHex}1${addrHex}\r`;
await this._writeRaw(command);
}
// Lista dei registri da interrogare in polling per ciascun MPPT
getPollTargets() {
const targets = [];
for (const mppt of this.mppts.values()) {
for (const regAddr of Object.values(registerAddresses)) {
targets.push({ rxId: mppt.rxID, regAddr });
}
}
return targets;
}
// Costruisce gli update aggregati (somma dei due lati Port + Starboard)
// Costruisce gli update aggregati (somma dei lati attivi)
buildAggregateUpdates() {
let panelPowerSum = 0;
let powerSum = 0;
@@ -184,17 +177,201 @@ class MPPTReader extends EventEmitter {
];
}
// ---- gestione interna ----
// ---- loop di polling ----
_writeRaw(data) {
_startPolling() {
if (this.pollTimer) return;
// setTimeout ricorsivo: garantisce che un ciclo finisca prima del successivo
const tick = async () => {
if (!this.isOpen) return;
try {
await this._pollCycle();
} catch (err) {
this.emit('error', err);
}
if (this.isOpen) {
this.pollTimer = setTimeout(tick, this.pollIntervalMs);
}
};
this.pollTimer = setTimeout(tick, 0);
}
_stopPolling() {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
}
// Un ciclo di polling: per ogni MPPT legge tutti i registri e pubblica gli update
async _pollCycle() {
if (this.isPolling) return;
this.isPolling = true;
try {
for (const mppt of this.mppts.values()) {
let updated = false;
for (const regAddr of pollRegisters) {
try {
const rawValue = await this._readRegister(mppt.address, regAddr);
mppt.updateRegister(regAddr, rawValue);
updated = true;
} catch (err) {
// Timeout/errore sul singolo registro: log e si prosegue col prossimo
this.log(`[reader] lettura reg ${regAddr} MPPT ${mppt.address} fallita: ${err.message}`);
}
}
if (updated) {
const updates = mppt.buildSignalKUpdates();
if (updates.length) this.emit('updates', updates);
}
}
} finally {
this.isPolling = false;
}
}
// ---- transazione sincrona di lettura registro ----
/*
Legge un singolo registro dall'MPPT indicato (DST=address).
Ritorna il valore raw a 16 bit. Esegue retry in caso di timeout.
*/
async _readRegister(address, regAddr) {
let lastError = null;
for (let attempt = 0; attempt <= this.retries; attempt++) {
try {
const responseData = await this._transact(address, this._buildReadPacket(address, regAddr));
if (!responseData || responseData.length < 2) {
throw new SerialFrameParseError('risposta troppo corta');
}
return (responseData[0] << 8) | responseData[1];
} catch (err) {
lastError = err;
if (attempt < this.retries) await this._delay(transactionTiming.retryDelayMs);
}
}
throw lastError || new RegisterReadTimeoutError(address, regAddr);
}
// Invia un pacchetto e attende il prossimo frame valido (con timeout)
_transact(address, packet) {
return new Promise((resolve, reject) => {
this.port.write(data, (err) => {
if (err) reject(new SlcanDriverError(err.message, err));
else resolve();
if (!this.isOpen) {
reject(new SerialDriverError('porta non aperta'));
return;
}
// Pulisce eventuali residui dal buffer prima di una nuova transazione
this.rxBuffer = Buffer.alloc(0);
const timer = setTimeout(() => {
if (this.pendingTransaction && this.pendingTransaction.timer === timer) {
this.pendingTransaction = null;
reject(new RegisterReadTimeoutError(address, packet[5]));
}
}, this.timeoutMs);
this.pendingTransaction = { resolve, reject, timer };
this.port.write(packet, (err) => {
if (err) {
clearTimeout(timer);
this.pendingTransaction = null;
reject(new SerialDriverError(err.message, err));
}
});
});
}
// Costruisce un pacchetto di lettura registro: DATA=[reg], CHK=reg
_buildReadPacket(address, regAddr) {
const checksum = regAddr & 0xFFFF;
return Buffer.from([
serialProtocol.startOfFrame,
address & 0xFF,
serialProtocol.sourceHost,
0x00,
0x01, // DLEN = 1
regAddr & 0xFF, // DATA
(checksum >> 8) & 0xFF,
checksum & 0xFF,
serialProtocol.endOfFrame,
]);
}
// ---- ricezione e parsing ----
_onData(chunk) {
this.rxBuffer = Buffer.concat([this.rxBuffer, chunk]);
let frame;
while ((frame = this._extractFrame()) !== null) {
if (this.pendingTransaction) {
const { resolve, timer } = this.pendingTransaction;
clearTimeout(timer);
this.pendingTransaction = null;
resolve(frame.data);
}
}
}
/*
Estrae il primo frame completo e valido dal buffer di ricezione, consumandone i byte.
Ritorna { dst, src, data } oppure null se non c'e' ancora un frame completo.
Scarta i byte spuri finche' non trova il SOF.
*/
_extractFrame() {
const { startOfFrame, endOfFrame, headerLength, tailLength } = serialProtocol;
// Allinea il buffer al primo SOF disponibile
let sofIndex = this.rxBuffer.indexOf(startOfFrame);
if (sofIndex < 0) {
this.rxBuffer = Buffer.alloc(0);
return null;
}
if (sofIndex > 0) {
this.rxBuffer = this.rxBuffer.slice(sofIndex);
}
if (this.rxBuffer.length < headerLength) return null;
const dlen = (this.rxBuffer[3] << 8) | this.rxBuffer[4];
const totalLength = headerLength + dlen + tailLength;
if (this.rxBuffer.length < totalLength) return null;
const dst = this.rxBuffer[1];
const src = this.rxBuffer[2];
const data = this.rxBuffer.slice(headerLength, headerLength + dlen);
const checksum = (this.rxBuffer[headerLength + dlen] << 8) | this.rxBuffer[headerLength + dlen + 1];
const eof = this.rxBuffer[headerLength + dlen + 2];
// Consuma il frame dal buffer (anche se invalido, per non bloccarsi)
this.rxBuffer = this.rxBuffer.slice(totalLength);
if (eof !== endOfFrame) {
this.log('[reader] frame scartato: EOF mancante');
return null;
}
let sum = 0;
for (const b of data) sum += b;
if ((sum & 0xFFFF) !== checksum) {
this.log('[reader] frame scartato: checksum errato');
return null;
}
return { dst, src, data };
}
// ---- helper vari ----
_setControlLines(dtr, rts) {
return new Promise((resolve) => {
if (!this.port || typeof this.port.set !== 'function') {
resolve();
return;
}
this.port.set({ dtr, rts }, () => resolve());
});
}
_delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -210,72 +387,6 @@ class MPPTReader extends EventEmitter {
});
}, delay);
}
// Parsing di una riga slcan in arrivo
_onLine(rawLine) {
const line = rawLine.toString().trim();
if (!line) return;
// Risposte di ack/err: 'z', 'Z', BEL (0x07)
if (line === 'z' || line === 'Z') return;
try {
const frame = this._parseFrame(line);
if (!frame) return;
this.emit('frame', frame);
const route = this.routing.get(frame.id);
if (!route) return;
if (route.type === 'status' || route.type === 'power') {
route.mppt.updateFromBroadcast(route.type, frame.data);
} else if (route.type === 'reply') {
route.mppt.updateFromRegisterReply(frame.data);
}
// Emette gli update SignalK aggiornati per questo MPPT
const updates = route.mppt.buildSignalKUpdates();
if (updates.length) this.emit('updates', updates);
} catch (err) {
if (err instanceof CanFrameParseError) {
this.log(`[reader] frame ignorato: ${err.message}`);
} else {
this.emit('error', err);
}
}
}
// Parser di un singolo frame slcan:
// tIIILDD... standard (ID 3 hex)
// TIIIIIIIILDD... extended (ID 8 hex)
_parseFrame(line) {
const type = line[0];
if (type !== 't' && type !== 'T') return null;
const isExtended = type === 'T';
const idLen = isExtended ? 8 : 3;
if (line.length < 1 + idLen + 1) throw new CanFrameParseError('frame troppo corto', line);
const idHex = line.substr(1, idLen);
const id = parseInt(idHex, 16);
if (Number.isNaN(id)) throw new CanFrameParseError('CAN ID non valido', line);
const dlc = parseInt(line.substr(1 + idLen, 1), 10);
if (Number.isNaN(dlc) || dlc < 0 || dlc > 8) throw new CanFrameParseError('DLC non valido', line);
const dataStart = 1 + idLen + 1;
const dataHex = line.substr(dataStart, dlc * 2);
if (dataHex.length < dlc * 2) throw new CanFrameParseError('payload incompleto', line);
const data = Buffer.alloc(dlc);
for (let i = 0; i < dlc; i++) {
const byte = parseInt(dataHex.substr(i * 2, 2), 16);
if (Number.isNaN(byte)) throw new CanFrameParseError('byte payload non valido', line);
data[i] = byte;
}
return { id, data, extended: isExtended };
}
}
module.exports = { MPPTReader };

View File

@@ -1,45 +1,32 @@
/*
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',
name: 'MEB Solar Panels',
description: 'Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-UART',
};
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'],
required: ['device', 'baudRate'],
properties: {
device: {
type: 'string',
title: 'Path del dispositivo seriale USB-CAN',
default: '/dev/ttyUSB1',
description: 'Es. /dev/ttyUSB1 o symlink udev /dev/mppt-can',
title: 'Path del dispositivo seriale USB-UART',
default: '/dev/ttyUSB0',
description: 'Es. /dev/ttyUSB0 (Linux) o /dev/cu.usbserial-XXXX (macOS)',
},
canBitrate: {
baudRate: {
type: 'number',
title: 'Bitrate del bus CAN',
default: 250000,
enum: [125000, 250000, 500000],
title: 'Baud rate seriale',
default: 115200,
enum: [9600, 19200, 38400, 57600, 115200],
},
publishIntervalMs: {
type: 'number',
@@ -47,12 +34,12 @@ module.exports = function (app) {
default: 1000,
minimum: 200,
},
pollExtraIntervalMs: {
pollIntervalMs: {
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)',
title: 'Intervallo di polling UART (ms)',
default: 1000,
minimum: 200,
description: 'Frequenza con cui interrogare i registri di ciascun MPPT',
},
mppts: {
type: 'array',
@@ -61,13 +48,11 @@ module.exports = function (app) {
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 },
address: { type: 'number', title: 'Indirizzo UART/DST (registro Addr)', default: 50 },
},
},
default: [
{ id: 'port', rxId: 50, txIdBase: 51 },
{ id: 'starboard', rxId: 60, txIdBase: 61 },
{ id: 'port', address: 50 },
],
},
},
@@ -78,8 +63,9 @@ module.exports = function (app) {
reader = new MPPTReader({
device: options.device,
canBitrate: options.canBitrate,
baudRate: options.baudRate,
mppts: options.mppts,
pollIntervalMs: options.pollIntervalMs,
log: app.debug ? app.debug.bind(app) : console.log,
});
@@ -91,31 +77,24 @@ module.exports = function (app) {
});
reader.on('open', () => {
app.setPluginStatus(`Connesso al bus CAN (${options.device} @ ${options.canBitrate} bps)`);
app.setPluginStatus(`Connesso alla seriale (${options.device} @ ${options.baudRate} bps)`);
});
reader.on('error', (err) => {
app.error(`Errore driver CAN: ${err.message}`);
app.error(`Errore driver UART: ${err.message}`);
app.setPluginError(err.message);
});
reader.on('close', () => {
app.setPluginStatus('Bus CAN disconnesso, tentativo di riconnessione in corso');
app.setPluginStatus('Seriale disconnessa, 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
// Pubblicazione periodica degli update bufferizzati + aggregati.
// Il polling dei registri UART e' gestito internamente dal driver.
publishInterval = setInterval(() => {
const aggregateUpdates = reader.buildAggregateUpdates();
for (const update of aggregateUpdates) {
@@ -128,14 +107,13 @@ module.exports = function (app) {
}, options.publishIntervalMs);
})
.catch((err) => {
app.error(`Impossibile aprire il driver CAN: ${err.message}`);
app.error(`Impossibile aprire il driver UART: ${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}`));