diff --git a/debian/control b/debian/control index 1840e055..813808a2 100644 --- a/debian/control +++ b/debian/control @@ -564,6 +564,36 @@ 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-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}, @@ -852,6 +882,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/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 diff --git a/nymea-plugins.pro b/nymea-plugins.pro index 9e1177a0..1a61f501 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -46,6 +46,8 @@ PLUGIN_DIRS = \ tasmota \ tcpcommander \ texasinstruments \ + tplink \ + tuya \ 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}) + + + + 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..cedcd3cd --- /dev/null +++ b/tuya/deviceplugintuya.cpp @@ -0,0 +1,457 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * 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" + +// API info: +// Python project: https://github.com/PaulAnnekov/tuyaha +// JS project: https://github.com/unparagoned/cloudtuya + +DevicePluginTuya::DevicePluginTuya(QObject *parent): DevicePlugin(parent) +{ + +} + +DevicePluginTuya::~DevicePluginTuya() +{ + +} + +void DevicePluginTuya::setupDevice(DeviceSetupInfo *info) +{ + Device *device = info->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](){ + qCDebug(dcTuya()) << "Timer refresh token"; + 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); + 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); + } + + // Else, let's refresh the token now + 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 ; + } + + info->finish(Device::DeviceErrorNoError); +} + +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; + } +} + +void DevicePluginTuya::startPairing(DevicePairingInfo *info) +{ + info->finish(Device::DeviceErrorNoError, QT_TR_NOOP("Please enter username and password for your Tuya (Smart Life) account.")); +} + +void 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()); + connect(reply, &QNetworkReply::finished, &QNetworkReply::deleteLater); + + qCDebug(dcTuya()) << "Pairing Tuya device"; + connect(reply, &QNetworkReply::finished, info, [this, reply, info, username](){ + reply->deleteLater(); + QByteArray data = reply->readAll(); + + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcTuya()) << "Server error:" << reply->errorString(); + info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error communicating with Tuya server.")); + return; + } + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcTuya()) << "Json parse error:" << error.errorString(); + 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(); + + + 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()->setValue("username", username); + 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; + + info->finish(Device::DeviceErrorNoError); + }); +} + +void DevicePluginTuya::executeAction(DeviceActionInfo *info) +{ + if (info->action().actionTypeId() == tuyaSwitchPowerActionTypeId) { + 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()); +} + +void DevicePluginTuya::refreshAccessToken(Device *device) +{ + qCDebug(dcTuya()) << device->name() << "Refreshing access token for" << device->name(); + + 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](){ + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcTuya()) << "Error refreshing access token"; + device->setStateValue(tuyaCloudConnectedStateTypeId, false); + device->setStateValue(tuyaCloudLoggedInStateTypeId, false); + emit tokenRefreshed(device, 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(); + device->setStateValue(tuyaCloudConnectedStateTypeId, false); + device->setStateValue(tuyaCloudLoggedInStateTypeId, false); + emit tokenRefreshed(device, false); + return; + } + + if (jsonDoc.toVariant().toMap().isEmpty()) { + qCWarning(dcTuya()) << "Empty response from Tuya server"; + device->setStateValue(tuyaCloudConnectedStateTypeId, false); + device->setStateValue(tuyaCloudLoggedInStateTypeId, 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()) << 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); + 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); + }); +} + +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(); + 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(); + 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(); + 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" << d->name() << id << name << (online ? "online:" : "offline") << (state ? "on": "off"); + 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 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" << d->name() << id << name << (online ? "online" : "offline"); + 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; + qCWarning(dcTuya()) << "Please report this including the following data:\n" << qUtf8Printable(QJsonDocument::fromVariant(deviceVariant).toJson()); + continue; + } + } + + if (!unknownDevices.isEmpty()) { + emit autoDevicesAppeared(unknownDevices); + } + }); + +} + +void DevicePluginTuya::controlTuyaSwitch(const QString &command, const QString &value, DeviceActionInfo *info) +{ + Device *device = info->device(); + Device *parentDevice = myDevices().findById(device->parentId()); + + 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(); + pluginStorage()->endGroup(); + + QUrl url("http://px1.tuyaeu.com/homeassistant/skill"); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QVariantMap header; + 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", value); + + 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, 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.")); + 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; + 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"; + if (!success) { + qCWarning(dcTuya()) << "Tuya response indicates an issue..."; + info->finish(Device::DeviceErrorHardwareFailure); + return; + } + + qCDebug(dcTuya()) << "Device controlled"; + info->finish(Device::DeviceErrorNoError); + }); +} + diff --git a/tuya/deviceplugintuya.h b/tuya/deviceplugintuya.h new file mode 100644 index 00000000..c302a4eb --- /dev/null +++ b/tuya/deviceplugintuya.h @@ -0,0 +1,62 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * 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 + +#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() override; + + void setupDevice(DeviceSetupInfo *info) override; + void postSetupDevice(Device *device) override; + void deviceRemoved(Device *device) 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); + void updateChildDevices(Device *device); + + void controlTuyaSwitch(const QString &command, const QString &value, DeviceActionInfo *info); + + 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..fc835cfb --- /dev/null +++ b/tuya/deviceplugintuya.json @@ -0,0 +1,130 @@ +{ + "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": [ "account" ], + "stateTypes": [ + { + "id": "c844a23a-301b-4e6c-ba18-2926a38e6bf5", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "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": "" + } + ] + }, + { + "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 + } + ] + }, + { + "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": "cf051676-3041-4e90-8c37-63e98412dfe8", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "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" + } + ] + } + ] + } + ] +} 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 \