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 \