diff --git a/debian/control b/debian/control index de77f4b2..3fdbb21d 100644 --- a/debian/control +++ b/debian/control @@ -210,6 +210,15 @@ Description: nymea integration plugin for elgato This package contains the nymea integration plugin for devices from Elgato +Package: nymea-plugin-espuino +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, +Conflicts: nymea-plugins-translations (< 1.0.1) +Description: nymea integration plugin for ESPuino + This package contains the nymea integration plugin for ESPuino devices. + + Package: nymea-plugin-fastcom Architecture: any Depends: ${misc:Depends}, diff --git a/debian/nymea-plugin-espuino.install.in b/debian/nymea-plugin-espuino.install.in new file mode 100644 index 00000000..6f7d89f9 --- /dev/null +++ b/debian/nymea-plugin-espuino.install.in @@ -0,0 +1,2 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationpluginespuino.so +espuino/translations/*qm usr/share/nymea/translations/ diff --git a/espuino/README.md b/espuino/README.md new file mode 100644 index 00000000..81beda8b --- /dev/null +++ b/espuino/README.md @@ -0,0 +1,29 @@ +# ESPuino + +This plugin allows to integrate nymea with [ESPunio](https://github.com/biologist79/ESPuino), +a software for Rfid-controlled music players running on ESP32 hardware. + +ESPuino boxes can't be bought off the shelf, but there's a (mostly +German speaking) community in the [ESPuino forum](https://forum.espuino.de/) +that provides a lot of documentation and ideas for building your own custom +ESPuino. + +## Usage + +As a prerequisite, ESPuino has to be compiled with WiFi and MQTT +enabled and the ESPuino must have been set up to connect to your home +WiFi. For the mDNS based auto discovery to work, the hostname must start with +`espuino`. + +Then the ESPuino can be added to nymea. + +## Supported features + +- Display current title and cover art +- Control volume +- Play/pause +- Lock hardware controls +- Browse SD-Card and start playback +- Control LED brightness +- Configure sleep or repeat modes +- Show battery status diff --git a/espuino/espuino.pro b/espuino/espuino.pro new file mode 100644 index 00000000..efefd13f --- /dev/null +++ b/espuino/espuino.pro @@ -0,0 +1,12 @@ +include(../plugins.pri) + +QT += network \ + websockets + +PKGCONFIG += nymea-mqtt + +SOURCES += \ + integrationpluginespuino.cpp + +HEADERS += \ + integrationpluginespuino.h diff --git a/espuino/integrationpluginespuino.cpp b/espuino/integrationpluginespuino.cpp new file mode 100644 index 00000000..42d3054b --- /dev/null +++ b/espuino/integrationpluginespuino.cpp @@ -0,0 +1,556 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2022, 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 "integrationpluginespuino.h" + +#include "integrations/integrationplugin.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 + +#include +#include +#include +#include + +void IntegrationPluginEspuino::init() +{ + m_zeroConfBrowser = hardwareManager()->zeroConfController()->createServiceBrowser("_http._tcp"); +} + +void IntegrationPluginEspuino::discoverThings(ThingDiscoveryInfo *info) +{ + foreach (const ZeroConfServiceEntry &entry, m_zeroConfBrowser->serviceEntries()) { + QRegExp match("espuino.*"); + if (!match.exactMatch(entry.name())) { + 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 = 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())).arg(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(QRegExp("\\.(:?mp3|ogg|wav|wma|acc|m4a|flac)$", Qt::CaseInsensitive))) { + 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; +} diff --git a/espuino/integrationpluginespuino.h b/espuino/integrationpluginespuino.h new file mode 100644 index 00000000..3e4d224c --- /dev/null +++ b/espuino/integrationpluginespuino.h @@ -0,0 +1,78 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2022, 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 INTEGRATIONPLUGINESPUINO_H +#define INTEGRATIONPLUGINESPUINO_H + +#include "integrations/integrationplugin.h" +#include "plugintimer.h" + +#include + +#include "extern-plugininfo.h" + +class MqttChannel; +class ZeroConfServiceBrowser; + +class IntegrationPluginEspuino : public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginespuino.json") + Q_INTERFACES(IntegrationPlugin) + +public: + void init() override; + void discoverThings(ThingDiscoveryInfo *info) override; + void setupThing(ThingSetupInfo *info) override; + void thingRemoved(Thing *thing) override; + void executeAction(ThingActionInfo *info) override; + void browseThing(BrowseResult *result) override; + void browseThing(BrowseResult *result, const QString &path); + void browserItem(BrowserItemResult *result) override; + BrowserItem browserItemFromQuery(const QUrlQuery &query); + void executeBrowserItem(BrowserActionInfo *info) override; + void executeBrowserItemAction(BrowserItemActionInfo *info) override; + +private slots: + void onClientConnected(MqttChannel *channel); + void onClientDisconnected(MqttChannel *channel); + void onPublishReceived(MqttChannel* channel, const QString &topic, const QByteArray &payload); + +private: + QString getHost(Thing *thing) const; + + ZeroConfServiceBrowser *m_zeroConfBrowser; + QHash m_mqttChannels; + QHash m_ipAddressParamTypeMap; + QMap> m_pendingActions; +}; + +#endif // INTEGRATIONPLUGINESPUINO_H diff --git a/espuino/integrationpluginespuino.json b/espuino/integrationpluginespuino.json new file mode 100644 index 00000000..ee116a36 --- /dev/null +++ b/espuino/integrationpluginespuino.json @@ -0,0 +1,261 @@ +{ + "name": "ESPuino", + "displayName": "ESPuino", + "id": "5f8ba72b-d3fb-4efe-952d-a927bed20cfe", + "vendors": [ + { + "name": "ESPuino", + "displayName": "ESPuino", + "id": "58c8eb30-98a4-44fd-aaac-cb2a7aae7e8a", + "thingClasses": [ + { + "id": "ee24ce2b-d34a-4c2c-85f3-9d895d17f414", + "name": "espuino", + "displayName": "ESPuino", + "createMethods": ["discovery"], + "interfaces": ["mediaplayer", "mediametadataprovider", "volumecontroller", "wirelessconnectable", "battery", "childlock"], + "browsable": true, + "paramTypes": [ + { + "id": "2a9c9427-3e4e-4473-805e-c25242cfc621", + "name": "hostname", + "displayName": "Hostname", + "type": "QString", + "readOnly": true + } + ], + "stateTypes": [ + { + "id": "edbff474-0cdc-488c-a9e9-970b25ce7548", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "57bd1bab-d872-4315-b53e-1157fe3889d4", + "name": "signalStrength", + "displayName": "Signal strength", + "type": "uint", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "defaultValue": 0, + "cached": false + }, + { + "id": "bee497e6-a320-458a-9006-ddfe4c7c37c2", + "name": "batteryCritical", + "displayName": "Battery critical", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "9fd8f882-8240-492f-8c6b-b5477e26623e", + "name": "batteryLevel", + "displayName": "Battery level", + "type": "int", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "defaultValue": 0, + "cached": false + }, + { + "id": "dd1cfb1f-fec4-4035-9c02-562a6fba683d", + "name": "playbackStatus", + "displayName": "Playback status", + "type": "QString", + "possibleValues": ["Playing", "Paused", "Stopped"], + "defaultValue": "Stopped", + "writable": false, + "cached": false + }, + { + "id": "a274e048-9820-444a-b5de-a3a421c855a2", + "name": "title", + "displayName": "Title", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "5acce950-cdac-44ea-963d-0635afcabdca", + "name": "playmode", + "displayName": "Playmode", + "type": "QString", + "possibleValues": [ + "None", + "Single track", + "Single track (loop)", + "Single track of a directory (random). Followed by sleep.", + "Audiobook", + "Audiobook (loop)", + "All tracks of a directory (sorted alph.)", + "All tracks of a directory (random)", + "All tracks of a directory (sorted alph., loop)", + "All tracks of a directory (random, loop)", + "Webradio", + "List (files from SD and/or webstreams) from local .m3u-File", + "Busy"], + "defaultValue": "None", + "writable": false, + "cached": false + }, + { + "id": "27b5ff3b-bd60-411f-b8e3-b1c8f6897bec", + "name": "repeat", + "displayName": "Repeat mode", + "type": "QString", + "possibleValues": [ + "None", + "One", + "All" + ], + "displayNameAction": "Set repeat", + "defaultValue": "None", + "writable": true, + "cached": false + }, + { + "id": "93a5098a-a41a-46ee-8613-266d4f9ed69a", + "displayName": "Volume", + "name": "volume", + "type": "int", + "minValue": "0", + "maxValue": "21", + "displayNameAction": "Set volume", + "defaultValue": 0, + "writable": true, + "cached": false + }, + { + "id": "595908c1-57b1-4303-a0ca-4c64f3cb1907", + "name": "brightness", + "displayName": "LED brightness", + "type": "int", + "minValue": 0, + "maxValue": 255, + "displayNameAction": "Set LED brightness", + "writable": true, + "defaultValue": 0, + "ioType": "analogOutput", + "cached": false + }, + { + "id": "03e7a5e2-9434-47e8-91ad-03610601b925", + "name": "childLock", + "displayName": "Locl controls", + "type": "bool", + "displayNameAction": "Enable/disable control lock", + "writable": true, + "defaultValue": false, + "cached": false + }, + { + "id": "19bd1456-2e4f-444a-a586-75bf6cc9fb73", + "name": "sleepmode", + "displayName": "Sleepmode", + "type": "QString", + "possibleValues": ["None", "End of playlist", "End of track", "End of five tracks", "Timer"], + "defaultValue": "None", + "displayNameAction": "Set Sleepmode", + "writable": true, + "cached": false + }, + { + "id": "4c7594e4-70e7-4f0c-aae4-02e3993ffa1d", + "name": "sleeptimer", + "displayName": "Sleeptimer", + "type": "uint", + "unit": "Minutes", + "defaultValue": 10, + "displayNameAction": "Set Sleeptimer", + "writable": true, + "cached": false + }, + { + "id": "f84ccfc3-0698-40ff-b413-53f0064ce663", + "name": "artwork", + "displayName": "Artwork", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "67a5b71e-ec88-4272-8d68-9562b7f786cf", + "name": "artist", + "displayName": "Artist", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "c1af97a6-f061-4082-8bf5-595728b03ab1", + "name": "collection", + "displayName": "Collection", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "c7814ee8-52b1-4cc9-b8f4-f3f91ad8f33e", + "displayName": "Player Type", + "name": "playerType", + "type": "QString", + "possibleValues": ["audio", "video"], + "defaultValue": "audio", + "cached": false + } + ], + "actionTypes": [ + { + "id": "d045e491-c83b-4155-85ef-abc28a391402", + "name": "increaseVolume", + "displayName": "Increase volume" + }, + { + "id": "16ae2d6a-68cc-497f-9e5d-2fa1f5f7107a", + "name": "decreaseVolume", + "displayName": "Decrease volume" + }, + { + "id": "e04b74cc-cf74-482c-908d-8df294bd5ec8", + "name": "skipBack", + "displayName": "Prev" + }, + { + "id": "d46f0b61-d406-4302-adc3-6bbc00fc2a8f", + "name": "stop", + "displayName": "Stop" + }, + { + "id": "4e3b2f50-82dc-4f51-a9e5-69012985b491", + "name": "play", + "displayName": "Play" + }, + { + "id": "b7128827-b429-4583-bc34-1ef4e7987809", + "name": "pause", + "displayName": "Pause" + }, + { + "id": "25301c30-727c-43fd-bf3b-f7b3916947c7", + "name": "skipNext", + "displayName": "Next" + } + ], + "browserItemActionTypes": [ + { + "id": "ccb210ac-5819-4614-897b-e5a0b130a38a", + "name": "playAll", + "displayName": "Play All" + } + ] + } + ] + } + ] +} diff --git a/espuino/meta.json b/espuino/meta.json new file mode 100644 index 00000000..3bba194c --- /dev/null +++ b/espuino/meta.json @@ -0,0 +1,14 @@ +{ + "title": "ESPunio", + "tagline": "Remote control ESPunio (Rfid-controller musicplayer) via MQTT.", + "stability": "community", + "offline": true, + "technologies": [ + "mqtt", + "network" + ], + "categories": [ + "dyi", + "multimedia", + ] +} diff --git a/espuino/translations/5f8ba72b-d3fb-4efe-952d-a927bed20cfe-en_US.ts b/espuino/translations/5f8ba72b-d3fb-4efe-952d-a927bed20cfe-en_US.ts new file mode 100644 index 00000000..d237f7b7 --- /dev/null +++ b/espuino/translations/5f8ba72b-d3fb-4efe-952d-a927bed20cfe-en_US.ts @@ -0,0 +1,244 @@ + + + + + ESPuino + + + Artist + The name of the StateType ({67a5b71e-ec88-4272-8d68-9562b7f786cf}) of ThingClass espuino + + + + + Artwork + The name of the StateType ({f84ccfc3-0698-40ff-b413-53f0064ce663}) of ThingClass espuino + + + + + Battery critical + The name of the StateType ({bee497e6-a320-458a-9006-ddfe4c7c37c2}) of ThingClass espuino + + + + + Battery level + The name of the StateType ({9fd8f882-8240-492f-8c6b-b5477e26623e}) of ThingClass espuino + + + + + Collection + The name of the StateType ({c1af97a6-f061-4082-8bf5-595728b03ab1}) of ThingClass espuino + + + + + Connected + The name of the StateType ({edbff474-0cdc-488c-a9e9-970b25ce7548}) of ThingClass espuino + + + + + Decrease volume + The name of the ActionType ({16ae2d6a-68cc-497f-9e5d-2fa1f5f7107a}) of ThingClass espuino + + + + + + + ESPuino + The name of the ThingClass ({ee24ce2b-d34a-4c2c-85f3-9d895d17f414}) +---------- +The name of the vendor ({58c8eb30-98a4-44fd-aaac-cb2a7aae7e8a}) +---------- +The name of the plugin ESPuino ({5f8ba72b-d3fb-4efe-952d-a927bed20cfe}) + + + + + Enable/disable control lock + The name of the ActionType ({03e7a5e2-9434-47e8-91ad-03610601b925}) of ThingClass espuino + + + + + Hostname + The name of the ParamType (ThingClass: espuino, Type: thing, ID: {2a9c9427-3e4e-4473-805e-c25242cfc621}) + + + + + Increase volume + The name of the ActionType ({d045e491-c83b-4155-85ef-abc28a391402}) of ThingClass espuino + + + + + + LED brightness + The name of the ParamType (ThingClass: espuino, ActionType: brightness, ID: {595908c1-57b1-4303-a0ca-4c64f3cb1907}) +---------- +The name of the StateType ({595908c1-57b1-4303-a0ca-4c64f3cb1907}) of ThingClass espuino + + + + + + Locl controls + The name of the ParamType (ThingClass: espuino, ActionType: childLock, ID: {03e7a5e2-9434-47e8-91ad-03610601b925}) +---------- +The name of the StateType ({03e7a5e2-9434-47e8-91ad-03610601b925}) of ThingClass espuino + + + + + Next + The name of the ActionType ({25301c30-727c-43fd-bf3b-f7b3916947c7}) of ThingClass espuino + + + + + Pause + The name of the ActionType ({b7128827-b429-4583-bc34-1ef4e7987809}) of ThingClass espuino + + + + + Play + The name of the ActionType ({4e3b2f50-82dc-4f51-a9e5-69012985b491}) of ThingClass espuino + + + + + Play All + The name of the Browser Item ActionType ({ccb210ac-5819-4614-897b-e5a0b130a38a}) of ThingClass espuino + + + + + Playback status + The name of the StateType ({dd1cfb1f-fec4-4035-9c02-562a6fba683d}) of ThingClass espuino + + + + + Player Type + The name of the StateType ({c7814ee8-52b1-4cc9-b8f4-f3f91ad8f33e}) of ThingClass espuino + + + + + Playmode + The name of the StateType ({5acce950-cdac-44ea-963d-0635afcabdca}) of ThingClass espuino + + + + + Prev + The name of the ActionType ({e04b74cc-cf74-482c-908d-8df294bd5ec8}) of ThingClass espuino + + + + + + Repeat mode + The name of the ParamType (ThingClass: espuino, ActionType: repeat, ID: {27b5ff3b-bd60-411f-b8e3-b1c8f6897bec}) +---------- +The name of the StateType ({27b5ff3b-bd60-411f-b8e3-b1c8f6897bec}) of ThingClass espuino + + + + + Set LED brightness + The name of the ActionType ({595908c1-57b1-4303-a0ca-4c64f3cb1907}) of ThingClass espuino + + + + + Set Sleepmode + The name of the ActionType ({19bd1456-2e4f-444a-a586-75bf6cc9fb73}) of ThingClass espuino + + + + + Set Sleeptimer + The name of the ActionType ({4c7594e4-70e7-4f0c-aae4-02e3993ffa1d}) of ThingClass espuino + + + + + Set repeat + The name of the ActionType ({27b5ff3b-bd60-411f-b8e3-b1c8f6897bec}) of ThingClass espuino + + + + + Set volume + The name of the ActionType ({93a5098a-a41a-46ee-8613-266d4f9ed69a}) of ThingClass espuino + + + + + Signal strength + The name of the StateType ({57bd1bab-d872-4315-b53e-1157fe3889d4}) of ThingClass espuino + + + + + + Sleepmode + The name of the ParamType (ThingClass: espuino, ActionType: sleepmode, ID: {19bd1456-2e4f-444a-a586-75bf6cc9fb73}) +---------- +The name of the StateType ({19bd1456-2e4f-444a-a586-75bf6cc9fb73}) of ThingClass espuino + + + + + + Sleeptimer + The name of the ParamType (ThingClass: espuino, ActionType: sleeptimer, ID: {4c7594e4-70e7-4f0c-aae4-02e3993ffa1d}) +---------- +The name of the StateType ({4c7594e4-70e7-4f0c-aae4-02e3993ffa1d}) of ThingClass espuino + + + + + Stop + The name of the ActionType ({d46f0b61-d406-4302-adc3-6bbc00fc2a8f}) of ThingClass espuino + + + + + Title + The name of the StateType ({a274e048-9820-444a-b5de-a3a421c855a2}) of ThingClass espuino + + + + + + Volume + The name of the ParamType (ThingClass: espuino, ActionType: volume, ID: {93a5098a-a41a-46ee-8613-266d4f9ed69a}) +---------- +The name of the StateType ({93a5098a-a41a-46ee-8613-266d4f9ed69a}) of ThingClass espuino + + + + + IntegrationPluginEspuino + + + + Error creating MQTT channel. Please check MQTT server settings. + + + + + + Failed to configure MQTT via Websocket. + + + + diff --git a/nymea-plugins.pro b/nymea-plugins.pro index d0802476..f6d3babf 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -21,6 +21,7 @@ PLUGIN_DIRS = \ dynatrace \ elgato \ eq-3 \ + espuino \ fastcom \ flowercare \ fronius \