This repository has been archived on 2026-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
Simon Stürz 8895a853a7 Update some signal handlers to JavaScript functions with formal parameters
Update translations
Bump build revision
2025-12-05 13:58:19 +01:00

581 lines
24 KiB
QML

// 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 <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
import QtQuick.Layouts
import QtCharts
import Nymea
import NymeaApp.Utils
import "../components"
import "../customviews"
Item {
id: root
implicitHeight: width * .6
implicitWidth: 400
property Thing thing: null
property StateType stateType: null
property int roundTo: 2
property color color: Style.accentColor
property string iconSource: ""
property alias title: titleLabel.text
property bool titleVisible: true
property bool inverted: false
readonly property State valueState: thing && stateType ? thing.states.getState(stateType.id) : null
readonly property StateType connectedStateType: hasConnectable ? thing.thingClass.stateTypes.findByName("connected") : null
readonly property bool hasConnectable: connectedStateType != null
QtObject {
id: d
property date now: new Date()
readonly property int range: selectionTabs.currentValue.range
readonly property int sampleRate: root.stateType == null || root.stateType.type.toLowerCase() == "bool" ? NewLogsModel.SampleRateAny : 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) {
return timestamp
}
}
NewLogsModel {
id: logsModel
engine: _engine
source: root.thing ? "state-" + thing.id + "-" + root.stateType.name : ""
startTime: new Date(d.startTime.getTime() - d.range * 1.1 * 60000)
endTime: new Date(d.endTime.getTime() + d.range * 1.1 * 60000)
sampleRate: d.sampleRate
sortOrder: Qt.AscendingOrder
Component.onCompleted: {
// print("****** completed")
ready = true
update()
}
property bool ready: false
onSourceChanged: {
// print("***** source changed")
update()
}
function update() {
// print("*********+ source", source, "start", startTime, "end", endTime, ready)
if (ready && source != "") {
fetchLogs()
}
}
property double minValue
property double maxValue
onEntriesAddedIdx: (index, count) => {
// print("**** entries added", index, count, "entries in series:", valueSeries.count, "in model", logsModel.count)
for (var i = 0; i < count; i++) {
var entry = logsModel.get(i)
// print("entry", entry.timestamp, entry.source, JSON.stringify(entry.values))
zeroSeries.ensureValue(entry.timestamp)
if (root.stateType.type.toLowerCase() == "bool") {
var value = entry.values[root.stateType.name]
if (value == null) {
value = false;
}
value *= root.inverted ? -1 : 1
var previousEntry = i > 0 ? logsModel.get(i-1) : null;
var previousValue = previousEntry ? previousEntry.values[root.stateType.name] : false
if (previousValue == null) {
previousValue = false
}
// for booleans, we'll insert the previous value right before the new one so the position is doubled
var insertIdx = (index + i) * 2
// print("inserting bool 1", insertIdx, entry.timestamp.getTime() - 500, !value, new Date(entry.timestamp.getTime() - 500))
valueSeries.insert(insertIdx, entry.timestamp.getTime() - 500, previousValue)
// print("inserting bool 2", insertIdx + 1, entry.timestamp.getTime(), value, entry.timestamp)
valueSeries.insert(insertIdx+1, entry.timestamp, value)
} else {
var value = entry.values[root.stateType.name]
if (value == null) {
value = 0;
}
value *= root.inverted ? -1 : 1
minValue = minValue == undefined ? value : Math.min(minValue, value)
maxValue = maxValue == undefined ? value : Math.max(maxValue, value)
var insertIdx = index + i
valueSeries.insert(insertIdx, entry.timestamp, value)
}
}
if (root.stateType.type.toLowerCase() == "bool") {
var last = valueSeries.at(valueSeries.count-1);
if (last.x < d.endTime) {
valueSeries.append(d.endTime, last.y)
zeroSeries.ensureValue(d.endTime)
}
}
print("added entries. now in series:", valueSeries.count)
}
onEntriesRemoved: (index, count) => {
print("removing:", index, count, valueSeries.count)
if (root.stateType.type.toLowerCase() == "bool") {
valueSeries.removePoints(index * 2, count * 2)
if (valueSeries.count == 1) {
valueSeries.removePoints(0, 1);
}
} else {
valueSeries.removePoints(index, count)
}
zeroSeries.shrink()
}
}
ColumnLayout {
anchors.fill: parent
spacing: 0
Label {
id: titleLabel
Layout.fillWidth: true
Layout.margins: Style.smallMargins
horizontalAlignment: Text.AlignHCenter
text: root.stateType.displayName
visible: root.titleVisible
elide: Text.ElideMiddle
// MouseArea {
// anchors.fill: parent
// onClicked: {
// pageStack.push(Qt.resolvedUrl("PowerBalanceHistoryPage.qml"))
// }
// }
}
SelectionTabs {
id: selectionTabs
Layout.fillWidth: true
Layout.leftMargin: Style.smallMargins
Layout.rightMargin: Style.smallMargins
currentIndex: 1
model: ListModel {
ListElement {
modelData: qsTr("Hours")
sampleRate: NewLogsModel.SampleRate1Min
range: 180 // 3 Hours: 3 * 60
}
ListElement {
modelData: qsTr("Days")
sampleRate: NewLogsModel.SampleRate15Mins
range: 1440 // 1 Day: 24 * 60
}
ListElement {
modelData: qsTr("Weeks")
sampleRate: NewLogsModel.SampleRate1Hour
range: 10080 // 7 Days: 7 * 24 * 60
}
ListElement {
modelData: qsTr("Months")
sampleRate: NewLogsModel.SampleRate3Hours
range: 43200 // 30 Days: 30 * 24 * 60
}
}
onTabSelected: {
d.now = new Date()
logsModel.clear()
print("*** tab selected")
logsModel.fetchLogs()
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ChartView {
id: chartView
anchors.fill: parent
// backgroundColor: "transparent"
margins.left: 0
margins.right: 0
margins.bottom: Style.smallMargins //Style.smallIconSize + Style.margins
margins.top: 0
backgroundColor: Style.tileBackgroundColor
backgroundRoundness: Style.cornerRadius
legend.alignment: Qt.AlignBottom
legend.labelColor: Style.foregroundColor
legend.font: Style.extraSmallFont
legend.visible: false
ActivityIndicator {
anchors.centerIn: parent
visible: logsModel.busy
opacity: .5
}
Label {
anchors.centerIn: parent
visible: !logsModel.busy && logsModel.count == 0
text: qsTr("No data")
font: Style.smallFont
opacity: .5
}
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 NewLogsModel.SampleRate1Min:
return d.startTime.toLocaleDateString(Qt.locale(), Locale.LongFormat)
case NewLogsModel.SampleRate15Mins:
case NewLogsModel.SampleRate1Hour:
case NewLogsModel.SampleRate3Hours:
case NewLogsModel.SampleRate1Day:
case NewLogsModel.SampleRate1Week:
case NewLogsModel.SampleRate1Month:
case NewLogsModel.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 {} }
}
ValueAxis {
id: valueAxis
min: logsModel.minValue == undefined || logsModel.minValue == 0
? 0
: root.stateType.minValue ? Math.max(logsModel.minValue - 5, root.stateType.minValue) : logsModel.minValue - 5
max: logsModel.maxValue == undefined || logsModel.maxValue == 0
? 0
: root.stateType.maxValue ? Math.min(logsModel.maxValue + 5, root.stateType.maxValue) : logsModel.maxValue + 5
labelFormat: "%0." + labelsLayout.precision + "f " + Types.toUiUnit(root.stateType.unit)
gridLineColor: Style.tileOverlayColor
labelsVisible: false
lineVisible: false
titleVisible: false
shadesVisible: false
labelsFont: Style.extraSmallFont
labelsColor: Style.foregroundColor
}
// Overriding the labels with our own as printf struggles with special chars
Item {
id: labelsLayout
x: Style.smallMargins
y: chartView.plotArea.y
height: chartView.plotArea.height
width: chartView.plotArea.x - x
visible: root.stateType.type.toLowerCase() != "bool" && logsModel.minValue != logsModel.maxValue
property double range: Math.abs(valueAxis.max - valueAxis.min)
property double stepSize: range / (valueAxis.tickCount - 1)
property int precision: valueAxis.max - valueAxis.min < 5 ? 2 : 0
Repeater {
model: valueAxis.tickCount
delegate: Label {
y: parent.height / (valueAxis.tickCount - 1) * index - font.pixelSize / 2
width: parent.width - Style.smallMargins
horizontalAlignment: Text.AlignRight
property double offset: (valueAxis.tickCount - index - 1) * labelsLayout.stepSize
property double value: valueAxis.min + offset
text: root.stateType ? Types.toUiValue(value, root.stateType.unit).toFixed(labelsLayout.precision) + " " + Types.toUiUnit(root.stateType.unit) : ""
verticalAlignment: Text.AlignTop
font: Style.extraSmallFont
}
}
}
DateTimeAxis {
id: dateTimeAxis
min: d.startTime
max: d.endTime
format: {
switch (selectionTabs.currentValue.sampleRate) {
case NewLogsModel.SampleRate1Min:
case NewLogsModel.SampleRate15Mins:
return "hh:mm"
case NewLogsModel.SampleRate1Hour:
case NewLogsModel.SampleRate3Hours:
case NewLogsModel.SampleRate1Day:
return "dd.MM."
}
}
tickCount: {
switch (selectionTabs.currentValue.sampleRate) {
case NewLogsModel.SampleRate1Min:
case NewLogsModel.SampleRate15Mins:
return root.width > 500 ? 13 : 7
case NewLogsModel.SampleRate1Hour:
return 7
case NewLogsModel.SampleRate3Hours:
case NewLogsModel.SampleRate1Day:
return root.width > 500 ? 12 : 6
}
}
labelsFont: Style.extraSmallFont
gridVisible: false
minorGridVisible: false
lineVisible: false
shadesVisible: false
labelsColor: Style.foregroundColor
}
AreaSeries {
id: mainSeries
axisX: dateTimeAxis
axisY: valueAxis
name: root.stateType ? root.stateType.displayName : ""
color: Qt.rgba(root.color.r, root.color.g, root.color.b, .5)
borderColor: root.color
borderWidth: 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(1)
append(timestamp, 0)
} else if (timestamp.getTime() < at(1).x) {
remove(0)
insert(0, timestamp, 0)
}
}
}
function shrink() {
clear();
if (logsModel.count > 0) {
ensureValue(logsModel.get(0).timestamp)
ensureValue(logsModel.get(logsModel.count-1).timestamp)
}
}
}
upperSeries: LineSeries {
id: valueSeries
}
}
}
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
propagateComposedEvents: true
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: {
if (mouseArea.dragging) {
logsModel.fetchLogs()
mouseArea.dragging = false;
}
mouseArea.tooltipping = false;
}
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()
}
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: logsModel.fetchLogs()
}
Rectangle {
height: parent.height
width: 1
color: Style.foregroundColor
x: Math.min(mouseArea.width, Math.max(0, toolTip.entryX))
visible: toolTip.visible
}
NymeaToolTip {
id: toolTip
visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging && logsModel.count > 0
backgroundItem: chartView
backgroundRect: Qt.rect(mouseArea.x + toolTip.x, mouseArea.y + toolTip.y, toolTip.width, toolTip.height)
property var timestamp: new Date(d.startTime.getTime() + (mouseArea.mouseX * (d.endTime.getTime() - d.startTime.getTime()) / mouseArea.width) )
property NewLogEntry entry: logsModel.count > 0 ? logsModel.find(timestamp) : null
// eX : eT = w : duration
property int entryX: entry ? (entry.timestamp.getTime() - d.startTime.getTime()) * mouseArea.width / (d.endTime.getTime() - d.startTime.getTime()) : 0
property int xOnRight: Math.max(0, entryX) + Style.smallMargins
property int xOnLeft: Math.min(entryX, mouseArea.width) - Style.smallMargins - width
x: xOnRight + width < mouseArea.width ? xOnRight : xOnLeft
property var value: entry ? entry.values[root.stateType.name] * (root.inverted ? -1 : 1) : null
y: Math.min(Math.max(mouseArea.height - (value * 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
width: parent.width
anchors {
left: parent.left
top: parent.top
margins: Style.smallMargins
}
Label {
text: toolTip.entry ? toolTip.entry.timestamp.toLocaleString(Qt.locale(), Locale.ShortFormat) : ""
font: Style.smallFont
}
RowLayout {
ColorIcon {
id: icon
size: Style.smallIconSize
name: root.iconSource
color: root.color
visible: name != ""
}
Rectangle {
width: Style.extraSmallFont.pixelSize
height: width
color: root.color
visible: !icon.visible
}
Label {
Layout.fillWidth: true
elide: Text.ElideRight
text: toolTip.value === null
? qsTr("No data")
: root.stateType.type.toLowerCase() == "bool"
? root.stateType.displayName + ": " + (toolTip.value ? qsTr("Yes") : qsTr("No"))
: Types.toUiValue(toolTip.value, root.stateType.unit).toFixed(root.roundTo) + Types.toUiUnit(root.stateType.unit)
font: Style.extraSmallFont
}
}
}
}
}
}
}
}