From 91161d2d2c936e7077ae093a3424f396e2df0957 Mon Sep 17 00:00:00 2001 From: Giuseppe Raffa <77052701+sesee3@users.noreply.github.com> Date: Mon, 25 May 2026 13:14:19 +0200 Subject: [PATCH] Implemented: - MMPT class for data store - SerialPort library for USB-CAN connection - Stream to Singnlak's DataBrowser 3 --- package-lock.json | 362 ++++++++++++++++++++++++++++++++++++++++++ src/core/constants.js | 80 ++++++++++ src/core/errors.js | 38 +++++ src/core/mpptscore.js | 205 ++++++++++++++++++++++++ src/core/reader.js | 281 ++++++++++++++++++++++++++++++++ 5 files changed, 966 insertions(+) create mode 100644 package-lock.json create mode 100644 src/core/constants.js create mode 100644 src/core/errors.js create mode 100644 src/core/mpptscore.js create mode 100644 src/core/reader.js diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..70cea39 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,362 @@ +{ + "name": "meb-solars", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meb-solars", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@serialport/parser-readline": "^12.0.0", + "serialport": "^12.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-12.0.1.tgz", + "integrity": "sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "11.0.0", + "debug": "4.3.4", + "node-addon-api": "7.0.0", + "node-gyp-build": "4.6.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-11.0.0.tgz", + "integrity": "sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-11.0.0.tgz", + "integrity": "sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "11.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-12.0.0.tgz", + "integrity": "sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-12.0.0.tgz", + "integrity": "sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-12.0.0.tgz", + "integrity": "sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-12.0.0.tgz", + "integrity": "sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ==", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-12.0.0.tgz", + "integrity": "sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-12.0.0.tgz", + "integrity": "sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-12.0.0.tgz", + "integrity": "sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-12.0.0.tgz", + "integrity": "sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-12.0.0.tgz", + "integrity": "sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.3.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@serialport/stream/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz", + "integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", + "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/serialport": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-12.0.0.tgz", + "integrity": "sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "12.0.1", + "@serialport/parser-byte-length": "12.0.0", + "@serialport/parser-cctalk": "12.0.0", + "@serialport/parser-delimiter": "12.0.0", + "@serialport/parser-inter-byte-timeout": "12.0.0", + "@serialport/parser-packet-length": "12.0.0", + "@serialport/parser-readline": "12.0.0", + "@serialport/parser-ready": "12.0.0", + "@serialport/parser-regex": "12.0.0", + "@serialport/parser-slip-encoder": "12.0.0", + "@serialport/parser-spacepacket": "12.0.0", + "@serialport/stream": "12.0.0", + "debug": "4.3.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/serialport/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/serialport/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + } + } +} diff --git a/src/core/constants.js b/src/core/constants.js new file mode 100644 index 0000000..55b9b0c --- /dev/null +++ b/src/core/constants.js @@ -0,0 +1,80 @@ +/* + 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. +*/ + +// 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 + powerInput: 10, // potenza ingresso [W] es: 3260 -> 326.0 W + voltageOutput: 10, // tensione uscita [V] es: 864 -> 86.4 V + currentOutput: 100, // corrente uscita [A] es: 380 -> 3.80 A + powerOutput: 10, // potenza uscita [W] es: 3110 -> 311.0 W + temperaturePhase1: 10, // temperatura fase 1 [°C] + temperaturePhase2: 10, // temperatura fase 2 [°C] + chargeCapacity: 100, // capacita' caricata [Ah] es: 1234 -> 12.34 Ah +}; + +// Flag del registro Status1 (registro 4): mappa bit -> codice operativo +const status1Flags = { + 0: 'PwrEna', // Power stage abilitato via software + 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 + 7: 'CCapRst', // Reset contatore Ah + 8: 'Ph1Ena', // Fase 1 abilitata + 9: 'Ph2Ena', // Fase 2 abilitata + 10: 'QREna', // Quasi-Resonant Mode + 11: 'DirMPPT', // Direzione algoritmo MPPT +}; + +// Flag del registro Warning (registro 6): mappa bit -> codice di errore/warning +const errorsFlags = { + 0: 'fault', + 1: 'vi_under_voltage_protection', + 2: 'vi_low_voltage', // MPPT in standby + 3: 'vi_over_voltage_protection', + 4: 'li_over_current_protection', + 5: 'under_minimum_battery_voltage', + 6: 'over_maximum_battery_voltage', + 7: 'current_to_battery_over_max', // protezione hw a 7A verso batteria + 8: 'internal_hardware_protection', + 9: 'over_temperature_protection', // sopra 120 °C + 10: 'memory_flash_read_error', +}; + +// Indirizzi dei registri richiesti via polling attivo +const registerAddresses = { + powerInput: 20, + powerOutput: 21, + temperature1: 35, + temperature2: 36, +}; + +// 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', +}; + +module.exports = { + conversionsFactors, + status1Flags, + errorsFlags, + registerAddresses, + slcanBitrateCommands, +}; diff --git a/src/core/errors.js b/src/core/errors.js new file mode 100644 index 0000000..c36cc5b --- /dev/null +++ b/src/core/errors.js @@ -0,0 +1,38 @@ +/* + Utility centralizzate per la gestione degli errori del plugin. + 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 { + constructor(message, cause) { + super(message); + this.name = 'SlcanDriverError'; + if (cause) this.cause = cause; + } +} + +// Errore di parsing dei frame CAN ricevuti +class CanFrameParseError extends Error { + constructor(message, rawLine) { + super(message); + this.name = 'CanFrameParseError'; + this.rawLine = rawLine; + } +} + +// Errore di timeout su una richiesta di lettura registro +class RegisterReadTimeoutError extends Error { + constructor(rxId, regAddr) { + super(`Timeout su lettura registro ${regAddr} dal nodo CAN 0x${rxId.toString(16)}`); + this.name = 'RegisterReadTimeoutError'; + this.rxId = rxId; + this.regAddr = regAddr; + } +} + +module.exports = { + SlcanDriverError, + CanFrameParseError, + RegisterReadTimeoutError, +}; diff --git a/src/core/mpptscore.js b/src/core/mpptscore.js new file mode 100644 index 0000000..2691b97 --- /dev/null +++ b/src/core/mpptscore.js @@ -0,0 +1,205 @@ +/* + 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. + + I valori esposti sono gia' convertiti in unita' fisiche (V, A, W, °C, Ah). +*/ + +const { + conversionsFactors, + status1Flags, + errorsFlags, + registerAddresses, +} = require('./constants'); + +// Arrotondamenti convenzionali per esporre dati leggibili nei getter +const roundedTo1 = (v) => Number(v.toFixed(1)); +const roundedTo2 = (v) => Number(v.toFixed(2)); +const roundedTo3 = (v) => Number(v.toFixed(3)); + +// Decodifica un valore numerico in array di stringhe usando una mappa bit -> nome +const decodeFlags = (value, map) => { + const active = []; + for (const [bit, name] of Object.entries(map)) { + if (value & (1 << parseInt(bit, 10))) { + // Nel caso di status1Flags i valori sono stringhe; nel caso di errorsFlags idem + active.push(typeof name === 'string' ? name : name.code); + } + } + 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, + 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.log = log; + + this.state = { + voltageInput: null, + currentInput: null, + voltageOutput: null, + currentOutput: null, + powerInput: null, // letto via polling registro 20 + powerOutput: null, // letto via polling registro 21 + temperature1: null, // [°C] + temperature2: null, // [°C] + chargeCapacity: null, // [Ah] + status1Raw: 0, + warningRaw: 0, + flags: [], + warnings: [], + lastUpdate: 0, + }; + } + + // Restituisce true se l'MPPT ha trasmesso dati recentemente + isOnline(thresholdMs = 5000) { + return this.state.lastUpdate > 0 && (Date.now() - this.state.lastUpdate) < thresholdMs; + } + + // Calcola l'efficienza istantanea (output/input) come ratio 0..1 + calculateEfficiency() { + const { voltageInput, currentInput, voltageOutput, currentOutput } = this.state; + if (voltageInput === null || currentInput === null || voltageOutput === null || currentOutput === null) return null; + const inputPower = voltageInput * currentInput; + if (inputPower < 1) return null; + return roundedTo3((voltageOutput * currentOutput) / inputPower); + } + + // Deriva la modalita' di carica corrente a partire dai flag attivi + deriveChargingMode() { + if (!this.state.flags.includes('PwrOn')) return 'off'; + if (this.state.flags.includes('FloatMod')) return 'float'; + if (this.state.flags.includes('StorMod')) return 'storage'; + 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); + 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; + } + 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() { + const base = `meb.solar.${this.name}`; + const s = this.state; + const updates = []; + + if (s.voltageInput !== null) updates.push({ path: `${base}.panelVoltage`, value: roundedTo1(s.voltageInput) }); + if (s.currentInput !== null) updates.push({ path: `${base}.panelCurrent`, value: roundedTo2(s.currentInput) }); + if (s.voltageInput !== null && s.currentInput !== null) { + updates.push({ path: `${base}.panelPower`, value: roundedTo2(s.voltageInput * s.currentInput) }); + } + if (s.voltageOutput !== null) updates.push({ path: `${base}.voltage`, value: roundedTo1(s.voltageOutput) }); + if (s.currentOutput !== null) updates.push({ path: `${base}.current`, value: roundedTo2(s.currentOutput) }); + if (s.voltageOutput !== null && s.currentOutput !== null) { + updates.push({ path: `${base}.power`, value: roundedTo2(s.voltageOutput * s.currentOutput) }); + } + + const efficiency = this.calculateEfficiency(); + if (efficiency !== null) updates.push({ path: `${base}.efficiency`, value: efficiency }); + + if (s.temperature1 !== null) updates.push({ path: `${base}.controllerTemperature.phase1`, value: roundedTo2(s.temperature1 + 273.15) }); + if (s.temperature2 !== null) updates.push({ path: `${base}.controllerTemperature.phase2`, value: roundedTo2(s.temperature2 + 273.15) }); + + if (s.chargeCapacity !== null) { + updates.push({ path: `${base}.chargedCapacity`, value: roundedTo2(s.chargeCapacity) }); + if (s.voltageOutput !== null) { + // Energia in Joule: Ah * V * 3600 + updates.push({ path: `${base}.chargedEnergy`, value: roundedTo2(s.chargeCapacity * s.voltageOutput * 3600) }); + } + } + + updates.push({ path: `${base}.chargingMode`, value: this.deriveChargingMode() }); + updates.push({ path: `${base}.flags`, value: s.flags }); + updates.push({ path: `${base}.warnings`, value: s.warnings }); + + return updates; + } + + // Snapshot operativo per logging / debug + getSnapshot() { + return { + online: this.isOnline(), + voltageInput: this.state.voltageInput, + currentInput: this.state.currentInput, + voltageOutput: this.state.voltageOutput, + currentOutput: this.state.currentOutput, + temperature1: this.state.temperature1, + temperature2: this.state.temperature2, + chargeCapacity: this.state.chargeCapacity, + flags: this.state.flags, + warnings: this.state.warnings, + chargingMode: this.deriveChargingMode(), + efficiency: this.calculateEfficiency(), + }; + } +} + +module.exports = { + MPPT, + parsePayload, + decodeFlags, +}; diff --git a/src/core/reader.js b/src/core/reader.js new file mode 100644 index 0000000..9be7d86 --- /dev/null +++ b/src/core/reader.js @@ -0,0 +1,281 @@ +/* + Driver per convertitore USB-CAN in modalita' slcan (Lawicel CAN232 / CANUSB). + + 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. + + Eventi emessi: + 'open' -> bus CAN aperto + 'frame' -> { id, data: Buffer } per ogni frame valido + 'updates' -> array di {path, value} pronti per la pubblicazione SignalK + 'error' -> Error + 'close' -> chiusura porta +*/ + +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'); + +// Riconnessione automatica con backoff esponenziale (clamp 30s) +const RECONNECT_BASE_DELAY_MS = 1000; +const RECONNECT_MAX_DELAY_MS = 30000; + +class MPPTReader extends EventEmitter { + constructor({ + device, + canBitrate = 250000, + mppts = [], + log = () => {}, + } = {}) { + super(); + this.device = device; + this.canBitrate = canBitrate; + this.log = log; + + this.port = null; + this.parser = null; + this.isOpen = false; + this.shouldReconnect = true; + this.reconnectAttempts = 0; + this.reconnectTimer = null; + + // 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' } + + for (const config of mppts) { + const mppt = new MPPT({ + name: config.id, + rxID: config.rxId, + txIdBase: config.txIdBase, + 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 + async open() { + return new Promise((resolve, reject) => { + try { + this.port = new SerialPort({ + path: this.device, + baudRate: 115200, + autoOpen: false, + }); + + this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\r' })); + this.parser.on('data', (line) => this._onLine(line)); + + this.port.on('error', (err) => { + this.log(`[reader] errore porta seriale: ${err.message}`); + this.emit('error', new SlcanDriverError(err.message, err)); + }); + + this.port.on('close', () => { + this.isOpen = false; + 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)); + return; + } + try { + await this._initSlcan(); + this.isOpen = true; + this.reconnectAttempts = 0; + this.emit('open'); + resolve(); + } catch (initErr) { + reject(initErr); + } + }); + } catch (err) { + reject(new SlcanDriverError(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 + async close() { + this.shouldReconnect = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = 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) + buildAggregateUpdates() { + let panelPowerSum = 0; + let powerSum = 0; + let currentSum = 0; + let anyOnline = false; + + for (const mppt of this.mppts.values()) { + if (!mppt.isOnline()) continue; + anyOnline = true; + const s = mppt.state; + if (s.voltageInput !== null && s.currentInput !== null) panelPowerSum += s.voltageInput * s.currentInput; + if (s.voltageOutput !== null && s.currentOutput !== null) powerSum += s.voltageOutput * s.currentOutput; + if (s.currentOutput !== null) currentSum += s.currentOutput; + } + + if (!anyOnline) return []; + const roundedTo2 = (v) => Number(v.toFixed(2)); + return [ + { path: 'meb.solar.total.panelPower', value: roundedTo2(panelPowerSum) }, + { path: 'meb.solar.total.power', value: roundedTo2(powerSum) }, + { path: 'meb.solar.total.current', value: roundedTo2(currentSum) }, + ]; + } + + // ---- gestione interna ---- + + _writeRaw(data) { + return new Promise((resolve, reject) => { + this.port.write(data, (err) => { + if (err) reject(new SlcanDriverError(err.message, err)); + else resolve(); + }); + }); + } + + _delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + _scheduleReconnect() { + const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts), RECONNECT_MAX_DELAY_MS); + this.reconnectAttempts++; + this.log(`[reader] riconnessione tra ${delay}ms (tentativo ${this.reconnectAttempts})`); + this.reconnectTimer = setTimeout(() => { + this.open().catch((err) => { + this.emit('error', err); + if (this.shouldReconnect) this._scheduleReconnect(); + }); + }, 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 };