diff --git a/debian/control b/debian/control index 49ef4ffd..3e08f32e 100644 --- a/debian/control +++ b/debian/control @@ -352,6 +352,16 @@ Conflicts: nymea-plugins-translations (< 1.0.1) Description: nymea integration plugin for lifx This package contains the nymea integration plugin for lifx devices +Package: nymea-plugin-logilink +Architecture: any +Section: libs +Depends: ${shlibs:Depends}, + ${misc:Depends}, +Conflicts: nymea-plugins-translations (< 1.0.1) +Description: nymea integration plugin for Logilink power sockets + This package contains the nymea integration plugin for Logilink PDU + network controlled power sockets. + Package: nymea-plugin-mecelectronics Architecture: any Depends: ${shlibs:Depends}, diff --git a/debian/nymea-plugin-logilink.install.in b/debian/nymea-plugin-logilink.install.in new file mode 100644 index 00000000..fcf460cb --- /dev/null +++ b/debian/nymea-plugin-logilink.install.in @@ -0,0 +1,2 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationpluginlogilink.so +logilink/translations/*qm usr/share/nymea/translations/ diff --git a/logilink/README.md b/logilink/README.md new file mode 100644 index 00000000..00531636 --- /dev/null +++ b/logilink/README.md @@ -0,0 +1,22 @@ +# Logilink + +This integration allows nymea to control Logilink power sockets. + +## Supported things + +* PDU8P01 + +* Secure connection with username and password +* Get and set the state of each socket +* No internet or cloud connection required + +## Requirements + +* The Logilink device must be in the same local area network as nymea. +* TCP Sockets on port 80 must not be blocked by the router. +* Access to the device login credentials. +* The package “nymea-plugin-logilink” must be installed + +## More + +See https://logilink.de for a detailed description of the devices. diff --git a/logilink/integrationpluginlogilink.cpp b/logilink/integrationpluginlogilink.cpp new file mode 100644 index 00000000..c5058ada --- /dev/null +++ b/logilink/integrationpluginlogilink.cpp @@ -0,0 +1,288 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2024, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "integrationpluginlogilink.h" +#include "plugininfo.h" +#include "plugintimer.h" + +#include +#include +#include +#include +#include + +IntegrationPluginLogilink::IntegrationPluginLogilink() +{ +} + +IntegrationPluginLogilink::~IntegrationPluginLogilink() +{ + m_pollTimer->deleteLater(); +} + +void IntegrationPluginLogilink::startPairing(ThingPairingInfo *info) +{ + info->finish(Thing::ThingErrorNoError, QT_TR_NOOP("Please enter the login credentials for your device.")); +} + +void IntegrationPluginLogilink::init() +{ + m_pollTimer = hardwareManager()->pluginTimerManager()->registerTimer(m_pollInterval); + connect(m_pollTimer, &PluginTimer::timeout, this, &IntegrationPluginLogilink::refreshStates); +} + +void IntegrationPluginLogilink::confirmPairing(ThingPairingInfo *info, const QString &username, const QString &password) +{ + if (info->thingClassId() == pdu8p01ThingClassId) { + QString ipAddress = info->params().paramValue(pdu8p01ThingIpv4AddressParamTypeId).toString(); + + QNetworkRequest request; + request.setUrl(QUrl(QString("http://%1/status.xml").arg(ipAddress))); + request.setRawHeader("Authorization", "Basic " + QString("%1:%2").arg(username).arg(password).toUtf8().toBase64()); + qCDebug(dcLogilink()) << "ConfirmPairing fetching:" << request.url() << request.rawHeader("Authorization"); + QNetworkReply *reply = hardwareManager()->networkManager()->get(request); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, info, [=](){ + if (reply->error() == QNetworkReply::NoError) { + pluginStorage()->beginGroup(info->thingId().toString()); + pluginStorage()->setValue("username", username); + pluginStorage()->setValue("password", password); + pluginStorage()->endGroup(); + info->finish(Thing::ThingErrorNoError); + } else { + info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Wrong username or password.")); + } + }); + } else { + qCWarning(dcLogilink()) << "Unhandled ThingClass in confirmPairing" << info->thingClassId(); + info->finish(Thing::ThingErrorThingClassNotFound); + } +} + +void IntegrationPluginLogilink::setupThing(ThingSetupInfo *info) +{ + Thing *thing = info->thing(); + qCDebug(dcLogilink()) << "Setup" << thing->name(); + + if (thing->thingClassId() == pdu8p01ThingClassId) { + if (!m_pollTimer->running()) { + m_pollTimer->start(); + } + + QString ipAddress = thing->paramValue(pdu8p01ThingIpv4AddressParamTypeId).toString(); + + QNetworkRequest request; + pluginStorage()->beginGroup(thing->id().toString()); + const QString username = pluginStorage()->value("username").toString(); + const QString password = pluginStorage()->value("password").toString(); + pluginStorage()->endGroup(); + + request.setUrl(QUrl(QString("http://%1/status.xml").arg(ipAddress))); + request.setRawHeader("Authorization", "Basic " + QString("%1:%2").arg(username).arg(password).toUtf8().toBase64()); + QNetworkReply *reply = hardwareManager()->networkManager()->get(request); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, info, [=](){ + if (reply->error() == QNetworkReply::NoError) { + info->finish(Thing::ThingErrorNoError); + + if (myThings().filterByParentId(thing->id()).isEmpty()) { + // Creating sockets as child 'things' + ThingDescriptors descriptorList; + for (int i = 0; i < 8; i++) { + QString deviceName = thing->name() + " socket " + QString::number(i); + ThingDescriptor d(socketThingClassId, deviceName, thing->name(), thing->id()); + d.setParams(ParamList() << Param(socketThingNumberParamTypeId, i)); + descriptorList << d; + } + emit autoThingsAppeared(descriptorList); + } + } else if (reply->error() == QNetworkReply::AuthenticationRequiredError) { + info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Wrong username or password")); + } else { + info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Device not found")); + } + }); + + return; + } + + if (thing->thingClassId() == socketThingClassId) { + info->finish(Thing::ThingErrorNoError); + return; + } + + qCWarning(dcLogilink()) << "Unhandled ThingClass in setupDevice" << thing->thingClassId(); + info->finish(Thing::ThingErrorThingClassNotFound); +} + +void IntegrationPluginLogilink::postSetupThing(Thing *thing) +{ + qCDebug(dcLogilink()) << "Post setup" << thing->name(); + if (thing->thingClassId() == pdu8p01ThingClassId) { + getStates(thing); + } +} + +void IntegrationPluginLogilink::thingRemoved(Thing *thing) +{ + qCDebug(dcLogilink()) << "Thing removed" << thing->name(); + if (thing->thingClassId() == pdu8p01ThingClassId) { + pluginStorage()->remove(thing->id().toString()); + } + + if (myThings().filterByThingClassId(pdu8p01ThingClassId).isEmpty()) { + m_pollTimer->stop(); + } +} + +void IntegrationPluginLogilink::executeAction(ThingActionInfo *info) +{ + Thing *thing = info->thing(); + Action action = info->action(); + + if (thing->thingClassId() == socketThingClassId) { + if (action.actionTypeId() == socketPowerActionTypeId) { + + Thing *parentDevice = myThings().findById(thing->parentId()); + auto ipAddress = parentDevice->paramValue(pdu8p01ThingIpv4AddressParamTypeId).toString(); + pluginStorage()->beginGroup(parentDevice->id().toString()); + QString username = pluginStorage()->value("username").toString(); + QString password = pluginStorage()->value("password").toString(); + pluginStorage()->endGroup(); + + QUrl url(QString("http://%1/control_outlet.htm").arg(ipAddress)); + QUrlQuery query; + query.addQueryItem("outlet" + thing->paramValue(socketThingNumberParamTypeId).toString(), "1"); + query.addQueryItem("op", action.param(socketPowerActionPowerParamTypeId).value().toBool() ? "0" : "1"); // op code 0 is on, 1 is off + query.addQueryItem("submit", "Apply"); + url.setQuery(query); + QNetworkRequest request(url); + request.setRawHeader("Authorization", "Basic " + QString("%1:%2").arg(username, password).toUtf8().toBase64()); + + QNetworkReply *reply = hardwareManager()->networkManager()->get(request); + qCDebug(dcLogilink()) << "Requesting:" << url.toString() << request.rawHeader("Authorization"); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, info, [reply, info](){ + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcLogilink()) << "Execute action failed:" << reply->error() << reply->errorString(); + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + info->finish(Thing::ThingErrorNoError); + }); + return; + } + info->finish(Thing::ThingErrorActionTypeNotFound); + } + info->finish(Thing::ThingErrorThingClassNotFound); +} + +void IntegrationPluginLogilink::refreshStates() +{ + foreach (Thing *thing, myThings().filterByThingClassId(pdu8p01ThingClassId)) { + getStates(thing); + } +} + +void IntegrationPluginLogilink::getStates(Thing *thing) +{ + auto ipAddress = thing->paramValue(pdu8p01ThingIpv4AddressParamTypeId).toString(); + pluginStorage()->beginGroup(thing->id().toString()); + QString username = pluginStorage()->value("username").toString(); + QString password = pluginStorage()->value("password").toString(); + pluginStorage()->endGroup(); + + QUrl url(QString("http://%1/status.xml").arg(ipAddress)); + + QNetworkRequest request; + request.setUrl(url); + request.setRawHeader("Authorization", "Basic " + QString("%1:%2").arg(username, password).toUtf8().toBase64()); + QNetworkReply *reply = hardwareManager()->networkManager()->get(request); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, thing, [this, thing, reply](){ + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcLogilink()) << "Error fetching stats for" << thing->name() << reply->errorString(); + thing->setStateValue(pdu8p01ConnectedStateTypeId, false); + foreach (auto child, myThings().filterByParentId(thing->id())) { + child->setStateValue(socketConnectedStateTypeId, false); + } + return; + } + QXmlStreamReader xml; + xml.addData(reply->readAll()); + if (xml.hasError()) { + qCDebug(dcLogilink()) << "XML Error:" << xml.errorString(); + return; + } + thing->setStateValue(pdu8p01ConnectedStateTypeId, true); + if (xml.readNextStartElement()) { + if (xml.name() == "response") { + qCDebug(dcLogilink()) << "XML contains response"; + } else { + qCWarning(dcLogilink()) << "xml name" << xml.name(); + } + while(xml.readNextStartElement()) { + qCDebug(dcLogilink()) << "XML name" << xml.name(); + if (xml.name() == "curBan") { + auto current = xml.readElementText().toDouble(); + qCDebug(dcLogilink()) << "Current" << current; + thing->setStateValue(pdu8p01TotalLoadStateTypeId, current); + } else if (xml.name() == "statBan") { + auto status = xml.readElementText(); + qCDebug(dcLogilink()) << "Status" << status; + thing->setStateValue(pdu8p01StatusStateTypeId, status); + } else if (xml.name() == "tempBan") { + auto temperature = xml.readElementText().toDouble(); + qCDebug(dcLogilink()) << "Temperature" << temperature; + thing->setStateValue(pdu8p01TemperatureStateTypeId, temperature); + } else if (xml.name() == "humBan") { + auto humidity = xml.readElementText().toDouble(); + qCDebug(dcLogilink()) << "hummidity" << humidity; + thing->setStateValue(pdu8p01HumidityStateTypeId, humidity); + } else if (xml.name().startsWith("outletStat")){ + int socketNumber = xml.name().right(1).toInt(); + bool socketValue = xml.readElementText().startsWith("on"); + auto socketThing = myThings().filterByParentId(thing->id()) + .filterByParam(socketThingNumberParamTypeId, socketNumber) + .first(); + if (!socketThing) { + // Socket not yet setup + continue; + } + qCDebug(dcLogilink()) << "Socket" << socketNumber << socketValue; + socketThing->setStateValue(socketPowerStateTypeId, socketValue); + socketThing->setStateValue(socketConnectedStateTypeId, true); + } else { + xml.skipCurrentElement(); + } + } + } + }); +} diff --git a/logilink/integrationpluginlogilink.h b/logilink/integrationpluginlogilink.h new file mode 100644 index 00000000..5d77ad79 --- /dev/null +++ b/logilink/integrationpluginlogilink.h @@ -0,0 +1,69 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2024, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef INTEGRATIONPLUGINLOGILINK_H +#define INTEGRATIONPLUGINLOGILINK_H + +#include "integrations/integrationplugin.h" + +#include + +class PluginTimer; + +class IntegrationPluginLogilink: public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginlogilink.json") + Q_INTERFACES(IntegrationPlugin) + +public: + explicit IntegrationPluginLogilink(); + ~IntegrationPluginLogilink(); + + void startPairing(ThingPairingInfo *info) override; + void init() override; + void confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret) override; + void setupThing(ThingSetupInfo *info) override; + void postSetupThing(Thing *thing) override; + void thingRemoved(Thing *thing) override; + void executeAction(ThingActionInfo *info) override; + +private slots: + void refreshStates(); + +private: + PluginTimer *m_pollTimer = nullptr; + const int m_pollInterval = 1; // seconds + + void getStates(Thing *thing); +}; + +#endif // INTEGRATIONPLUGINLOGILINK_H diff --git a/logilink/integrationpluginlogilink.json b/logilink/integrationpluginlogilink.json new file mode 100644 index 00000000..1a611e3a --- /dev/null +++ b/logilink/integrationpluginlogilink.json @@ -0,0 +1,116 @@ +{ + "name": "logilink", + "displayName": "Logilink", + "id": "3e80496e-06b5-11ef-8f86-2be8f966eb8d", + "vendors": [ + { + "name": "logilink", + "displayName": "Logilink", + "id": "4aed2c3a-06b5-11ef-8a6b-ff770ac74f51", + "thingClasses": [ + { + "id": "d8162d6e-06b5-11ef-888f-13a1e6eca98c", + "name": "pdu8p01", + "displayName": "PDU8P01", + "createMethods": ["user"], + "setupMethod": "userandpassword", + "interfaces": [ "gateway", "temperaturesensor", "humiditysensor"], + "paramTypes": [ + { + "id": "d2e1a63e-06b5-11ef-bf2d-5f0d67f222af", + "name":"ipv4Address", + "displayName": "IPv4 address", + "type": "QString", + "inputType": "IPv4Address", + "defaultValue": "192.168.0.100" + } + ], + "stateTypes": [ + { + "id": "c5c7f1ba-06b5-11ef-ad08-03562210396e", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "413af8c0-0796-11ef-bd2b-4fde50f95418", + "name": "totalLoad", + "displayName": "Total load", + "displayNameEvent": "Total load changed", + "type": "double", + "defaultValue": 0, + "unit": "Ampere" + }, + { + "id": "c0ce082a-06b5-11ef-b71a-7f0e8b9a48c0", + "name": "temperature", + "displayName": "Temperature", + "displayNameEvent": "Temperature changed", + "type": "double", + "defaultValue": 0, + "unit": "DegreeCelsius" + }, + { + "id": "1aaaf3ca-0793-11ef-8b3e-f7eff48006c2", + "name": "humidity", + "displayName": "Humidity", + "displayNameEvent": "Humidity changed", + "type": "double", + "defaultValue": 0, + "minValue": 0, + "maxValue": 100, + "unit": "Percentage" + }, + { + "id": "6ec71e40-0796-11ef-a8f8-a33a504a66bb", + "name": "status", + "displayName": "Status", + "displayNameEvent": "Status changed", + "type": "QString", + "defaultValue": "Unknown" + } + ] + }, + { + "id": "a320bfb6-06b5-11ef-81e4-834aeaf5fe45", + "name": "socket", + "displayName": "Logilink Socket", + "createMethods": ["auto"], + "interfaces": ["powersocket", "connectable"], + "paramTypes": [ + { + "id": "b329edba-06b5-11ef-b04e-d3622c766dd0", + "name": "number", + "displayName": "Socket number", + "type": "int" + } + ], + "stateTypes": [ + { + "id": "b8d9679a-06b5-11ef-9812-fb840afffc88", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false + }, + { + "id": "47329958-c33f-478f-b2a0-910abd150da8", + "name": "power", + "displayName": "Power", + "displayNameEvent": "Power changed", + "displayNameAction": "Set power", + "writable": true, + "type": "bool", + "defaultValue": false, + "ioType": "digitalOutput" + } + ] + } + ] + } + ] +} diff --git a/logilink/logilink.png b/logilink/logilink.png new file mode 100644 index 00000000..24d65dd6 Binary files /dev/null and b/logilink/logilink.png differ diff --git a/logilink/logilink.pro b/logilink/logilink.pro new file mode 100644 index 00000000..41fb471d --- /dev/null +++ b/logilink/logilink.pro @@ -0,0 +1,11 @@ +include(../plugins.pri) + +QT += network xml + +TARGET = $$qtLibraryTarget(nymea_integrationpluginlogilink) + +SOURCES += \ + integrationpluginlogilink.cpp \ + +HEADERS += \ + integrationpluginlogilink.h \ diff --git a/logilink/meta.json b/logilink/meta.json new file mode 100644 index 00000000..0d1be361 --- /dev/null +++ b/logilink/meta.json @@ -0,0 +1,14 @@ +{ + "title": "Logilink", + "tagline": "Integrates Logilink with nymea.", + "icon": "logilink.png", + "stability": "consumer", + "offline": true, + "technologies": [ + "network" + ], + "categories": [ + "sensor", + "socket" + ] +} diff --git a/logilink/translations/3e80496e-06b5-11ef-8f86-2be8f966eb8d-en_US.ts b/logilink/translations/3e80496e-06b5-11ef-8f86-2be8f966eb8d-en_US.ts new file mode 100644 index 00000000..c077f8ca --- /dev/null +++ b/logilink/translations/3e80496e-06b5-11ef-8f86-2be8f966eb8d-en_US.ts @@ -0,0 +1,111 @@ + + + + + IntegrationPluginLogilink + + + Please enter the login credentials for your device. + + + + + Wrong username or password. + + + + + Wrong username or password + + + + + Device not found + + + + + logilink + + + + Connected + The name of the StateType ({b8d9679a-06b5-11ef-9812-fb840afffc88}) of ThingClass socket +---------- +The name of the StateType ({c5c7f1ba-06b5-11ef-ad08-03562210396e}) of ThingClass pdu8p01 + + + + + Humidity + The name of the StateType ({1aaaf3ca-0793-11ef-8b3e-f7eff48006c2}) of ThingClass pdu8p01 + + + + + IPv4 address + The name of the ParamType (ThingClass: pdu8p01, Type: thing, ID: {d2e1a63e-06b5-11ef-bf2d-5f0d67f222af}) + + + + + + Logilink + The name of the vendor ({4aed2c3a-06b5-11ef-8a6b-ff770ac74f51}) +---------- +The name of the plugin logilink ({3e80496e-06b5-11ef-8f86-2be8f966eb8d}) + + + + + Logilink Socket + The name of the ThingClass ({a320bfb6-06b5-11ef-81e4-834aeaf5fe45}) + + + + + PDU8P01 + The name of the ThingClass ({d8162d6e-06b5-11ef-888f-13a1e6eca98c}) + + + + + + Power + The name of the ParamType (ThingClass: socket, ActionType: power, ID: {47329958-c33f-478f-b2a0-910abd150da8}) +---------- +The name of the StateType ({47329958-c33f-478f-b2a0-910abd150da8}) of ThingClass socket + + + + + Set power + The name of the ActionType ({47329958-c33f-478f-b2a0-910abd150da8}) of ThingClass socket + + + + + Socket number + The name of the ParamType (ThingClass: socket, Type: thing, ID: {b329edba-06b5-11ef-b04e-d3622c766dd0}) + + + + + Status + The name of the StateType ({6ec71e40-0796-11ef-a8f8-a33a504a66bb}) of ThingClass pdu8p01 + + + + + Temperature + The name of the StateType ({c0ce082a-06b5-11ef-b71a-7f0e8b9a48c0}) of ThingClass pdu8p01 + + + + + Total load + The name of the StateType ({413af8c0-0796-11ef-bd2b-4fde50f95418}) of ThingClass pdu8p01 + + + + diff --git a/nymea-plugins.pro b/nymea-plugins.pro index 6e9da803..9f4b53b0 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -37,6 +37,7 @@ PLUGIN_DIRS = \ kodi \ lgsmarttv \ lifx \ + logilink \ mecelectronics \ meross \ mailnotification \