New plugin: Shelly

This commit is contained in:
Michael Zanetti 2019-09-25 14:44:38 +02:00
parent 97f507033e
commit 0d4d6cd4f6
8 changed files with 396 additions and 19 deletions

View File

@ -36,7 +36,7 @@ Device::DeviceSetupStatus DevicePluginMqttClient::setupDevice(Device *device)
{
MqttClient *client = nullptr;
if (device->deviceClassId() == internalMqttClientDeviceClassId) {
client = hardwareManager()->mqttProvider()->createInternalClient(device->id());
client = hardwareManager()->mqttProvider()->createInternalClient(device->id().toString());
} else if (device->deviceClassId() == mqttClientDeviceClassId){
client = new MqttClient("nymea-" + device->id().toString().remove(QRegExp("[{}]")).left(8), this);
client->setUsername(device->paramValue(mqttClientDeviceUsernameParamTypeId).toString());

View File

@ -34,6 +34,7 @@ PLUGIN_DIRS = \
osdomotics \
philipshue \
pushbullet \
shelly \
systemmonitor \
remotessh \
senic \

20
shelly/README.md Normal file
View File

@ -0,0 +1,20 @@
# Tasmota
This plugin allows to make use of Sonoff-Tasmota devices via the nymea internal MQTT broker. There is no external MQTT broker needed.
Note that Sonoff devices must be flashed with the Tasmota sofware and connected to the WiFi network in order to work with this plugin.
See the [Sonoff-Tasmota wiki](https://github.com/arendst/Sonoff-Tasmota/wiki) for a list of all supported devices and instructions on how to
install Tasmota on those.
After flashing Tasmota to a Sonoff device and connecting it to WiFi, it can be added to nymea. The only required
thing is the IP address to the device. This plugin will create a new isoloated MQTT channel on the nymea internal
MQTT broker and provision login details to the Tasmota device via HTTP. Once that is successful, the Tasmota device
will connect to the MQTT broker and appear as connected in nymea.
## Plugin properties
When adding a Tasmota device it will add a new Gateway type device representing the Tasmota device itself. In addition
to that a power switch device will appear which can be used to control the switches in the Tasmota device. Upon
device setup, the user can optionally select the type of the connected hardware, (e.g. a light) which causes this
plugin to create a light device in the system which also controls the switches inside the Tasmota device and nicely
integrates with the nymea:ux for the given device type.

View File

@ -0,0 +1,231 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* *
* Copyright (C) 2019 Michael Zanetti <michael.zanetti@nymea.io> *
* *
* This file is part of nymea. *
* *
* This library is free software; you can redistribute it and/or *
* modify it under the terms of the GNU Lesser General Public *
* License as published by the Free Software Foundation; either *
* version 2.1 of the License, or (at your option) any later version. *
* *
* This library 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 library; If not, see *
* <http://www.gnu.org/licenses/>. *
* *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "devicepluginshelly.h"
#include "plugininfo.h"
#include <QUrlQuery>
#include <QNetworkReply>
#include <QHostAddress>
#include <QJsonDocument>
#include "hardwaremanager.h"
#include "network/networkaccessmanager.h"
#include "network/mqtt/mqttprovider.h"
#include "network/mqtt/mqttchannel.h"
#include "network/zeroconf/zeroconfservicebrowser.h"
#include "platform/platformzeroconfcontroller.h"
DevicePluginShelly::DevicePluginShelly()
{
}
DevicePluginShelly::~DevicePluginShelly()
{
}
void DevicePluginShelly::init()
{
m_zeroconfBrowser = hardwareManager()->zeroConfController()->createServiceBrowser("_http._tcp");
}
void DevicePluginShelly::discoverDevices(DeviceDiscoveryInfo *info)
{
foreach (const ZeroConfServiceEntry &entry, m_zeroconfBrowser->serviceEntries()) {
// qCDebug(dcShelly()) << "Have entry" << entry;
QRegExp namePattern("^shelly[1-2]-[0-9A-Z]+$");
if (!entry.name().contains(namePattern)) {
continue;
}
DeviceDescriptor descriptor(shellyOneDeviceClassId, entry.name(), entry.hostAddress().toString());
ParamList params;
params << Param(shellyOneDeviceIdParamTypeId, entry.name());
descriptor.setParams(params);
Device *existingDevice = myDevices().findByParams(params);
if (existingDevice) {
descriptor.setDeviceId(existingDevice->id());
}
info->addDeviceDescriptor(descriptor);
qCDebug(dcShelly()) << "Found shelly device!" << entry;
}
info->finish(Device::DeviceErrorNoError);
}
void DevicePluginShelly::setupDevice(DeviceSetupInfo *info)
{
Device *device = info->device();
if (device->deviceClassId() == shellyOneDeviceClassId) {
QString shellyId = device->paramValue(shellyOneDeviceIdParamTypeId).toString();
ZeroConfServiceEntry zeroConfEntry;
foreach (const ZeroConfServiceEntry &entry, m_zeroconfBrowser->serviceEntries()) {
if (entry.name() == shellyId) {
zeroConfEntry = entry;
}
}
QHostAddress address;
pluginStorage()->beginGroup(device->id().toString());
if (zeroConfEntry.isValid()) {
address = zeroConfEntry.hostAddress().toString();
pluginStorage()->setValue("cachedAddress", address.toString());
} else {
qCWarning(dcShelly()) << "Could not find Shelly device on zeroconf. Trying cached address.";
address = pluginStorage()->value("cachedAddress").toString();
}
pluginStorage()->endGroup();
if (address.isNull()) {
qCWarning(dcShelly()) << "Unable to determine Shelly's network address. Failed to set up device.";
info->finish(Device::DeviceErrorHardwareNotAvailable, QT_TR_NOOP("Unable to find the device in the network."));
return;
}
MqttChannel *channel = hardwareManager()->mqttProvider()->createChannel(shellyId, QHostAddress(address), {"shellies"});
if (!channel) {
qCWarning(dcShelly()) << "Failed to create MQTT channel.";
return info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error creating MQTT channel. Please check MQTT server settings."));
}
QUrl url;
url.setScheme("http");
url.setHost(address.toString());
url.setPort(80);
url.setPath("/settings");
QUrlQuery query;
query.addQueryItem("mqtt_server", channel->serverAddress().toString() + ":" + QString::number(channel->serverPort()));
query.addQueryItem("mqtt_user", channel->username());
query.addQueryItem("mqtt_pass", channel->password());
query.addQueryItem("mqtt_enable", "true");
url.setQuery(query);
QNetworkRequest request(url);
qCDebug(dcShelly()) << "Connecting to" << url.toString();
QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(info, &DeviceSetupInfo::aborted, channel, [this, channel](){
hardwareManager()->mqttProvider()->releaseChannel(channel);
});
connect(reply, &QNetworkReply::finished, info, [this, info, reply, channel](){
if (reply->error() != QNetworkReply::NoError) {
qCWarning(dcShelly()) << "Error fetching device settings" << reply->error() << reply->errorString();
info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error connecting to Shelly device."));
hardwareManager()->mqttProvider()->releaseChannel(channel);
return;
}
QByteArray data = reply->readAll();
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
qCWarning(dcShelly()) << "Error parsing settings reply" << error.errorString() << "\n" << data;
info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Unexpected data received from Shelly device."));
hardwareManager()->mqttProvider()->releaseChannel(channel);
return;
}
qCDebug(dcShelly()) << "Settings data" << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Indented));
m_mqttChannels.insert(info->device(), channel);
connect(channel, &MqttChannel::clientConnected, this, &DevicePluginShelly::onClientConnected);
connect(channel, &MqttChannel::clientDisconnected, this, &DevicePluginShelly::onClientDisconnected);
connect(channel, &MqttChannel::publishReceived, this, &DevicePluginShelly::onPublishReceived);
info->finish(Device::DeviceErrorNoError);
});
return;
}
qCWarning(dcShelly) << "Unhandled DeviceClass in setupDevice" << device->deviceClassId();
}
void DevicePluginShelly::deviceRemoved(Device *device)
{
if (m_mqttChannels.contains(device)) {
hardwareManager()->mqttProvider()->releaseChannel(m_mqttChannels.take(device));
}
qCDebug(dcShelly()) << "Device removed" << device->name();
}
void DevicePluginShelly::executeAction(DeviceActionInfo *info)
{
Device *device = info->device();
Action action = info->action();
if (action.actionTypeId() == shellyOnePowerActionTypeId) {
MqttChannel *channel = m_mqttChannels.value(device);
QString shellyId = device->paramValue(shellyOneDeviceIdParamTypeId).toString();
bool on = action.param(shellyOnePowerActionPowerParamTypeId).value().toBool();
channel->publish("shellies/" + shellyId + "/relay/0/command", on ? "on" : "off");
info->finish(Device::DeviceErrorNoError);
return;
}
qCWarning(dcShelly()) << "Unhandled execute action call for device" << device;
}
void DevicePluginShelly::onClientConnected(MqttChannel *channel)
{
Device *device = m_mqttChannels.key(channel);
if (!device) {
qCWarning(dcShelly()) << "Received a client connect for a device we don't know!";
return;
}
device->setStateValue(shellyOneConnectedStateTypeId, true);
}
void DevicePluginShelly::onClientDisconnected(MqttChannel *channel)
{
Device *device = m_mqttChannels.key(channel);
if (!device) {
qCWarning(dcShelly()) << "Received a client disconnect for a device we don't know!";
return;
}
device->setStateValue(shellyOneConnectedStateTypeId, false);
}
void DevicePluginShelly::onPublishReceived(MqttChannel *channel, const QString &topic, const QByteArray &payload)
{
Device *device = m_mqttChannels.key(channel);
if (!device) {
qCWarning(dcShelly()) << "Received a publish message for a device we don't know!";
return;
}
QString shellyId = device->paramValue(shellyOneDeviceIdParamTypeId).toString();
if (topic == "shellies/" + shellyId + "/input/0") {
// "1" or "0"
// Emit event button pressed
}
if (topic == "shellies/" + shellyId + "/relay/0") {
bool on = payload == "on";
device->setStateValue(shellyOnePowerStateTypeId, on);
}
qCDebug(dcShelly()) << "Publish received from" << device->name() << topic << payload;
}

View File

@ -0,0 +1,59 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* *
* Copyright (C) 2019 Michael Zanetti <michael.zanetti@nymea.io> *
* *
* This file is part of nymea. *
* *
* nymea is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, version 2 of the License. *
* *
* nymea 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 General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with nymea. If not, see <http://www.gnu.org/licenses/>. *
* *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef DEVICEPLUGINSHELLY_H
#define DEVICEPLUGINSHELLY_H
#include "devices/deviceplugin.h"
class ZeroConfServiceBrowser;
class MqttChannel;
class DevicePluginShelly: public DevicePlugin
{
Q_OBJECT
Q_PLUGIN_METADATA(IID "io.nymea.DevicePlugin" FILE "devicepluginshelly.json")
Q_INTERFACES(DevicePlugin)
public:
explicit DevicePluginShelly();
~DevicePluginShelly() override;
void init() override;
void discoverDevices(DeviceDiscoveryInfo *info) override;
void setupDevice(DeviceSetupInfo *info) override;
void deviceRemoved(Device *device) override;
void executeAction(DeviceActionInfo *info) override;
private slots:
void onClientConnected(MqttChannel* channel);
void onClientDisconnected(MqttChannel* channel);
void onPublishReceived(MqttChannel* channel, const QString &topic, const QByteArray &payload);
private:
ZeroConfServiceBrowser *m_zeroconfBrowser = nullptr;
QHash<Device*, MqttChannel*> m_mqttChannels;
};
#endif // DEVICEPLUGINSHELLY_H

View File

@ -0,0 +1,57 @@
{
"name": "shelly",
"displayName": "Shelly",
"id": "6162773b-0435-408c-a4f8-7860d38031a9",
"vendors": [
{
"name": "shelly",
"displayName": "Shelly",
"id": "d8e45fc2-90af-492e-8305-50baa1ec4c18",
"deviceClasses": [
{
"id": "f810b66a-7177-4397-9771-4229abaabbb6",
"name": "shellyOne",
"displayName": "Shelly One",
"createMethods": ["discovery"],
"interfaces": [ "powerswitch", "connectable" ],
"paramTypes": [
{
"id": "1d301dc0-5e48-473f-a611-8e407289e545",
"name":"id",
"displayName": "ID",
"type": "QString"
}
],
"stateTypes": [
{
"id": "e5d41e05-2296-457e-97d8-98a5ac0de615",
"name": "connected",
"displayName": "Connected",
"displayNameEvent": "Connected changed",
"type": "bool",
"defaultValue": false,
"cached": false
},
{
"id": "0f6df838-7fc4-4fc0-9247-b9b8fa4ec924",
"name": "power",
"displayName": "Power",
"displayNameEvent": "Power changed",
"displayNameAction": "Set power",
"type": "bool",
"defaultValue": false,
"writable": true
}
],
"eventTypes": [
{
"id": "172e6aa3-13d3-4c71-8a4d-112605460863",
"name": "pressed",
"displayName": "Pressed"
}
]
}
]
}
]
}

9
shelly/shelly.pro Normal file
View File

@ -0,0 +1,9 @@
include(../plugins.pri)
QT += network
SOURCES += \
devicepluginshelly.cpp \
HEADERS += \
devicepluginshelly.h \

View File

@ -84,7 +84,7 @@ Device::DeviceSetupStatus DevicePluginTasmota::setupDevice(Device *device)
qCWarning(dcTasmota) << "Not a valid IP address given for IP address parameter";
return Device::DeviceSetupStatusFailure;
}
MqttChannel *channel = hardwareManager()->mqttProvider()->createChannel(device->id(), deviceAddress);
MqttChannel *channel = hardwareManager()->mqttProvider()->createChannel(device->id().toString().remove(QRegExp("[{}-]")), deviceAddress);
if (!channel) {
qCWarning(dcTasmota) << "Failed to create MQTT channel.";
return Device::DeviceSetupStatusFailure;
@ -99,7 +99,7 @@ Device::DeviceSetupStatus DevicePluginTasmota::setupDevice(Device *device)
configItems.insert("MqttUser", channel->username());
configItems.insert("MqttPassword", channel->password());
configItems.insert("Topic", "sonoff");
configItems.insert("FullTopic", channel->topicPrefix() + "/%topic%/");
configItems.insert("FullTopic", channel->topicPrefixList().first() + "/%topic%/");
QStringList configList;
foreach (const QString &key, configItems.keys()) {
@ -222,8 +222,8 @@ Device::DeviceError DevicePluginTasmota::executeAction(Device *device, const Act
}
ParamTypeId channelParamTypeId = m_channelParamTypeMap.value(device->deviceClassId());
ParamTypeId powerActionParamTypeId = ParamTypeId(m_powerStateTypeMap.value(device->deviceClassId()).toString());
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(channelParamTypeId).toString() << (action.param(powerActionParamTypeId).value().toBool() ? "ON" : "OFF");
channel->publish(channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(channelParamTypeId).toString().toLower(), action.param(powerActionParamTypeId).value().toBool() ? "ON" : "OFF");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(channelParamTypeId).toString() << (action.param(powerActionParamTypeId).value().toBool() ? "ON" : "OFF");
channel->publish(channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(channelParamTypeId).toString().toLower(), action.param(powerActionParamTypeId).value().toBool() ? "ON" : "OFF");
device->setStateValue(m_powerStateTypeMap.value(device->deviceClassId()), action.param(powerActionParamTypeId).value().toBool());
return Device::DeviceErrorNoError;
}
@ -237,20 +237,20 @@ Device::DeviceError DevicePluginTasmota::executeAction(Device *device, const Act
ParamTypeId openingChannelParamTypeId = m_openingChannelParamTypeMap.value(device->deviceClassId());
ParamTypeId closingChannelParamTypeId = m_closingChannelParamTypeMap.value(device->deviceClassId());
if (action.actionTypeId() == tasmotaShutterOpenActionTypeId) {
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString() << "OFF";
channel->publish(channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString().toLower(), "OFF");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString() << "ON";
channel->publish(channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString().toLower(), "ON");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString() << "OFF";
channel->publish(channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString().toLower(), "OFF");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString() << "ON";
channel->publish(channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString().toLower(), "ON");
} else if (action.actionTypeId() == tasmotaShutterCloseActionTypeId) {
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString() << "OFF";
channel->publish(channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString().toLower(), "OFF");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString() << "ON";
channel->publish(channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString().toLower(), "ON");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString() << "OFF";
channel->publish(channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString().toLower(), "OFF");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString() << "ON";
channel->publish(channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString().toLower(), "ON");
} else { // Stop
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString() << "OFF";
channel->publish(channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString().toLower(), "OFF");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString() << "OFF";
channel->publish(channel->topicPrefix() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString().toLower(), "OFF");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString() << "OFF";
channel->publish(channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(openingChannelParamTypeId).toString().toLower(), "OFF");
qCDebug(dcTasmota) << "Publishing:" << channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString() << "OFF";
channel->publish(channel->topicPrefixList().first() + "/sonoff/cmnd/" + device->paramValue(closingChannelParamTypeId).toString().toLower(), "OFF");
}
return Device::DeviceErrorNoError;
}
@ -289,7 +289,7 @@ void DevicePluginTasmota::onPublishReceived(MqttChannel *channel, const QString
qCDebug(dcTasmota) << "Publish received from Sonoff device:" << topic << payload;
Device *dev = m_mqttChannels.key(channel);
if (m_ipAddressParamTypeMap.contains(dev->deviceClassId())) {
if (topic.startsWith(channel->topicPrefix() + "/sonoff/POWER")) {
if (topic.startsWith(channel->topicPrefixList().first() + "/sonoff/POWER")) {
QString channelName = topic.split("/").last();
foreach (Device *child, myDevices()) {
@ -308,7 +308,7 @@ void DevicePluginTasmota::onPublishReceived(MqttChannel *channel, const QString
}
}
}
if (topic.startsWith(channel->topicPrefix() + "/sonoff/STATE")) {
if (topic.startsWith(channel->topicPrefixList().first() + "/sonoff/STATE")) {
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(payload, &error);
if (error.error != QJsonParseError::NoError) {