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)}}, + ] + } + } + + } + } +}