Compare commits
5 Commits
main
...
uart-conne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b572642f | ||
|
|
2385152887 | ||
|
|
2cc320b484 | ||
|
|
c893fa3edf | ||
|
|
1d5bb340d9 |
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
176
src/index.js
176
src/index.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user