Compare commits

5 Commits

Author SHA1 Message Date
Giuseppe Raffa
03b572642f 2nd iteration for fixes 2026-06-06 14:20:27 +02:00
Giuseppe Raffa
2385152887 Fix some data browser errors 2026-06-06 14:17:54 +02:00
Giuseppe Raffa
2cc320b484 Refactor MPPT CAN reader with options and logging 2026-06-06 14:11:45 +02:00
Giuseppe Raffa
c893fa3edf Fixed for initial handshake to fetch data from panels. 2026-06-06 14:01:47 +02:00
Giuseppe Raffa
1d5bb340d9 Switched from CAN connection to UART for correctly fetch the datas. 2026-06-06 13:42:32 +02:00
6 changed files with 688 additions and 300 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
@@ -25,9 +23,9 @@ const status1Flags = {
1: 'ChgEna', // Ponte Pin1-Pin8 inserito (enable hardware)
2: 'ChgOk', // Condizioni di ricarica OK
3: 'PwrOn', // Stadio di potenza acceso (eroga corrente)
4: 'StorMod', // Modalita' storage attiva
5: 'FloatMod', // Modalita' float (mantenimento)
6: 'CtrlEna', // Algoritmo MPPT abilitato
4: 'CtrlEna', // Algoritmo MPPT abilitato
5: 'StorMod', // Modalita' storage attiva
6: 'FloatMod', // Modalita' float (mantenimento)
7: 'CCapRst', // Reset contatore Ah
8: 'Ph1Ena', // Fase 1 abilitata
9: 'Ph2Ena', // Fase 2 abilitata
@@ -50,25 +48,95 @@ 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,
deviceAddress: 1, // Addr - indirizzo del dispositivo (confermato: usato per discovery)
status1: 4, // St1 - flag di stato (confermato: "registro 4")
warning: 6, // Warn - flag di warning/errore (confermato: "registro 6")
voltageInput: 16, // Vi - tensione ingresso (manuale: registro 16)
currentInput: 18, // Ii - corrente ingresso (manuale: registro 18)
voltageOutput: 22, // Vo - tensione uscita (manuale: registro 22)
currentOutput: 27, // Io - corrente uscita (manuale: registro 27)
powerInput: 20, // Pi - potenza ingresso (confermato)
powerOutput: 31, // Po - potenza uscita (manuale: registro 31)
chargeCapacity: null, // Chg_Cap - nel PDF hw1.2r01 collide con T1 sul registro 35: non pollare finche' non confermato
temperature1: 35, // T1 - temperatura fase 1 (manuale: registro 35)
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.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
addressAny: 0x00, // DST = qualsiasi dispositivo (usato per discovery)
defaultDestinationAddress: 0x00, // DST UART usato da DataCOM: l'Addr del registro 1 e' il CAN Rx ID
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,
// Linee di controllo: alcuni adapter (es. FTDI FT232R) resettano il target all'apertura.
// L'esempio di discovery Poweren funzionante le imposta entrambe a true.
dtr: true,
rts: true,
// Tempo di assestamento dopo l'apertura della porta prima di iniziare a trasmettere
openSettleMs: 250,
};
// Timeout e retry per le transazioni sincrone (richiesta -> risposta)
const transactionTiming = {
timeoutMs: 500,
retries: 5,
retryDelayMs: 100,
};
module.exports = {
@@ -76,5 +144,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,32 @@ 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.temperature1]: { field: 'temperature1', factor: conversionsFactors.temperaturePhase1, signed: true },
[registerAddresses.temperature2]: { field: 'temperature2', factor: conversionsFactors.temperaturePhase2, signed: true },
};
if (Number.isInteger(registerAddresses.chargeCapacity)) {
numericRegisterMap[registerAddresses.chargeCapacity] = {
field: 'chargeCapacity',
factor: conversionsFactors.chargeCapacity,
signed: false,
};
}
// Arrotondamenti
const roundedTo1 = (v) => Number(v.toFixed(1));
const roundedTo2 = (v) => Number(v.toFixed(2));
const roundedTo3 = (v) => Number(v.toFixed(3));
@@ -32,27 +55,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 +105,37 @@ 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) {
this.log(`[${this.name}] update registro ${regAddr}: raw=${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);
this.log(`[${this.name}] St1 flags=${JSON.stringify(this.state.flags)}`);
} 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.log(`[${this.name}] ${target.field}=${this.state[target.field]} (raw=${rawValue}, conv=${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() {
@@ -176,6 +172,7 @@ class MPPT {
updates.push({ path: `${base}.flags`, value: s.flags });
updates.push({ path: `${base}.warnings`, value: s.warnings });
this.log(`[${this.name}] update SignalK costruiti=${updates.length} snapshot=${JSON.stringify(this.getSnapshot())}`);
return updates;
}
@@ -200,6 +197,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] oppure [reg, val_hi, val_lo]
- 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,20 @@
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,
registerAddresses,
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 +44,155 @@ 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,
dtr = serialDefaults.dtr,
rts = serialDefaults.rts,
log = () => {},
} = {}) {
super();
this.device = device;
this.canBitrate = canBitrate;
this.baudRate = baudRate;
this.pollIntervalMs = pollIntervalMs;
this.timeoutMs = timeoutMs;
this.retries = retries;
this.dtr = dtr;
this.rts = rts;
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;
// Indirizzo DST usato sul protocollo UART. Il registro Addr identifica il CAN Rx ID;
// DataCOM e il test Python interrogano l'MPPT con DST=0x00.
this.uartDestinationAddress = serialProtocol.defaultDestinationAddress;
// Istanzia gli MPPT configurati (indicizzati per nome logico)
this.mppts = new Map();
for (const config of mppts) {
const normalizedAddress = Number(config.address);
if (!config.id || !Number.isFinite(normalizedAddress)) {
this.log(`[reader] configurazione MPPT ignorata perche' incompleta: ${JSON.stringify(config)}`);
continue;
}
const mppt = new MPPT({
name: config.id,
rxID: config.rxId,
txIdBase: config.txIdBase,
address: normalizedAddress,
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' });
}
this.log(
`[reader] configurazione: device=${this.device}, baudRate=${this.baudRate}, ` +
`pollIntervalMs=${this.pollIntervalMs}, timeoutMs=${this.timeoutMs}, retries=${this.retries}, ` +
`dtr=${this.dtr}, rts=${this.rts}, uartDestinationAddress=${this.uartDestinationAddress}, ` +
`mppts=${JSON.stringify(Array.from(this.mppts.values()).map((mppt) => ({
name: mppt.name,
address: mppt.address,
})))}`
);
}
// Apre la porta seriale ed esegue la sequenza di init slcan
// Apre la porta seriale e avvia il loop di polling
async open() {
this.log(`[reader] open(): preparo apertura seriale ${this.device}`);
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.log(`[reader] RX chunk len=${chunk.length} hex=${chunk.toString('hex')}`);
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.log('[reader] porta seriale chiusa');
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));
this.log(`[reader] apertura seriale fallita: ${err.message}`);
reject(new SerialDriverError(`Impossibile aprire ${this.device}: ${err.message}`, err));
return;
}
try {
await this._initSlcan();
this.log(`[reader] porta aperta: ${this.device}`);
// Imposta le linee DTR/RTS come da protocollo Poweren e attende l'assestamento
// dell'adapter (l'FT232R può resettare il target all'apertura della porta).
await this._setControlLines(this.dtr, this.rts);
this.log(`[reader] linee controllo impostate: dtr=${this.dtr}, rts=${this.rts}`);
await this._delay(serialDefaults.openSettleMs);
this.log(`[reader] attesa assestamento completata: ${serialDefaults.openSettleMs}ms`);
this.isOpen = true;
this.reconnectAttempts = 0;
// Invia la prima richiesta UART: l'MPPT Poweren risponde solo dopo una lettura.
await this._discoverDevices();
this.log('[reader] discovery iniziale completata, avvio polling');
this.emit('open');
this._startPolling();
resolve();
} catch (initErr) {
this.log(`[reader] inizializzazione fallita: ${initErr.stack || initErr.message}`);
reject(initErr);
}
});
} catch (err) {
reject(new SlcanDriverError(err.message, err));
this.log(`[reader] errore creazione SerialPort: ${err.stack || err.message}`);
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.log('[reader] close(): arresto richiesto');
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;
this.log('[reader] close(): completato');
}
// 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;
@@ -177,20 +210,316 @@ class MPPTReader extends EventEmitter {
if (!anyOnline) return [];
const roundedTo2 = (v) => Number(v.toFixed(2));
return [
const aggregateUpdates = [
{ path: 'meb.solar.total.panelPower', value: roundedTo2(panelPowerSum) },
{ path: 'meb.solar.total.power', value: roundedTo2(powerSum) },
{ path: 'meb.solar.total.current', value: roundedTo2(currentSum) },
];
this.log(`[reader] aggregati calcolati: ${JSON.stringify(aggregateUpdates)}`);
return aggregateUpdates;
}
// ---- gestione interna ----
// ---- loop di polling ----
_writeRaw(data) {
_startPolling() {
if (this.pollTimer) return;
this.log('[reader] startPolling(): timer polling attivato');
// setTimeout ricorsivo: garantisce che un ciclo finisca prima del successivo
const tick = async () => {
if (!this.isOpen) {
this.log('[reader] tick polling ignorato: porta non aperta');
return;
}
try {
await this._pollCycle();
} catch (err) {
this.log(`[reader] errore ciclo polling: ${err.stack || err.message}`);
this.emit('error', err);
}
if (this.isOpen) {
this.pollTimer = setTimeout(tick, this.pollIntervalMs);
}
};
this.pollTimer = setTimeout(tick, 0);
}
_stopPolling() {
if (this.pollTimer) {
this.log('[reader] stopPolling(): timer polling fermato');
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) {
this.log('[reader] ciclo polling saltato: ciclo precedente ancora attivo');
return;
}
this.isPolling = true;
const cycleStartedAt = Date.now();
this.log(`[reader] ciclo polling avviato: mppts=${this.mppts.size}, registri=${pollRegisters.join(',')}`);
try {
for (const mppt of this.mppts.values()) {
let updated = false;
this.log(`[reader] polling MPPT ${mppt.name} address=${mppt.address}`);
for (const regAddr of pollRegisters) {
try {
this.log(`[reader] leggo MPPT ${mppt.name} address=${mppt.address} registro=${regAddr}`);
const rawValue = await this._readRegister(this.uartDestinationAddress, regAddr);
this.log(`[reader] letto MPPT ${mppt.name} address=${mppt.address} registro=${regAddr} raw=${rawValue}`);
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();
this.log(`[reader] MPPT ${mppt.name}: updated=true, updateSignalK=${updates.length}`);
if (updates.length) this.emit('updates', updates);
} else {
this.log(`[reader] MPPT ${mppt.name}: nessun registro aggiornato in questo ciclo`);
}
}
} finally {
this.isPolling = false;
this.log(`[reader] ciclo polling completato in ${Date.now() - cycleStartedAt}ms`);
}
}
// ---- transazione sincrona di lettura registro ----
/*
Esegue una lettura iniziale del registro ADDR con DST=0x00, equivalente al test Python:
42 00 ff 00 01 01 00 01 0d
La discovery non deve impedire il polling degli indirizzi configurati: su bus con piu'
MPPT, l'indirizzo broadcast puo' non essere utilizzabile per collisione delle risposte.
*/
async _discoverDevices() {
try {
const discoveredDeviceAddress = await this._readRegister(
serialProtocol.addressAny,
registerAddresses.deviceAddress,
);
this.log(`[reader] richiesta iniziale UART OK, indirizzo MPPT rilevato: ${discoveredDeviceAddress}`);
if (this.mppts.size === 1) {
const [configuredMppt] = this.mppts.values();
if (configuredMppt.address !== discoveredDeviceAddress) {
this.log(
`[reader] MPPT ${configuredMppt.name}: indirizzo configurato ${configuredMppt.address}, ` +
`indirizzo rilevato ${discoveredDeviceAddress}; continuo con la configurazione SignalK`,
);
}
}
} catch (err) {
this.log(`[reader] richiesta iniziale UART fallita: ${err.message}; continuo con il polling configurato`);
}
}
/*
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 packet = this._buildReadPacket(address, regAddr);
this.log(
`[reader] richiesta registro: address=${address}, reg=${regAddr}, ` +
`tentativo=${attempt + 1}/${this.retries + 1}, packet=${packet.toString('hex')}`
);
const responseData = await this._transact(address, packet);
const rawValue = this._parseRegisterValue(responseData, regAddr);
this.log(
`[reader] risposta registro: address=${address}, reg=${regAddr}, ` +
`data=${responseData.toString('hex')}, raw=${rawValue}`
);
return rawValue;
} catch (err) {
lastError = err;
this.log(
`[reader] tentativo fallito: address=${address}, reg=${regAddr}, ` +
`tentativo=${attempt + 1}/${this.retries + 1}, errore=${err.message}`
);
if (attempt < this.retries) await this._delay(transactionTiming.retryDelayMs);
}
}
throw lastError || new RegisterReadTimeoutError(address, regAddr);
}
/*
Estrae il valore raw dalla risposta Poweren.
Alcuni firmware rispondono con DATA=[hi, lo], altri includono il registro letto:
DATA=[reg, hi, lo]. Supportare entrambi evita di pubblicare valori sfalsati.
*/
_parseRegisterValue(responseData, requestedRegisterAddress) {
if (!responseData || responseData.length < 2) {
throw new SerialFrameParseError('risposta troppo corta');
}
if (responseData.length >= 3 && responseData[0] === (requestedRegisterAddress & 0xFF)) {
return (responseData[1] << 8) | responseData[2];
}
return (responseData[0] << 8) | responseData[1];
}
// 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) {
this.log(`[reader] transact rifiutata: porta non aperta, address=${address}, packet=${packet.toString('hex')}`);
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;
this.log(`[reader] timeout transazione: address=${address}, reg=${packet[5]}, packet=${packet.toString('hex')}`);
reject(new RegisterReadTimeoutError(address, packet[5]));
}
}, this.timeoutMs);
this.pendingTransaction = { resolve, reject, timer, address, regAddr: packet[5], packet };
this.log(`[reader] TX write: address=${address}, reg=${packet[5]}, hex=${packet.toString('hex')}`);
this.port.write(packet, (err) => {
if (err) {
clearTimeout(timer);
this.pendingTransaction = null;
this.log(`[reader] errore TX write: ${err.message}`);
reject(new SerialDriverError(err.message, err));
return;
}
this.log(`[reader] TX write callback OK: address=${address}, reg=${packet[5]}`);
if (typeof this.port.drain === 'function') {
this.port.drain((drainErr) => {
if (drainErr && this.pendingTransaction && this.pendingTransaction.timer === timer) {
clearTimeout(timer);
this.pendingTransaction = null;
this.log(`[reader] errore TX drain: ${drainErr.message}`);
reject(new SerialDriverError(drainErr.message, drainErr));
return;
}
this.log(`[reader] TX drain OK: address=${address}, reg=${packet[5]}`);
});
}
});
});
}
// 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]);
this.log(`[reader] rxBuffer len=${this.rxBuffer.length} hex=${this.rxBuffer.toString('hex')}`);
let frame;
while ((frame = this._extractFrame()) !== null) {
this.log(`[reader] frame valido: dst=${frame.dst}, src=${frame.src}, data=${frame.data.toString('hex')}`);
if (this.pendingTransaction) {
const { resolve, timer, address, regAddr } = this.pendingTransaction;
clearTimeout(timer);
this.pendingTransaction = null;
this.log(`[reader] transazione risolta: address=${address}, reg=${regAddr}, data=${frame.data.toString('hex')}`);
resolve(frame.data);
} else {
this.log(`[reader] frame ricevuto senza transazione pendente: data=${frame.data.toString('hex')}`);
}
}
}
/*
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.log(`[reader] nessun SOF nel buffer, scarto ${this.rxBuffer.length} byte: ${this.rxBuffer.toString('hex')}`);
this.rxBuffer = Buffer.alloc(0);
return null;
}
if (sofIndex > 0) {
this.log(`[reader] scarto ${sofIndex} byte prima del SOF: ${this.rxBuffer.slice(0, sofIndex).toString('hex')}`);
this.rxBuffer = this.rxBuffer.slice(sofIndex);
}
if (this.rxBuffer.length < headerLength) return null;
const dlen = (this.rxBuffer[3] << 8) | this.rxBuffer[4];
if (dlen > 512) {
this.log(`[reader] frame scartato: DLEN non plausibile (${dlen}), buffer=${this.rxBuffer.toString('hex')}`);
this.rxBuffer = this.rxBuffer.slice(1);
return null;
}
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, frame=${this.rxBuffer.toString('hex')}`);
return null;
}
let sum = 0;
for (const b of data) sum += b;
if ((sum & 0xFFFF) !== checksum) {
this.log(
`[reader] frame scartato: checksum errato, atteso=${sum & 0xFFFF}, ricevuto=${checksum}, data=${data.toString('hex')}`
);
return null;
}
return { dst, src, data };
}
// ---- helper vari ----
_setControlLines(dtr, rts) {
return new Promise((resolve) => {
if (!this.port || typeof this.port.set !== 'function') {
this.log('[reader] setControlLines ignorato: port.set non disponibile');
resolve();
return;
}
this.port.set({ dtr, rts }, (err) => {
if (err) this.log(`[reader] errore setControlLines: ${err.message}`);
resolve();
});
});
}
@@ -210,72 +539,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,43 @@
/*
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 defaultPluginOptions = {
device: '/dev/ttyUSB0',
baudRate: 115200,
publishIntervalMs: 1000,
pollIntervalMs: 1000,
mppts: [
{ id: 'port', address: 50 },
{ id: 'starboard', address: 60 },
],
};
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 +45,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,81 +59,89 @@ 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 },
{ id: 'starboard', address: 60 },
],
},
},
};
plugin.start = function (options) {
app.debug('Avvio plugin meb-solars con opzioni:', options);
const resolvedOptions = normalizeOptions(options);
const pluginLogger = createPluginLogger(app);
pluginLogger(`Avvio plugin con opzioni risolte: ${JSON.stringify(resolvedOptions)}`);
reader = new MPPTReader({
device: options.device,
canBitrate: options.canBitrate,
mppts: options.mppts,
log: app.debug ? app.debug.bind(app) : console.log,
device: resolvedOptions.device,
baudRate: resolvedOptions.baudRate,
mppts: resolvedOptions.mppts,
pollIntervalMs: resolvedOptions.pollIntervalMs,
log: pluginLogger,
});
// Bufferizza gli update in arrivo dal driver: pubblicazione periodica
reader.on('updates', (updates) => {
pluginLogger(`Ricevuti ${updates.length} update dal reader`);
pluginLogger(`Primi update reader: ${JSON.stringify(updates.slice(0, 8))}`);
for (const update of updates) {
pendingUpdatesByPath.set(update.path, update.value);
}
pluginLogger(`Buffer SignalK aggiornato: ${pendingUpdatesByPath.size} path in attesa`);
});
reader.on('open', () => {
app.setPluginStatus(`Connesso al bus CAN (${options.device} @ ${options.canBitrate} bps)`);
pluginLogger(`Evento open: seriale connessa (${resolvedOptions.device} @ ${resolvedOptions.baudRate} bps)`);
app.setPluginStatus(`Connesso alla seriale (${resolvedOptions.device} @ ${resolvedOptions.baudRate} bps)`);
});
reader.on('error', (err) => {
app.error(`Errore driver CAN: ${err.message}`);
pluginLogger(`Evento error dal reader: ${err.stack || 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');
pluginLogger('Evento close: seriale disconnessa');
app.setPluginStatus('Seriale disconnessa, tentativo di riconnessione in corso');
});
reader.open()
.then(() => {
pluginLogger('Reader aperto: pubblico metadata e avvio intervallo SignalK');
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();
pluginLogger(`Tick publish: aggregati=${aggregateUpdates.length}, pendingPrima=${pendingUpdatesByPath.size}`);
for (const update of aggregateUpdates) {
pendingUpdatesByPath.set(update.path, update.value);
}
if (pendingUpdatesByPath.size === 0) return;
if (pendingUpdatesByPath.size === 0) {
pluginLogger('Tick publish: nessun dato da pubblicare');
return;
}
const values = Array.from(pendingUpdatesByPath.entries()).map(([path, value]) => ({ path, value }));
pendingUpdatesByPath.clear();
publishUpdates(values);
}, options.publishIntervalMs);
pluginLogger(`Tick publish: pubblico ${values.length} path, esempi=${JSON.stringify(values.slice(0, 10))}`);
publishUpdates(values, pluginLogger);
}, resolvedOptions.publishIntervalMs);
})
.catch((err) => {
app.error(`Impossibile aprire il driver CAN: ${err.message}`);
pluginLogger(`Impossibile aprire il reader UART: ${err.stack || 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; }
const pluginLogger = createPluginLogger(app);
pluginLogger('Arresto plugin');
if (publishInterval) { clearInterval(publishInterval); publishInterval = null; }
if (reader) {
reader.close().catch((err) => app.error(`Errore in chiusura: ${err.message}`));
@@ -146,7 +152,7 @@ module.exports = function (app) {
};
// Pubblica un set di update come delta SignalK
function publishUpdates(values) {
function publishUpdates(values, pluginLogger = createPluginLogger(app)) {
const delta = {
updates: [
{
@@ -156,12 +162,21 @@ module.exports = function (app) {
},
],
};
app.handleMessage(plugin.id, delta);
try {
pluginLogger(`handleMessage values: ${JSON.stringify(delta)}`);
app.handleMessage(plugin.id, delta);
pluginLogger(`handleMessage completato: ${values.length} path pubblicati`);
} catch (err) {
pluginLogger(`Errore app.handleMessage(values): ${err.stack || err.message}`);
app.error(`Errore pubblicazione SignalK: ${err.message}`);
app.setPluginError(err.message);
}
}
// Pubblica i metadati (unita' di misura, displayName) per il data browser
function publishMetadata() {
if (!reader) return;
const pluginLogger = createPluginLogger(app);
const meta = [];
for (const mpptName of reader.mppts.keys()) {
const base = `meb.solar.${mpptName}`;
@@ -197,9 +212,54 @@ module.exports = function (app) {
{ path: 'meb.solar.total.current', value: { units: 'A', displayName: 'Total Battery Current' } },
);
app.handleMessage(plugin.id, {
const metaDelta = {
updates: [{ meta, timestamp: new Date().toISOString() }],
};
try {
pluginLogger(`Pubblico metadata SignalK: ${meta.length} path`);
pluginLogger(`Metadata delta: ${JSON.stringify(metaDelta)}`);
app.handleMessage(plugin.id, metaDelta);
pluginLogger('Metadata SignalK pubblicati');
} catch (err) {
pluginLogger(`Errore app.handleMessage(metadata): ${err.stack || err.message}`);
app.error(`Errore metadata SignalK: ${err.message}`);
app.setPluginError(err.message);
}
}
function normalizeOptions(options = {}) {
const configuredMppts = Array.isArray(options.mppts) && options.mppts.length > 0
? options.mppts
: defaultPluginOptions.mppts;
const normalizedMppts = configuredMppts.map((configuredMppt, index) => {
const defaultMppt = defaultPluginOptions.mppts[index] || defaultPluginOptions.mppts[0];
const normalizedAddress = Number(configuredMppt.address);
return {
// Identificativo SignalK del controller; se manca si usa il default per indice.
id: configuredMppt.id || defaultMppt.id,
// Indirizzo UART/DST del controller; evita polling con address=undefined.
address: Number.isFinite(normalizedAddress) ? normalizedAddress : defaultMppt.address,
};
});
return {
device: options.device || defaultPluginOptions.device,
baudRate: Number(options.baudRate || defaultPluginOptions.baudRate),
publishIntervalMs: Number(options.publishIntervalMs || defaultPluginOptions.publishIntervalMs),
pollIntervalMs: Number(options.pollIntervalMs || defaultPluginOptions.pollIntervalMs),
mppts: normalizedMppts,
};
}
function createPluginLogger(appInstance) {
return (message) => {
const logMessage = `[meb-solars] ${new Date().toISOString()} ${message}`;
console.log(logMessage);
if (appInstance && typeof appInstance.debug === 'function') {
appInstance.debug(logMessage);
}
};
}
return plugin;