diff --git a/debian/control b/debian/control index 9058a0ba..97a9a85f 100644 --- a/debian/control +++ b/debian/control @@ -211,6 +211,22 @@ Description: nymea.io plugin for dweet.io This package will install the nymea.io plugin for dweet.io +Package: nymea-plugin-elgato +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + nymea-plugins-translations, +Replaces: guh-plugin-elgato +Description: nymea.io plugin for elgato + The nymea daemon is a plugin based IoT (Internet of Things) server. The + server works like a translator for devices, things and services and + allows them to interact. + With the powerful rule engine you are able to connect any device available + in the system and create individual scenes and behaviors for your environment. + . + This package will install the nymea.io plugin for elgato + + Package: nymea-plugin-elro Architecture: any Depends: ${shlibs:Depends}, @@ -657,38 +673,6 @@ Description: nymea.io plugin for HTTP commander This package will install the nymea.io plugin for the HTPP commander -Package: nymea-plugin-simulation -Architecture: any -Depends: ${shlibs:Depends}, - ${misc:Depends}, - nymea-plugins-translations, -Replaces: guh-plugin-simulation -Description: nymea.io plugin for simulated devices - The nymea daemon is a plugin based IoT (Internet of Things) server. The - server works like a translator for devices, things and services and - allows them to interact. - With the powerful rule engine you are able to connect any device available - in the system and create individual scenes and behaviors for your environment. - . - This package will install the nymea.io plugin for simulated devices - - -Package: nymea-plugin-elgato -Architecture: any -Depends: ${shlibs:Depends}, - ${misc:Depends}, - nymea-plugins-translations, -Replaces: guh-plugin-elgato -Description: nymea.io plugin for elgato - The nymea daemon is a plugin based IoT (Internet of Things) server. The - server works like a translator for devices, things and services and - allows them to interact. - With the powerful rule engine you are able to connect any device available - in the system and create individual scenes and behaviors for your environment. - . - This package will install the nymea.io plugin for elgato - - Package: nymea-plugin-senic Architecture: any Depends: ${shlibs:Depends}, @@ -705,6 +689,38 @@ Description: nymea.io plugin for senic This package will install the nymea.io plugin for senic +Package: nymea-plugin-shelly +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + nymea-plugins-translations, +Replaces: guh-plugin-simulation +Description: nymea.io plugin for Shelly devices + The nymea daemon is a plugin based IoT (Internet of Things) server. The + server works like a translator for devices, things and services and + allows them to interact. + With the powerful rule engine you are able to connect any device available + in the system and create individual scenes and behaviors for your environment. + . + This package will install the nymea.io plugin for Shelly devices + + +Package: nymea-plugin-simulation +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + nymea-plugins-translations, +Replaces: guh-plugin-simulation +Description: nymea.io plugin for simulated devices + The nymea daemon is a plugin based IoT (Internet of Things) server. The + server works like a translator for devices, things and services and + allows them to interact. + With the powerful rule engine you are able to connect any device available + in the system and create individual scenes and behaviors for your environment. + . + This package will install the nymea.io plugin for simulated devices + + Package: nymea-plugin-sonos Architecture: any Depends: ${shlibs:Depends}, @@ -836,6 +852,7 @@ Depends: nymea-plugin-awattar, nymea-plugin-tasmota, nymea-plugin-wemo, nymea-plugin-elgato, + nymea-plugin-shelly, nymea-plugin-senic, nymea-plugin-sonos, nymea-plugin-keba, diff --git a/debian/nymea-plugin-shelly.install.in b/debian/nymea-plugin-shelly.install.in new file mode 100644 index 00000000..c8767a56 --- /dev/null +++ b/debian/nymea-plugin-shelly.install.in @@ -0,0 +1 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_devicepluginshelly.so diff --git a/mqttclient/devicepluginmqttclient.cpp b/mqttclient/devicepluginmqttclient.cpp index a0d25fb6..a19eaabd 100644 --- a/mqttclient/devicepluginmqttclient.cpp +++ b/mqttclient/devicepluginmqttclient.cpp @@ -38,7 +38,7 @@ void DevicePluginMqttClient::setupDevice(DeviceSetupInfo *info) 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()); diff --git a/nymea-plugins.pro b/nymea-plugins.pro index 4954e159..9e1177a0 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -35,6 +35,7 @@ PLUGIN_DIRS = \ osdomotics \ philipshue \ pushbullet \ + shelly \ systemmonitor \ remotessh \ senic \ diff --git a/shelly/README.md b/shelly/README.md new file mode 100644 index 00000000..7148ec3d --- /dev/null +++ b/shelly/README.md @@ -0,0 +1,29 @@ +# Shelly + +The Shelly plugin adds support for Shelly devices (https://shelly.cloud). + +Currently the Shelly1 and Shelly1PM are supported. + +## Requirements +Shelly devices communicate with via MQTT. This means, in order to add Shelly devices to nymea, the nymea instance is required +to have the MQTT broker enabled in the nymea settings and the Shelly device needs to be connected to the same WiFi as nymea is +in. New Shelly devices will open a WiFi named with their name as SSID. For instance, a Shelly 1 would appear as "shelly1-XXXXXX". +Connect to this WiFi and open the webpage that will pop up. From there, it can be configured it to connect to the same +network where the nymea system is located. No other options need to be set as they can be configured using nymea later on. + + +## Setting up devices +Once the Shelly is connected to the WiFi, a device discovery in nymea can be performed and will list the Shelly device. +During setup, the connected device can be configured. If the Shelly is connected to e.g. a light bulb, choose "Light" here. +Optionally, a username and password can be set. If the Shelly device is already configured to require authentication, +the username and password here must match the ones set on the Shelly. NOTE: If the Shelly is not configured to require a +login yet, but credentials are entered during setup, the Shelly device will be configured to require authentication from +now on. + +## Plugin properties +When adding a Shelly device it will add a new Gateway type device representing the Shelly device itself. It will allow +basic monitoring (such as the connected state) and interaction (e.g. reboot the Shelly device). In addition to that, a +power switch device will appear which will reflect presses on the Shelly's SW input. This power switch device also +offers the possiblity to configure the used switch (e.g. toggle, momentary, edge or detached from the Shelly's output). +If a connected device has been selected during setup, an additional device, e.g. the light will appear in the system and +can be used to control the power output of the Shelly, e.g. turning on or off the connected light. diff --git a/shelly/devicepluginshelly.cpp b/shelly/devicepluginshelly.cpp new file mode 100644 index 00000000..c93b8486 --- /dev/null +++ b/shelly/devicepluginshelly.cpp @@ -0,0 +1,395 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2019 Michael Zanetti * + * * + * 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 * + * . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "devicepluginshelly.h" +#include "plugininfo.h" + +#include +#include +#include +#include + +#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() +{ + m_connectedStateTypesMap[shellySwitchDeviceClassId] = shellySwitchConnectedStateTypeId; + m_connectedStateTypesMap[shellyGenericDeviceClassId] = shellyGenericConnectedStateTypeId; + m_connectedStateTypesMap[shellyLightDeviceClassId] = shellyLightConnectedStateTypeId; + + m_powerActionTypesMap[shellyGenericPowerActionTypeId] = shellyGenericDeviceClassId; + m_powerActionTypesMap[shellyLightPowerActionTypeId] = shellyLightDeviceClassId; + + m_powerActionParamTypesMap[shellyGenericPowerActionTypeId] = shellyGenericPowerActionPowerParamTypeId; + m_powerActionParamTypesMap[shellyLightPowerActionTypeId] = shellyLightPowerActionPowerParamTypeId; + + m_powerStateTypeMap[shellyGenericDeviceClassId] = shellyGenericPowerStateTypeId; + m_powerStateTypeMap[shellyLightDeviceClassId] = shellyLightPowerStateTypeId; +} + +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; + if (info->deviceClassId() == shelly1DeviceClassId) { + namePattern = QRegExp("^shelly(1|1pm)-[0-9A-Z]+$"); + } + if (!entry.name().contains(namePattern)) { + continue; + } + + DeviceDescriptor descriptor(shelly1DeviceClassId, entry.name(), entry.hostAddress().toString()); + ParamList params; + params << Param(shelly1DeviceIdParamTypeId, 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() == shelly1DeviceClassId) { + setupShellyGateway(info); + return; + } + + setupShellyChild(info); +} + +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() == shelly1RebootActionTypeId) { + QUrl url; + url.setScheme("http"); + url.setHost(getIP(info->device())); + url.setPath("/reboot"); + url.setUserName(device->paramValue(shelly1DeviceUsernameParamTypeId).toString()); + url.setPassword(device->paramValue(shelly1DevicePasswordParamTypeId).toString()); + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, info, [info, reply](){ + info->finish(reply->error() == QNetworkReply::NoError ? Device::DeviceErrorNoError : Device::DeviceErrorHardwareFailure); + }); + return; + } + + if (m_powerActionTypesMap.contains(action.actionTypeId())) { + Device *parentDevice = myDevices().findById(device->parentId()); + MqttChannel *channel = m_mqttChannels.value(parentDevice); + QString shellyId = parentDevice->paramValue(shelly1DeviceIdParamTypeId).toString(); + ParamTypeId powerParamTypeId = m_powerActionParamTypesMap.value(action.actionTypeId()); + bool on = action.param(powerParamTypeId).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(shelly1ConnectedStateTypeId, true); + + foreach (Device *child, myDevices().filterByParentDeviceId(device->id())) { + child->setStateValue(m_connectedStateTypesMap[child->deviceClassId()], 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(shelly1ConnectedStateTypeId, false); + + foreach (Device *child, myDevices().filterByParentDeviceId(device->id())) { + child->setStateValue(m_connectedStateTypesMap[child->deviceClassId()], 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(shelly1DeviceIdParamTypeId).toString(); + if (topic == "shellies/" + shellyId + "/input/0") { + // "1" or "0" + // Emit event button pressed + bool on = payload == "1"; + foreach (Device *child, myDevices().filterByParentDeviceId(device->id())) { + if (child->deviceClassId() == shellySwitchDeviceClassId) { + if (child->stateValue(shellySwitchPowerStateTypeId).toBool() != on) { + child->setStateValue(shellySwitchPowerStateTypeId, on); + emit emitEvent(Event(shellySwitchPressedEventTypeId, child->id())); + } + } + } + } + + if (topic == "shellies/" + shellyId + "/relay/0") { + bool on = payload == "on"; + + foreach (Device *child, myDevices().filterByParentDeviceId(device->id())) { + if (m_powerStateTypeMap.contains(child->deviceClassId())) { + child->setStateValue(m_powerStateTypeMap.value(child->deviceClassId()), on); + } + } + } + qCDebug(dcShelly()) << "Publish received from" << device->name() << topic << payload; +} + +void DevicePluginShelly::setupShellyGateway(DeviceSetupInfo *info) +{ + QString shellyId = info->device()->paramValue(shelly1DeviceIdParamTypeId).toString(); + ZeroConfServiceEntry zeroConfEntry; + foreach (const ZeroConfServiceEntry &entry, m_zeroconfBrowser->serviceEntries()) { + if (entry.name() == shellyId) { + zeroConfEntry = entry; + } + } + QHostAddress address; + pluginStorage()->beginGroup(info->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"); + url.setUserName(info->device()->paramValue(shelly1DeviceUsernameParamTypeId).toString()); + url.setPassword(info->device()->paramValue(shelly1DevicePasswordParamTypeId).toString()); + + 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, address](){ + if (reply->error() != QNetworkReply::NoError) { + hardwareManager()->mqttProvider()->releaseChannel(channel); + qCWarning(dcShelly()) << "Error fetching device settings" << reply->error() << reply->errorString(); + if (reply->error() == QNetworkReply::AuthenticationRequiredError) { + info->finish(Device::DeviceErrorAuthenticationFailure, QT_TR_NOOP("Username and password not set correctly.")); + } else { + info->finish(Device::DeviceErrorHardwareFailure, QT_TR_NOOP("Error connecting to Shelly device.")); + } + 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); + + DeviceDescriptors autoChilds; + + // Always create the switch device if we don't have one yet + if (myDevices().filterByParentDeviceId(info->device()->id()).filterByDeviceClassId(shellySwitchDeviceClassId).isEmpty()) { + DeviceDescriptor switchChild(shellySwitchDeviceClassId, "Shelly switch", QString(), info->device()->id()); + autoChilds.append(switchChild); + } + + // Add connected devices as configured in params + if (info->device()->paramValue(shelly1DeviceConnectedDeviceParamTypeId).toString() == "Generic") { + if (myDevices().filterByParentDeviceId(info->device()->id()).filterByDeviceClassId(shellyGenericDeviceClassId).isEmpty()) { + DeviceDescriptor genericChild(shellyGenericDeviceClassId, "Shelly connected device", QString(), info->device()->id()); + autoChilds.append(genericChild); + } + } + if (info->device()->paramValue(shelly1DeviceConnectedDeviceParamTypeId).toString() == "Light") { + if (myDevices().filterByParentDeviceId(info->device()->id()).filterByDeviceClassId(shellyLightDeviceClassId).isEmpty()) { + DeviceDescriptor genericChild(shellyLightDeviceClassId, "Shelly connected light", QString(), info->device()->id()); + autoChilds.append(genericChild); + } + } + + info->finish(Device::DeviceErrorNoError); + + emit autoDevicesAppeared(autoChilds); + + // Make sure authentication is enalbed if the user wants it + QString username = info->device()->paramValue(shelly1DeviceUsernameParamTypeId).toString(); + QString password = info->device()->paramValue(shelly1DevicePasswordParamTypeId).toString(); + if (!username.isEmpty()) { + QUrl url; + url.setScheme("http"); + url.setHost(address.toString()); + url.setPort(80); + url.setPath("/settings/login"); + url.setUserName(username); + url.setPassword(password); + + QUrlQuery query; + query.addQueryItem("username", username); + query.addQueryItem("password", password); + query.addQueryItem("enabled", "true"); + + url.setQuery(query); + + QNetworkRequest request(url); + qCDebug(dcShelly()) << "Enabling auth" << username << password; + QNetworkReply *reply = hardwareManager()->networkManager()->get(request); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + } + }); +} + +void DevicePluginShelly::setupShellyChild(DeviceSetupInfo *info) +{ + Device *device = info->device(); + + // Connect to settings changes to store them to the device + connect(info->device(), &Device::settingChanged, this, [this, device](const ParamTypeId ¶mTypeId, const QVariant &value){ + Device *parentDevice = myDevices().findById(device->parentId()); + pluginStorage()->beginGroup(parentDevice->id().toString()); + QString address = pluginStorage()->value("cachedAddress").toString(); + pluginStorage()->endGroup(); + + QUrl url; + url.setScheme("http"); + url.setHost(address); + url.setPort(80); + url.setPath("/settings/relay/0"); + url.setUserName(parentDevice->paramValue(shelly1DeviceUsernameParamTypeId).toString()); + url.setPassword(parentDevice->paramValue(shelly1DevicePasswordParamTypeId).toString()); + + QUrlQuery query; + if (paramTypeId == shellySwitchSettingsButtonTypeParamTypeId) { + query.addQueryItem("btn_type", value.toString()); + } + if (paramTypeId == shellySwitchSettingsInvertButtonParamTypeId) { + query.addQueryItem("btn_reverse", value.toBool() ? "1" : "0"); + } + if (paramTypeId == shellyGenericSettingsDefaultStateParamTypeId || paramTypeId == shellyLightSettingsDefaultStateParamTypeId) { + query.addQueryItem("default_state", value.toString()); + } + + url.setQuery(query); + + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + }); + + info->finish(Device::DeviceErrorNoError); +} + +QString DevicePluginShelly::getIP(Device *device) const +{ + Device *d = device; + if (!device->parentId().isNull()) { + d = myDevices().findById(device->parentId()); + } + pluginStorage()->beginGroup(d->id().toString()); + QString ip = pluginStorage()->value("cachedAddress").toString(); + pluginStorage()->endGroup(); + return ip; +} diff --git a/shelly/devicepluginshelly.h b/shelly/devicepluginshelly.h new file mode 100644 index 00000000..085e0891 --- /dev/null +++ b/shelly/devicepluginshelly.h @@ -0,0 +1,70 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2019 Michael Zanetti * + * * + * 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 . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#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: + void setupShellyGateway(DeviceSetupInfo *info); + void setupShellyChild(DeviceSetupInfo *info); + + QString getIP(Device *device) const; + +private: + ZeroConfServiceBrowser *m_zeroconfBrowser = nullptr; + + QHash m_mqttChannels; + + QHash m_connectedStateTypesMap; + QHash m_powerStateTypeMap; + QHash m_powerActionTypesMap; + QHash m_powerActionParamTypesMap; +}; + +#endif // DEVICEPLUGINSHELLY_H diff --git a/shelly/devicepluginshelly.json b/shelly/devicepluginshelly.json new file mode 100644 index 00000000..11f525b4 --- /dev/null +++ b/shelly/devicepluginshelly.json @@ -0,0 +1,196 @@ +{ + "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": "shelly1", + "displayName": "Shelly1 / Shelly1PM", + "createMethods": ["discovery"], + "interfaces": [ "gateway" ], + "paramTypes": [ + { + "id": "1d301dc0-5e48-473f-a611-8e407289e545", + "name":"id", + "displayName": "Shelly ID", + "type": "QString", + "readOnly": true + }, + { + "id": "d0e0499e-faa0-432a-a760-c295b0aefed0", + "name": "connectedDevice", + "displayName": "Connected device", + "type": "QString", + "allowedValues": ["None", "Generic", "Light"], + "defaultValue": "Generic" + }, + { + "id": "fa1aa0f6-93b2-410d-a2c5-7b2f45eae679", + "name": "username", + "displayName": "Username (optional)", + "type": "QString" + }, + { + "id": "d29b8399-bfa6-4146-921d-a1d43ca4e184", + "name": "password", + "displayName": "Password (optional)", + "type": "QString" + } + ], + "stateTypes": [ + { + "id": "e5d41e05-2296-457e-97d8-98a5ac0de615", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + } + ], + "actionTypes": [ + { + "id": "b4067d54-36c5-4d30-bbc3-c8c712d6fd32", + "name": "reboot", + "displayName": "Reboot" + } + ] + }, + { + "id": "6de35a17-0f54-4397-894d-4321b64c53d1", + "name": "shellySwitch", + "displayName": "Shelly switch", + "createMethods": ["auto"], + "interfaces": [ "powerswitch", "connectable"], + "settingsTypes": [ + { + "id": "ce9f1650-5e12-40f4-97de-27af86afa40b", + "name": "buttonType", + "displayName": "Button type", + "allowedValues": ["momentary", "toggle", "edge", "detached"], + "type": "QString", + "defaultValue": "toggle" + }, + { + "id": "f31eb52b-9aaf-409d-8bba-badda7c1a249", + "name": "invertButton", + "displayName": "Invert button", + "type": "bool", + "defaultValue": false + } + ], + "stateTypes": [ + { + "id": "0c233312-7b8f-4ca3-880d-523cab9b3ccb", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected or disconnected", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "20f74d88-0683-4d3a-9513-6b29b5112b7b", + "name": "power", + "displayName": "On/Off", + "displayNameEvent": "On/Off toggled", + "type": "bool", + "defaultValue": false + } + ], + "eventTypes": [ + { + "id": "41498655-1943-4b46-ac36-adea7bafab87", + "name": "pressed", + "displayName": "Pressed" + } + ] + }, + { + "id": "512c3c7d-d6a6-4d2a-bccd-83147e5f9a25", + "name": "shellyGeneric", + "displayName": "Shelly connected device", + "createMethods": ["auto"], + "interfaces": ["power", "connectable"], + "settingsTypes": [ + { + "id": "7d35aea3-1444-48c8-9732-a41bfc3b9d75", + "name": "defaultState", + "displayName": "Default state", + "allowedValues": ["on", "off", "last", "switch"], + "defaultValue": "off", + "type": "QString" + } + + ], + "stateTypes": [ + { + "id": "4a141674-faa6-4953-8272-5b4a4da84d31", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected or disconnected", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "72d7dbba-757c-4b03-a092-1d3f374fa961", + "name": "power", + "displayName": "Power", + "displayNameEvent": "Turned on or off", + "displayNameAction": "Turn on or off", + "type": "bool", + "defaultValue": false, + "writable": true + } + + ] + }, + { + "id": "62a2d6b8-d70d-45fc-ba8c-1c680282a399", + "name": "shellyLight", + "displayName": "Shelly connected light", + "createMethods": ["auto"], + "interfaces": ["light", "connectable"], + "settingsTypes": [ + { + "id": "4fe9ae31-3657-41bf-bd40-a219d58465d3", + "name": "defaultState", + "displayName": "Default state", + "allowedValues": ["on", "off", "last", "switch"], + "defaultValue": "off", + "type": "QString" + } + ], + "stateTypes": [ + { + "id": "61b7d8ac-d229-4268-8143-6edb2eca978d", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected or disconnected", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "2ee5bfab-271e-4b95-9464-122a5208f1a5", + "name": "power", + "displayName": "Power", + "displayNameEvent": "Turned on or off", + "displayNameAction": "Turn on or off", + "type": "bool", + "defaultValue": false, + "writable": true + } + ] + } + ] + } + ] +} diff --git a/shelly/shelly.pro b/shelly/shelly.pro new file mode 100644 index 00000000..f6ee82c4 --- /dev/null +++ b/shelly/shelly.pro @@ -0,0 +1,9 @@ +include(../plugins.pri) + +QT += network + +SOURCES += \ + devicepluginshelly.cpp \ + +HEADERS += \ + devicepluginshelly.h \ diff --git a/tasmota/deviceplugintasmota.cpp b/tasmota/deviceplugintasmota.cpp index 4d3297a4..a87f7267 100644 --- a/tasmota/deviceplugintasmota.cpp +++ b/tasmota/deviceplugintasmota.cpp @@ -87,7 +87,7 @@ void DevicePluginTasmota::setupDevice(DeviceSetupInfo *info) //: Error setting up device return info->finish(Device::DeviceErrorInvalidParameter, QT_TR_NOOP("The given IP address is not valid.")); } - 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."; //: Error setting up device @@ -103,7 +103,7 @@ void DevicePluginTasmota::setupDevice(DeviceSetupInfo *info) 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()) { @@ -229,8 +229,8 @@ void DevicePluginTasmota::executeAction(DeviceActionInfo *info) } 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 info->finish(Device::DeviceErrorNoError); } @@ -244,20 +244,20 @@ void DevicePluginTasmota::executeAction(DeviceActionInfo *info) 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 info->finish(Device::DeviceErrorNoError); } @@ -295,7 +295,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()) { @@ -314,7 +314,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) {