Nur der Vollständigkeit halber, ich hab jetzt eine für mich akzeptable Lösung gefunden, mit der ich leben kann.
flexchart_bat_work.png
Und hier das Script:
// Importiere javascript-stringify
const stringify = require('javascript-stringify');
// --- KONFIGURATION ---
const INFLUXDB_INSTANCE = 'influxdb.0';
const FLEXCHARTS_TARGET_STATE = '0_userdata.0.Flexcharts.Battery24Hours';
// --- ZEIT-KONFIGURATION ---
const TIME_RANGE_MS = 24 * 3600 * 1000; // 24 Stunden (00:00 bis 24:00)
const STEP_5M_MS = 5 * 60 * 1000; // 5 Minuten Auflösung für die Leistung (Basis für X-Achse)
const STEP_1M_MS = 60 * 1000; // 1 Minuten Auflösung für den Ladestand (Basis: 60s Speicherung)
// --- FIXE LEISTUNGSGRENZEN (kW) ---
const MAX_DISCHARGE_KW = 2.5; // 2500 W Entladung (negativer Achsenbereich)
const MAX_CHARGE_KW = 1.25; // 1250 W Ladung (positiver Achsenbereich)
// ------------------------------------
// Definiere die Datenpunkte und ihre Zuordnung zu den Y-Achsen
const DATAPOINTS_CONFIG = [
// 0. Ladung (kW) - GRÜN
{ id: 'senec.0.ENERGY.GUI_BAT_DATA_POWER', name: 'Laden (kW)', type: 'bar', color: '#2ECC40', is_charge: true, yAxisIndex: 0, unit: 'kW', step_ms: STEP_5M_MS, smooth: false, lineWidth: 3 },
// 1. Entladung (kW) - ROT
{ id: 'senec.0.ENERGY.GUI_BAT_DATA_POWER', name: 'Entladen (kW)', type: 'bar', color: '#FF4136', is_discharge: true, yAxisIndex: 0, unit: 'kW', step_ms: STEP_5M_MS, smooth: false, lineWidth: 3 },
// 2. Ladestand (%) - BLAU
{ id: 'senec.0.ENERGY.GUI_BAT_DATA_FUEL_CHARGE', name: 'Ladestand', type: 'line', color: '#0040ff', is_fuel_charge: true, yAxisIndex: 0, unit: '%', step_ms: STEP_1M_MS, smooth: true, lineWidth: 2 },
];
// ----------------------------------------------------------------------
// Starte das Skript alle 5 Minuten
schedule('*/5 * * * *', async () => {
await createAndSetChartData();
});
// Starte das Skript sofort beim Start
createAndSetChartData();
/**
* Hilfsfunktion zum Abfragen der History-Daten.
*/
function queryHistoryData(id: string, options: any): Promise<any> {
return new Promise<any>((resolve, reject) => {
sendTo(INFLUXDB_INSTANCE, 'getHistory', { id: id, options: options }, (result) => {
if (result && (result as any).error) {
reject(new Error(result.error));
} else {
resolve(result);
}
});
});
}
/**
* Normalisiert die History-Antwort.
*/
function extractDataArray(result: any): any[] {
if (Array.isArray(result)) return result;
if (result && Array.isArray(result.result)) return result.result;
if (result && Array.isArray(result.data)) return result.data;
return [];
}
/**
* Generiert alle X-Achsen Zeitstempel von 00:00 bis 23:55 (heute).
*/
function generateFullDayTimestamps(startOfTodayMs: number): string[] {
const timestamps: string[] = [];
const endOfTodayMs = startOfTodayMs + TIME_RANGE_MS;
// Die X-Achse bleibt bei 5-Minuten-Schritten für die Übersichtlichkeit
for (let ts = startOfTodayMs; ts < endOfTodayMs; ts += STEP_5M_MS) {
const date = new Date(ts);
timestamps.push(date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }));
}
return timestamps;
}
// Hauptfunktion zur Abfrage der Daten und Erstellung des Charts
async function createAndSetChartData(): Promise<void> {
// 1. ZEITRAUM BERECHNUNG
const now = Date.now();
const todayStart = new Date().setHours(0, 0, 0, 0);
const endTimeHistory = now;
// Erzeuge die X-Achse für den kompletten Tag (00:00 bis 23:55)
const fullDayTimestamps = generateFullDayTimestamps(todayStart);
try {
// --- 2. DATENABFRAGE ---
// Wir fragen nur die UNIQUE IDs ab, um History-Anfragen zu sparen
const uniqueIds = Array.from(new Set(DATAPOINTS_CONFIG.map(dp => dp.id)));
const historyPromises = uniqueIds.map(id => {
const dpConfig = DATAPOINTS_CONFIG.find(dp => dp.id === id); // Nutze die erste Konfiguration zur Optionsbestimmung
const step_ms = dpConfig.is_fuel_charge ? STEP_1M_MS : STEP_5M_MS;
const historyOptions = {
start: todayStart,
end: endTimeHistory,
aggregate: 'average',
step: step_ms,
limit: 5000,
removeBorderValues: false
};
return queryHistoryData(id, historyOptions).catch(e => {
log(`FEHLER bei History-Abfrage ${id}: ${e.message}`, 'error');
return { result: [] };
});
});
const historyResults = await Promise.all(historyPromises);
// Mappe Ergebnisse zu IDs
const historyResultsMap = new Map<string, any>();
uniqueIds.forEach((id, index) => {
historyResultsMap.set(id, extractDataArray(historyResults[index]));
});
// 3. DATEN MAPPING & TRANSFORMATION
// Leistung (kW) und Ladestand (%) Daten separat aus den History-Ergebnissen extrahieren
const rawPowerData = historyResultsMap.get('senec.0.ENERGY.GUI_BAT_DATA_POWER') || [];
const rawChargeData = historyResultsMap.get('senec.0.ENERGY.GUI_BAT_DATA_FUEL_CHARGE') || [];
const powerDataByTimeKey = new Map<number, number>();
rawPowerData.forEach((entry: any) => {
if (entry.val !== null && typeof entry.val === 'number') {
// WATT ZU KILOWATT KONVERTIERUNG
powerDataByTimeKey.set(
Math.floor(entry.ts / STEP_5M_MS) * STEP_5M_MS,
parseFloat((entry.val / 1000).toFixed(2))
);
}
});
const chargeDataByTimeKey = new Map<number, number>();
let lastValidChargeValue: number | null = 0;
rawChargeData.forEach((entry: any) => {
if (entry.val !== null && typeof entry.val === 'number') {
chargeDataByTimeKey.set(
Math.floor(entry.ts / STEP_1M_MS) * STEP_1M_MS,
parseFloat(entry.val.toFixed(2))
);
}
});
// ZWEI neue Leistungs-Arrays + EIN Ladestand-Array
const data_charge: (number | null)[] = []; // Grün: > 0
const data_discharge: (number | null)[] = []; // Rot: < 0
const data_fuel_charge: (number | null)[] = []; // Blau: %
let currentTimelineMs = todayStart;
for (let i = 0; i < fullDayTimestamps.length; i++) {
const timeKey = Math.floor(currentTimelineMs / STEP_5M_MS) * STEP_5M_MS;
const powerValue = powerDataByTimeKey.get(timeKey) || 0; // Standard 0 kW wenn kein Wert
// --- 1. Leistung aufteilen ---
if (currentTimelineMs > now) {
// Zukunft: Daten beenden
data_charge.push(null);
data_discharge.push(null);
} else {
if (powerValue > 0) {
data_charge.push(powerValue);
data_discharge.push(0);
} else if (powerValue < 0) {
data_charge.push(0);
data_discharge.push(powerValue); // Entladung ist negativ
} else {
data_charge.push(0);
data_discharge.push(0);
}
}
// --- 2. Ladestand hinzufügen ---
const chargeTimeKey = Math.floor(currentTimelineMs / STEP_1M_MS) * STEP_1M_MS;
let chargeValue = chargeDataByTimeKey.get(chargeTimeKey);
if (currentTimelineMs <= now) {
if (chargeValue !== undefined && chargeValue !== null) {
lastValidChargeValue = chargeValue;
} else {
chargeValue = lastValidChargeValue;
}
data_fuel_charge.push(chargeValue);
} else {
data_fuel_charge.push(null);
}
currentTimelineMs += STEP_5M_MS;
}
// Füllen der seriesDataMap mit den aufgeteilten Daten
const seriesDataMap = new Map<string, (number | null)[]>();
seriesDataMap.set('Laden (kW)', data_charge);
seriesDataMap.set('Entladen (kW)', data_discharge);
seriesDataMap.set('Ladestand', data_fuel_charge);
// Asymmetrische Achsengrenzen mit Puffer (0.5 kW)
const maxLimit = MAX_CHARGE_KW + 0.5;
const minLimit = MAX_DISCHARGE_KW + 0.5;
// *** TRANSFORMATION DES LADESTANDS ***
const fuelChargeData = seriesDataMap.get('Ladestand');
if (fuelChargeData) {
const transformedData = fuelChargeData.map(val => {
if (val === null) return null;
// Skalierung von 0 bis maxLimit für Ladestand
return (val / 100) * maxLimit;
});
seriesDataMap.set('Ladestand (Skaliert)', transformedData);
}
// ************************************
// 4. CHART-DEFINITION (ECharts Option)
const series = DATAPOINTS_CONFIG.map(dp => {
const seriesName = dp.is_fuel_charge ? 'Ladestand (Skaliert)' : dp.name;
const seriesData = seriesDataMap.get(seriesName) || [];
const seriesConfig: any = {
name: dp.name,
type: dp.type,
yAxisIndex: 0,
data: seriesData,
itemStyle: {
color: dp.color
},
lineStyle: {
width: dp.lineWidth || 1
},
smooth: dp.smooth || false,
showSymbol: false,
z: dp.is_fuel_charge ? 10 : 1,
// Animation für Säulen (type: 'bar')
animationDelay: dp.type === 'bar' ? function (idx: number) { return idx * 10; } : undefined,
animationDelayUpdate: dp.type === 'bar' ? function (idx: number) { return idx * 5; } : undefined,
};
return seriesConfig;
});
// Wir verwenden nur noch EINE Y-Achse
const yAxis = [
{
type: 'value',
name: 'Leistung (kW)',
min: -minLimit, // -3.0 kW
max: maxLimit, // 1.75 kW
axisLabel: {
formatter: (value: number) => value.toFixed(1) + ' kW',
color: '#FFFFFF'
},
axisLine: { onZero: true, lineStyle: { color: '#888' } },
splitLine: { show: true, lineStyle: { color: '#333333' } }
}
];
// *** VISUAL MAP IST HIER ENTFERNT ***
const option = {
backgroundColor: 'rgb(17, 18, 23, 0.0)',
title: { text: 'Batterieübersicht (Laden/Entladen & Ladestand)' },
// visualMap fehlt hier absichtlich
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: function (params: any[]) {
let res = params[0].name + '<br/>';
const chargeMax = maxLimit;
// Sortiere die Tooltip-Einträge: Ladestand zuletzt, dann Entladen, dann Laden
params.sort((a, b) => {
const nameA = a.seriesName;
const nameB = b.seriesName;
if (nameA.includes('Ladestand')) return 1;
if (nameB.includes('Ladestand')) return -1;
if (nameA.includes('Entladen')) return 1; // Entladen vor Laden
if (nameB.includes('Entladen')) return -1;
return 0;
});
params.forEach((item: any) => {
const dpConfig = DATAPOINTS_CONFIG.find(d => d.name === item.seriesName);
if (dpConfig && item.value !== null && item.value !== 0) { // Null- oder Nullwerte der unsichtbaren Serie ignorieren
let value = item.value;
let seriesName = item.seriesName;
let unit = dpConfig.unit;
if (dpConfig.is_fuel_charge) {
value = (value / chargeMax) * 100;
seriesName = 'Ladestand';
unit = '%';
} else {
// Ladung/Entladung werden mit ihren echten Namen angezeigt
unit = 'kW';
}
res += item.marker + seriesName + ': **' + value.toFixed(dpConfig.unit === 'kW' ? 2 : 1) + ' ' + unit + '**<br/>';
}
});
// Wenn keine Leistung angezeigt wird, zeige nur Ladestand an
if (res.endsWith('<br/>')) {
res = res.substring(0, res.lastIndexOf('<br/>'));
}
return res;
}
},
legend: {
data: DATAPOINTS_CONFIG.map(item => item.name),
bottom: 0,
textStyle: { color: '#FFFFFF' }
},
grid: {
left: '2%', right: '5%', bottom: '15%', top: '10%', containLabel: true
},
xAxis: {
type: 'category',
data: fullDayTimestamps,
axisLabel: { color: '#FFFFFF' }
},
yAxis: yAxis,
series: series,
animationEasing: 'elasticOut',
};
// 5. SPEICHERUNG IM FLEXCHARTS-STATE
setState(FLEXCHARTS_TARGET_STATE, stringify.stringify(option), true);
log(`ECharts-Optionen (Bar/Bar/Line Kombi mit Farb-Fix) erfolgreich in State ${FLEXCHARTS_TARGET_STATE} geschrieben.`, 'info');
} catch (e) {
log(`Allgemeiner Fehler im Skript oder bei der History-Abfrage: ${e.message}`, 'error');
}
}