diff --git a/package.json b/package.json index fe25b48..15f6c2b 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/core/constants.js b/src/core/constants.js index 55b9b0c..4d148a4 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -1,12 +1,10 @@ /* - Costanti del protocollo Poweren MPPT Boost. - Riferimento: Manuale Doc. 40.0000.206, Hw v1.2 Rev 01. - Il microcontrollore degli MPPT (PIC24/dsPIC33 a virgola fissa) trasmette interi a 16 bit. - Per ottenere il valore fisico bisogna dividere il raw per il fattore di conversione. +Il microcontrollore degli MPPT (PIC24/dsPIC33 a virgola fissa) trasmette interi a 16 bit. Per +inviare dati decimali piu precisi, il dato viene moltiplicato per un valore definito. +conversionFactors mappa i valori di conversione usati, in modo tale che il codice riceve i dati e li divide il fattore di conversione. + */ - -// Fattori di conversione (valore_fisico = raw / factor) const conversionsFactors = { voltageInput: 10, // tensione ingresso [V] es: 296 -> 29.6 V currentInput: 100, // corrente ingresso [A] es: 1100 -> 11.00 A @@ -50,25 +48,87 @@ const errorsFlags = { 10: 'memory_flash_read_error', }; -// Indirizzi dei registri richiesti via polling attivo +/* + Indirizzi dei registri Poweren da leggere via UART (comando READ singolo registro). + + ATTENZIONE: i valori marcati con "(da verificare)" sono dedotti dalla struttura del + protocollo e NON sono confermati dal manuale fornito. Per ottenere la mappa completa e + certa, interrogare l'MPPT con i comandi GET_REG_NAMES (102) / GET_REG_VALUES (101) tramite + DataCOM, oppure consultare la tabella registri del manuale (Doc. 40.0000.206) e correggere + qui i numeri. I valori confermati dalla documentazione sono indicati come "(confermato)". +*/ const registerAddresses = { - powerInput: 20, - powerOutput: 21, - temperature1: 35, - temperature2: 36, + status1: 4, // St1 - flag di stato (confermato: "registro 4") + warning: 6, // Warn - flag di warning/errore (confermato: "registro 6") + chargeCapacity: 7, // Chg_Cap - Ah caricati (da verificare) + voltageInput: 16, // Vi - tensione ingresso (da verificare) + currentInput: 17, // Ii - corrente ingresso (da verificare) + voltageOutput: 18, // Vo - tensione uscita (da verificare) + currentOutput: 19, // Io - corrente uscita (da verificare) + powerInput: 20, // Pi - potenza ingresso (confermato) + powerOutput: 21, // Po - potenza uscita (confermato) + temperature1: 35, // T1 - temperatura fase 1 (confermato) + temperature2: 36, // T2 - temperatura fase 2 (confermato) }; -// Mapping dei bitrate CAN al comando slcan corrispondente (Lawicel CAN232/CANUSB) -const slcanBitrateCommands = { - 10000: 'S0', - 20000: 'S1', - 50000: 'S2', - 100000: 'S3', - 125000: 'S4', - 250000: 'S5', - 500000: 'S6', - 800000: 'S7', - 1000000: 'S8', +/* + Elenco ordinato dei registri da interrogare ad ogni ciclo di polling per ciascun MPPT. + Si leggono solo i registri che alimentano i path SignalK; Pi/Po non sono inclusi perche' + la potenza viene calcolata come Vi*Ii e Vo*Io (come nella versione CAN). +*/ +const pollRegisters = [ + registerAddresses.status1, + registerAddresses.warning, + registerAddresses.voltageInput, + registerAddresses.currentInput, + registerAddresses.voltageOutput, + registerAddresses.currentOutput, + registerAddresses.chargeCapacity, + registerAddresses.temperature1, + registerAddresses.temperature2, +]; + +/* + Costanti del frame del protocollo seriale Poweren (UART). + Struttura pacchetto: + [SOF][DST][SRC][0x00][DLEN][DATA...][CHK_HI][CHK_LO][EOF] + - SOF = 0x42 ('B') + - SRC della GUI/host = 0xFF + - DST = indirizzo MPPT (0x00 = qualsiasi, es. 50 = MPPT di default) + - CHK = sum(DATA) & 0xFFFF in big-endian + - EOF = 0x0D +*/ +const serialProtocol = { + startOfFrame: 0x42, // SOF + sourceHost: 0xFF, // SRC host/GUI + endOfFrame: 0x0D, // EOF + headerLength: 5, // SOF + DST + SRC + 0x00 + DLEN + tailLength: 3, // CHK_HI + CHK_LO + EOF + // Comandi (scritti nel registro 2) + commands: { + reset: 99, + defaultValues: 100, + getRegValues: 101, + getRegNames: 102, + getRegUnits: 103, + getRegTypes: 104, + getFirmwareVersion: 120, + }, +}; + +// Parametri di default della porta seriale (Waveshare USB-UART, 8N1) +const serialDefaults = { + baudRate: 115200, + dataBits: 8, + parity: 'none', + stopBits: 1, +}; + +// Timeout e retry per le transazioni sincrone (richiesta -> risposta) +const transactionTiming = { + timeoutMs: 500, + retries: 5, + retryDelayMs: 100, }; module.exports = { @@ -76,5 +136,8 @@ module.exports = { status1Flags, errorsFlags, registerAddresses, - slcanBitrateCommands, + pollRegisters, + serialProtocol, + serialDefaults, + transactionTiming, }; diff --git a/src/core/errors.js b/src/core/errors.js index c36cc5b..59d83b2 100644 --- a/src/core/errors.js +++ b/src/core/errors.js @@ -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, }; diff --git a/src/core/mpptscore.js b/src/core/mpptscore.js index 2691b97..107110a 100644 --- a/src/core/mpptscore.js +++ b/src/core/mpptscore.js @@ -1,6 +1,4 @@ /* - Modello di un singolo controller MPPT Poweren Boost. - Ogni istanza mantiene lo stato corrente del dispositivo (tensioni, correnti, temperature, flag operativi, warnings) aggiornato dai frame CAN broadcast (MSG1 status, MSG2 power) e dalle risposte alle richieste di lettura registro. @@ -15,7 +13,25 @@ const { registerAddresses, } = require('./constants'); -// Arrotondamenti convenzionali per esporre dati leggibili nei getter +/* + Mappa indirizzo registro -> campo di stato e fattore di conversione. + Usata da updateRegister per convertire il valore raw a 16 bit nel valore fisico. + I registri di flag (status1, warning) sono gestiti a parte perche' vanno decodificati a bit. + signed=true indica un intero a 16 bit con segno (complemento a 2), usato per le temperature. +*/ +const numericRegisterMap = { + [registerAddresses.voltageInput]: { field: 'voltageInput', factor: conversionsFactors.voltageInput, signed: false }, + [registerAddresses.currentInput]: { field: 'currentInput', factor: conversionsFactors.currentInput, signed: false }, + [registerAddresses.voltageOutput]: { field: 'voltageOutput', factor: conversionsFactors.voltageOutput, signed: false }, + [registerAddresses.currentOutput]: { field: 'currentOutput', factor: conversionsFactors.currentOutput, signed: false }, + [registerAddresses.powerInput]: { field: 'powerInput', factor: conversionsFactors.powerInput, signed: false }, + [registerAddresses.powerOutput]: { field: 'powerOutput', factor: conversionsFactors.powerOutput, signed: false }, + [registerAddresses.chargeCapacity]: { field: 'chargeCapacity', factor: conversionsFactors.chargeCapacity, signed: false }, + [registerAddresses.temperature1]: { field: 'temperature1', factor: conversionsFactors.temperaturePhase1, signed: true }, + [registerAddresses.temperature2]: { field: 'temperature2', factor: conversionsFactors.temperaturePhase2, signed: true }, +}; + +// Arrotondamenti const roundedTo1 = (v) => Number(v.toFixed(1)); const roundedTo2 = (v) => Number(v.toFixed(2)); const roundedTo3 = (v) => Number(v.toFixed(3)); @@ -32,27 +48,14 @@ const decodeFlags = (value, map) => { return active; }; -// Parsing del payload broadcast: 8 byte -> 4 uint16 big-endian -function parsePayload(data) { - if (!data || data.length < 8) return null; - return [ - (data[0] << 8) | data[1], - (data[2] << 8) | data[3], - (data[4] << 8) | data[5], - (data[6] << 8) | data[7], - ]; -} - class MPPT { constructor({ name, - rxID, - txIdBase, + address, log = () => {}, } = {}) { this.name = name; // identificativo logico (es. 'port', 'starboard') - this.rxID = rxID; // indirizzo CAN del dispositivo (registro Addr) - this.txIdBase = txIdBase; // ID base di trasmissione (broadcast su +1 e +2) + this.address = address; // indirizzo UART/DST del dispositivo (registro Addr) this.log = log; this.state = { @@ -95,51 +98,34 @@ class MPPT { return 'bulk'; } - // Aggiorna lo stato da un frame broadcast (MSG1=status, MSG2=power) - updateFromBroadcast(type, data) { - const regs = parsePayload(data); - if (!regs) return; - - if (type === 'status') { - const [st1, , warn, chgCap] = regs; - this.state.status1Raw = st1; - this.state.warningRaw = warn; - this.state.chargeCapacity = chgCap / conversionsFactors.chargeCapacity; - this.state.flags = decodeFlags(st1, status1Flags); - this.state.warnings = decodeFlags(warn, errorsFlags); + /* + Aggiorna lo stato a partire dalla lettura UART di un singolo registro. + regAddr = indirizzo del registro letto + rawValue = valore raw a 16 bit ricevuto nella risposta (big-endian, gia' decodificato) + */ + updateRegister(regAddr, rawValue) { + if (regAddr === registerAddresses.status1) { + // Registro di stato St1: decodifica i bit in flag operativi + this.state.status1Raw = rawValue; + this.state.flags = decodeFlags(rawValue, status1Flags); + } else if (regAddr === registerAddresses.warning) { + // Registro Warning: decodifica i bit in codici di errore/warning + this.state.warningRaw = rawValue; + this.state.warnings = decodeFlags(rawValue, errorsFlags); if (this.state.warnings.length) { this.log(`[${this.name}] WARN: ${this.state.warnings.join(',')}`); } - } else if (type === 'power') { - const [vi, ii, vo, io] = regs; - this.state.voltageInput = vi / conversionsFactors.voltageInput; - this.state.currentInput = ii / conversionsFactors.currentInput; - this.state.voltageOutput = vo / conversionsFactors.voltageOutput; - this.state.currentOutput = io / conversionsFactors.currentOutput; + } else { + // Registri numerici (tensioni, correnti, temperature, capacita') + const target = numericRegisterMap[regAddr]; + if (!target) return; + let value = rawValue; + if (target.signed && value >= 0x8000) value -= 0x10000; // intero con segno + this.state[target.field] = value / target.factor; } this.state.lastUpdate = Date.now(); } - // Aggiorna lo stato a partire da una risposta a richiesta di registro - // Payload reply: [reg_addr, value_high, value_low] - updateFromRegisterReply(data) { - if (!data || data.length < 3) return; - const regAddr = data[0]; - const rawValue = (data[1] << 8) | data[2]; - - const mapping = { - [registerAddresses.powerInput]: { field: 'powerInput', factor: conversionsFactors.powerInput }, - [registerAddresses.powerOutput]: { field: 'powerOutput', factor: conversionsFactors.powerOutput }, - [registerAddresses.temperature1]: { field: 'temperature1', factor: conversionsFactors.temperaturePhase1 }, - [registerAddresses.temperature2]: { field: 'temperature2', factor: conversionsFactors.temperaturePhase2 }, - }; - - const target = mapping[regAddr]; - if (!target) return; - this.state[target.field] = rawValue / target.factor; - this.state.lastUpdate = Date.now(); - } - // Costruisce gli update SignalK (path + value) a partire dallo stato corrente // Tutti i valori sono in unita' SI (V, A, W, K, ratio, Ah) buildSignalKUpdates() { @@ -200,6 +186,5 @@ class MPPT { module.exports = { MPPT, - parsePayload, decodeFlags, }; diff --git a/src/core/reader.js b/src/core/reader.js index 9be7d86..5dfe4bf 100644 --- a/src/core/reader.js +++ b/src/core/reader.js @@ -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\r bitrate, O\r apri) e parsea le righe in arrivo - terminate da CR. Mantiene una collezione di istanze MPPT e instrada - ad esse i frame ricevuti in base al CAN ID, esponendo gli update - in formato SignalK delta. + A differenza della versione CAN (broadcast asincrono), il protocollo UART Poweren e' + SINCRONO: per ogni dato si invia un pacchetto di lettura registro e si attende subito la + risposta. Non esistono notifiche spontanee. Il driver esegue quindi un loop di polling che, + ad ogni ciclo, interroga in sequenza i registri necessari di ciascun MPPT configurato. + + Formato pacchetto Poweren: + [SOF=0x42][DST][SRC=0xFF][0x00][DLEN][DATA...][CHK_HI][CHK_LO][EOF=0x0D] + - Richiesta lettura registro: DATA = [reg] (DLEN=1) + - Risposta: DATA = [val_hi, val_lo] (valore a 16 bit big-endian) + - CHK = sum(DATA) & 0xFFFF in big-endian Eventi emessi: - 'open' -> bus CAN aperto - 'frame' -> { id, data: Buffer } per ogni frame valido + 'open' -> porta seriale aperta 'updates' -> array di {path, value} pronti per la pubblicazione SignalK 'error' -> Error 'close' -> chiusura porta @@ -17,11 +22,19 @@ const EventEmitter = require('events'); const { SerialPort } = require('serialport'); -const { ReadlineParser } = require('@serialport/parser-readline'); const { MPPT } = require('./mpptscore'); -const { slcanBitrateCommands, registerAddresses } = require('./constants'); -const { SlcanDriverError, CanFrameParseError } = require('./errors'); +const { + pollRegisters, + serialProtocol, + serialDefaults, + transactionTiming, +} = require('./constants'); +const { + SerialDriverError, + SerialFrameParseError, + RegisterReadTimeoutError, +} = require('./errors'); // Riconnessione automatica con backoff esponenziale (clamp 30s) const RECONNECT_BASE_DELAY_MS = 1000; @@ -30,136 +43,116 @@ const RECONNECT_MAX_DELAY_MS = 30000; class MPPTReader extends EventEmitter { constructor({ device, - canBitrate = 250000, + baudRate = serialDefaults.baudRate, mppts = [], + pollIntervalMs = 1000, + timeoutMs = transactionTiming.timeoutMs, + retries = transactionTiming.retries, log = () => {}, } = {}) { super(); this.device = device; - this.canBitrate = canBitrate; + this.baudRate = baudRate; + this.pollIntervalMs = pollIntervalMs; + this.timeoutMs = timeoutMs; + this.retries = retries; this.log = log; this.port = null; - this.parser = null; this.isOpen = false; this.shouldReconnect = true; this.reconnectAttempts = 0; this.reconnectTimer = null; + this.pollTimer = null; + this.isPolling = false; - // Istanzia gli MPPT e costruisce la routing table CAN_ID -> {mppt, type} - this.mppts = new Map(); // name -> MPPT - this.routing = new Map(); // canId -> { mppt, type: 'status'|'power'|'reply' } + // Buffer di ricezione e transazione pendente (protocollo sincrono: 1 alla volta) + this.rxBuffer = Buffer.alloc(0); + this.pendingTransaction = null; + // Istanzia gli MPPT configurati (indicizzati per nome logico) + this.mppts = new Map(); for (const config of mppts) { const mppt = new MPPT({ name: config.id, - rxID: config.rxId, - txIdBase: config.txIdBase, + address: config.address, log: this.log, }); this.mppts.set(config.id, mppt); - // Broadcast MSG1 (status) e MSG2 (power) - this.routing.set(config.txIdBase + 1, { mppt, type: 'status' }); - this.routing.set(config.txIdBase + 2, { mppt, type: 'power' }); - // Risposta a richiesta di lettura registro (txIdBase) - this.routing.set(config.txIdBase, { mppt, type: 'reply' }); } } - // Apre la porta seriale ed esegue la sequenza di init slcan + // Apre la porta seriale e avvia il loop di polling async open() { return new Promise((resolve, reject) => { try { this.port = new SerialPort({ path: this.device, - baudRate: 115200, + baudRate: this.baudRate, + dataBits: serialDefaults.dataBits, + parity: serialDefaults.parity, + stopBits: serialDefaults.stopBits, autoOpen: false, }); - this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\r' })); - this.parser.on('data', (line) => this._onLine(line)); + // Protocollo binario: si leggono i byte grezzi (l'EOF 0x0D puo' comparire nei dati) + this.port.on('data', (chunk) => this._onData(chunk)); this.port.on('error', (err) => { this.log(`[reader] errore porta seriale: ${err.message}`); - this.emit('error', new SlcanDriverError(err.message, err)); + this.emit('error', new SerialDriverError(err.message, err)); }); this.port.on('close', () => { this.isOpen = false; + this._stopPolling(); this.emit('close'); if (this.shouldReconnect) this._scheduleReconnect(); }); this.port.open(async (err) => { if (err) { - reject(new SlcanDriverError(`Impossibile aprire ${this.device}: ${err.message}`, err)); + reject(new SerialDriverError(`Impossibile aprire ${this.device}: ${err.message}`, err)); return; } try { - await this._initSlcan(); + // Disattiva DTR/RTS per non tenere l'MPPT in reset (alcuni adapter lo resettano) + await this._setControlLines(false, false); this.isOpen = true; this.reconnectAttempts = 0; this.emit('open'); + this._startPolling(); resolve(); } catch (initErr) { reject(initErr); } }); } catch (err) { - reject(new SlcanDriverError(err.message, err)); + reject(new SerialDriverError(err.message, err)); } }); } - // Sequenza di inizializzazione slcan: C (close), S (bitrate), O (open) - async _initSlcan() { - const bitrateCommand = slcanBitrateCommands[this.canBitrate]; - if (!bitrateCommand) { - throw new SlcanDriverError(`Bitrate CAN non supportato: ${this.canBitrate}`); - } - await this._writeRaw('C\r'); - await this._delay(50); - await this._writeRaw(`${bitrateCommand}\r`); - await this._delay(50); - await this._writeRaw('O\r'); - await this._delay(50); - } - - // Chiude il canale e la porta + // Chiude la porta e ferma il polling async close() { this.shouldReconnect = false; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + this._stopPolling(); + if (this.pendingTransaction) { + clearTimeout(this.pendingTransaction.timer); + this.pendingTransaction.reject(new SerialDriverError('Porta in chiusura')); + this.pendingTransaction = null; + } if (this.port && this.port.isOpen) { - try { await this._writeRaw('C\r'); } catch (_) { /* best effort */ } await new Promise((resolve) => this.port.close(() => resolve())); } this.isOpen = false; } - // Invia una richiesta di lettura registro: t\r - async sendReadRequest(rxId, regAddr) { - if (!this.isOpen) return; - const idHex = rxId.toString(16).toUpperCase().padStart(3, '0'); - const addrHex = regAddr.toString(16).toUpperCase().padStart(2, '0'); - const command = `t${idHex}1${addrHex}\r`; - await this._writeRaw(command); - } - - // Lista dei registri da interrogare in polling per ciascun MPPT - getPollTargets() { - const targets = []; - for (const mppt of this.mppts.values()) { - for (const regAddr of Object.values(registerAddresses)) { - targets.push({ rxId: mppt.rxID, regAddr }); - } - } - return targets; - } - - // Costruisce gli update aggregati (somma dei due lati Port + Starboard) + // Costruisce gli update aggregati (somma dei lati attivi) buildAggregateUpdates() { let panelPowerSum = 0; let powerSum = 0; @@ -184,17 +177,201 @@ class MPPTReader extends EventEmitter { ]; } - // ---- gestione interna ---- + // ---- loop di polling ---- - _writeRaw(data) { + _startPolling() { + if (this.pollTimer) return; + // setTimeout ricorsivo: garantisce che un ciclo finisca prima del successivo + const tick = async () => { + if (!this.isOpen) return; + try { + await this._pollCycle(); + } catch (err) { + this.emit('error', err); + } + if (this.isOpen) { + this.pollTimer = setTimeout(tick, this.pollIntervalMs); + } + }; + this.pollTimer = setTimeout(tick, 0); + } + + _stopPolling() { + if (this.pollTimer) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + } + + // Un ciclo di polling: per ogni MPPT legge tutti i registri e pubblica gli update + async _pollCycle() { + if (this.isPolling) return; + this.isPolling = true; + try { + for (const mppt of this.mppts.values()) { + let updated = false; + for (const regAddr of pollRegisters) { + try { + const rawValue = await this._readRegister(mppt.address, regAddr); + mppt.updateRegister(regAddr, rawValue); + updated = true; + } catch (err) { + // Timeout/errore sul singolo registro: log e si prosegue col prossimo + this.log(`[reader] lettura reg ${regAddr} MPPT ${mppt.address} fallita: ${err.message}`); + } + } + if (updated) { + const updates = mppt.buildSignalKUpdates(); + if (updates.length) this.emit('updates', updates); + } + } + } finally { + this.isPolling = false; + } + } + + // ---- transazione sincrona di lettura registro ---- + + /* + Legge un singolo registro dall'MPPT indicato (DST=address). + Ritorna il valore raw a 16 bit. Esegue retry in caso di timeout. + */ + async _readRegister(address, regAddr) { + let lastError = null; + for (let attempt = 0; attempt <= this.retries; attempt++) { + try { + const responseData = await this._transact(address, this._buildReadPacket(address, regAddr)); + if (!responseData || responseData.length < 2) { + throw new SerialFrameParseError('risposta troppo corta'); + } + return (responseData[0] << 8) | responseData[1]; + } catch (err) { + lastError = err; + if (attempt < this.retries) await this._delay(transactionTiming.retryDelayMs); + } + } + throw lastError || new RegisterReadTimeoutError(address, regAddr); + } + + // Invia un pacchetto e attende il prossimo frame valido (con timeout) + _transact(address, packet) { return new Promise((resolve, reject) => { - this.port.write(data, (err) => { - if (err) reject(new SlcanDriverError(err.message, err)); - else resolve(); + if (!this.isOpen) { + reject(new SerialDriverError('porta non aperta')); + return; + } + // Pulisce eventuali residui dal buffer prima di una nuova transazione + this.rxBuffer = Buffer.alloc(0); + + const timer = setTimeout(() => { + if (this.pendingTransaction && this.pendingTransaction.timer === timer) { + this.pendingTransaction = null; + reject(new RegisterReadTimeoutError(address, packet[5])); + } + }, this.timeoutMs); + + this.pendingTransaction = { resolve, reject, timer }; + + this.port.write(packet, (err) => { + if (err) { + clearTimeout(timer); + this.pendingTransaction = null; + reject(new SerialDriverError(err.message, err)); + } }); }); } + // Costruisce un pacchetto di lettura registro: DATA=[reg], CHK=reg + _buildReadPacket(address, regAddr) { + const checksum = regAddr & 0xFFFF; + return Buffer.from([ + serialProtocol.startOfFrame, + address & 0xFF, + serialProtocol.sourceHost, + 0x00, + 0x01, // DLEN = 1 + regAddr & 0xFF, // DATA + (checksum >> 8) & 0xFF, + checksum & 0xFF, + serialProtocol.endOfFrame, + ]); + } + + // ---- ricezione e parsing ---- + + _onData(chunk) { + this.rxBuffer = Buffer.concat([this.rxBuffer, chunk]); + let frame; + while ((frame = this._extractFrame()) !== null) { + if (this.pendingTransaction) { + const { resolve, timer } = this.pendingTransaction; + clearTimeout(timer); + this.pendingTransaction = null; + resolve(frame.data); + } + } + } + + /* + Estrae il primo frame completo e valido dal buffer di ricezione, consumandone i byte. + Ritorna { dst, src, data } oppure null se non c'e' ancora un frame completo. + Scarta i byte spuri finche' non trova il SOF. + */ + _extractFrame() { + const { startOfFrame, endOfFrame, headerLength, tailLength } = serialProtocol; + + // Allinea il buffer al primo SOF disponibile + let sofIndex = this.rxBuffer.indexOf(startOfFrame); + if (sofIndex < 0) { + this.rxBuffer = Buffer.alloc(0); + return null; + } + if (sofIndex > 0) { + this.rxBuffer = this.rxBuffer.slice(sofIndex); + } + + if (this.rxBuffer.length < headerLength) return null; + + const dlen = (this.rxBuffer[3] << 8) | this.rxBuffer[4]; + const totalLength = headerLength + dlen + tailLength; + if (this.rxBuffer.length < totalLength) return null; + + const dst = this.rxBuffer[1]; + const src = this.rxBuffer[2]; + const data = this.rxBuffer.slice(headerLength, headerLength + dlen); + const checksum = (this.rxBuffer[headerLength + dlen] << 8) | this.rxBuffer[headerLength + dlen + 1]; + const eof = this.rxBuffer[headerLength + dlen + 2]; + + // Consuma il frame dal buffer (anche se invalido, per non bloccarsi) + this.rxBuffer = this.rxBuffer.slice(totalLength); + + if (eof !== endOfFrame) { + this.log('[reader] frame scartato: EOF mancante'); + return null; + } + let sum = 0; + for (const b of data) sum += b; + if ((sum & 0xFFFF) !== checksum) { + this.log('[reader] frame scartato: checksum errato'); + return null; + } + + return { dst, src, data }; + } + + // ---- helper vari ---- + + _setControlLines(dtr, rts) { + return new Promise((resolve) => { + if (!this.port || typeof this.port.set !== 'function') { + resolve(); + return; + } + this.port.set({ dtr, rts }, () => resolve()); + }); + } + _delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -210,72 +387,6 @@ class MPPTReader extends EventEmitter { }); }, delay); } - - // Parsing di una riga slcan in arrivo - _onLine(rawLine) { - const line = rawLine.toString().trim(); - if (!line) return; - // Risposte di ack/err: 'z', 'Z', BEL (0x07) - if (line === 'z' || line === 'Z') return; - - try { - const frame = this._parseFrame(line); - if (!frame) return; - - this.emit('frame', frame); - - const route = this.routing.get(frame.id); - if (!route) return; - - if (route.type === 'status' || route.type === 'power') { - route.mppt.updateFromBroadcast(route.type, frame.data); - } else if (route.type === 'reply') { - route.mppt.updateFromRegisterReply(frame.data); - } - - // Emette gli update SignalK aggiornati per questo MPPT - const updates = route.mppt.buildSignalKUpdates(); - if (updates.length) this.emit('updates', updates); - } catch (err) { - if (err instanceof CanFrameParseError) { - this.log(`[reader] frame ignorato: ${err.message}`); - } else { - this.emit('error', err); - } - } - } - - // Parser di un singolo frame slcan: - // tIIILDD... standard (ID 3 hex) - // TIIIIIIIILDD... extended (ID 8 hex) - _parseFrame(line) { - const type = line[0]; - if (type !== 't' && type !== 'T') return null; - - const isExtended = type === 'T'; - const idLen = isExtended ? 8 : 3; - if (line.length < 1 + idLen + 1) throw new CanFrameParseError('frame troppo corto', line); - - const idHex = line.substr(1, idLen); - const id = parseInt(idHex, 16); - if (Number.isNaN(id)) throw new CanFrameParseError('CAN ID non valido', line); - - const dlc = parseInt(line.substr(1 + idLen, 1), 10); - if (Number.isNaN(dlc) || dlc < 0 || dlc > 8) throw new CanFrameParseError('DLC non valido', line); - - const dataStart = 1 + idLen + 1; - const dataHex = line.substr(dataStart, dlc * 2); - if (dataHex.length < dlc * 2) throw new CanFrameParseError('payload incompleto', line); - - const data = Buffer.alloc(dlc); - for (let i = 0; i < dlc; i++) { - const byte = parseInt(dataHex.substr(i * 2, 2), 16); - if (Number.isNaN(byte)) throw new CanFrameParseError('byte payload non valido', line); - data[i] = byte; - } - - return { id, data, extended: isExtended }; - } } module.exports = { MPPTReader }; diff --git a/src/index.js b/src/index.js index 871bdae..dc69a4a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,45 +1,32 @@ -/* - Plugin SignalK: MEB Solars - Poweren MPPT Boost (CAN). - - Legge i dati dai controller MPPT Poweren Boost collegati via convertitore - USB-CAN (slcan) e li pubblica nei path SignalK sotto il namespace - meb.solar..* (port / starboard / total). - - Pattern di pubblicazione: gli update arrivano dal driver in modo asincrono - ad ogni frame, vengono bufferizzati e inviati a SignalK in blocco con - cadenza configurabile (publishIntervalMs) per non saturare i WebSocket. -*/ - const { MPPTReader } = require('./core/reader'); module.exports = function (app) { const plugin = { id: 'meb-solars', - name: 'MEB Solar Panels (Poweren MPPT)', - description: 'Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-CAN', + name: 'MEB Solar Panels', + description: 'Plugin SignalK per controller MPPT Poweren Boost via convertitore USB-UART', }; let reader = null; - let pollInterval = null; let publishInterval = null; let pendingUpdatesByPath = new Map(); // path -> value (deduplicazione) // Schema configurazione mostrato nella UI di SignalK plugin.schema = { type: 'object', - required: ['device', 'canBitrate'], + required: ['device', 'baudRate'], properties: { device: { type: 'string', - title: 'Path del dispositivo seriale USB-CAN', - default: '/dev/ttyUSB1', - description: 'Es. /dev/ttyUSB1 o symlink udev /dev/mppt-can', + title: 'Path del dispositivo seriale USB-UART', + default: '/dev/ttyUSB0', + description: 'Es. /dev/ttyUSB0 (Linux) o /dev/cu.usbserial-XXXX (macOS)', }, - canBitrate: { + baudRate: { type: 'number', - title: 'Bitrate del bus CAN', - default: 250000, - enum: [125000, 250000, 500000], + title: 'Baud rate seriale', + default: 115200, + enum: [9600, 19200, 38400, 57600, 115200], }, publishIntervalMs: { type: 'number', @@ -47,12 +34,12 @@ module.exports = function (app) { default: 1000, minimum: 200, }, - pollExtraIntervalMs: { + pollIntervalMs: { type: 'number', - title: 'Intervallo polling registri extra (ms)', - default: 10000, - minimum: 2000, - description: 'Polling di temperature e potenze pre-calcolate (registri 20,21,35,36)', + title: 'Intervallo di polling UART (ms)', + default: 1000, + minimum: 200, + description: 'Frequenza con cui interrogare i registri di ciascun MPPT', }, mppts: { type: 'array', @@ -61,13 +48,11 @@ module.exports = function (app) { type: 'object', properties: { id: { type: 'string', title: 'ID logico (usato nei path SignalK)', default: 'port' }, - rxId: { type: 'number', title: 'CAN Rx ID (registro Addr)', default: 50 }, - txIdBase: { type: 'number', title: 'CAN Tx ID base (registro CAN_TxId)', default: 51 }, + address: { type: 'number', title: 'Indirizzo UART/DST (registro Addr)', default: 50 }, }, }, default: [ - { id: 'port', rxId: 50, txIdBase: 51 }, - { id: 'starboard', rxId: 60, txIdBase: 61 }, + { id: 'port', address: 50 }, ], }, }, @@ -78,8 +63,9 @@ module.exports = function (app) { reader = new MPPTReader({ device: options.device, - canBitrate: options.canBitrate, + baudRate: options.baudRate, mppts: options.mppts, + pollIntervalMs: options.pollIntervalMs, log: app.debug ? app.debug.bind(app) : console.log, }); @@ -91,31 +77,24 @@ module.exports = function (app) { }); reader.on('open', () => { - app.setPluginStatus(`Connesso al bus CAN (${options.device} @ ${options.canBitrate} bps)`); + app.setPluginStatus(`Connesso alla seriale (${options.device} @ ${options.baudRate} bps)`); }); reader.on('error', (err) => { - app.error(`Errore driver CAN: ${err.message}`); + app.error(`Errore driver UART: ${err.message}`); app.setPluginError(err.message); }); reader.on('close', () => { - app.setPluginStatus('Bus CAN disconnesso, tentativo di riconnessione in corso'); + app.setPluginStatus('Seriale disconnessa, tentativo di riconnessione in corso'); }); reader.open() .then(() => { publishMetadata(); - // Polling ciclico dei registri extra (temperature, potenze calcolate) - pollInterval = setInterval(() => { - const targets = reader.getPollTargets(); - targets.forEach(({ rxId, regAddr }) => { - reader.sendReadRequest(rxId, regAddr).catch(() => { /* best effort */ }); - }); - }, options.pollExtraIntervalMs); - - // Pubblicazione periodica degli update bufferizzati + aggregati + // Pubblicazione periodica degli update bufferizzati + aggregati. + // Il polling dei registri UART e' gestito internamente dal driver. publishInterval = setInterval(() => { const aggregateUpdates = reader.buildAggregateUpdates(); for (const update of aggregateUpdates) { @@ -128,14 +107,13 @@ module.exports = function (app) { }, options.publishIntervalMs); }) .catch((err) => { - app.error(`Impossibile aprire il driver CAN: ${err.message}`); + app.error(`Impossibile aprire il driver UART: ${err.message}`); app.setPluginError(err.message); }); }; plugin.stop = function () { app.debug('Arresto plugin meb-solars'); - if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } if (publishInterval) { clearInterval(publishInterval); publishInterval = null; } if (reader) { reader.close().catch((err) => app.error(`Errore in chiusura: ${err.message}`));