Add PluginTimer API to python plugins

pull/451/head
Michael Zanetti 2021-08-07 01:29:25 +02:00
parent 67b097f2fe
commit 59011c0387
7 changed files with 251 additions and 66 deletions

View File

@ -17,6 +17,7 @@
#include "pybrowseritem.h" #include "pybrowseritem.h"
#include "pybrowseractioninfo.h" #include "pybrowseractioninfo.h"
#include "pybrowseritemresult.h" #include "pybrowseritemresult.h"
#include "pyplugintimer.h"
#pragma GCC diagnostic push #pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Winvalid-offsetof" #pragma GCC diagnostic ignored "-Winvalid-offsetof"
@ -39,6 +40,7 @@ static int nymea_exec(PyObject *m) {
registerBrowserItemType(m); registerBrowserItemType(m);
registerBrowserActionInfoType(m); registerBrowserActionInfoType(m);
registerBrowserItemResultType(m); registerBrowserItemResultType(m);
registerPluginTimerType(m);
return 0; return 0;
} }

View File

@ -0,0 +1,139 @@
#ifndef PYPLUGINTIMER_H
#define PYPLUGINTIMER_H
#include <Python.h>
#include <QTimer>
#include <QThread>
#include <QCoreApplication>
#include <QtConcurrent/QtConcurrent>
#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<void> 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<initproc>(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<PyObject*>(&PyPluginTimerType));
}
#pragma GCC diagnostic pop
#endif // PYPLUGINTIMER_H

View File

@ -50,6 +50,7 @@ public:
static PyObject* pyAutoThingDisappeared(PyObject *self, PyObject* args); static PyObject* pyAutoThingDisappeared(PyObject *self, PyObject* args);
static PyObject* pyPluginStorage(PyObject* self, PyObject* args); static PyObject* pyPluginStorage(PyObject* self, PyObject* args);
static PyObject* pyApiKeyStorage(PyObject* self, PyObject* args); static PyObject* pyApiKeyStorage(PyObject* self, PyObject* args);
static PyObject* pyHardwareManager(PyObject* self, PyObject* args);
private: private:
void exportIds(); void exportIds();

View File

@ -1370,7 +1370,7 @@ void ThingManagerImplementation::loadPlugins()
delete p; delete p;
} }
#else #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 #endif
} else { } else {
// Not a known plugin type // Not a known plugin type

View File

@ -74,6 +74,7 @@ HEADERS += nymeacore.h \
integrations/python/pybrowseritem.h \ integrations/python/pybrowseritem.h \
integrations/python/pybrowseritemresult.h \ integrations/python/pybrowseritemresult.h \
integrations/python/pypluginstorage.h \ integrations/python/pypluginstorage.h \
integrations/python/pyplugintimer.h \
integrations/thingmanagerimplementation.h \ integrations/thingmanagerimplementation.h \
integrations/translator.h \ integrations/translator.h \
experiences/experiencemanager.h \ experiences/experiencemanager.h \

View File

@ -50,6 +50,15 @@
"createMethods": ["user"], "createMethods": ["user"],
"setupMethod": "justAdd", "setupMethod": "justAdd",
"browsable": true, "browsable": true,
"settingsTypes": [
{
"id": "f3ef303b-d719-4a64-a44c-6b1935bf0d4d",
"name": "interval",
"displayName": "Timer interval",
"type": "uint",
"defaultValue": 5
}
],
"eventTypes": [ "eventTypes": [
{ {
"id": "de6c2425-0dee-413f-8f4c-bb0929e83c0d", "id": "de6c2425-0dee-413f-8f4c-bb0929e83c0d",

View File

@ -1,59 +1,24 @@
import nymea import nymea
import time import time
#from fastdotcom import fast_com
watchingAutoThings = False globalPluginTimer = None
loopRunning = False pluginTimers = {}
# Optional, for initialisation, if needed
def init(): def init():
global loopRunning
loopRunning = True
logger.log("Python mock plugin init") logger.log("Python mock plugin init")
logger.warn("Python mock warning") logger.warn("Python mock warning")
print("python stdout") 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(): def deinit():
logger.log("shutting down") 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(): def startMonitoringAutoThings():
global watchingAutoThings
watchingAutoThings = True
logger.log("Start monitoring auto things. Have %i auto devices. Need %i." % (len(autoThings()), configValue(pyMockPluginAutoThingCountParamTypeId))) logger.log("Start monitoring auto things. Have %i auto devices. Need %i." % (len(autoThings()), configValue(pyMockPluginAutoThingCountParamTypeId)))
things = autoThings(); things = autoThings();
for i in range(len(things), configValue(pyMockPluginAutoThingCountParamTypeId)): for i in range(len(things), configValue(pyMockPluginAutoThingCountParamTypeId)):
@ -68,13 +33,14 @@ def startMonitoringAutoThings():
logger.log("Done start monitoring auto things") 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): def discoverThings(info):
logger.log("Discovery started for", info.thingClassId, "with result count:", info.params[0].value) 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 time.sleep(5) # Some delay for giving a feeling of a real discovery
# Add 2 new discovery results # Add discovery results (in this example the amount given by the discovery params)
for i in range(0, info.params[0].value): for i in range(0, info.params[0].value):
info.addDescriptor(nymea.ThingDescriptor(pyMockDiscoveryPairingThingClassId, "Python mock thing %i" % i)) 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(): for thing in myThings():
if thing.thingClassId == pyMockDiscoveryPairingThingClassId: if thing.thingClassId == pyMockDiscoveryPairingThingClassId:
info.addDescriptor(nymea.ThingDescriptor(pyMockDiscoveryPairingThingClassId, thing.name, thingId=thing.id)) info.addDescriptor(nymea.ThingDescriptor(pyMockDiscoveryPairingThingClassId, thing.name, thingId=thing.id))
@ -82,11 +48,13 @@ def discoverThings(info):
info.finish(nymea.ThingErrorNoError) 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): def startPairing(info):
logger.log("startPairing for", info.thingName, info.thingId, info.params) logger.log("startPairing for", info.thingName, info.thingId, info.params)
info.finish(nymea.ThingErrorNoError, "Log in as user \"john\" with password \"smith\".") 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): def confirmPairing(info, username, secret):
logger.log("confirming pairing for", info.thingName, username, secret) logger.log("confirming pairing for", info.thingName, username, secret)
time.sleep(1) time.sleep(1)
@ -96,16 +64,23 @@ def confirmPairing(info, username, secret):
info.finish(nymea.ThingErrorAuthenticationFailure, "Error logging in here!") info.finish(nymea.ThingErrorAuthenticationFailure, "Error logging in here!")
# Mandatory, a new thing is being set up. Initialize (connect etc...) it
def setupThing(info): def setupThing(info):
logger.log("setupThing for", info.thing.name) logger.log("setupThing for", info.thing.name)
info.finish(nymea.ThingErrorNoError) info.finish(nymea.ThingErrorNoError)
info.thing.nameChangedHandler = thingNameChanged
# Signal handlers
info.thing.settingChangedHandler = thingSettingChanged 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): def postSetupThing(thing):
logger.log("postSetupThing for", thing.name) 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: if thing.thingClassId == pyMockAutoThingClassId:
logger.log("State 1 value:", thing.stateValue(pyMockAutoState1StateTypeId)) 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("Param 1 value:", thing.paramValue(pyMockDiscoveryPairingThingParam1ParamTypeId))
logger.log("Setting 1 value:", thing.setting(pyMockDiscoveryPairingSettingsSetting1ParamTypeId)) 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): def executeAction(info):
logger.log("executeAction for", info.thing.name, info.actionTypeId, "with params", info.params) logger.log("executeAction for", info.thing.name, info.actionTypeId, "with params", info.params)
paramValueByIndex = info.params[0].value paramValueByIndex = info.params[0].value
@ -123,22 +137,35 @@ def executeAction(info):
info.finish(nymea.ThingErrorNoError) info.finish(nymea.ThingErrorNoError)
def autoThings(): # Callback handler when the user changes settings for a particular thing
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)
def thingSettingChanged(thing, paramTypeId, value): def thingSettingChanged(thing, paramTypeId, value):
logger.log("Thing setting changed:", thing.name, 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): def browseThing(result):
logger.log("browseThing called", result.thing.name, result.itemId) logger.log("browseThing called", result.thing.name, result.itemId)
if result.itemId == "": if result.itemId == "":
@ -155,11 +182,17 @@ def browseThing(result):
result.finish(nymea.ThingErrorNoError) result.finish(nymea.ThingErrorNoError)
def executeBrowserItem(info): # If a thingclass supports browser item actions, nymea will call this upon execution
logger.log("executeBrowserItem called for thing", info.thing.name, "and item", info.itemId)
info.finish(nymea.ThingErrorNoError)
# Intentionally commented out to also have a test case for unimplmented functions # Intentionally commented out to also have a test case for unimplmented functions
# def thingRemoved(thing): #def executeBrowserItem(info):
# logger.log("thingRemoved for", thing.name) # 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