// SPDX-License-Identifier: GPL-3.0-or-later /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright (C) 2013 - 2024, nymea GmbH * Copyright (C) 2024 - 2025, chargebyte austria GmbH * * This file is part of nymea-app. * * nymea-app is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * nymea-app is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with nymea-app. If not, see . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ import QtQuick import QtCharts import QtQuick.Layouts import QtQuick.Controls import Nymea import NymeaApp.Utils import "qrc:/ui/components" Item { id: root property EnergyManager energyManager: null property ThingsProxy consumers: null property bool titleVisible: true PowerBalanceLogs { id: powerBalanceLogs engine: _engine startTime: new Date(d.startTime.getTime() - d.range * 60000) endTime: new Date(d.endTime.getTime() + d.range * 60000) sampleRate: d.sampleRate Component.onCompleted: fetchLogs() onEntriesAddedIdx: (index, count) => { print("entries added", index, count) for (var i = 0; i < count; i++) { var entry = powerBalanceLogs.get(index + i) // print("got entry", entry.timestamp) zeroSeries.ensureValue(entry.timestamp) valueAxis.adjustMax(entry.consumption) consumptionSeries.insertEntry(index + i, entry) if (entry.timestamp > d.now && new Date().getTime() - d.now.getTime() < 120000) { d.now = entry.timestamp } } } onEntriesRemoved: (index, count) => { consumptionUpperSeries.removePoints(index, Math.min(count, consumptionUpperSeries.count)) zeroSeries.shrink() } } ThingPowerLogsLoader { id: logsLoader engine: _engine startTime: new Date(d.startTime.getTime() - d.range * 60000) endTime: new Date(d.endTime.getTime() + d.range * 60000) sampleRate: d.sampleRate } QtObject { id: d property date now: new Date() property var selectedSeries: null readonly property int range: selectionTabs.currentValue.range readonly property int sampleRate: selectionTabs.currentValue.sampleRate readonly property int visibleValues: range / sampleRate readonly property var startTime: { var date = new Date(fixTime(now)); date.setTime(date.getTime() - range * 60000 + 2000); return date; } readonly property var endTime: { var date = new Date(fixTime(now)); date.setTime(date.getTime() + 2000) return date; } function fixTime(timestamp) { switch (sampleRate) { case EnergyLogs.SampleRate1Min: timestamp.setSeconds(0, 0) break; case EnergyLogs.SampleRate15Mins: timestamp.setMinutes(timestamp.getMinutes() - timestamp.getMinutes() % 15, 0, 0) break; case EnergyLogs.SampleRate1Hour: timestamp.setMinutes(0, 0, 0); break; case EnergyLogs.SampleRate3Hours: timestamp.setHours(timestamp.getHours() % 3, 0, 0, 0); break; case EnergyLogs.SampleRate1Day: timestamp.setHours(0, 0, 0, 0) break; } return timestamp } function update() { if (!engine.thingManager.fetchingData && !engine.tagsManager.busy && consumersRepeater.count == consumers.count) { logsLoader.fetchLogs(); } } function selectSeries(series) { print("selecting series", series) if (d.selectedSeries === series) { d.selectedSeries = null } else { d.selectedSeries = series } } } Connections { target: engine.tagsManager onBusyChanged: d.update() } Component { id: lineSeriesComponent LineSeries { } } ColumnLayout { anchors.fill: parent spacing: 0 Label { Layout.fillWidth: true Layout.margins: Style.smallMargins horizontalAlignment: Text.AlignHCenter text: qsTr("Consumers history") visible: root.titleVisible MouseArea { anchors.fill: parent onClicked: pageStack.push(Qt.resolvedUrl("ConsumersHistoryPage.qml"), {energyManager: root.energyManager, consumers: root.consumers}) } } SelectionTabs { id: selectionTabs Layout.fillWidth: true Layout.leftMargin: Style.smallMargins Layout.rightMargin: Style.smallMargins currentIndex: 1 model: ListModel { ListElement { modelData: qsTr("Hours") sampleRate: EnergyLogs.SampleRate1Min range: 180 // 3 Hours: 3 * 60 } ListElement { modelData: qsTr("Days") sampleRate: EnergyLogs.SampleRate15Mins range: 1440 // 1 Day: 24 * 60 } ListElement { modelData: qsTr("Weeks") sampleRate: EnergyLogs.SampleRate1Hour range: 10080 // 7 Days: 7 * 24 * 60 } ListElement { modelData: qsTr("Months") sampleRate: EnergyLogs.SampleRate3Hours range: 43200 // 30 Days: 30 * 24 * 60 } } onTabSelected: { d.now = new Date() powerBalanceLogs.fetchLogs() d.update() } } Item { Layout.fillWidth: true Layout.fillHeight: true Label { x: chartView.x + chartView.plotArea.x + (chartView.plotArea.width - width) / 2 y: chartView.y + chartView.plotArea.y + Style.smallMargins text: { switch (d.sampleRate) { case EnergyLogs.SampleRate1Min: return d.startTime.toLocaleDateString(Qt.locale(), Locale.LongFormat) case EnergyLogs.SampleRate15Mins: case EnergyLogs.SampleRate1Hour: case EnergyLogs.SampleRate3Hours: case EnergyLogs.SampleRate1Day: case EnergyLogs.SampleRate1Week: case EnergyLogs.SampleRate1Month: case EnergyLogs.SampleRate1Year: return d.startTime.toLocaleDateString(Qt.locale(), Locale.ShortFormat) + " - " + d.endTime.toLocaleDateString(Qt.locale(), Locale.ShortFormat) } } font: Style.smallFont opacity: ((new Date().getTime() - d.now.getTime()) / d.sampleRate / 60000) > d.visibleValues ? .5 : 0 Behavior on opacity { NumberAnimation {} } } ChartView { id: chartView anchors.fill: parent backgroundColor: "transparent" margins.left: Math.max(Style.smallMargins * 2, valueLabelMetrics.width + Style.smallMargins * 2) margins.right: 0 margins.top: 0 margins.bottom: Style.smallIconSize + Style.margins legend.visible: false legend.alignment: Qt.AlignBottom legend.font: Style.extraSmallFont legend.labelColor: Style.foregroundColor ActivityIndicator { x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2 y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8) visible: powerBalanceLogs.fetchingData || logsLoader.fetchingData opacity: .5 } Label { x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2 y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8) text: qsTr("No data available") visible: !powerBalanceLogs.fetchingData && !logsLoader.fetchingData && (powerBalanceLogs.count == 0 || powerBalanceLogs.get(0).timestamp > d.now) font: Style.smallFont opacity: .5 } TextMetrics { id: valueLabelMetrics font: Style.extraSmallFont text: ((valueAxis.max) / 1000).toFixed(2) + "kW" } ValueAxis { id: valueAxis min: 0 max: 1 labelFormat: "" gridLineColor: Style.tileOverlayColor labelsVisible: false lineVisible: false titleVisible: false shadesVisible: false function adjustMax(value) { max = Math.max(max, Math.ceil(value / 100) * 100) } } Item { id: labelsLayout x: Style.smallMargins y: chartView.plotArea.y height: chartView.plotArea.height width: Math.max(0, chartView.margins.left - Style.smallMargins) Repeater { model: valueAxis.tickCount delegate: Label { y: parent.height / (valueAxis.tickCount - 1) * index - font.pixelSize / 2 width: parent.width - Style.smallMargins horizontalAlignment: Text.AlignRight text: ((valueAxis.max - (index * valueAxis.max / (valueAxis.tickCount - 1))) / 1000).toFixed(2) + "kW" verticalAlignment: Text.AlignTop font: Style.extraSmallFont } } } DateTimeAxis { id: dateTimeAxis min: d.startTime max: d.endTime format: { switch (selectionTabs.currentValue.sampleRate) { case EnergyLogs.SampleRate1Min: case EnergyLogs.SampleRate15Mins: return "hh:mm" case EnergyLogs.SampleRate1Hour: case EnergyLogs.SampleRate3Hours: case EnergyLogs.SampleRate1Day: return "dd.MM." } } tickCount: { switch (selectionTabs.currentValue.sampleRate) { case EnergyLogs.SampleRate1Min: case EnergyLogs.SampleRate15Mins: return root.width > 500 ? 13 : 7 case EnergyLogs.SampleRate1Hour: return 7 case EnergyLogs.SampleRate3Hours: case EnergyLogs.SampleRate1Day: return root.width > 500 ? 12 : 6 } } labelsFont: Style.extraSmallFont gridVisible: false minorGridVisible: false lineVisible: false shadesVisible: false labelsColor: Style.foregroundColor } AreaSeries { id: consumptionSeries axisX: dateTimeAxis axisY: valueAxis color: Style.gray borderWidth: 0 borderColor: color name: qsTr("Unknown") // visible: false opacity: .2 lowerSeries: LineSeries { id: zeroSeries XYPoint { x: dateTimeAxis.min.getTime(); y: 0 } XYPoint { x: dateTimeAxis.max.getTime(); y: 0 } function ensureValue(timestamp) { if (count == 0) { append(timestamp, 0) } else if (count == 1) { if (timestamp.getTime() < at(0).x) { insert(0, timestamp, 0) } else { append(timestamp, 0) } } else { if (timestamp.getTime() < at(0).x) { remove(0) insert(0, timestamp, 0) } else if (timestamp.getTime() > at(1).x) { remove(1) append(timestamp, 0) } } } function shrink() { clear(); if (powerBalanceLogs.count > 0) { ensureValue(powerBalanceLogs.get(0).timestamp) ensureValue(powerBalanceLogs.get(powerBalanceLogs.count - 1).timestamp) } } } upperSeries: LineSeries { id: consumptionUpperSeries } function addEntry(entry) { consumptionUpperSeries.append(entry.timestamp.getTime(), entry.consumption) } function insertEntry(index, entry) { consumptionUpperSeries.insert(index, entry.timestamp.getTime(), entry.consumption) } } Repeater { id: consumersRepeater model: consumers.count Component.onCompleted: { if (count != 0) { d.update() } } onCountChanged: { if (count == consumers.count) { d.update(); } } delegate: Item { id: consumerDelegate readonly property Thing thing: consumers.get(index) property AreaSeries series: null property QHash valueCache: QHash {} property LineSeries lowerSeries: null property LineSeries upperSeries: null function calculateBaseValue(timestamp) { if (index > 0) { return consumersRepeater.itemAt(index - 1).calculateValue(timestamp) } return 0 } function calculateValue(timestamp) { var cached = valueCache.value(timestamp) // print("ached:", cached) if (cached !== undefined) { return cached//valueCache.value(timestamp) } var ret = calculateBaseValue(timestamp) var entry = logs.find(timestamp) if (entry) { ret += entry.currentPower; } valueCache.insert(timestamp, ret); // print("calculating value for", thing.name, timestamp, ret) return ret } function insertEntry(idx, entry) { // print("inserting entry for", thing.name, entry.timestamp) var baseValue = calculateBaseValue(entry.timestamp); lowerSeries.insert(idx, entry.timestamp.getTime(), baseValue) upperSeries.insert(idx, entry.timestamp.getTime(), baseValue + entry.currentPower) } function addEntries(index, count) { // print("adding entries for", thing.name) series.lowerSeries = null; series.upperSeries = null; var oldestTimestamp = null var newestTimestamp = null // Remove the leading 0-value entry if (lowerSeries.count > 0) { lowerSeries.removePoints(0, 1); } if (upperSeries.count > 0) { upperSeries.removePoints(0, 1); } for (var i = 0; i < count; i++) { var entry = logs.get(index + i) // print("got thing entry", thing.name, entry.timestamp, entry.currentPower, index + i) // zeroSeries.ensureValue(entry.timestamp) if (oldestTimestamp == null || entry.timestamp < oldestTimestamp) { oldestTimestamp = entry.timestamp; } if (newestTimestamp == null || entry.timestamp > newestTimestamp) { newestTimestamp = entry.timestamp; } valueAxis.adjustMax(entry.currentPower) insertEntry(index + i, entry) if (entry.timestamp > d.now && new Date().getTime() - d.now.getTime() < 120000) { d.now = entry.timestamp } } zeroSeries.ensureValue(oldestTimestamp) zeroSeries.ensureValue(newestTimestamp) // Add the leading 0-value entry back lowerSeries.insert(0, upperSeries.at(0).x, 0) upperSeries.insert(0, upperSeries.at(0).x, 0) series.upperSeries = upperSeries; series.lowerSeries = lowerSeries; } readonly property ThingPowerLogs logs: ThingPowerLogs { engine: _engine startTime: new Date(d.startTime.getTime() - d.range * 60000) endTime: new Date(d.endTime.getTime() + d.range * 60000) sampleRate: d.sampleRate thingId: consumerDelegate.thing.id loader: logsLoader onEntriesAddedIdx: (index, count) => { addTimer.addEntries(index, count) } onEntriesRemoved: (index, count) => { // Note QtCharts crash when calling removePoints() for points that don't exist. // Additionally it may decide to ignore values we add, e.g. if we try to add an Inf or undefined value for whatever reason // So, even though in theory the series should always 1:1 reflect the model, it may not do so in practice and we'll have to make sure not crash here // Remove the leading 0-value entry if (consumerDelegate.lowerSeries.count > 0) { consumerDelegate.lowerSeries.removePoints(0, 1); } if (consumerDelegate.upperSeries.count > 0) { consumerDelegate.upperSeries.removePoints(0, 1); } print("removing:", index, count, "from", consumerDelegate.lowerSeries.count, consumerDelegate.upperSeries.count) if (consumerDelegate.lowerSeries.count >= index + count) { consumerDelegate.lowerSeries.removePoints(index, count) } if (consumerDelegate.upperSeries.count >= index + count) { consumerDelegate.upperSeries.removePoints(index, count) } // Add the leading 0-value entry back consumerDelegate.lowerSeries.insert(0, consumerDelegate.series.upperSeries.at(0).x, 0) consumerDelegate.upperSeries.insert(0, consumerDelegate.series.upperSeries.at(0).x, 0) zeroSeries.shrink() } } // We'll have to make sure all the consumers have their data ready before adding Timer { id: addTimer interval: 1000 repeat: false onTriggered: consumerDelegate.addEntries(index, count) property int index property var count function addEntries(index, count) { addTimer.index = index addTimer.count = count start() } } Component.onCompleted: { series = chartView.createSeries(ChartView.SeriesTypeArea, thing.name, dateTimeAxis, valueAxis) lowerSeries = lineSeriesComponent.createObject(series) upperSeries = lineSeriesComponent.createObject(series) series.color = NymeaUtils.generateColor(Style.generationBaseColor, index) series.opacity = Qt.binding(function() { return d.selectedSeries == null || d.selectedSeries == series ? 1 : 0.3 }) series.borderWidth = 0; series.borderColor = series.color // Add a first point at 0 value lowerSeries.insert(0, new Date().getTime(), 0) upperSeries.insert(0, new Date().getTime(), 0) } Component.onDestruction: { chartView.removeSeries(series) } } } } RowLayout { id: legend anchors { left: parent.left; bottom: parent.bottom; right: parent.right } anchors.leftMargin: chartView.plotArea.x height: Style.smallIconSize anchors.margins: Style.margins Repeater { model: root.consumers delegate: Item { id: legendDelegate Layout.fillWidth: true Layout.fillHeight: true readonly property Thing thing: root.consumers.get(index) opacity: d.selectedSeries == null || d.selectedSeries === consumersRepeater.itemAt(index).series ? 1 : 0.3 MouseArea { anchors.fill: parent anchors.topMargin: -Style.smallMargins anchors.bottomMargin: -Style.smallMargins onClicked: d.selectSeries(consumersRepeater.itemAt(index).series) } Row { anchors.centerIn: parent spacing: Style.smallMargins ColorIcon { name: app.interfacesToIcon(legendDelegate.thing.thingClass.interfaces) size: Style.smallIconSize color: index >= 0 ? NymeaUtils.generateColor(Style.generationBaseColor, index) : "white" } Label { text: legendDelegate.thing.name width: Math.max(0, legendDelegate.width - x) anchors.verticalCenter: parent.verticalCenter elide: Text.ElideRight font: Style.smallFont visible: legend.width / root.consumers.count >= 80 } } } } } MouseArea { id: mouseArea anchors.fill: parent anchors.leftMargin: chartView.plotArea.x anchors.topMargin: chartView.plotArea.y anchors.rightMargin: chartView.width - chartView.plotArea.width - chartView.plotArea.x anchors.bottomMargin: chartView.height - chartView.plotArea.height - chartView.plotArea.y hoverEnabled: true preventStealing: tooltipping || dragging property int startMouseX: 0 property bool dragging: false property bool tooltipping: false property var startDatetime: null Timer { interval: 300 running: mouseArea.pressed onTriggered: { if (!mouseArea.dragging) { mouseArea.tooltipping = true } } } onReleased: { mouseArea.tooltipping = false; if (mouseArea.dragging) { mouseArea.dragging = false; for (var i = 0; i < consumersRepeater.count; i++) { if (consumersRepeater.itemAt(i).logs.fetchingData) { wheelStopTimer.start() return; } } powerBalanceLogs.fetchLogs() logsLoader.fetchLogs() // for (var i = 0; i < consumersRepeater.count; i++) { // consumersRepeater.itemAt(i).logs.fetchLogs() // } } } onPressed: { startMouseX = mouseX startDatetime = d.now } onDoubleClicked: { if (selectionTabs.currentIndex == 0) { return; } var idx = Math.ceil(mouseArea.mouseX * d.visibleValues / mouseArea.width) var timestamp = new Date(d.startTime.getTime() + (idx * d.sampleRate * 60000)) selectionTabs.currentIndex-- d.now = new Date(Math.min(new Date().getTime(), timestamp.getTime() + (d.visibleValues / 2) * d.sampleRate * 60000)) powerBalanceLogs.fetchLogs() logsLoader.fetchLogs() } onMouseXChanged: { if (!pressed || mouseArea.tooltipping) { return; } if (Math.abs(startMouseX - mouseX) < 10) { return; } dragging = true var dragDelta = startMouseX - mouseX var totalTime = d.endTime.getTime() - d.startTime.getTime() // dragDelta : timeDelta = width : totalTime var timeDelta = dragDelta * totalTime / mouseArea.width print("dragging", dragDelta, totalTime, mouseArea.width) d.now = new Date(Math.min(new Date(), new Date(startDatetime.getTime() + timeDelta))) } onWheel: (wheel) => { startDatetime = d.now var totalTime = d.endTime.getTime() - d.startTime.getTime() // pixelDelta : timeDelta = width : totalTime var timeDelta = wheel.pixelDelta.x * totalTime / mouseArea.width print("wheeling", wheel.pixelDelta.x, totalTime, mouseArea.width) d.now = new Date(Math.min(new Date(), new Date(startDatetime.getTime() - timeDelta))) wheelStopTimer.restart() } Timer { id: wheelStopTimer interval: 300 repeat: false onTriggered: { for (var i = 0; i < consumersRepeater.count; i++) { if (consumersRepeater.itemAt(i).logs.fetchingData) { wheelStopTimer.start() return; } } powerBalanceLogs.fetchLogs() logsLoader.fetchLogs() // for (var i = 0; i < consumersRepeater.count; i++) { // consumersRepeater.itemAt(i).logs.fetchLogs() // } } } Rectangle { height: parent.height width: 1 color: Style.foregroundColor x: Math.min(mouseArea.width - 1, Math.max(0, mouseArea.mouseX)) visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging } NymeaToolTip { id: toolTip visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging backgroundItem: chartView backgroundRect: Qt.rect(mouseArea.x + toolTip.x, mouseArea.y + toolTip.y, toolTip.width, toolTip.height) property int idx: Math.min(d.visibleValues, Math.max(0, Math.round(mouseArea.mouseX * d.visibleValues / mouseArea.width))) property var timestamp: new Date(Math.min(d.endTime.getTime(), Math.max(d.startTime, d.startTime.getTime() + (idx * d.sampleRate * 60000)))) property PowerBalanceLogEntry entry: powerBalanceLogs.find(timestamp) property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins property int xOnLeft: Math.min(mouseArea.width, mouseArea.mouseX) - Style.smallMargins - width x: xOnRight + width < mouseArea.width ? xOnRight : xOnLeft property double maxValue: toolTip.entry ? Math.max(0, entry.consumption) : 0 y: Math.min(Math.max(mouseArea.height - (maxValue * mouseArea.height / valueAxis.max) - height - Style.margins, 0), mouseArea.height - height) width: tooltipLayout.implicitWidth + Style.smallMargins * 2 height: tooltipLayout.implicitHeight + Style.smallMargins * 2 ColumnLayout { id: tooltipLayout anchors { left: parent.left top: parent.top margins: Style.smallMargins } Label { text: toolTip.entry ? toolTip.entry.timestamp.toLocaleString(Qt.locale(), Locale.ShortFormat) : 0 font: Style.smallFont } RowLayout { Rectangle { width: Style.extraSmallFont.pixelSize height: width color: consumptionSeries.color } Label { property double rawValue: toolTip.entry ? toolTip.entry.consumption : 0 property double displayValue: rawValue >= 1000 ? rawValue / 1000 : rawValue property string unit: rawValue >= 1000 ? "kW" : "W" text: "%1: %2 %3".arg(qsTr("Total")).arg(displayValue.toFixed(2)).arg(unit) font: Style.extraSmallFont } } Repeater { model: consumersRepeater.count delegate: RowLayout { readonly property Item chartItem: consumersRepeater.itemAt(index) id: consumerToolTipDelegate opacity: d.selectedSeries == null || d.selectedSeries === chartItem.series ? 1 : 0.3 Rectangle { width: Style.extraSmallFont.pixelSize height: width color: index >= 0 ? NymeaUtils.generateColor(Style.generationBaseColor, index) : "white" } Label { property ThingPowerLogEntry entry: toolTip.idx >= 0 ? chartItem.logs.find(toolTip.timestamp) : null property double rawValue: entry ? entry.currentPower : 0 property double displayValue: rawValue >= 1000 ? rawValue / 1000 : rawValue property string unit: rawValue >= 1000 ? "kW" : "W" text: "%1: %2 %3".arg(chartItem.thing.name).arg(displayValue.toFixed(2)).arg(unit) font: Style.extraSmallFont } } } } } } } } }