diff --git a/libnymea-core/integrations/python/pynymeamodule.h b/libnymea-core/integrations/python/pynymeamodule.h index 489b8270..07351471 100644 --- a/libnymea-core/integrations/python/pynymeamodule.h +++ b/libnymea-core/integrations/python/pynymeamodule.h @@ -17,6 +17,7 @@ #include "pybrowseritem.h" #include "pybrowseractioninfo.h" #include "pybrowseritemresult.h" +#include "pyplugintimer.h" #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Winvalid-offsetof" @@ -39,6 +40,7 @@ static int nymea_exec(PyObject *m) { registerBrowserItemType(m); registerBrowserActionInfoType(m); registerBrowserItemResultType(m); + registerPluginTimerType(m); return 0; } diff --git a/libnymea-core/integrations/python/pyplugintimer.h b/libnymea-core/integrations/python/pyplugintimer.h new file mode 100644 index 00000000..3fe33eef --- /dev/null +++ b/libnymea-core/integrations/python/pyplugintimer.h @@ -0,0 +1,139 @@ +#ifndef PYPLUGINTIMER_H +#define PYPLUGINTIMER_H + +#include +#include +#include +#include +#include +#include "structmember.h" + +#include "loggingcategories.h" + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Winvalid-offsetof" +#pragma GCC diagnostic ignored "-Wwrite-strings" +#pragma GCC diagnostic ignored "-Wmissing-field-initializers" + + +typedef struct { + PyObject_HEAD + PyObject *pyTimeoutHandler = nullptr; + QTimer *timer; + int interval; + PyInterpreterState *interpreter; +} PyPluginTimer; + +static int PyPluginTimer_init(PyPluginTimer *self, PyObject *args, PyObject */*kwds*/) +{ + PyObject *handler; + + qCDebug(dcPythonIntegrations()) << "+++ PyPluginTimer"; + if (!PyArg_ParseTuple(args, "i|O", &self->interval, &handler)) { + return -1; + } + + // QTimer needs to be run in a thread that has a QEventLoop but we don't necessarily have one in + // python threads. So we're moving the timer to the main app thread. + self->timer = new QTimer(); + self->timer->start(self->interval * 1000); + self->timer->moveToThread(QCoreApplication::instance()->thread()); + + self->pyTimeoutHandler = handler; + Py_XINCREF(handler); + + // Remember the interpreter from the current thread so we can run the callback in the correct interpreter + self->interpreter = PyThreadState_GET()->interp; + + + QObject::connect(self->timer, &QTimer::timeout, [=](){ + qCDebug(dcPythonIntegrations) << "Plugin timer timeout" << self->pyTimeoutHandler; + + // Spawn a new thread for the callback of the timer (like we do for every python call). + // FIXME: Ideally we'd use the plugin's thread pool but we can't easily access that here. + // If the timer callback blocks for longer than the timer interval, we might end up with + // tons of threads... + QFuture future = QtConcurrent::run([=](){ + // Acquire GIL and make the new thread state the current one + PyThreadState *threadState = PyThreadState_New(self->interpreter); + PyEval_RestoreThread(threadState); + + if (self->pyTimeoutHandler) { + + PyObject *ret = PyObject_CallFunction(self->pyTimeoutHandler, nullptr); + if (PyErr_Occurred()) { + PyErr_Print(); + } + Py_XDECREF(ret); + } + PyThreadState_Clear(threadState); + PyEval_ReleaseThread(threadState); + PyThreadState_Delete(threadState); + }); + }); + + return 0; +} + +static void PyPluginTimer_dealloc(PyPluginTimer * self) +{ + qCDebug(dcPythonIntegrations()) << "--- PyPluginTimer"; + Py_XDECREF(self->pyTimeoutHandler); + + QMetaObject::invokeMethod(self->timer, "stop", Qt::QueuedConnection); + self->timer->deleteLater(); + + Py_TYPE(self)->tp_free(self); +} + + +static PyObject *PyPluginTimer_getInterval(PyPluginTimer *self, void */*closure*/) +{ + return PyLong_FromLong(self->interval); +} + +static int PyPluginTimer_setInterval(PyPluginTimer *self, PyObject *value, void */*closure*/){ + self->interval = PyLong_AsLong(value); + QMetaObject::invokeMethod(self->timer, "start", Qt::QueuedConnection, Q_ARG(int, self->interval * 1000)); + return 0; +} + +static PyGetSetDef PyPluginTimer_getset[] = { + {"interval", (getter)PyPluginTimer_getInterval, (setter)PyPluginTimer_setInterval, "Timer interval", nullptr}, + {nullptr , nullptr, nullptr, nullptr, nullptr} /* Sentinel */ +}; + + +static PyMemberDef PyPluginTimer_members[] = { + {"timeoutHandler", T_OBJECT_EX, offsetof(PyPluginTimer, pyTimeoutHandler), 0, "Set a callback for when the timer timeout triggers."}, + {nullptr, 0, 0, 0, nullptr} /* Sentinel */ +}; + +static PyTypeObject PyPluginTimerType = { + PyVarObject_HEAD_INIT(NULL, 0) + "nymea.PluginTimer", /* tp_name */ + sizeof(PyPluginTimer), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)PyPluginTimer_dealloc, /* tp_dealloc */ +}; + + + +static void registerPluginTimerType(PyObject *module) +{ + PyPluginTimerType.tp_new = PyType_GenericNew; + PyPluginTimerType.tp_members = PyPluginTimer_members; + PyPluginTimerType.tp_getset = PyPluginTimer_getset; + PyPluginTimerType.tp_init = reinterpret_cast(PyPluginTimer_init); + PyPluginTimerType.tp_doc = "PluginTimers can be used to perform repeating tasks, such as polling a device or online service."; + PyPluginTimerType.tp_flags = Py_TPFLAGS_DEFAULT; + + if (PyType_Ready(&PyPluginTimerType) < 0) { + return; + } + PyModule_AddObject(module, "PluginTimer", reinterpret_cast(&PyPluginTimerType)); +} + +#pragma GCC diagnostic pop + +#endif // PYPLUGINTIMER_H diff --git a/libnymea-core/integrations/pythonintegrationplugin.h b/libnymea-core/integrations/pythonintegrationplugin.h index fed70a70..89e555aa 100644 --- a/libnymea-core/integrations/pythonintegrationplugin.h +++ b/libnymea-core/integrations/pythonintegrationplugin.h @@ -50,6 +50,7 @@ public: static PyObject* pyAutoThingDisappeared(PyObject *self, PyObject* args); static PyObject* pyPluginStorage(PyObject* self, PyObject* args); static PyObject* pyApiKeyStorage(PyObject* self, PyObject* args); + static PyObject* pyHardwareManager(PyObject* self, PyObject* args); private: void exportIds(); diff --git a/libnymea-core/integrations/thingmanagerimplementation.cpp b/libnymea-core/integrations/thingmanagerimplementation.cpp index 2fbd9822..4a4ecc84 100644 --- a/libnymea-core/integrations/thingmanagerimplementation.cpp +++ b/libnymea-core/integrations/thingmanagerimplementation.cpp @@ -1370,7 +1370,7 @@ void ThingManagerImplementation::loadPlugins() delete p; } #else - qCWarning(dcThingManager()) << "Not loading Python plugin as Python plugin support is not included in this nymea instance.2"; + qCWarning(dcThingManager()) << "Not loading Python plugin as Python plugin support is not included in this nymea instance."; #endif } else { // Not a known plugin type diff --git a/libnymea-core/libnymea-core.pro b/libnymea-core/libnymea-core.pro index 0b4c2f5c..713b1d9f 100644 --- a/libnymea-core/libnymea-core.pro +++ b/libnymea-core/libnymea-core.pro @@ -70,6 +70,7 @@ HEADERS += nymeacore.h \ integrations/python/pybrowseritem.h \ integrations/python/pybrowseritemresult.h \ integrations/python/pypluginstorage.h \ + integrations/python/pyplugintimer.h \ integrations/thingmanagerimplementation.h \ integrations/translator.h \ experiences/experiencemanager.h \ diff --git a/plugins/pymock/integrationpluginpymock.json b/plugins/pymock/integrationpluginpymock.json index f4b07b45..6c94e7ae 100644 --- a/plugins/pymock/integrationpluginpymock.json +++ b/plugins/pymock/integrationpluginpymock.json @@ -50,6 +50,15 @@ "createMethods": ["user"], "setupMethod": "justAdd", "browsable": true, + "settingsTypes": [ + { + "id": "f3ef303b-d719-4a64-a44c-6b1935bf0d4d", + "name": "interval", + "displayName": "Timer interval", + "type": "uint", + "defaultValue": 5 + } + ], "eventTypes": [ { "id": "de6c2425-0dee-413f-8f4c-bb0929e83c0d", diff --git a/plugins/pymock/integrationpluginpymock.py b/plugins/pymock/integrationpluginpymock.py index 0ccf4e6c..3616bfce 100644 --- a/plugins/pymock/integrationpluginpymock.py +++ b/plugins/pymock/integrationpluginpymock.py @@ -1,59 +1,24 @@ import nymea import time -#from fastdotcom import fast_com -watchingAutoThings = False -loopRunning = False +globalPluginTimer = None +pluginTimers = {} +# Optional, for initialisation, if needed def init(): - global loopRunning - loopRunning = True - logger.log("Python mock plugin init") logger.warn("Python mock warning") print("python stdout") - while loopRunning: - time.sleep(5); - for thing in myThings(): - if thing.thingClassId == pyMockThingClassId: - logger.log("Emitting event 1 for", thing.name, "eventTypeId", pyMockEvent1EventTypeId) - thing.emitEvent(pyMockEvent1EventTypeId, [nymea.Param(pyMockEvent1EventParam1ParamTypeId, "Im an event")]) - logger.log("Setting state 1 for", thing.name, "to", thing.stateValue(pyMockState1StateTypeId) + 1) - thing.setStateValue(pyMockState1StateTypeId, thing.stateValue(pyMockState1StateTypeId) + 1) - if thing.thingClassId == pyMockDiscoveryPairingThingClassId: - logger.log("Emitting event 1 for", thing.name) - thing.emitEvent(pyMockDiscoveryPairingEvent1EventTypeId, [nymea.Param(pyMockDiscoveryPairingEvent1EventParam1ParamTypeId, "Im an event")]) - logger.log("Setting state 1 for", thing.name, "Old value is:", thing.stateValue(pyMockDiscoveryPairingState1StateTypeId)) - thing.setStateValue(pyMockDiscoveryPairingState1StateTypeId, thing.stateValue(pyMockDiscoveryPairingState1StateTypeId) + 1) - logger.log("Bye bye") - +# Optional, clean up stuff if needed def deinit(): logger.log("shutting down") - global loopRunning - loopRunning = False - - -def configValueChanged(paramTypeId, value): - logger.log("Plugin config value changed:", paramTypeId, value, watchingAutoThings) - if watchingAutoThings and paramTypeId == pyMockPluginAutoThingCountParamTypeId: - logger.log("Auto Thing Count plugin config changed:", value, "Currently there are:", len(autoThings()), "auto things") - things = autoThings(); - for i in range(len(things), value): - logger.log("Creating new auto thing") - descriptor = nymea.ThingDescriptor(pyMockAutoThingClassId, "Python Mock auto thing") - descriptor.params = [nymea.Param(pyMockAutoThingParam1ParamTypeId, True)] - autoThingsAppeared([descriptor]) - - for i in range(value, len(things)): - logger.log("Removing auto thing") - autoThingDisappeared(things[i].id) +# Optional, if the plugin should create auto things, this is the right place to create them, +# or, start monitoring the network (or whatever) for them to appear def startMonitoringAutoThings(): - global watchingAutoThings - watchingAutoThings = True logger.log("Start monitoring auto things. Have %i auto devices. Need %i." % (len(autoThings()), configValue(pyMockPluginAutoThingCountParamTypeId))) things = autoThings(); for i in range(len(things), configValue(pyMockPluginAutoThingCountParamTypeId)): @@ -68,13 +33,14 @@ def startMonitoringAutoThings(): logger.log("Done start monitoring auto things") +# If the plugin supports things of createMethod "discovery", nymea will call this to discover things def discoverThings(info): logger.log("Discovery started for", info.thingClassId, "with result count:", info.params[0].value) - time.sleep(10) # Some delay for giving a feeling of a discovery - # Add 2 new discovery results + time.sleep(5) # Some delay for giving a feeling of a real discovery + # Add discovery results (in this example the amount given by the discovery params) for i in range(0, info.params[0].value): info.addDescriptor(nymea.ThingDescriptor(pyMockDiscoveryPairingThingClassId, "Python mock thing %i" % i)) - # Also add existing ones again so reconfiguration is possible + # Also add existing ones again so reconfiguration is possible, setting the existing thing ID properly for thing in myThings(): if thing.thingClassId == pyMockDiscoveryPairingThingClassId: info.addDescriptor(nymea.ThingDescriptor(pyMockDiscoveryPairingThingClassId, thing.name, thingId=thing.id)) @@ -82,11 +48,13 @@ def discoverThings(info): info.finish(nymea.ThingErrorNoError) +# If the plugin supports things with a setupMethod other than "justAdd", this will be called to initiate login/pairing def startPairing(info): logger.log("startPairing for", info.thingName, info.thingId, info.params) info.finish(nymea.ThingErrorNoError, "Log in as user \"john\" with password \"smith\".") +# If the plugin supports things with a setupMethod other than "justAdd", this will be called to complete login/pairing def confirmPairing(info, username, secret): logger.log("confirming pairing for", info.thingName, username, secret) time.sleep(1) @@ -96,16 +64,23 @@ def confirmPairing(info, username, secret): info.finish(nymea.ThingErrorAuthenticationFailure, "Error logging in here!") +# Mandatory, a new thing is being set up. Initialize (connect etc...) it def setupThing(info): logger.log("setupThing for", info.thing.name) info.finish(nymea.ThingErrorNoError) - info.thing.nameChangedHandler = thingNameChanged + + # Signal handlers info.thing.settingChangedHandler = thingSettingChanged + info.thing.nameChangedHandler = lambda info : logger.log("Thing name changed", info.thing.name) +# Optional, run additional code after a successful thing setup def postSetupThing(thing): logger.log("postSetupThing for", thing.name) - thing.nameChangedHandler = lambda thing : logger.log("Thing name changed", thing.name) + + global globalPluginTimer + if globalPluginTimer is None: + globalPluginTimer = nymea.PluginTimer(5, timerTriggered) if thing.thingClassId == pyMockAutoThingClassId: logger.log("State 1 value:", thing.stateValue(pyMockAutoState1StateTypeId)) @@ -114,7 +89,46 @@ def postSetupThing(thing): logger.log("Param 1 value:", thing.paramValue(pyMockDiscoveryPairingThingParam1ParamTypeId)) logger.log("Setting 1 value:", thing.setting(pyMockDiscoveryPairingSettingsSetting1ParamTypeId)) + if thing.thingClassId == pyMockThingClassId: + interval = thing.setting(pyMockSettingsIntervalParamTypeId) + pluginTimer = nymea.PluginTimer(interval, lambda thing=thing : logger.log("Timer triggered for %s. (Interval: %i)" % (thing.name, thing.setting(pyMockSettingsIntervalParamTypeId)))) + + logger.log("Thing timer interval for %s: %i" % (thing.name, pluginTimer.interval)) + pluginTimers[thing] = pluginTimer + + +# Optional, do cleanups when a thing is removed +def thingRemoved(thing): + logger.log("thingRemoved for", thing.name) + logger.log("Remaining things:", len(myThings())) + + if thing.thingClassId == pyMockThingClassId: + del pluginTimers[thing] + + # Clean up the global plugin timer if there are no things left + if len(myThings()) == 0: + global globalPluginTimer + globalPluginTimer = None + + +# Callback for the plugin timer. If polling is needed, fetch values and set thing states accordingly +def timerTriggered(): + logger.log("Timer triggered") + for thing in myThings(): + if thing.thingClassId == pyMockThingClassId: + logger.log("Emitting event 1 for", thing.name, "eventTypeId", pyMockEvent1EventTypeId) + thing.emitEvent(pyMockEvent1EventTypeId, [nymea.Param(pyMockEvent1EventParam1ParamTypeId, "Im an event")]) + logger.log("Setting state 1 for", thing.name, "to", thing.stateValue(pyMockState1StateTypeId) + 1) + thing.setStateValue(pyMockState1StateTypeId, thing.stateValue(pyMockState1StateTypeId) + 1) + if thing.thingClassId == pyMockDiscoveryPairingThingClassId: + logger.log("Emitting event 1 for", thing.name) + thing.emitEvent(pyMockDiscoveryPairingEvent1EventTypeId, [nymea.Param(pyMockDiscoveryPairingEvent1EventParam1ParamTypeId, "Im an event")]) + logger.log("Setting state 1 for", thing.name, "Old value is:", thing.stateValue(pyMockDiscoveryPairingState1StateTypeId)) + thing.setStateValue(pyMockDiscoveryPairingState1StateTypeId, thing.stateValue(pyMockDiscoveryPairingState1StateTypeId) + 1) + + +# If the plugin supports things with actions, nymea will call this to run actions def executeAction(info): logger.log("executeAction for", info.thing.name, info.actionTypeId, "with params", info.params) paramValueByIndex = info.params[0].value @@ -123,22 +137,35 @@ def executeAction(info): info.finish(nymea.ThingErrorNoError) -def autoThings(): - autoThings = [] - for thing in myThings(): - if thing.thingClassId == pyMockAutoThingClassId: - autoThings.append(thing) - return autoThings - - -def thingNameChanged(thing, name): - logger.log("Thing name changed:", thing.name) - - +# Callback handler when the user changes settings for a particular thing def thingSettingChanged(thing, paramTypeId, value): logger.log("Thing setting changed:", thing.name, paramTypeId, value) + if thing.thingClassId == pyMockThingClassId: + if paramTypeId == pyMockSettingsIntervalParamTypeId: + logger.log("Adjusting plugin timer to interval:", value) + timer = pluginTimers[thing] + timer.interval = value + +# Callback handler when the user changes the global plugin configuration +def configValueChanged(paramTypeId, value): + logger.log("Plugin config value changed:", paramTypeId, value) + if paramTypeId == pyMockPluginAutoThingCountParamTypeId: + logger.log("Auto Thing Count plugin config changed:", value, "Currently there are:", len(autoThings()), "auto things") + things = autoThings(); + for i in range(len(things), value): + logger.log("Creating new auto thing") + descriptor = nymea.ThingDescriptor(pyMockAutoThingClassId, "Python Mock auto thing") + descriptor.params = [nymea.Param(pyMockAutoThingParam1ParamTypeId, True)] + autoThingsAppeared([descriptor]) + + for i in range(value, len(things)): + logger.log("Removing auto thing") + autoThingDisappeared(things[i].id) + + +# If a plugin supports browsable things, nymea will call this to browse a thing def browseThing(result): logger.log("browseThing called", result.thing.name, result.itemId) if result.itemId == "": @@ -155,11 +182,17 @@ def browseThing(result): result.finish(nymea.ThingErrorNoError) -def executeBrowserItem(info): - logger.log("executeBrowserItem called for thing", info.thing.name, "and item", info.itemId) - info.finish(nymea.ThingErrorNoError) - - +# If a thingclass supports browser item actions, nymea will call this upon execution # Intentionally commented out to also have a test case for unimplmented functions -# def thingRemoved(thing): -# logger.log("thingRemoved for", thing.name) +#def executeBrowserItem(info): +# logger.log("executeBrowserItem called for thing", info.thing.name, "and item", info.itemId) +# info.finish(nymea.ThingErrorNoError) + + +# Helper functions can be added too +def autoThings(): + autoThings = [] + for thing in myThings(): + if thing.thingClassId == pyMockAutoThingClassId: + autoThings.append(thing) + return autoThings