diff --git a/tado/integrationplugintado.cpp b/tado/integrationplugintado.cpp index 1be4c4cf..9b8a7676 100644 --- a/tado/integrationplugintado.cpp +++ b/tado/integrationplugintado.cpp @@ -1,6 +1,6 @@ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -* Copyright 2013 - 2020, nymea GmbH +* Copyright 2013 - 2025, nymea GmbH * Contact: contact@nymea.io * * This file is part of nymea. @@ -43,67 +43,74 @@ IntegrationPluginTado::IntegrationPluginTado() } -void IntegrationPluginTado::startPairing(ThingPairingInfo *info) +void IntegrationPluginTado::init() { - qCDebug(dcTado()) << "Start pairing process, checking the internet connection ..."; - NetworkAccessManager *network = hardwareManager()->networkManager(); - QNetworkReply *reply = network->get(QNetworkRequest(QUrl("https://my.tado.com/api/v2"))); - connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, info, [reply, info] { - if (reply->error() == QNetworkReply::NetworkError::HostNotFoundError) { - qCWarning(dcTado()) << "Tado server is not reachable, likely because of a missing internet connection."; - info->finish(Thing::ThingErrorHardwareNotAvailable, QT_TR_NOOP("Tado server is not reachable.")); - } else { - qCDebug(dcTado()) << "Internet connection available"; - info->finish(Thing::ThingErrorNoError, QT_TR_NOOP("Please enter the login credentials for your Tado account.")); - } - }); } -void IntegrationPluginTado::confirmPairing(ThingPairingInfo *info, const QString &username, const QString &password) +void IntegrationPluginTado::startPairing(ThingPairingInfo *info) { - qCDebug(dcTado()) << "Confirm pairing" << username << "Network manager available" << hardwareManager()->networkManager()->available(); - Tado *tado = new Tado(hardwareManager()->networkManager(), username, this); + qCDebug(dcTado()) << "Start pairing process ..."; + + Tado *tado = new Tado(hardwareManager()->networkManager(), this); m_unfinishedTadoAccounts.insert(info->thingId(), tado); connect(info, &ThingPairingInfo::aborted, this, [info, tado, this]() { - qCWarning(dcTado()) << "Thing pairing has been aborted, going to clean-up"; + qCWarning(dcTado()) << "Thing pairing has been aborted, cleaning up..."; m_unfinishedTadoAccounts.remove(info->thingId()); tado->deleteLater(); }); + connect(tado, &Tado::getLoginUrlFinished, info, [info, tado, this] (bool success) { + if (!success) { + info->finish(Thing::ThingErrorAuthenticationFailure); + return; + } + + connect(info, &ThingPairingInfo::aborted, tado, [info, this]() { + qCWarning(dcTado()) << "ThingPairingInfo aborted, cleaning up pending setup connection."; + m_unfinishedTadoAccounts.take(info->thingId())->deleteLater(); + }); + + + + qCDebug(dcTado()) << "Tado server is reachable. Starting the OAuth pairing process using" << tado->loginUrl(); + info->setOAuthUrl(QUrl(tado->loginUrl())); + info->finish(Thing::ThingErrorNoError); + }); + + tado->getLoginUrl(); +} + +void IntegrationPluginTado::confirmPairing(ThingPairingInfo *info, const QString &username, const QString &password) +{ + Q_UNUSED(username) + + qCDebug(dcTado()) << "Confirm pairing" << password; + Tado *tado = m_unfinishedTadoAccounts.value(info->thingId()); + connect(tado, &Tado::connectionError, info, [info] (QNetworkReply::NetworkError error){ - - if (error == QNetworkReply::NetworkError::ProtocolInvalidOperationError) { - qCWarning(dcTado()) << "Confirm pairing failed, wrong username or password"; - info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Wrong username or password.")); - } else if (error != QNetworkReply::NetworkError::NoError){ + if (error != QNetworkReply::NetworkError::NoError){ qCWarning(dcTado()) << "Confirm pairing failed" << error; - info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Connection error")); - } - // info->finish(success) will be called after the token has been received - }); - - connect(tado, &Tado::apiCredentialsReceived, info, [info, password, tado] (bool success) { - if (success) { - tado->getToken(password); - } else { - info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Client credentials not found, the plug-in version might be outdated.")); + info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("A connection error occurred.")); } }); - connect(tado, &Tado::tokenReceived, info, [this, info, username, password](Tado::Token token) { - Q_UNUSED(token) + connect(tado, &Tado::startAuthenticationFinished, info, [info, tado, this](bool success) { + if (!success) { + info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Failed to authenticate with tado server.")); + return; + } + qCDebug(dcTado()) << "Authentication finished successfully."; pluginStorage()->beginGroup(info->thingId().toString()); - pluginStorage()->setValue("username", username); - pluginStorage()->setValue("password", password); + pluginStorage()->setValue("refreshToken", tado->refreshToken()); pluginStorage()->endGroup(); info->finish(Thing::ThingErrorNoError); }); - tado->getApiCredentials(); + + tado->startAuthentication(); } void IntegrationPluginTado::setupThing(ThingSetupInfo *info) @@ -112,8 +119,9 @@ void IntegrationPluginTado::setupThing(ThingSetupInfo *info) if (thing->thingClassId() == tadoAccountThingClassId) { - qCDebug(dcTado) << "Setup Tado account" << thing->name() << thing->params(); - Tado *tado; + qCDebug(dcTado) << "Setting up Tado account" << thing->name() << thing->params(); + + Tado *tado = nullptr; if (m_tadoAccounts.contains(thing->id())) { qCDebug(dcTado()) << "Setup after reconfigure, cleaning up"; @@ -125,51 +133,65 @@ void IntegrationPluginTado::setupThing(ThingSetupInfo *info) tado = m_unfinishedTadoAccounts.take(thing->id()); m_tadoAccounts.insert(thing->id(), tado); info->finish(Thing::ThingErrorNoError); - } else { + pluginStorage()->beginGroup(thing->id().toString()); - QString username = pluginStorage()->value("username").toString(); - QString password = pluginStorage()->value("password").toString(); + pluginStorage()->setValue("refreshToken", tado->refreshToken()); + pluginStorage()->endGroup(); + } else { + + // Load refresh token + pluginStorage()->beginGroup(thing->id().toString()); + QString refreshToken = pluginStorage()->value("refreshToken").toString(); + qCDebug(dcTado()) << "Loaded refresh token" << refreshToken; pluginStorage()->endGroup(); - tado = new Tado(hardwareManager()->networkManager(), username, this); + if (refreshToken.isEmpty()) { + info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Could not authenticate on the server. Please reconfigure the connection.")); + return; + } + + tado = new Tado(hardwareManager()->networkManager(), this); m_tadoAccounts.insert(thing->id(), tado); - connect(info, &ThingSetupInfo::aborted, [info, this] { + tado->setRefreshToken(refreshToken); + } + + // Delete any leftover username password from deprecated password grant flow, + // storing passwords in plaintext does not correspond to good manners + pluginStorage()->beginGroup(thing->id().toString()); + pluginStorage()->remove("username"); + pluginStorage()->remove("password"); + pluginStorage()->endGroup(); + + connect(info, &ThingSetupInfo::aborted, this, [info, this] { + if (m_tadoAccounts.contains(info->thing()->id())) { + m_tadoAccounts.take(info->thing()->id())->deleteLater(); + } + }); + + connect(tado, &Tado::refreshTokenReceived, this, [thing, this](const QString &refreshToken){ + pluginStorage()->beginGroup(thing->id().toString()); + pluginStorage()->setValue("refreshToken", refreshToken); + pluginStorage()->endGroup(); + }); + + connect(tado, &Tado::accessTokenReceived, info, [info]() { + qCDebug(dcTado()) << "Token received, account setup successfull"; + info->finish(Thing::ThingErrorNoError); + }); + + connect(tado, &Tado::connectionError, info, [this, info] (QNetworkReply::NetworkError error) { + if (error != QNetworkReply::NetworkError::NoError){ if (m_tadoAccounts.contains(info->thing()->id())) { Tado *tado = m_tadoAccounts.take(info->thing()->id()); tado->deleteLater(); } - }); - connect(tado, &Tado::apiCredentialsReceived, info, [password, tado] { - tado->getToken(password); - }); + qCWarning(dcTado()) << "Connection error during setup:" << error; + info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Connection error")); + } + }); - connect(tado, &Tado::tokenReceived, info, [ info](Tado::Token token) { - Q_UNUSED(token) - - qCDebug(dcTado()) << "Token received, account setup successfull"; - info->finish(Thing::ThingErrorNoError); - }); - - connect(tado, &Tado::connectionError, info, [this, info] (QNetworkReply::NetworkError error) { - - if (error != QNetworkReply::NetworkError::NoError){ - - if (m_tadoAccounts.contains(info->thing()->id())) { - Tado *tado = m_tadoAccounts.take(info->thing()->id()); - tado->deleteLater(); - } - if (error == QNetworkReply::NetworkError::ProtocolInvalidOperationError) { - qCWarning(dcTado()) << "Confirm pairing failed, wrong username or password"; - info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Wrong username or password.")); - } else { - qCWarning(dcTado()) << "Confirm pairing failed" << error; - info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Connection error")); - } - } - }); - tado->getApiCredentials(); - } + connect(tado, &Tado::usernameChanged, this, &IntegrationPluginTado::onUsernameChanged); connect(tado, &Tado::authenticationStatusChanged, this, &IntegrationPluginTado::onAuthenticationStatusChanged); connect(tado, &Tado::requestExecuted, this, &IntegrationPluginTado::onRequestExecuted); connect(tado, &Tado::connectionChanged, this, &IntegrationPluginTado::onConnectionChanged); @@ -177,6 +199,9 @@ void IntegrationPluginTado::setupThing(ThingSetupInfo *info) connect(tado, &Tado::zonesReceived, this, &IntegrationPluginTado::onZonesReceived); connect(tado, &Tado::zoneStateReceived, this, &IntegrationPluginTado::onZoneStateReceived); connect(tado, &Tado::overlayReceived, this, &IntegrationPluginTado::onOverlayReceived); + + tado->getAccessToken(); + return; } else if (thing->thingClassId() == zoneThingClassId) { @@ -205,6 +230,9 @@ void IntegrationPluginTado::thingRemoved(Thing *thing) tado->deleteLater(); } + // Clean up storage + pluginStorage()->remove(thing->id().toString()); + if (myThings().isEmpty() && m_pluginTimer) { m_pluginTimer->deleteLater(); m_pluginTimer = nullptr; @@ -220,7 +248,7 @@ void IntegrationPluginTado::postSetupThing(Thing *thing) if (thing->thingClassId() == tadoAccountThingClassId) { Tado *tado = m_tadoAccounts.value(thing->id()); - thing->setStateValue(tadoAccountUserDisplayNameStateTypeId, tado->username()); + //thing->setStateValue(tadoAccountUserDisplayNameStateTypeId, tado->username()); thing->setStateValue(tadoAccountLoggedInStateTypeId, true); thing->setStateValue(tadoAccountConnectedStateTypeId, true); tado->getHomes(); @@ -260,7 +288,7 @@ void IntegrationPluginTado::executeAction(ThingActionInfo *info) } } m_asyncActions.insert(requestId, info); - connect(info, &ThingActionInfo::aborted, [requestId, this] {m_asyncActions.remove(requestId);}); + connect(info, &ThingActionInfo::aborted, thing, [requestId, this] {m_asyncActions.remove(requestId);}); } else if (action.actionTypeId() == zoneTargetTemperatureActionTypeId) { double temperature = action.param(zoneTargetTemperatureActionTargetTemperatureParamTypeId).value().toDouble(); @@ -271,7 +299,7 @@ void IntegrationPluginTado::executeAction(ThingActionInfo *info) requestId = tado->setOverlay(homeId, zoneId, true, temperature); } m_asyncActions.insert(requestId, info); - connect(info, &ThingActionInfo::aborted, [requestId, this] {m_asyncActions.remove(requestId);}); + connect(info, &ThingActionInfo::aborted, thing, [requestId, this] {m_asyncActions.remove(requestId);}); } else if (action.actionTypeId() == zonePowerActionTypeId) { bool power = action.param(zonePowerActionPowerParamTypeId).value().toBool(); thing->setStateValue(zonePowerStateTypeId, power); // the actual power set response might be slow @@ -283,7 +311,7 @@ void IntegrationPluginTado::executeAction(ThingActionInfo *info) requestId = tado->setOverlay(homeId, zoneId, true, temperature); } m_asyncActions.insert(requestId, info); - connect(info, &ThingActionInfo::aborted, [requestId, this] {m_asyncActions.remove(requestId);}); + connect(info, &ThingActionInfo::aborted, thing, [requestId, this] {m_asyncActions.remove(requestId);}); } else { qCWarning(dcTado()) << "Execute action, unhandled actionTypeId" << action.actionTypeId(); info->finish(Thing::ThingErrorActionTypeNotFound); @@ -299,12 +327,9 @@ void IntegrationPluginTado::onPluginTimer() Q_FOREACH(Tado *tado, m_tadoAccounts){ ThingId accountThingId = m_tadoAccounts.key(tado); if (!tado->authenticated()) { - pluginStorage()->beginGroup(accountThingId.toString()); - QString password = pluginStorage()->value("password").toString(); - pluginStorage()->endGroup(); - tado->getToken(password); + tado->getAccessToken(); } else { - Q_FOREACH(Thing *thing, myThings().filterByParentId(accountThingId)) { + foreach (Thing *thing, myThings().filterByParentId(accountThingId)) { if (thing->thingClassId() == zoneThingClassId) { QString homeId = thing->paramValue(zoneThingHomeIdParamTypeId).toString(); QString zoneId = thing->paramValue(zoneThingZoneIdParamTypeId).toString(); @@ -326,7 +351,7 @@ void IntegrationPluginTado::onConnectionChanged(bool connected) thing->setStateValue(tadoAccountConnectedStateTypeId, connected); if (!connected) { - Q_FOREACH(Thing *child, myThings().filterByParentId(thing->id())) { + foreach (Thing *child, myThings().filterByParentId(thing->id())) { if (child->thingClassId() == zoneThingClassId) { child->setStateValue(zoneConnectedStateTypeId, connected); } @@ -347,7 +372,7 @@ void IntegrationPluginTado::onAuthenticationStatusChanged(bool authenticated) } thing->setStateValue(tadoAccountLoggedInStateTypeId, authenticated); if (!authenticated) { - Q_FOREACH(Thing *child, myThings().filterByParentId(thing->id())) { + foreach (Thing *child, myThings().filterByParentId(thing->id())) { if (child->thingClassId() == zoneThingClassId) { child->setStateValue(zoneConnectedStateTypeId, authenticated); } @@ -356,6 +381,16 @@ void IntegrationPluginTado::onAuthenticationStatusChanged(bool authenticated) } } +void IntegrationPluginTado::onUsernameChanged(const QString &username) +{ + Tado *tado = static_cast(sender()); + + if (m_tadoAccounts.values().contains(tado)){ + Thing *thing = myThings().findById(m_tadoAccounts.key(tado)); + thing->setStateValue(tadoAccountUserDisplayNameStateTypeId, username); + } +} + void IntegrationPluginTado::onRequestExecuted(QUuid requestId, bool success) { if (m_asyncActions.contains(requestId)) { diff --git a/tado/integrationplugintado.h b/tado/integrationplugintado.h index 692dfeb2..20b13977 100644 --- a/tado/integrationplugintado.h +++ b/tado/integrationplugintado.h @@ -1,6 +1,6 @@ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -* Copyright 2013 - 2020, nymea GmbH +* Copyright 2013 - 2025, nymea GmbH * Contact: contact@nymea.io * * This file is part of nymea. @@ -39,7 +39,6 @@ #include #include - #include class IntegrationPluginTado : public IntegrationPlugin @@ -50,8 +49,12 @@ class IntegrationPluginTado : public IntegrationPlugin public: explicit IntegrationPluginTado(); + + void init() override; + void startPairing(ThingPairingInfo *info) override; void confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret) override; + void setupThing(ThingSetupInfo *info) override; void thingRemoved(Thing *thing) override; void postSetupThing(Thing *thing) override; @@ -59,9 +62,9 @@ public: private: PluginTimer *m_pluginTimer = nullptr; - QHash m_unfinishedTadoAccounts; + QHash m_unfinishedTadoAccounts; - QHash m_tadoAccounts; + QHash m_tadoAccounts; QHash m_asyncActions; private slots: @@ -69,6 +72,7 @@ private slots: void onConnectionChanged(bool connected); void onAuthenticationStatusChanged(bool authenticated); + void onUsernameChanged(const QString &username); void onRequestExecuted(QUuid requestId, bool success); void onHomesReceived(QList homes); void onZonesReceived(const QString &homeId, QList zones); diff --git a/tado/integrationplugintado.json b/tado/integrationplugintado.json index 25272c05..b0d5bb71 100644 --- a/tado/integrationplugintado.json +++ b/tado/integrationplugintado.json @@ -14,13 +14,12 @@ "displayName": "Tado account", "interfaces": ["account"], "createMethods": ["user"], - "setupMethod": "userandpassword", + "setupMethod": "oauth", "stateTypes": [ { "id": "2f79bc1d-27ed-480a-b583-728363c83ea6", "name": "connected", "displayName": "Connected", - "displayNameEvent": "Connected changed", "type": "bool", "defaultValue": false }, @@ -28,7 +27,6 @@ "id": "2aed240b-8c5c-418b-a9d1-0d75412c1c27", "name": "loggedIn", "displayName": "Logged in", - "displayNameEvent": "Logged in changed", "type": "bool", "defaultValue": false }, @@ -36,7 +34,6 @@ "id": "33f55afc-a673-47a4-9fb0-75fdac6a66f4", "name": "userDisplayName", "displayName": "Username", - "displayNameEvent": "Username changed", "type": "QString", "defaultValue": "-" } @@ -78,7 +75,6 @@ "id": "9f45a703-6a15-447c-a77a-0df731cda48e", "name": "connected", "displayName": "Connected", - "displayNameEvent": "Connected changed", "type": "bool", "defaultValue": false }, @@ -86,7 +82,6 @@ "id": "4cecf87c-8a5d-4bc4-a4ba-d2ee6103714b", "name": "mode", "displayName": "Mode", - "displayNameEvent": "Mode changed", "displayNameAction": "Set mode", "type": "QString", "defaultValue": "Tado", @@ -101,7 +96,6 @@ "id": "8b800998-5c2d-4940-9d0e-036979cf49ca", "name": "tadoMode", "displayName": "Tado mode", - "displayNameEvent": "Tado mode changed", "type": "QString", "defaultValue": "Tado" }, @@ -109,7 +103,6 @@ "id": "e886377d-34b7-4908-ad0d-ed463fc6181d", "name": "power", "displayName": "Power", - "displayNameEvent": "Power changed", "displayNameAction": "Set power", "type": "bool", "writable": true, @@ -126,7 +119,6 @@ "id": "80098178-7d92-43dd-a216-23704cc0eaa2", "name": "temperature", "displayName": "Temperature", - "displayNameEvent": "Temperature changed", "unit": "DegreeCelsius", "type": "double", "defaultValue": 0 @@ -135,7 +127,6 @@ "id": "684fcc62-f12b-4669-988e-4b79f153b0f2", "name": "targetTemperature", "displayName": "Target temperature", - "displayNameEvent": "Target temperature changed", "displayNameAction": "Set target temperature", "unit": "DegreeCelsius", "type": "double", @@ -148,7 +139,6 @@ "id": "0faaaff1-2a33-44ec-b68d-d8855f584b02", "name": "humidity", "displayName": "Humidity", - "displayNameEvent": "Humidity changed", "unit": "Percentage", "type": "double", "defaultValue": 0, diff --git a/tado/tado.cpp b/tado/tado.cpp index 7a96f81e..eb3e1511 100644 --- a/tado/tado.cpp +++ b/tado/tado.cpp @@ -1,6 +1,6 @@ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -* Copyright 2013 - 2020, nymea GmbH +* Copyright 2013 - 2025, nymea GmbH * Contact: contact@nymea.io * * This file is part of nymea. @@ -36,24 +36,27 @@ #include #include -Tado::Tado(NetworkAccessManager *networkManager, const QString &username, QObject *parent) : +Tado::Tado(NetworkAccessManager *networkManager, QObject *parent) : QObject(parent), - m_networkManager(networkManager), - m_username(username) + m_networkManager(networkManager) { - m_refreshTimer = new QTimer(this); - m_refreshTimer->setSingleShot(true); - connect(m_refreshTimer, &QTimer::timeout, this, &Tado::onRefreshTimer); -} + m_baseControlUrl = "https://my.tado.com/api/v2"; + m_baseAuthorizationUrl = "https://login.tado.com/oauth2"; -void Tado::setUsername(const QString &username) -{ - m_username = username; -} + m_clientId = "1bb50063-6b0c-4d11-bd99-387f4a91cc46"; -QString Tado::username() -{ - return m_username; + m_refreshTimer.setSingleShot(true); + connect(&m_refreshTimer, &QTimer::timeout, this, [this](){ + qCDebug(dcTado()) << "Refresh token..."; + getAccessToken(); + }); + + m_pollAuthenticationTimer.setSingleShot(true); + m_pollAuthenticationTimer.setInterval(2000); + connect(&m_pollAuthenticationTimer, &QTimer::timeout, this, [this](){ + qCDebug(dcTado()) << "Checking authentication status..."; + requestAuthenticationToken(); + }); } bool Tado::apiAvailable() @@ -71,138 +74,160 @@ bool Tado::connected() return m_connectionStatus; } -void Tado::getApiCredentials(const QString &url) + +QString Tado::loginUrl() const { - QNetworkRequest request; - request.setUrl(url); - QNetworkReply *reply = m_networkManager->get(request); - qCDebug(dcTado()) << "Sending request" << request.url(); + return m_loginUrl; +} + +QString Tado::username() const +{ + return m_username; +} + +QString Tado::refreshToken() const +{ + return m_refreshToken; +} + +void Tado::setRefreshToken(const QString &refreshToken) +{ + m_refreshToken = refreshToken; +} + +void Tado::startAuthentication() +{ + qCDebug(dcTado()) << "Start authentication process..."; + m_pollAuthenticationCount = 0; + requestAuthenticationToken(); +} + +void Tado::getLoginUrl() +{ + QNetworkRequest request = QNetworkRequest(QUrl(m_baseAuthorizationUrl + "/device_authorize")); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QUrlQuery query; + query.addQueryItem("client_id", m_clientId); + query.addQueryItem("scope", "offline_access"); + + QByteArray payload = query.toString(QUrl::FullyEncoded).toUtf8(); + + qCDebug(dcTado()) << "Get login url request" << request.url() << payload; + + QNetworkReply *reply = m_networkManager->post(request, payload); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); connect(reply, &QNetworkReply::finished, this, [reply, this] { int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); // Check HTTP status code if (status != 200 || reply->error() != QNetworkReply::NoError) { - qCWarning(dcTado()) << "Request error:" << status << reply->errorString(); - emit apiCredentialsReceived(false); - return; - } - QRegExp filter; - filter.setPatternSyntax(QRegExp::Wildcard); - filter.setPattern("*tgaRestApiV2Endpoint:*"); + emit getLoginUrlFinished(false); - QStringList list = QString(reply->readAll()).split('\n'); - int index = list.indexOf(filter); - if (index == -1) { - qCWarning(dcTado()) << "GetApiCredenitals: Could not find the API url"; - emit apiCredentialsReceived(false); + emit connectionError(reply->error()); + + if (reply->error() == QNetworkReply::HostNotFoundError) + setConnectionStatus(false); + + if (status == 401 || status == 400) + setAuthenticationStatus(false); + + qCWarning(dcTado()) << "Request error:" << status << reply->errorString(); return; } - m_baseControlUrl = list.value(index).split(": ").last().remove(QRegExp("[,']"));; - qCDebug(dcTado()) << "Received control url" << m_baseControlUrl; - filter.setPattern("*apiEndpoint*"); - index = list.indexOf(filter); - if (index == -1) { - qCWarning(dcTado()) << "GetApiCredenitals: Could not find the authorization url"; - emit apiCredentialsReceived(false); - return; - } - m_baseAuthorizationUrl = list.value(index).split(": ").last().remove(QRegExp("[,']"))+"/token"; - qCDebug(dcTado()) << "Received auth url" << m_baseAuthorizationUrl; - filter.setPattern("*clientId*"); - index = list.indexOf(filter); - if (index == -1) { - emit apiCredentialsReceived(false); - qCWarning(dcTado()) << "GetApiCredenitals: Could not find the client Id"; - return; - } - m_clientId = list.value(index).split(": ").last().remove(QRegExp("[,']")); - qCDebug(dcTado()) << "Received client id" << m_clientId.mid(0, 4)+"*****"; - filter.setPattern("*clientSecret*"); - index = list.indexOf(filter); - if (index == -1) { - qCWarning(dcTado()) << "GetApiCredenitals: Could not find the client secret"; - emit apiCredentialsReceived(false); - return; - } - m_clientSecret = list.value(index).split(": ").last().remove(QRegExp("[,']")); - qCDebug(dcTado()) << "Received client secret" << m_clientSecret.mid(0, 4)+"*****"; + m_apiAvailable = true; - emit apiCredentialsReceived(true); + setConnectionStatus(true); + + QByteArray data = reply->readAll(); + QJsonParseError error; + QJsonDocument responseJsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qDebug(dcTado()) << "Get Token: Received invalid JSON object:" << data; + emit getLoginUrlFinished(false); + return; + } + + qCDebug(dcTado()) << "Get login url response" << qUtf8Printable(responseJsonDoc.toJson()); + QVariantMap responseMap = responseJsonDoc.toVariant().toMap(); + m_deviceCode = responseMap.value("device_code").toString(); + m_loginUrl = responseMap.value("verification_uri_complete").toString(); + uint pollInterval = responseMap.value("interval").toUInt(); + + qCDebug(dcTado()) << "Login url:" << m_loginUrl; + qCDebug(dcTado()) << "Device code:" << m_deviceCode; + qCDebug(dcTado()) << "Poll interval:" << pollInterval; + m_pollAuthenticationTimer.setInterval(pollInterval * 1000); + + emit getLoginUrlFinished(true); }); } -void Tado::getToken(const QString &password) + +void Tado::getAccessToken() { - if (!m_apiAvailable) { - qCWarning(dcTado()) << "Not sending request, get API credentials first"; - return; - } - - QNetworkRequest request; - request.setUrl(QUrl(m_baseAuthorizationUrl)); + QNetworkRequest request = QNetworkRequest(QUrl(m_baseAuthorizationUrl + "/token")); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded"); - QUrlQuery query; - query.setQueryItems({{"client_id", m_clientId}, - {"client_secret", m_clientSecret}, - {"grant_type", "password"}, - {"scope", "home.user"}, - {"username", m_username}, - {"password", password}}); - QNetworkReply *reply = m_networkManager->post(request, query.toString(QUrl::FullyEncoded).toUtf8()); -// qCDebug(dcTado()) << "Sending request" << request.url() << query.toString(QUrl::FullyEncoded).toUtf8(); + QUrlQuery query; + query.addQueryItem("grant_type", "refresh_token"); + query.addQueryItem("refresh_token", m_refreshToken); + query.addQueryItem("client_id", m_clientId); + + QByteArray payload = query.toString(QUrl::FullyEncoded).toUtf8(); + + qCDebug(dcTado()) << "Get access token request" << request.url() << payload; + + QNetworkReply *reply = m_networkManager->post(request, payload); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); connect(reply, &QNetworkReply::finished, this, [reply, this] { + QByteArray data = reply->readAll(); int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); // Check HTTP status code if (status != 200 || reply->error() != QNetworkReply::NoError) { + emit connectionError(reply->error()); - if (reply->error() == QNetworkReply::HostNotFoundError) { + + if (reply->error() == QNetworkReply::HostNotFoundError) setConnectionStatus(false); - } - if (status == 401 || status == 400) { + + if (status == 401 || status == 400) setAuthenticationStatus(false); - } - qCWarning(dcTado()) << "Request error:" << status << reply->errorString(); + + qCWarning(dcTado()) << "Request error:" << status << reply->errorString() << qUtf8Printable(data); return; } + + m_apiAvailable = true; setConnectionStatus(true); QJsonParseError error; - QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); + QJsonDocument responseJsonDoc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { - qDebug(dcTado()) << "Get Token: Received invalid JSON object:" << data; + qDebug(dcTado()) << "Get access token received invalid JSON object:" << data; + emit getLoginUrlFinished(false); return; } - if (data.isObject()) { - Token token; - QVariantMap obj = data.toVariant().toMap(); - if (obj.contains("access_token")) { - token.accesToken = obj["access_token"].toString(); - m_accessToken = token.accesToken; - } else { - qCWarning(dcTado()) << "Received response doesnt contain an access token"; - } - token.tokenType = obj["token_type"].toString(); - token.refreshToken = obj["refresh_token"].toString(); - m_refreshToken = token.refreshToken; - if (obj.contains("expires_in")) { - token.expires = obj["expires_in"].toInt(); - m_refreshTimer->start((token.expires - 10)*1000); - } else { - qCWarning(dcTado()) << "Received response doesn't contain an expire time"; - } - token.scope = obj["scope"].toString(); - token.jti = obj["jti"].toString(); - setAuthenticationStatus(true); - emit tokenReceived(token); - } else { - qCWarning(dcTado()) << "Received response isn't an object" << data.toJson(); - setAuthenticationStatus(false); + qCDebug(dcTado()) << "Get access token response" << qUtf8Printable(responseJsonDoc.toJson()); + QVariantMap responseMap = responseJsonDoc.toVariant().toMap(); + + m_accessToken = responseMap.value("access_token").toString(); + emit accessTokenReceived(); + + QString refreshToken = responseMap.value("refresh_token").toString(); + if (m_refreshToken != refreshToken) { + m_refreshToken = refreshToken; + emit refreshTokenReceived(m_refreshToken); } + + + setAuthenticationStatus(true); + + // Refresh 10 sekonds before expiration + m_refreshTimer.setInterval((responseMap.value("expires_in").toUInt() - 10) * 1000); + m_refreshTimer.start(); }); } @@ -246,11 +271,21 @@ void Tado::getHomes() QJsonParseError error; QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); if (error.error != QJsonParseError::NoError) { - qDebug(dcTado()) << "Get Token: Recieved invalid JSON object"; + qDebug(dcTado()) << "Get Homes: Recieved invalid JSON object"; return; } + + qCDebug(dcTado()) << "Get homes response" << qUtf8Printable(data.toJson()); + + QVariantMap responseMap = data.toVariant().toMap(); + QString username = responseMap.value("username").toString(); + if (m_username != username) { + m_username = username; + emit usernameChanged(m_username); + } + QList homes; - QVariantList homeList = data.toVariant().toMap().value("homes").toList(); + QVariantList homeList = responseMap.value("homes").toList(); foreach (QVariant variant, homeList) { QVariantMap obj = variant.toMap(); Home home; @@ -258,6 +293,7 @@ void Tado::getHomes() home.name = obj["name"].toString(); homes.append(home); } + emit homesReceived(homes); }); } @@ -275,7 +311,7 @@ void Tado::getZones(const QString &homeId) } QNetworkRequest request; - request.setUrl(QUrl(m_baseControlUrl+"/homes/"+homeId+"/zones")); + request.setUrl(QUrl(m_baseControlUrl + "/homes/" + homeId + "/zones")); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded"); request.setRawHeader("Authorization", "Bearer " + m_accessToken.toLocal8Bit()); QNetworkReply *reply = m_networkManager->get(request); @@ -332,7 +368,7 @@ void Tado::getZoneState(const QString &homeId, const QString &zoneId) } QNetworkRequest request; - request.setUrl(QUrl(m_baseControlUrl+"/homes/"+homeId+"/zones/"+zoneId+"/state")); + request.setUrl(QUrl(m_baseControlUrl + "/homes/" + homeId + "/zones/" + zoneId + "/state")); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded"); request.setRawHeader("Authorization", "Bearer " + m_accessToken.toLocal8Bit()); QNetworkReply *reply = m_networkManager->get(request); @@ -413,7 +449,7 @@ QUuid Tado::setOverlay(const QString &homeId, const QString &zoneId, bool power, QUuid requestId = QUuid::createUuid(); QNetworkRequest request; - request.setUrl(QUrl(m_baseControlUrl+"/homes/"+homeId+"/zones/"+zoneId+"/overlay")); + request.setUrl(QUrl(m_baseControlUrl + "/homes/" + homeId + "/zones/" + zoneId + "/overlay")); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json;charset=utf-8"); request.setRawHeader("Authorization", "Bearer " + m_accessToken.toLocal8Bit()); @@ -424,14 +460,14 @@ QUuid Tado::setOverlay(const QString &homeId, const QString &zoneId, bool power, else powerString = "OFF"; - body.append("{\"setting\":{\"type\":\"HEATING\",\"power\":\""+ powerString + "\",\"temperature\":{\"celsius\":" + QVariant(targetTemperature).toByteArray() + "}},\"termination\":{\"type\":\"MANUAL\"}}"); + body.append("{\"setting\":{\"type\":\"HEATING\",\"power\":\"" + powerString + "\",\"temperature\":{\"celsius\":" + QVariant(targetTemperature).toByteArray() + "}},\"termination\":{\"type\":\"MANUAL\"}}"); + //qCDebug(dcTado()) << "Sending request" << body; QNetworkReply *reply = m_networkManager->put(request, body); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); connect(reply, &QNetworkReply::finished, this, [homeId, zoneId, requestId, reply, this] { int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - // Check HTTP status code if (status != 200 || reply->error() != QNetworkReply::NoError) { emit requestExecuted(requestId, false); emit connectionError(reply->error()); @@ -439,6 +475,7 @@ QUuid Tado::setOverlay(const QString &homeId, const QString &zoneId, bool power, if (reply->error() == QNetworkReply::HostNotFoundError) { setConnectionStatus(false); } + if (status == 401 || status == 400) { //Unauthorized setAuthenticationStatus(false); } else if (status == 422) { //Unprocessable Entity @@ -448,6 +485,7 @@ QUuid Tado::setOverlay(const QString &homeId, const QString &zoneId, bool power, } return; } + setAuthenticationStatus(true); setConnectionStatus(true); emit requestExecuted(requestId, true); @@ -488,21 +526,22 @@ QUuid Tado::deleteOverlay(const QString &homeId, const QString &zoneId) QUuid requestId = QUuid::createUuid(); QNetworkRequest request; - request.setUrl(QUrl(m_baseControlUrl+"/homes/"+homeId+"/zones/"+zoneId+"/overlay")); + request.setUrl(QUrl(m_baseControlUrl + "/homes/" + homeId + "/zones/" + zoneId + "/overlay")); request.setRawHeader("Authorization", "Bearer " + m_accessToken.toLocal8Bit()); QNetworkReply *reply = m_networkManager->deleteResource(request); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); connect(reply, &QNetworkReply::finished, this, [homeId, zoneId, requestId, reply, this] { int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - // Check HTTP status code if (status < 200 || status > 210 || reply->error() != QNetworkReply::NoError) { + emit requestExecuted(requestId ,false); emit connectionError(reply->error()); + if (reply->error() == QNetworkReply::HostNotFoundError) { setConnectionStatus(false); } + if (status == 401 || status == 400) { //Unauthorized setAuthenticationStatus(false); } else if (status == 422) { //Unprocessable Entity @@ -510,9 +549,10 @@ QUuid Tado::deleteOverlay(const QString &homeId, const QString &zoneId) } else { qCWarning(dcTado()) << "Request error:" << status << reply->errorString(); } - qCWarning(dcTado()) << "Request error:" << status << reply->errorString(); + return; } + setAuthenticationStatus(true); setConnectionStatus(true); emit requestExecuted(requestId, true); @@ -523,6 +563,7 @@ QUuid Tado::deleteOverlay(const QString &homeId, const QString &zoneId) qDebug(dcTado()) << "Get Token: Recieved invalid JSON object"; return; } + QVariantMap map = data.toVariant().toMap(); Overlay overlay; @@ -539,6 +580,66 @@ QUuid Tado::deleteOverlay(const QString &homeId, const QString &zoneId) return requestId; } +void Tado::requestAuthenticationToken() +{ + QNetworkRequest request = QNetworkRequest(QUrl(m_baseAuthorizationUrl + "/token")); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QUrlQuery query; + query.addQueryItem("client_id", m_clientId); + query.addQueryItem("device_code", m_deviceCode); + query.addQueryItem("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + + QByteArray payload = query.toString(QUrl::FullyEncoded).toUtf8(); + qCDebug(dcTado()) << "Request authentication token" << request.url() << payload; + + QNetworkReply *reply = m_networkManager->post(request, payload); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, this, [reply, this] { + + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status != 200 || reply->error() != QNetworkReply::NoError) { + qCDebug(dcTado()) << "Request error:" << status << "Retrying:" << m_pollAuthenticationCount << "/" << m_pollAuthenticationLimit; + + if (m_pollAuthenticationCount >= m_pollAuthenticationLimit) { + qCWarning(dcTado()) << "Authentication request failed" << m_pollAuthenticationCount << "times. Giving up."; + emit startAuthenticationFinished(false); + setAuthenticationStatus(false); + return; + } + + // We poll until the user finished the login or until we reached the limit + m_pollAuthenticationTimer.start(); + m_pollAuthenticationCount++; + return; + } + + QByteArray data = reply->readAll(); + QJsonParseError error; + QJsonDocument responseJsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qDebug(dcTado()) << "Authentication received invalid JSON object:" << data; + emit startAuthenticationFinished(false); + setAuthenticationStatus(false); + return; + } + + qCDebug(dcTado()) << "Authentication finished successfully:" << qUtf8Printable(responseJsonDoc.toJson()); + QVariantMap responseMap = responseJsonDoc.toVariant().toMap(); + + m_accessToken = responseMap.value("access_token").toString(); + QString refreshToken = responseMap.value("refresh_token").toString(); + + if (m_refreshToken != refreshToken) { + m_refreshToken = refreshToken; + emit refreshTokenReceived(m_refreshToken); + } + + emit startAuthenticationFinished(true); + setAuthenticationStatus(true); + }); +} + void Tado::setAuthenticationStatus(bool status) { if (m_authenticationStatus != status) { @@ -546,9 +647,9 @@ void Tado::setAuthenticationStatus(bool status) emit authenticationStatusChanged(status); } - if (!status) { - m_refreshTimer->stop(); - } + if (!status) + m_refreshTimer.stop(); + } void Tado::setConnectionStatus(bool status) @@ -558,62 +659,3 @@ void Tado::setConnectionStatus(bool status) emit connectionChanged(status); } } - -void Tado::onRefreshTimer() -{ - if(m_refreshToken.isEmpty()) { - qCWarning(dcTado()) << "Not sending request, get the access token first"; - return; - } - - QNetworkRequest request; - request.setUrl(QUrl(m_baseAuthorizationUrl)); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded"); - QUrlQuery query; - query.setQueryItems({{"client_id", m_clientId}, - {"client_secret", m_clientSecret}, - {"grant_type", "refresh_token"}, - {"refresh_token", m_refreshToken}, - {"scope", "home.user"}}); - - QNetworkReply *reply = m_networkManager->post(request, query.toString(QUrl::FullyEncoded).toUtf8()); - connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, this, [reply, this] { - - int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - // Check HTTP status code - if (status != 200 || reply->error() != QNetworkReply::NoError) { - emit connectionError(reply->error()); - if (reply->error() == QNetworkReply::HostNotFoundError) { - setConnectionStatus(false); - } - if (status == 400 || status == 401) { - setAuthenticationStatus(false); - } - qCWarning(dcTado()) << "Request error:" << status << reply->errorString(); - return; - } - setConnectionStatus(true); - setAuthenticationStatus(true); - - QJsonParseError error; - QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); - if (error.error != QJsonParseError::NoError) { - qDebug(dcTado()) << "Get Token: Recieved invalid JSON object"; - return; - } - Token token; - QVariantMap obj = data.toVariant().toMap(); - token.accesToken = obj["access_token"].toString(); - m_accessToken = token.accesToken; - token.tokenType = obj["token_type"].toString(); - token.refreshToken = obj["refresh_token"].toString(); - m_refreshToken = token.refreshToken; - token.expires = obj["expires_in"].toInt(); - m_refreshTimer->start((token.expires - 10)*1000); - token.scope = obj["scope"].toString(); - token.jti = obj["jti"].toString(); - emit tokenReceived(token); - }); -} diff --git a/tado/tado.h b/tado/tado.h index e2d8303e..9b534cfb 100644 --- a/tado/tado.h +++ b/tado/tado.h @@ -1,6 +1,6 @@ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -* Copyright 2013 - 2020, nymea GmbH +* Copyright 2013 - 2025, nymea GmbH * Contact: contact@nymea.io * * This file is part of nymea. @@ -31,8 +31,8 @@ #ifndef TADO_H #define TADO_H -#include "network/networkaccessmanager.h" -#include "integrations/thing.h" +#include +#include #include #include @@ -42,16 +42,6 @@ class Tado : public QObject { Q_OBJECT public: - - struct Token { - QString accesToken; - QString tokenType; - QString refreshToken; - int expires; - QString scope; - QString jti; - }; - struct Zone { QString id; QString name; @@ -89,16 +79,24 @@ public: QString name; }; - explicit Tado(NetworkAccessManager *networkManager, const QString &username, QObject *parent = nullptr); + explicit Tado(NetworkAccessManager *networkManager, QObject *parent = nullptr); - void setUsername(const QString &username); - QString username(); bool apiAvailable(); bool authenticated(); bool connected(); - void getApiCredentials(const QString &url = "https://app.tado.com/env.js"); - void getToken(const QString &password); + QString loginUrl() const; + + QString username() const; + + QString refreshToken() const; + void setRefreshToken(const QString &refreshToken); + + // Login process + void getLoginUrl(); + void startAuthentication(); + void getAccessToken(); + void getHomes(); void getZones(const QString &homeId); void getZoneState(const QString &homeId, const QString &zoneId); @@ -110,36 +108,46 @@ private: bool m_apiAvailable = false; QString m_baseAuthorizationUrl; QString m_baseControlUrl; - QString m_clientSecret; QString m_clientId; + QString m_deviceCode; NetworkAccessManager *m_networkManager = nullptr; - QString m_username; + QString m_loginUrl; QString m_accessToken; QString m_refreshToken; - QTimer *m_refreshTimer = nullptr; + QString m_username; + + QTimer m_refreshTimer; + QTimer m_pollAuthenticationTimer; + uint m_pollAuthenticationCount = 0; + uint m_pollAuthenticationLimit = 5; bool m_authenticationStatus = false; bool m_connectionStatus = false; + + void requestAuthenticationToken(); + void setAuthenticationStatus(bool status); void setConnectionStatus(bool status); signals: void connectionChanged(bool connected); - void apiCredentialsReceived(bool success); + void getLoginUrlFinished(bool success); + void startAuthenticationFinished(bool success); + void accessTokenReceived(); + void usernameChanged(const QString &username); + void refreshTokenReceived(const QString &refreshToken); + void authenticationStatusChanged(bool authenticated); + void requestExecuted(QUuid requestId, bool success); - void tokenReceived(Token token); - void homesReceived(QList homes); - void zonesReceived(const QString &homeId, QList zones); - void zoneStateReceived(const QString &homeId,const QString &zoneId, ZoneState sate); - void overlayReceived(const QString &homeId, const QString &zoneId, const Overlay &overlay); + void homesReceived(QList homes); + void zonesReceived(const QString &homeId, QList zones); + void zoneStateReceived(const QString &homeId,const QString &zoneId, Tado::ZoneState sate); + void overlayReceived(const QString &homeId, const QString &zoneId, const Tado::Overlay &overlay); void connectionError(QNetworkReply::NetworkError error); -private slots: - void onRefreshTimer(); - }; #endif // TADO_H