diff --git a/debian/control b/debian/control
index 87f28ea3..3bcefef0 100644
--- a/debian/control
+++ b/debian/control
@@ -387,6 +387,21 @@ Description: nymea.io plugin for gpio
This package will install the nymea.io plugin for gpio
+Package: nymea-plugin-homeconnect
+Architecture: any
+Depends: ${shlibs:Depends},
+ ${misc:Depends},
+ nymea-plugins-translations,
+Description: nymea.io plugin for home connect
+ 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 home connect appliances
+
+
Package: nymea-plugin-i2cdevices
Architecture: any
Section: libs
@@ -1058,6 +1073,7 @@ Depends: nymea-plugin-anel,
nymea-plugin-flowercare,
nymea-plugin-fronius,
nymea-plugin-genericthings,
+ nymea-plugin-homeconnect,
nymea-plugin-kodi,
nymea-plugin-lgsmarttv,
nymea-plugin-lifx,
diff --git a/debian/nymea-plugin-homeconnect.install.in b/debian/nymea-plugin-homeconnect.install.in
new file mode 100644
index 00000000..44410fd7
--- /dev/null
+++ b/debian/nymea-plugin-homeconnect.install.in
@@ -0,0 +1 @@
+usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationpluginhomeconnect.so
diff --git a/homeconnect/README.md b/homeconnect/README.md
new file mode 100644
index 00000000..29f963ab
--- /dev/null
+++ b/homeconnect/README.md
@@ -0,0 +1,38 @@
+# Home Connect
+
+Connects your Home Connect home appliances to nymea.
+
+## Supported Things
+
+Home connected home appliances, like:
+* Oven
+* Dishwasher
+* Coffee maker
+* Dryer
+* Fridge
+* Washer
+* Cook Top
+* Hood
+
+## Requirements
+
+* The package “nymea-plugin-homeconnect” must be installed
+* Internet connection
+* Home connect account
+
+## Plug-In Settings
+
+**Simulation Mode**
+HomeConnect has a simulation server, where the home appliances are simulated. This feature is useful for demonstration and development.
+
+**Control**
+The control of the oven for example is only allowed with an authorized API key. The nymea community
+API credentials does not have this permission. Enabling this without the permission will result in failing login attempts.
+
+**Custom client key and secret**
+You can register as developer on https://developer.home-connect.com, where you can obtain you own client credentials. The default client credentials are made available through the nymea community API key provider.
+
+## More
+
+Home Connect developer documentation:
+https://developer.home-connect.com/
diff --git a/homeconnect/homeconnect.cpp b/homeconnect/homeconnect.cpp
new file mode 100644
index 00000000..23b5fdc2
--- /dev/null
+++ b/homeconnect/homeconnect.cpp
@@ -0,0 +1,903 @@
+/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+*
+* Copyright 2013 - 2020, nymea GmbH
+* Contact: contact@nymea.io
+*
+* This file is part of nymea.
+* This project including source code and documentation is protected by
+* copyright law, and remains the property of nymea GmbH. All rights, including
+* reproduction, publication, editing and translation, are reserved. The use of
+* this project is subject to the terms of a license agreement to be concluded
+* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
+* under https://nymea.io/license
+*
+* GNU Lesser General Public License Usage
+* Alternatively, this project may be redistributed and/or modified under the
+* terms of the GNU Lesser General Public License as published by the Free
+* Software Foundation; version 3. This project 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 project. If not, see .
+*
+* For any further details and any questions please contact us under
+* contact@nymea.io or see our FAQ/Licensing Information on
+* https://nymea.io/license/faq
+*
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+#include "homeconnect.h"
+#include "extern-plugininfo.h"
+
+#include
+#include
+#include
+#include
+
+HomeConnect::HomeConnect(NetworkAccessManager *networkmanager, const QByteArray &clientKey, const QByteArray &clientSecret, bool simulationMode, QObject *parent) :
+ QObject(parent),
+ m_clientKey(clientKey),
+ m_clientSecret(clientSecret),
+ m_networkManager(networkmanager)
+
+{
+ m_tokenRefreshTimer = new QTimer(this);
+ m_tokenRefreshTimer->setSingleShot(true);
+ connect(m_tokenRefreshTimer, &QTimer::timeout, this, &HomeConnect::onRefreshTimeout);
+ setSimulationMode(simulationMode);
+}
+
+QByteArray HomeConnect::accessToken()
+{
+ return m_accessToken;
+}
+
+QByteArray HomeConnect::refreshToken()
+{
+ return m_refreshToken;
+}
+
+void HomeConnect::setSimulationMode(bool simulation)
+{
+ m_simulationMode = simulation;
+ if (simulation) {
+ m_baseAuthorizationUrl = "https://simulator.home-connect.com/security/oauth/authorize";
+ m_baseTokenUrl = "https://simulator.home-connect.com/security/oauth/token";
+ m_baseControlUrl = "https://simulator.home-connect.com";
+ } else {
+ m_baseAuthorizationUrl = "https://api.home-connect.com/security/oauth/authorize";
+ m_baseTokenUrl = "https://api.home-connect.com/security/oauth/token";
+ m_baseControlUrl = "https://api.home-connect.com";
+ }
+}
+
+QUrl HomeConnect::getLoginUrl(const QUrl &redirectUrl, const QString &scope)
+{
+ if (m_clientKey.isEmpty()) {
+ qWarning(dcHomeConnect) << "Client key not defined!";
+ return QUrl("");
+ }
+
+ if (redirectUrl.isEmpty()){
+ qWarning(dcHomeConnect) << "No redirect uri defined!";
+ }
+ m_redirectUri = QUrl::toPercentEncoding(redirectUrl.toString());
+
+ QUrl url(m_baseAuthorizationUrl);
+ QUrlQuery queryParams;
+ queryParams.addQueryItem("client_id", m_clientKey);
+ queryParams.addQueryItem("redirect_uri", m_redirectUri);
+ queryParams.addQueryItem("response_type", "code");
+ queryParams.addQueryItem("scope", scope);
+ queryParams.addQueryItem("state", QUuid::createUuid().toString());
+ queryParams.addQueryItem("nonce", QUuid::createUuid().toString());
+ m_codeChallenge = QUuid::createUuid().toString().remove(QRegExp("[{}-]"));
+ queryParams.addQueryItem("code_challenge", m_codeChallenge);
+ queryParams.addQueryItem("code_challenge_method", "plain");
+ url.setQuery(queryParams);
+
+ return url;
+}
+
+void HomeConnect::onRefreshTimeout()
+{
+ qCDebug(dcHomeConnect) << "Refresh authentication token";
+ getAccessTokenFromRefreshToken(m_refreshToken);
+}
+
+bool HomeConnect::checkStatusCode(QNetworkReply *reply, const QByteArray &rawData)
+{
+ // Check for the internet connection
+ if (reply->error() == QNetworkReply::NetworkError::HostNotFoundError ||
+ reply->error() == QNetworkReply::NetworkError::UnknownNetworkError ||
+ reply->error() == QNetworkReply::NetworkError::TemporaryNetworkFailureError) {
+ qCWarning(dcHomeConnect()) << "Connection error" << reply->errorString();
+ setConnected(false);
+ setAuthenticated(false);
+ return false;
+ } else {
+ setConnected(true);
+ }
+ int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(rawData, &error);
+
+ switch (status){
+ case 200: //The request was successful. Typically returned for successful GET requests.
+ case 204: //The request was successful. Typically returned for successful PUT/DELETE requests with no payload.
+ break;
+ case 400: //Error occurred (e.g. validation error - value is out of range)
+ if(!jsonDoc.toVariant().toMap().contains("error")) {
+ if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_client") {
+ qWarning(dcHomeConnect()) << "Client token provided doesn’t correspond to client that generated auth code.";
+ }
+ if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_redirect_uri") {
+ qWarning(dcHomeConnect()) << "Missing redirect_uri parameter.";
+ }
+ if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_code") {
+ qWarning(dcHomeConnect()) << "Expired authorization code.";
+ }
+ }
+ setAuthenticated(false);
+ return false;
+ case 401:
+ qWarning(dcHomeConnect()) << "Client does not have permission to use this API.";
+ setAuthenticated(false);
+ return false;
+ case 403:
+ qCWarning(dcHomeConnect()) << "Forbidden, Scope has not been granted or home appliance is not assigned to HC account";
+ setAuthenticated(false);
+ return false;
+ case 404:
+ qCWarning(dcHomeConnect()) << "Not Found. This resource is not available (e.g. no images on washing machine)";
+ return false;
+ case 405:
+ qWarning(dcHomeConnect()) << "Wrong HTTP method used.";
+ setAuthenticated(false);
+ return false;
+ case 408:
+ qCWarning(dcHomeConnect())<< "Request Timeout, API Server failed to produce an answer or has no connection to backend service";
+ return false;
+ case 409:
+ qCWarning(dcHomeConnect()) << "Conflict - Command/Query cannot be executed for the home appliance, the error response contains the error details";
+ qCWarning(dcHomeConnect()) << "Error" << jsonDoc;
+ return false;
+ case 415:
+ qCWarning(dcHomeConnect())<< "Unsupported Media Type. The request's Content-Type is not supported";
+ return false;
+ case 429:
+ qCWarning(dcHomeConnect())<< "Too Many Requests, the number of requests for a specific endpoint exceeded the quota of the client";
+ return false;
+ case 500:
+ qCWarning(dcHomeConnect())<< "Internal Server Error, in case of a server configuration error or any errors in resource files";
+ return false;
+ case 503:
+ qCWarning(dcHomeConnect())<< "Service Unavailable,if a required backend service is not available";
+ return false;
+ default:
+ break;
+ }
+
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcHomeConnect()) << "Received invalide JSON object" << rawData;
+ qCWarning(dcHomeConnect()) << "Status" << status;
+ setAuthenticated(false);
+ return false;
+ }
+
+ setAuthenticated(true);
+ return true;
+}
+
+void HomeConnect::getAccessTokenFromRefreshToken(const QByteArray &refreshToken)
+{
+ if (refreshToken.isEmpty()) {
+ qWarning(dcHomeConnect) << "No refresh token given!";
+ setAuthenticated(false);
+ return;
+ }
+
+ QUrl url(m_baseTokenUrl);
+ QUrlQuery query;
+ query.clear();
+ query.addQueryItem("grant_type", "refresh_token");
+ query.addQueryItem("refresh_token", refreshToken);
+ query.addQueryItem("client_secret", m_clientSecret);
+
+ QNetworkRequest request(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
+
+ QNetworkReply *reply = m_networkManager->post(request, query.toString().toUtf8());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, reply](){
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+ QJsonDocument data = QJsonDocument::fromJson(rawData);
+
+ if(!data.toVariant().toMap().contains("access_token")) {
+ setAuthenticated(false);
+ return;
+ }
+ m_accessToken = data.toVariant().toMap().value("access_token").toByteArray();
+ emit receivedAccessToken(m_accessToken);
+
+ if (data.toVariant().toMap().contains("expires_in")) {
+ int expireTime = data.toVariant().toMap().value("expires_in").toInt();
+ qCDebug(dcHomeConnect) << "Access token expires int" << expireTime << "s, at" << QDateTime::currentDateTime().addSecs(expireTime).toString();
+ if (!m_tokenRefreshTimer) {
+ qWarning(dcHomeConnect()) << "Access token refresh timer not initialized";
+ return;
+ }
+ if (expireTime < 20) {
+ qCWarning(dcHomeConnect()) << "Expire time too short";
+ return;
+ }
+ m_tokenRefreshTimer->start((expireTime - 20) * 1000);
+ }
+ });
+}
+
+void HomeConnect::getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode)
+{
+ // Obtaining access token
+ if(authorizationCode.isEmpty())
+ qWarning(dcHomeConnect) << "No authorization code given!";
+ if(m_clientKey.isEmpty())
+ qWarning(dcHomeConnect) << "Client key not set!";
+ if(m_clientSecret.isEmpty())
+ qWarning(dcHomeConnect) << "Client secret not set!";
+
+ QUrl url = QUrl(m_baseTokenUrl);
+ QUrlQuery query; url.setQuery(query);
+
+ query.clear();
+ query.addQueryItem("client_id", m_clientKey);
+ query.addQueryItem("client_secret", m_clientSecret);
+ query.addQueryItem("redirect_uri", m_redirectUri);
+ query.addQueryItem("grant_type", "authorization_code");
+ query.addQueryItem("code", authorizationCode);
+ query.addQueryItem("code_verifier", m_codeChallenge);
+
+ QNetworkRequest request(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
+
+ QNetworkReply *reply = m_networkManager->post(request, query.toString().toUtf8());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, reply](){
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(rawData);
+ if(!jsonDoc.toVariant().toMap().contains("access_token") || !jsonDoc.toVariant().toMap().contains("refresh_token") ) {
+ setAuthenticated(false);
+ return;
+ }
+ m_accessToken = jsonDoc.toVariant().toMap().value("access_token").toByteArray();
+ receivedAccessToken(m_accessToken);
+ m_refreshToken = jsonDoc.toVariant().toMap().value("refresh_token").toByteArray();
+ receivedRefreshToken(m_refreshToken);
+
+ if (jsonDoc.toVariant().toMap().contains("expires_in")) {
+ int expireTime = jsonDoc.toVariant().toMap().value("expires_in").toInt();
+ qCDebug(dcHomeConnect()) << "Token expires in" << expireTime << "s, at" << QDateTime::currentDateTime().addSecs(expireTime).toString();
+ if (!m_tokenRefreshTimer) {
+ qWarning(dcHomeConnect()) << "Token refresh timer not initialized";
+ setAuthenticated(false);
+ return;
+ }
+ if (expireTime < 20) {
+ qCWarning(dcHomeConnect()) << "Expire time too short";
+ return;
+ }
+ m_tokenRefreshTimer->start((expireTime - 20) * 1000);
+ }
+ });
+}
+
+void HomeConnect::getHomeAppliances()
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, reply](){
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+ QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap().value("data").toMap();
+
+ QList appliances;
+ foreach (const QVariant &variant, dataMap.value("homeappliances").toList()) {
+ QVariantMap obj = variant.toMap();
+ HomeAppliance appliance;
+ appliance.name = obj["name"].toString();
+ appliance.brand = obj["brand"].toString();
+ appliance.vib = obj["vib"].toString();
+ appliance.type = obj["type"].toString();
+ appliance.homeApplianceId = obj["haId"].toString();
+ appliance.enumber = obj["enumber"].toString();
+ appliance.connected = obj["connected"].toBool();
+ appliances.append(appliance);
+ }
+ if (!appliances.isEmpty())
+ emit receivedHomeAppliances(appliances);
+
+ });
+}
+
+void HomeConnect::getPrograms(const QString &haId)
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, haId, reply]{
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+
+ QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap().value("data").toMap();
+ QVariantList programList = dataMap.value("programs").toList();
+ QStringList programs;
+ Q_FOREACH(QVariant var, programList) {
+ if (var.toMap().contains("key")) {
+ programs.append(var.toMap().value("key").toString());
+ }
+ }
+ if (!programs.isEmpty())
+ emit receivedPrograms(haId, programs);
+ });
+}
+
+void HomeConnect::getProgramsAvailable(const QString &haId)
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs/available");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, haId, reply]{
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+
+ QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap().value("data").toMap();
+ QVariantList programList = dataMap.value("programs").toList();
+ QStringList programs;
+ Q_FOREACH(QVariant var, programList) {
+ if (var.toMap().contains("key")) {
+ programs.append(var.toMap().value("key").toString());
+ }
+ }
+ if (!programs.isEmpty())
+ emit receivedAvailablePrograms(haId, programs);
+ });
+}
+
+void HomeConnect::getProgramsActive(const QString &haId)
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs/active");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, haId, reply]{
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+
+ QVariantMap map = QJsonDocument::fromJson(rawData).toVariant().toMap();
+ QHash options;
+ if (map.contains("data")) {
+ QString key = map.value("data").toMap().value("key").toString();
+ Q_FOREACH(QVariant var, map.value("data").toMap().value("options").toList()) {
+ options.insert(var.toMap().value("key").toString(), var.toMap().value("value"));
+ }
+ emit receivedActiveProgram(haId, key, options);
+ } else if (map.contains("error")) {
+ QString key = map.value("error").toMap().value("key").toString();
+ emit receivedActiveProgram(haId, key, options);
+ }
+ });
+}
+
+void HomeConnect::getProgramsSelected(const QString &haId)
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs/selected");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, haId, reply]{
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+
+ QVariantMap map = QJsonDocument::fromJson(rawData).toVariant().toMap();
+ QHash options;
+ if (map.contains("data")) {
+ QString key = map.value("data").toMap().value("key").toString();
+ Q_FOREACH(QVariant var, map.value("data").toMap().value("options").toList()) {
+ options.insert(var.toMap().value("key").toString(), var.toMap().value("value"));
+ }
+ emit receivedSelectedProgram(haId, key, options);
+ } else if (map.contains("error")) {
+ QString key = map.value("error").toMap().value("key").toString();
+ emit receivedSelectedProgram(haId, key, options);
+ }
+ });
+}
+
+void HomeConnect::getProgramsActiveOption(const QString &haId, const QString &optionKey)
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs/active/options/"+optionKey);
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, reply]{
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+ QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap().value("data").toMap();
+ qCDebug(dcHomeConnect()) << "key" << dataMap.value("key").toString() << "value" << dataMap.value("value").toString() << dataMap.value("unit").toString();
+ });
+}
+
+QUuid HomeConnect::selectProgram(const QString &haId, const QString &programKey, QList options)
+{
+ QUuid commandId = QUuid::createUuid();
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs/selected");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+ request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/vnd.bsh.sdk.v1+json");
+
+ QJsonDocument doc;
+ QVariantMap data;
+ data.insert("key", programKey);
+ if (!options.isEmpty()) {
+ QVariantList optionsArray;
+ Q_FOREACH(Option option, options) {
+ QVariantMap optionObject;
+ optionObject["key"] = option.key;
+ optionObject["value"] = option.value;
+ if (!option.unit.isEmpty())
+ optionObject["unit"] = option.unit;
+ optionsArray.append(optionObject);
+ }
+ data.insert("options", optionsArray);
+ }
+ QVariantMap obj;
+ obj.insert("data", data);
+ doc.setObject(QJsonObject::fromVariantMap(obj));
+ QNetworkReply *reply = m_networkManager->put(request, doc.toJson());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, commandId, reply]{
+
+ int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ if (status != 204) {
+ emit commandExecuted(commandId, false);
+ QByteArray rawData = reply->readAll();
+ qCDebug(dcHomeConnect()) << "Selected program" << rawData;
+ QJsonParseError error;
+ QJsonDocument data = QJsonDocument::fromJson(rawData, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCDebug(dcHomeConnect()) << "Selected program: Received invalide JSON object";
+ return;
+ }
+ if (data.toVariant().toMap().contains("error")) {
+ qCWarning(dcHomeConnect()) << "Start program" << data.toVariant().toMap().value("error").toMap().value("description").toString();
+ return;
+ }
+ } else {
+ emit commandExecuted(commandId, true);
+ }
+ });
+ return commandId;
+}
+
+QUuid HomeConnect::setSelectedProgramOptions(const QString &haId, QList options)
+{
+ if (options.isEmpty())
+ return "";
+
+ QUuid commandId = QUuid::createUuid();
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs/selected/options");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+ request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/vnd.bsh.sdk.v1+json");
+
+ QJsonDocument doc;
+ QVariantMap data;
+ QVariantList optionsArray;
+ Q_FOREACH(Option option, options) {
+ QVariantMap optionObject;
+ optionObject["key"] = option.key;
+ optionObject["value"] = option.value;
+ if (!option.unit.isEmpty())
+ optionObject["unit"] = option.unit;
+ optionsArray.append(optionObject);
+ }
+ data.insert("options", optionsArray);
+ QVariantMap obj;
+ obj.insert("data", data);
+ doc.setObject(QJsonObject::fromVariantMap(obj));
+ qCDebug(dcHomeConnect()) << "Selected Program Options" << doc.toJson();
+
+ QNetworkReply *reply = m_networkManager->put(request, doc.toJson());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, commandId, reply]{
+
+ int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ if (status != 204) {
+ emit commandExecuted(commandId, false);
+ QByteArray rawData = reply->readAll();
+ qCDebug(dcHomeConnect()) << "Selected program" << rawData;
+ QJsonParseError error;
+ QJsonDocument data = QJsonDocument::fromJson(rawData, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCDebug(dcHomeConnect()) << "Selected program options: Received invalide JSON object";
+ return;
+ }
+ if (data.toVariant().toMap().contains("error")) {
+ qCWarning(dcHomeConnect()) << "Selected program options:" << data.toVariant().toMap().value("error").toMap().value("description").toString();
+ return;
+ }
+ } else {
+ emit commandExecuted(commandId, true);
+ }
+ });
+ return commandId;
+}
+
+QUuid HomeConnect::startProgram(const QString &haId, const QString &programKey, QList options)
+{
+ QUuid commandId = QUuid::createUuid();
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs/active");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+ request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/vnd.bsh.sdk.v1+json");
+
+ QJsonDocument doc;
+ QVariantMap data;
+ data.insert("key", programKey);
+ QVariantList optionsArray;
+ Q_FOREACH(Option option, options) {
+ QVariantMap optionObject;
+ optionObject["key"] = option.key;
+ optionObject["value"] = option.value;
+ if (!option.unit.isEmpty())
+ optionObject["unit"] = option.unit;
+ optionsArray.append(optionObject);
+ }
+ data.insert("options", optionsArray);
+ QVariantMap obj;
+ obj.insert("data", data);
+ doc.setObject(QJsonObject::fromVariantMap(obj));
+ QNetworkReply *reply = m_networkManager->put(request, doc.toJson());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, commandId, reply]{
+
+ int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ if (status != 204) {
+ emit commandExecuted(commandId, false);
+ QJsonParseError error;
+ QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCDebug(dcHomeConnect()) << "Start program: Received invalide JSON object";
+ return;
+ }
+ qCDebug(dcHomeConnect()) << "Start program response" << data.toJson();
+ if (data.toVariant().toMap().contains("error")) {
+ qCWarning(dcHomeConnect()) << "Start program" << data.toVariant().toMap().value("error").toMap().value("description").toString();
+ }
+ } else {
+ emit commandExecuted(commandId, true);
+ }
+ });
+ return commandId;
+}
+
+QUuid HomeConnect::stopProgram(const QString &haId)
+{
+ QUuid commandId = QUuid::createUuid();
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haId+"/programs/active");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->deleteResource(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, commandId, reply]{
+
+
+ int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ if (status != 204) {
+ emit commandExecuted(commandId, false);
+ } else {
+ emit commandExecuted(commandId, true);
+ }
+ });
+ return commandId;
+}
+
+void HomeConnect::getStatus(const QString &haid)
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haid+"/status");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, haid, reply]{
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+
+ QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap().value("data").toMap();
+ QHash statusList;
+ QVariantList statusVariantList= dataMap.value("status").toList();
+ Q_FOREACH(QVariant status, statusVariantList) {
+ QVariantMap map = status.toMap();
+ statusList.insert(map.value("key").toString(), map.value("value"));
+ }
+ if (!statusList.isEmpty())
+ emit receivedStatusList(haid, statusList);
+ });
+}
+
+/* Get a list of available setting of the home appliance.
+ * Possible Settings:
+ * Power state
+ * Fridge temperature
+ * Fridge super mode
+ * Freezer temperature
+ * Freezer super mode
+ */
+
+void HomeConnect::getSettings(const QString &haid)
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haid+"/settings");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, haid, reply]{
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+ QVariantMap dataMap = QJsonDocument::fromJson(rawData).toVariant().toMap().value("data").toMap();
+ QVariantList settingsList = dataMap.value("settings").toList();
+ QHash settings;
+ Q_FOREACH(QVariant var, settingsList) {
+ settings.insert(var.toMap().value("key").toString(), var.toMap().value("value"));
+ }
+ if (!settings.isEmpty())
+ emit receivedSettings(haid, settings);
+ });
+}
+
+void HomeConnect::connectEventStream()
+{
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/events");
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "text/event-stream");
+
+ QNetworkReply *reply = m_networkManager->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, [reply, this] {
+ int reconnectTime = 5000; // Usual reconnect in 5 s
+ if (reply->error() != QNetworkReply::NetworkError::NoError) {
+ qCDebug(dcHomeConnect()) << "Event stream error" << reply->errorString() << reply->readAll();
+ }
+ qCDebug(dcHomeConnect()) << "Eventstream disconected";
+ int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ if (status == 429) {
+ reconnectTime = 600000;
+ }
+ qCDebug(dcHomeConnect()) << "Trying to reconnect event stream in" << reconnectTime/1000 << "seconds";
+ QTimer::singleShot(reconnectTime, this, [this] {
+ qCDebug(dcHomeConnect()) << "Reconnecting event stream";
+ connectEventStream();
+ });
+ });
+ connect(reply, &QNetworkReply::readyRead, this, [this, reply]{
+
+ int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ if (status == 200) {
+ while (reply->canReadLine()) {
+ QJsonDocument data;
+ QString haId;
+ EventType eventType;
+
+ QByteArray eventTypeLine = reply->readLine();
+ if (eventTypeLine == "\n")
+ continue;
+ if (eventTypeLine.startsWith("event")) {
+ QString eventString = eventTypeLine.split(':').last().trimmed();
+ if (eventString == "KEEP-ALIVE") {
+ eventType = EventTypeKeepAlive;
+ } else if (eventString == "STATUS") {
+ eventType = EventTypeStatus;
+ } else if (eventString == "EVENT") {
+ eventType = EventTypeEvent;
+ } else if (eventString == "NOTIFY") {
+ eventType = EventTypeNotify;
+ } else if (eventString == "DISCONNECTED") {
+ eventType = EventTypeDisconnected;
+ } else if (eventString == "CONNECTED") {
+ eventType = EventTypeConnected;
+ } else if (eventString == "PAIRED") {
+ eventType = EventTypePaired;
+ } else if (eventString == "DEPAIRED") {
+ eventType = EventTypeDepaired;
+ } else {
+ qCWarning(dcHomeConnect()) << "Unhandled event type" << eventString;
+ return;
+ }
+ QByteArray dataLine = reply->readLine();
+ if (dataLine.startsWith("data")) {
+ data = QJsonDocument::fromJson(dataLine.remove(0,6));
+
+ QByteArray idLine = reply->readLine();
+ if (idLine.startsWith("id")) {
+ haId = idLine.split(':').last().trimmed();
+ } else {
+ qCWarning(dcHomeConnect()) << "Id line: Unexpected line" << eventTypeLine;
+ continue;
+ }
+ } else {
+ qCWarning(dcHomeConnect()) << "Data Line: Unexpected line" << eventTypeLine;
+ continue;
+ }
+ } else {
+ qCWarning(dcHomeConnect()) << "Event type: Unexpected line" << eventTypeLine;
+ continue;
+ }
+
+ if (data.toVariant().toMap().contains("items")) {
+ QList events;
+ QVariantList itemsList = data.toVariant().toMap().value("items").toList();
+ Q_FOREACH(QVariant item, itemsList) {
+ QVariantMap map = item.toMap();
+ Event event;
+ event.key = map["key"].toString();
+ event.uri = map["uri"].toString();
+ event.name = map["uri"].toString();
+ event.value = map["value"];
+ event.unit = map["unit"].toString();
+ event.timestamp = map["timestamp"].toInt();
+ events.append(event);
+ }
+ if (!events.isEmpty())
+ emit receivedEvents(eventType, haId, events);
+ } else if (data.toVariant().toMap().contains("error")) {
+ qCWarning(dcHomeConnect()) << "Event stream error" << data.toVariant().toMap().value("error");
+ }
+ }
+ }
+ });
+}
+
+QUuid HomeConnect::sendCommand(const QString &haid, const QString &command)
+{
+ QUuid commandId = QUuid::createUuid();
+ QUrl url = QUrl(m_baseControlUrl+"/api/homeappliances/"+haid+"/commands/"+command);
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", "Bearer "+m_accessToken);
+ request.setRawHeader("Accept-Language", "en-US");
+ request.setRawHeader("accept", "application/vnd.bsh.sdk.v1+json");
+
+ QJsonDocument doc;
+ QJsonObject data;
+ data.insert("key", command);
+ data.insert("value", true);
+ QJsonObject obj;
+ obj.insert("data", data);
+ doc.setObject(obj);
+ QNetworkReply *reply = m_networkManager->put(request, doc.toJson());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [this, commandId, reply]{
+
+ QByteArray rawData = reply->readAll();
+ if (!checkStatusCode(reply, rawData)) {
+ return;
+ }
+ QVariantMap map = QJsonDocument::fromJson(rawData).toVariant().toMap();
+ qCDebug(dcHomeConnect()) << "Send command" << map;
+ if (map.contains("data")) {
+ QVariantMap dataMap = map.value("data").toMap();
+ qCDebug(dcHomeConnect()) << "key" << dataMap.value("key").toString() << "value" << dataMap.value("value").toString() << dataMap.value("unit").toString();
+ } else if (map.contains("error")) {
+ qCWarning(dcHomeConnect()) << "Send command" << map.value("error").toMap().value("description").toString();
+ }
+ emit commandExecuted(commandId, true);
+ });
+ return commandId;
+}
+
+void HomeConnect::setAuthenticated(bool state)
+{
+ if (state != m_authenticated) {
+ m_authenticated = state;
+ emit authenticationStatusChanged(state);
+ }
+}
+
+void HomeConnect::setConnected(bool state)
+{
+ if (state != m_connected) {
+ m_connected = state;
+ emit connectionChanged(state);
+ }
+}
diff --git a/homeconnect/homeconnect.h b/homeconnect/homeconnect.h
new file mode 100644
index 00000000..ae2f757f
--- /dev/null
+++ b/homeconnect/homeconnect.h
@@ -0,0 +1,188 @@
+/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+*
+* Copyright 2013 - 2020, nymea GmbH
+* Contact: contact@nymea.io
+*
+* This file is part of nymea.
+* This project including source code and documentation is protected by
+* copyright law, and remains the property of nymea GmbH. All rights, including
+* reproduction, publication, editing and translation, are reserved. The use of
+* this project is subject to the terms of a license agreement to be concluded
+* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
+* under https://nymea.io/license
+*
+* GNU Lesser General Public License Usage
+* Alternatively, this project may be redistributed and/or modified under the
+* terms of the GNU Lesser General Public License as published by the Free
+* Software Foundation; version 3. This project 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 project. If not, see .
+*
+* For any further details and any questions please contact us under
+* contact@nymea.io or see our FAQ/Licensing Information on
+* https://nymea.io/license/faq
+*
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+#ifndef HOMECONNECT_H
+#define HOMECONNECT_H
+
+#include
+#include
+#include
+
+#include "network/networkaccessmanager.h"
+
+class HomeConnect : public QObject
+{
+ Q_OBJECT
+public:
+ enum EventType {
+ EventTypeKeepAlive,
+ EventTypeStatus,
+ EventTypeEvent,
+ EventTypeNotify,
+ EventTypeDisconnected,
+ EventTypeConnected,
+ EventTypePaired,
+ EventTypeDepaired
+ };
+
+ enum Type {
+ Oven,
+ Dishwasher,
+ Washer,
+ Dryer,
+ WasherDryer,
+ FridgeFreezer,
+ Refrigerator,
+ Freezer,
+ WineCooler,
+ CoffeeMaker,
+ Hood,
+ CleaningRobot,
+ CookProcessor
+ };
+
+ struct HomeAppliance {
+ QString name;
+ QString brand;
+ QString enumber;
+ QString vib;
+ bool connected;
+ QString type;
+ QString homeApplianceId;
+ };
+
+ struct OptionData {
+ QString key;
+ QVariant value;
+ QString unit;
+ };
+
+ struct Option {
+ QString key;
+ QVariant value;
+ QString unit;
+ };
+
+ /*
+ "key": "Cooking.Oven.Option.SetpointTemperature",
+ "name": "Target temperature for the oven",
+ "uri": "/api/homeappliances/BOSCH-HNG6764B6-0000000011FF/programs/active/options/Cooking.Oven.Option.SetpointTemperature",
+ "timestamp": 1556793979,
+ "level": "hint",
+ "handling": "none",
+ "value": 200,
+ "unit": "°C"
+ */
+ struct Event {
+ QString key;
+ QString name;
+ QString uri;
+ int timestamp;
+ QVariant value;
+ QString unit;
+ };
+
+ HomeConnect(NetworkAccessManager *networkmanager, const QByteArray &clientKey, const QByteArray &clientSecret, bool simulationMode = false, QObject *parent = nullptr);
+ QByteArray accessToken();
+ QByteArray refreshToken();
+ void setSimulationMode(bool simulation);
+
+ QUrl getLoginUrl(const QUrl &redirectUrl, const QString &scope);
+
+ void getAccessTokenFromRefreshToken(const QByteArray &refreshToken);
+ void getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode);
+
+ // DEFAULT
+ void getHomeAppliances(); // Get all home appliances which are paired with the logged-in user account.
+
+ // PROGRAMS
+ void getPrograms(const QString &haId); //Get all programs of a given home appliance
+ void getProgramsAvailable(const QString &haId); //Get all programs which are currently available on the given home appliance
+ void getProgramsActive(const QString &haId); //Get program which is currently executed
+ void getProgramsSelected(const QString &haId); //Get the program which is currently selected
+ void getProgramsActiveOption(const QString &haId, const QString &optionKey);
+ QUuid selectProgram(const QString &haId, const QString &programKey, QList