From 387b8362f46c9d24dd94bb6de26725f79b2e33df Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Sun, 19 Jun 2022 01:13:18 +0200 Subject: [PATCH 1/4] Shelly: Add support for the Shelly Plus 1 PM --- shelly/README.md | 1 + shelly/integrationpluginshelly.cpp | 183 ++++++++++++++++++++++++---- shelly/integrationpluginshelly.h | 13 +- shelly/integrationpluginshelly.json | 2 +- shelly/shelly.pro | 4 +- shelly/shellyjsonrpcclient.cpp | 86 +++++++++++++ shelly/shellyjsonrpcclient.h | 52 ++++++++ 7 files changed, 312 insertions(+), 29 deletions(-) create mode 100644 shelly/shellyjsonrpcclient.cpp create mode 100644 shelly/shellyjsonrpcclient.h diff --git a/shelly/README.md b/shelly/README.md index 3e52691e..d10ac304 100644 --- a/shelly/README.md +++ b/shelly/README.md @@ -5,6 +5,7 @@ The Shelly plugin adds support for Shelly devices (https://shelly.cloud). The currently supported devices are: * Shelly 1 * Shelly 1PM +* Shelly Plus 1PM * Shelly 1L * Shelly 2 * Shelly 2.5 diff --git a/shelly/integrationpluginshelly.cpp b/shelly/integrationpluginshelly.cpp index 0d61cb92..63651320 100644 --- a/shelly/integrationpluginshelly.cpp +++ b/shelly/integrationpluginshelly.cpp @@ -30,6 +30,7 @@ #include "integrationpluginshelly.h" #include "plugininfo.h" +#include "shellyjsonrpcclient.h" #include #include @@ -263,12 +264,12 @@ void IntegrationPluginShelly::init() void IntegrationPluginShelly::discoverThings(ThingDiscoveryInfo *info) { foreach (const ZeroConfServiceEntry &entry, m_zeroconfBrowser->serviceEntries()) { - // qCDebug(dcShelly()) << "Have entry" << entry; + qCDebug(dcShelly()) << "Have entry" << entry; QRegExp namePattern; if (info->thingClassId() == shelly1ThingClassId) { namePattern = QRegExp("^shelly1-[0-9A-Z]+$"); } else if (info->thingClassId() == shelly1pmThingClassId) { - namePattern = QRegExp("^shelly1pm-[0-9A-Z]+$"); + namePattern = QRegExp("^(shelly1pm|ShellyPlus1PM)-[0-9A-Z]+$"); } else if (info->thingClassId() == shelly1lThingClassId) { namePattern = QRegExp("^shelly1l-[0-9A-Z]+$"); } else if (info->thingClassId() == shellyPlugThingClassId) { @@ -298,8 +299,6 @@ void IntegrationPluginShelly::discoverThings(ThingDiscoveryInfo *info) continue; } - qCDebug(dcShelly()) << "Found shelly thing!" << entry; - ThingDescriptor descriptor(info->thingClassId(), entry.name(), entry.hostAddress().toString()); ParamList params; params << Param(idParamTypeMap.value(info->thingClassId()), entry.name()); @@ -312,8 +311,10 @@ void IntegrationPluginShelly::discoverThings(ThingDiscoveryInfo *info) Things existingThings = myThings().filterByParam(idParamTypeMap.value(info->thingClassId()), entry.name()); if (existingThings.count() == 1) { - qCDebug(dcShelly()) << "This shelly already exists in the system!"; + qCInfo(dcShelly()) << "This existing shelly:" << entry; descriptor.setThingId(existingThings.first()->id()); + } else { + qCInfo(dcShelly()) << "Found new shelly:" << entry; } info->addThingDescriptor(descriptor); @@ -327,7 +328,14 @@ void IntegrationPluginShelly::setupThing(ThingSetupInfo *info) Thing *thing = info->thing(); if (idParamTypeMap.contains(thing->thingClassId())) { - setupShellyGateway(info); + + QString shellyId = info->thing()->paramValue(idParamTypeMap.value(info->thing()->thingClassId())).toString(); + if (!shellyId.contains("Plus")) { + setupGen1(info); + } else { + setupGen2(info); + } + return; } @@ -342,7 +350,11 @@ void IntegrationPluginShelly::postSetupThing(Thing *thing) } if (thing->parentId().isNull()) { - fetchStatus(thing); + if (thing->paramValue("id").toString().contains("Plus")) { + fetchStatusGen2(thing); + } else { + fetchStatusGen1(thing); + } } } @@ -356,6 +368,9 @@ void IntegrationPluginShelly::thingRemoved(Thing *thing) hardwareManager()->pluginTimerManager()->unregisterTimer(m_reconfigureTimer); m_reconfigureTimer = nullptr; } + if (m_rpcClients.contains(thing)) { + m_rpcClients.remove(thing); // Deleted by parenting + } qCDebug(dcShelly()) << "Device removed" << thing->name(); } @@ -364,6 +379,7 @@ void IntegrationPluginShelly::executeAction(ThingActionInfo *info) // We'll always execute actions on the main gateway thing. If info->thing() has a parent, use that. Thing *thing = info->thing()->parentId().isNull() ? info->thing() : myThings().findById(info->thing()->parentId()); Action action = info->action(); + QString shellyId = thing->paramValue("id").toString(); QUrl url; url.setScheme("http"); @@ -372,15 +388,22 @@ void IntegrationPluginShelly::executeAction(ThingActionInfo *info) url.setPassword(thing->paramValue(passwordParamTypeMap.value(thing->thingClassId())).toString()); if (rebootActionTypeMap.contains(action.actionTypeId())) { - url.setPath("/reboot"); - QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, info, [info, reply](){ - if (reply->error() != QNetworkReply::NoError) { - qCWarning(dcShelly()) << "Failed to execute reboot action:" << reply->error() << reply->errorString(); - } - info->finish(reply->error() == QNetworkReply::NoError ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure); - }); + if (shellyId.contains("Plus")) { + ShellyRpcReply *reply = m_rpcClients.value(thing)->sendRequest("Shelly.Reboot"); + connect(reply, &ShellyRpcReply::finished, info, [info](ShellyRpcReply::Status status, const QVariantMap &/*response*/){ + info->finish(status == ShellyRpcReply::StatusSuccess ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure); + }); + } else { + url.setPath("/reboot"); + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, info, [info, reply](){ + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcShelly()) << "Failed to execute reboot action:" << reply->error() << reply->errorString(); + } + info->finish(reply->error() == QNetworkReply::NoError ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure); + }); + } return; } @@ -653,7 +676,7 @@ void IntegrationPluginShelly::onMulticastMessageReceived(const QHostAddress &sou QString shellyId = parts.at(1); Thing *thing = nullptr; foreach (Thing *t, myThings()) { - if (t->paramValue(idParamTypeMap.value(t->thingClassId())).toString().endsWith(shellyId)) { + if (t->paramValue("id").toString().endsWith(shellyId)) { thing = t; break; } @@ -989,11 +1012,15 @@ void IntegrationPluginShelly::onMulticastMessageReceived(const QHostAddress &sou void IntegrationPluginShelly::updateStatus() { foreach (Thing *thing, myThings().filterByParentId(ThingId())) { - fetchStatus(thing); + if (thing->paramValue("id").toString().contains("Plus")) { + fetchStatusGen2(thing); + } else { + fetchStatusGen1(thing); + } } } -void IntegrationPluginShelly::fetchStatus(Thing *thing) +void IntegrationPluginShelly::fetchStatusGen1(Thing *thing) { QUrl url; url.setScheme("http"); @@ -1044,11 +1071,37 @@ void IntegrationPluginShelly::fetchStatus(Thing *thing) }); } -void IntegrationPluginShelly::setupShellyGateway(ThingSetupInfo *info) +void IntegrationPluginShelly::fetchStatusGen2(Thing *thing) +{ + ShellyJsonRpcClient *client = m_rpcClients.value(thing); + ShellyRpcReply *statusReply = client->sendRequest("Shelly.GetStatus"); + connect(statusReply, &ShellyRpcReply::finished, thing, [thing, this](ShellyRpcReply::Status status, const QVariantMap &response){ + if (status != ShellyRpcReply::StatusSuccess) { + qCWarning(dcShelly()) << "Error updating status from shelly:" << status; + return; + } + int signalStrength = qMin(100, qMax(0, (response.value("wifi").toMap().value("rssi").toInt() + 100) * 2)); + thing->setStateValue("connected", true); + thing->setStateValue("signalStrength", signalStrength); + foreach (Thing *child, myThings().filterByParentId(thing->id())) { + child->setStateValue("connected", true); + child->setStateValue("signalStrength", signalStrength); + } + }); + + ShellyRpcReply *infoReply = client->sendRequest("Shelly.GetDeviceInfo"); + connect(infoReply, &ShellyRpcReply::finished, thing, [thing](ShellyRpcReply::Status status, const QVariantMap &response){ + if (status != ShellyRpcReply::StatusSuccess) { + qCWarning(dcShelly()) << "Error updating device info from shelly:" << status; + return; + } + thing->setStateValue("currentVersion", response.value("ver").toString()); + }); +} + +void IntegrationPluginShelly::setupGen1(ThingSetupInfo *info) { Thing *thing = info->thing(); - QString shellyId = info->thing()->paramValue(idParamTypeMap.value(info->thing()->thingClassId())).toString(); - QHostAddress address = getIP(thing); if (address.isNull()) { @@ -1057,6 +1110,8 @@ void IntegrationPluginShelly::setupShellyGateway(ThingSetupInfo *info) return; } + QString shellyId = info->thing()->paramValue("id").toString(); + bool rollerMode = false; if (info->thing()->thingClassId() == shelly2ThingClassId || info->thing()->thingClassId() == shelly25ThingClassId) { rollerMode = info->thing()->paramValue(rollerModeParamTypeMap.value(info->thing()->thingClassId())).toBool(); @@ -1071,7 +1126,6 @@ void IntegrationPluginShelly::setupShellyGateway(ThingSetupInfo *info) url.setPassword(info->thing()->paramValue(passwordParamTypeMap.value(info->thing()->thingClassId())).toString()); QUrlQuery query; - query.addQueryItem("coiot_enable", "true"); // Make sure the shelly 2.5 is in the mode we expect it to be (roller or relay) @@ -1087,6 +1141,7 @@ void IntegrationPluginShelly::setupShellyGateway(ThingSetupInfo *info) connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); connect(reply, &QNetworkReply::finished, info, [this, info, reply, address, rollerMode](){ if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcShelly) << "Error connecting to shelly:" << reply->error() << reply->errorString(); if (reply->error() == QNetworkReply::AuthenticationRequiredError) { info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Username and password not set correctly.")); } else { @@ -1262,6 +1317,77 @@ void IntegrationPluginShelly::setupShellyGateway(ThingSetupInfo *info) } } +void IntegrationPluginShelly::setupGen2(ThingSetupInfo *info) +{ + Thing *thing = info->thing(); + QHostAddress address = getIP(thing); + + if (address.isNull()) { + qCWarning(dcShelly()) << "Unable to determine Shelly's network address. Failed to set up device."; + info->finish(Thing::ThingErrorHardwareNotAvailable, QT_TR_NOOP("Unable to find the thing in the network.")); + return; + } + + ShellyJsonRpcClient *client = new ShellyJsonRpcClient(info->thing()); + client->open(address); + connect(client, &ShellyJsonRpcClient::stateChanged, info, [info, client, this](QAbstractSocket::SocketState state) { + qCDebug(dcShelly()) << "Websocket state changed:" << state; + ShellyRpcReply *reply = client->sendRequest("Shelly.GetDeviceInfo"); + connect(reply, &ShellyRpcReply::finished, info, [info, client, this](ShellyRpcReply::Status status, const QVariantMap &response){ + if (status != ShellyRpcReply::StatusSuccess) { + qCWarning(dcShelly) << "Error during shelly setup"; + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + qCDebug(dcShelly) << "Init response:" << response; + m_rpcClients.insert(info->thing(), client); + info->finish(Thing::ThingErrorNoError); + + if (myThings().filterByParentId(info->thing()->id()).count() == 0) { + if (info->thing()->thingClassId() == shelly1pmThingClassId) { + ThingDescriptor switchChild(shellySwitchThingClassId, info->thing()->name() + " switch", QString(), info->thing()->id()); + switchChild.setParams(ParamList() << Param(shellySwitchThingChannelParamTypeId, 1)); + emit autoThingsAppeared({switchChild}); + } + } + }); + }); + + connect(client, &ShellyJsonRpcClient::stateChanged, thing, [thing, client, this](QAbstractSocket::SocketState state) { + thing->setStateValue("connected", state == QAbstractSocket::ConnectedState); + foreach (Thing *child, myThings().filterByParentId(thing->id())) { + child->setStateValue("connected", state == QAbstractSocket::ConnectedState); + } + + if (state == QAbstractSocket::UnconnectedState) { + client->open(getIP(thing)); + } + }); + connect(client, &ShellyJsonRpcClient::notificationReceived, thing, [thing, this](const QVariantMap ¬ification){ + qCDebug(dcShelly) << "notification received" << qUtf8Printable(QJsonDocument::fromVariant(notification).toJson()); + if (notification.contains("switch:0")) { + QVariantMap switch0 = notification.value("switch:0").toMap(); + if (switch0.contains("apower") && thing->hasState("currentPower")) { + thing->setStateValue("currentPower", switch0.value("apower").toDouble()); + } + if (switch0.contains("aenergy") && thing->hasState("totalEnergyConsumed")) { + thing->setStateValue("totalEnergyConsumed", notification.value("switch:0").toMap().value("aenergy").toMap().value("total").toDouble()); + } + if (switch0.contains("output") && thing->hasState("power")) { + thing->setStateValue("power", switch0.value("output").toBool()); + } + } + if (notification.contains("input:0")) { + QVariantMap input0 = notification.value("input:0").toMap(); + Thing *t = myThings().filterByParentId(thing->id()).findByParams({Param(shellySwitchThingChannelParamTypeId, 1)}); + if (t) { + t->setStateValue("power", input0.value("state").toBool()); + t->emitEvent("pressed"); + } + } + }); +} + void IntegrationPluginShelly::setupShellyChild(ThingSetupInfo *info) { Thing *thing = info->thing(); @@ -1326,7 +1452,7 @@ QHostAddress IntegrationPluginShelly::getIP(Thing *thing) const d = myThings().findById(thing->parentId()); } - QString shellyId = d->paramValue(idParamTypeMap.value(d->thingClassId())).toString(); + QString shellyId = d->paramValue("id").toString(); ZeroConfServiceEntry zeroConfEntry; foreach (const ZeroConfServiceEntry &entry, m_zeroconfBrowser->serviceEntries()) { if (entry.name() == shellyId) { @@ -1385,3 +1511,12 @@ void IntegrationPluginShelly::handleInputEvent(Thing *thing, const QString &butt qCDebug(dcShelly()) << "Invalid button code from shelly" << thing->name() << inputEventString; } } + +QVariantMap IntegrationPluginShelly::createRpcRequest(const QString &method) +{ + QVariantMap map; + map.insert("src", "nymea"); + map.insert("id", 1); + map.insert("method", method); + return map; +} diff --git a/shelly/integrationpluginshelly.h b/shelly/integrationpluginshelly.h index 39a38f67..e926591d 100644 --- a/shelly/integrationpluginshelly.h +++ b/shelly/integrationpluginshelly.h @@ -41,7 +41,7 @@ class ZeroConfServiceBrowser; class PluginTimer; -class MqttChannel; +class ShellyJsonRpcClient; class IntegrationPluginShelly: public IntegrationPlugin { @@ -67,21 +67,28 @@ private slots: void onMulticastMessageReceived(const QHostAddress &source, const CoapPdu &pdu); void updateStatus(); - void fetchStatus(Thing *thing); + void fetchStatusGen1(Thing *thing); + void fetchStatusGen2(Thing *thing); private: - void setupShellyGateway(ThingSetupInfo *info); + void setupGen1(ThingSetupInfo *info); + void setupGen2(ThingSetupInfo *info); void setupShellyChild(ThingSetupInfo *info); QHostAddress getIP(Thing *thing) const; void handleInputEvent(Thing *thing, const QString &buttonName, const QString &inputEventString, int inputEventCount); + + QVariantMap createRpcRequest(const QString &method); + private: ZeroConfServiceBrowser *m_zeroconfBrowser = nullptr; PluginTimer *m_statusUpdateTimer = nullptr; PluginTimer *m_reconfigureTimer = nullptr; Coap *m_coap = nullptr; + + QHash m_rpcClients; }; #endif // INTEGRATIONPLUGINSHELLY_H diff --git a/shelly/integrationpluginshelly.json b/shelly/integrationpluginshelly.json index 98523708..352552d9 100644 --- a/shelly/integrationpluginshelly.json +++ b/shelly/integrationpluginshelly.json @@ -110,7 +110,7 @@ { "id": "30e74e9f-57f4-4bbc-b0df-f2c4f28b2f06", "name": "shelly1pm", - "displayName": "Shelly 1PM", + "displayName": "Shelly 1PM/Plus 1PM", "createMethods": ["discovery"], "interfaces": [ "gateway", "smartmeterconsumer", "wirelessconnectable", "update" ], "paramTypes": [ diff --git a/shelly/shelly.pro b/shelly/shelly.pro index c1ded65b..1ff184dc 100644 --- a/shelly/shelly.pro +++ b/shelly/shelly.pro @@ -1,11 +1,13 @@ include(../plugins.pri) -QT += network +QT += network websockets PKGCONFIG += nymea-mqtt SOURCES += \ integrationpluginshelly.cpp \ + shellyjsonrpcclient.cpp HEADERS += \ integrationpluginshelly.h \ + shellyjsonrpcclient.h diff --git a/shelly/shellyjsonrpcclient.cpp b/shelly/shellyjsonrpcclient.cpp new file mode 100644 index 00000000..001e4903 --- /dev/null +++ b/shelly/shellyjsonrpcclient.cpp @@ -0,0 +1,86 @@ +#include "shellyjsonrpcclient.h" + +#include +#include +#include +Q_DECLARE_LOGGING_CATEGORY(dcShelly) + +ShellyRpcReply::ShellyRpcReply(int id, QObject *parent): + QObject(parent), + m_id(id) +{ + QTimer::singleShot(10000, this, [this]{finished(StatusTimeout, QVariantMap());}); + connect(this, &ShellyRpcReply::finished, this, &ShellyRpcReply::deleteLater); +} + +int ShellyRpcReply::id() const +{ + return m_id; +} + +ShellyJsonRpcClient::ShellyJsonRpcClient(QObject *parent) + : QObject(parent) +{ + m_socket = new QWebSocket("nymea", QWebSocketProtocol::VersionLatest, this); + connect(m_socket, &QWebSocket::stateChanged, this, &ShellyJsonRpcClient::stateChanged); + + connect(m_socket, &QWebSocket::textMessageReceived, this, &ShellyJsonRpcClient::onTextMessageReceived); +} + +void ShellyJsonRpcClient::open(const QHostAddress &address) +{ + QUrl url; + url.setScheme("ws"); + url.setHost(address.toString()); + url.setPath("/rpc"); + m_socket->open(url); +} + +ShellyRpcReply *ShellyJsonRpcClient::sendRequest(const QString &method) +{ + int id = m_currentId++; + + QVariantMap data; + data.insert("id", id); + data.insert("src", "nymea"); + data.insert("method", method); + + ShellyRpcReply *reply = new ShellyRpcReply(id, this); + connect(reply, &ShellyRpcReply::finished, this, [this, id]{ + m_pendingReplies.remove(id); + }); + m_pendingReplies.insert(id, reply); + + qCDebug(dcShelly) << "Sending request" << QJsonDocument::fromVariant(data).toJson(); + m_socket->sendTextMessage(QJsonDocument::fromVariant(data).toJson(QJsonDocument::Compact)); + + return reply; +} + +void ShellyJsonRpcClient::onTextMessageReceived(const QString &message) +{ + qCDebug(dcShelly) << "Text message received from shelly:" << message; + + QJsonParseError error; + QVariantMap data = QJsonDocument::fromJson(message.toUtf8(), &error).toVariant().toMap(); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcShelly()) << "Error parsing data from shelly"; + m_socket->close(QWebSocketProtocol::CloseCodeBadOperation); + return; + } + + if (data.value("method").toString() == "NotifyStatus") { + emit notificationReceived(data.value("params").toMap()); + return; + } + + int id = data.value("id").toInt(); + ShellyRpcReply *reply = m_pendingReplies.take(id); + + if (!reply) { + qCDebug(dcShelly()) << "Received a message which is neither a notification nor a reply to a request:" << message; + return; + } + + reply->finished(ShellyRpcReply::StatusSuccess, data.value("result").toMap()); +} diff --git a/shelly/shellyjsonrpcclient.h b/shelly/shellyjsonrpcclient.h new file mode 100644 index 00000000..5a1c2d02 --- /dev/null +++ b/shelly/shellyjsonrpcclient.h @@ -0,0 +1,52 @@ +#ifndef SHELLYJSONRPCCLIENT_H +#define SHELLYJSONRPCCLIENT_H + +#include +#include + +class ShellyRpcReply: public QObject +{ + Q_OBJECT +public: + enum Status { + StatusSuccess, + StatusTimeout + }; + Q_ENUM(Status) + + explicit ShellyRpcReply(int id, QObject *parent = nullptr); + + int id() const; + +signals: + void finished(Status status, const QVariantMap &response); + +private: + int m_id = 0; +}; + +class ShellyJsonRpcClient : public QObject +{ + Q_OBJECT +public: + explicit ShellyJsonRpcClient(QObject *parent = nullptr); + + void open(const QHostAddress &address); + + ShellyRpcReply* sendRequest(const QString &method); + +signals: + void stateChanged(QAbstractSocket::SocketState state); + void notificationReceived(const QVariantMap ¬ification); + +private slots: + void onTextMessageReceived(const QString &message); + +private: + QWebSocket *m_socket = nullptr; + QHash m_pendingReplies; + + int m_currentId = 1; +}; + +#endif // SHELLYJSONRPCCLIENT_H From 70e0f2c7ceb45509f58bce668cdd0c67d31390b9 Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Sun, 19 Jun 2022 23:16:06 +0200 Subject: [PATCH 2/4] Implement digest auth for gen2 rpc --- shelly/integrationpluginshelly.cpp | 43 +++++++++++----- shelly/shellyjsonrpcclient.cpp | 82 +++++++++++++++++++++++++++--- shelly/shellyjsonrpcclient.h | 36 ++++++++++--- 3 files changed, 133 insertions(+), 28 deletions(-) diff --git a/shelly/integrationpluginshelly.cpp b/shelly/integrationpluginshelly.cpp index 63651320..90097682 100644 --- a/shelly/integrationpluginshelly.cpp +++ b/shelly/integrationpluginshelly.cpp @@ -426,7 +426,6 @@ void IntegrationPluginShelly::executeAction(ThingActionInfo *info) {shelly25Channel1ActionTypeId, 1}, {shelly25Channel2ActionTypeId, 2} }; - if (channelParamTypeMap.contains(thing->thingClassId())) { relay = thing->paramValue(channelParamTypeMap.value(thing->thingClassId())).toInt(); } else if (actionChannelMap.contains(action.actionTypeId())) { @@ -435,16 +434,27 @@ void IntegrationPluginShelly::executeAction(ThingActionInfo *info) ParamTypeId powerParamTypeId = powerActionParamTypesMap.value(action.actionTypeId()); bool on = action.param(powerParamTypeId).value().toBool(); - url.setPath(QString("/relay/%1").arg(relay - 1)); - QUrlQuery query; - query.addQueryItem("turn", on ? "on" : "off"); - url.setQuery(query); - QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, info, [info, reply, on](){ - info->thing()->setStateValue("power", on); - info->finish(reply->error() == QNetworkReply::NoError ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure); - }); + + if (shellyId.contains("Plus")) { + QVariantMap params; + params.insert("id", relay - 1); + params.insert("on", on); + ShellyRpcReply *reply = m_rpcClients.value(thing)->sendRequest("Switch.Set", params); + connect(reply, &ShellyRpcReply::finished, info, [info](ShellyRpcReply::Status status, const QVariantMap &/*response*/){ + info->finish(status == ShellyRpcReply::StatusSuccess ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure); + }); + } else { + url.setPath(QString("/relay/%1").arg(relay - 1)); + QUrlQuery query; + query.addQueryItem("turn", on ? "on" : "off"); + url.setQuery(query); + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, info, [info, reply, on](){ + info->thing()->setStateValue("power", on); + info->finish(reply->error() == QNetworkReply::NoError ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure); + }); + } return; } @@ -1321,6 +1331,7 @@ void IntegrationPluginShelly::setupGen2(ThingSetupInfo *info) { Thing *thing = info->thing(); QHostAddress address = getIP(thing); + QString shellyId = info->thing()->paramValue("id").toString(); if (address.isNull()) { qCWarning(dcShelly()) << "Unable to determine Shelly's network address. Failed to set up device."; @@ -1328,11 +1339,15 @@ void IntegrationPluginShelly::setupGen2(ThingSetupInfo *info) return; } + QString password = info->thing()->paramValue("password").toString(); + ShellyJsonRpcClient *client = new ShellyJsonRpcClient(info->thing()); - client->open(address); + client->open(address, "admin", password, shellyId); connect(client, &ShellyJsonRpcClient::stateChanged, info, [info, client, this](QAbstractSocket::SocketState state) { qCDebug(dcShelly()) << "Websocket state changed:" << state; - ShellyRpcReply *reply = client->sendRequest("Shelly.GetDeviceInfo"); + // GetDeviceInfo wouldn't require authentication if enabled, so if the setup is changed to fetch some info from GetDeviceInfo, + // make sure to not just replace the GetStatus call, or authentication verification won't work any more. + ShellyRpcReply *reply = client->sendRequest("Shelly.GetStatus"); connect(reply, &ShellyRpcReply::finished, info, [info, client, this](ShellyRpcReply::Status status, const QVariantMap &response){ if (status != ShellyRpcReply::StatusSuccess) { qCWarning(dcShelly) << "Error during shelly setup"; @@ -1360,7 +1375,7 @@ void IntegrationPluginShelly::setupGen2(ThingSetupInfo *info) } if (state == QAbstractSocket::UnconnectedState) { - client->open(getIP(thing)); + client->open(getIP(thing), "admin", thing->paramValue("password").toString(), thing->paramValue("id").toString()); } }); connect(client, &ShellyJsonRpcClient::notificationReceived, thing, [thing, this](const QVariantMap ¬ification){ diff --git a/shelly/shellyjsonrpcclient.cpp b/shelly/shellyjsonrpcclient.cpp index 001e4903..588d3ad0 100644 --- a/shelly/shellyjsonrpcclient.cpp +++ b/shelly/shellyjsonrpcclient.cpp @@ -5,9 +5,9 @@ #include Q_DECLARE_LOGGING_CATEGORY(dcShelly) -ShellyRpcReply::ShellyRpcReply(int id, QObject *parent): +ShellyRpcReply::ShellyRpcReply(const QVariantMap &requestBody, QObject *parent): QObject(parent), - m_id(id) + m_requestBody(requestBody) { QTimer::singleShot(10000, this, [this]{finished(StatusTimeout, QVariantMap());}); connect(this, &ShellyRpcReply::finished, this, &ShellyRpcReply::deleteLater); @@ -15,7 +15,12 @@ ShellyRpcReply::ShellyRpcReply(int id, QObject *parent): int ShellyRpcReply::id() const { - return m_id; + return m_requestBody.value("id").toInt(); +} + +QVariantMap ShellyRpcReply::requestBody() const +{ + return m_requestBody; } ShellyJsonRpcClient::ShellyJsonRpcClient(QObject *parent) @@ -27,8 +32,12 @@ ShellyJsonRpcClient::ShellyJsonRpcClient(QObject *parent) connect(m_socket, &QWebSocket::textMessageReceived, this, &ShellyJsonRpcClient::onTextMessageReceived); } -void ShellyJsonRpcClient::open(const QHostAddress &address) +void ShellyJsonRpcClient::open(const QHostAddress &address, const QString &user, const QString &password, const QString &shellyId) { + m_password = password; + m_user = user; + m_shellyId = shellyId; + QUrl url; url.setScheme("ws"); url.setHost(address.toString()); @@ -36,7 +45,7 @@ void ShellyJsonRpcClient::open(const QHostAddress &address) m_socket->open(url); } -ShellyRpcReply *ShellyJsonRpcClient::sendRequest(const QString &method) +ShellyRpcReply *ShellyJsonRpcClient::sendRequest(const QString &method, const QVariantMap ¶ms) { int id = m_currentId++; @@ -44,8 +53,15 @@ ShellyRpcReply *ShellyJsonRpcClient::sendRequest(const QString &method) data.insert("id", id); data.insert("src", "nymea"); data.insert("method", method); + if (!params.isEmpty()) { + data.insert("params", params); + } - ShellyRpcReply *reply = new ShellyRpcReply(id, this); + if (!m_password.isEmpty() && m_nonce != 0) { + data.insert("auth", createAuthMap()); + } + + ShellyRpcReply *reply = new ShellyRpcReply(data, this); connect(reply, &ShellyRpcReply::finished, this, [this, id]{ m_pendingReplies.remove(id); }); @@ -75,12 +91,62 @@ void ShellyJsonRpcClient::onTextMessageReceived(const QString &message) } int id = data.value("id").toInt(); - ShellyRpcReply *reply = m_pendingReplies.take(id); + ShellyRpcReply *reply = m_pendingReplies.value(id); if (!reply) { qCDebug(dcShelly()) << "Received a message which is neither a notification nor a reply to a request:" << message; return; } - reply->finished(ShellyRpcReply::StatusSuccess, data.value("result").toMap()); + ShellyRpcReply::Status status = ShellyRpcReply::StatusSuccess; + if (data.contains("error")) { + QVariantMap errorMap = data.value("error").toMap(); + qCWarning(dcShelly()) << "Error in shelly command:" << errorMap.value("code").toInt() << errorMap.value("message"); + status = static_cast(errorMap.value("code").toInt()); + + if (status == ShellyRpcReply::StatusAuthenticationRequired) { + if (m_nonce == 0) { + qCInfo(dcShelly) << "Authentication required. Initializing nonce and retrying..."; + + QJsonParseError error; + QVariantMap authInfo = QJsonDocument::fromJson(errorMap.value("message").toByteArray(), &error).toVariant().toMap(); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcShelly()) << "Unable to parse auth error message. Authentication will not work."; + emit reply->finished(status, QVariantMap()); + return; + } + m_nonce = authInfo.value("nonce").toInt(); + m_nc = authInfo.value("nc").toInt(); + QVariantMap newBody = reply->requestBody(); + newBody.insert("auth", createAuthMap()); + qCDebug(dcShelly) << "Sending request with auth" << qUtf8Printable(QJsonDocument::fromVariant(newBody).toJson()); + m_socket->sendTextMessage(QJsonDocument::fromVariant(newBody).toJson(QJsonDocument::Compact)); + return; + } else { + qCWarning(dcShelly()) << "Username and password seem to be wrong."; + } + } + } + + emit reply->finished(status, data.value("result").toMap()); +} + +QVariantMap ShellyJsonRpcClient::createAuthMap() const +{ + int cnonce = qrand(); + QByteArray ha1 = QString("%1:%2:%3").arg(m_user).arg(m_shellyId.toLower()).arg(m_password).toUtf8(); + ha1 = QCryptographicHash::hash(ha1, QCryptographicHash::Sha256).toHex(); + QByteArray ha2 = QByteArrayLiteral("dummy_method:dummy_uri"); + ha2 = QCryptographicHash::hash(ha2, QCryptographicHash::Sha256).toHex(); + QByteArray response = QString("%1:%2:%3:%4:auth:%5").arg(QString::fromUtf8(ha1)).arg(m_nonce).arg(m_nc).arg(cnonce).arg(QString::fromUtf8(ha2)).toUtf8(); + response = QCryptographicHash::hash(response, QCryptographicHash::Sha256).toHex(); + + QVariantMap auth; + auth.insert("realm", m_shellyId.toLower()); + auth.insert("username", m_user); + auth.insert("nonce", m_nonce); + auth.insert("cnonce", cnonce); + auth.insert("response", response); + auth.insert("algorithm", "SHA-256"); + return auth; } diff --git a/shelly/shellyjsonrpcclient.h b/shelly/shellyjsonrpcclient.h index 5a1c2d02..a19f1982 100644 --- a/shelly/shellyjsonrpcclient.h +++ b/shelly/shellyjsonrpcclient.h @@ -9,20 +9,35 @@ class ShellyRpcReply: public QObject Q_OBJECT public: enum Status { - StatusSuccess, - StatusTimeout + StatusSuccess = 0, + + // Shelly common error codes + StatusInvalidArgument = -103, + StatusDeadlineExceeded = -104, + StatusResourceExhausted = -108, + StatusFailedPrecondition = -109, + StatusUnavailable = -114, + + StatusCodeBadRequest = 400, + StatusAuthenticationRequired = 401, + + // Our own + StatusTimeout = -1 + }; Q_ENUM(Status) - explicit ShellyRpcReply(int id, QObject *parent = nullptr); + explicit ShellyRpcReply(const QVariantMap &requestBody, QObject *parent = nullptr); int id() const; + QString method() const; + QVariantMap requestBody() const; signals: void finished(Status status, const QVariantMap &response); private: - int m_id = 0; + QVariantMap m_requestBody; }; class ShellyJsonRpcClient : public QObject @@ -31,9 +46,9 @@ class ShellyJsonRpcClient : public QObject public: explicit ShellyJsonRpcClient(QObject *parent = nullptr); - void open(const QHostAddress &address); + void open(const QHostAddress &address, const QString &user, const QString &password, const QString &shellyId); - ShellyRpcReply* sendRequest(const QString &method); + ShellyRpcReply* sendRequest(const QString &method, const QVariantMap ¶ms = QVariantMap()); signals: void stateChanged(QAbstractSocket::SocketState state); @@ -43,10 +58,19 @@ private slots: void onTextMessageReceived(const QString &message); private: + QVariantMap createAuthMap() const; + QWebSocket *m_socket = nullptr; QHash m_pendingReplies; int m_currentId = 1; + + // Needed (only) for authentication + QString m_user; + QString m_password; + QString m_shellyId; + qulonglong m_nonce = 0; + int m_nc = 0; }; #endif // SHELLYJSONRPCCLIENT_H From 4b70216f652ac9ef38ef4c6c3b43233df09237ce Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 20 Jun 2022 20:15:02 +0200 Subject: [PATCH 3/4] Add Shelly Plus 1 (no PM) support too --- shelly/integrationpluginshelly.cpp | 2 +- shelly/integrationpluginshelly.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shelly/integrationpluginshelly.cpp b/shelly/integrationpluginshelly.cpp index 90097682..cdb5cdab 100644 --- a/shelly/integrationpluginshelly.cpp +++ b/shelly/integrationpluginshelly.cpp @@ -267,7 +267,7 @@ void IntegrationPluginShelly::discoverThings(ThingDiscoveryInfo *info) qCDebug(dcShelly()) << "Have entry" << entry; QRegExp namePattern; if (info->thingClassId() == shelly1ThingClassId) { - namePattern = QRegExp("^shelly1-[0-9A-Z]+$"); + namePattern = QRegExp("^(shelly1|ShellyPlus1)-[0-9A-Z]+$"); } else if (info->thingClassId() == shelly1pmThingClassId) { namePattern = QRegExp("^(shelly1pm|ShellyPlus1PM)-[0-9A-Z]+$"); } else if (info->thingClassId() == shelly1lThingClassId) { diff --git a/shelly/integrationpluginshelly.json b/shelly/integrationpluginshelly.json index 352552d9..72dbb0af 100644 --- a/shelly/integrationpluginshelly.json +++ b/shelly/integrationpluginshelly.json @@ -11,7 +11,7 @@ { "id": "f810b66a-7177-4397-9771-4229abaabbb6", "name": "shelly1", - "displayName": "Shelly 1", + "displayName": "Shelly 1/Plus 1", "createMethods": ["discovery"], "interfaces": [ "gateway", "wirelessconnectable", "update" ], "paramTypes": [ From 07997b6dceb4cc9a598b833a6181028a05ebcbfe Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Sat, 25 Jun 2022 23:09:13 +0200 Subject: [PATCH 4/4] Add settings support --- shelly/integrationpluginshelly.cpp | 87 +++++++++++++++++++++--------- shelly/shellyjsonrpcclient.cpp | 2 +- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/shelly/integrationpluginshelly.cpp b/shelly/integrationpluginshelly.cpp index cdb5cdab..c3228e5d 100644 --- a/shelly/integrationpluginshelly.cpp +++ b/shelly/integrationpluginshelly.cpp @@ -1323,6 +1323,9 @@ void IntegrationPluginShelly::setupGen1(ThingSetupInfo *info) QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); qCDebug(dcShelly()) << "Setting configuration:" << url.toString(); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, [reply](){ + qCDebug(dcShelly) << "Set config reply:" << reply->error() << reply->errorString() << reply->readAll(); + }); }); } } @@ -1375,7 +1378,9 @@ void IntegrationPluginShelly::setupGen2(ThingSetupInfo *info) } if (state == QAbstractSocket::UnconnectedState) { - client->open(getIP(thing), "admin", thing->paramValue("password").toString(), thing->paramValue("id").toString()); + QTimer::singleShot(1000, thing, [this, client, thing](){ + client->open(getIP(thing), "admin", thing->paramValue("password").toString(), thing->paramValue("id").toString()); + }); } }); connect(client, &ShellyJsonRpcClient::notificationReceived, thing, [thing, this](const QVariantMap ¬ification){ @@ -1427,34 +1432,64 @@ void IntegrationPluginShelly::setupShellyChild(ThingSetupInfo *info) qCDebug(dcShelly()) << "Parent for" << info->thing()->name() << "is set up. Finishing child setup."; // Connect to settings changes to store them to the thing - connect(info->thing(), &Thing::settingChanged, this, [this, thing](const ParamTypeId ¶mTypeId, const QVariant &value){ - Thing *parentDevice = myThings().findById(thing->parentId()); - pluginStorage()->beginGroup(parentDevice->id().toString()); - QString address = pluginStorage()->value("cachedAddress").toString(); - pluginStorage()->endGroup(); + connect(info->thing(), &Thing::settingChanged, this, [this, thing, parent](const ParamTypeId ¶mTypeId, const QVariant &value){ + if (parent->paramValue("id").toString().contains("Plus")) { + ShellyJsonRpcClient *client = m_rpcClients.value(parent); + QVariantMap params; + params.insert("id", thing->paramValue("channel").toInt() - 1); - QUrl url; - url.setScheme("http"); - url.setHost(address); - url.setPort(80); - url.setPath(QString("/settings/relay/%0").arg(thing->paramValue(channelParamTypeMap.value(thing->thingClassId())).toInt() - 1)); - url.setUserName(parentDevice->paramValue(usernameParamTypeMap.value(parentDevice->thingClassId())).toString()); - url.setPassword(parentDevice->paramValue(passwordParamTypeMap.value(parentDevice->thingClassId())).toString()); + if (paramTypeId == shellySwitchSettingsButtonTypeParamTypeId) { + QVariantMap inputConfig; + if (value == "toggle" || value == "edge") { + inputConfig.insert("type", "switch"); + } else { + inputConfig.insert("type", "button"); + } + params["config"] = inputConfig; + client->sendRequest("Input.SetConfig", params); - QUrlQuery query; - if (paramTypeId == shellySwitchSettingsButtonTypeParamTypeId) { - query.addQueryItem("btn_type", value.toString()); + QVariantMap switchConfig; + switchConfig.insert("in_mode", value.toString().replace("toggle", "follow").replace("edge", "flip")); + params["config"] = switchConfig; + client->sendRequest("Switch.SetConfig", params); + + } else if (paramTypeId == shellySwitchSettingsInvertButtonParamTypeId) { + QVariantMap config; + config.insert("invert", value.toBool()); + params.insert("config", config); + client->sendRequest("Input.SetConfig", params); + } + } else { + pluginStorage()->beginGroup(parent->id().toString()); + QString address = pluginStorage()->value("cachedAddress").toString(); + pluginStorage()->endGroup(); + + QUrl url; + url.setScheme("http"); + url.setHost(address); + url.setPort(80); + url.setPath(QString("/settings/relay/%0").arg(thing->paramValue(channelParamTypeMap.value(thing->thingClassId())).toInt() - 1)); + url.setUserName(parent->paramValue(usernameParamTypeMap.value(parent->thingClassId())).toString()); + url.setPassword(parent->paramValue(passwordParamTypeMap.value(parent->thingClassId())).toString()); + + QUrlQuery query; + if (paramTypeId == shellySwitchSettingsButtonTypeParamTypeId) { + query.addQueryItem("btn_type", value.toString()); + } + if (paramTypeId == shellySwitchSettingsInvertButtonParamTypeId) { + query.addQueryItem("btn_reverse", value.toBool() ? "1" : "0"); + } + + url.setQuery(query); + + qCDebug(dcShelly) << "Setting configuration:" << url.toString(); + + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, [reply](){ + qCDebug(dcShelly) << "Set config reply:" << reply->error() << reply->errorString() << reply->readAll(); + }); } - if (paramTypeId == shellySwitchSettingsInvertButtonParamTypeId) { - query.addQueryItem("btn_reverse", value.toBool() ? "1" : "0"); - } - - url.setQuery(query); - - qCDebug(dcShelly) << "Setting configuration:" << url.toString(); - - QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); }); info->finish(Thing::ThingErrorNoError); diff --git a/shelly/shellyjsonrpcclient.cpp b/shelly/shellyjsonrpcclient.cpp index 588d3ad0..db45906b 100644 --- a/shelly/shellyjsonrpcclient.cpp +++ b/shelly/shellyjsonrpcclient.cpp @@ -67,7 +67,7 @@ ShellyRpcReply *ShellyJsonRpcClient::sendRequest(const QString &method, const QV }); m_pendingReplies.insert(id, reply); - qCDebug(dcShelly) << "Sending request" << QJsonDocument::fromVariant(data).toJson(); + qCDebug(dcShelly) << "Sending request" << qUtf8Printable(QJsonDocument::fromVariant(data).toJson()); m_socket->sendTextMessage(QJsonDocument::fromVariant(data).toJson(QJsonDocument::Compact)); return reply;