diff --git a/debian/control b/debian/control
index 5d1547af..d56a6ac5 100644
--- a/debian/control
+++ b/debian/control
@@ -202,6 +202,14 @@ Description: nymea integration plugin for dynatrace
This package contains the nymea integration plugin for the dynatrace UFO
+Package: nymea-plugin-easee
+Architecture: any
+Depends: ${shlibs:Depends},
+ ${misc:Depends},
+Description: nymea integration plugin for easee wallboxes
+ This package contains the nymea integration plugin for easee wallboxes
+
+
Package: nymea-plugin-elgato
Architecture: any
Depends: ${shlibs:Depends},
diff --git a/debian/nymea-plugin-easee.install.in b/debian/nymea-plugin-easee.install.in
new file mode 100644
index 00000000..24d7b2ff
--- /dev/null
+++ b/debian/nymea-plugin-easee.install.in
@@ -0,0 +1,2 @@
+usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationplugineasee.so
+easee/translations/*qm usr/share/nymea/translations/
diff --git a/easee/README.md b/easee/README.md
new file mode 100644
index 00000000..520fbf6c
--- /dev/null
+++ b/easee/README.md
@@ -0,0 +1,15 @@
+# Easee
+
+This integration allows nymea to control Easee wallboxes.
+
+## Supported things
+
+All models of Easee wallboxes are supported.
+
+## Requirements and setup
+
+It is required to create an account at easee and connect the wallbox to that account. Generally, setting up the Wallbox using the Easee app will provide everything necessary.
+Set up an "easee account" in nymea using your easee account credentials. All connected wallboxes will appear in nymea.
+
+
+
diff --git a/easee/easee.png b/easee/easee.png
new file mode 100644
index 00000000..d8307aeb
Binary files /dev/null and b/easee/easee.png differ
diff --git a/easee/easee.pro b/easee/easee.pro
new file mode 100644
index 00000000..05cdefde
--- /dev/null
+++ b/easee/easee.pro
@@ -0,0 +1,11 @@
+include(../plugins.pri)
+
+QT += network websockets
+
+SOURCES += \
+ integrationplugineasee.cpp \
+ signalrconnection.cpp
+
+HEADERS += \
+ integrationplugineasee.h \
+ signalrconnection.h
diff --git a/easee/integrationplugineasee.cpp b/easee/integrationplugineasee.cpp
new file mode 100644
index 00000000..891bd227
--- /dev/null
+++ b/easee/integrationplugineasee.cpp
@@ -0,0 +1,531 @@
+/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+*
+* Copyright 2013 - 2023, 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 "integrationplugineasee.h"
+#include "plugininfo.h"
+#include "signalrconnection.h"
+
+#include
+
+#include
+#include
+#include
+#include
+
+IntegrationPluginEasee::IntegrationPluginEasee()
+{
+
+}
+
+IntegrationPluginEasee::~IntegrationPluginEasee()
+{
+}
+
+void IntegrationPluginEasee::confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret)
+{
+ QNetworkRequest request(QUrl(QString("https://api.easee.cloud/api/accounts/login")));
+ request.setRawHeader("accept", "application/json");
+ request.setRawHeader("content-type", "application/*+json");
+ QVariantMap body;
+ body.insert("userName", username);
+ body.insert("password", secret);
+ QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(body).toJson(QJsonDocument::Compact));
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, info, [this, info, reply, username, secret](){
+ qCDebug(dcEasee) << "auth reply finished" << reply->error();
+
+ if (reply->error() == QNetworkReply::ProtocolInvalidOperationError) {
+ info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Authentication failed. Please try again."));
+ return;
+ }
+ if (reply->error() != QNetworkReply::NoError) {
+ info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Unable to contact the easee server. Please try again later."));
+ return;
+ }
+
+ QByteArray data = reply->readAll();
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcEasee) << "Unable to parse json:" << error.errorString() << data;
+ info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Unable to process the response from easee. Please try again later."));
+ return;
+ }
+
+ QVariantMap map = jsonDoc.toVariant().toMap();
+ QByteArray accessToken = map.value("accessToken").toByteArray();
+ int expiresIn = map.value("expiresIn").toInt();
+ QByteArray refreshToken = map.value("refreshToken").toByteArray();
+
+ pluginStorage()->beginGroup(info->thingId().toString());
+ pluginStorage()->setValue("accessToken", accessToken);
+ pluginStorage()->setValue("expiry", QDateTime::currentDateTime().addSecs(expiresIn));
+ pluginStorage()->setValue("refreshToken", refreshToken);
+
+ // FIXME: the refresh_token api call seems to not work... So we'll store user/pass in the config for now
+ pluginStorage()->setValue("username", username);
+ pluginStorage()->setValue("password", secret);
+
+ pluginStorage()->endGroup();
+
+ info->finish(Thing::ThingErrorNoError);
+ });
+}
+
+void IntegrationPluginEasee::setupThing(ThingSetupInfo *info)
+{
+ Thing *thing = info->thing();
+
+ if (thing->thingClassId() == accountThingClassId) {
+ pluginStorage()->beginGroup(info->thing()->id().toString());
+ QByteArray accessToken = pluginStorage()->value("accessToken").toByteArray();
+ QByteArray refreshToken = pluginStorage()->value("refreshToken").toByteArray();
+ QDateTime expiry = pluginStorage()->value("expiry").toDateTime();
+ pluginStorage()->endGroup();
+
+ if (expiry < QDateTime::currentDateTime()) {
+ QNetworkReply *reply = this->refreshToken(thing);
+ connect(reply, &QNetworkReply::finished, info, [=](){
+ setupThing(info);
+ });
+ return;
+ }
+
+ QNetworkRequest request = createRequest(thing, "accounts/profile");
+ QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, thing, [this, thing, reply](){
+ qCDebug(dcEasee) << "profile info finished" << reply->error();
+ if (reply->error() != QNetworkReply::NoError) {
+ qCWarning(dcEasee) << "Unable to contact easee server...";
+ thing->setStateValue(accountConnectedStateTypeId, false);
+ thing->setStateValue(accountLoggedInStateTypeId, false);
+ return;
+ }
+
+ QByteArray data = reply->readAll();
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcEasee) << "Unable to parse json:" << error.errorString() << data;
+ thing->setStateValue(accountConnectedStateTypeId, false);
+ thing->setStateValue(accountLoggedInStateTypeId, false);
+ return;
+ }
+
+ thing->setStateValue(accountConnectedStateTypeId, true);
+ thing->setStateValue(accountLoggedInStateTypeId, true);
+
+ QVariantMap map = jsonDoc.toVariant().toMap();
+ qCDebug(dcEasee) << "Profile reply:" << data;
+
+ refreshProducts(thing);
+
+ });
+ }
+
+ if (thing->thingClassId() == chargerThingClassId) {
+ refreshCurrentState(thing);
+ }
+
+
+ info->finish(Thing::ThingErrorNoError);
+
+}
+
+void IntegrationPluginEasee::postSetupThing(Thing *thing)
+{
+ if (!m_timer) {
+ m_timer = hardwareManager()->pluginTimerManager()->registerTimer(60);
+ connect(m_timer, &PluginTimer::timeout, [this](){
+ foreach (Thing *t, myThings()) {
+ if (t->thingClassId() == accountThingClassId) {
+
+ // Refreshing the token if it is about to expire
+ pluginStorage()->beginGroup(t->id().toString());
+ QByteArray accessToken = pluginStorage()->value("accessToken").toByteArray();
+ QByteArray refreshToken = pluginStorage()->value("refreshToken").toByteArray();
+ QDateTime expiry = pluginStorage()->value("expiry").toDateTime();
+ pluginStorage()->endGroup();
+ if (expiry < QDateTime::currentDateTime().addSecs(120)) {
+ this->refreshToken(t);
+ }
+
+ // Refreshing the products
+ refreshProducts(t);
+
+ if (!m_signalRConnections.value(t)->connected()) {
+ // If the SignalR connection fails for whatever reason, let's poll
+ foreach (Thing *child, myThings().filterByParentId(t->id())) {
+ refreshCurrentState(child);
+ }
+ }
+ } else if (t->thingClassId() == chargerThingClassId) {
+ // We'll be using the SignalR connection instead for updates.
+ //refreshCurrentState(t);
+ }
+ }
+ });
+ }
+
+
+ if (thing->thingClassId() == accountThingClassId) {
+ pluginStorage()->beginGroup(thing->id().toString());
+ QByteArray accessToken = pluginStorage()->value("accessToken").toByteArray();
+ QDateTime expiry = pluginStorage()->value("expiry").toDateTime();
+ pluginStorage()->endGroup();
+
+ qCDebug(dcEasee()) << "Access token:" << accessToken;
+ qCDebug(dcEasee()) << "Token expiry:" << expiry;
+
+ SignalRConnection *signalR = new SignalRConnection(QUrl("http://streams.easee.com/hubs/chargers"), accessToken, hardwareManager()->networkManager(), thing);
+ m_signalRConnections.insert(thing, signalR);
+
+ connect(signalR, &SignalRConnection::connectionStateChanged, thing, [=](bool connected){
+ foreach (Thing *charger, myThings().filterByParentId(thing->id())) {
+ charger->setStateValue(chargerConnectedStateTypeId, true);
+ if (connected) {
+ signalR->subscribe(charger->paramValue(chargerThingIdParamTypeId).toString());
+ }
+ }
+ });
+
+ connect(signalR, &SignalRConnection::dataReceived, thing, [=](const QVariantMap &data){
+ if (data.value("target").toString() != "ProductUpdate") {
+ qCWarning(dcEasee()) << "Unhandled SignalR notification:" << data;
+ return;
+ }
+
+ foreach (const QVariant &argumentVariant, data.value("arguments").toList()) {
+ QVariantMap arg = argumentVariant.toMap();
+ QString chargerId = arg.value("mid").toString();
+ ObservationPoint dataId = static_cast(arg.value("id").toUInt());
+ QVariant value = arg.value("value");
+ Thing *charger = myThings().filterByParentId(thing->id()).findByParams({Param(chargerThingIdParamTypeId, chargerId)});
+ if (!charger) {
+ qCWarning(dcEasee()) << "Cannot find charger" << chargerId;
+ continue;
+ }
+ qCDebug(dcEasee()) << "SignalR data point:" << dataId << value;
+
+ switch (dataId) {
+ case ObservationPointTotalPower:
+ charger->setStateValue(chargerCurrentPowerStateTypeId, value.toDouble() * 1000);
+ break;
+ case ObservationPointSessionEnergy:
+ charger->setStateValue(chargerSessionEnergyStateTypeId, value.toDouble());
+ break;
+ case ObservationPointLifetimeEnergy:
+ charger->setStateValue(chargerTotalEnergyConsumedStateTypeId, value.toDouble());
+ break;
+ case ObservationPointWiFiRSSI:
+ charger->setStateValue(chargerSignalStrengthStateTypeId, qMin(100, qMax(0, ((value.toInt() + 100) * 2))));
+ break;
+ case ObservationPointPilotMode: {
+ QString mode = value.toString();
+ qCDebug(dcEasee()) << "CP mode:" << mode;
+ if (mode == "A") {
+ charger->setStateValue(chargerPluggedInStateTypeId, false);
+ } else if (mode == "B" || mode == "C") {
+ charger->setStateValue(chargerPluggedInStateTypeId, true);
+ } else {
+ }
+ break;
+ }
+ case ObservationPointOutputPhase:
+ charger->setStateValue(chargerPhaseCountStateTypeId, value.toUInt() > 10 ? 3 : 1);
+ break;
+ case ObservationPointChargerOpMode:
+ // 2: charging disabled, 3: enabled and charging, 4: enabled but not charging
+ charger->setStateValue(chargerChargingStateTypeId, value.toUInt() == 3);
+ charger->setStateValue(chargerPowerStateTypeId, value.toUInt() >= 3);
+ break;
+ case ObservationPointDynamicChargerCurrent:
+ charger->setStateValue(chargerMaxChargingCurrentStateTypeId, value.toUInt());
+ break;
+ case ObservationPointMaxChargerCurrent:
+ charger->setStateMaxValue(chargerMaxChargingCurrentStateTypeId, value.toUInt());
+ break;
+
+ default:
+ break;
+ }
+ }
+ });
+ }
+}
+
+void IntegrationPluginEasee::thingRemoved(Thing *thing)
+{
+ pluginStorage()->beginGroup(thing->id().toString());
+ pluginStorage()->remove("");
+ pluginStorage()->endGroup();
+
+ if (myThings().isEmpty()) {
+ hardwareManager()->pluginTimerManager()->unregisterTimer(m_timer);
+ m_timer = nullptr;
+ }
+}
+
+void IntegrationPluginEasee::executeAction(ThingActionInfo *info)
+{
+ Thing *thing = info->thing();
+ if (thing->thingClassId() == chargerThingClassId) {
+ Thing *parentThing = myThings().findById(thing->parentId());
+ QString chargerId = thing->paramValue(chargerThingIdParamTypeId).toString();
+ if (info->action().actionTypeId() == chargerPowerActionTypeId) {
+ bool power = info->action().paramValue(chargerPowerActionPowerParamTypeId).toBool();
+ QString actionPath = power ? "start_charging" : "stop_charging";
+ QNetworkRequest request = createRequest(parentThing, QString("chargers/%1/commands/%2").arg(chargerId).arg(actionPath));
+ qCDebug(dcEasee()) << "Setting power:" << request.url().toString();
+ QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QByteArray());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, info, [reply, info, power](){
+ qCDebug(dcEasee()) << "Reply" << reply->error();
+ if (reply->error() == QNetworkReply::NoError) {
+ info->thing()->setStateValue(chargerPowerStateTypeId, power);
+ }
+ info->finish(reply->error() == QNetworkReply::NoError ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure);
+ });
+ return;
+ }
+ if (info->action().actionTypeId() == chargerMaxChargingCurrentActionTypeId) {
+ uint maxChargingCurrent = info->action().paramValue(chargerMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toUInt();
+ QNetworkRequest request = createRequest(parentThing, QString("chargers/%1/settings").arg(chargerId));
+ QVariantMap data;
+ data.insert("dynamicChargerCurrent", maxChargingCurrent);
+ qCDebug(dcEasee()) << "Setting max current:" << request.url().toString() << QJsonDocument::fromVariant(data).toJson();
+ QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(data).toJson(QJsonDocument::Compact));
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, info, [reply, info, maxChargingCurrent](){
+ qCDebug(dcEasee()) << "Reply" << reply->error();
+ if (reply->error() == QNetworkReply::NoError) {
+ info->thing()->setStateValue(chargerMaxChargingCurrentStateTypeId, maxChargingCurrent);
+ }
+ info->finish(reply->error() == QNetworkReply::NoError ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure);
+ });
+ return;
+ }
+ if (info->action().actionTypeId() == chargerDesiredPhaseCountActionTypeId) {
+ uint desiredPhaseCount = info->action().paramValue(chargerMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toUInt();
+ QNetworkRequest request = createRequest(parentThing, QString("chargers/%1/settings").arg(chargerId));
+ QVariantMap data;
+ data.insert("lockToSinglePhaseCharging", desiredPhaseCount == 1);
+ qCDebug(dcEasee()) << "Setting single phase charging:" << request.url().toString() << QJsonDocument::fromVariant(data).toJson();
+ QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(data).toJson(QJsonDocument::Compact));
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, info, [reply, info, desiredPhaseCount](){
+ qCDebug(dcEasee()) << "Reply" << reply->error();
+ if (reply->error() == QNetworkReply::NoError) {
+ info->thing()->setStateValue(chargerDesiredPhaseCountStateTypeId, desiredPhaseCount);
+ }
+ info->finish(reply->error() == QNetworkReply::NoError ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure);
+ });
+ return;
+ }
+
+ }
+ info->finish(Thing::ThingErrorNoError);
+}
+
+QNetworkRequest IntegrationPluginEasee::createRequest(Thing *thing, const QString &endpoint)
+{
+ pluginStorage()->beginGroup(thing->id().toString());
+ QByteArray accessToken = pluginStorage()->value("accessToken").toByteArray();
+ pluginStorage()->endGroup();
+ QNetworkRequest request(QUrl(QString("https://api.easee.cloud/api/%1").arg(endpoint)));
+ request.setRawHeader("Authorization", "Bearer " + accessToken);
+ request.setRawHeader("accept", "application/json");
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/*+json");
+ return request;
+}
+
+QNetworkReply *IntegrationPluginEasee::refreshToken(Thing *thing)
+{
+ pluginStorage()->beginGroup(thing->id().toString());
+ QByteArray refreshToken = pluginStorage()->value("refreshToken").toByteArray();
+ QByteArray accessToken = pluginStorage()->value("accessToken").toByteArray();
+ QString username = pluginStorage()->value("username").toString();
+ QString password = pluginStorage()->value("password").toString();
+ pluginStorage()->endGroup();
+
+
+ // FIXME: Ideally we should use the refresh_token API and not store user/pass in the config, but it seems to not work
+// QNetworkRequest request(QUrl(QString("https://api.easee.cloud/api/accounts/refresh_token")));
+// request.setRawHeader("accept", "application/json");
+// request.setRawHeader("content-type", "application/*+json");
+// QVariantMap body;
+// body.insert("refreshToken", refreshToken);
+// body.insert("accessToken", accessToken);
+
+ QNetworkRequest request(QUrl(QString("https://api.easee.cloud/api/accounts/login")));
+ request.setRawHeader("accept", "application/json");
+ request.setRawHeader("content-type", "application/*+json");
+ QVariantMap body;
+ body.insert("userName", username);
+ body.insert("password", password);
+
+
+ QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(body).toJson(QJsonDocument::Compact));
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, thing, [=](){
+ qCDebug(dcEasee) << "Token refresh finished" << reply->error();
+ if (reply->error() != QNetworkReply::NoError) {
+ qCWarning(dcEasee) << "Unable to contact easee server...";
+ return;
+ }
+
+ QByteArray data = reply->readAll();
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcEasee) << "Unable to parse json:" << error.errorString() << data;
+ return;
+ }
+
+ QVariantMap map = jsonDoc.toVariant().toMap();
+ qCDebug(dcEasee) << "Token refresh reply:" << data;
+
+ QByteArray accessToken = map.value("accessToken").toByteArray();
+ int expiresIn = map.value("expiresIn").toInt();
+ QByteArray refreshToken = map.value("refreshToken").toByteArray();
+
+ pluginStorage()->beginGroup(thing->id().toString());
+ pluginStorage()->setValue("accessToken", accessToken);
+ pluginStorage()->setValue("expiry", QDateTime::currentDateTime().addSecs(expiresIn));
+ pluginStorage()->setValue("refreshToken", refreshToken);
+ pluginStorage()->endGroup();
+
+ m_signalRConnections.value(thing)->updateToken(accessToken);
+
+ });
+
+ return reply;
+}
+
+void IntegrationPluginEasee::refreshProducts(Thing *account)
+{
+ QNetworkRequest request = createRequest(account, "accounts/products");
+ QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, account, [this, account, reply](){
+ if (reply->error() != QNetworkReply::NoError) {
+ qCWarning(dcEasee) << "Unable to refresh products:" << reply->error() << reply->errorString();
+ return;
+ }
+
+ QByteArray data = reply->readAll();
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcEasee) << "Unable to parse json for products:" << error.errorString() << data;
+ return;
+ }
+
+ QVariantList list = jsonDoc.toVariant().toList();
+ qCDebug(dcEasee) << "Products reply:" << qUtf8Printable(data);
+
+ foreach (const QVariant &siteVariant, list) {
+ QVariantMap site = siteVariant.toMap();
+ foreach (const QVariant &circuitVariant, site.value("circuits").toList()) {
+ QVariantMap circuit = circuitVariant.toMap();
+// double maxChartingCurrentLimit = circuit.value("ratedCurrent").toDouble();
+ uint circuitId = circuit.value("id").toUInt();
+ uint siteId = circuit.value("siteId").toUInt();
+ foreach (const QVariant &chargerVariant, circuit.value("chargers").toList()) {
+ QVariantMap charger = chargerVariant.toMap();
+ QString id = charger.value("id").toString();
+ QString name = charger.value("name").toString();
+
+ ParamList params{Param(chargerThingIdParamTypeId, id)};
+ Thing *existingThing = myThings().filterByParentId(account->id()).findByParams(params);
+ if (!existingThing) {
+ ThingDescriptor descriptor(chargerThingClassId, name, QString(), account->id());
+ descriptor.setParams(params);
+ emit autoThingsAppeared({descriptor});
+ }
+ m_siteIds[id] = siteId;
+ m_circuitIds[id] = circuitId;
+ }
+ }
+ }
+ });
+
+}
+
+void IntegrationPluginEasee::refreshCurrentState(Thing *charger)
+{
+ Thing *parentThing = myThings().findById(charger->parentId());
+ QString chargerId = charger->paramValue(chargerThingIdParamTypeId).toString();
+ QNetworkRequest request = createRequest(parentThing, QString("chargers/%1/state").arg(chargerId));
+
+ QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, charger, [charger, reply](){
+ if (reply->error() != QNetworkReply::NoError) {
+ qCWarning(dcEasee) << "Unable to fetch charger state:" << reply->error() << reply->errorString();
+ return;
+ }
+
+ QByteArray data = reply->readAll();
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcEasee) << "Unable to parse json for charger state:" << error.errorString() << data;
+ return;
+ }
+
+ QVariantMap map = jsonDoc.toVariant().toMap();
+ qCDebug(dcEasee) << "Charger state reply:" << qUtf8Printable(jsonDoc.toJson());
+ charger->setStateValue(chargerConnectedStateTypeId, map.value("isOnline").toBool());
+ charger->setStateValue(chargerSignalStrengthStateTypeId, qMax(0, qMin(100, (map.value("wiFiRSSI").toInt() + 100) * 2)));
+ charger->setStateValue(chargerCurrentPowerStateTypeId, map.value("totalPower").toDouble() * 1000);
+ charger->setStateValue(chargerPhaseCountStateTypeId, map.value("outputPhase").toUInt() > 10 ? 3 : 1);
+ charger->setStateValue(chargerChargingStateTypeId, map.value("chargerOpMode").toUInt() == 3);
+
+ // 1: unplugged, 2: charging disabled, 3: enabled and charging, 4: enabled but not charging
+ uint chargerOpMode = map.value("chargerOpMode").toUInt();
+ charger->setStateValue(chargerPluggedInStateTypeId, chargerOpMode >= 2);
+ charger->setStateValue(chargerChargingStateTypeId, chargerOpMode == 3);
+ charger->setStateValue(chargerPowerStateTypeId, chargerOpMode >= 3);
+
+ charger->setStateValue(chargerMaxChargingCurrentStateTypeId, map.value("dynamicChargerCurrent").toUInt());
+ charger->setStateMaxValue(chargerMaxChargingCurrentStateTypeId, 6); // Fixme: where to get this from?
+ charger->setStateMaxValue(chargerMaxChargingCurrentStateTypeId, 32); // Fixme: where to get this from?
+
+ charger->setStateValue(chargerTotalEnergyConsumedStateTypeId, map.value("lifetimeEnergy").toDouble());
+ charger->setStateValue(chargerSessionEnergyStateTypeId, map.value("sessionEnergy").toDouble());
+
+ });
+
+
+}
+
diff --git a/easee/integrationplugineasee.h b/easee/integrationplugineasee.h
new file mode 100644
index 00000000..cca914fb
--- /dev/null
+++ b/easee/integrationplugineasee.h
@@ -0,0 +1,242 @@
+/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+*
+* Copyright 2013 - 2023, 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 INTEGRATIONPLUGINEASEE_H
+#define INTEGRATIONPLUGINEASEE_H
+
+#include
+#include
+
+#include "extern-plugininfo.h"
+
+#include
+#include
+#include
+#include
+
+class SignalRConnection;
+
+class IntegrationPluginEasee: public IntegrationPlugin
+{
+ Q_OBJECT
+
+ Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationplugineasee.json")
+ Q_INTERFACES(IntegrationPlugin)
+
+public:
+ enum ObservationPoint {
+ ObservationPointSelfTestResult = 1,
+ ObservationPointSelfTestDetails = 2,
+ ObservationPointWiFiEvent = 10,
+ ObservationPointChargerOfflineReason = 11,
+ ObservationPointEaseeLinkCommandResponse = 13,
+ ObservationPointEaseeLinkDataReceived = 14,
+ ObservationPointLocalPreAuthorizeEnabled = 15,
+ ObservationPointLocalAuthorizeOfflineEnabled = 16,
+ ObservationPointAllowOfflineTxForUnknownId = 17,
+ ObservationPointErraticEvMaxToggles = 18,
+ ObservationPointBackplateType = 19,
+ ObservationPointSiteStructure = 20,
+ ObservationPointDetectedPowerGridType = 21,
+ ObservationPointCircuitMaxCurrentP1 = 22,
+ ObservationPointCircuitMaxCurrentP2 = 23,
+ ObservationPointCircuitMaxCurrentP3 = 24,
+ ObservationPointLocation = 25,
+ ObservationPointSiteIdString = 26,
+ ObservationPointSiteIdNumeric = 27,
+ ObservationPointLockCablePermanently = 30,
+ ObservationPointIsEnabled = 31,
+ ObservationPointCircuitSequenceNumber = 33,
+ ObservationPointSinglePhaseNumber = 34,
+ ObservationPointEnable3PhasesDeprecated = 35,
+ ObservationPointWiFiSSID = 36,
+ ObservationPointEnableIdCurrent = 37,
+ ObservationPointPhaseMode = 38,
+ ObservationPointForced3PhaseOnITWithGndFault = 39,
+ ObservationPointLedStripBrightness = 40,
+ ObservationPointLocalAuthorizationRequired = 41,
+ ObservationPointAuthorizationRequired = 42,
+ ObservationPointRemoteStartRequired = 43,
+ ObservationPointSmartButtonEnabled = 44,
+ ObservationPointOfflineChargingMode = 45,
+ ObservationPointLedMode = 46,
+ ObservationPointMaxChargerCurrent = 47,
+ ObservationPointDynamicChargerCurrent = 48,
+ ObservationPointMaxCurrentOfflineFallbackP1 = 50,
+ ObservationPointMaxCurrentOfflineFallbackP2 = 51,
+ ObservationPointMaxCurrentOfflineFallbackP3 = 52,
+ ObservationPointListenToControlPulse = 56,
+ ObservationPointControlPulseRTT = 57,
+ ObservationPointChargingSchedule = 62,
+ ObservationPointPairedEqualizer = 65,
+ ObservationPointWiFiApEnabled = 68,
+ ObservationPointPairedUserIdToken = 69,
+ ObservationPointCircuitTotalAllocatedPhaseConductorCurrentL1 = 70,
+ ObservationPointCircuitTotalAllocatedPhaseConductorCurrentL2 = 71,
+ ObservationPointCircuitTotalAllocatedPhaseConductorCurrentL3 = 72,
+ ObservationPointCircuitAllocatedPhaseConductorCurrentL1 = 73,
+ ObservationPointCircuitAllocatedPhaseConductorCurrentL2 = 74,
+ ObservationPointCircuitAllocatedPhaseConductorCurrentL3 = 75,
+ ObservationPointNumberOfCarsConnected = 76,
+ ObservationPointNumberOfCarsCharging = 77,
+ ObservationPointNumberOfCarsInQueue = 78,
+ ObservationPointNumberOfCarsFullyCharged = 79,
+ ObservationPointSoftwareRelease = 80,
+ ObservationPointICCID = 81,
+ ObservationPointModemFwId = 82,
+ ObservationPointOTAErrorCode = 83,
+ ObservationPointMobileNetworkOperator = 84,
+ ObservationPointRebootReason = 89,
+ ObservationPointPowerPCBVersion = 90,
+ ObservationPointCOMPCBVersion = 91,
+ ObservationPointReasonForNoCurrent = 96,
+ ObservationPointLoadBalancingNumberOfConnectedCharger = 97,
+ ObservationPointUDPNumOfConnectedNodes = 98,
+ ObservationPointLocalConnection = 99,
+ ObservationPointPilotMode = 100,
+ ObservationPointCarConnectedDeprecated = 101,
+ ObservationPointSmartCharging = 102,
+ ObservationPointCableLocked = 103,
+ ObservationPointCableRating = 104,
+ ObservationPointPilotHigh = 105,
+ ObservationPointPilotLow = 106,
+ ObservationPointBackPlateId = 107,
+ ObservationPointUserIdTokenReversed = 108,
+ ObservationPointChargerOpMode = 109,
+ ObservationPointOutputPhase = 110,
+ ObservationPointDynamicCircuitCurrentP1 = 111,
+ ObservationPointDynamicCircuitCurrentP2 = 112,
+ ObservationPointDynamicCircuitCurrentP3 = 113,
+ ObservationPointOutputCurrent = 114,
+ ObservationPointDeratedCurrent = 115,
+ ObservationPointDeratingActive = 116,
+ ObservationPointDebugString = 117,
+ ObservationPointErrorString = 118,
+ ObservationPointErrorCode = 119,
+ ObservationPointTotalPower = 120,
+ ObservationPointSessionEnergy = 121,
+ ObservationPointEnergyPerHour = 122,
+ ObservationPointLegacyEVStatus = 123,
+ ObservationPointLifetimeEnergy = 124,
+ ObservationPointLifetimeRelaySwitches = 125,
+ ObservationPointLifetimeHours = 126,
+ ObservationPointDynamicCurrentOfflineFallbackDeprecated = 127,
+ ObservationPointUserIdToken = 128,
+ ObservationPointChargingSession = 129,
+ ObservationPointCellRSSI = 130,
+ ObservationPointCellRAT = 131,
+ ObservationPointWiFiRSSI = 132,
+ ObservationPointCellAddress = 133,
+ ObservationPointWiFiAddress = 134,
+ ObservationPointWiFiType = 135,
+ ObservationPointLocalRSSI = 136,
+ ObservationPointMasterBackplateId = 137,
+ ObservationPointLocalTXPower = 138,
+ ObservationPointLocalState = 139,
+ ObservationPointFoundWiFi = 140,
+ ObservationPointChargerRAT = 141,
+ ObservationPointCellularInterfaceErrorCount = 142,
+ ObservationPointCellularInterfaceResetCount = 143,
+ ObservationPointWiFiInterfaceErrorCount = 144,
+ ObservationPointWiFiInterfaceResetCount = 145,
+ ObservationPointLocalNodeType = 146,
+ ObservationPointLocalRadioChannel = 147,
+ ObservationPointLocalShortAddress = 148,
+ ObservationPointLocalParentAddrOrNumOfNodes = 149,
+ ObservationPointTempMax = 150,
+ ObservationPointTempAmbientPowerBoard = 151,
+ ObservationPointTempInputT2 = 152,
+ ObservationPointTempInputT3 = 153,
+ ObservationPointTempInputT4 = 154,
+ ObservationPointTempInputT5 = 155,
+ ObservationPointTempOutputN = 160,
+ ObservationPointTempOutputL1 = 161,
+ ObservationPointTempOutputL2 = 162,
+ ObservationPointTempOutputL3 = 163,
+ ObservationPointTempAmbient = 170,
+ ObservationPointLightAmbient = 171,
+ ObservationPointIntRelHumidity = 172,
+ ObservationPointBackplateLocked = 173,
+ ObservationPointCurrentMotor = 174,
+ ObservationPointBackplateHallSensor = 175,
+ ObservationPointInCurrentT2 = 182,
+ ObservationPointInCurrentT3 = 183,
+ ObservationPointInCurrentT4 = 184,
+ ObservationPointInCurrentT5 = 185,
+ ObservationPointInVoltT1T2 = 190,
+ ObservationPointInVoltT1T3 = 191,
+ ObservationPointInVoltT1T4 = 192,
+ ObservationPointInVoltT1T5 = 193,
+ ObservationPointInVoltT2T3 = 194,
+ ObservationPointInVoltT2T4 = 195,
+ ObservationPointInVoltT2T5 = 196,
+ ObservationPointInVoltT3T4 = 197,
+ ObservationPointInVoltT3T5 = 198,
+ ObservationPointInVoltT4T5 = 199,
+ ObservationPointOutVoltPin12 = 202,
+ ObservationPointOutVoltPin13 = 203,
+ ObservationPointOutVoltPin14 = 204,
+ ObservationPointOutVoltPin15 = 205,
+ ObservationPointVoltLevel33 = 210,
+ ObservationPointVoltLevel5 = 211,
+ ObservationPointVoltLevel12 = 212,
+ ObservationPointLTERSRP = 220,
+ ObservationPointLTESINR = 221,
+ ObservationPointLTERSRQ = 222,
+ ObservationPointEQAvailableCurrentP1 = 230,
+ ObservationPointEQAvailableCurrentP2 = 231,
+ ObservationPointEQAvailableCurrentP3 = 232
+ };
+ Q_ENUM(ObservationPoint)
+
+ explicit IntegrationPluginEasee();
+ ~IntegrationPluginEasee();
+
+// void startPairing(ThingPairingInfo *info) override;
+ void confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret) override;
+ void setupThing(ThingSetupInfo *info) override;
+ void postSetupThing(Thing *thing) override;
+ void thingRemoved(Thing *thing) override;
+ void executeAction(ThingActionInfo *info) override;
+
+private:
+ QNetworkRequest createRequest(Thing *thing, const QString &endpoint);
+ QNetworkReply *refreshToken(Thing *thing);
+ void refreshProducts(Thing *account);
+ void refreshCurrentState(Thing *charger);
+
+ QHash m_signalRConnections;
+ QHash m_circuitIds; // chargerId, circuitId
+ QHash m_siteIds; // chargerId, siteId
+
+ PluginTimer *m_timer = nullptr;
+};
+
+#endif // INTEGRATIONPLUGINEASEE_H
diff --git a/easee/integrationplugineasee.json b/easee/integrationplugineasee.json
new file mode 100644
index 00000000..5f9a2943
--- /dev/null
+++ b/easee/integrationplugineasee.json
@@ -0,0 +1,155 @@
+{
+ "name": "easee",
+ "displayName": "Easee",
+ "id": "471aa296-78de-4917-84ed-c9a4216f5ae9",
+ "vendors": [
+ {
+ "name": "easee",
+ "displayName": "Easee",
+ "id": "e43f66da-4e8c-4e2c-b821-c1eb8ac6bbf8",
+ "thingClasses": [
+ {
+ "id": "2c93c25e-d12a-4709-b537-b5619ab1145a",
+ "name": "account",
+ "displayName": "Easee account",
+ "createMethods": ["user"],
+ "setupMethod": "userandpassword",
+ "interfaces": [ "account" ],
+ "providedInterfaces": ["evcharger"],
+ "stateTypes": [
+ {
+ "id": "568d5896-813e-4f66-b430-ed8c8b1ad8c4",
+ "name": "connected",
+ "displayName": "Connected",
+ "type": "bool",
+ "defaultValue": false,
+ "cached": false
+ },
+ {
+ "id": "69d01e58-c1a3-4b9c-a4b3-f1de7c67febd",
+ "name": "loggedIn",
+ "displayName": "Logged in",
+ "type": "bool",
+ "defaultValue": false
+ }
+ ]
+ },
+ {
+ "id": "9fe4d708-70d2-47bd-8433-54c5f3e8a110",
+ "name": "charger",
+ "displayName": "Easee charger",
+ "createMethods": ["auto"],
+ "interfaces": ["evcharger", "smartmeterconsumer", "wirelessconnectable"],
+ "paramTypes": [
+ {
+ "id": "b9f0573e-bf41-45f0-a53e-b2457d51ecb5",
+ "name": "id",
+ "displayName": "Charger ID",
+ "type": "QString"
+ }
+ ],
+ "stateTypes": [
+ {
+ "id": "1c818574-5aa8-43f5-8fa5-9a620ba86ccc",
+ "name": "maxChargingCurrent",
+ "displayName": "Maximum charging current",
+ "displayNameAction": "Set maximum charging current",
+ "type": "uint",
+ "unit": "Ampere",
+ "minValue": 6,
+ "maxValue": 16,
+ "defaultValue": 6,
+ "writable": true
+ },
+ {
+ "id": "b5070800-3556-4156-aed5-ecbad7fa455d",
+ "name": "power",
+ "displayName": "Power",
+ "displayNameAction": "Set power",
+ "type": "bool",
+ "defaultValue": false,
+ "writable": true
+ },
+ {
+ "id": "6b0c22e9-cdb4-49a0-9c18-97b776b63c59",
+ "name": "currentPower",
+ "displayName": "Current charting power",
+ "type": "double",
+ "unit": "Watt",
+ "defaultValue": 0,
+ "cached": false
+ },
+ {
+ "id": "0ac13ef2-e646-491f-9336-f39d5110f8bb",
+ "name": "pluggedIn",
+ "displayName": "Plugged in",
+ "type": "bool",
+ "defaultValue": false
+ },
+ {
+ "id": "d95137ad-4f32-4ffd-851b-593fc40f5c0c",
+ "name": "charging",
+ "displayName": "Charging",
+ "type": "bool",
+ "defaultValue": false
+ },
+ {
+ "id": "b55297a8-f1bd-44eb-8309-691d674ef4a0",
+ "name": "totalEnergyConsumed",
+ "displayName": "Total energy consumed",
+ "type": "double",
+ "unit": "KiloWattHour",
+ "defaultValue": 0
+ },
+ {
+ "id": "2f0c3491-d524-4ee5-8214-8e3d2f750b66",
+ "name": "sessionEnergy",
+ "displayName": "Session energy",
+ "type": "double",
+ "unit": "KiloWattHour",
+ "defaultValue": 0
+ },
+ {
+ "id": "3a68d6c5-d48f-4cd6-b9ca-fc21cbcc8808",
+ "name": "phaseCount",
+ "displayName": "Used phases",
+ "type": "uint",
+ "minValue": 1,
+ "maxValue": 3,
+ "defaultValue": 1
+ },
+ {
+ "id": "1baac39e-0e0e-4638-bd8d-09f04fd7bd62",
+ "name": "desiredPhaseCount",
+ "displayName": "Desired phase count",
+ "displayNameAction": "Set desired phase count",
+ "type": "uint",
+ "minValue": 1,
+ "maxValue": 3,
+ "possibleValues": [1,3],
+ "writable": true,
+ "defaultValue": 3
+ },
+ {
+ "id": "2a9b4c84-7b1d-4b32-b26f-84eff54fb04f",
+ "name": "connected",
+ "displayName": "Online",
+ "type": "bool",
+ "defaultValue": false
+ },
+ {
+ "id": "7effe12e-2884-4597-ad0c-6b4aa9cdacfd",
+ "name": "signalStrength",
+ "displayName": "Signal strength",
+ "type": "uint",
+ "unit": "Percentage",
+ "minValue": 0,
+ "maxValue": 100,
+ "defaultValue": false
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/easee/meta.json b/easee/meta.json
new file mode 100644
index 00000000..565dadde
--- /dev/null
+++ b/easee/meta.json
@@ -0,0 +1,13 @@
+{
+ "title": "Easeex",
+ "tagline": "Integrates Easee wallboxes with nymea.",
+ "icon": "easee.png",
+ "stability": "consumer",
+ "offline": false,
+ "technologies": [
+ "cloud"
+ ],
+ "categories": [
+ "energy"
+ ]
+}
diff --git a/easee/signalrconnection.cpp b/easee/signalrconnection.cpp
new file mode 100644
index 00000000..886741c1
--- /dev/null
+++ b/easee/signalrconnection.cpp
@@ -0,0 +1,159 @@
+#include "signalrconnection.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "extern-plugininfo.h"
+
+SignalRConnection::SignalRConnection(const QUrl &url, const QByteArray &accessToken, NetworkAccessManager *nam, QObject *parent)
+ : QObject{parent},
+ m_url(url),
+ m_accessToken(accessToken),
+ m_nam(nam)
+{
+ m_socket = new QWebSocket();
+ typedef void (QWebSocket:: *errorSignal)(QAbstractSocket::SocketError);
+ connect(m_socket, static_cast(&QWebSocket::error), this, [](QAbstractSocket::SocketError error){
+ qCWarning(dcEasee) << "Error in websocket:" << error;
+ });
+ connect(m_socket, &QWebSocket::stateChanged, this, [=](QAbstractSocket::SocketState state){
+ qCDebug(dcEasee) << "Websocket state changed" << state;
+
+ if (state == QAbstractSocket::ConnectedState) {
+ qCDebug(dcEasee) << "Websocket connected";
+
+ QVariantMap handshake;
+ handshake.insert("protocol", "json");
+ handshake.insert("version", 1);
+ QByteArray data = encode(handshake);
+ qCDebug(dcEasee) << "Sending handshake" << data;
+ m_socket->sendTextMessage(data);
+ } else if (QAbstractSocket::UnconnectedState) {
+ QTimer::singleShot(5000, this, [=](){
+ connectToHost();
+ });
+ }
+
+ });
+ connect(m_socket, &QWebSocket::binaryMessageReceived, this, [](const QByteArray &message){
+ qCDebug(dcEasee) << "Binary message received" << message;
+ });
+ connect(m_socket, &QWebSocket::textMessageReceived, this, [=](const QString &message){
+ QStringList messages = message.split(QByteArray::fromHex("1E"));
+
+ foreach (const QString &msg, messages) {
+ if (msg.isEmpty()) {
+ continue;
+ }
+// qCDebug(dcEasee()) << "Received message:" << msg;
+
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(msg.toUtf8(), &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcEasee()) << "Unable to parse message from SignalR socket" << error.errorString() << msg;
+ continue;
+ }
+
+ if (m_waitingForHandshakeReply && jsonDoc.toVariant().toMap().isEmpty()) {
+ m_waitingForHandshakeReply = false;
+ qCDebug(dcEasee()) << "Handshake reply received.";
+ emit connectionStateChanged(true);
+ return;
+ }
+
+ QVariantMap map = jsonDoc.toVariant().toMap();
+ switch (map.value("type").toUInt()) {
+ case 1:
+ emit dataReceived(map);
+ break;
+ case 3:
+ // Silencing acks to our requests
+ qCDebug(dcEasee()) << "Message ACK received:" << map;
+ case 6:
+ // Silencing pings
+ break;
+ default:
+ qCWarning(dcEasee()) << "Unhandled signalr message type" << map;
+ }
+ }
+ });
+
+ connectToHost();
+}
+
+void SignalRConnection::subscribe(const QString &chargerId)
+{
+ QVariantMap map;
+ map.insert("type", 1);
+ map.insert("invocationId", QUuid::createUuid());
+ map.insert("target", "SubscribeWithCurrentState");
+ map.insert("arguments", QVariantList{chargerId, true});
+ qCDebug(dcEasee) << "subscribing to" << chargerId;
+ m_socket->sendTextMessage(encode(map));
+}
+
+bool SignalRConnection::connected() const
+{
+ return m_socket->state() == QAbstractSocket::ConnectedState;
+}
+
+void SignalRConnection::updateToken(const QByteArray &accessToken)
+{
+ m_accessToken = accessToken;
+}
+
+QByteArray SignalRConnection::encode(const QVariantMap &message)
+{
+ return QJsonDocument::fromVariant(message).toJson(QJsonDocument::Compact).append(QByteArray::fromHex("1E"));
+}
+
+void SignalRConnection::connectToHost()
+{
+ QUrl negotiationUrl = m_url;
+ negotiationUrl.setScheme("https");
+ negotiationUrl.setPath(negotiationUrl.path() + "/negotiate");
+ QNetworkRequest negotiateRequest(negotiationUrl);
+ negotiateRequest.setRawHeader("Authorization", "Bearer " + m_accessToken);
+ qCDebug(dcEasee()) << "Negotiating:" << negotiationUrl << negotiateRequest.rawHeader("Authorization");
+ QNetworkReply *negotiantionReply = m_nam->post(negotiateRequest, QByteArray());
+ connect(negotiantionReply, &QNetworkReply::finished, this, [=](){
+ if (negotiantionReply->error() != QNetworkReply::NoError) {
+ qCWarning(dcEasee()) << "Unable to neotiate SignalR channel:" << negotiantionReply->error();
+ return;
+ }
+ QByteArray data = negotiantionReply->readAll();
+ qCDebug(dcEasee) << "Negotiation reply" << data;
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcEasee()) << "Unable to parse json from negoatiate endpoint" << error.errorString() << data;
+ return;
+ }
+
+ QVariantMap map = jsonDoc.toVariant().toMap();
+ QString connectionId = map.value("connectionId").toString();
+
+
+ QUrl wsUrl = m_url;
+ wsUrl.setScheme("wss");
+ QUrlQuery query;
+ query.addQueryItem("id", connectionId);
+ wsUrl.setQuery(query);
+ QNetworkRequest request(wsUrl);
+ request.setRawHeader("Authorization", "Bearer " + m_accessToken);
+ qCDebug(dcEasee()) << "Connecting websocket:" << wsUrl.toString();
+ m_waitingForHandshakeReply = true;
+#if QT_VERSION >= QT_VERSION_CHECK(5,6,0)
+ m_socket->open(request);
+#else
+ qCWarning(dcEasee()) << "This plugin requires at least Qt 5.6 to establish a signal R connection. Updating values won't work.";
+#endif
+
+ });
+
+}
+
diff --git a/easee/signalrconnection.h b/easee/signalrconnection.h
new file mode 100644
index 00000000..19ef1c11
--- /dev/null
+++ b/easee/signalrconnection.h
@@ -0,0 +1,39 @@
+#ifndef SIGNALRCONNECTION_H
+#define SIGNALRCONNECTION_H
+
+#include
+#include
+#include
+#include
+
+class SignalRConnection : public QObject
+{
+ Q_OBJECT
+public:
+ explicit SignalRConnection(const QUrl &url, const QByteArray &accessToken, NetworkAccessManager *nam, QObject *parent = nullptr);
+
+ void subscribe(const QString &chargerId);
+ bool connected() const;
+
+ void updateToken(const QByteArray &accessToken);
+
+signals:
+ void connectionStateChanged(bool connected);
+ void dataReceived(const QVariantMap &data);
+
+private:
+ QByteArray encode(const QVariantMap &message);
+
+private slots:
+ void connectToHost();
+
+private:
+ QUrl m_url;
+ QByteArray m_accessToken;
+ NetworkAccessManager *m_nam = nullptr;
+ QWebSocket *m_socket = nullptr;
+
+ bool m_waitingForHandshakeReply = false;
+};
+
+#endif // SIGNALRCONNECTION_H
diff --git a/easee/translations/471aa296-78de-4917-84ed-c9a4216f5ae9-en_US.ts b/easee/translations/471aa296-78de-4917-84ed-c9a4216f5ae9-en_US.ts
new file mode 100644
index 00000000..35b6623e
--- /dev/null
+++ b/easee/translations/471aa296-78de-4917-84ed-c9a4216f5ae9-en_US.ts
@@ -0,0 +1,157 @@
+
+
+
+
+ IntegrationPluginEasee
+
+
+ Authentication failed. Please try again.
+
+
+
+
+ Unable to contact the easee server. Please try again later.
+
+
+
+
+ Unable to process the response from easee. Please try again later.
+
+
+
+
+ easee
+
+
+ Charger ID
+ The name of the ParamType (ThingClass: charger, Type: thing, ID: {b9f0573e-bf41-45f0-a53e-b2457d51ecb5})
+
+
+
+
+ Charging
+ The name of the StateType ({d95137ad-4f32-4ffd-851b-593fc40f5c0c}) of ThingClass charger
+
+
+
+
+ Connected
+ The name of the StateType ({568d5896-813e-4f66-b430-ed8c8b1ad8c4}) of ThingClass account
+
+
+
+
+ Current charting power
+ The name of the StateType ({6b0c22e9-cdb4-49a0-9c18-97b776b63c59}) of ThingClass charger
+
+
+
+
+
+ Desired phase count
+ The name of the ParamType (ThingClass: charger, ActionType: desiredPhaseCount, ID: {1baac39e-0e0e-4638-bd8d-09f04fd7bd62})
+----------
+The name of the StateType ({1baac39e-0e0e-4638-bd8d-09f04fd7bd62}) of ThingClass charger
+
+
+
+
+ Logged in
+ The name of the StateType ({69d01e58-c1a3-4b9c-a4b3-f1de7c67febd}) of ThingClass account
+
+
+
+
+
+ Maximum charging current
+ The name of the ParamType (ThingClass: charger, ActionType: maxChargingCurrent, ID: {1c818574-5aa8-43f5-8fa5-9a620ba86ccc})
+----------
+The name of the StateType ({1c818574-5aa8-43f5-8fa5-9a620ba86ccc}) of ThingClass charger
+
+
+
+
+ Online
+ The name of the StateType ({2a9b4c84-7b1d-4b32-b26f-84eff54fb04f}) of ThingClass charger
+
+
+
+
+ Plugged in
+ The name of the StateType ({0ac13ef2-e646-491f-9336-f39d5110f8bb}) of ThingClass charger
+
+
+
+
+
+ Power
+ The name of the ParamType (ThingClass: charger, ActionType: power, ID: {b5070800-3556-4156-aed5-ecbad7fa455d})
+----------
+The name of the StateType ({b5070800-3556-4156-aed5-ecbad7fa455d}) of ThingClass charger
+
+
+
+
+ Session energy
+ The name of the StateType ({2f0c3491-d524-4ee5-8214-8e3d2f750b66}) of ThingClass charger
+
+
+
+
+ Set desired phase count
+ The name of the ActionType ({1baac39e-0e0e-4638-bd8d-09f04fd7bd62}) of ThingClass charger
+
+
+
+
+ Set maximum charging current
+ The name of the ActionType ({1c818574-5aa8-43f5-8fa5-9a620ba86ccc}) of ThingClass charger
+
+
+
+
+ Set power
+ The name of the ActionType ({b5070800-3556-4156-aed5-ecbad7fa455d}) of ThingClass charger
+
+
+
+
+ Signal strength
+ The name of the StateType ({7effe12e-2884-4597-ad0c-6b4aa9cdacfd}) of ThingClass charger
+
+
+
+
+ Total energy consumed
+ The name of the StateType ({b55297a8-f1bd-44eb-8309-691d674ef4a0}) of ThingClass charger
+
+
+
+
+ Used phases
+ The name of the StateType ({3a68d6c5-d48f-4cd6-b9ca-fc21cbcc8808}) of ThingClass charger
+
+
+
+
+
+ easee
+ The name of the vendor ({e43f66da-4e8c-4e2c-b821-c1eb8ac6bbf8})
+----------
+The name of the plugin easee ({471aa296-78de-4917-84ed-c9a4216f5ae9})
+
+
+
+
+ easee account
+ The name of the ThingClass ({2c93c25e-d12a-4709-b537-b5619ab1145a})
+
+
+
+
+ easee charger
+ The name of the ThingClass ({9fe4d708-70d2-47bd-8433-54c5f3e8a110})
+
+
+
+
diff --git a/nymea-plugins.pro b/nymea-plugins.pro
index 94ca9d1e..969f2796 100644
--- a/nymea-plugins.pro
+++ b/nymea-plugins.pro
@@ -19,6 +19,7 @@ PLUGIN_DIRS = \
dht \
dweetio \
dynatrace \
+ easee \
elgato \
eq-3 \
espuino \