Implemented:

- MMPT class for data store
- SerialPort library for USB-CAN connection
- Stream to Singnlak's DataBrowser 3
This commit is contained in:
Giuseppe Raffa
2026-05-25 13:14:19 +02:00
parent 9d6766cda1
commit 91161d2d2c
5 changed files with 966 additions and 0 deletions

362
package-lock.json generated Normal file
View File

@@ -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"
}
}
}

80
src/core/constants.js Normal file
View File

@@ -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,
};

38
src/core/errors.js Normal file
View File

@@ -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,
};

205
src/core/mpptscore.js Normal file
View File

@@ -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,
};

281
src/core/reader.js Normal file
View File

@@ -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<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.
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<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
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<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)
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 };