From e9ea2d919e3a164866d046dc9a5f51ffdd6a6eab Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Sun, 5 Jan 2020 15:09:58 +0100 Subject: [PATCH 01/10] New Plugin: tp-link Kasa smart plugs --- debian/control | 16 + debian/nymea-plugin-tplink.install.in | 1 + nymea-plugins.pro | 1 + tplink/README.md | 9 + tplink/deviceplugintplink.cpp | 434 ++++++++++++++++++ tplink/deviceplugintplink.h | 80 ++++ tplink/deviceplugintplink.json | 69 +++ tplink/tplink.pro | 9 + ...024ff2e3-30df-44a1-9c8d-63cc416f1fb8-de.ts | 120 +++++ ...ff2e3-30df-44a1-9c8d-63cc416f1fb8-en_US.ts | 120 +++++ 10 files changed, 859 insertions(+) create mode 100644 debian/nymea-plugin-tplink.install.in create mode 100644 tplink/README.md create mode 100644 tplink/deviceplugintplink.cpp create mode 100644 tplink/deviceplugintplink.h create mode 100644 tplink/deviceplugintplink.json create mode 100644 tplink/tplink.pro create mode 100644 tplink/translations/024ff2e3-30df-44a1-9c8d-63cc416f1fb8-de.ts create mode 100644 tplink/translations/024ff2e3-30df-44a1-9c8d-63cc416f1fb8-en_US.ts diff --git a/debian/control b/debian/control index 1840e055..5a9889fd 100644 --- a/debian/control +++ b/debian/control @@ -564,6 +564,21 @@ Description: nymea.io plugin for Texas Instruments devices This package will install the nymea.io plugin for Texas Instruments devices +Package: nymea-plugin-tplink +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + nymea-plugins-translations, +Description: nymea.io plugin for tp-link Kasa devices + The nymea daemon is a plugin based IoT (Internet of Things) server. The + server works like a translator for devices, things and services and + allows them to interact. + With the powerful rule engine you are able to connect any device available + in the system and create individual scenes and behaviors for your environment. + . + This package will install the nymea.io plugin for tp-link Kasa devices + + Package: nymea-plugin-udpcommander Architecture: any Depends: ${shlibs:Depends}, @@ -852,6 +867,7 @@ Depends: nymea-plugin-anel, nymea-plugin-pushbullet, nymea-plugin-wakeonlan, nymea-plugin-tasmota, + nymea-plugin-tplink, nymea-plugin-wemo, nymea-plugin-elgato, nymea-plugin-shelly, diff --git a/debian/nymea-plugin-tplink.install.in b/debian/nymea-plugin-tplink.install.in new file mode 100644 index 00000000..6b068cdb --- /dev/null +++ b/debian/nymea-plugin-tplink.install.in @@ -0,0 +1 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_deviceplugintplink.so diff --git a/nymea-plugins.pro b/nymea-plugins.pro index 9e1177a0..056a3184 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -46,6 +46,7 @@ PLUGIN_DIRS = \ tasmota \ tcpcommander \ texasinstruments \ + tplink \ udpcommander \ unitec \ wakeonlan \ diff --git a/tplink/README.md b/tplink/README.md new file mode 100644 index 00000000..b5876fe8 --- /dev/null +++ b/tplink/README.md @@ -0,0 +1,9 @@ +# tp-link Kasa + +This plugin adds support for tp-link Kasa smart plugs to nymea. Supported features are controlling power +and reading energy consumption. + +In order to use such a device, it must be connected to the same network as nymea. The Kasa app is required +for a one time setup of the device to connect it to the Wi-Fi. + + diff --git a/tplink/deviceplugintplink.cpp b/tplink/deviceplugintplink.cpp new file mode 100644 index 00000000..2a3e52cf --- /dev/null +++ b/tplink/deviceplugintplink.cpp @@ -0,0 +1,434 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2020 Michael Zanetti * + * * + * This file is part of nymea. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Lesser General Public * + * License as published by the Free Software Foundation; either * + * version 2.1 of the License, or (at your option) any later version. * + * * + * This library 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with this library; If not, see * + * . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "deviceplugintplink.h" +#include "plugininfo.h" + +#include +#include + +#include +#include +#include +#include + +// https://github.com/softScheck/tplink-smartplug/blob/master/tplink-smarthome-commands.txt + +DevicePluginTPLink::DevicePluginTPLink() +{ +} + +DevicePluginTPLink::~DevicePluginTPLink() +{ +} + +void DevicePluginTPLink::init() +{ + m_broadcastSocket = new QUdpSocket(this); + +} + +void DevicePluginTPLink::discoverDevices(DeviceDiscoveryInfo *info) +{ + QVariantMap map; + QVariantMap getSysInfo; + getSysInfo.insert("get_sysinfo", QVariant()); + map.insert("system", getSysInfo); + QByteArray payload = QJsonDocument::fromVariant(map).toJson(QJsonDocument::Compact); + QByteArray datagram = encryptPayload(payload); + + qint64 len = m_broadcastSocket->writeDatagram(datagram, QHostAddress::Broadcast, 9999); + if (len != datagram.length()) { + info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("An error happened sending the discovery to the network.")); + return; + } + + QTimer::singleShot(2000, info, [this, info](){ + while(m_broadcastSocket->hasPendingDatagrams()) { + char buffer[1024]; + QHostAddress senderAddress; + qint64 len = m_broadcastSocket->readDatagram(buffer, 1024, &senderAddress); + QByteArray data = decryptPayload(QByteArray::fromRawData(buffer, len)); + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTplink()) << "Error parsing JSON from device:" << data; + continue; + } + QVariantMap properties = jsonDoc.toVariant().toMap(); + QVariantMap sysInfo = properties.value("system").toMap().value("get_sysinfo").toMap(); + if (sysInfo.value("type").toString() == "IOT.SMARTPLUGSWITCH") { + + DeviceDescriptor descriptor(kasaPlugDeviceClassId, sysInfo.value("alias").toString(), sysInfo.value("dev_name").toString()); + Param idParam = Param(kasaPlugDeviceIdParamTypeId, sysInfo.value("deviceId").toString()); + descriptor.setParams(ParamList() << idParam); + Device *existingDevice = myDevices().findByParams(ParamList() << idParam); + if (existingDevice) { + descriptor.setDeviceId(existingDevice->id()); + } + info->addDeviceDescriptor(descriptor); + + } else { + qCWarning(dcTplink()) << "Unhandled device type:" << sysInfo.value("type").toString(); + } + + } + info->finish(Device::DeviceErrorNoError); + }); +} + +void DevicePluginTPLink::setupDevice(DeviceSetupInfo *info) +{ + QVariantMap map; + QVariantMap getSysInfo; + getSysInfo.insert("get_sysinfo", QVariant()); + map.insert("system", getSysInfo); + QVariantMap getRealTime; + getRealTime.insert("get_realtime", QVariant()); + map.insert("emeter", getRealTime); + QByteArray payload = QJsonDocument::fromVariant(map).toJson(QJsonDocument::Compact); + QByteArray datagram = encryptPayload(payload); + + qint64 len = m_broadcastSocket->writeDatagram(datagram, QHostAddress::Broadcast, 9999); + if (len != datagram.length()) { + info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("An error happened finding the device in the network.")); + return; + } + + QTimer::singleShot(2000, info, [this, info](){ + + while(m_broadcastSocket->hasPendingDatagrams()) { + char buffer[1024]; + QHostAddress senderAddress; + qint64 len = m_broadcastSocket->readDatagram(buffer, 1024, &senderAddress); + QByteArray data = decryptPayload(QByteArray::fromRawData(buffer, len)); + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTplink()) << "Error parsing JSON from device:" << data; + continue; + } + QVariantMap properties = jsonDoc.toVariant().toMap(); + QVariantMap sysInfo = properties.value("system").toMap().value("get_sysinfo").toMap(); + if (info->device()->paramValue(kasaPlugDeviceIdParamTypeId).toString() == sysInfo.value("deviceId").toString()) { + qCDebug(dcTplink()) << "Found device at" << senderAddress; + + connectToDevice(info->device(), senderAddress); + + info->finish(Device::DeviceErrorNoError); + m_setupRetries.remove(info); + return; + } + } + + if (!m_setupRetries.contains(info) || m_setupRetries.value(info) < 5) { + qCDebug(dcTplink()) << "Device not found in network. Retrying... (" << m_setupRetries[info] << ")"; + m_setupRetries[info]++; + setupDevice(info); + return; + } + m_setupRetries.remove(info); + info->finish(Device::DeviceErrorDeviceNotFound, QT_TR_NOOP("The device could not be found on the network.")); + }); +} + +void DevicePluginTPLink::postSetupDevice(Device *device) +{ + connect(device, &Device::nameChanged, this, [this, device](){ + QVariantMap map; + QVariantMap systemMap; + QVariantMap aliasMap; + aliasMap.insert("alias", device->name()); + systemMap.insert("set_dev_alias", aliasMap); + map.insert("system", systemMap); + QByteArray payload = QJsonDocument::fromVariant(map).toJson(QJsonDocument::Compact); + qCDebug(dcTplink) << "Setting device name:" << payload; + payload = encryptPayload(payload); + QByteArray data; + QDataStream stream(&data, QIODevice::ReadWrite); + stream << static_cast(payload.length()); + data.append(payload); + + Job job; + job.id = m_jobIdx++; + job.data = data; + m_jobQueue[device].append(job); + processQueue(device); + }); + + if (!m_timer) { + m_timer = hardwareManager()->pluginTimerManager()->registerTimer(1); + connect(m_timer, &PluginTimer::timeout, this, [this](){ + foreach (Device *d, myDevices()) { + if (!m_pendingJobs.contains(d) && m_jobQueue[d].isEmpty()) { + fetchState(d); + } + } + }); + } +} + +void DevicePluginTPLink::deviceRemoved(Device *device) +{ + qCDebug(dcTplink()) << "Device removed" << device->name(); + m_sockets.remove(device); + m_pendingJobs.remove(device); + m_jobQueue.remove(device); + + if (myDevices().isEmpty() && m_timer) { + hardwareManager()->pluginTimerManager()->unregisterTimer(m_timer); + m_timer = nullptr; + } +} + +void DevicePluginTPLink::executeAction(DeviceActionInfo *info) +{ + QVariantMap map; + QVariantMap systemMap; + QVariantMap powerMap; + powerMap.insert("state", info->action().param(kasaPlugPowerActionPowerParamTypeId).value().toBool() ? 1 : 0); + systemMap.insert("set_relay_state", powerMap); + map.insert("system", systemMap); + +// qCDebug(dcTplink()) << "Executing action" << qUtf8Printable(QJsonDocument::fromVariant(map).toJson(QJsonDocument::Compact)); + + QByteArray payload = encryptPayload(QJsonDocument::fromVariant(map).toJson(QJsonDocument::Compact)); + QByteArray data; + QDataStream stream(&data, QIODevice::ReadWrite); + stream << static_cast(payload.length()); + data.append(payload); + + Job job; + job.id = m_jobIdx++; + job.data = data; + job.actionInfo = info; + m_jobQueue[info->device()].append(job); + connect(info, &DeviceActionInfo::aborted, this, [=](){ + m_jobQueue[info->device()].removeAll(job); + }); + + // Directly queue up fetchState + fetchState(info->device(), info); + + processQueue(info->device()); +} + +QByteArray DevicePluginTPLink::encryptPayload(const QByteArray &payload) +{ + QByteArray result; + int k = 171; + for (int i = 0; i < payload.length(); i++){ + char t = payload.at(i) xor k; + k = t; + result.append(t); + } + return result; +} + +QByteArray DevicePluginTPLink::decryptPayload(const QByteArray &payload) +{ + QByteArray result; + int k = 171; + for (int i = 0; i < payload.length(); i++){ + char t = payload.at(i); + result.append(t xor k); + k = t; + } + return result; +} + +void DevicePluginTPLink::connectToDevice(Device *device, const QHostAddress &address) +{ + if (m_sockets.contains(device)) { + qCWarning(dcTplink) << "Already have a connection to this device"; + return; + } + qCDebug(dcTplink()) << "Connecting to" << address; + + QTcpSocket *socket = new QTcpSocket(this); + m_sockets.insert(device, socket); + + connect(socket, &QTcpSocket::connected, device, [this, device, address] () { + qCDebug(dcTplink()) << "Connected to device" << address; + device->setStateValue(kasaPlugConnectedStateTypeId, true); + fetchState(device); + }); + + typedef void (QTcpSocket:: *errorSignal)(QAbstractSocket::SocketError); + connect(socket, static_cast(&QTcpSocket::error), device, [](QAbstractSocket::SocketError error) { + qCWarning(dcTplink()) << "Error in device connection:" << error; + }); + + connect(socket, &QTcpSocket::readyRead, device, [this, socket, device](){ + m_inputBuffers[device].append(socket->readAll()); + while (m_inputBuffers[device].length() > 4) { + QByteArray data = m_inputBuffers[device]; + QDataStream stream(data); + qint32 len; + stream >> len; + data.remove(0, 4); + if (data.length() < len) { + // Buffer not complete... wait for more... + return; + } + QByteArray payload = data.left(len); + data.remove(0, len); + m_inputBuffers[device] = data; + + if (!m_pendingJobs.contains(device)) { + qCWarning(dcTplink()) << "Received packet from device but don't have a job waiting for it. Did it time out?"; + processQueue(device); + return; + } + Job job = m_pendingJobs.take(device); + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(decryptPayload(payload), &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTplink()) << "Cannot parse json from device:" << decryptPayload(payload); + m_jobQueue[device].prepend(job); + socket->disconnectFromHost(); + return; + } +// qCDebug(dcTplink()) << "Socket data received" << qUtf8Printable(jsonDoc.toJson()); + + QVariantMap map = jsonDoc.toVariant().toMap(); + if (map.contains("system")) { + QVariantMap systemMap = map.value("system").toMap(); + if (systemMap.contains("set_relay_state")) { + int err_code = systemMap.value("set_relay_state").toMap().value("err_code").toInt(); + if (err_code != 0) { + qCWarning(dcTplink()) << "Set relay state failed:" << qUtf8Printable(jsonDoc.toJson()); + if (job.actionInfo) { + job.actionInfo->finish(Device::DeviceErrorHardwareFailure); + } + } + } + if (systemMap.contains("get_sysinfo")) { + int relayState = systemMap.value("get_sysinfo").toMap().value("relay_state").toInt(); + device->setStateValue(kasaPlugPowerStateTypeId, relayState == 1 ? true : false); + + QString alias = systemMap.value("get_sysinfo").toMap().value("alias").toString(); + if (device->name() != alias) { + device->setName(alias); + } + + if (job.actionInfo) { + job.actionInfo->finish(Device::DeviceErrorNoError); + } + } + } + if (map.contains("emeter")) { + QVariantMap emeterMap = map.value("emeter").toMap(); + if (emeterMap.contains("get_realtime")) { + // This has quite a bit of jitter... Let's smoothen it while within +/- 0.1W to produce less events in the system + double oldValue = device->stateValue(kasaPlugCurrentPowerStateTypeId).toDouble(); + double newValue = emeterMap.value("get_realtime").toMap().value("power_mw").toDouble() / 1000; + qCDebug(dcTplink()) << "old:" << oldValue << "new" << newValue << "diff" << qAbs(oldValue - newValue); + if (qAbs(oldValue - newValue) > 0.1) { + device->setStateValue(kasaPlugCurrentPowerStateTypeId, newValue); + } + device->setStateValue(kasaPlugTotalEnergyConsumedStateTypeId, emeterMap.value("get_realtime").toMap().value("total_wh").toDouble() / 1000); + } + } + + processQueue(device); + } + }); + + connect(socket, &QTcpSocket::disconnected, device, [this, device, address](){ + qCDebug(dcTplink()) << "Device disconnected"; + m_sockets.take(device)->deleteLater(); + if (m_pendingJobs.contains(device)) { + // Putting active job back to queue + m_jobQueue[device].prepend(m_pendingJobs.take(device)); + } + device->setStateValue(kasaPlugConnectedStateTypeId, false); + QTimer::singleShot(500, device, [this, device, address]() {connectToDevice(device, address);}); + }); + + socket->connectToHost(address.toString(), 9999, QIODevice::ReadWrite); +} + +void DevicePluginTPLink::fetchState(Device *device, DeviceActionInfo *info) +{ + QTcpSocket *socket = m_sockets.value(device); + if (!socket || !socket->isOpen()) { + qCWarning(dcTplink()) << "Cannot fetch state"; + } + + QVariantMap map; + QVariantMap getSysInfo; + getSysInfo.insert("get_sysinfo", QVariant()); + map.insert("system", getSysInfo); + QVariantMap getRealTime; + getRealTime.insert("get_realtime", QVariant()); + map.insert("emeter", getRealTime); + QByteArray plaintext = QJsonDocument::fromVariant(map).toJson(QJsonDocument::Compact); +// qCDebug(dcTplink()) << "Fetching device state"; + QByteArray payload = encryptPayload(plaintext); + QByteArray data; + QDataStream stream(&data, QIODevice::ReadWrite); + stream << static_cast(payload.length()); + data.append(payload); + + Job job; + job.id = m_jobIdx++; + job.data = data; + job.actionInfo = info; + m_jobQueue[device].append(job); + + processQueue(device); + +} + +void DevicePluginTPLink::processQueue(Device *device) +{ + if (m_pendingJobs.contains(device)) { + // Busy + return; + } + if (m_jobQueue[device].isEmpty()) { + // No jobs queued for this device + return; + } + + QTcpSocket *socket = m_sockets.value(device); + if (!socket) { + qCWarning(dcTplink()) << "Cannot process queue. Device not connected."; + return; + } + Job job = m_jobQueue[device].takeFirst(); + + m_pendingJobs[device] = job; + + qint64 len = socket->write(job.data); + if (len != job.data.length()) { + qCWarning(dcTplink()) << "Error writing data to network."; + if (job.actionInfo) { + job.actionInfo->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error sending command to the network.")); + } + socket->disconnectFromHost(); + return; + } +} + diff --git a/tplink/deviceplugintplink.h b/tplink/deviceplugintplink.h new file mode 100644 index 00000000..82503c25 --- /dev/null +++ b/tplink/deviceplugintplink.h @@ -0,0 +1,80 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2020 Michael Zanetti * + * * + * This file is part of nymea. * + * * + * nymea is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, version 2 of the License. * + * * + * nymea 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 nymea. If not, see . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef DEVICEPLUGINTPLINK_H +#define DEVICEPLUGINTPLINK_H + +#include "devices/deviceplugin.h" + +#include + +#include + +class PluginTimer; + +class DevicePluginTPLink: public DevicePlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.DevicePlugin" FILE "deviceplugintplink.json") + Q_INTERFACES(DevicePlugin) + +public: + explicit DevicePluginTPLink(); + ~DevicePluginTPLink(); + + void init() override; + void discoverDevices(DeviceDiscoveryInfo *info) override; + void setupDevice(DeviceSetupInfo *info) override; + void postSetupDevice(Device *device) override; + void deviceRemoved(Device *device) override; + void executeAction(DeviceActionInfo *info) override; + +private: + QByteArray encryptPayload(const QByteArray &payload); + QByteArray decryptPayload(const QByteArray &payload); + + void connectToDevice(Device *device, const QHostAddress &address); + void fetchState(Device *device, DeviceActionInfo *info = nullptr); + + void processQueue(Device *device); + +private: + class Job { + public: + int id = 0; + QByteArray data; + DeviceActionInfo *actionInfo = nullptr; + bool operator==(const Job &other) { return id == other.id; } + }; + QHash m_pendingJobs; + QHash> m_jobQueue; + int m_jobIdx = 0; + + QUdpSocket *m_broadcastSocket = nullptr; + QHash m_sockets; + QHash m_setupRetries; + + QHash m_inputBuffers; + + PluginTimer *m_timer = nullptr; +}; + +#endif // DEVICEPLUGINANEL_H diff --git a/tplink/deviceplugintplink.json b/tplink/deviceplugintplink.json new file mode 100644 index 00000000..1f8f67f5 --- /dev/null +++ b/tplink/deviceplugintplink.json @@ -0,0 +1,69 @@ +{ + "name": "tplink", + "displayName": "tp-link", + "id": "024ff2e3-30df-44a1-9c8d-63cc416f1fb8", + "vendors": [ + { + "name": "tplink", + "displayName": "tp-link", + "id": "8603b6cf-52ec-4481-aca2-f29ebd6cd8a8", + "deviceClasses": [ + { + "id": "32830124-9efb-4614-8227-ee269b1889b0", + "name": "kasaPlug", + "displayName": "Kasa Smart Wi-Fi Plug", + "createMethods": ["discovery"], + "interfaces": [ "powersocket", "extendedsmartmeterconsumer", "connectable" ], + "paramTypes": [ + { + "id": "de3238f7-fe94-440d-b212-61cd4e221b50", + "name": "id", + "displayName": "ID", + "type": "QString", + "defaultValue": "" + } + ], + "stateTypes": [ + { + "id": "b66825ec-9f1b-48da-af18-f36913291c0e", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "f1a5fda4-87a6-46f6-9499-16811a5f4f4d", + "name": "power", + "displayName": "Power", + "displayNameEvent": "Turned on or off", + "displayNameAction": "Turn on or off", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "id": "a3533121-69ee-44fd-8394-13373e8f960e", + "name": "totalEnergyConsumed", + "displayName": "Total energy consumed", + "displayNameEvent": "Total energy consumed changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0 + }, + { + "id": "ccb52b57-5800-4f03-b7fa-f36dcebe1d4e", + "name": "currentPower", + "displayName": "Current power consumption", + "displayNameEvent": "Current power consumption changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + } + ] + } + ] + } + ] +} diff --git a/tplink/tplink.pro b/tplink/tplink.pro new file mode 100644 index 00000000..ed28f12e --- /dev/null +++ b/tplink/tplink.pro @@ -0,0 +1,9 @@ +include(../plugins.pri) + +QT += network + +SOURCES += \ + deviceplugintplink.cpp \ + +HEADERS += \ + deviceplugintplink.h \ diff --git a/tplink/translations/024ff2e3-30df-44a1-9c8d-63cc416f1fb8-de.ts b/tplink/translations/024ff2e3-30df-44a1-9c8d-63cc416f1fb8-de.ts new file mode 100644 index 00000000..021cf521 --- /dev/null +++ b/tplink/translations/024ff2e3-30df-44a1-9c8d-63cc416f1fb8-de.ts @@ -0,0 +1,120 @@ + + + + + DevicePluginTPLink + + + An error happened sending the discovery to the network. + Beim Durchsuchen des Netzwerks ist ein Fehler aufgetreten. + + + + An error happened finding the device in the network. + Beim Suchen des Geräts ist ein Fehler aufgetreten. + + + + The device could not be found on the network. + Das Gerät konnte nicht im Netzwerk gefunden werden. + + + + Error sending command to the network. + Der Befehl konnte nicht ins Netzwerk gesendet werden. + + + + tplink + + + + Connected + The name of the ParamType (DeviceClass: kasaPlug, EventType: connected, ID: {b66825ec-9f1b-48da-af18-f36913291c0e}) +---------- +The name of the StateType ({b66825ec-9f1b-48da-af18-f36913291c0e}) of DeviceClass kasaPlug + Verbunden + + + + Connected changed + The name of the EventType ({b66825ec-9f1b-48da-af18-f36913291c0e}) of DeviceClass kasaPlug + Verbunden/getrennt + + + + + Current power consumption + The name of the ParamType (DeviceClass: kasaPlug, EventType: currentPower, ID: {ccb52b57-5800-4f03-b7fa-f36dcebe1d4e}) +---------- +The name of the StateType ({ccb52b57-5800-4f03-b7fa-f36dcebe1d4e}) of DeviceClass kasaPlug + Aktueller Energieverbrauch + + + + Current power consumption changed + The name of the EventType ({ccb52b57-5800-4f03-b7fa-f36dcebe1d4e}) of DeviceClass kasaPlug + Aktueller Energieverbrauch geändert + + + + ID + The name of the ParamType (DeviceClass: kasaPlug, Type: device, ID: {de3238f7-fe94-440d-b212-61cd4e221b50}) + ID + + + + Kasa Smart Wi-Fi Plug + The name of the DeviceClass ({32830124-9efb-4614-8227-ee269b1889b0}) + Kasa Smart Wi-Fi Plug + + + + + + Power + The name of the ParamType (DeviceClass: kasaPlug, ActionType: power, ID: {f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) +---------- +The name of the ParamType (DeviceClass: kasaPlug, EventType: power, ID: {f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) +---------- +The name of the StateType ({f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) of DeviceClass kasaPlug + Eingeschaltet + + + + + Total energy consumed + The name of the ParamType (DeviceClass: kasaPlug, EventType: totalEnergyConsumed, ID: {a3533121-69ee-44fd-8394-13373e8f960e}) +---------- +The name of the StateType ({a3533121-69ee-44fd-8394-13373e8f960e}) of DeviceClass kasaPlug + Gesamter Energieverbrauch + + + + Total energy consumed changed + The name of the EventType ({a3533121-69ee-44fd-8394-13373e8f960e}) of DeviceClass kasaPlug + Gesamter Energieverbrauch geändert + + + + Turn on or off + The name of the ActionType ({f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) of DeviceClass kasaPlug + Ein- ausschalten + + + + Turned on or off + The name of the EventType ({f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) of DeviceClass kasaPlug + Ein- ausgeschaltet + + + + + tp-link + The name of the vendor ({8603b6cf-52ec-4481-aca2-f29ebd6cd8a8}) +---------- +The name of the plugin tplink ({024ff2e3-30df-44a1-9c8d-63cc416f1fb8}) + tp-link + + + diff --git a/tplink/translations/024ff2e3-30df-44a1-9c8d-63cc416f1fb8-en_US.ts b/tplink/translations/024ff2e3-30df-44a1-9c8d-63cc416f1fb8-en_US.ts new file mode 100644 index 00000000..07a42fc9 --- /dev/null +++ b/tplink/translations/024ff2e3-30df-44a1-9c8d-63cc416f1fb8-en_US.ts @@ -0,0 +1,120 @@ + + + + + DevicePluginTPLink + + + An error happened sending the discovery to the network. + + + + + An error happened finding the device in the network. + + + + + The device could not be found on the network. + + + + + Error sending command to the network. + + + + + tplink + + + + Connected + The name of the ParamType (DeviceClass: kasaPlug, EventType: connected, ID: {b66825ec-9f1b-48da-af18-f36913291c0e}) +---------- +The name of the StateType ({b66825ec-9f1b-48da-af18-f36913291c0e}) of DeviceClass kasaPlug + + + + + Connected changed + The name of the EventType ({b66825ec-9f1b-48da-af18-f36913291c0e}) of DeviceClass kasaPlug + + + + + + Current power consumption + The name of the ParamType (DeviceClass: kasaPlug, EventType: currentPower, ID: {ccb52b57-5800-4f03-b7fa-f36dcebe1d4e}) +---------- +The name of the StateType ({ccb52b57-5800-4f03-b7fa-f36dcebe1d4e}) of DeviceClass kasaPlug + + + + + Current power consumption changed + The name of the EventType ({ccb52b57-5800-4f03-b7fa-f36dcebe1d4e}) of DeviceClass kasaPlug + + + + + ID + The name of the ParamType (DeviceClass: kasaPlug, Type: device, ID: {de3238f7-fe94-440d-b212-61cd4e221b50}) + + + + + Kasa Smart Wi-Fi Plug + The name of the DeviceClass ({32830124-9efb-4614-8227-ee269b1889b0}) + + + + + + + Power + The name of the ParamType (DeviceClass: kasaPlug, ActionType: power, ID: {f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) +---------- +The name of the ParamType (DeviceClass: kasaPlug, EventType: power, ID: {f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) +---------- +The name of the StateType ({f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) of DeviceClass kasaPlug + + + + + + Total energy consumed + The name of the ParamType (DeviceClass: kasaPlug, EventType: totalEnergyConsumed, ID: {a3533121-69ee-44fd-8394-13373e8f960e}) +---------- +The name of the StateType ({a3533121-69ee-44fd-8394-13373e8f960e}) of DeviceClass kasaPlug + + + + + Total energy consumed changed + The name of the EventType ({a3533121-69ee-44fd-8394-13373e8f960e}) of DeviceClass kasaPlug + + + + + Turn on or off + The name of the ActionType ({f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) of DeviceClass kasaPlug + + + + + Turned on or off + The name of the EventType ({f1a5fda4-87a6-46f6-9499-16811a5f4f4d}) of DeviceClass kasaPlug + + + + + + tp-link + The name of the vendor ({8603b6cf-52ec-4481-aca2-f29ebd6cd8a8}) +---------- +The name of the plugin tplink ({024ff2e3-30df-44a1-9c8d-63cc416f1fb8}) + + + + From 188336a217cb188cb9c90fe0b474f3f0dcf75f35 Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Fri, 6 Sep 2019 21:44:51 +0200 Subject: [PATCH 02/10] New plugin: Tuya cloud devices --- nymea-plugins.pro | 1 + tuya/README.md | 5 + tuya/deviceplugintuya.cpp | 385 +++++++++++++++++++++++++++++++++++++ tuya/deviceplugintuya.h | 58 ++++++ tuya/deviceplugintuya.json | 70 +++++++ tuya/tuya.pro | 13 ++ 6 files changed, 532 insertions(+) create mode 100644 tuya/README.md create mode 100644 tuya/deviceplugintuya.cpp create mode 100644 tuya/deviceplugintuya.h create mode 100644 tuya/deviceplugintuya.json create mode 100644 tuya/tuya.pro diff --git a/nymea-plugins.pro b/nymea-plugins.pro index 056a3184..1a61f501 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -47,6 +47,7 @@ PLUGIN_DIRS = \ tcpcommander \ texasinstruments \ tplink \ + tuya \ udpcommander \ unitec \ wakeonlan \ diff --git a/tuya/README.md b/tuya/README.md new file mode 100644 index 00000000..da9d555b --- /dev/null +++ b/tuya/README.md @@ -0,0 +1,5 @@ +# Tuya + +This plugin allows to make use of Tuya based devices through the Tuya cloud. This includes all the devices that work with the Smart Life app. + +The plugin will allow logging in with the Smart Life app account and fetch all the devices connected to the Tuya/Smart Life cloud. diff --git a/tuya/deviceplugintuya.cpp b/tuya/deviceplugintuya.cpp new file mode 100644 index 00000000..eef9b710 --- /dev/null +++ b/tuya/deviceplugintuya.cpp @@ -0,0 +1,385 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2019 Michael Zanetti * + * * + * This file is part of nymea. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Lesser General Public * + * License as published by the Free Software Foundation; either * + * version 2.1 of the License, or (at your option) any later version. * + * * + * This library 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with this library; If not, see * + * . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "deviceplugintuya.h" +#include "plugininfo.h" + +#include +#include +#include +#include + +#include "hardwaremanager.h" +#include "network/networkaccessmanager.h" + +#include "plugintimer.h" + +DevicePluginTuya::DevicePluginTuya(QObject *parent): DevicePlugin(parent) +{ + +} + +Device::DeviceSetupStatus DevicePluginTuya::setupDevice(Device *device) +{ + if (device->deviceClassId() == tuyaCloudDeviceClassId) { + QTimer *tokenRefreshTimer = m_tokenExpiryTimers.value(device->id()); + if (!tokenRefreshTimer) { + tokenRefreshTimer = new QTimer(device); + tokenRefreshTimer->setSingleShot(true); + m_tokenExpiryTimers.insert(device->id(), tokenRefreshTimer); + } + + connect(tokenRefreshTimer, &QTimer::timeout, device, [this, device](){ + refreshAccessToken(device); + }); + + // If token refresh timer is already running, we just passed the login... + if (tokenRefreshTimer->isActive()) { + qCDebug(dcTuya()) << "Device already set up during pairing."; + device->setStateValue(tuyaCloudConnectedStateTypeId, true); + return Device::DeviceSetupStatusSuccess; + } + + // Else, let's refresh the token now + refreshAccessToken(device, true); + return Device::DeviceSetupStatusAsync; + } + + return Device::DeviceSetupStatusSuccess; +} + +void DevicePluginTuya::postSetupDevice(Device *device) +{ + if (device->deviceClassId() == tuyaCloudDeviceClassId) { + updateChildDevices(device); + + if (!m_pluginTimer) { + m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(5); + connect(m_pluginTimer, &PluginTimer::timeout, this, [this](){ + foreach (Device *d, myDevices().filterByDeviceClassId(tuyaCloudDeviceClassId)) { + updateChildDevices(d); + } + }); + } + } +} + +void DevicePluginTuya::deviceRemoved(Device *device) +{ + if (device->deviceClassId() == tuyaCloudDeviceClassId) { + m_tokenExpiryTimers.take(device->id())->deleteLater(); + } + + if (myDevices().isEmpty()) { + hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer); + m_pluginTimer = nullptr; + } +} + +DevicePairingInfo DevicePluginTuya::pairDevice(DevicePairingInfo &info) +{ + info.setMessage(tr("Please enter username and password for your Tuya (Smart Life) account.")); + return info; +} + +DevicePairingInfo DevicePluginTuya::confirmPairing(DevicePairingInfo &info, const QString &username, const QString &secret) +{ + QUrl url(QString("http://px1.tuyaeu.com/homeassistant/auth.do")); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QUrlQuery query; + query.addQueryItem("userName", username); + query.addQueryItem("password", secret); + query.addQueryItem("countryCode", "44"); + query.addQueryItem("bizType", "smart_life"); + query.addQueryItem("from", "tuya"); + + + QNetworkReply *reply = hardwareManager()->networkManager()->post(request, query.toString().toUtf8()); + + qCDebug(dcTuya()) << "Pairing Tuya device"; + connect(reply, &QNetworkReply::finished, this, [this, reply, info](){ + reply->deleteLater(); + QByteArray data = reply->readAll(); + + DevicePairingInfo ret(info); + + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcTuya()) << "Server error:" << reply->errorString(); + ret.setMessage(tr("Error communicating with Tuya server.")); + ret.setStatus(Device::DeviceErrorHardwareFailure); + emit pairingFinished(ret); + return; + } + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTuya()) << "Json parse error:" << error.errorString(); + ret.setMessage(tr("Error communicating with Tuya server.")); + ret.setStatus(Device::DeviceErrorHardwareFailure); + emit pairingFinished(ret); + return; + } + + QVariantMap result = jsonDoc.toVariant().toMap(); + pluginStorage()->beginGroup(info.deviceId().toString()); + pluginStorage()->setValue("accessToken", result.value("access_token").toString()); + pluginStorage()->setValue("refreshToken", result.value("refresh_token").toString()); + pluginStorage()->endGroup(); + + int timeout = result.value("expires_in").toInt(); + + QTimer *t = new QTimer(this); + t->setSingleShot(true); + t->start(timeout * 1000); + + m_tokenExpiryTimers.insert(info.deviceId(), t); + + qCDebug(dcTuya()) << "Tuya device paired. Token expires in" << timeout; + + ret.setStatus(Device::DeviceErrorNoError); + emit pairingFinished(ret); + + }); + + info.setStatus(Device::DeviceErrorAsync); + return info; +} + +Device::DeviceError DevicePluginTuya::executeAction(Device *device, const Action &action) +{ + if (action.actionTypeId() == tuyaSwitchPowerActionTypeId) { + controlTuyaSwitch(device, action.param(tuyaSwitchPowerActionPowerParamTypeId).value().toBool(), action.id()); + return Device::DeviceErrorAsync; + } + return Device::DeviceErrorDeviceClassNotFound; +} + +void DevicePluginTuya::refreshAccessToken(Device *device, bool emitSetupFinished) +{ + qCDebug(dcTuya()) << device->name() << "Refreshing access token."; + + pluginStorage()->beginGroup(device->id().toString()); + QString refreshToken = pluginStorage()->value("refreshToken").toString(); + pluginStorage()->endGroup(); + + QUrl url("http://px1.tuyaeu.com/homeassistant/access.do"); + + QUrlQuery query; + query.addQueryItem("grant_type", "refresh_token"); + query.addQueryItem("refresh_token", refreshToken); + url.setQuery(query); + + QNetworkRequest request(url); + + QNetworkReply *reply = hardwareManager()->networkManager()->get(request); + connect(reply, &QNetworkReply::finished, [reply](){ reply->deleteLater(); }); + connect(reply, &QNetworkReply::finished, device, [this, reply, device, emitSetupFinished](){ + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcTuya()) << "Error refreshing access token"; + if (emitSetupFinished) { + emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); + } + device->setStateValue(tuyaCloudConnectedStateTypeId, false); + return; + } + QByteArray data = reply->readAll(); + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTuya()) << "Failed to parse json reply when refreshing access token" << error.errorString(); + if (emitSetupFinished) { + emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); + } + device->setStateValue(tuyaCloudConnectedStateTypeId, false); + return; + } + + pluginStorage()->beginGroup(device->id().toString()); + pluginStorage()->setValue("accessToken", jsonDoc.toVariant().toMap().value("access_token").toString()); + pluginStorage()->setValue("refreshToken", jsonDoc.toVariant().toMap().value("refresh_token").toString()); + pluginStorage()->endGroup(); + int tokenExpiry = jsonDoc.toVariant().toMap().value("expires_in").toInt(); + + qCDebug(dcTuya()) << "Access token for" << device->name() << "refreshed. Expires in" << tokenExpiry; + + QTimer *t = m_tokenExpiryTimers.value(device->id()); + t->start(tokenExpiry); + device->setStateValue(tuyaCloudConnectedStateTypeId, true); + + if (emitSetupFinished) { + emit deviceSetupFinished(device, Device::DeviceSetupStatusSuccess); + } + }); +} + +void DevicePluginTuya::updateChildDevices(Device *device) +{ + qCDebug(dcTuya()) << device->name() << "Updating child devices"; + pluginStorage()->beginGroup(device->id().toString()); + QString accesToken = pluginStorage()->value("accessToken").toString(); + pluginStorage()->endGroup(); + + QUrl url("http://px1.tuyaeu.com/homeassistant/skill"); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QVariantMap header; + header.insert("name", "Discovery"); + header.insert("namespace", "discovery"); + header.insert("payloadVersion", 1); + + QVariantMap payload; + payload.insert("accessToken", accesToken); + + QVariantMap data; + data.insert("header", header); + data.insert("payload", payload); + + QJsonDocument jsonDoc = QJsonDocument::fromVariant(data); + + QNetworkReply *reply = hardwareManager()->networkManager()->post(request, jsonDoc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, [reply](){reply->deleteLater();}); + connect(reply, &QNetworkReply::finished, device, [this, device, reply](){ + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcTuya()) << "Error fetching devices from Tuya cloud" << reply->error(); + emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); + return; + } + + QByteArray data = reply->readAll(); + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTuya()) << "Json parser error updating child devices" << error.errorString(); + emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); + return; + } + + QVariantMap dataMap = jsonDoc.toVariant().toMap(); + if (!dataMap.contains("payload") || !dataMap.value("payload").toMap().contains("devices")) { + qCWarning(dcTuya()) << "Invalid data from Tuya cloud:" << jsonDoc.toJson(); + emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); + return; + } + QVariantList devices = dataMap.value("payload").toMap().value("devices").toList(); + + qCDebug(dcTuya()) << "Devices fetched"; + + QList unknownDevices; + + foreach (const QVariant &deviceVariant, devices) { + QVariantMap deviceMap = deviceVariant.toMap(); + QString devType = deviceMap.value("dev_type").toString(); + QString id = deviceMap.value("id").toString(); + QString name = deviceMap.value("name").toString(); + + if (devType == "switch") { + bool online = deviceMap.value("data").toMap().value("online").toBool(); + bool state = deviceMap.value("data").toMap().value("state").toBool(); + + Device *d = myDevices().findByParams(ParamList() << Param(tuyaSwitchDeviceIdParamTypeId, id)); + if (d) { + qCDebug(dcTuya()) << "Found existing Tuya switch" << id << name; + d->setName(name); + d->setStateValue(tuyaSwitchConnectedStateTypeId, online); + d->setStateValue(tuyaSwitchPowerStateTypeId, state); + } else { + qCDebug(dcTuya()) << "Found new Tuya switch" << id << name; + DeviceDescriptor descriptor(tuyaSwitchDeviceClassId, name, QString(), device->id()); + descriptor.setParams(ParamList() << Param(tuyaSwitchDeviceIdParamTypeId, id)); + unknownDevices.append(descriptor); + } + } else { + qCWarning(dcTuya()) << "Skipping unsupported device type:" << devType; + continue; + } + } + + if (!unknownDevices.isEmpty()) { + emit autoDevicesAppeared(tuyaSwitchDeviceClassId, unknownDevices); + } + }); + +} + +void DevicePluginTuya::controlTuyaSwitch(Device *device, bool on, const ActionId &actionId) +{ + qCDebug(dcTuya()) << device->name() << "Controlling Tuya switch"; + Device *parentDevice = myDevices().findById(device->parentId()); + + pluginStorage()->beginGroup(parentDevice->id().toString()); + QString accesToken = pluginStorage()->value("accessToken").toString(); + pluginStorage()->endGroup(); + + QUrl url("http://px1.tuyaeu.com/homeassistant/skill"); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QVariantMap header; + header.insert("name", "turnOnOff"); + header.insert("namespace", "control"); + header.insert("payloadVersion", 1); + + QVariantMap payload; + payload.insert("accessToken", accesToken); + payload.insert("devId", device->paramValue(tuyaSwitchDeviceIdParamTypeId).toString()); + payload.insert("value", on ? 1 : 0); + + QVariantMap data; + data.insert("header", header); + data.insert("payload", payload); + + QJsonDocument jsonDoc = QJsonDocument::fromVariant(data); + + QNetworkReply *reply = hardwareManager()->networkManager()->post(request, jsonDoc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, [reply](){reply->deleteLater();}); + connect(reply, &QNetworkReply::finished, device, [this, device, reply, on, actionId](){ + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcTuya()) << "Error setting switch state" << reply->error(); + emit actionExecutionFinished(actionId, Device::DeviceErrorHardwareFailure); + return; + } + + QByteArray data = reply->readAll(); + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTuya()) << "Json parser error in control switch reply" << error.errorString() << data; + emit actionExecutionFinished(actionId, Device::DeviceErrorHardwareFailure); + return; + } + + QVariantMap dataMap = jsonDoc.toVariant().toMap(); + bool success = dataMap.value("header").toMap().value("code").toString() == "SUCCESS"; + emit actionExecutionFinished(actionId, success ? Device::DeviceErrorNoError : Device::DeviceErrorHardwareFailure); + device->setStateValue(tuyaSwitchPowerStateTypeId, on); + qCDebug(dcTuya()) << "Device controlled"; + }); +} + diff --git a/tuya/deviceplugintuya.h b/tuya/deviceplugintuya.h new file mode 100644 index 00000000..32fe79c9 --- /dev/null +++ b/tuya/deviceplugintuya.h @@ -0,0 +1,58 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2019 Michael Zanetti * + * * + * This file is part of nymea. * + * * + * nymea is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, version 2 of the License. * + * * + * nymea 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 nymea. If not, see . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef DEVICEPLUGINTUYA_H +#define DEVICEPLUGINTUYA_H + +#include "devices/deviceplugin.h" + +class PluginTimer; + +class DevicePluginTuya: public DevicePlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.DevicePlugin" FILE "deviceplugintuya.json") + Q_INTERFACES(DevicePlugin) + + +public: + explicit DevicePluginTuya(QObject *parent = nullptr); + ~DevicePluginTuya(); + + Device::DeviceSetupStatus setupDevice(Device *device) override; + void postSetupDevice(Device *device) override; + void deviceRemoved(Device *device) override; + DevicePairingInfo pairDevice(DevicePairingInfo &info) override; + DevicePairingInfo confirmPairing(DevicePairingInfo &info, const QString &username, const QString &secret) override; + Device::DeviceError executeAction(Device *device, const Action &action) override; + + +private: + void refreshAccessToken(Device *device, bool emitSetupFinished = false); + void updateChildDevices(Device *device); + + void controlTuyaSwitch(Device *device, bool on, const ActionId &actionId); + + QHash m_tokenExpiryTimers; + PluginTimer *m_pluginTimer = nullptr; +}; + +#endif // DEVICEPLUGINTUYA_H diff --git a/tuya/deviceplugintuya.json b/tuya/deviceplugintuya.json new file mode 100644 index 00000000..d572825d --- /dev/null +++ b/tuya/deviceplugintuya.json @@ -0,0 +1,70 @@ +{ + "name": "tuya", + "displayName": "Tuya", + "id": "405643b3-22ec-4a36-9808-e8b1405b01c9", + "vendors": [ + { + "name": "tuya", + "displayName": "Tuya", + "id": "d5dd33a7-e5f6-48be-bdd9-1a1ec04152c9", + "deviceClasses": [ + { + "id": "dd6dcd91-f667-45a5-9594-12b95f94337e", + "name": "tuyaCloud", + "displayName": "Tuya cloud login", + "createMethods": ["user"], + "setupMethod": "userandpassword", + "interfaces": [ ], + "stateTypes": [ + { + "id": "c844a23a-301b-4e6c-ba18-2926a38e6bf5", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + } + ] + }, + { + "id": "393d7256-e792-4dca-adb5-b13750e05391", + "name": "tuyaSwitch", + "displayName": "Tuya switch", + "createMethods": ["auto"], + "interfaces": ["powersocket", "connectable"], + "paramTypes": [ + { + "id": "bfdb02b0-d12d-4385-a03d-d2c147c2aca2", + "name": "id", + "displayName": "ID", + "type": "QString", + "defaultValue": "" + } + ], + "stateTypes": [ + { + "id": "b5ac83c4-e1ff-4682-80f2-61cca097ed8f", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "c84a703a-d1c7-491d-9507-5a69b217ac53", + "name": "power", + "displayName": "Power", + "displayNameEvent": "Power changed", + "displayNameAction": "Set power", + "type": "bool", + "defaultValue": false, + "writable": true + } + ] + } + ] + } + ] +} diff --git a/tuya/tuya.pro b/tuya/tuya.pro new file mode 100644 index 00000000..8bd79772 --- /dev/null +++ b/tuya/tuya.pro @@ -0,0 +1,13 @@ +include(../plugins.pri) + +QT += network + +PKGCONFIG += nymea-mqtt + +TARGET = $$qtLibraryTarget(nymea_deviceplugintuya) + +SOURCES += \ + deviceplugintuya.cpp \ + +HEADERS += \ + deviceplugintuya.h \ From b0884f88d7eb4397921399a57d77053273b47e07 Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Sat, 14 Sep 2019 02:10:25 +0200 Subject: [PATCH 03/10] Add closable, dpkg --- debian/control | 15 +++++++++++++++ tuya/deviceplugintuya.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/debian/control b/debian/control index 5a9889fd..813808a2 100644 --- a/debian/control +++ b/debian/control @@ -579,6 +579,21 @@ Description: nymea.io plugin for tp-link Kasa devices This package will install the nymea.io plugin for tp-link Kasa devices +Package: nymea-plugin-tuya +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + nymea-plugins-translations, +Description: nymea.io plugin for Tuya cloud devices + The nymea daemon is a plugin based IoT (Internet of Things) server. The + server works like a translator for devices, things and services and + allows them to interact. + With the powerful rule engine you are able to connect any device available + in the system and create individual scenes and behaviors for your environment. + . + This package will install the nymea.io plugin for Tuya cloud devices + + Package: nymea-plugin-udpcommander Architecture: any Depends: ${shlibs:Depends}, diff --git a/tuya/deviceplugintuya.json b/tuya/deviceplugintuya.json index d572825d..9865d8cf 100644 --- a/tuya/deviceplugintuya.json +++ b/tuya/deviceplugintuya.json @@ -63,7 +63,35 @@ "writable": true } ] + }, + { + "id": "d4bb0170-596d-4904-8fd0-fd8e7ad39f72", + "name": "tuyaClosable", + "displayName": "Tuya blinds", + "createMethods": ["auto"], + "interfaces": ["blind", "connectable"], + "paramTypes": [ + { + "id": "b9b2bb1f-b44b-43d7-8bbb-e67cf1b5d0a0", + "name": "id", + "displayName": "ID", + "type": "QString", + "defaultValue": "" + } + ], + "stateTypes": [ + { + "id": "b5ac83c4-e1ff-4682-80f2-61cca097ed8f", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + } + ] } + ] } ] From 7f7d211570a9df7e75279d399e276cf644b816ee Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 23 Sep 2019 00:19:51 +0200 Subject: [PATCH 04/10] update to new plugin api --- tuya/deviceplugintuya.cpp | 134 ++++++++++++++++++++++--------------- tuya/deviceplugintuya.h | 16 +++-- tuya/deviceplugintuya.json | 20 +++++- 3 files changed, 107 insertions(+), 63 deletions(-) diff --git a/tuya/deviceplugintuya.cpp b/tuya/deviceplugintuya.cpp index eef9b710..0f0b31d4 100644 --- a/tuya/deviceplugintuya.cpp +++ b/tuya/deviceplugintuya.cpp @@ -38,8 +38,14 @@ DevicePluginTuya::DevicePluginTuya(QObject *parent): DevicePlugin(parent) } -Device::DeviceSetupStatus DevicePluginTuya::setupDevice(Device *device) +DevicePluginTuya::~DevicePluginTuya() { + +} + +void DevicePluginTuya::setupDevice(DeviceSetupInfo *info) +{ + Device *device = info->device(); if (device->deviceClassId() == tuyaCloudDeviceClassId) { QTimer *tokenRefreshTimer = m_tokenExpiryTimers.value(device->id()); if (!tokenRefreshTimer) { @@ -49,6 +55,7 @@ Device::DeviceSetupStatus DevicePluginTuya::setupDevice(Device *device) } connect(tokenRefreshTimer, &QTimer::timeout, device, [this, device](){ + qCDebug(dcTuya()) << "Timer refresh token"; refreshAccessToken(device); }); @@ -56,15 +63,27 @@ Device::DeviceSetupStatus DevicePluginTuya::setupDevice(Device *device) if (tokenRefreshTimer->isActive()) { qCDebug(dcTuya()) << "Device already set up during pairing."; device->setStateValue(tuyaCloudConnectedStateTypeId, true); - return Device::DeviceSetupStatusSuccess; + return info->finish(Device::DeviceErrorNoError); } // Else, let's refresh the token now - refreshAccessToken(device, true); - return Device::DeviceSetupStatusAsync; + qCDebug(dcTuya()) << "Setup refresh token"; + refreshAccessToken(device); + + connect(this, &DevicePluginTuya::tokenRefreshed, info, [info](Device *device, bool success){ + if (device == info->device()) { + if (!success) { + info->finish(Device::DeviceErrorAuthenticationFailure, QT_TR_NOOP("Error authenticating to Tuya device.")); + } else { + info->finish(Device::DeviceErrorNoError); + } + } + }); + + return ; } - return Device::DeviceSetupStatusSuccess; + info->finish(Device::DeviceErrorNoError); } void DevicePluginTuya::postSetupDevice(Device *device) @@ -95,13 +114,12 @@ void DevicePluginTuya::deviceRemoved(Device *device) } } -DevicePairingInfo DevicePluginTuya::pairDevice(DevicePairingInfo &info) +void DevicePluginTuya::startPairing(DevicePairingInfo *info) { - info.setMessage(tr("Please enter username and password for your Tuya (Smart Life) account.")); - return info; + info->finish(Device::DeviceErrorNoError, QT_TR_NOOP("Please enter username and password for your Tuya (Smart Life) account.")); } -DevicePairingInfo DevicePluginTuya::confirmPairing(DevicePairingInfo &info, const QString &username, const QString &secret) +void DevicePluginTuya::confirmPairing(DevicePairingInfo *info, const QString &username, const QString &secret) { QUrl url(QString("http://px1.tuyaeu.com/homeassistant/auth.do")); @@ -117,19 +135,16 @@ DevicePairingInfo DevicePluginTuya::confirmPairing(DevicePairingInfo &info, cons QNetworkReply *reply = hardwareManager()->networkManager()->post(request, query.toString().toUtf8()); + connect(reply, &QNetworkReply::finished, &QNetworkReply::deleteLater); qCDebug(dcTuya()) << "Pairing Tuya device"; - connect(reply, &QNetworkReply::finished, this, [this, reply, info](){ + connect(reply, &QNetworkReply::finished, info, [this, reply, info](){ reply->deleteLater(); QByteArray data = reply->readAll(); - DevicePairingInfo ret(info); - if (reply->error() != QNetworkReply::NoError) { qCWarning(dcTuya()) << "Server error:" << reply->errorString(); - ret.setMessage(tr("Error communicating with Tuya server.")); - ret.setStatus(Device::DeviceErrorHardwareFailure); - emit pairingFinished(ret); + info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error communicating with Tuya server.")); return; } @@ -137,14 +152,22 @@ DevicePairingInfo DevicePluginTuya::confirmPairing(DevicePairingInfo &info, cons QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qCWarning(dcTuya()) << "Json parse error:" << error.errorString(); - ret.setMessage(tr("Error communicating with Tuya server.")); - ret.setStatus(Device::DeviceErrorHardwareFailure); - emit pairingFinished(ret); + info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error communicating with Tuya server.")); return; } + qCDebug(dcTuya()) << "Response from tuya api:" << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Indented)); + QVariantMap result = jsonDoc.toVariant().toMap(); - pluginStorage()->beginGroup(info.deviceId().toString()); + + + if (result.value("responseStatus") == "error") { + qCDebug(dcTuya()) << "Error response from service."; + info->finish(Device::DeviceErrorAuthenticationFailure, QT_TR_NOOP("Wrong username or password.")); + return; + } + + pluginStorage()->beginGroup(info->deviceId().toString()); pluginStorage()->setValue("accessToken", result.value("access_token").toString()); pluginStorage()->setValue("refreshToken", result.value("refresh_token").toString()); pluginStorage()->endGroup(); @@ -155,31 +178,26 @@ DevicePairingInfo DevicePluginTuya::confirmPairing(DevicePairingInfo &info, cons t->setSingleShot(true); t->start(timeout * 1000); - m_tokenExpiryTimers.insert(info.deviceId(), t); + m_tokenExpiryTimers.insert(info->deviceId(), t); qCDebug(dcTuya()) << "Tuya device paired. Token expires in" << timeout; - ret.setStatus(Device::DeviceErrorNoError); - emit pairingFinished(ret); - + info->finish(Device::DeviceErrorNoError); }); - - info.setStatus(Device::DeviceErrorAsync); - return info; } -Device::DeviceError DevicePluginTuya::executeAction(Device *device, const Action &action) +void DevicePluginTuya::executeAction(DeviceActionInfo *info) { - if (action.actionTypeId() == tuyaSwitchPowerActionTypeId) { - controlTuyaSwitch(device, action.param(tuyaSwitchPowerActionPowerParamTypeId).value().toBool(), action.id()); - return Device::DeviceErrorAsync; + if (info->action().actionTypeId() == tuyaSwitchPowerActionTypeId) { + controlTuyaSwitch(info); + return; } - return Device::DeviceErrorDeviceClassNotFound; + Q_ASSERT_X(false, "tuyaplugin", "Unhandled action type " + info->action().actionTypeId().toByteArray()); } -void DevicePluginTuya::refreshAccessToken(Device *device, bool emitSetupFinished) +void DevicePluginTuya::refreshAccessToken(Device *device) { - qCDebug(dcTuya()) << device->name() << "Refreshing access token."; + qCDebug(dcTuya()) << device->name() << "Refreshing access token for" << device->name(); pluginStorage()->beginGroup(device->id().toString()); QString refreshToken = pluginStorage()->value("refreshToken").toString(); @@ -196,13 +214,12 @@ void DevicePluginTuya::refreshAccessToken(Device *device, bool emitSetupFinished QNetworkReply *reply = hardwareManager()->networkManager()->get(request); connect(reply, &QNetworkReply::finished, [reply](){ reply->deleteLater(); }); - connect(reply, &QNetworkReply::finished, device, [this, reply, device, emitSetupFinished](){ + + connect(reply, &QNetworkReply::finished, device, [this, reply, device](){ if (reply->error() != QNetworkReply::NoError) { qCWarning(dcTuya()) << "Error refreshing access token"; - if (emitSetupFinished) { - emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); - } device->setStateValue(tuyaCloudConnectedStateTypeId, false); + emit tokenRefreshed(device, false); return; } QByteArray data = reply->readAll(); @@ -211,9 +228,13 @@ void DevicePluginTuya::refreshAccessToken(Device *device, bool emitSetupFinished QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qCWarning(dcTuya()) << "Failed to parse json reply when refreshing access token" << error.errorString(); - if (emitSetupFinished) { - emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); - } + device->setStateValue(tuyaCloudConnectedStateTypeId, false); + emit tokenRefreshed(device, false); + return; + } + + if (jsonDoc.toVariant().toMap().isEmpty()) { + qCWarning(dcTuya()) << "Empty response from Tuya server"; device->setStateValue(tuyaCloudConnectedStateTypeId, false); return; } @@ -224,15 +245,14 @@ void DevicePluginTuya::refreshAccessToken(Device *device, bool emitSetupFinished pluginStorage()->endGroup(); int tokenExpiry = jsonDoc.toVariant().toMap().value("expires_in").toInt(); + qCDebug(dcTuya()) << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Indented)); qCDebug(dcTuya()) << "Access token for" << device->name() << "refreshed. Expires in" << tokenExpiry; QTimer *t = m_tokenExpiryTimers.value(device->id()); t->start(tokenExpiry); device->setStateValue(tuyaCloudConnectedStateTypeId, true); - if (emitSetupFinished) { - emit deviceSetupFinished(device, Device::DeviceSetupStatusSuccess); - } + emit tokenRefreshed(device, true); }); } @@ -267,7 +287,6 @@ void DevicePluginTuya::updateChildDevices(Device *device) connect(reply, &QNetworkReply::finished, device, [this, device, reply](){ if (reply->error() != QNetworkReply::NoError) { qCWarning(dcTuya()) << "Error fetching devices from Tuya cloud" << reply->error(); - emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); return; } @@ -276,14 +295,12 @@ void DevicePluginTuya::updateChildDevices(Device *device) QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qCWarning(dcTuya()) << "Json parser error updating child devices" << error.errorString(); - emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); return; } QVariantMap dataMap = jsonDoc.toVariant().toMap(); if (!dataMap.contains("payload") || !dataMap.value("payload").toMap().contains("devices")) { qCWarning(dcTuya()) << "Invalid data from Tuya cloud:" << jsonDoc.toJson(); - emit deviceSetupFinished(device, Device::DeviceSetupStatusFailure); return; } QVariantList devices = dataMap.value("payload").toMap().value("devices").toList(); @@ -321,17 +338,20 @@ void DevicePluginTuya::updateChildDevices(Device *device) } if (!unknownDevices.isEmpty()) { - emit autoDevicesAppeared(tuyaSwitchDeviceClassId, unknownDevices); + emit autoDevicesAppeared(unknownDevices); } }); } -void DevicePluginTuya::controlTuyaSwitch(Device *device, bool on, const ActionId &actionId) +void DevicePluginTuya::controlTuyaSwitch(DeviceActionInfo *info) { - qCDebug(dcTuya()) << device->name() << "Controlling Tuya switch"; + bool on = info->action().param(tuyaSwitchPowerActionPowerParamTypeId).value().toBool(); + Device *device = info->device(); Device *parentDevice = myDevices().findById(device->parentId()); + qCDebug(dcTuya()) << device->name() << "Controlling Tuya switch. Parent:" << parentDevice->name() << "on:" << on; + pluginStorage()->beginGroup(parentDevice->id().toString()); QString accesToken = pluginStorage()->value("accessToken").toString(); pluginStorage()->endGroup(); @@ -359,10 +379,10 @@ void DevicePluginTuya::controlTuyaSwitch(Device *device, bool on, const ActionId QNetworkReply *reply = hardwareManager()->networkManager()->post(request, jsonDoc.toJson(QJsonDocument::Compact)); connect(reply, &QNetworkReply::finished, [reply](){reply->deleteLater();}); - connect(reply, &QNetworkReply::finished, device, [this, device, reply, on, actionId](){ + connect(reply, &QNetworkReply::finished, info, [this, info, reply, on](){ if (reply->error() != QNetworkReply::NoError) { qCWarning(dcTuya()) << "Error setting switch state" << reply->error(); - emit actionExecutionFinished(actionId, Device::DeviceErrorHardwareFailure); + info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error connecting to Tuya switch.")); return; } @@ -371,15 +391,21 @@ void DevicePluginTuya::controlTuyaSwitch(Device *device, bool on, const ActionId QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qCWarning(dcTuya()) << "Json parser error in control switch reply" << error.errorString() << data; - emit actionExecutionFinished(actionId, Device::DeviceErrorHardwareFailure); + info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Received an unexpected reply from the Tuya switch.")); return; } QVariantMap dataMap = jsonDoc.toVariant().toMap(); bool success = dataMap.value("header").toMap().value("code").toString() == "SUCCESS"; - emit actionExecutionFinished(actionId, success ? Device::DeviceErrorNoError : Device::DeviceErrorHardwareFailure); - device->setStateValue(tuyaSwitchPowerStateTypeId, on); + if (!success) { + qCWarning(dcTuya()) << "Tuya response indicates an issue..."; + info->finish(Device::DeviceErrorHardwareFailure); + return; + } + qCDebug(dcTuya()) << "Device controlled"; + info->device()->setStateValue(tuyaSwitchPowerStateTypeId, on); + info->finish(Device::DeviceErrorNoError); }); } diff --git a/tuya/deviceplugintuya.h b/tuya/deviceplugintuya.h index 32fe79c9..d3ae85be 100644 --- a/tuya/deviceplugintuya.h +++ b/tuya/deviceplugintuya.h @@ -35,21 +35,23 @@ class DevicePluginTuya: public DevicePlugin public: explicit DevicePluginTuya(QObject *parent = nullptr); - ~DevicePluginTuya(); + ~DevicePluginTuya() override; - Device::DeviceSetupStatus setupDevice(Device *device) override; + void setupDevice(DeviceSetupInfo *info) override; void postSetupDevice(Device *device) override; void deviceRemoved(Device *device) override; - DevicePairingInfo pairDevice(DevicePairingInfo &info) override; - DevicePairingInfo confirmPairing(DevicePairingInfo &info, const QString &username, const QString &secret) override; - Device::DeviceError executeAction(Device *device, const Action &action) override; + void startPairing(DevicePairingInfo *info) override; + void confirmPairing(DevicePairingInfo *info, const QString &username, const QString &secret) override; + void executeAction(DeviceActionInfo *info) override; +signals: + void tokenRefreshed(Device *device, bool success); private: - void refreshAccessToken(Device *device, bool emitSetupFinished = false); + void refreshAccessToken(Device *device); void updateChildDevices(Device *device); - void controlTuyaSwitch(Device *device, bool on, const ActionId &actionId); + void controlTuyaSwitch(DeviceActionInfo *info); QHash m_tokenExpiryTimers; PluginTimer *m_pluginTimer = nullptr; diff --git a/tuya/deviceplugintuya.json b/tuya/deviceplugintuya.json index 9865d8cf..19053191 100644 --- a/tuya/deviceplugintuya.json +++ b/tuya/deviceplugintuya.json @@ -81,7 +81,7 @@ ], "stateTypes": [ { - "id": "b5ac83c4-e1ff-4682-80f2-61cca097ed8f", + "id": "cf051676-3041-4e90-8c37-63e98412dfe8", "name": "connected", "displayName": "Connected", "displayNameEvent": "Connected changed", @@ -89,9 +89,25 @@ "defaultValue": false, "cached": false } + ], + "actionTypes": [ + { + "id": "f9f34515-670d-439e-851a-0bf82f867882", + "name": "open", + "displayName": "Open blinds" + }, + { + "id": "f266ea4a-052f-466b-bf31-8c5c7e80a843", + "name": "close", + "displayName": "Close blinds" + }, + { + "id": "8fc2ea7d-b945-41c8-bbda-820bdadc9e02", + "name": "stop", + "displayName": "Stop" + } ] } - ] } ] From 82cbf58ba8e2de973c66c18aa018459188275b67 Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 23 Sep 2019 01:10:02 +0200 Subject: [PATCH 05/10] Add blinds support --- tuya/deviceplugintuya.cpp | 45 ++++++++++++++++++++++++++++++++------- tuya/deviceplugintuya.h | 2 +- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/tuya/deviceplugintuya.cpp b/tuya/deviceplugintuya.cpp index 0f0b31d4..33ca8ecd 100644 --- a/tuya/deviceplugintuya.cpp +++ b/tuya/deviceplugintuya.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include "hardwaremanager.h" #include "network/networkaccessmanager.h" @@ -189,7 +190,23 @@ void DevicePluginTuya::confirmPairing(DevicePairingInfo *info, const QString &us void DevicePluginTuya::executeAction(DeviceActionInfo *info) { if (info->action().actionTypeId() == tuyaSwitchPowerActionTypeId) { - controlTuyaSwitch(info); + bool on = info->action().param(tuyaSwitchPowerActionPowerParamTypeId).value().toBool(); + controlTuyaSwitch("turnOnOff", on ? "1" : "0", info); + connect(info, &DeviceActionInfo::finished, [info, on](){ + info->device()->setStateValue(tuyaSwitchPowerStateTypeId, on); + }); + return; + } + if (info->action().actionTypeId() == tuyaClosableOpenActionTypeId) { + controlTuyaSwitch("turnOnOff", "1", info); + return; + } + if (info->action().actionTypeId() == tuyaClosableCloseActionTypeId) { + controlTuyaSwitch("turnOnOff", "0", info); + return; + } + if (info->action().actionTypeId() == tuyaClosableStopActionTypeId) { + controlTuyaSwitch("startStop", "0", info); return; } Q_ASSERT_X(false, "tuyaplugin", "Unhandled action type " + info->action().actionTypeId().toByteArray()); @@ -331,6 +348,20 @@ void DevicePluginTuya::updateChildDevices(Device *device) descriptor.setParams(ParamList() << Param(tuyaSwitchDeviceIdParamTypeId, id)); unknownDevices.append(descriptor); } + } else if (devType == "cover") { + bool online = deviceMap.value("data").toMap().value("online").toBool(); + + Device *d = myDevices().findByParams(ParamList() << Param(tuyaClosableDeviceIdParamTypeId, id)); + if (d) { + qCDebug(dcTuya()) << "Found existing Tuya cover" << id << name; + d->setName(name); + d->setStateValue(tuyaClosableConnectedStateTypeId, online); + } else { + qCDebug(dcTuya()) << "Found new Tuya cover" << id << name; + DeviceDescriptor descriptor(tuyaClosableDeviceClassId, name, QString(), device->id()); + descriptor.setParams(ParamList() << Param(tuyaClosableDeviceIdParamTypeId, id)); + unknownDevices.append(descriptor); + } } else { qCWarning(dcTuya()) << "Skipping unsupported device type:" << devType; continue; @@ -344,13 +375,12 @@ void DevicePluginTuya::updateChildDevices(Device *device) } -void DevicePluginTuya::controlTuyaSwitch(DeviceActionInfo *info) +void DevicePluginTuya::controlTuyaSwitch(const QString &command, const QString &value, DeviceActionInfo *info) { - bool on = info->action().param(tuyaSwitchPowerActionPowerParamTypeId).value().toBool(); Device *device = info->device(); Device *parentDevice = myDevices().findById(device->parentId()); - qCDebug(dcTuya()) << device->name() << "Controlling Tuya switch. Parent:" << parentDevice->name() << "on:" << on; + qCDebug(dcTuya()) << device->name() << "Controlling Tuya switch. Parent:" << parentDevice->name() << "command:" << command << "value:" << value; pluginStorage()->beginGroup(parentDevice->id().toString()); QString accesToken = pluginStorage()->value("accessToken").toString(); @@ -362,14 +392,14 @@ void DevicePluginTuya::controlTuyaSwitch(DeviceActionInfo *info) request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); QVariantMap header; - header.insert("name", "turnOnOff"); + header.insert("name", command); header.insert("namespace", "control"); header.insert("payloadVersion", 1); QVariantMap payload; payload.insert("accessToken", accesToken); payload.insert("devId", device->paramValue(tuyaSwitchDeviceIdParamTypeId).toString()); - payload.insert("value", on ? 1 : 0); + payload.insert("value", value); QVariantMap data; data.insert("header", header); @@ -379,7 +409,7 @@ void DevicePluginTuya::controlTuyaSwitch(DeviceActionInfo *info) QNetworkReply *reply = hardwareManager()->networkManager()->post(request, jsonDoc.toJson(QJsonDocument::Compact)); connect(reply, &QNetworkReply::finished, [reply](){reply->deleteLater();}); - connect(reply, &QNetworkReply::finished, info, [this, info, reply, on](){ + connect(reply, &QNetworkReply::finished, info, [info, reply](){ if (reply->error() != QNetworkReply::NoError) { qCWarning(dcTuya()) << "Error setting switch state" << reply->error(); info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error connecting to Tuya switch.")); @@ -404,7 +434,6 @@ void DevicePluginTuya::controlTuyaSwitch(DeviceActionInfo *info) } qCDebug(dcTuya()) << "Device controlled"; - info->device()->setStateValue(tuyaSwitchPowerStateTypeId, on); info->finish(Device::DeviceErrorNoError); }); } diff --git a/tuya/deviceplugintuya.h b/tuya/deviceplugintuya.h index d3ae85be..03b6625e 100644 --- a/tuya/deviceplugintuya.h +++ b/tuya/deviceplugintuya.h @@ -51,7 +51,7 @@ private: void refreshAccessToken(Device *device); void updateChildDevices(Device *device); - void controlTuyaSwitch(DeviceActionInfo *info); + void controlTuyaSwitch(const QString &command, const QString &value, DeviceActionInfo *info); QHash m_tokenExpiryTimers; PluginTimer *m_pluginTimer = nullptr; From 20bae3b5d512e0960e0893173a599c511df1e1cf Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 23 Sep 2019 01:11:36 +0200 Subject: [PATCH 06/10] Add links to "docs" --- tuya/deviceplugintuya.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tuya/deviceplugintuya.cpp b/tuya/deviceplugintuya.cpp index 33ca8ecd..1d8118e8 100644 --- a/tuya/deviceplugintuya.cpp +++ b/tuya/deviceplugintuya.cpp @@ -34,6 +34,10 @@ #include "plugintimer.h" +// API info: +// Python project: https://github.com/PaulAnnekov/tuyaha +// JS project: https://github.com/unparagoned/cloudtuya + DevicePluginTuya::DevicePluginTuya(QObject *parent): DevicePlugin(parent) { From f93a433367b285cca9ba3b3b7f8e3e8a15a745cf Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 23 Sep 2019 17:07:51 +0200 Subject: [PATCH 07/10] add install file --- debian/nymea-plugin-tuya.install.in | 1 + 1 file changed, 1 insertion(+) create mode 100644 debian/nymea-plugin-tuya.install.in diff --git a/debian/nymea-plugin-tuya.install.in b/debian/nymea-plugin-tuya.install.in new file mode 100644 index 00000000..f2e09362 --- /dev/null +++ b/debian/nymea-plugin-tuya.install.in @@ -0,0 +1 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_deviceplugintuya.so From a8e0ace17f0f11e97d227086dea1e253f5578005 Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 23 Sep 2019 19:54:40 +0200 Subject: [PATCH 08/10] fix xenial build --- tuya/deviceplugintuya.cpp | 1 - tuya/deviceplugintuya.h | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tuya/deviceplugintuya.cpp b/tuya/deviceplugintuya.cpp index 1d8118e8..9e8519ac 100644 --- a/tuya/deviceplugintuya.cpp +++ b/tuya/deviceplugintuya.cpp @@ -26,7 +26,6 @@ #include #include #include -#include #include #include "hardwaremanager.h" diff --git a/tuya/deviceplugintuya.h b/tuya/deviceplugintuya.h index 03b6625e..c302a4eb 100644 --- a/tuya/deviceplugintuya.h +++ b/tuya/deviceplugintuya.h @@ -21,6 +21,8 @@ #ifndef DEVICEPLUGINTUYA_H #define DEVICEPLUGINTUYA_H +#include + #include "devices/deviceplugin.h" class PluginTimer; From 3c0a4986f0a5b3faa2478b28531a3701d88c0cc5 Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Tue, 17 Dec 2019 23:57:38 +0100 Subject: [PATCH 09/10] Implement account interface --- tuya/deviceplugintuya.cpp | 17 ++++++++++++++++- tuya/deviceplugintuya.json | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tuya/deviceplugintuya.cpp b/tuya/deviceplugintuya.cpp index 9e8519ac..795de219 100644 --- a/tuya/deviceplugintuya.cpp +++ b/tuya/deviceplugintuya.cpp @@ -67,6 +67,11 @@ void DevicePluginTuya::setupDevice(DeviceSetupInfo *info) if (tokenRefreshTimer->isActive()) { qCDebug(dcTuya()) << "Device already set up during pairing."; device->setStateValue(tuyaCloudConnectedStateTypeId, true); + device->setStateValue(tuyaCloudLoggedInStateTypeId, true); + pluginStorage()->beginGroup(device->id().toString()); + QString username = pluginStorage()->value("username").toString(); + pluginStorage()->endGroup(); + device->setStateValue(tuyaCloudUserDisplayNameStateTypeId, username); return info->finish(Device::DeviceErrorNoError); } @@ -142,7 +147,7 @@ void DevicePluginTuya::confirmPairing(DevicePairingInfo *info, const QString &us connect(reply, &QNetworkReply::finished, &QNetworkReply::deleteLater); qCDebug(dcTuya()) << "Pairing Tuya device"; - connect(reply, &QNetworkReply::finished, info, [this, reply, info](){ + connect(reply, &QNetworkReply::finished, info, [this, reply, info, username](){ reply->deleteLater(); QByteArray data = reply->readAll(); @@ -174,6 +179,7 @@ void DevicePluginTuya::confirmPairing(DevicePairingInfo *info, const QString &us pluginStorage()->beginGroup(info->deviceId().toString()); pluginStorage()->setValue("accessToken", result.value("access_token").toString()); pluginStorage()->setValue("refreshToken", result.value("refresh_token").toString()); + pluginStorage()->setValue("username", username); pluginStorage()->endGroup(); int timeout = result.value("expires_in").toInt(); @@ -239,6 +245,7 @@ void DevicePluginTuya::refreshAccessToken(Device *device) if (reply->error() != QNetworkReply::NoError) { qCWarning(dcTuya()) << "Error refreshing access token"; device->setStateValue(tuyaCloudConnectedStateTypeId, false); + device->setStateValue(tuyaCloudLoggedInStateTypeId, false); emit tokenRefreshed(device, false); return; } @@ -249,6 +256,7 @@ void DevicePluginTuya::refreshAccessToken(Device *device) if (error.error != QJsonParseError::NoError) { qCWarning(dcTuya()) << "Failed to parse json reply when refreshing access token" << error.errorString(); device->setStateValue(tuyaCloudConnectedStateTypeId, false); + device->setStateValue(tuyaCloudLoggedInStateTypeId, false); emit tokenRefreshed(device, false); return; } @@ -256,6 +264,7 @@ void DevicePluginTuya::refreshAccessToken(Device *device) if (jsonDoc.toVariant().toMap().isEmpty()) { qCWarning(dcTuya()) << "Empty response from Tuya server"; device->setStateValue(tuyaCloudConnectedStateTypeId, false); + device->setStateValue(tuyaCloudLoggedInStateTypeId, false); return; } @@ -271,6 +280,12 @@ void DevicePluginTuya::refreshAccessToken(Device *device) QTimer *t = m_tokenExpiryTimers.value(device->id()); t->start(tokenExpiry); device->setStateValue(tuyaCloudConnectedStateTypeId, true); + device->setStateValue(tuyaCloudLoggedInStateTypeId, true); + + pluginStorage()->beginGroup(device->id().toString()); + QString username = pluginStorage()->value("username").toString(); + pluginStorage()->endGroup(); + device->setStateValue(tuyaCloudUserDisplayNameStateTypeId, username); emit tokenRefreshed(device, true); }); diff --git a/tuya/deviceplugintuya.json b/tuya/deviceplugintuya.json index 19053191..fc835cfb 100644 --- a/tuya/deviceplugintuya.json +++ b/tuya/deviceplugintuya.json @@ -14,7 +14,7 @@ "displayName": "Tuya cloud login", "createMethods": ["user"], "setupMethod": "userandpassword", - "interfaces": [ ], + "interfaces": [ "account" ], "stateTypes": [ { "id": "c844a23a-301b-4e6c-ba18-2926a38e6bf5", @@ -24,6 +24,22 @@ "type": "bool", "defaultValue": false, "cached": false + }, + { + "id": "33e9d30c-c988-4cd4-8d39-ce84bcfa79c5", + "name": "loggedIn", + "displayName": "Logged in", + "displayNameEvent": "Logged in changed", + "type": "bool", + "defaultValue": false + }, + { + "id": "15bc38a7-0962-4f3b-a0b1-4f164958493b", + "name": "userDisplayName", + "displayName": "Username", + "displayNameEvent": "User changed", + "type": "QString", + "defaultValue": "" } ] }, From ad614f370ac7227163b8bd41f19279c4e1c98688 Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Sat, 18 Jan 2020 20:57:43 +0100 Subject: [PATCH 10/10] clean up some debug prints and avoid overwriting the device name --- tuya/deviceplugintuya.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tuya/deviceplugintuya.cpp b/tuya/deviceplugintuya.cpp index 795de219..cedcd3cd 100644 --- a/tuya/deviceplugintuya.cpp +++ b/tuya/deviceplugintuya.cpp @@ -356,8 +356,7 @@ void DevicePluginTuya::updateChildDevices(Device *device) Device *d = myDevices().findByParams(ParamList() << Param(tuyaSwitchDeviceIdParamTypeId, id)); if (d) { - qCDebug(dcTuya()) << "Found existing Tuya switch" << id << name; - d->setName(name); + qCDebug(dcTuya()) << "Found existing Tuya switch" << d->name() << id << name << (online ? "online:" : "offline") << (state ? "on": "off"); d->setStateValue(tuyaSwitchConnectedStateTypeId, online); d->setStateValue(tuyaSwitchPowerStateTypeId, state); } else { @@ -371,8 +370,7 @@ void DevicePluginTuya::updateChildDevices(Device *device) Device *d = myDevices().findByParams(ParamList() << Param(tuyaClosableDeviceIdParamTypeId, id)); if (d) { - qCDebug(dcTuya()) << "Found existing Tuya cover" << id << name; - d->setName(name); + qCDebug(dcTuya()) << "Found existing Tuya cover" << d->name() << id << name << (online ? "online" : "offline"); d->setStateValue(tuyaClosableConnectedStateTypeId, online); } else { qCDebug(dcTuya()) << "Found new Tuya cover" << id << name; @@ -382,6 +380,7 @@ void DevicePluginTuya::updateChildDevices(Device *device) } } else { qCWarning(dcTuya()) << "Skipping unsupported device type:" << devType; + qCWarning(dcTuya()) << "Please report this including the following data:\n" << qUtf8Printable(QJsonDocument::fromVariant(deviceVariant).toJson()); continue; } }