// SPDX-License-Identifier: GPL-3.0-or-later /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright (C) 2013 - 2024, nymea GmbH * Copyright (C) 2024 - 2025, chargebyte austria GmbH * * This file is part of nymea-plugins. * * nymea-plugins 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, either version 3 of the License, or * (at your option) any later version. * * nymea-plugins 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-plugins. If not, see . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "tado.h" #include "extern-plugininfo.h" #include #include #include #include Tado::Tado(NetworkAccessManager *networkManager, QObject *parent) : QObject(parent) , m_networkManager(networkManager) { m_baseControlUrl = "https://my.tado.com/api/v2"; m_baseAuthorizationUrl = "https://login.tado.com/oauth2"; m_clientId = "1bb50063-6b0c-4d11-bd99-387f4a91cc46"; 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() { return m_apiAvailable; } bool Tado::authenticated() { return m_authenticationStatus; } bool Tado::connected() { return m_connectionStatus; } QString Tado::loginUrl() const { 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) { emit getLoginUrlFinished(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_apiAvailable = true; setConnectionStatus(true); QByteArray data = reply->readAll(); QJsonParseError error; QJsonDocument responseJsonDoc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qCDebug(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::getAccessToken() { QNetworkRequest request = QNetworkRequest(QUrl(m_baseAuthorizationUrl + "/token")); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded"); 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) setConnectionStatus(false); if (status == 401 || status == 400) setAuthenticationStatus(false); qCWarning(dcTado()) << "Request error:" << status << reply->errorString() << qUtf8Printable(data); return; } m_apiAvailable = true; setConnectionStatus(true); QJsonParseError error; QJsonDocument responseJsonDoc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qCDebug(dcTado()) << "Get access token received invalid JSON object:" << data; emit getLoginUrlFinished(false); return; } 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(); }); } void Tado::getHomes() { if (!m_apiAvailable) { qCWarning(dcTado()) << "Not sending request, get API credentials first"; return; } if (m_accessToken.isEmpty()) { qCWarning(dcTado()) << "Not sending request, get the access token first"; return; } QNetworkRequest request; request.setUrl(QUrl(m_baseControlUrl + "/me")); request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/x-www-form-urlencoded"); request.setRawHeader("Authorization", "Bearer " + m_accessToken.toLocal8Bit()); QNetworkReply *reply = m_networkManager->get(request); //qCDebug(dcTado()) << "Sending request" << request.url() << request.rawHeaderList(); 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 == 401 || status == 400) { 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) { qCDebug(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 = responseMap.value("homes").toList(); foreach (QVariant variant, homeList) { QVariantMap obj = variant.toMap(); Home home; home.id = obj["id"].toString(); home.name = obj["name"].toString(); homes.append(home); } emit homesReceived(homes); }); } void Tado::getZones(const QString &homeId) { if (!m_apiAvailable) { qCWarning(dcTado()) << "Not sending request, get API credentials first"; return; } if (m_accessToken.isEmpty()) { qCWarning(dcTado()) << "Not sending request, get the access token first"; return; } QNetworkRequest request; 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); //qCDebug(dcTado()) << "Sending request" << request.url() << request.rawHeaderList(); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); connect(reply, &QNetworkReply::finished, this, [reply, homeId, this] { int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); // Check HTTP status code if (status != 200 || reply->error() != QNetworkReply::NoError) { if (reply->error() == QNetworkReply::HostNotFoundError) { setConnectionStatus(false); } if (status == 401 || status == 400) { 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) { qCDebug(dcTado()) << "Get Token: Recieved invalid JSON object"; return; } QList zones; QVariantList list = data.toVariant().toList(); foreach (QVariant variant, list) { QVariantMap obj = variant.toMap(); Zone zone; zone.id = obj["id"].toString(); zone.name = obj["name"].toString(); zone.type = obj["type"].toString(); zones.append(zone); } emit zonesReceived(homeId, zones); }); } void Tado::getZoneState(const QString &homeId, const QString &zoneId) { if (!m_apiAvailable) { qCWarning(dcTado()) << "Not sending request, get API credentials first"; return; } if (m_accessToken.isEmpty()) { qCWarning(dcTado()) << "Not sending request, get the access token first"; return; } QNetworkRequest request; 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); //qCDebug(dcTado()) << "Sending request" << request.url() << request.rawHeaderList(); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); connect(reply, &QNetworkReply::finished, this, [reply, homeId, zoneId, 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 == 401 || status == 400) { 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) { qCDebug(dcTado()) << "Get Token: Recieved invalid JSON object"; return; } qCDebug(dcTado()) << "Zone status received:" << qUtf8Printable(data.toJson(QJsonDocument::Indented)); ZoneState state; QVariantMap map = data.toVariant().toMap(); state.tadoMode = map["tadoMode"].toString(); state.windowOpenDetected = map["openWindowDetected"].toBool(); QVariantMap settingsMap = map["setting"].toMap(); state.settingType = settingsMap["type"].toString(); state.settingPower = (settingsMap["power"].toString() == "ON"); state.settingTemperature = settingsMap["temperature"].toMap().value("celsius").toDouble(); state.connected = (map["link"].toMap().value("state").toString() == "ONLINE"); QVariantMap activityDataMap = map["activityDataPoints"].toMap(); state.heatingPowerPercentage = activityDataMap["heatingPower"].toMap().value("percentage").toDouble(); state.heatingPowerType = activityDataMap["heatingPower"].toMap().value("type").toString(); QVariantMap dataMap = map["sensorDataPoints"].toMap(); state.temperature = dataMap["insideTemperature"].toMap().value("celsius").toDouble(); state.humidity = dataMap["humidity"].toMap().value("percentage").toDouble(); if (!map["overlay"].toMap().isEmpty()) { state.overlayIsSet = true; QVariantMap overlayMap = map["overlay"].toMap(); state.overlayType = map["overlayType"].toString(); state.overlaySettingPower = (overlayMap["setting"].toMap().value("power").toString() == "ON"); state.overlaySettingTemperature = overlayMap["setting"].toMap().value("temperature").toDouble(); } else { state.overlayIsSet = false; } emit zoneStateReceived(homeId, zoneId, state); }); } QUuid Tado::setOverlay(const QString &homeId, const QString &zoneId, bool power, double targetTemperature) { if (!m_apiAvailable) { qCWarning(dcTado()) << "Not sending request, get API credentials first"; return QUuid(); } if (m_accessToken.isEmpty()) { qCWarning(dcTado()) << "Not sending request, get the access token first"; return QUuid(); } QUuid requestId = QUuid::createUuid(); QNetworkRequest request; 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()); QByteArray body; QByteArray powerString; if (power) powerString = "ON"; else powerString = "OFF"; 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(); if (status != 200 || 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 qCWarning(dcTado()) << "Unprocessable Entity, probably a value out of range"; } else { qCWarning(dcTado()) << "Request error:" << status << reply->errorString(); } return; } setAuthenticationStatus(true); setConnectionStatus(true); emit requestExecuted(requestId, true); QJsonParseError error; QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); if (error.error != QJsonParseError::NoError) { qCDebug(dcTado()) << "Get Token: Recieved invalid JSON object"; return; } QVariantMap map = data.toVariant().toMap(); Overlay overlay; QVariantMap settingsMap = map["setting"].toMap(); overlay.zoneType = settingsMap["type"].toString(); overlay.power = (settingsMap["power"].toString() == "ON"); overlay.temperature = settingsMap["temperature"].toMap().value("celsius").toDouble(); QVariantMap terminationMap = map["termination"].toMap(); overlay.terminationType = terminationMap["type"].toString(); overlay.tadoMode = map["type"].toString(); emit overlayReceived(homeId, zoneId, overlay); }); return requestId; } QUuid Tado::deleteOverlay(const QString &homeId, const QString &zoneId) { if (!m_apiAvailable) { qCWarning(dcTado()) << "Not sending request, get API credentials first"; return QUuid(); } if (m_accessToken.isEmpty()) { qCWarning(dcTado()) << "Not sending request, get the access token first"; return QUuid(); } QUuid requestId = QUuid::createUuid(); QNetworkRequest request; 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(); 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 qCWarning(dcTado()) << "Unprocessable Entity, probably a value out of range"; } else { qCWarning(dcTado()) << "Request error:" << status << reply->errorString(); } return; } setAuthenticationStatus(true); setConnectionStatus(true); emit requestExecuted(requestId, true); QJsonParseError error; QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); if (error.error != QJsonParseError::NoError) { qCDebug(dcTado()) << "Get Token: Recieved invalid JSON object"; return; } QVariantMap map = data.toVariant().toMap(); Overlay overlay; QVariantMap settingsMap = map["setting"].toMap(); overlay.zoneType = settingsMap["type"].toString(); overlay.power = (settingsMap["power"].toString() == "ON"); overlay.temperature = settingsMap["temperature"].toMap().value("celsius").toDouble(); QVariantMap terminationMap = map["termination"].toMap(); overlay.terminationType = terminationMap["type"].toString(); overlay.tadoMode = map["type"].toString(); emit overlayReceived(homeId, zoneId, overlay); }); 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) { qCDebug(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) { m_authenticationStatus = status; emit authenticationStatusChanged(status); } if (!status) m_refreshTimer.stop(); } void Tado::setConnectionStatus(bool status) { if (m_connectionStatus != status) { m_connectionStatus = status; emit connectionChanged(status); } }