Shelly: Add support for the Shelly Plus 1 PM

pull/577/head
Michael Zanetti 2022-06-19 01:13:18 +02:00
parent 04434e150b
commit 387b8362f4
7 changed files with 312 additions and 29 deletions

View File

@ -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

View File

@ -30,6 +30,7 @@
#include "integrationpluginshelly.h"
#include "plugininfo.h"
#include "shellyjsonrpcclient.h"
#include <QUrlQuery>
#include <QNetworkReply>
@ -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 &notification){
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;
}

View File

@ -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<Thing*, ShellyJsonRpcClient*> m_rpcClients;
};
#endif // INTEGRATIONPLUGINSHELLY_H

View File

@ -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": [

View File

@ -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

View File

@ -0,0 +1,86 @@
#include "shellyjsonrpcclient.h"
#include <QJsonDocument>
#include <QTimer>
#include <QLoggingCategory>
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());
}

View File

@ -0,0 +1,52 @@
#ifndef SHELLYJSONRPCCLIENT_H
#define SHELLYJSONRPCCLIENT_H
#include <QObject>
#include <QWebSocket>
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 &notification);
private slots:
void onTextMessageReceived(const QString &message);
private:
QWebSocket *m_socket = nullptr;
QHash<int, ShellyRpcReply*> m_pendingReplies;
int m_currentId = 1;
};
#endif // SHELLYJSONRPCCLIENT_H