diff --git a/libnymea-app/jsonrpc/jsonrpcclient.cpp b/libnymea-app/jsonrpc/jsonrpcclient.cpp index a30bf3fa..eb8c97a3 100644 --- a/libnymea-app/jsonrpc/jsonrpcclient.cpp +++ b/libnymea-app/jsonrpc/jsonrpcclient.cpp @@ -691,7 +691,7 @@ void JsonRpcClient::helloReply(int /*commandId*/, const QVariantMap ¶ms) m_connection->currentHost()->setName(name); QVersionNumber minimumRequiredVersion = QVersionNumber(5, 0); - QVersionNumber maximumMajorVersion = QVersionNumber(7); + QVersionNumber maximumMajorVersion = QVersionNumber(8); if (m_jsonRpcVersion < minimumRequiredVersion) { qCWarning(dcJsonRpc()) << "Nymea core doesn't support minimum required version. Required:" << minimumRequiredVersion << "Found:" << m_jsonRpcVersion; emit invalidMinimumVersion(m_jsonRpcVersion.toString(), minimumRequiredVersion.toString()); diff --git a/libnymea-app/libnymea-app-core.h b/libnymea-app/libnymea-app-core.h index d6bde858..d90a2c1f 100644 --- a/libnymea-app/libnymea-app-core.h +++ b/libnymea-app/libnymea-app-core.h @@ -68,6 +68,7 @@ #include "models/barseriesadapter.h" #include "models/xyseriesadapter.h" #include "models/boolseriesadapter.h" +#include "models/newlogsmodel.h" #include "models/interfacesproxy.h" #include "configuration/nymeaconfiguration.h" #include "configuration/serverconfiguration.h" @@ -278,6 +279,9 @@ void registerQmlTypes() { qmlRegisterType(uri, 1, 0, "XYSeriesAdapter"); qmlRegisterType(uri, 1, 0, "BoolSeriesAdapter"); + qmlRegisterType(uri, 1, 0, "NewLogsModel"); + qmlRegisterUncreatableType(uri, 1, 0, "NewLogEntry", "Get them from NewLogsModel"); + qmlRegisterUncreatableType(uri, 1, 0, "TagsManager", "Get it from Engine"); qmlRegisterUncreatableType(uri, 1, 0, "Tags", "Get it from TagsManager"); qmlRegisterUncreatableType(uri, 1, 0, "Tag", "Get it from Tags"); diff --git a/libnymea-app/libnymea-app.pri b/libnymea-app/libnymea-app.pri index 1c5eae50..34307d74 100644 --- a/libnymea-app/libnymea-app.pri +++ b/libnymea-app/libnymea-app.pri @@ -28,6 +28,8 @@ SOURCES += \ $$PWD/energy/thingpowerlogs.cpp \ $$PWD/connection/tunnelproxytransport.cpp \ $$PWD/models/boolseriesadapter.cpp \ + $$PWD/models/newlogentry.cpp \ + $$PWD/models/newlogsmodel.cpp \ $$PWD/models/scriptsproxymodel.cpp \ $$PWD/pluginconfigmanager.cpp \ $$PWD/tagwatcher.cpp \ @@ -192,6 +194,8 @@ HEADERS += \ $$PWD/energy/thingpowerlogs.h \ $$PWD/connection/tunnelproxytransport.h \ $$PWD/models/boolseriesadapter.h \ + $$PWD/models/newlogentry.h \ + $$PWD/models/newlogsmodel.h \ $$PWD/models/scriptsproxymodel.h \ $$PWD/pluginconfigmanager.h \ $$PWD/tagwatcher.h \ diff --git a/libnymea-app/models/newlogentry.cpp b/libnymea-app/models/newlogentry.cpp new file mode 100644 index 00000000..1b93a562 --- /dev/null +++ b/libnymea-app/models/newlogentry.cpp @@ -0,0 +1,25 @@ +#include "newlogentry.h" + +NewLogEntry::NewLogEntry(const QString &source, const QDateTime ×tamp, const QVariantMap &values, QObject *parent) + : QObject{parent}, + m_source(source), + m_timestamp(timestamp), + m_values(values) +{ + +} + +QString NewLogEntry::source() const +{ + return m_source; +} + +QDateTime NewLogEntry::timestamp() const +{ + return m_timestamp; +} + +QVariantMap NewLogEntry::values() const +{ + return m_values; +} diff --git a/libnymea-app/models/newlogentry.h b/libnymea-app/models/newlogentry.h new file mode 100644 index 00000000..4d22d486 --- /dev/null +++ b/libnymea-app/models/newlogentry.h @@ -0,0 +1,28 @@ +#ifndef NEWLOGENTRY_H +#define NEWLOGENTRY_H + +#include +#include +#include + +class NewLogEntry : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString source READ source CONSTANT) + Q_PROPERTY(QDateTime timestamp READ timestamp CONSTANT) + Q_PROPERTY(QVariantMap values READ values CONSTANT) + +public: + explicit NewLogEntry(const QString &source, const QDateTime ×tamp, const QVariantMap &values, QObject *parent = nullptr); + + QString source() const; + QDateTime timestamp() const; + QVariantMap values() const; + +private: + QString m_source; + QDateTime m_timestamp; + QVariantMap m_values; +}; + +#endif // NEWLOGENTRY_H diff --git a/libnymea-app/models/newlogsmodel.cpp b/libnymea-app/models/newlogsmodel.cpp new file mode 100644 index 00000000..8f5071ea --- /dev/null +++ b/libnymea-app/models/newlogsmodel.cpp @@ -0,0 +1,406 @@ +#include "newlogsmodel.h" + +#include "engine.h" + +#include "logging.h" +//NYMEA_LOGGING_CATEGORY(dcLogEngine, "LogEngine") +Q_DECLARE_LOGGING_CATEGORY(dcLogEngine) + +#include +#include + +NewLogsModel::NewLogsModel(QObject *parent) + : QAbstractListModel{parent} +{ + +} + +int NewLogsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return m_list.count(); +} + +QVariant NewLogsModel::data(const QModelIndex &index, int role) const +{ + switch (role) { + case RoleSource: + return m_list.at(index.row())->source(); + case RoleTimestamp: + return m_list.at(index.row())->timestamp(); + case RoleValues: + return m_list.at(index.row())->values(); + } + + return QVariant(); +} + +QHash NewLogsModel::roleNames() const +{ + return { + {RoleSource, "source"}, + {RoleTimestamp, "timestamp"}, + {RoleValues, "values"} + }; +} + +void NewLogsModel::classBegin() +{ + +} + +void NewLogsModel::componentComplete() +{ + m_completed = true; +// fetchMore(); +} + +bool NewLogsModel::canFetchMore(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return m_canFetchMore; +} + +void NewLogsModel::fetchMore(const QModelIndex &parent) +{ + Q_UNUSED(parent) + + if (!m_engine) { + return; + } + if (!m_completed) { + return; + } + + fetchLogs(); + +} + +Engine *NewLogsModel::engine() const +{ + return m_engine; +} + +void NewLogsModel::setEngine(Engine *engine) +{ + if (m_engine != engine) { + m_engine = engine; + emit engineChanged(); + +// if (m_completed && m_canFetchMore) { +// fetchMore(); +// } + } +} + +QString NewLogsModel::source() const +{ + return m_sources.count() > 0 ? m_sources.first() : ""; +} + +void NewLogsModel::setSource(const QString &source) +{ + if (m_sources != QStringList(source)) { + m_sources = QStringList(source); + emit sourcesChanged(); + } +} + +QStringList NewLogsModel::sources() const +{ + return m_sources; +} + +void NewLogsModel::setSources(const QStringList &sources) +{ + if (m_sources != sources) { + m_sources = sources; + emit sourcesChanged(); + } +} + +QStringList NewLogsModel::columns() const +{ + return m_columns; +} + +void NewLogsModel::setColumns(const QStringList &columns) +{ + if (m_columns != columns) { + m_columns = columns; + emit columnsChanged(); + } +} + +QVariantMap NewLogsModel::filter() const +{ + return m_filter; +} + +void NewLogsModel::setFilter(const QVariantMap &filter) +{ + if (m_filter != filter) { + m_filter = filter; + emit filterChanged(); + } +} + +QDateTime NewLogsModel::startTime() const +{ + return m_startTime; +} + +void NewLogsModel::setStartTime(const QDateTime &startTime) +{ + if (m_startTime != startTime) { + m_startTime = startTime; + emit startTimeChanged(); + } +} + +QDateTime NewLogsModel::endTime() const +{ + return m_endTime; +} + +void NewLogsModel::setEndTime(const QDateTime &endTime) +{ + if (m_endTime != endTime) { + m_endTime = endTime; + emit endTimeChanged(); + } +} + +NewLogsModel::SampleRate NewLogsModel::sampleRate() const +{ + return m_sampleRate; +} + +void NewLogsModel::setSampleRate(SampleRate sampleRate) +{ + if (m_sampleRate != sampleRate) { + m_sampleRate = sampleRate; + emit sampleRateChanged(); + clear(); + } +} + +bool NewLogsModel::busy() const +{ + return m_busy; +} + +NewLogEntry *NewLogsModel::get(int index) const +{ + if (index < 0 || index >= m_list.count()) { + return nullptr; + } + return m_list.at(index); +} + +NewLogEntry *NewLogsModel::find(const QDateTime ×tamp) const +{ +// qCDebug(dcLogEngine()) << "finding:" << timestamp.toString(); + if (m_list.isEmpty()) { + return nullptr; + } + int idx = m_list.count() / 2; + int jump = m_list.count() / 4; + int stopper = 10; + while (stopper-- > 0) { +// qCDebug(dcLogEngine()) << "idx:" << idx << "cnt:" << m_list.count() << "jmp" << jump; + NewLogEntry *entry = m_list.at(idx); + if (entry->timestamp() == timestamp) { +// qCDebug(dcLogEngine()) << "found exact"; + return entry; + } + qint64 diff = timestamp.msecsTo(entry->timestamp()); + if (entry->timestamp() > timestamp) { +// qCDebug(dcLogEngine()) << "entry is newer than searched:" << entry->timestamp().toString() << timestamp.toString(); + if (idx == m_list.count() - 1) { +// qCDebug(dcLogEngine()) << "Is oldest."; + return entry; + } + NewLogEntry *previousEntry = m_list.at(idx+1); + if (previousEntry->timestamp() < timestamp) { + qint64 previousDiff = timestamp.msecsTo(previousEntry->timestamp()); +// qCDebug(dcLogEngine()) << "time between this and previous:" << entry->timestamp().toString() << previousEntry->timestamp().toString() << (qAbs(previousDiff) < qAbs(diff) ? "next" : "this"); + return qAbs(previousDiff) < qAbs(diff) ? previousEntry : entry; + } + idx += jump; + } else if (entry->timestamp() < timestamp) { +// qCDebug(dcLogEngine()) << "entry is older than searched:" << entry->timestamp().toString() << timestamp.toString(); + if (idx == 0) { +// qCDebug(dcLogEngine()) << "Is newest."; + return entry; + } + NewLogEntry *nextEntry = m_list.at(idx-1); + if (nextEntry->timestamp() > timestamp) { + qint64 nextDiff = timestamp.msecsTo(nextEntry->timestamp()); +// qCDebug(dcLogEngine()) << "time between next and this:" << nextEntry->timestamp().toString() << "-" << entry->timestamp().toString() << (qAbs(nextDiff) > qAbs(diff) ? "prev" : "this"); + return qAbs(nextDiff) < qAbs(diff) ? nextEntry : entry; + } + idx -= jump; + } + jump = qMax(1, jump / 2); + }; + return nullptr; +} + +void NewLogsModel::clear() +{ + int count = m_list.count(); + beginResetModel(); + qDeleteAll(m_list); + m_list.clear(); + endResetModel(); + emit countChanged(); + emit entriesRemoved(0, count); +} + +void NewLogsModel::fetchLogs() +{ + if (!m_engine) { + return; + } + QVariantMap params { + {"sources", m_sources}, + {"columns", m_columns}, + {"filter", m_filter} + }; + + if (!m_startTime.isNull() && !m_endTime.isNull()) { + QDateTime startTime; + QDateTime endTime; + + QDateTime oldestExisting = m_list.count() > 0 ? m_list.last()->timestamp() : QDateTime(); + QDateTime newestExisting = m_list.count() > 0 ? m_list.first()->timestamp() : QDateTime(); + qCDebug(dcLogEngine()) << "request timeframe: " << m_startTime.toString() << " - " << m_endTime.toString(); + qCDebug(dcLogEngine()) << "existing timeframe:" << oldestExisting.toString() << "- " << newestExisting.toString(); + + if (oldestExisting.isNull() || newestExisting.isNull()) { + startTime = m_startTime; + endTime = qMin(QDateTime::currentDateTime(), m_endTime); + } else { + + if (m_startTime < oldestExisting) { + startTime = m_startTime; + endTime = qMin(QDateTime::currentDateTime(), qMin(m_endTime, oldestExisting)); + } else if (newestExisting < m_endTime) { + startTime = qMax(m_startTime, newestExisting); + endTime = qMin(QDateTime::currentDateTime(), m_endTime); + } else { + // Nothing to do... + return; + } + } + + qCDebug(dcLogEngine()) << "Actual request:" << startTime.toString() << " - " << endTime.toString(); + params.insert("startTime", startTime.toMSecsSinceEpoch()); + params.insert("endTime", endTime.toMSecsSinceEpoch()); + QMetaEnum sampleRateEnum = QMetaEnum::fromType(); + params.insert("sampleRate", sampleRateEnum.valueToKey(m_sampleRate)); + } else { + params.insert("limit", m_blockSize); + if (m_list.count() > 0) { + params.insert("offset", m_list.count() - 1); // -1 because we'll fetch the last existing one again as the receiving logic checks if timestamps line up for proper insertion. It will be removed again there + params.insert("endTime", m_list.first()->timestamp().toMSecsSinceEpoch()); + } + } + +// if (!m_startTime.isNull()) { +// params.insert("startTime", m_startTime.toMSecsSinceEpoch()); +// } +// if (!m_endTime.isNull()) { +// params.insert("endTime", m_endTime.toMSecsSinceEpoch()); +// } + qCDebug(dcLogEngine()) << "Fetching logs:" << QJsonDocument::fromVariant(params).toJson(); + m_engine->jsonRpcClient()->sendCommand("Logging.GetLogEntries", params, this, "logsReply"); +} + +void NewLogsModel::logsReply(int commandId, const QVariantMap &data) +{ + + QList entries; + foreach (const QVariant &entryVariant, data.value("logEntries").toList()) { + QVariantMap map = entryVariant.toMap(); + QString source = map.value("source").toString(); + QDateTime timestamp = QDateTime::fromMSecsSinceEpoch(map.value("timestamp").toULongLong()); + QVariantMap values = map.value("values").toMap(); + NewLogEntry *entry = new NewLogEntry(source, timestamp, values, this); + entries.append(entry); + } + + m_canFetchMore = entries.count() >= m_blockSize; + qCDebug(dcLogEngine()) << "Logs received:" << entries.count() << "Requested:" << m_blockSize; + + if (!entries.isEmpty()) { + qCDebug(dcLogEngine()) << "Logs received:" << entries.first()->timestamp().toString() << " - " << entries.last()->timestamp().toString(); + if (m_list.isEmpty()) { + qCDebug(dcLogEngine()) << "Inserting into emptry model"; + beginInsertRows(QModelIndex(), 0, entries.count() - 1); + m_list.append(entries); + endInsertRows(); + emit entriesAdded(0, entries); + + } else if (entries.last()->timestamp() == m_list.last()->timestamp()) { + qCDebug(dcLogEngine()) << "First item of new list already existing... no new data..."; + qDeleteAll(entries); + + } else if (entries.last()->timestamp() < m_list.last()->timestamp()) { + if (entries.first()->timestamp() == m_list.last()->timestamp()) { + qCDebug(dcLogEngine()) << "Appending received items"; + beginRemoveRows(QModelIndex(), m_list.count() - 1, m_list.count() - 1); + m_list.takeLast()->deleteLater(); + endRemoveRows(); + emit entriesRemoved(m_list.count(), 1); + + int insertIdx = m_list.count(); + beginInsertRows(QModelIndex(), insertIdx, insertIdx + entries.count() - 1); + m_list = m_list + entries; + endInsertRows(); + emit entriesAdded(insertIdx, entries); + } else { + // Start of fetched entries does not line up with end of existing entries. Discarding existing entries + qCDebug(dcLogEngine()) << "Start of fetched entries does not line up with end of existing entries. Discarding existing entries" << entries.first()->timestamp().toString() << " - " << m_list.last()->timestamp().toString(); + clear(); + + // If the mismatch is in the visible area, we'll discard everything and fetch again + // Else if the mismatch is outside the visible area, we'll just discard the old data and work with what we received + if ((entries.first()->timestamp() >= m_endTime && entries.last()->timestamp() >= m_endTime) + || (entries.first()->timestamp() <= m_startTime && entries.last()->timestamp() <= m_endTime)) { + clear(); + beginInsertRows(QModelIndex(), 0, entries.count() - 1); + m_list.append(entries); + endInsertRows(); + emit entriesAdded(0, entries); + } else { + clear(); + fetchLogs(); + } + } + + } else if (entries.last()->timestamp() == m_list.first()->timestamp()) { + beginRemoveRows(QModelIndex(), 0, 0); + m_list.takeAt(0)->deleteLater(); + endRemoveRows(); + emit entriesRemoved(0, 1); + qCDebug(dcLogEngine()) << "Prepending received items"; + beginInsertRows(QModelIndex(), 0, entries.count() - 1); + m_list = entries + m_list; + endInsertRows(); + emit entriesAdded(0, entries); + + } else { + // End of fetched entries does not line up with start of existing entries. Discarding existing entries + qCDebug(dcLogEngine()) << "End of fetched entries does not line up with start of existing entries" << m_list.last()->timestamp().toString() << " - " << m_list.first()->timestamp().toString(); + clear(); + beginInsertRows(QModelIndex(), 0, entries.count() - 1); + m_list.append(entries); + endInsertRows(); + emit entriesAdded(0, entries); + } + } + + emit countChanged(); +} diff --git a/libnymea-app/models/newlogsmodel.h b/libnymea-app/models/newlogsmodel.h new file mode 100644 index 00000000..ad95be1e --- /dev/null +++ b/libnymea-app/models/newlogsmodel.h @@ -0,0 +1,128 @@ +#ifndef NEWLOGSMODEL_H +#define NEWLOGSMODEL_H + +#include +#include +#include "newlogentry.h" + +class Engine; + +class NewLogsModel : public QAbstractListModel, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + Q_PROPERTY(Engine* engine READ engine WRITE setEngine NOTIFY engineChanged) + Q_PROPERTY(QString source READ source WRITE setSource NOTIFY sourcesChanged) + Q_PROPERTY(QStringList sources READ sources WRITE setSources NOTIFY sourcesChanged) + Q_PROPERTY(QStringList columns READ columns WRITE setColumns NOTIFY columnsChanged) + Q_PROPERTY(QVariantMap filter READ filter WRITE setFilter NOTIFY filterChanged) + Q_PROPERTY(QDateTime startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) + Q_PROPERTY(QDateTime endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) + Q_PROPERTY(SampleRate sampleRate READ sampleRate WRITE setSampleRate NOTIFY sampleRateChanged) + + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) +// Q_PROPERTY(bool live READ live WRITE setLive NOTIFY liveChanged) + +public: + enum Role { + RoleSource, + RoleTimestamp, + RoleValues + }; + Q_ENUM(Role) + + enum SampleRate { + SampleRateAny = 0, + SampleRate1Min = 1, + SampleRate15Mins = 15, + SampleRate1Hour = 60, + SampleRate3Hours = 180, + SampleRate1Day = 1440, + SampleRate1Week = 10080, + SampleRate1Month = 43200, + SampleRate1Year = 525600 + }; + Q_ENUM(SampleRate) + + explicit NewLogsModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + void classBegin() override; + void componentComplete() override; + bool canFetchMore(const QModelIndex &parent) const override; + void fetchMore(const QModelIndex &parent = QModelIndex()) override; + + Engine *engine() const; + void setEngine(Engine *engine); + + QString source() const; + void setSource(const QString &source); + + QStringList sources() const; + void setSources(const QStringList &sources); + + QStringList columns() const; + void setColumns(const QStringList &columns); + + QVariantMap filter() const; + void setFilter(const QVariantMap &filter); + + QDateTime startTime() const; + void setStartTime(const QDateTime &startTime); + + QDateTime endTime() const; + void setEndTime(const QDateTime &endTime); + + SampleRate sampleRate() const; + void setSampleRate(SampleRate sampleRate); + + bool busy() const; + + Q_INVOKABLE NewLogEntry *get(int index) const; + Q_INVOKABLE NewLogEntry *find(const QDateTime ×tamp) const; + +// bool live() const; +// void setLive(bool live); + +public slots: + void clear(); + void fetchLogs(); + +signals: + void engineChanged(); + void sourcesChanged(); + void columnsChanged(); + void filterChanged(); + void busyChanged(); + void countChanged(); + void startTimeChanged(); + void endTimeChanged(); + void sampleRateChanged(); + + void entriesAdded(int index, const QList &entries); + void entriesRemoved(int index, int count); + +private slots: + void logsReply(int commandId, const QVariantMap &data); + +private: + Engine *m_engine = nullptr; + QStringList m_sources; + QStringList m_columns; + QVariantMap m_filter; + QDateTime m_startTime; + QDateTime m_endTime; + SampleRate m_sampleRate = SampleRateAny; + + bool m_completed = false; + bool m_canFetchMore = true; + bool m_busy = false; + int m_blockSize = 30; + + QList m_list; +}; + +#endif // NEWLOGSMODEL_H diff --git a/libnymea-app/models/thingmodel.cpp b/libnymea-app/models/thingmodel.cpp index c792175f..4e92b18d 100644 --- a/libnymea-app/models/thingmodel.cpp +++ b/libnymea-app/models/thingmodel.cpp @@ -48,6 +48,20 @@ QVariant ThingModel::data(const QModelIndex &index, int role) const if (role == RoleId) { return m_list.at(index.row()); } + if (role == RoleName) { + StateType* stateType = m_device->thingClass()->stateTypes()->getStateType(m_list.at(index.row())); + if (stateType) { + return stateType->name(); + } + ActionType* actionType = m_device->thingClass()->actionTypes()->getActionType(m_list.at(index.row())); + if (actionType) { + return actionType->name(); + } + EventType* eventType = m_device->thingClass()->eventTypes()->getEventType(m_list.at(index.row())); + if (eventType) { + return eventType->name(); + } + } if (role == RoleType) { StateType* stateType = m_device->thingClass()->stateTypes()->getStateType(m_list.at(index.row())); if (stateType) { @@ -87,6 +101,7 @@ QHash ThingModel::roleNames() const { QHash roles; roles.insert(RoleId, "id"); + roles.insert(RoleName, "name"); roles.insert(RoleType, "type"); roles.insert(RoleDisplayName, "displayName"); roles.insert(RoleWritable, "writable"); diff --git a/libnymea-app/models/thingmodel.h b/libnymea-app/models/thingmodel.h index 7a5ce700..dc9ab580 100644 --- a/libnymea-app/models/thingmodel.h +++ b/libnymea-app/models/thingmodel.h @@ -52,6 +52,7 @@ public: enum Roles { RoleId, RoleType, + RoleName, RoleDisplayName, RoleWritable }; diff --git a/libnymea-app/thingmanager.cpp b/libnymea-app/thingmanager.cpp index 33db5d3b..744f5a76 100644 --- a/libnymea-app/thingmanager.cpp +++ b/libnymea-app/thingmanager.cpp @@ -951,6 +951,17 @@ Thing* ThingManager::unpackThing(ThingManager *thingManager, const QVariantMap & } thing->setStates(states); + + QList loggedStateTypeIds; + foreach (const QVariant &uuid, thingMap.value("loggedStateTypeIds").toList()) { + loggedStateTypeIds.append(uuid.toUuid()); + } + thing->setLoggedStateTypeIds(loggedStateTypeIds); + QList loggedEventTypeIds; + foreach (const QVariant &uuid, thingMap.value("loggedEventTypeIds").toList()) { + loggedEventTypeIds.append(uuid.toUuid()); + } + thing->setLoggedEventTypeIds(loggedEventTypeIds); return thing; } diff --git a/libnymea-app/thingmanager.h b/libnymea-app/thingmanager.h index 21c75d37..c8cb119c 100644 --- a/libnymea-app/thingmanager.h +++ b/libnymea-app/thingmanager.h @@ -182,4 +182,6 @@ private: QDateTime m_connectionBenchmark; }; +Q_DECLARE_METATYPE(QList) + #endif // THINGMANAGER_H diff --git a/libnymea-app/types/thing.cpp b/libnymea-app/types/thing.cpp index c4c92295..f99b22fd 100644 --- a/libnymea-app/types/thing.cpp +++ b/libnymea-app/types/thing.cpp @@ -218,6 +218,32 @@ void Thing::setStateValue(const QUuid &stateTypeId, const QVariant &value) } } +QList Thing::loggedStateTypeIds() const +{ + return m_loggedStateTypeIds; +} + +void Thing::setLoggedStateTypeIds(const QList &loggedStateTypeIds) +{ + if (m_loggedStateTypeIds != loggedStateTypeIds) { + m_loggedStateTypeIds = loggedStateTypeIds; + emit loggedStateTypeIdsChanged(); + } +} + +QList Thing::loggedEventTypeIds() const +{ + return m_loggedEventTypeIds; +} + +void Thing::setLoggedEventTypeIds(const QList &loggedEventTypeIds) +{ + if (m_loggedEventTypeIds != loggedEventTypeIds) { + m_loggedEventTypeIds = loggedEventTypeIds; + emit loggedEventTypeIdsChanged(); + } +} + int Thing::executeAction(const QString &actionName, const QVariantList ¶ms) { ActionType *actionType = m_thingClass->actionTypes()->findByName(actionName); diff --git a/libnymea-app/types/thing.h b/libnymea-app/types/thing.h index 29bfd1d4..be63eb35 100644 --- a/libnymea-app/types/thing.h +++ b/libnymea-app/types/thing.h @@ -55,6 +55,8 @@ class Thing : public QObject Q_PROPERTY(Params *settings READ settings NOTIFY settingsChanged) Q_PROPERTY(States *states READ states NOTIFY statesChanged) Q_PROPERTY(ThingClass *thingClass READ thingClass CONSTANT) + Q_PROPERTY(QList loggedStateTypeIds READ loggedStateTypeIds NOTIFY loggedStateTypeIdsChanged) + Q_PROPERTY(QList loggedEventTypeIds READ loggedEventTypeIds NOTIFY loggedEventTypeIdsChanged) public: enum ThingSetupStatus { @@ -122,6 +124,12 @@ public: void setStates(States *states); void setStateValue(const QUuid &stateTypeId, const QVariant &value); + QList loggedStateTypeIds() const; + void setLoggedStateTypeIds(const QList &loggedStateTypeIds); + + QList loggedEventTypeIds() const; + void setLoggedEventTypeIds(const QList &loggedEventTypeIds); + ThingClass *thingClass() const; Q_INVOKABLE bool hasState(const QUuid &stateTypeId) const; @@ -140,6 +148,8 @@ signals: void paramsChanged(); void settingsChanged(); void statesChanged(); + void loggedStateTypeIdsChanged(); + void loggedEventTypeIdsChanged(); void eventTriggered(const QUuid &eventTypeId, const QVariantList ¶ms); signals: @@ -156,6 +166,8 @@ protected: Params *m_settings = nullptr; States *m_states = nullptr; ThingClass *m_thingClass = nullptr; + QList m_loggedStateTypeIds; + QList m_loggedEventTypeIds; QList m_pendingActions; }; diff --git a/nymea-app/images.qrc b/nymea-app/images.qrc index 20c1f0a8..8b214782 100644 --- a/nymea-app/images.qrc +++ b/nymea-app/images.qrc @@ -305,5 +305,7 @@ ui/images/infinity.svg ui/images/edit-paste.svg ui/images/list-move.svg + ui/images/system-log-out.svg + ui/images/system-restart.svg diff --git a/nymea-app/resources.qrc b/nymea-app/resources.qrc index 466abb97..02aec37c 100644 --- a/nymea-app/resources.qrc +++ b/nymea-app/resources.qrc @@ -41,7 +41,7 @@ ui/customviews/WeatherView.qml ui/devicepages/MediaThingPage.qml ui/devicepages/ButtonThingPage.qml - ui/devicepages/GenericDevicePage.qml + ui/devicepages/GenericThingPage.qml ui/devicepages/WeatherDevicePage.qml ui/devicepages/SensorDevicePage.qml ui/devicepages/ThingPageBase.qml @@ -90,6 +90,7 @@ ui/delegates/ThingDelegate.qml ui/delegates/InterfaceTile.qml ui/system/LogViewerPage.qml + ui/system/LogViewerPagePre18.qml ui/system/PluginsPage.qml ui/system/PluginParamsPage.qml ui/system/AboutNymeaPage.qml @@ -309,5 +310,7 @@ ui/mainviews/airconditioning/EditZonePage.qml ui/mainviews/airconditioning/EditZoneThingsPage.qml ui/mainviews/airconditioning/LegendDelegate.qml + ui/customviews/StateChart.qml + ui/devicepages/ThingLogPage.qml diff --git a/nymea-app/ui/SettingsPage.qml b/nymea-app/ui/SettingsPage.qml index a0fe42d7..46daef52 100644 --- a/nymea-app/ui/SettingsPage.qml +++ b/nymea-app/ui/SettingsPage.qml @@ -174,7 +174,13 @@ Page { text: qsTr("Log viewer") subText: qsTr("View system log") visible: NymeaUtils.hasPermissionScope(engine.jsonRpcClient.permissions, UserInfo.PermissionScopeAdmin) - onClicked: pageStack.push(Qt.resolvedUrl("system/LogViewerPage.qml")) + onClicked: { + if (engine.jsonRpcClient.ensureServerVersion("8.0")) { + pageStack.push(Qt.resolvedUrl("system/LogViewerPage.qml")) + } else { + pageStack.push(Qt.resolvedUrl("system/LogViewerPagePre18.qml")) + } + } } SettingsTile { diff --git a/nymea-app/ui/StyleBase.qml b/nymea-app/ui/StyleBase.qml index 2e39dd77..c59fce92 100644 --- a/nymea-app/ui/StyleBase.qml +++ b/nymea-app/ui/StyleBase.qml @@ -98,7 +98,7 @@ Item { // Icon/graph colors for various interfaces property var interfaceColors: { - "temperaturesensor": red, + "temperaturesensor": orange, "humiditysensor": lightBlue, "moisturesensor": blue, "lightsensor": yellow, diff --git a/nymea-app/ui/components/NymeaItemDelegate.qml b/nymea-app/ui/components/NymeaItemDelegate.qml index cbda9481..8d17524a 100644 --- a/nymea-app/ui/components/NymeaItemDelegate.qml +++ b/nymea-app/ui/components/NymeaItemDelegate.qml @@ -140,7 +140,7 @@ ItemDelegate { Layout.fillWidth: true Layout.fillHeight: true text: root.subText - font.pixelSize: root.prominentSubText ? Style.smallFont : app.extraSmallFont + font.pixelSize: root.prominentSubText ? Style.smallFont.pixelSize : Style.extraSmallFont.pixelSize color: root.prominentSubText ? Material.foreground : Material.color(Material.Grey) wrapMode: root.wrapTexts ? Text.WordWrap : Text.NoWrap maximumLineCount: root.wrapTexts ? 2 : 1 diff --git a/nymea-app/ui/components/ThingContextMenu.qml b/nymea-app/ui/components/ThingContextMenu.qml index 433023eb..f15375cf 100644 --- a/nymea-app/ui/components/ThingContextMenu.qml +++ b/nymea-app/ui/components/ThingContextMenu.qml @@ -55,7 +55,7 @@ AutoSizeMenu { pageStack.push(Qt.resolvedUrl("../magic/ThingRulesPage.qml"), {thing: root.thing}) } function openGenericThingPage() { - pageStack.push(Qt.resolvedUrl("../devicepages/GenericDevicePage.qml"), {thing: root.thing}) + pageStack.push(Qt.resolvedUrl("../devicepages/GenericThingPage.qml"), {thing: root.thing}) } function toggleFavorite() { if (favoritesProxy.count === 0) { @@ -74,7 +74,11 @@ AutoSizeMenu { } function openThingLogPage() { - pageStack.push(Qt.resolvedUrl("../devicepages/DeviceLogPage.qml"), {thing: root.thing }); + if (engine.jsonRpcClient.ensureServerVersion("8.0")) { + pageStack.push(Qt.resolvedUrl("../devicepages/ThingLogPage.qml"), {thing: root.thing }); + } else { + pageStack.push(Qt.resolvedUrl("../devicepages/DeviceLogPage.qml"), {thing: root.thing }); + } } function writeNfcTag() { diff --git a/nymea-app/ui/customviews/StateChart.qml b/nymea-app/ui/customviews/StateChart.qml new file mode 100644 index 00000000..8b1d19f2 --- /dev/null +++ b/nymea-app/ui/customviews/StateChart.qml @@ -0,0 +1,519 @@ +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 + + 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 + + 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: root.thing && root.stateType ? _engine : null + source: root.thing ? "state-" + thing.id + "-" + root.stateType.name : "" + // columns: [root.stateType.name] +// filter: root.stateType ? ({state: root.stateType.name}) : ({}) + startTime: new Date(d.startTime.getTime() - d.range * 60000) + endTime: new Date(d.endTime.getTime() + d.range * 60000) + sampleRate: d.sampleRate + + property double minValue + property double maxValue + + onEntriesAdded: { + print("**** entries added", index, entries.length, "entries in series:", valueSeries.count, "in model", logsModel.count) + if (valueSeries.count == 0) { + print("adding zero item", new Date()) + valueSeries.insert(0, new Date(), 0) + zeroSeries.ensureValue(new Date()) + } + + for (var i = 0; i < entries.length; i++) { + var entry = entries[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; + } + // for booleans, we'll insert the opposite value right before the new one so the position is doubled + // +1 because the is the "new" value at the beginning + var insertIdx = (index + i) * 2 + 1 + valueSeries.insert(insertIdx, entry.timestamp, value) + valueSeries.insert(insertIdx + 1, entry.timestamp.getTime() - 500, !value) + + if (insertIdx == 1) { + // first index, we'll have to update the "now" value + valueSeries.removePoints(0, 1); + valueSeries.insert(0, entry.timestamp.getTime() + 2000, value) + zeroSeries.ensureValue(new Date(entry.timestamp.getTime() + 2000)) + } + + } else { + var value = entry.values[root.stateType.name] + if (value == null) { + value = 0; + } + + minValue = minValue == undefined ? value : Math.min(minValue, value) + maxValue = maxValue == undefined ? value : Math.max(maxValue, value) + + var insertIdx = (index + i) + 1 + valueSeries.insert(insertIdx, entry.timestamp, value) + + if (insertIdx == 1) { + // first index, we'll have to update the "now" value + valueSeries.removePoints(0, 1); + valueSeries.insert(0, entry.timestamp.getTime() + 2000, value) + zeroSeries.ensureValue(new Date(entry.timestamp.getTime() + 2000)) + } + } + } + print("added entries. now in series:", valueSeries.count) + } + onEntriesRemoved: { + print("removing:", index, count, valueSeries.count) + if (root.stateType.type.toLowerCase() == "bool") { + valueSeries.removePoints((index * 2) + 1, count * 2) + } else { + valueSeries.removePoints(index + 1, count) + } + + zeroSeries.shrink() + } + + onEngineChanged: fetchLogs() + Component.onCompleted: fetchLogs() + + } + + 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 + // 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() + 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 + + ValueAxis { + id: valueAxis + min: logsModel.minValue == undefined || logsModel.minValue == 0 + ? 0 + : logsModel.minValue - 5 + max: logsModel.maxValue == undefined || logsModel.maxValue == 0 + ? 0 + : logsModel.maxValue + 5 + + labelFormat: "" + gridLineColor: Style.tileOverlayColor + labelsVisible: false + lineVisible: false + titleVisible: false + shadesVisible: false + + } + 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" + 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: root.stateType ? Types.toUiValue(((valueAxis.max - (index * valueAxis.max / (valueAxis.tickCount - 1)))), root.stateType.unit).toFixed(0) + 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.max.getTime(); y: 0 } + XYPoint { x: dateTimeAxis.min.getTime(); y: 0 } + function ensureValue(timestamp) { + if (count == 0) { + append(timestamp, 0) + } else if (count == 1) { + if (timestamp.getTime() < at(0).x) { + append(timestamp, 0) + } else { + insert(0, timestamp, 0) + } + } else { + if (timestamp.getTime() < at(1).x) { + remove(1) + append(timestamp, 0) + } else if (timestamp.getTime() > at(0).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 + } + } + } + + + 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 + visible: false + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + // opacity: selfProductionConsumptionSeries.opacity + MouseArea { + anchors.fill: parent + anchors.topMargin: -Style.smallMargins + anchors.bottomMargin: -Style.smallMargins + // onClicked: d.selectSeries(selfProductionConsumptionSeries) + } + Row { + anchors.centerIn: parent + spacing: Style.smallMargins + ColorIcon { + name: "weathericons/weather-clear-day" + size: Style.smallIconSize + color: Style.green + } + Label { + width: parent.parent.width - x + elide: Text.ElideRight + visible: legend.width > 500 + text: qsTr("Produced") + anchors.verticalCenter: parent.verticalCenter + font: Style.smallFont + } + } + } + } + + 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: logsModel.fetchLogs() + } + + Rectangle { + height: parent.height + width: 1 + color: Style.foregroundColor + x: Math.min(mouseArea.width, Math.max(0, toolTip.entryX)) + 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 var timestamp: new Date(d.startTime.getTime() + (mouseArea.mouseX * (d.endTime.getTime() - d.startTime.getTime()) / mouseArea.width) ) + property NewLogEntry entry: logsModel.find(timestamp) + + // 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 double value: toolTip.entry ? entry.values[root.stateType.name] : 0 + 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 + } + + Label { + Layout.fillWidth: true + elide: Text.ElideRight + property double value: toolTip.entry + ? (toolTip.entry.acquisition >= 0 ? toolTip.entry.consumption : Math.max(0, -toolTip.entry.production)) + : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: toolTip.entry == null + ? "" + : root.stateType.type.toLowerCase() == "bool" + ? root.stateType.displayName + ": " + (toolTip.value ? qsTr("Yes") : qsTr("No")) + : Types.toUiValue(toolTip.entry.values[root.stateType.name], root.stateType.unit).toFixed(root.roundTo) + Types.toUiUnit(root.stateType.unit) + font: Style.smallFont + } + + } + } + } + } + } + +} diff --git a/nymea-app/ui/delegates/InterfaceTile.qml b/nymea-app/ui/delegates/InterfaceTile.qml index d7086798..0e3213d9 100644 --- a/nymea-app/ui/delegates/InterfaceTile.qml +++ b/nymea-app/ui/delegates/InterfaceTile.qml @@ -57,7 +57,7 @@ MainPageTile { // Only one item? Go streight to the thing page if (thingsProxy.count === 1) { if (!iface) { - page = "GenericDevicePage.qml"; + page = "GenericThingPage.qml"; } else { page = NymeaUtils.interfaceListToDevicePage([iface.name]); } diff --git a/nymea-app/ui/devicepages/DeviceLogPage.qml b/nymea-app/ui/devicepages/DeviceLogPage.qml index fa421b37..60d74b72 100644 --- a/nymea-app/ui/devicepages/DeviceLogPage.qml +++ b/nymea-app/ui/devicepages/DeviceLogPage.qml @@ -36,6 +36,8 @@ import Nymea 1.0 import "../components" import "../customviews" +// Legacy for jsonrpc < 8.0 + Page { id: root diff --git a/nymea-app/ui/devicepages/GenericDevicePage.qml b/nymea-app/ui/devicepages/GenericThingPage.qml similarity index 96% rename from nymea-app/ui/devicepages/GenericDevicePage.qml rename to nymea-app/ui/devicepages/GenericThingPage.qml index 88001b53..65c927a8 100644 --- a/nymea-app/ui/devicepages/GenericDevicePage.qml +++ b/nymea-app/ui/devicepages/GenericThingPage.qml @@ -128,7 +128,12 @@ ThingPageBase { } onClicked: { swipe.close(); - pageStack.push(Qt.resolvedUrl("DeviceLogPage.qml"), {thing: root.thing, filterTypeIds: [model.id]}) + print("opening logs for", delegate.stateType) + if (engine.jsonRpcClient.ensureServerVersion("8.0")) { + pageStack.push(Qt.resolvedUrl("StateLogPage.qml"), {thing: root.thing, stateType: delegate.stateType}) + } else { + pageStack.push(Qt.resolvedUrl("DeviceLogPage.qml"), {thing: root.thing, filterTypeIds: [model.id]}) + } } } } @@ -269,16 +274,14 @@ ThingPageBase { when: !stateDelegate.valueCacheDirty && stateDelegate.pendingActionId === -1 } Binding { - target: stateDelegateLoader.item + target: stateDelegateLoader.item.hasOwnProperty("from") ? stateDelegateLoader.item : null property: "from" value: stateDelegate.thingState.minValue - when: stateDelegateLoader.item.hasOwnProperty("from") } Binding { - target: stateDelegateLoader.item + target: stateDelegateLoader.item.hasOwnProperty("to") ? stateDelegateLoader.item : null property: "to" value: stateDelegate.thingState.maxValue - when: stateDelegateLoader.item.hasOwnProperty("to") } Binding { target: stateDelegateLoader.item.hasOwnProperty("unit") ? stateDelegateLoader.item : null diff --git a/nymea-app/ui/devicepages/SensorDevicePage.qml b/nymea-app/ui/devicepages/SensorDevicePage.qml index 11ad9d7a..ddceecb9 100644 --- a/nymea-app/ui/devicepages/SensorDevicePage.qml +++ b/nymea-app/ui/devicepages/SensorDevicePage.qml @@ -142,13 +142,23 @@ ThingPageBase { property State state: root.thing.stateByName(interfaceStateMap[modelData]) property string interfaceName: modelData - sourceComponent: graphComponent + sourceComponent: engine.jsonRpcClient.ensureServerVersion("8.0") ? stateChartComponent : graphComponent } } } } + Component { + id: stateChartComponent + StateChart { + thing: root.thing + stateType: parent.stateType + color: app.interfaceToColor(interfaceName) + + } + } + Component { id: graphComponent diff --git a/nymea-app/ui/devicepages/StateLogPage.qml b/nymea-app/ui/devicepages/StateLogPage.qml index ff52e842..955546c7 100644 --- a/nymea-app/ui/devicepages/StateLogPage.qml +++ b/nymea-app/ui/devicepages/StateLogPage.qml @@ -42,6 +42,8 @@ Page { property Thing thing: null property StateType stateType: null + readonly property bool isLogged: thing.loggedStateTypeIds.indexOf(stateType.id) >= 0 + readonly property bool canShowGraph: { switch (root.stateType.type) { case "Int": @@ -58,71 +60,115 @@ Page { onBackPressed: pageStack.pop() } - LogsModelNg { - id: logsModelNg + NewLogsModel { + id: logsModel engine: _engine - thingId: root.thing.id - typeIds: [root.stateType.id] - live: true + columns: [root.stateType.name] + source: "states-" + root.thing.id + filter: ({state: root.stateType.name}) } - ColumnLayout { - anchors.fill: parent + Component.onCompleted: { + print("loaded statelogpage for", root.stateType) + } - TabBar { - id: tabBar + GridLayout { + anchors.fill: parent + columns: app.landscape ? 2 : 1 + + StateChart { Layout.fillWidth: true - visible: root.canShowGraph - TabButton { - text: qsTr("Log") - } - TabButton { - text: qsTr("Graph") - } + thing: root.thing + stateType: root.stateType } - SwipeView { - id: swipeView + ListView { + id: listView Layout.fillWidth: true Layout.fillHeight: true - currentIndex: tabBar.currentIndex - interactive: false + implicitWidth: 400 + model: logsModel + clip: true + ScrollBar.vertical: ScrollBar {} - GenericTypeLogView { - id: logView - width: swipeView.width - height: swipeView.height - - logsModel: logsModelNg - - onAddRuleClicked: { - var value = logView.logsModel.get(index).value - var typeId = logView.logsModel.get(index).typeId - var rule = engine.ruleManager.createNewRule(); - var stateEvaluator = rule.createStateEvaluator(); - stateEvaluator.stateDescriptor.thingId = thing.id; - stateEvaluator.stateDescriptor.stateTypeId = typeId; - stateEvaluator.stateDescriptor.value = value; - stateEvaluator.stateDescriptor.valueOperator = StateDescriptor.ValueOperatorEquals; - rule.setStateEvaluator(stateEvaluator); - rule.name = root.thing.name + " - " + stateType.displayName + " = " + value; - - var rulePage = pageStack.push(Qt.resolvedUrl("../magic/ThingRulesPage.qml"), {thing: root.thing}); - rulePage.addRule(rule); - } - } - - Loader { - id: graphLoader - width: swipeView.width - height: swipeView.height - Component.onCompleted: { - var source; - source = Qt.resolvedUrl("../customviews/GenericTypeGraph.qml"); - setSource(source, {thing: root.thing, stateType: root.stateType}) - } + delegate: NymeaItemDelegate { + width: listView.width + property NewLogEntry entry: logsModel.get(index) + text: entry.values[root.stateType.name] + subText: entry.timestamp.toLocaleString(Qt.locale()) + progressive: false + Component.onCompleted: print("delegate:", JSON.stringify(entry.values), root.stateType.name, entry.values[root.stateType.name]) } } + +// TabBar { +// id: tabBar +// Layout.fillWidth: true +// visible: root.canShowGraph +// TabButton { +// text: qsTr("Log") +// } +// TabButton { +// text: qsTr("Graph") +// } +// } + +// SwipeView { +// id: swipeView +// Layout.fillWidth: true +// Layout.fillHeight: true +// currentIndex: tabBar.currentIndex +// interactive: false + +// GenericTypeLogView { +// id: logView +// width: swipeView.width +// height: swipeView.height + +// logsModel: logsModelNg + +// onAddRuleClicked: { +// var value = logView.logsModel.get(index).value +// var typeId = logView.logsModel.get(index).typeId +// var rule = engine.ruleManager.createNewRule(); +// var stateEvaluator = rule.createStateEvaluator(); +// stateEvaluator.stateDescriptor.thingId = thing.id; +// stateEvaluator.stateDescriptor.stateTypeId = typeId; +// stateEvaluator.stateDescriptor.value = value; +// stateEvaluator.stateDescriptor.valueOperator = StateDescriptor.ValueOperatorEquals; +// rule.setStateEvaluator(stateEvaluator); +// rule.name = root.thing.name + " - " + stateType.displayName + " = " + value; + +// var rulePage = pageStack.push(Qt.resolvedUrl("../magic/ThingRulesPage.qml"), {thing: root.thing}); +// rulePage.addRule(rule); +// } +// } + +// Loader { +// id: graphLoader +// width: swipeView.width +// height: swipeView.height +// Component.onCompleted: { +// var source; +// source = Qt.resolvedUrl("../customviews/GenericTypeGraph.qml"); +// setSource(source, {thing: root.thing, stateType: root.stateType}) +// } +// } +// } + } + + EmptyViewPlaceholder { + anchors.centerIn: parent + width: parent.width - app.margins * 2 + title: qsTr("Logging not enabled") + text: qsTr("This state is not being logged.") + imageSource: "qrc:/styles/%1/logo.svg".arg(styleController.currentStyle) + buttonText: qsTr("Enable logging") + visible: !root.isLogged + onButtonClicked: { + + } + } } diff --git a/nymea-app/ui/devicepages/ThingLogPage.qml b/nymea-app/ui/devicepages/ThingLogPage.qml new file mode 100644 index 00000000..a71eef18 --- /dev/null +++ b/nymea-app/ui/devicepages/ThingLogPage.qml @@ -0,0 +1,397 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* 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("History for %1").arg(root.thing.name) + onBackPressed: pageStack.pop() + + HeaderButton { + imageSource: "../images/filters.svg" + color: logsModelNg.filterEnabled ? Style.accentColor : Style.iconColor + onClicked: logsModelNg.filterEnabled = !logsModelNg.filterEnabled + visible: root.filterTypeIds.length === 0 + } + } + + NewLogsModel { + id: logsModelNg + engine: _engine + columns: [root.stateType.name] + sources: ["states-" + root.thing.id, "events-" + root.thing.id, "actions-" + root.thing.id] + filter: { + if (!filterEnabled) { + return ({}) + } + print("*** filter updated", isStateFilter, isEventFilter, isActionFilter, filterTypeName, thing.thingClass.stateTypes.findByName(filterTypeName)) + if (isStateFilter) { + return ({state: filterTypeName}) + } + if (isEventFilter) { + return ({event: filterTypeName}) + } + if (isActionFilter) { + return ({action: filterTypeName}) + } + return ({}) + } + property string filterTypeName: filterDeviceModel.getData(filterComboBox.currentIndex, ThingModel.RoleName) + property bool isStateFilter: thing.thingClass.stateTypes.findByName(filterTypeName) !== null + property bool isEventFilter: thing.thingClass.eventTypes.findByName(filterTypeName) !== null + property bool isActionFilter: thing.thingClass.actionTypes.findByName(filterTypeName) !== null + + onFilterChanged: { + logsModelNg.clear() + logsModelNg.fetchLogs() + } + +// thingId: root.thing.id +// typeIds: root.filterTypeIds.length > 0 +// ? root.filterTypeIds +// : filterEnabled +// ? [filterDeviceModel.getData(filterComboBox.currentIndex, ThingModel.RoleId)] +// : [] +// live: true + + onEntriesAdded: { + console.log("entries added", JSON.stringify(entries)) + } + + property bool filterEnabled: false + } + + ThingModel { + id: filterDeviceModel + thing: root.thing + } + + Pane { + id: filterPane + anchors { left: parent.left; top: parent.top; right: parent.right } + Behavior on height { NumberAnimation { duration: 120; easing.type: Easing.InOutQuad } } + + height: logsModelNg.filterEnabled ? implicitHeight + app.margins * 2 : 0 + Material.elevation: 1 + + leftPadding: 0; rightPadding: 0; topPadding: 0; bottomPadding: 0 + contentItem: Item { + clip: true + RowLayout { + anchors.fill: parent + anchors.margins: app.margins + spacing: app.margins + Label { + text: qsTr("Filter by") + } + + ComboBox { + id: filterComboBox + Layout.fillWidth: true + textRole: "displayName" + model: filterDeviceModel + } + } + } + } + + Loader { + id: graphLoader + anchors { + left: parent.left + top: filterPane.bottom + right: parent.right + } + + readonly property StateType stateType: root.thing.thingClass.stateTypes.getStateType(root.filterTypeIds[0]) + + readonly property bool canShowGraph: { + if (stateType === null) { + return false + } + + if (stateType.unit === Types.UnitUnixTime) { + return false; + } + + switch (stateType.type.toLowerCase()) { + case "uint": + case "int": + case "double": + case "bool": + return true; + } + print("not showing graph for", stateType.type) + return false; + } + + Component.onCompleted: { + if (root.filterTypeIds.length === 0) { + return; + } + if (!canShowGraph) { + return; + } + + var source = Qt.resolvedUrl("../customviews/GenericTypeGraph.qml"); + setSource(source, {thing: root.thing, stateType: stateType}) + } + } + + + ListView { + anchors { left: parent.left; top: graphLoader.bottom; right: parent.right; bottom: parent.bottom } + clip: true + model: logsModelNg + ScrollBar.vertical: ScrollBar {} + + BusyIndicator { + anchors.centerIn: parent + visible: logsModelNg.busy + } + + delegate: ItemDelegate { + id: entryDelegate + width: parent.width + property NewLogEntry entry: logsModelNg.get(index) + + property StateType stateType: entry && entry.values.hasOwnProperty("state") ? root.thing.thingClass.stateTypes.findByName(entry.values.state) : null + property EventType eventType: entry && entry.values.hasOwnProperty("event") ? root.thing.thingClass.eventTypes.findByName(entry.values.event) : null + property ActionType actionType: entry && entry.values.hasOwnProperty("action") ? root.thing.thingClass.actionTypes.findByName(entry.values.action) : null + + contentItem: RowLayout { + ColorIcon { + Layout.preferredWidth: Style.iconSize + Layout.preferredHeight: width + Layout.alignment: Qt.AlignVCenter + color: Style.accentColor + name: { + if (entryDelegate.stateType) { + return "../images/state.svg" + } + if (entryDelegate.eventType) { + return "../images/event.svg" + } + if (entryDelegate.actionType) { + return "../images/action.svg" + } + } + } + ColumnLayout { + RowLayout { + Label { + text: { + if (entryDelegate.stateType) { + return entryDelegate.stateType.displayName + } + if (entryDelegate.eventType) { + return entryDelegate.eventType.displayName + } + if (entryDelegate.actionType) { + return entryDelegate.actionType.displayName + } + } + Layout.fillWidth: true + elide: Text.ElideRight + font: Style.smallFont + } + Label { + text: Qt.formatDateTime(model.timestamp,"dd.MM.yy hh:mm:ss") + elide: Text.ElideRight + font.pixelSize: app.smallFont + enabled: false + } + } + + RowLayout { + Loader { + id: valueLoader + Layout.fillWidth: true + sourceComponent: { + if (entryDelegate.stateType) { + switch (entryDelegate.stateType.type.toLowerCase()) { + case "bool": + return boolComponent; + case "color": + return colorComponent + case "double": + return floatLabelComponent; + default: + if (entryDelegate.stateType.unit == Types.UnitUnixTime) { + return dateTimeComponent + } + + return labelComponent + + } + + } + +// switch (model.source) { +// case LogEntry.LoggingSourceStates: +// case LogEntry.LoggingSourceActions: +// return labelComponent; +// case LogEntry.LoggingSourceEvents: + +// break; +// } + + return labelComponent + } + Binding { + when: entryDelegate.stateType != null + target: valueLoader.item; + property: "value"; + value: entryDelegate.stateType ? Types.toUiValue(entry.values[entry.values.state], entryDelegate.stateType.unit) : "" + } + Binding { + when: entryDelegate.stateType != null + target: entryDelegate.stateType && valueLoader.item.hasOwnProperty("unitString") ? valueLoader.item : null; + property: "unitString" + value: entryDelegate.stateType ? Types.toUiUnit(entryDelegate.stateType.unit) : "" + } + Binding { + when: entryDelegate.actionType != null + target: valueLoader.item; + property: "value"; + value: { + if (entryDelegate.actionType == null) { + return "" + } + + var ret = [] + var values = JSON.parse(model.values.params) + for (var i = 0; i < entryDelegate.actionType.paramTypes.count; i++) { + var paramType = entryDelegate.actionType.paramTypes.get(i) + ret.push(paramType.displayName + ": " + Types.toUiValue(values[paramType.name], paramType.unit) + " " + Types.toUiUnit(paramType.unit)) + } + return ret.join(", ") + } + } + Binding { + when: entryDelegate.eventType != null + target: valueLoader.item; + property: "value"; + value: { + if (entryDelegate.eventType == null) { + return "" + } + + var ret = [] + var values = JSON.parse(entry.values.params) + for (var i = 0; i < entryDelegate.eventType.paramTypes.count; i++) { + var paramType = entryDelegate.eventType.paramTypes.get(i) + ret.push(paramType.displayName + ": " + Types.toUiValue(values[paramType.name], paramType.unit) + " " + Types.toUiUnit(paramType.unit)) + } + return ret.join(", ") + } + } + } + } + } + } + } + } + + Component { + id: labelComponent + Label { + property var value + property string unitString + text: value + " " + unitString + font: Style.smallFont + elide: Text.ElideRight + } + } + + Component { + id: floatLabelComponent + Label { + property double value + property string unitString + text: value.toFixed(value > 1000 ? 0 : 2) + " " + unitString + font: Style.smallFont + elide: Text.ElideRight + } + } + + Component { + id: dateTimeComponent + Label { + property var value + font: Style.smallFont + text: Qt.formatDateTime(new Date(value * 1000), Qt.DefaultLocaleShortDate) + } + } + + Component { + id: boolComponent + RowLayout { + id: boolLed + property var value + Led { + implicitHeight: app.smallFont + state: boolLed.value === "true" ? "on" : "off" + } + Label { + font: Style.smallFont + text: boolLed.value === "true" ? qsTr("Yes") : qsTr("No") + Layout.fillWidth: true + } + } + } + + Component { + id: colorComponent + Item { + property var value + implicitHeight: app.smallFont + Rectangle { + height: parent.height + width: height * 2 + color: parent.value + // radius: width / 2 + border.color: Style.foregroundColor + border.width: 1 + } + } + } +} diff --git a/nymea-app/ui/devicepages/WeatherDevicePage.qml b/nymea-app/ui/devicepages/WeatherDevicePage.qml index b1c7fa54..b745ffc4 100644 --- a/nymea-app/ui/devicepages/WeatherDevicePage.qml +++ b/nymea-app/ui/devicepages/WeatherDevicePage.qml @@ -57,33 +57,125 @@ ThingPageBase { Layout.fillWidth: true columns: Math.min(width / 300, 4) - GenericTypeGraph { + Loader { Layout.fillWidth: true - thing: root.thing - stateType: root.thingClass.stateTypes.findByName("temperature") - iconSource: app.interfaceToIcon("temperaturesensor") - color: app.interfaceToColor("temperaturesensor") + sourceComponent: { + if (engine.jsonRpcClient.ensureServerVersion("8.0")) { + return tempComponent + } + return tempComponentPre18 + } } - GenericTypeGraph { - Layout.fillWidth: true - thing: root.thing - stateType: root.thingClass.stateTypes.findByName("humidity") - iconSource: app.interfaceToIcon("humiditysensor") - color: app.interfaceToColor("humiditysensor") + + Component { + id: tempComponent + StateChart { + thing: root.thing + stateType: root.thingClass.stateTypes.findByName("temperature") + color: app.interfaceToColor("temperaturesensor") + } } - GenericTypeGraph { - Layout.fillWidth: true - thing: root.thing - stateType: root.thingClass.stateTypes.findByName("pressure") - iconSource: app.interfaceToIcon("pressuresensor") - color: app.interfaceToColor("pressuresensor") + + Component { + id: tempComponentPre18 + + GenericTypeGraph { + Layout.fillWidth: true + thing: root.thing + stateType: root.thingClass.stateTypes.findByName("temperature") + iconSource: app.interfaceToIcon("temperaturesensor") + color: app.interfaceToColor("temperaturesensor") + } } - GenericTypeGraph { + + Loader { Layout.fillWidth: true - thing: root.thing - stateType: root.thingClass.stateTypes.findByName("windSpeed") - iconSource: app.interfaceToIcon("windspeedsensor") - color: app.interfaceToColor("windspeedsensor") + sourceComponent: { + if (engine.jsonRpcClient.ensureServerVersion("8.0")) { + return humidityComponent + } + return humidityComponentPre18 + } + } + + Component { + id: humidityComponent + StateChart { + thing: root.thing + stateType: root.thingClass.stateTypes.findByName("humidity") + color: app.interfaceToColor("humiditysensor") + } + } + + Component { + id: humidityComponentPre18 + GenericTypeGraph { + Layout.fillWidth: true + thing: root.thing + stateType: root.thingClass.stateTypes.findByName("humidity") + iconSource: app.interfaceToIcon("humiditysensor") + color: app.interfaceToColor("humiditysensor") + } + } + + Loader { + Layout.fillWidth: true + sourceComponent: { + if (engine.jsonRpcClient.ensureServerVersion("8.0")) { + return pressureComponent + } + return pressureComponentPre18 + } + } + + Component { + id: pressureComponent + StateChart { + thing: root.thing + stateType: root.thingClass.stateTypes.findByName("pressure") + color: app.interfaceToColor("pressuresensor") + } + } + + Component { + id: pressureComponentPre18 + GenericTypeGraph { + Layout.fillWidth: true + thing: root.thing + stateType: root.thingClass.stateTypes.findByName("pressure") + iconSource: app.interfaceToIcon("pressuresensor") + color: app.interfaceToColor("pressuresensor") + } + } + + Loader { + Layout.fillWidth: true + sourceComponent: { + if (engine.jsonRpcClient.ensureServerVersion("8.0")) { + return windSpeedComponent + } + return windSpeedComponentPre18 + } + } + + Component { + id: windSpeedComponent + StateChart { + thing: root.thing + stateType: root.thingClass.stateTypes.findByName("windSpeed") + color: app.interfaceToColor("windspeedsensor") + } + } + + Component { + id: windSpeedComponentPre18 + GenericTypeGraph { + Layout.fillWidth: true + thing: root.thing + stateType: root.thingClass.stateTypes.findByName("windSpeed") + iconSource: app.interfaceToIcon("windspeedsensor") + color: app.interfaceToColor("windspeedsensor") + } } } } diff --git a/nymea-app/ui/images/system-log-out.svg b/nymea-app/ui/images/system-log-out.svg new file mode 100644 index 00000000..7fd86aeb --- /dev/null +++ b/nymea-app/ui/images/system-log-out.svg @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/nymea-app/ui/images/system-restart.svg b/nymea-app/ui/images/system-restart.svg new file mode 100644 index 00000000..a80b7e9c --- /dev/null +++ b/nymea-app/ui/images/system-restart.svg @@ -0,0 +1,18 @@ + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/nymea-app/ui/mainviews/airconditioning/ACChartsPage.qml b/nymea-app/ui/mainviews/airconditioning/ACChartsPage.qml index 495dea35..fa003883 100644 --- a/nymea-app/ui/mainviews/airconditioning/ACChartsPage.qml +++ b/nymea-app/ui/mainviews/airconditioning/ACChartsPage.qml @@ -31,6 +31,7 @@ Page { id: d property date now: new Date() + property int sampleRate: NewLogsModel.SampleRate15Mins property int range: 60 * 24 @@ -203,28 +204,29 @@ Page { readonly property Thing thing: zoneWrapper.thermostats.get(index) property XYSeries series: null - readonly property LogsModel logsModel: LogsModel { + readonly property NewLogsModel logsModel: NewLogsModel { objectName: "temp: " + thing.name - engine: typeIds.length > 0 ? _engine : null - thingId: thing.id - live: true - sourceFilter: LogsModel.SourceStates - // graphSeries: series - viewStartTime: new Date(d.startTime.getTime() - d.range * 60000) + engine: _engine + source: "states-" + thing.id + filter: ({state: "temperature"}) + startTime: new Date(d.startTime.getTime() - d.range * 60000) + endTime: new Date(d.endTime.getTime() + d.range * 60000) + sampleRate: d.sampleRate - fetchBlockSize: 500 - - typeIds: { - var ret = []; - ret.push(thing.thingClass.stateTypes.findByName("temperature").id) - return ret; + onEntriesAdded: { + for (var i = 0; i < entries.length; i++) { + var entry = entries[i] + var value = entry.values["temperature"] + if (value == null) { + value = 0; + } + series.insert(index + i, entry.timestamp, value) + } } - } - - XYSeriesAdapter { - logsModel: thermostatDelegate.logsModel - xySeries: series - sampleRate: XYSeriesAdapter.SampleRate10Minutes + onEntriesRemoved: { + series.removePoints(index, count) + } + Component.onCompleted: fetchLogs() } Component.onCompleted: { @@ -253,28 +255,29 @@ Page { readonly property Thing thing: zoneWrapper.indoorTempSensors.get(index) property XYSeries series: null - readonly property LogsModel logsModel: LogsModel { + readonly property NewLogsModel logsModel: NewLogsModel { objectName: "temp: " + thing.name - engine: typeIds.length > 0 ? _engine : null - thingId: thing.id - sourceFilter: LogsModel.SourceStates - live: true - // graphSeries: series - viewStartTime: new Date(d.startTime.getTime() - d.range * 60000) - - fetchBlockSize: 500 - - typeIds: { - var ret = []; - ret.push(thing.thingClass.stateTypes.findByName("temperature").id) - return ret; + engine: _engine + source: "states-" + thing.id + filter: ({state: "temperature"}) + startTime: new Date(d.startTime.getTime() - d.range * 60000) + endTime: new Date(d.endTime.getTime() + d.range * 60000) + sampleRate: d.sampleRate + onEntriesAdded: { + for (var i = 0; i < entries.length; i++) { + var entry = entries[i] + var value = entry.values["temperature"] + if (value == null) { + value = 0; + } + series.insert(index + i, entry.timestamp, value) + } } - } + onEntriesRemoved: { + series.removePoints(index, count) + } + Component.onCompleted: fetchLogs() - XYSeriesAdapter { - logsModel: tempDelegate.logsModel - xySeries: series - sampleRate: XYSeriesAdapter.SampleRate10Minutes } Component.onCompleted: { @@ -303,27 +306,29 @@ Page { readonly property Thing thing: zoneWrapper.indoorHumiditySensors.get(index) property XYSeries series: null - readonly property LogsModel logsModel: LogsModel { + readonly property NewLogsModel logsModel: NewLogsModel { objectName: "hum: " + thing.name - engine: typeIds.length > 0 ? _engine : null - thingId: thing.id - sourceFilter: LogsModel.SourceStates - live: true - // graphSeries: series - viewStartTime: new Date(d.startTime.getTime() - d.range * 60000) - fetchBlockSize: 500 + engine: _engine + source: "states-" + thing.id + filter: ({state: "humidity"}) + startTime: new Date(d.startTime.getTime() - d.range * 60000) + endTime: new Date(d.endTime.getTime() + d.range * 60000) + sampleRate: d.sampleRate - typeIds: { - var ret = []; - ret.push(thing.thingClass.stateTypes.findByName("humidity").id) - return ret; + onEntriesAdded: { + for (var i = 0; i < entries.length; i++) { + var entry = entries[i] + var value = entry.values["humidity"] + if (value == null) { + value = 0; + } + series.insert(index + i, entry.timestamp, value) + } } - } - - XYSeriesAdapter { - logsModel: humidityDelegate.logsModel - xySeries: series - sampleRate: XYSeriesAdapter.SampleRate10Minutes + onEntriesRemoved: { + series.removePoints(index, count) + } + Component.onCompleted: fetchLogs() } Component.onCompleted: { @@ -345,7 +350,7 @@ Page { Repeater { id: vocRepeater - model: zoneWrapper.indoorVocSensors +// model: zoneWrapper.indoorVocSensors delegate: Item { id: vocDelegate readonly property Thing thing: zoneWrapper.indoorVocSensors.get(index) @@ -391,7 +396,7 @@ Page { } Repeater { - model: zoneWrapper.windowSensors +// model: zoneWrapper.windowSensors delegate: Item { id: closableDelegate readonly property Thing thing: zoneWrapper.windowSensors.get(index) @@ -446,7 +451,7 @@ Page { } Repeater { - model: zoneWrapper.thermostats.count +// model: zoneWrapper.thermostats.count delegate: Item { id: heatingDelegate readonly property Thing thing: zoneWrapper.thermostats.get(index) diff --git a/nymea-app/ui/mainviews/dashboard/DashboardGraphDelegate.qml b/nymea-app/ui/mainviews/dashboard/DashboardGraphDelegate.qml index 23ff1e83..868c16ce 100644 --- a/nymea-app/ui/mainviews/dashboard/DashboardGraphDelegate.qml +++ b/nymea-app/ui/mainviews/dashboard/DashboardGraphDelegate.qml @@ -45,19 +45,35 @@ DashboardDelegateBase { readonly property StateType stateType: thing ? thing.thingClass.stateTypes.getStateType(item.stateTypeId) : null readonly property State state: thing ? thing.states.getState(item.stateTypeId) : null - contentItem: GenericTypeGraph { + contentItem: StateChart { id: graph width: root.width height: root.height - title: root.state && root.stateType ? root.thing.name + " " + Types.toUiValue(root.state.value, root.stateType.unit) + Types.toUiUnit(root.stateType.unit) : "" + title: root.state && root.stateType ? root.thing.name + ", " + root.stateType.displayName + ": " + Types.toUiValue(root.state.value, root.stateType.unit).toFixed(0) + Types.toUiUnit(root.stateType.unit) : "" thing: root.thing - color: "blue"//app.interfaceToColor(interfaceName) - iconSource: ""// app.interfaceToIcon(interfaceName) + color: root.thing ? app.interfaceToColor(root.thing.thingClass.interfaces[0]) : Style.accentColor +// iconSource: ""// app.interfaceToIcon(interfaceName) implicitHeight: width * .6 // property string interfaceName: parent.interfaceName stateType: root.stateType - property State state: root.state +// property State state: root.state + } + +// contentItem: GenericTypeGraph { +// id: graph +// width: root.width +// height: root.height +// title: root.state && root.stateType ? root.thing.name + " " + Types.toUiValue(root.state.value, root.stateType.unit) + Types.toUiUnit(root.stateType.unit) : "" + +// thing: root.thing +// color: "blue"//app.interfaceToColor(interfaceName) +// iconSource: ""// app.interfaceToIcon(interfaceName) +// implicitHeight: width * .6 +//// property string interfaceName: parent.interfaceName +// stateType: root.stateType +// property State state: root.state +// } } diff --git a/nymea-app/ui/system/LogViewerPage.qml b/nymea-app/ui/system/LogViewerPage.qml index 1b2c74f0..70b2f9de 100644 --- a/nymea-app/ui/system/LogViewerPage.qml +++ b/nymea-app/ui/system/LogViewerPage.qml @@ -55,10 +55,17 @@ Page { LogsModel { id: logsModel - engine: _engine + //engine: _engine live: true } + NewLogsModel { + id: newLogsModel + engine: _engine +// sources: ["core", "rules", "scripts"] + source: "core" + } + BusyIndicator { anchors.centerIn: listView visible: logsModel.busy @@ -66,7 +73,7 @@ Page { ListView { id: listView - model: logsModel + model: newLogsModel anchors.fill: parent clip: true headerPositioning: ListView.OverlayHeader @@ -84,14 +91,28 @@ Page { visible: listView.model.busy } - delegate: ItemDelegate { + delegate: NymeaItemDelegate { id: delegate - width: parent.width - property Thing thing: engine.thingManager.things.getThing(model.thingId) + width: listView.width leftPadding: 0 rightPadding: 0 topPadding: 0 bottomPadding: 0 + property NewLogEntry entry: newLogsModel.get(index) + property string event: entry.values.event + property string shutdownReason: { + switch (entry.values.shutdownReason) { + case "ShutdownReasonTerm": + return qsTr("Terminated by system") + case "ShutdownReasonQuit": + return qsTr("Application quit") + case "ShutdownReasonFailure": + return qsTr("Application error") + default: + return qsTr("Unknown reason") + } + } + contentItem: RowLayout { id: contentColumn anchors { left: parent.left; right: parent.right; margins: app.margins / 2 } @@ -99,32 +120,24 @@ Page { Layout.preferredWidth: Style.iconSize Layout.preferredHeight: width Layout.alignment: Qt.AlignVCenter - color: { - switch (model.source) { - case LogEntry.LoggingSourceStates: - case LogEntry.LoggingSourceSystem: - case LogEntry.LoggingSourceActions: - case LogEntry.LoggingSourceEvents: - return Style.accentColor - case LogEntry.LoggingSourceRules: - if (model.loggingEventType === LogEntry.LoggingEventTypeActiveChange) { - return model.value === true ? "green" : Style.iconColor - } - return Style.accentColor - } - } + color: delegate.event == "started" + ? Style.accentColor + : delegate.entry.values.shutdownReason === "ShutdownReasonFailure" + ? Style.red + : Style.iconColor name: { - switch (model.source) { - case LogEntry.LoggingSourceStates: - return "../images/state.svg" - case LogEntry.LoggingSourceSystem: - return "../images/system-shutdown.svg" - case LogEntry.LoggingSourceActions: - return "../images/action.svg" - case LogEntry.LoggingSourceEvents: - return "../images/event.svg" - case LogEntry.LoggingSourceRules: - return "../images/magic.svg" + switch (delegate.event) { + case "started": + return "system-restart" + case "stopped": + switch (delegate.entry.values.shutdownReason) { + case "ShutdownReasonQuit": + return "system-logout" + case "ShutdownReasonTerm": + return "system-shutdown" + case "ShutdownReasonFailure": + return "dialog-error-symbolic" + } } } } @@ -132,54 +145,30 @@ Page { RowLayout { Label { Layout.fillWidth: true - text: model.source === LogEntry.LoggingSourceSystem ? - qsTr("%1 Server").arg(Configuration.systemName) - : model.source === LogEntry.LoggingSourceRules ? - engine.ruleManager.rules.getRule(model.typeId).name - : delegate.thing.name + text: { + switch (delegate.event) { + case "started": + return qsTr("Started") + case "stopped": + return qsTr("Stopped") + default: + console.warn("LogViewer: Unhand event", delegate.event) + return qsTr(delegate.event) + } + } elide: Text.ElideRight } Label { text: Qt.formatDateTime(model.timestamp,"dd.MM.yy - hh:mm:ss") elide: Text.ElideRight - font.pixelSize: app.smallFont + font: Style.smallFont } } Label { - text : { - switch (model.source) { - case LogEntry.LoggingSourceStates: - var stateType = delegate.thing.thingClass.stateTypes.getStateType(model.typeId); - return "%1 -> %2 %3".arg(stateType.displayName).arg(Types.toUiValue(model.value, stateType.unit)).arg(Types.toUiUnit(stateType.unit)); - case LogEntry.LoggingSourceSystem: - return model.loggingEventType === LogEntry.LoggingEventTypeActiveChange ? qsTr("System started") : "N/A" - case LogEntry.LoggingSourceActions: - return "%1 (%2)".arg(delegate.thing.thingClass.actionTypes.getActionType(model.typeId).displayName).arg(model.value); - case LogEntry.LoggingSourceEvents: - return "%1 (%2)".arg(delegate.thing.thingClass.eventTypes.getEventType(model.typeId).displayName).arg(model.value); - case LogEntry.LoggingSourceRules: - switch (model.loggingEventType) { - case LogEntry.LoggingEventTypeTrigger: - return qsTr("Rule triggered"); - case LogEntry.LoggingEventTypeActionsExecuted: - return qsTr("Actions executed"); - case LogEntry.LoggingEventTypeActiveChange: - return model.value === true ? qsTr("Rule active") : qsTr("Rule inactive") - case LogEntry.LoggingEventTypeExitActionsExecuted: - return qsTr("Exit actions executed"); - case LogEntry.LoggingEventTypeEnabledChange: - return qsTr("Enabled changed"); - default: - print("Unhandled logging event type", model.loggingEventType) - } - return "N/A" - default: - print("unhandled logging source:", model.source) - } - return "N/A"; - } + text: delegate.shutdownReason + visible: delegate.event == "stopped" elide: Text.ElideRight - font.pixelSize: app.smallFont + font: Style.smallFont } } } diff --git a/nymea-app/ui/system/LogViewerPagePre18.qml b/nymea-app/ui/system/LogViewerPagePre18.qml new file mode 100644 index 00000000..1b2c74f0 --- /dev/null +++ b/nymea-app/ui/system/LogViewerPagePre18.qml @@ -0,0 +1,188 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, 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.8 +import QtQuick.Layouts 1.2 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.1 +import Nymea 1.0 +import "../components" + +Page { + id: root + header: NymeaHeader { + text: qsTr("Log viewer") + onBackPressed: pageStack.pop() + + HeaderButton { + imageSource: "../images/down.svg" + color: root.autoScroll ? Style.accentColor : Style.iconColor + onClicked: { + listView.positionViewAtEnd(); + root.autoScroll = !root.autoScroll + } + } + } + + property bool autoScroll: true + + LogsModel { + id: logsModel + engine: _engine + live: true + } + + BusyIndicator { + anchors.centerIn: listView + visible: logsModel.busy + } + + ListView { + id: listView + model: logsModel + anchors.fill: parent + clip: true + headerPositioning: ListView.OverlayHeader + + onDraggingChanged: { + if (dragging) { + root.autoScroll = false; + } + } + + ScrollBar.vertical: ScrollBar {} + + BusyIndicator { + anchors.centerIn: parent + visible: listView.model.busy + } + + delegate: ItemDelegate { + id: delegate + width: parent.width + property Thing thing: engine.thingManager.things.getThing(model.thingId) + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + contentItem: RowLayout { + id: contentColumn + anchors { left: parent.left; right: parent.right; margins: app.margins / 2 } + ColorIcon { + Layout.preferredWidth: Style.iconSize + Layout.preferredHeight: width + Layout.alignment: Qt.AlignVCenter + color: { + switch (model.source) { + case LogEntry.LoggingSourceStates: + case LogEntry.LoggingSourceSystem: + case LogEntry.LoggingSourceActions: + case LogEntry.LoggingSourceEvents: + return Style.accentColor + case LogEntry.LoggingSourceRules: + if (model.loggingEventType === LogEntry.LoggingEventTypeActiveChange) { + return model.value === true ? "green" : Style.iconColor + } + return Style.accentColor + } + } + name: { + switch (model.source) { + case LogEntry.LoggingSourceStates: + return "../images/state.svg" + case LogEntry.LoggingSourceSystem: + return "../images/system-shutdown.svg" + case LogEntry.LoggingSourceActions: + return "../images/action.svg" + case LogEntry.LoggingSourceEvents: + return "../images/event.svg" + case LogEntry.LoggingSourceRules: + return "../images/magic.svg" + } + } + } + ColumnLayout { + RowLayout { + Label { + Layout.fillWidth: true + text: model.source === LogEntry.LoggingSourceSystem ? + qsTr("%1 Server").arg(Configuration.systemName) + : model.source === LogEntry.LoggingSourceRules ? + engine.ruleManager.rules.getRule(model.typeId).name + : delegate.thing.name + elide: Text.ElideRight + } + Label { + text: Qt.formatDateTime(model.timestamp,"dd.MM.yy - hh:mm:ss") + elide: Text.ElideRight + font.pixelSize: app.smallFont + } + } + Label { + text : { + switch (model.source) { + case LogEntry.LoggingSourceStates: + var stateType = delegate.thing.thingClass.stateTypes.getStateType(model.typeId); + return "%1 -> %2 %3".arg(stateType.displayName).arg(Types.toUiValue(model.value, stateType.unit)).arg(Types.toUiUnit(stateType.unit)); + case LogEntry.LoggingSourceSystem: + return model.loggingEventType === LogEntry.LoggingEventTypeActiveChange ? qsTr("System started") : "N/A" + case LogEntry.LoggingSourceActions: + return "%1 (%2)".arg(delegate.thing.thingClass.actionTypes.getActionType(model.typeId).displayName).arg(model.value); + case LogEntry.LoggingSourceEvents: + return "%1 (%2)".arg(delegate.thing.thingClass.eventTypes.getEventType(model.typeId).displayName).arg(model.value); + case LogEntry.LoggingSourceRules: + switch (model.loggingEventType) { + case LogEntry.LoggingEventTypeTrigger: + return qsTr("Rule triggered"); + case LogEntry.LoggingEventTypeActionsExecuted: + return qsTr("Actions executed"); + case LogEntry.LoggingEventTypeActiveChange: + return model.value === true ? qsTr("Rule active") : qsTr("Rule inactive") + case LogEntry.LoggingEventTypeExitActionsExecuted: + return qsTr("Exit actions executed"); + case LogEntry.LoggingEventTypeEnabledChange: + return qsTr("Enabled changed"); + default: + print("Unhandled logging event type", model.loggingEventType) + } + return "N/A" + default: + print("unhandled logging source:", model.source) + } + return "N/A"; + } + elide: Text.ElideRight + font.pixelSize: app.smallFont + } + } + } + } + } +} diff --git a/nymea-app/ui/utils/NymeaUtils.qml b/nymea-app/ui/utils/NymeaUtils.qml index 5f0e45f9..1e185a75 100644 --- a/nymea-app/ui/utils/NymeaUtils.qml +++ b/nymea-app/ui/utils/NymeaUtils.qml @@ -74,7 +74,7 @@ Item { } else if (interfaceList.indexOf("cleaningrobot") >= 0) { page = "CleaningRobotThingPage.qml"; } else { - page = "GenericDevicePage.qml"; + page = "GenericThingPage.qml"; } print("Selecting page", page, "for interface list:", interfaceList) return page;