diff --git a/libnymea-app/models/newlogsmodel.cpp b/libnymea-app/models/newlogsmodel.cpp
index 5595c50e..d381e5e4 100644
--- a/libnymea-app/models/newlogsmodel.cpp
+++ b/libnymea-app/models/newlogsmodel.cpp
@@ -429,8 +429,8 @@ void NewLogsModel::logsReply(int commandId, const QVariantMap &data)
beginInsertRows(QModelIndex(), 0, entries.count() - 1);
m_list = entries;
endInsertRows();
- emit entriesAdded(0, entries);
}
+ emit entriesAdded(0, entries);
emit countChanged();
} else {
@@ -441,9 +441,9 @@ void NewLogsModel::logsReply(int commandId, const QVariantMap &data)
});
m_list.append(entries);
endInsertRows();
- emit entriesAdded(m_list.count() - entries.count(), entries);
- emit countChanged();
}
+ emit entriesAdded(m_list.count() - entries.count(), entries);
+ emit countChanged();
}
}
diff --git a/libnymea-app/types/thing.h b/libnymea-app/types/thing.h
index aa347c7f..33fd2344 100644
--- a/libnymea-app/types/thing.h
+++ b/libnymea-app/types/thing.h
@@ -144,7 +144,7 @@ public:
Q_INVOKABLE Param *param(const QUuid ¶mTypeId) const;
Q_INVOKABLE Param *paramByName(const QString ¶mName) const;
- Q_INVOKABLE virtual int executeAction(const QString &actionName, const QVariantList ¶ms);
+ Q_INVOKABLE virtual int executeAction(const QString &actionName, const QVariantList ¶ms = QVariantList());
signals:
void nameChanged();
diff --git a/nymea-app/resources.qrc b/nymea-app/resources.qrc
index c4ae21e3..3667d88c 100644
--- a/nymea-app/resources.qrc
+++ b/nymea-app/resources.qrc
@@ -316,5 +316,7 @@
ui/devicepages/EventLogPage.qml
ui/mainviews/dashboard/DashboardStateDelegate.qml
ui/mainviews/dashboard/DashboardSensorDelegate.qml
+ ui/devicepages/ThingStatusPage.qml
+ ui/customviews/MultiStateChart.qml
diff --git a/nymea-app/ui/components/BigThingTile.qml b/nymea-app/ui/components/BigThingTile.qml
index 93c3378a..491d81db 100644
--- a/nymea-app/ui/components/BigThingTile.qml
+++ b/nymea-app/ui/components/BigThingTile.qml
@@ -8,7 +8,7 @@ BigTile {
property Thing thing: null
- readonly property State connectedState: thing.thingClass.interfaces.indexOf("connectable") >= 0 && thing.stateByName("connected")
+ readonly property State connectedState: thing.thingClass.interfaces.indexOf("connectable") >= 0 ? thing.stateByName("connected") : null
readonly property bool isConnected: connectedState === null || connectedState.value === true
readonly property bool isEnabled: thing.setupStatus == Thing.ThingSetupStatusComplete && isConnected
diff --git a/nymea-app/ui/components/ThingStatusIcons.qml b/nymea-app/ui/components/ThingStatusIcons.qml
index 08421ce0..3ebb3a78 100644
--- a/nymea-app/ui/components/ThingStatusIcons.qml
+++ b/nymea-app/ui/components/ThingStatusIcons.qml
@@ -12,11 +12,6 @@ RowLayout {
property color color: Style.iconColor
- signal updateIconClicked();
- signal batteryIconClicked();
- signal connectionIconClicked();
- signal setupIconClicked();
-
UpdateStatusIcon {
id: updateStatusIcon
Layout.preferredHeight: Style.smallIconSize
@@ -28,30 +23,34 @@ RowLayout {
anchors.fill: parent
anchors.margins: -app.margins / 4
onClicked: {
- var dialogComponent = Qt.createComponent("NymeaDialog.qml")
- var currentVersionState = root.thing.stateByName("currentVersion")
- var availableVersionState = root.thing.stateByName("availableVersion")
- var text = qsTr("An update for %1 is available. Do you want to start the update now?").arg(root.thing.name)
- if (currentVersionState) {
- text += "\n\n" + qsTr("Current version: %1").arg(currentVersionState.value)
- }
- if (availableVersionState) {
- text += "\n\n" + qsTr("Available version: %1").arg(availableVersionState.value)
+ if (engine.jsonRpcClient.ensureServerVersion("8.0")) {
+ pageStack.push("../devicepages/ThingStatusPage.qml", {thing: root.thing})
+ } else {
+ var dialogComponent = Qt.createComponent("NymeaDialog.qml")
+ var currentVersionState = root.thing.stateByName("currentVersion")
+ var availableVersionState = root.thing.stateByName("availableVersion")
+ var text = qsTr("An update for %1 is available. Do you want to start the update now?").arg(root.thing.name)
+ if (currentVersionState) {
+ text += "\n\n" + qsTr("Current version: %1").arg(currentVersionState.value)
+ }
+ if (availableVersionState) {
+ text += "\n\n" + qsTr("Available version: %1").arg(availableVersionState.value)
+ }
+
+ var dialog = dialogComponent.createObject(app,
+ {
+ headerIcon: "../images/system-update.svg",
+ title: qsTr("Update"),
+ text: text,
+ standardButtons: Dialog.Ok | Dialog.Cancel
+ })
+ dialog.accepted.connect(function() {
+ print("starting update")
+ engine.thingManager.executeAction(root.thing.id, root.thing.thingClass.actionTypes.findByName("performUpdate").id)
+ })
+ dialog.open();
}
- var dialog = dialogComponent.createObject(app,
- {
- headerIcon: "../images/system-update.svg",
- title: qsTr("Update"),
- text: text,
- standardButtons: Dialog.Ok | Dialog.Cancel
- })
- dialog.accepted.connect(function() {
- print("starting update")
- engine.thingManager.executeAction(root.thing.id, root.thing.thingClass.actionTypes.findByName("performUpdate").id)
- })
- dialog.open();
- root.updateIconClicked()
}
}
}
@@ -66,21 +65,24 @@ RowLayout {
anchors.fill: parent
anchors.margins: -app.margins / 4
onClicked: {
- root.batteryIconClicked()
- var levelStateType = root.thing.thingClass.stateTypes.findByName("batteryLevel");
- var criticalStateType = root.thing.thingClass.stateTypes.findByName("batteryCritical");
- var stateTypes = []
- if (levelStateType) {
- stateTypes.push(levelStateType.id)
+ if (engine.jsonRpcClient.ensureServerVersion("8.0")) {
+ pageStack.push("../devicepages/ThingStatusPage.qml", {thing: root.thing})
+ } else {
+ var levelStateType = root.thing.thingClass.stateTypes.findByName("batteryLevel");
+ var criticalStateType = root.thing.thingClass.stateTypes.findByName("batteryCritical");
+ var stateTypes = []
+ if (levelStateType) {
+ stateTypes.push(levelStateType.id)
+ }
+ if (criticalStateType) {
+ stateTypes.push(criticalStateType.id)
+ }
+ pageStack.push("../devicepages/DeviceLogPage.qml",
+ {
+ thing: root.thing,
+ filterTypeIds: stateTypes
+ });
}
- if (criticalStateType) {
- stateTypes.push(criticalStateType.id)
- }
- pageStack.push("../devicepages/DeviceLogPage.qml",
- {
- thing: root.thing,
- filterTypeIds: stateTypes
- });
}
}
}
@@ -95,21 +97,24 @@ RowLayout {
anchors.fill: parent
anchors.margins: -app.margins / 4
onClicked: {
- root.connectionIconClicked()
- var signalStateType = root.thing.thingClass.stateTypes.findByName("signalStrength")
- var connectedStateType = root.thing.thingClass.stateTypes.findByName("connected")
- var stateTypes = []
- if (signalStateType) {
- stateTypes.push(signalStateType.id)
+ if (engine.jsonRpcClient.ensureServerVersion("8.0")) {
+ pageStack.push("../devicepages/ThingStatusPage.qml", {thing: root.thing})
+ } else {
+ var signalStateType = root.thing.thingClass.stateTypes.findByName("signalStrength")
+ var connectedStateType = root.thing.thingClass.stateTypes.findByName("connected")
+ var stateTypes = []
+ if (signalStateType) {
+ stateTypes.push(signalStateType.id)
+ }
+ if (connectedStateType) {
+ stateTypes.push(connectedStateType.id)
+ }
+ pageStack.push("../devicepages/DeviceLogPage.qml",
+ {
+ thing: root.thing,
+ filterTypeIds: stateTypes
+ });
}
- if (connectedStateType) {
- stateTypes.push(connectedStateType.id)
- }
- pageStack.push("../devicepages/DeviceLogPage.qml",
- {
- thing: root.thing,
- filterTypeIds: stateTypes
- });
}
}
}
@@ -124,7 +129,6 @@ RowLayout {
anchors.fill: parent
anchors.margins: -app.margins / 4
onClicked: {
- root.setupIconClicked()
pageStack.push("../thingconfiguration/ConfigureThingPage.qml", { thing: root.thing });
}
}
diff --git a/nymea-app/ui/customviews/MultiStateChart.qml b/nymea-app/ui/customviews/MultiStateChart.qml
new file mode 100644
index 00000000..5725ba5c
--- /dev/null
+++ b/nymea-app/ui/customviews/MultiStateChart.qml
@@ -0,0 +1,656 @@
+import QtQuick 2.9
+import QtQuick.Controls 2.2
+import QtQuick.Controls.Material 2.2
+import QtQuick.Layouts 1.1
+import Nymea 1.0
+import NymeaApp.Utils 1.0
+import "../components"
+import "../customviews"
+import QtCharts 2.2
+
+Item {
+ id: root
+ implicitHeight: width * .6
+ implicitWidth: 400
+
+ // A model with roles:
+ // - thingId: uuid/string
+ // - stateName: string
+ // - color: color
+ // - fillArea: bool (default false)
+ property var statesModel: []
+ property string title: ""
+
+
+ QtObject {
+ id: d
+
+ readonly property int range: selectionTabs.currentValue.range
+ readonly property int sampleRate: selectionTabs.currentValue.sampleRate
+ readonly property int visibleValues: range / sampleRate
+
+ property date now: new Date()
+
+ 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
+ }
+
+ function ensureValue(series, timestamp) {
+ if (!series) return;
+ if (series.count == 0) {
+ series.append(timestamp, 0)
+ } else if (series.count == 1) {
+ if (timestamp.getTime() < series.at(0).x) {
+ series.insert(0, timestamp, 0)
+ } else {
+ series.append(timestamp, 0)
+ }
+ } else {
+ if (timestamp.getTime() > series.at(0).x) {
+ series.remove(1)
+ series.append(timestamp, 0)
+ } else if (timestamp.getTime() < series.at(1).x) {
+ series.remove(0)
+ series.insert(0, timestamp, 0)
+ }
+ }
+ }
+ function shrink(series, logsModel) {
+ if (!series) return;
+ series.clear();
+ if (logsModel.count > 0) {
+ ensureValue(series, logsModel.get(0).timestamp)
+ ensureValue(series, logsModel.get(logsModel.count-1).timestamp)
+ }
+ }
+
+ function refreshAll() {
+ for (var i = 0; i < root.statesModel.length; i++) {
+ modelsRepeater.itemAt(i).logsModel.fetchLogs()
+ }
+ }
+ }
+
+ Repeater {
+ id: modelsRepeater
+ model: root.statesModel
+ delegate: Item {
+ id: modelDelegate
+ readonly property string thingId: root.statesModel[index].thingId
+ readonly property string stateName: root.statesModel[index].stateName
+ readonly property color color: root.statesModel[index].color
+ readonly property bool fillArea: root.statesModel[index].hasOwnProperty("fillArea") ? root.statesModel[index].fillArea : false
+
+ readonly property Thing thing: engine.thingManager.things.getThing(root.statesModel[index].thingId)
+ readonly property StateType stateType: thing.thingClass.stateTypes.findByName(stateName)
+ readonly property State thingState: thing.stateByName(stateName)
+ property XYSeries series: null
+ property XYSeries zeroSeries: null
+ property AreaSeries areaSeries: null
+ property ValueAxis axis: null
+
+ readonly property bool isBool: stateType.type.toLowerCase() === "bool"
+
+ Component {
+ id: valueAxisComponent
+ ValueAxis {
+ labelFormat: isBool ? " " : "%0.2" /*+ labelsLayout.precision*/ + "f " + Types.toUiUnit(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
+ // }
+ // }
+ // }
+
+ }
+ }
+
+ Component.onCompleted: {
+// var axis = isBool ? boolAxis : valueAxis
+
+ axis = valueAxisComponent.createObject(chartView)
+ axis.min = Qt.binding(function() {return logsModel.minValue})
+ axis.max = Qt.binding(function() {return logsModel.maxValue})
+
+ series = chartView.createSeries(ChartView.SeriesTypeLine, thing.name, dateTimeAxis, axis)
+ series.color = color
+ series.width = isBool ? 0 : 2
+
+ if (fillArea) {
+ print("creating zero series")
+ zeroSeries = chartView.createSeries(ChartView.SeriesTypeLine, thing.name, dateTimeAxis, axis)
+ zeroSeries.color = color
+
+ areaSeries = chartView.createSeries(ChartView.SeriesTypeArea, thing.name, dateTimeAxis, axis)
+ areaSeries.upperSeries = series
+ areaSeries.lowerSeries = zeroSeries
+ areaSeries.color = Qt.rgba(color.r, color.g, color.b, color.a * .5)
+ areaSeries.borderColor = color
+ areaSeries.borderWidth = isBool ? 0 : 2
+ }
+ }
+ Component.onDestruction: {
+ if (fillArea) {
+ chartView.removeSeries(zeroSeries)
+ chartView.removeSeries(areaSeries)
+ }
+ chartView.removeSeries(series)
+ axis.destroy()
+ }
+
+ Connections {
+ target: selectionTabs
+ onTabSelected: {
+ logsModel.clear()
+ logsModel.fetchLogs()
+ }
+ }
+
+ property NewLogsModel logsModel: NewLogsModel {
+// id: logsModel
+ engine: _engine
+ source: thing ? "state-" + thing.id + "-" + stateName : ""
+ startTime: new Date(d.startTime.getTime() - d.range * 1.1 * 60000)
+ endTime: new Date(d.endTime.getTime() + d.range * 1.1 * 60000)
+ sampleRate: stateType.type.toLowerCase() === "bool" ? NewLogsModel.SampleRateAny : d.sampleRate
+ sortOrder: Qt.AscendingOrder
+
+ Component.onCompleted: {
+ print("****** completed", modelDelegate.thingId)
+ 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
+
+ onBusyChanged: {
+ if (busy) {
+ chartView.busyCounter++
+ } else {
+ chartView.busyCounter--
+ }
+ }
+
+ onEntriesAddedIdx: {
+ print("**** entries added", index, count, "entries in series:", series.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))
+ d.ensureValue(zeroSeries, entry.timestamp)
+
+ if (stateType.type.toLowerCase() == "bool") {
+ var value = entry.values[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[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))
+ series.insert(insertIdx, entry.timestamp.getTime() - 500, previousValue)
+ // print("inserting bool 2", insertIdx + 1, entry.timestamp.getTime(), value, entry.timestamp)
+ series.insert(insertIdx+1, entry.timestamp, value)
+
+ } else {
+ var value = entry.values[stateType.name]
+ if (value == null) {
+ value = 0;
+ }
+ value *= root.inverted ? -1.1 : 1.1
+
+ minValue = minValue == undefined ? value : Math.min(minValue, value)
+ maxValue = maxValue == undefined ? value : Math.max(maxValue, value)
+
+ var insertIdx = index + i
+ series.insert(insertIdx, entry.timestamp, value)
+ }
+ }
+
+ if (stateType.type.toLowerCase() == "bool") {
+
+ var last = series.at(series.count-1);
+ if (last.x < d.endTime) {
+ series.append(d.endTime, last.y)
+ d.ensureValue(zeroSeries, d.endTime)
+ }
+ }
+
+ print("added entries. now in series:", series.count)
+
+ }
+ onEntriesRemoved: {
+ print("removing:", index, count, series.count)
+ if (stateType.type.toLowerCase() == "bool") {
+ series.removePoints(index * 2, count * 2)
+ if (series.count == 1) {
+ series.removePoints(0, 1);
+ }
+ } else {
+ series.removePoints(index, count)
+ }
+
+ d.shrink(zeroSeries, logsModel)
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ Label {
+ id: titleLabel
+ Layout.fillWidth: true
+ Layout.margins: Style.smallMargins
+ horizontalAlignment: Text.AlignHCenter
+ text: root.title
+ visible: root.title != ""
+ }
+
+ 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()
+ }
+ }
+
+ 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
+
+ property int busyCounter: 0
+
+ ActivityIndicator {
+ anchors.centerIn: parent
+ visible: chartView.busyCounter > 0
+ opacity: .5
+ }
+
+ Label {
+ anchors.centerIn: parent
+ visible: {
+ if (chartView.busyCounter > 0) {
+ return false
+ }
+ for (var i = 0; i < modelsRepeater.count; i++) {
+ if (modelsRepeater.itemAt(i).logsModel.count > 0) {
+ return false
+ }
+ }
+ return true
+ }
+ 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 {} }
+ }
+
+
+
+ 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
+ }
+ }
+
+ 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: {
+ 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: d.refreshAll()
+ }
+
+ Rectangle {
+ height: parent.height
+ width: 1
+ color: Style.foregroundColor
+ x: Math.min(mouseArea.width, Math.max(0, mouseArea.mouseX))
+ visible: tooltipRepeater.tooltipsVisible
+ }
+
+ Timer {
+ id: updateTimer
+ interval: 0
+ onTriggered: tooltipRepeater.update()
+ }
+
+ Repeater {
+ id: tooltipRepeater
+ model: root.statesModel.length
+ property var timestamp: new Date(((d.endTime.getTime() - d.startTime.getTime()) * mouseArea.mouseX / mouseArea.width) + d.startTime.getTime())
+ property int tooltipWidth: 130
+ property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins
+ property int xOnLeft: Math.min(mouseArea.width, mouseArea.mouseX) - Style.smallMargins - tooltipWidth
+ property int tooltipX: xOnLeft < 0 ? xOnRight : xOnLeft
+ property bool tooltipsVisible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging
+
+
+ onTimestampChanged: {
+ updateTimer.start();
+ }
+
+ function update() {
+ var ordered = []
+ insert(tooltipRepeater, ordered);
+
+ for (var i = ordered.length - 1; i >= 0; i--) {
+ var item = ordered[i]
+ var newY = item.realY
+
+ if (i < ordered.length-1) {
+ var previous = ordered[i+1]
+ newY = Math.min(newY, previous.fixedY - item.height/* - Style.extraSmallMargins*/)
+ }
+
+ ordered[i].fixedY = newY
+ }
+ }
+
+ function insert(repeater, array) {
+ for (var i = 0; i < repeater.count; i++) {
+ var item = repeater.itemAt(i);
+ var insertIdx = 0;
+ while (array.length > insertIdx && item.realY > array[insertIdx].realY) {
+ insertIdx++
+ }
+ array.splice(insertIdx, 0, item)
+ }
+ }
+
+
+ delegate: NymeaToolTip {
+ id: tooltip
+ width: tooltipRepeater.tooltipWidth
+ height: layout.implicitHeight + Style.smallMargins * 2
+
+ visible: tooltipRepeater.tooltipsVisible && entry != null
+ x: tooltipRepeater.tooltipX
+ backgroundItem: chartView
+ backgroundRect: Qt.rect(mouseArea.x + x, mouseArea.y + y, width, height)
+
+ property Item chartDelegate: modelsRepeater.count > 0 ? modelsRepeater.itemAt(index) : null
+ property Thing thing: chartDelegate ? chartDelegate.thing : null
+ property NewLogEntry entry: chartDelegate ? chartDelegate.logsModel.find(tooltipRepeater.timestamp) : null
+ property string valueName: chartDelegate ? chartDelegate.stateType.name : ""
+// property alias iconSource: icon.name
+ property ValueAxis axis: chartDelegate ? chartDelegate.axis : null
+ property int unit: chartDelegate ? chartDelegate.stateType.unit : Types.UnitNone
+
+ readonly property var value: entry ? entry.values[valueName] : null
+ readonly property int realY: entry ? Math.min(Math.max(mouseArea.height - (value * mouseArea.height / axis.max) - height / 2 /*- Style.margins*/, 0), mouseArea.height - height) : 0
+ property int fixedY: 0
+ y: fixedY // Animated
+
+ RowLayout {
+ id: layout
+ anchors.fill: parent
+ anchors.margins: Style.smallMargins
+
+ ColorIcon {
+ id: icon
+ size: Style.smallIconSize
+ color: chartDelegate ? chartDelegate.color : "red"
+ visible: name != ""
+ }
+
+ Rectangle {
+ id: rect
+ width: Style.extraSmallFont.pixelSize
+ height: width
+ color: chartDelegate ? chartDelegate.color : "red"
+ visible: !icon.visible
+ }
+ Label {
+ text: root.statesModel[index].hasOwnProperty("tooltipFunction")
+ ? root.statesModel[index].tooltipFunction(tooltip.value)
+ : "%1: %2%3".arg(thing.name).arg(entry ? round(Types.toUiValue(tooltip.value, unit)) : "-").arg(Types.toUiUnit(tooltip.unit))
+ Layout.fillWidth: true
+ font: Style.extraSmallFont
+ elide: Text.ElideMiddle
+ function round(value) {
+ return Math.round(value * 100) / 100
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/nymea-app/ui/devicepages/ThingStatusPage.qml b/nymea-app/ui/devicepages/ThingStatusPage.qml
new file mode 100644
index 00000000..152b31b8
--- /dev/null
+++ b/nymea-app/ui/devicepages/ThingStatusPage.qml
@@ -0,0 +1,266 @@
+/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+*
+* Copyright 2013 - 2023, nymea GmbH
+* Contact: contact@nymea.io
+*
+* This file is part of nymea.
+* This project including source code and documentation is protected by
+* copyright law, and remains the property of nymea GmbH. All rights, including
+* reproduction, publication, editing and translation, are reserved. The use of
+* this project is subject to the terms of a license agreement to be concluded
+* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
+* under https://nymea.io/license
+*
+* GNU General Public License Usage
+* Alternatively, this project may be redistributed and/or modified under the
+* terms of the GNU General Public License as published by the Free Software
+* Foundation, GNU version 3. This project 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
+* this project. If not, see .
+*
+* For any further details and any questions please contact us under
+* contact@nymea.io or see our FAQ/Licensing Information on
+* https://nymea.io/license/faq
+*
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+import QtQuick 2.9
+import QtQuick.Controls 2.2
+import QtQuick.Controls.Material 2.2
+import QtQuick.Layouts 1.1
+import Nymea 1.0
+import "../components"
+import "../customviews"
+
+Page {
+ id: root
+
+ property Thing thing: null
+
+ header: NymeaHeader {
+ text: qsTr("Status for %1").arg(root.thing.name)
+ onBackPressed: pageStack.pop()
+ }
+
+ Flickable {
+ id: flickable
+ anchors.fill: parent
+ contentHeight: layout.implicitHeight
+ interactive: contentHeight > height
+ clip: true
+
+ GridLayout {
+ id: layout
+ width: flickable.width
+ columns: app.landscape ? 2 : 1
+
+ ColumnLayout {
+ id: updateStatusLayout
+ readonly property State currentVersionState: thing.stateByName("currentVersion")
+ readonly property State availableVersionState: thing.stateByName("availableVersion")
+ readonly property State updateStatusState: thing.stateByName("updateStatus")
+ readonly property State updateProgressState: thing.stateByName("updateProgress")
+
+ visible: thing.thingClass.interfaces.indexOf("update") >= 0
+
+ SettingsPageSectionHeader {
+ text: qsTr("Update information")
+ }
+
+ RowLayout {
+ Layout.leftMargin: Style.margins
+ Layout.rightMargin: Style.margins
+ Layout.bottomMargin: Style.margins
+ spacing: Style.margins
+
+ ColorIcon {
+ name: "system-update"
+ size: Style.largeIconSize
+ color: updateStatusLayout.updateStatusState != null && updateStatusLayout.updateStatusState.value == "updating" ? Style.accentColor : Style.iconColor
+ RotationAnimation on rotation {
+ from: 0; to: 360
+ duration: 2000
+ running: updateStatusLayout.updateStatusState != null && updateStatusLayout.updateStatusState.value == "updating"
+ loops: Animation.Infinite
+ }
+
+ }
+
+ ColumnLayout {
+
+ Label {
+ Layout.fillWidth: true
+ text: {
+ switch (updateStatusLayout.updateStatusState.value) {
+ case "idle":
+ return qsTr("Thing is up to date")
+ case "available":
+ return qsTr("Update available")
+ case "updating":
+ return qsTr("Updating...")
+ }
+ }
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: updateStatusLayout.currentVersionState ? qsTr("Installed version: %1").arg(updateStatusLayout.currentVersionState.value) : ""
+ font: Style.smallFont
+ }
+ Label {
+ Layout.fillWidth: true
+ visible: updateStatusLayout.availableVersionState != null && updateStatusLayout.updateStatusState != null && updateStatusLayout.updateStatusState.value === "available"
+ text: updateStatusLayout.availableVersionState ? qsTr("Available version: %1").arg(updateStatusLayout.availableVersionState.value) : ""
+ font: Style.smallFont
+ }
+
+ ProgressBar {
+ Layout.fillWidth: true
+ visible: updateStatusLayout.updateStatusState != null && updateStatusLayout.updateStatusState.value === "updating"
+ value: updateStatusLayout.updateProgressState ? updateStatusLayout.updateProgressState.value : 50
+ indeterminate: updateStatusLayout.updateProgressState == null
+ from: 0
+ to: 100
+ }
+ }
+ }
+
+ Button {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Update")
+ Layout.minimumWidth: parent.width / 2
+ visible: updateStatusLayout.updateStatusState && updateStatusLayout.updateStatusState.value === "available"
+ onClicked: {
+ var dialogComponent = Qt.createComponent("../components/NymeaDialog.qml")
+ var currentVersionState = root.thing.stateByName("currentVersion")
+ var availableVersionState = root.thing.stateByName("availableVersion")
+ var text = qsTr("Do you want to start the update now?")
+ if (currentVersionState) {
+ text += "\n\n" + qsTr("Current version: %1").arg(currentVersionState.value)
+ }
+ if (availableVersionState) {
+ text += "\n\n" + qsTr("Available version: %1").arg(availableVersionState.value)
+ }
+
+ var dialog = dialogComponent.createObject(app,
+ {
+ headerIcon: "system-update",
+ title: qsTr("Update"),
+ text: text,
+ standardButtons: Dialog.Ok | Dialog.Cancel
+ })
+ if (!dialog) {
+ print("Error:", dialogComponent.errorString())
+ }
+
+ dialog.accepted.connect(function() {
+ print("starting update")
+ root.thing.executeAction("performUpdate")
+ })
+ dialog.open();
+ }
+ }
+
+ }
+
+ ColumnLayout {
+ id: connectionStatusLayout
+ Layout.fillWidth: true
+
+ readonly property State connectedState: thing.stateByName("connected")
+ readonly property State signalStrengthState: thing.stateByName("signalStrength")
+
+ visible: thing.thingClass.interfaces.indexOf("connectable") >= 0
+
+ SettingsPageSectionHeader {
+ text: qsTr("Connection information")
+ }
+
+ RowLayout {
+ Layout.leftMargin: Style.margins
+ Layout.rightMargin: Style.margins
+ Layout.bottomMargin: Style.margins
+
+ Label {
+ Layout.fillWidth: true
+ text: (connectionStatusLayout.connectedState.value === true ? qsTr("Connected") : qsTr("Disconnected"))
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: connectionStatusLayout.signalStrengthState != null ? connectionStatusLayout.signalStrengthState.value + " %" : ""
+ horizontalAlignment: Text.AlignRight
+ }
+ ConnectionStatusIcon {
+ Layout.preferredHeight: Style.smallIconSize
+ Layout.preferredWidth: height
+ thing: root.thing
+ visible: connectionStatusLayout.signalStrengthState != null
+ }
+ }
+
+ MultiStateChart {
+ Layout.fillWidth: true
+ Layout.preferredHeight: width / 2
+ statesModel: [
+ {thingId: root.thing.id, stateName: "connected", color: Qt.rgba(Style.green.r, Style.green.g, Style.green.b, .2), fillArea: true, tooltipFunction: function(value) {
+ return value === true ? qsTr("Connected") : qsTr("Disconnected")
+ }},
+ {thingId: root.thing.id, stateName: "signalStrength", color: Style.orange, fillArea: true, tooltipFunction: function(value){ return qsTr("Signal strength: %1 %").arg(value)}},
+ ]
+ }
+ }
+
+ ColumnLayout {
+ id: batteryStatusLayout
+ Layout.fillWidth: true
+
+ readonly property State batteryCriticalState: thing.stateByName("batteryCritical")
+ readonly property State batteryLevelState: thing.stateByName("batteryLevel")
+
+ visible: thing.thingClass.interfaces.indexOf("battery") >= 0
+
+ SettingsPageSectionHeader {
+ text: qsTr("Battery information")
+ }
+
+ RowLayout {
+ Layout.margins: Style.margins
+
+ Label {
+ Layout.fillWidth: true
+ text: batteryStatusLayout.batteryCriticalState.value === true ? qsTr("Battery level critical") : qsTr("Battery level ok")
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: batteryStatusLayout.batteryLevelState != null ? batteryStatusLayout.batteryLevelState.value + " %" : ""
+ horizontalAlignment: Text.AlignRight
+ }
+ BatteryStatusIcon {
+ Layout.preferredHeight: Style.smallIconSize
+ Layout.preferredWidth: height
+ thing: root.thing
+ visible: batteryStatusLayout.batteryLevelState != null
+ }
+ }
+
+ MultiStateChart {
+ Layout.fillWidth: true
+ Layout.preferredHeight: width / 2
+ statesModel: [
+ {thingId: root.thing.id, stateName: "batteryCritical", color: Qt.rgba(Style.red.r, Style.red.g, Style.red.b, .2), fillArea: true, tooltipFunction(value) {
+ return value === true ? qsTr("Critical") : qsTr("OK")
+ }},
+ {thingId: root.thing.id, stateName: "batteryLevel", color: Style.orange, fillArea: true, tooltipFunction: function(value){ return qsTr("Battery level: %1 %").arg(value)}},
+ ]
+ }
+ }
+
+ }
+ }
+}