nymea-plugins/espuino/integrationpluginespuino.cpp

555 lines
25 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 "integrationpluginespuino.h"
#include "plugininfo.h"
#include <network/networkaccessmanager.h>
#include <network/mqtt/mqttprovider.h>
#include <network/mqtt/mqttchannel.h>
#include <network/zeroconf/zeroconfservicebrowser.h>
#include <platform/platformzeroconfcontroller.h>
#include <mqttclient.h>
#include <QWebSocket>
#include <QJsonDocument>
#include <QNetworkReply>
#include <QUrlQuery>
#include <QRegularExpression>
void IntegrationPluginEspuino::init()
{
m_zeroConfBrowser = hardwareManager()->zeroConfController()->createServiceBrowser("_http._tcp");
}
void IntegrationPluginEspuino::discoverThings(ThingDiscoveryInfo *info)
{
foreach (const ZeroConfServiceEntry &entry, m_zeroConfBrowser->serviceEntries()) {
if (!QRegularExpression("espuino.*").match(entry.name()).hasMatch())
continue;
qCDebug(dcESPuino()) << "Found device:" << entry;
ThingDescriptor descriptor(info->thingClassId(), entry.hostName(), entry.hostAddress().toString());
ParamList params;
params << Param(espuinoThingHostnameParamTypeId, entry.hostName());
descriptor.setParams(params);
Things existingThings = myThings().filterByParam(espuinoThingHostnameParamTypeId, entry.hostName());
if (existingThings.count() == 1) {
qCDebug(dcESPuino()) << "This device already exists in the system!";
descriptor.setThingId(existingThings.first()->id());
}
info->addThingDescriptor(descriptor);
}
info->finish(Thing::ThingErrorNoError);
}
void IntegrationPluginEspuino::setupThing(ThingSetupInfo *info)
{
Thing *thing = info->thing();
MqttChannel *channel;
if (myThings().findByParams(ParamList() << Param(espuinoThingHostnameParamTypeId, thing->paramValue(espuinoThingHostnameParamTypeId).toString())) == nullptr){
qCDebug(dcESPuino) << "Creating MQTT channel for new device.";
channel = hardwareManager()->mqttProvider()->createChannel(QHostAddress(getHost(thing)), {"Cmnd/ESPuino", "State/ESPuino"});
if (!channel) {
qCWarning(dcESPuino) << "Failed to create MQTT channel.";
info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Error creating MQTT channel. Please check MQTT server settings."));
return;
}
qCInfo(dcESPuino) << "Reconfiguring MQTT settings via Websocket.";
QWebSocket *ws = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, info);
connect(ws, &QWebSocket::connected, info, [channel, ws](){
QJsonDocument jsonRequest{QJsonObject
{
{"mqtt", QJsonObject{{"mqttEnable", "1"},
{"mqttClientId", channel->clientId()},
{"mqttServer", channel->serverAddress().toString()},
{"mqttUser", channel->username()},
{"mqttPwd", channel->password()},
{"mqttPort", QString::number(channel->serverPort())}}}
}};
ws->sendTextMessage(jsonRequest.toJson(QJsonDocument::Compact));
});
connect(ws, &QWebSocket::textMessageReceived, info, [this, info, thing, channel, ws](const QString &message){
ws->close();
QJsonParseError parseError;
QJsonDocument jsonDoc = QJsonDocument::fromJson(message.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
qCWarning(dcESPuino()) << "Json parse error:" << parseError.error << parseError.errorString() << "Received:" << message;
info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Failed to configure MQTT via Websocket."));
hardwareManager()->mqttProvider()->releaseChannel(channel);
return;
}
if (jsonDoc.object().value("status").toString() != "ok") {
qCWarning(dcESPuino()) << "Failed to configure MQTT via websocket. Received:" << message;
info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Failed to configure MQTT via Websocket."));
hardwareManager()->mqttProvider()->releaseChannel(channel);
return;
}
pluginStorage()->beginGroup(thing->id().toString());
pluginStorage()->setValue("clientId", channel->clientId());
pluginStorage()->setValue("username", channel->username());
pluginStorage()->setValue("password", channel->password());
pluginStorage()->endGroup();
qCInfo(dcESPuino) << "Restarting box to apply new MQTT config.";
QUrl url(QString("http://%1/restart").arg(getHost(thing)));
QNetworkRequest request(url);
QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
info->finish(Thing::ThingErrorNoError);
});
QUrl url(QString("ws://%1/ws").arg(getHost(thing)));
ws->open(url);
} else {
qCDebug(dcESPuino) << "Creating MQTT channel for existing device.";
pluginStorage()->beginGroup(thing->id().toString());
QString clientId = pluginStorage()->value("clientId").toString();
QString username = pluginStorage()->value("username").toString();
QString password = pluginStorage()->value("password").toString();
pluginStorage()->endGroup();
channel = hardwareManager()->mqttProvider()->createChannel(clientId, username, password, QHostAddress(getHost(thing)), {"Cmnd/ESPuino", "State/ESPuino"});
if (!channel) {
qCWarning(dcESPuino) << "Failed to create MQTT channel.";
info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Error creating MQTT channel. Please check MQTT server settings."));
return;
}
info->finish(Thing::ThingErrorNoError);
}
m_mqttChannels.insert(thing, channel);
connect(channel, &MqttChannel::clientConnected, this, &IntegrationPluginEspuino::onClientConnected);
connect(channel, &MqttChannel::clientDisconnected, this, &IntegrationPluginEspuino::onClientDisconnected);
connect(channel, &MqttChannel::publishReceived, this, &IntegrationPluginEspuino::onPublishReceived);
}
void IntegrationPluginEspuino::thingRemoved(Thing *thing)
{
qCDebug(dcESPuino) << "Device removed" << thing->name();
if (m_mqttChannels.contains(thing)) {
qCDebug(dcESPuino) << "Releasing MQTT channel";
MqttChannel* channel = m_mqttChannels.take(thing);
hardwareManager()->mqttProvider()->releaseChannel(channel);
}
}
void IntegrationPluginEspuino::onClientConnected(MqttChannel *channel)
{
Thing *thing = m_mqttChannels.key(channel);
qCDebug(dcESPuino) << "Thing connected" << thing->name();
if (!thing) {
qCWarning(dcESPuino) << "Received a MQTT connected message from a client but don't have a matching thing";
return;
}
thing->setStateValue(espuinoConnectedStateTypeId, true);
}
void IntegrationPluginEspuino::onClientDisconnected(MqttChannel *channel)
{
Thing *thing = m_mqttChannels.key(channel);
qCDebug(dcESPuino) << "Thing disconnected" << thing->name();
if (!thing) {
qCWarning(dcESPuino) << "Received a MQTT disconnected message from a client but don't have a matching thing";
return;
}
thing->setStateValue(espuinoConnectedStateTypeId, false);
}
void IntegrationPluginEspuino::onPublishReceived(MqttChannel *channel, const QString &topic, const QByteArray &payload)
{
qCDebug(dcESPuino) << "Publish received" << topic << payload;
Thing *thing = m_mqttChannels.key(channel);
if (!thing) {
qCWarning(dcESPuino) << "Received a publish message from a client but don't have a matching thing";
return;
}
if (topic == "State/ESPuino/State") {
thing->setStateValue(espuinoConnectedStateTypeId, payload == "Online");
} else if (topic == "State/ESPuino/Playmode") {
thing->setStateValue(espuinoPlaybackStatusStateTypeId, payload == "0" ? "Stopped" : "Playing");
QString playmode = "None";
if (payload == "0") {
playmode = "None";
} else if (payload == "1") {
playmode = "Single track";
} else if (payload == "2") {
playmode = "Single track (loop)";
} else if (payload == "12") {
playmode = "Single track of a directory (random). Followed by sleep.";
} else if (payload == "3") {
playmode = "Audiobook";
} else if (payload == "4") {
playmode = "Audiobook (loop)";
} else if (payload == "5") {
playmode = "All tracks of a directory (sorted alph.)";
} else if (payload == "6") {
playmode = "All tracks of a directory (random)";
} else if (payload == "7") {
playmode = "All tracks of a directory (sorted alph., loop)";
} else if (payload == "9") {
playmode = "All tracks of a directory (random, loop)";
} else if (payload == "8") {
playmode = "Webradio";
} else if (payload == "11") {
playmode = "List (files from SD and/or webstreams) from local .m3u-File";
} else if (payload == "10") {
playmode = "Busy";
} else {
qCWarning(dcESPuino) << "Unknown playmode received" << payload;
}
thing->setStateValue(espuinoPlaymodeStateTypeId, playmode);
} else if (topic == "State/ESPuino/Loudness") {
bool ok;
int volume = payload.toInt(&ok);
if (ok) {
thing->setStateValue(espuinoVolumeStateTypeId, volume);
} else {
qCWarning(dcESPuino) << "Failed to read numeric volume value" << payload;
thing->setStateValue(espuinoVolumeStateTypeId, 0);
}
} else if (topic == "State/ESPuino/Track") {
thing->setStateValue(espuinoTitleStateTypeId, payload);
} else if (topic == "State/ESPuino/CoverChanged") {
thing->setStateValue(espuinoArtworkStateTypeId, QString("http://%1/cover?%2").arg(getHost(thing)).arg(QDateTime::currentMSecsSinceEpoch()));
} else if (topic == "State/ESPuino/LedBrightness") {
bool ok;
int brightness = payload.toInt(&ok);
if (ok) {
thing->setStateValue(espuinoBrightnessStateTypeId, brightness);
} else {
qCWarning(dcESPuino) << "Failed to read numeric brightness value" << payload;
thing->setStateValue(espuinoBrightnessStateTypeId, 0);
}
} else if (topic == "State/ESPuino/RepeatMode") {
if (payload == "3") {
thing->setStateValue(espuinoRepeatStateTypeId, "All");
} if (payload == "1") {
thing->setStateValue(espuinoRepeatStateTypeId, "One");
} else {
thing->setStateValue(espuinoRepeatStateTypeId, "None");
}
} else if (topic == "State/ESPuino/WifiRssi") {
bool ok;
int rssi = payload.toInt(&ok);
if (ok) {
thing->setStateValue(espuinoSignalStrengthStateTypeId, qMin(100, qMax(0, (rssi + 100) * 2)));
} else {
thing->setStateValue(espuinoSignalStrengthStateTypeId, 0);
}
} else if (topic == "State/ESPuino/LockControl") {
thing->setStateValue(espuinoChildLockStateTypeId, payload == "ON");
} else if (topic == "State/ESPuino/SleepTimer") {
if (payload == "EOP") {
thing->setStateValue(espuinoSleepmodeStateTypeId, "End of playlist");
} else if (payload == "EOT") {
thing->setStateValue(espuinoSleepmodeStateTypeId, "End of track");
} else if (payload == "EO5T") {
thing->setStateValue(espuinoSleepmodeStateTypeId, "End of five tracks");
} else if (payload == "0") {
thing->setStateValue(espuinoSleepmodeStateTypeId, "None");
} else {
bool ok;
int timer = payload.toInt(&ok);
if (ok) {
thing->setStateValue(espuinoSleepmodeStateTypeId, "Timer");
thing->setStateValue(espuinoSleeptimerStateTypeId, timer);
} else {
qCWarning(dcESPuino) << "Failed to read numeric sleep timer value" << payload;
thing->setStateValue(espuinoSleepmodeStateTypeId, "None");
}
}
} else if (topic == "State/ESPuino/Battery") {
bool ok;
float battery = payload.toFloat(&ok);
if (ok) {
thing->setStateValue(espuinoBatteryLevelStateTypeId, battery);
thing->setStateValue(espuinoBatteryCriticalStateTypeId, battery < 5.0f);
} else {
qCWarning(dcESPuino) << "Failed to read numeric battery level value" << payload;
thing->setStateValue(espuinoBatteryLevelStateTypeId, 0);
thing->setStateValue(espuinoBatteryCriticalStateTypeId, false);
}
}
// Finish pending action.
QPointer<ThingActionInfo> thingActionInfo = m_pendingActions.take(topic);
if (!thingActionInfo.isNull()) {
thingActionInfo->finish(Thing::ThingErrorNoError);
}
}
void IntegrationPluginEspuino::executeAction(ThingActionInfo *info)
{
Thing *thing = info->thing();
Action action = info->action();
MqttChannel *channel = m_mqttChannels.value(thing);
if (!channel) {
qCWarning(dcESPuino) << "No valid MQTT channel for thing" << thing->name();
return info->finish(Thing::ThingErrorThingNotFound);
}
// See: https://github.com/biologist79/ESPuino#mqtt-topics-and-their-ranges
QString topic;
QByteArray payload;
if (action.actionTypeId() == espuinoVolumeActionTypeId) {
topic = "Cmnd/ESPuino/Loudness";
payload = QByteArray::number(action.param(espuinoVolumeActionVolumeParamTypeId).value().toInt());
m_pendingActions.insert("State/ESPuino/Loudness", info);
} else if (action.actionTypeId() == espuinoIncreaseVolumeActionTypeId) {
topic = "Cmnd/ESPuino/Loudness";
payload = QByteArray::number(thing->stateValue(espuinoVolumeStateTypeId).toInt() + 1);
m_pendingActions.insert("State/ESPuino/Loudness", info);
} else if (action.actionTypeId() == espuinoDecreaseVolumeActionTypeId) {
topic = "Cmnd/ESPuino/Loudness";
payload = QByteArray::number(thing->stateValue(espuinoVolumeStateTypeId).toInt() - 1);
m_pendingActions.insert("State/ESPuino/Loudness", info);
} else if (action.actionTypeId() == espuinoStopActionTypeId) {
topic = "Cmnd/ESPuino/TrackControl";
payload = "1";
m_pendingActions.insert("State/ESPuino/TrackControl", info);
} else if (action.actionTypeId() == espuinoSkipNextActionTypeId) {
topic = "Cmnd/ESPuino/TrackControl";
payload = "4";
m_pendingActions.insert("State/ESPuino/TrackControl", info);
} else if (action.actionTypeId() == espuinoSkipBackActionTypeId) {
topic = "Cmnd/ESPuino/TrackControl";
payload = "5";
m_pendingActions.insert("State/ESPuino/TrackControl", info);
} else if (action.actionTypeId() == espuinoPlayActionTypeId) {
topic = "Cmnd/ESPuino/TrackControl";
payload = "3";
m_pendingActions.insert("State/ESPuino/TrackControl", info);
} else if (action.actionTypeId() == espuinoPauseActionTypeId) {
topic = "Cmnd/ESPuino/TrackControl";
payload = "3";
m_pendingActions.insert("State/ESPuino/TrackControl", info);
} else if (action.actionTypeId() == espuinoBrightnessActionTypeId) {
topic = "Cmnd/ESPuino/LedBrightness";
payload = QByteArray::number(action.param(espuinoBrightnessActionBrightnessParamTypeId).value().toInt());
m_pendingActions.insert("State/ESPuino/LedBrightness", info);
} else if (action.actionTypeId() == espuinoRepeatActionTypeId) {
topic = "Cmnd/ESPuino/RepeatMode";
QString repeat = action.param(espuinoRepeatActionRepeatParamTypeId).value().toString();
if (repeat == "One") {
payload = "1";
} else if (repeat == "All") {
payload = "3";
} else {
payload = "0";
}
m_pendingActions.insert("State/ESPuino/RepeatMode", info);
} else if (action.actionTypeId() == espuinoChildLockActionTypeId) {
topic = "Cmnd/ESPuino/LockControls";
payload = action.param(espuinoChildLockActionChildLockParamTypeId).value().toBool() ? "ON" : "OFF";
m_pendingActions.insert("State/ESPuino/LockControls", info);
} else if (action.actionTypeId() == espuinoSleepmodeActionTypeId) {
topic = "Cmnd/ESPuino/SleepTimer";
QString sleepmode = action.param(espuinoSleepmodeActionSleepmodeParamTypeId).value().toString();
if (sleepmode == "None") {
payload = "0";
} else if (sleepmode == "End of playlist") {
payload = "EOP";
} else if (sleepmode == "End of track") {
payload = "EOT";
} else if (sleepmode == "End of five tracks") {
payload = "EO5T";
} else {
payload = QByteArray::number(thing->stateValue(espuinoSleeptimerStateTypeId).toInt());
}
m_pendingActions.insert("State/ESPuino/SleepTimer", info);
} else if (action.actionTypeId() == espuinoSleeptimerActionTypeId) {
thing->setStateValue(espuinoSleeptimerStateTypeId, action.param(espuinoSleeptimerActionSleeptimerParamTypeId).value().toUInt());
info->finish(Thing::ThingErrorNoError);
}
if (!topic.isEmpty()) {
qCDebug(dcESPuino) << "Publishing:" << topic << payload;
channel->publish(topic, payload);
}
return;
}
void IntegrationPluginEspuino::browseThing(BrowseResult *result)
{
QUrlQuery id(result->itemId());
browseThing(result, id.queryItemValue("path"));
}
void IntegrationPluginEspuino::browseThing(BrowseResult *result, const QString &path)
{
QUrl url(QString("http://%1/explorer?path=%2").arg(getHost(result->thing()), path.isEmpty() ? "/" : path));
QNetworkRequest request(url);
QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, result, [result, reply, path, this]() {
if (reply->error() != QNetworkReply::NoError) {
qCWarning(dcESPuino()) << "Error fetching paths";
result->finish(Thing::ThingErrorHardwareNotAvailable);
return;
}
QByteArray data = reply->readAll();
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
qCWarning(dcESPuino()) << "Error parsing json" << data;
result->finish(Thing::ThingErrorHardwareFailure);
return;
}
//qCDebug(dcESPuino()) << "Reply:" << qUtf8Printable(jsonDoc.toJson());
QVariantList variantList = jsonDoc.toVariant().toList();
foreach (const QVariant &element, variantList) {
QVariantMap variantMap = element.toMap();
QUrlQuery id;
id.addQueryItem("name", variantMap.value("name").toString());
id.addQueryItem("path", path + "/" + variantMap.value("name").toString());
if (variantMap.value("dir").toBool()) {
id.addQueryItem("playmode", QString::number(5));
id.addQueryItem("type", "dir");
} else if (variantMap.value("name").toString().contains(QRegularExpression("\\.(:?mp3|ogg|wav|wma|acc|m4a|flac)$", QRegularExpression::CaseInsensitiveOption))) {
id.addQueryItem("playmode", QString::number(1));
id.addQueryItem("type", "audiofile");
} else if (variantMap.value("name").toString().endsWith(".m3u", Qt::CaseInsensitive)) {
id.addQueryItem("playmode", QString::number(11));
id.addQueryItem("type", "playlist");
}
result->addItem(browserItemFromQuery(id));
}
result->finish(Thing::ThingErrorNoError);
});
}
void IntegrationPluginEspuino::browserItem(BrowserItemResult *result)
{
QUrlQuery id(result->itemId());
result->finish(browserItemFromQuery(id));
}
BrowserItem IntegrationPluginEspuino::browserItemFromQuery(const QUrlQuery &id)
{
BrowserItem item;
item.setDisplayName(id.queryItemValue("name"));
if (id.queryItemValue("type") == "dir") {
item.setId(id.toString());
item.setIcon(BrowserItem::BrowserIconFolder);
item.setBrowsable(true);
item.setActionTypeIds({espuinoPlayAllBrowserItemActionTypeId});
} else if (id.queryItemValue("type") == "audiofile") {
item.setId(id.toString());
item.setIcon(BrowserItem::BrowserIconMusic);
item.setExecutable(true);
} else if (id.queryItemValue("type") == "playlist") {
item.setId(id.toString());
item.setIcon(BrowserItem::BrowserIconDocument);
item.setExecutable(true);
} else {
item.setId(id.toString());
item.setIcon(BrowserItem::BrowserIconFile);
}
return item;
}
void IntegrationPluginEspuino::IntegrationPluginEspuino::executeBrowserItem(BrowserActionInfo *info)
{
Thing *thing = info->thing();
BrowserAction action = info->browserAction();
QUrl url(QString("http://%1/exploreraudio?%2").arg(getHost(thing)).arg(action.itemId()));
qCInfo(dcESPuino) << "Starting playback" << url.toString();
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QByteArray());
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [reply]() {
if (reply->error() != QNetworkReply::NoError) {
qCWarning(dcESPuino()) << "Fail to execute play action";
}
});
}
void IntegrationPluginEspuino::executeBrowserItemAction(BrowserItemActionInfo *info)
{
Thing *thing = info->thing();
BrowserItemAction action = info->browserItemAction();
if (action.actionTypeId() == espuinoPlayAllBrowserItemActionTypeId) {
QUrl url(QString("http://%1/exploreraudio?%2").arg(getHost(thing)).arg(action.itemId()));
qCInfo(dcESPuino) << "Starting playback" << url.toString();
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QByteArray());
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [reply]() {
if (reply->error() != QNetworkReply::NoError) {
qCWarning(dcESPuino()) << "Fail to execute play action";
}
});
}
}
QString IntegrationPluginEspuino::getHost(Thing *thing) const
{
QString hostName = thing->paramValue(espuinoThingHostnameParamTypeId).toString();
ZeroConfServiceEntry zeroConfEntry;
foreach (const ZeroConfServiceEntry &entry, m_zeroConfBrowser->serviceEntries()) {
if (hostName == entry.hostName()) {
zeroConfEntry = entry;
}
}
QString host;
pluginStorage()->beginGroup(thing->id().toString());
if (zeroConfEntry.isValid()) {
host = zeroConfEntry.hostAddress().toString();
pluginStorage()->setValue("cachedAddress", host);
} else if (pluginStorage()->contains("cachedAddress")){
host = pluginStorage()->value("cachedAddress").toString();
} else {
qCWarning(dcESPuino()) << "Unable to determine IP address for:" << hostName;
}
pluginStorage()->endGroup();
return host;
}