416 lines
20 KiB
C++
416 lines
20 KiB
C++
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
|
*
|
|
* Copyright 2013 - 2025, 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 <https://www.gnu.org/licenses/>.
|
|
*
|
|
* 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 "integrationpluginunifi.h"
|
|
#include "plugininfo.h"
|
|
|
|
#include <QNetworkReply>
|
|
#include <QNetworkRequest>
|
|
#include <QJsonDocument>
|
|
#include <QNetworkCookieJar>
|
|
|
|
#include <hardwaremanager.h>
|
|
#include <network/networkaccessmanager.h>
|
|
#include <plugintimer.h>
|
|
|
|
IntegrationPluginUnifi::IntegrationPluginUnifi(QObject *parent) : IntegrationPlugin(parent)
|
|
{
|
|
|
|
}
|
|
|
|
IntegrationPluginUnifi::~IntegrationPluginUnifi()
|
|
{
|
|
|
|
}
|
|
|
|
void IntegrationPluginUnifi::init()
|
|
{
|
|
}
|
|
|
|
void IntegrationPluginUnifi::discoverThings(ThingDiscoveryInfo *info)
|
|
{
|
|
Q_ASSERT_X(info->thingClassId() == clientThingClassId, "discoverDevices", "Invalid thing class in discovery");
|
|
|
|
Things controllers = myThings().filterByThingClassId(controllerThingClassId);
|
|
if (controllers.isEmpty()) {
|
|
info->finish(Thing::ThingErrorHardwareNotAvailable, QT_TR_NOOP("Please configure a UniFi controller first."));
|
|
return;
|
|
}
|
|
|
|
connect(info, &ThingDiscoveryInfo::aborted, this, [this, info](){
|
|
m_pendingDiscoveries.remove(info);
|
|
});
|
|
|
|
foreach (Thing *controller, controllers) {
|
|
m_pendingDiscoveries[info].append(controller);
|
|
QNetworkRequest request = createRequest(controller, "/api/self/sites");
|
|
QNetworkReply *sitesReply = hardwareManager()->networkManager()->get(request);
|
|
connect(sitesReply, &QNetworkReply::finished, sitesReply, &QNetworkReply::deleteLater);
|
|
connect(sitesReply, &QNetworkReply::finished, info, [this, info, sitesReply, controller](){
|
|
if (sitesReply->error() != QNetworkReply::NoError) {
|
|
qCWarning(dcUnifi()) << "Error fetching sites";
|
|
m_pendingDiscoveries[info].removeAll(controller);
|
|
if (m_pendingDiscoveries[info].isEmpty()) {
|
|
info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Fetching sites from controller failed."));
|
|
}
|
|
return;
|
|
}
|
|
QByteArray data = sitesReply->readAll();
|
|
QJsonParseError error;
|
|
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
|
|
if (error.error != QJsonParseError::NoError) {
|
|
qCWarning(dcUnifi()) << "Error parsing data" << data;
|
|
m_pendingDiscoveries[info].removeAll(controller);
|
|
if (m_pendingDiscoveries[info].isEmpty()) {
|
|
info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Error communicating with the controller."));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (jsonDoc.toVariant().toMap().value("meta").toMap().value("rc").toString() != "ok") {
|
|
qCWarning(dcUnifi()) << "Controller did not responde with OK" << qUtf8Printable(jsonDoc.toJson());
|
|
m_pendingDiscoveries[info].removeAll(controller);
|
|
if (m_pendingDiscoveries[info].isEmpty()) {
|
|
info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Fetching sites from controller failed."));
|
|
}
|
|
return;
|
|
}
|
|
|
|
qCDebug(dcUnifi()) << "**** sites reply finished" << data;
|
|
foreach (const QVariant &siteVariant, jsonDoc.toVariant().toMap().value("data").toList()) {
|
|
qCDebug(dcUnifi()) << "Have site:" << siteVariant.toMap().value("_id").toString() << siteVariant.toMap().value("name").toString() << siteVariant.toMap().value("desc").toString();
|
|
|
|
QString site = siteVariant.toMap().value("_id").toString();
|
|
QString siteName = siteVariant.toMap().value("name").toString();
|
|
QString siteDescription = siteVariant.toMap().value("desc").toString();
|
|
QNetworkRequest request = createRequest(controller, QString("/api/s/%1/stat/sta/").arg(siteName));
|
|
|
|
qCDebug(dcUnifi()) << "Fetching clients for site" << site << siteName << request.url();
|
|
|
|
m_pendingSiteDiscoveries[controller].append(siteName);
|
|
|
|
QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
|
|
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
|
connect(reply, &QNetworkReply::finished, info, [this, info, reply, controller, siteName](){
|
|
m_pendingSiteDiscoveries[controller].removeAll(siteName);
|
|
if (m_pendingSiteDiscoveries[controller].isEmpty()) {
|
|
m_pendingDiscoveries[info].removeAll(controller);
|
|
}
|
|
|
|
bool hasError = false;
|
|
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
qCWarning(dcUnifi()) << "Error fetching clients from site" << reply->error() << reply->errorString();
|
|
hasError = true;
|
|
} else {
|
|
QByteArray data = reply->readAll();
|
|
QJsonParseError error;
|
|
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
|
|
if (error.error != QJsonParseError::NoError) {
|
|
qCWarning(dcUnifi()) << "Error parsing json for clients reply:" << error.errorString() << data;
|
|
hasError = true;
|
|
} else {
|
|
QVariantMap response = jsonDoc.toVariant().toMap();
|
|
if (response.value("meta").toMap().value("rc").toString() != "ok") {
|
|
qCWarning(dcUnifi()) << "Error response from controller:" << qUtf8Printable(jsonDoc.toJson());
|
|
hasError = true;
|
|
} else {
|
|
QVariantList clients = response.value("data").toList();
|
|
foreach (const QVariant &clientVariant, clients) {
|
|
// qCDebug(dcUnifi()) << "client:" << qUtf8Printable(QJsonDocument::fromVariant(clientVariant).toJson());
|
|
|
|
QString name = clientVariant.toMap().value("name").toString();
|
|
if (name.isEmpty()) {
|
|
name = clientVariant.toMap().value("hostname").toString();
|
|
}
|
|
if (name.isEmpty()) {
|
|
name = clientVariant.toMap().value("oui").toString();
|
|
}
|
|
ThingDescriptor d(clientThingClassId, name, clientVariant.toMap().value("mac").toString());
|
|
ParamList params;
|
|
params << Param(clientThingMacParamTypeId, clientVariant.toMap().value("mac").toString());
|
|
params << Param(clientThingSiteParamTypeId, siteName);
|
|
d.setParams(params);
|
|
|
|
Thing *existingThing = myThings().findByParams(params);
|
|
if (existingThing) {
|
|
d.setThingId(existingThing->id());
|
|
}
|
|
|
|
d.setParentId(controller->id());
|
|
|
|
info->addThingDescriptor(d);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
if (m_pendingDiscoveries[info].isEmpty()) {
|
|
info->finish(hasError ? Thing::ThingErrorHardwareFailure : Thing::ThingErrorNoError);
|
|
}
|
|
});
|
|
}
|
|
|
|
});
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginUnifi::startPairing(ThingPairingInfo *info)
|
|
{
|
|
info->finish(Thing::ThingErrorNoError, QT_TR_NOOP("Please enter your login credentials for the UniFi controller."));
|
|
}
|
|
|
|
void IntegrationPluginUnifi::confirmPairing(ThingPairingInfo *info, const QString &username, const QString &secret)
|
|
{
|
|
QString host = info->params().paramValue(controllerThingIpAddressParamTypeId).toString();
|
|
uint port = info->params().paramValue(controllerThingPortParamTypeId).toUInt();
|
|
QString path;
|
|
if (info->params().paramValue(controllerThingModeParamTypeId).toString() == "UniFi OS") {
|
|
path = "/api/auth/login";
|
|
} else {
|
|
path = "/api/login";
|
|
}
|
|
QNetworkRequest request = createRequest(host, port, path);
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
|
QVariantMap login;
|
|
login.insert("username", username);
|
|
login.insert("password", secret);
|
|
qCDebug(dcUnifi()) << "ConfirmPairing: Sending request:" << request.url().toString() << QJsonDocument::fromVariant(login).toJson();
|
|
QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(login).toJson());
|
|
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
|
connect(reply, &QNetworkReply::finished, info, [this, info, reply, username, secret](){
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
qCWarning(dcUnifi()) << "ConfirmPairing: Network request error:" << reply->error() << reply->errorString();
|
|
info->finish(Thing::ThingErrorHardwareFailure);
|
|
return;
|
|
}
|
|
|
|
QByteArray data = reply->readAll();
|
|
QJsonParseError error;
|
|
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
|
|
if (error.error != QJsonParseError::NoError) {
|
|
qCWarning(dcUnifi()) << "ConfirmPairing: Error parsing JSON response from controller:" << error.errorString() << data;
|
|
info->finish(Thing::ThingErrorHardwareFailure);
|
|
return;
|
|
}
|
|
|
|
qCDebug(dcUnifi()) << "ConfirmPairing succeeded";
|
|
pluginStorage()->beginGroup(info->thingId().toString());
|
|
pluginStorage()->setValue("username", username);
|
|
pluginStorage()->setValue("password", secret);
|
|
pluginStorage()->endGroup();
|
|
info->finish(Thing::ThingErrorNoError);
|
|
});
|
|
}
|
|
|
|
void IntegrationPluginUnifi::setupThing(ThingSetupInfo *info)
|
|
{
|
|
if (info->thing()->thingClassId() == controllerThingClassId) {
|
|
|
|
// If the user just completed the pairing process we already have a valid cookie in the networkAccessManager.
|
|
if (info->isInitialSetup()) {
|
|
info->finish(Thing::ThingErrorNoError);
|
|
info->thing()->setStateValue(controllerConnectedStateTypeId, true);
|
|
return;
|
|
}
|
|
|
|
// After a nymea startup, we'll have to login to obtain a cookie.
|
|
QString host = info->thing()->params().paramValue(controllerThingIpAddressParamTypeId).toString();
|
|
uint port = info->thing()->params().paramValue(controllerThingPortParamTypeId).toUInt();
|
|
QString path;
|
|
if (info->thing()->paramValue(controllerThingModeParamTypeId).toString() == "UniFi OS") {
|
|
path = "/api/auth/login";
|
|
} else {
|
|
path = "/api/login";
|
|
}
|
|
QNetworkRequest request = createRequest(host, port, path);
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
|
QVariantMap login;
|
|
pluginStorage()->beginGroup(info->thing()->id().toString());
|
|
login.insert("username", pluginStorage()->value("username").toString());
|
|
login.insert("password", pluginStorage()->value("password").toString());
|
|
pluginStorage()->endGroup();
|
|
qCDebug(dcUnifi()) << "SetupThing: Sending request:" << request.url().toString() << QJsonDocument::fromVariant(login).toJson();
|
|
QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(login).toJson());
|
|
|
|
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
|
connect(reply, &QNetworkReply::finished, info, [info, reply](){
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
qCWarning(dcUnifi()) << "SetupThing: Network request error:" << reply->error() << reply->errorString();
|
|
info->finish(Thing::ThingErrorHardwareFailure);
|
|
return;
|
|
}
|
|
|
|
QByteArray data = reply->readAll();
|
|
QJsonParseError error;
|
|
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
|
|
if (error.error != QJsonParseError::NoError) {
|
|
qCWarning(dcUnifi()) << "SetupThing: Error parsing JSON response from controller:" << error.errorString() << data;
|
|
info->finish(Thing::ThingErrorHardwareFailure);
|
|
return;
|
|
}
|
|
|
|
qCDebug(dcUnifi()) << "SetupThing succeded";
|
|
|
|
info->thing()->setStateValue(controllerConnectedStateTypeId, true);
|
|
info->finish(Thing::ThingErrorNoError);
|
|
|
|
});
|
|
}
|
|
|
|
if (info->thing()->thingClassId() == clientThingClassId) {
|
|
info->finish(Thing::ThingErrorNoError);
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginUnifi::postSetupThing(Thing *thing)
|
|
{
|
|
if (thing->thingClassId() == controllerThingClassId && !m_loginTimer) {
|
|
// Let's refresh the login every minute
|
|
m_loginTimer = hardwareManager()->pluginTimerManager()->registerTimer();
|
|
connect(m_loginTimer, &PluginTimer::timeout, this, [this](){
|
|
foreach (Thing *controller, myThings().filterByThingClassId(controllerThingClassId)) {
|
|
QString host = controller->paramValue(controllerThingIpAddressParamTypeId).toString();
|
|
uint port = controller->paramValue(controllerThingPortParamTypeId).toUInt();
|
|
QString path;
|
|
if (controller->paramValue(controllerThingModeParamTypeId).toString() == "UniFi OS") {
|
|
path = "/api/auth/login";
|
|
} else {
|
|
path = "/api/login";
|
|
}
|
|
QNetworkRequest request = createRequest(host, port, path);
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
|
QVariantMap login;
|
|
pluginStorage()->beginGroup(controller->id().toString());
|
|
login.insert("username", pluginStorage()->value("username"));
|
|
login.insert("password", pluginStorage()->value("password"));
|
|
pluginStorage()->endGroup();
|
|
qCDebug(dcUnifi()) << "Cookie KeepAlive: Sending request:" << request.url().toString();
|
|
QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(login).toJson());
|
|
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (thing->thingClassId() == clientThingClassId && !m_pollTimer) {
|
|
m_pollTimer = hardwareManager()->pluginTimerManager()->registerTimer(1);
|
|
connect(m_pollTimer, &PluginTimer::timeout, this, [this](){
|
|
foreach (Thing *client, myThings().filterByThingClassId(clientThingClassId)) {
|
|
Thing *controller = myThings().findById(client->parentId());
|
|
QString mac = client->paramValue(clientThingMacParamTypeId).toString();
|
|
QString site = client->paramValue(clientThingSiteParamTypeId).toString();
|
|
QNetworkRequest request = createRequest(controller, QString("/api/s/%1/stat/sta/%2").arg(site).arg(mac));
|
|
QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
|
|
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
|
connect(reply, &QNetworkReply::finished, client, [this, client, reply](){
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
// If the thing is not present we'll get an InvalidOperationError, silence that as it's expected but print other failures
|
|
if (reply->error() != QNetworkReply::ProtocolInvalidOperationError) {
|
|
qCDebug(dcUnifi()) << "Error fetching thing state from controller" << reply->error() << reply->errorString();
|
|
}
|
|
markOffline(client);
|
|
return;
|
|
}
|
|
QByteArray data = reply->readAll();
|
|
QJsonParseError error;
|
|
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
|
|
if (error.error != QJsonParseError::NoError) {
|
|
qCWarning(dcUnifi()) << "Error parsing json from controller:" << error.error << error.errorString() << "\n" << data;
|
|
markOffline(client);
|
|
return;
|
|
}
|
|
|
|
// qCDebug(dcUnifi()) << "Client is present reply" << qUtf8Printable(jsonDoc.toJson());
|
|
QVariantList clientEntries = jsonDoc.toVariant().toMap().value("data").toList();
|
|
if (clientEntries.count() != 1) {
|
|
qCWarning(dcUnifi()) << "Client data not found in controller reply";
|
|
markOffline(client);
|
|
return;
|
|
}
|
|
|
|
QVariantMap clientData = clientEntries.first().toMap();
|
|
|
|
client->setStateValue(clientLastSeenTimeStateTypeId, clientData.value("last_seen").toInt());
|
|
client->setStateValue(clientIsPresentStateTypeId, true);
|
|
});
|
|
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginUnifi::thingRemoved(Thing *thing)
|
|
{
|
|
Q_UNUSED(thing)
|
|
if (myThings().filterByThingClassId(controllerThingClassId).isEmpty() && m_loginTimer) {
|
|
hardwareManager()->pluginTimerManager()->unregisterTimer(m_loginTimer);
|
|
m_loginTimer = nullptr;
|
|
}
|
|
if (myThings().filterByThingClassId(clientThingClassId).isEmpty() && m_pollTimer) {
|
|
hardwareManager()->pluginTimerManager()->unregisterTimer(m_pollTimer);
|
|
m_pollTimer = nullptr;
|
|
}
|
|
}
|
|
|
|
QNetworkRequest IntegrationPluginUnifi::createRequest(const QString &address, uint port, const QString &path, const QString &prefix)
|
|
{
|
|
QUrl url;
|
|
url.setScheme("https");
|
|
url.setHost(address);
|
|
url.setPort(port);
|
|
url.setPath(prefix + path);
|
|
|
|
QNetworkRequest request(url);
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
|
QSslConfiguration config = QSslConfiguration::defaultConfiguration();
|
|
config.setPeerVerifyMode(QSslSocket::VerifyNone);
|
|
request.setSslConfiguration(config);
|
|
return request;
|
|
}
|
|
|
|
QNetworkRequest IntegrationPluginUnifi::createRequest(Thing *thing, const QString &path)
|
|
{
|
|
QString ipAddress = thing->paramValue(controllerThingIpAddressParamTypeId).toString();
|
|
uint port = thing->paramValue(controllerThingPortParamTypeId).toUInt();
|
|
bool prefix = thing->paramValue(controllerThingModeParamTypeId).toString() == "UniFi OS";
|
|
return createRequest(ipAddress, port, path, prefix ? "/proxy/network" : "");
|
|
}
|
|
|
|
void IntegrationPluginUnifi::markOffline(Thing *thing)
|
|
{
|
|
uint gracePeriod = thing->setting(clientSettingsGracePeriodParamTypeId).toUInt();
|
|
QDateTime lastSeenTime = QDateTime::fromMSecsSinceEpoch(thing->stateValue(clientLastSeenTimeStateTypeId).toInt() * 1000);
|
|
if (lastSeenTime.addSecs(gracePeriod * 60) < QDateTime::currentDateTime()) {
|
|
thing->setStateValue(clientIsPresentStateTypeId, false);
|
|
}
|
|
}
|
|
|