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