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

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