This repository has been archived on 2026-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
Michael Zanetti 2a91dad1f0 Don't generate events for state changes any more
Up until now, nymea would generate EventTypes for every StateType
as well as emit an Event (along with a StateChanged notification) for
every change. This results in a lot of duplicated network traffic
which is of not much use. The StateChanged notification contains
all the information in the Event too and nymea:app actually never
really used Events for state changes.

This commit removes the events from the ThingClass, making it a lot
smaller and stops emitting Events for state changes.

As this is breaking the behavior, the JSONRPC API major version
is bumped.
2022-02-08 10:38:41 +01:00

489 lines
16 KiB
C++

/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* 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 <https://www.gnu.org/licenses/>.
*
* 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
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "scriptengine.h"
#include "integrations/thingmanager.h"
#include "scriptaction.h"
#include "scriptevent.h"
#include "scriptstate.h"
#include "scriptalarm.h"
#include "scriptinterfaceaction.h"
#include "scriptinterfacestate.h"
#include "scriptinterfaceevent.h"
#include "nymeasettings.h"
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQmlComponent>
#include <QJsonParseError>
#include <QJsonDocument>
#include "loggingcategories.h"
#include <QDir>
namespace nymeaserver {
QList<ScriptEngine*> ScriptEngine::s_engines;
QtMessageHandler ScriptEngine::s_upstreamMessageHandler;
QLoggingCategory::CategoryFilter ScriptEngine::s_oldCategoryFilter = nullptr;
QMutex ScriptEngine::s_loggerMutex;
ScriptEngine::ScriptEngine(ThingManager *thingManager, QObject *parent) : QObject(parent),
m_thingManager(thingManager)
{
qmlRegisterType<ScriptEvent>("nymea", 1, 0, "ThingEvent");
qmlRegisterType<ScriptAction>("nymea", 1, 0, "ThingAction");
qmlRegisterType<ScriptState>("nymea", 1, 0, "ThingState");
qmlRegisterType<ScriptInterfaceAction>("nymea", 1, 0, "InterfaceAction");
qmlRegisterType<ScriptInterfaceState>("nymea", 1, 0, "InterfaceState");
qmlRegisterType<ScriptInterfaceEvent>("nymea", 1, 0, "InterfaceEvent");
qmlRegisterType<ScriptAlarm>("nymea", 1, 0, "Alarm");
m_engine = new QQmlEngine(this);
m_engine->setProperty("thingManager", reinterpret_cast<quint64>(m_thingManager));
// Don't automatically print script warnings (that is, runtime errors, *not* console.warn() messages)
// to stdout as they'd end up on the "default" logging category.
// We collect them ourselves through the warnings() signal and print them to the dcScriptEngine category.
m_engine->setOutputWarningsToStandardError(false);
connect(m_engine, &QQmlEngine::warnings, this, [this](const QList<QQmlError> &warnings){
foreach (const QQmlError &warning, warnings) {
QMessageLogContext ctx(warning.url().toString().toUtf8(), warning.line(), "", "ScriptEngine");
// Send to script logs
onScriptMessage(
#if QT_VERSION >= QT_VERSION_CHECK(5,9,0)
warning.messageType(),
#else
QtMsgType::QtWarningMsg,
#endif
ctx, warning.description());
// and to logging system
qCWarning(dcScriptEngine()) << warning.toString();
}
});
// console.log()/warn() messages instead are printed to the "qml" category. We install our own
// filter to *always* get them, regardless of the configured logging categories
if (!s_oldCategoryFilter) {
s_oldCategoryFilter = QLoggingCategory::installFilter(&logCategoryFilter);
}
// and our own handler to redirect them to the ScriptEngine category
if (s_engines.isEmpty()) {
s_upstreamMessageHandler = qInstallMessageHandler(&logMessageHandler);
}
s_engines.append(this);
QDir dir;
if (!dir.exists(NymeaSettings::storagePath() + "/scripts/")) {
dir.mkpath(NymeaSettings::storagePath() + "/scripts/");
}
loadScripts();
}
ScriptEngine::~ScriptEngine()
{
foreach (Script *script, m_scripts) {
unloadScript(script);
delete script;
}
s_engines.removeAll(this);
if (s_engines.isEmpty()) {
qInstallMessageHandler(s_upstreamMessageHandler);
}
}
Scripts ScriptEngine::scripts()
{
Scripts ret;
foreach (Script *script, m_scripts) {
ret.append(*script);
}
return ret;
}
ScriptEngine::GetScriptReply ScriptEngine::scriptContent(const QUuid &id)
{
GetScriptReply reply;
if (!m_scripts.contains(id)) {
reply.scriptError = ScriptErrorScriptNotFound;
return reply;
}
QFile scriptFile(baseName(id) + ".qml");
if (!scriptFile.open(QFile::ReadOnly)) {
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
reply.content = scriptFile.readAll();
reply.scriptError = ScriptErrorNoError;
scriptFile.close();
return reply;
}
ScriptEngine::AddScriptReply ScriptEngine::addScript(const QString &name, const QByteArray &content)
{
QUuid id = QUuid::createUuid();
QString fileName = baseName(id) + ".qml";
QString jsonFileName = baseName(id) + ".json";
AddScriptReply reply;
QFile jsonFile(jsonFileName);
if (!jsonFile.open(QFile::ReadWrite)) {
qCWarning(dcScriptEngine()) << "Error opening script metadata" << jsonFileName;
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
QVariantMap metadata;
metadata.insert("name", name);
jsonFile.write(QJsonDocument::fromVariant(metadata).toJson());
jsonFile.close();
QFile scriptFile(fileName);
if (!scriptFile.open(QFile::WriteOnly)) {
qCWarning(dcScriptEngine()) << "Error opening script file:" << fileName;
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
qint64 len = scriptFile.write(content);
if (len != content.length()) {
qCWarning(dcScriptEngine()) << "Error writing script content";
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
scriptFile.close();
Script *script = new Script();
script->setId(id);
script->setName(name);
bool loaded = loadScript(script);
if (!loaded) {
reply.scriptError = ScriptErrorInvalidScript;
reply.errors = script->errors;
delete script;
QFile::remove(jsonFileName);
QFile::remove(fileName);
return reply;
}
m_scripts.insert(script->id(), script);
reply.scriptError = ScriptErrorNoError;
reply.script = *m_scripts.value(id);
emit scriptAdded(reply.script);
return reply;
}
ScriptEngine::ScriptError ScriptEngine::renameScript(const QUuid &id, const QString &name)
{
if (!m_scripts.contains(id)) {
qCWarning(dcScriptEngine()) << "No script with id" << id;
return ScriptErrorScriptNotFound;
}
QString jsonFileName = baseName(id) + ".json";
QFile jsonFile(jsonFileName);
if (!jsonFile.open(QFile::ReadWrite)) {
qCWarning(dcJsonRpc()) << "Erorr opening script json file" << jsonFileName;
return ScriptErrorHardwareFailure;
}
QJsonParseError error;
QJsonDocument jsonDocument = QJsonDocument::fromJson(jsonFile.readAll(), &error);
QVariantMap jsonData = jsonDocument.toVariant().toMap();
if (error.error != QJsonParseError::NoError) {
qCWarning(dcScriptEngine()) << "Error parsing json file. Recreating it...";
// This is non-critical as we could open it. We can recreate it now.
}
jsonData["name"] = name;
QByteArray jsonString = QJsonDocument::fromVariant(jsonData).toJson();
if (!jsonFile.resize(0) || jsonFile.write(jsonString) != jsonString.length()) {
qCWarning(dcScriptEngine()) << "Error writing json metadata" << jsonFileName;
return ScriptErrorHardwareFailure;
}
jsonFile.close();
m_scripts[id]->setName(name);
qCDebug(dcScriptEngine()) << "Script" << id << "renamed to" << name;
emit scriptRenamed(*m_scripts.value(id));
return ScriptErrorNoError;
}
ScriptEngine::EditScriptReply ScriptEngine::editScript(const QUuid &id, const QByteArray &content)
{
EditScriptReply reply;
if (!m_scripts.contains(id)) {
qCWarning(dcScriptEngine()) << "No script with id" << id;
reply.scriptError = ScriptErrorScriptNotFound;
return reply;
}
Script *script = m_scripts.value(id);
unloadScript(script);
// Deleted compiled qml file to make sure we're reloading the new one
QString compiledScriptFileName = baseName(id) + ".qmlc";
QFile::remove(compiledScriptFileName);
QString scriptFileName = baseName(id) + ".qml";
QFile scriptFile(scriptFileName);
if (!scriptFile.open(QFile::ReadWrite)) {
qCWarning(dcScriptEngine()) << "Error opening script" << id;
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
QByteArray oldContent = scriptFile.readAll();
scriptFile.close();
scriptFile.open(QFile::WriteOnly | QFile::Truncate);
qint64 bytesWritten = scriptFile.write(content);
scriptFile.flush();
scriptFile.close();
if (bytesWritten != content.length()) {
qCWarning(dcScriptEngine()) << "Error writing script content";
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
bool loaded = loadScript(script);
if (!loaded) {
qCDebug(dcScriptEngine()) << "Restoring old content";
reply.scriptError = ScriptErrorInvalidScript;
reply.errors = script->errors;
// Restore old content
scriptFile.open(QFile::WriteOnly | QFile::Truncate);
scriptFile.write(oldContent);
scriptFile.flush();
scriptFile.close();
loadScript(script);
return reply;
}
qCDebug(dcScriptEngine()) << "Script updated" << script->name();
reply.scriptError = ScriptErrorNoError;
emit scriptChanged(*script);
return reply;
}
ScriptEngine::ScriptError ScriptEngine::removeScript(const QUuid &id)
{
Script *script = m_scripts.take(id);
if (!script) {
return ScriptErrorScriptNotFound;
}
unloadScript(script);
QString jsonFileName = baseName(id) + ".json";
QString scriptFileName = baseName(id) + ".qml";
QString compiledScriptFileName = baseName(id) + ".qmlc";
QFile::remove(scriptFileName);
QFile::remove(jsonFileName);
QFile::remove(compiledScriptFileName);
emit scriptRemoved(script->id());
delete script;
return ScriptErrorNoError;
}
void ScriptEngine::loadScripts()
{
QDir dir(NymeaSettings::storagePath() + "/scripts/");
foreach (const QString &entry, dir.entryList({"*.json"})) {
qCDebug(dcScriptEngine()) << "Have script:" << entry;
QFileInfo jsonFileInfo(NymeaSettings::storagePath() + "/scripts/" + entry);
QString jsonFileName = jsonFileInfo.absoluteFilePath();
QString scriptFileName = jsonFileInfo.absolutePath() + "/" + jsonFileInfo.baseName() + ".qml";
if (!QFile::exists(scriptFileName)) {
qCWarning(dcScriptEngine()) << "Missing script" << scriptFileName;
continue;
}
QFile jsonFile(jsonFileName);
if (!jsonFile.open(QFile::ReadOnly)) {
qCWarning(dcScriptEngine()) << "Failed to open script metadata" << jsonFileName;
continue;
}
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonFile.readAll(), &error);
jsonFile.close();
if (error.error != QJsonParseError::NoError) {
qCWarning(dcScriptEngine()) << "Error parsing script metadata" << jsonFileName;
continue;
}
Script *script = new Script();
script->setId(jsonFileInfo.baseName());
script->setName(jsonDoc.toVariant().toMap().value("name").toString());
bool loaded = loadScript(script);
if (!loaded) {
qCWarning(dcScriptEngine()) << "Script failed to load:";
delete script;
continue;
}
m_scripts.insert(script->id(), script);
qCDebug(dcScriptEngine()) << "Script loaded" << scriptFileName;
}
}
bool ScriptEngine::loadScript(Script *script)
{
qCDebug(dcScriptEngine()) << "Loading script" << script->name();
QString fileName = baseName(script->id()) + ".qml";
QString jsonFileName = baseName(script->id()) + ".json";
QFile jsonFile(jsonFileName);
if (!jsonFile.open(QFile::ReadOnly)) {
qCWarning(dcScriptEngine()) << "Failed to open script metadata";
return false;
}
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonFile.readAll(), &error);
jsonFile.close();
if (error.error != QJsonParseError::NoError) {
qCWarning(dcScriptEngine()) << "Failed to parse script metadata";
return false;
}
QString name = jsonDoc.toVariant().toMap().value("name").toString();
script->errors.clear();
script->component = new QQmlComponent(m_engine, QUrl::fromLocalFile(fileName), this);
script->context = new QQmlContext(m_engine, this);
script->object = script->component->create(script->context);
if (!script->object) {
qCWarning(dcScriptEngine()) << "Script failed to load:";
foreach (const QQmlError &error, script->component->errors()) {
qCWarning(dcScriptEngine()) << error.toString();
script->errors.append(QString("%1:%2: %3").arg(error.line()).arg(error.column()).arg(error.description()));
}
delete script->context;
delete script->component;
m_engine->clearComponentCache();
return false;
}
return true;
}
void ScriptEngine::unloadScript(Script *script)
{
if (!script->object || !script->component || !script->context) {
qCWarning(dcScriptEngine()) << "Script seems not to be loaded. Cannot unload.";
return;
}
delete script->object;
script->object = nullptr;
delete script->component;
script->component = nullptr;
delete script->context;
script->context = nullptr;
m_engine->clearComponentCache();
qCDebug(dcScriptEngine()) << "Unloading script" << script->name();
}
QString ScriptEngine::baseName(const QUuid &id)
{
QString path = NymeaSettings::storagePath() + "/scripts/";
QString basename = id.toString().remove(QRegExp("[{}]"));
return path + basename;
}
void ScriptEngine::onScriptMessage(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
QFileInfo fi(context.file);
QUuid scriptId = fi.baseName();
if (!m_scripts.contains(scriptId)) {
return;
}
emit scriptConsoleMessage(scriptId, type == QtDebugMsg ? ScriptMessageTypeLog : ScriptMessageTypeWarning, QString::number(context.line) + ": " + message);
}
void ScriptEngine::logMessageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (strcmp(context.category, "qml") != 0) {
s_upstreamMessageHandler(type, context, message);
return;
}
QMutexLocker locker(&s_loggerMutex);
// Copy the message to the script engine
foreach (ScriptEngine *engine, s_engines) {
engine->onScriptMessage(type, context, message);
}
if (!s_oldCategoryFilter) {
return;
}
// Redirect qml messages to the ScriptEngine handler
QMessageLogContext newContext(context.file, context.line, context.function, "ScriptEngine");
QLoggingCategory *category = new QLoggingCategory("ScriptEngine", type);
s_oldCategoryFilter(category);
if (category->isEnabled(type)) {
QFileInfo fi(context.file);
s_upstreamMessageHandler(type, newContext, fi.fileName() + ":" + QString::number(context.line) + ": " + message);
}
}
void ScriptEngine::logCategoryFilter(QLoggingCategory *category)
{
// always enable qml logs, regardless what the filters are
if (qstrcmp(category->categoryName(), "qml") == 0) {
category->setEnabled(QtDebugMsg, true);
category->setEnabled(QtWarningMsg, true);
} else if (s_oldCategoryFilter) {
s_oldCategoryFilter(category);
}
}
}