/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright 2013 - 2020, 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 "integrationpluginkodi.h" #include "integrations/thing.h" #include "plugininfo.h" #include "network/upnp/upnpdiscovery.h" #include "platform/platformzeroconfcontroller.h" #include "network/zeroconf/zeroconfservicebrowser.h" #include "network/zeroconf/zeroconfserviceentry.h" #include "network/networkaccessmanager.h" #include #include IntegrationPluginKodi::IntegrationPluginKodi() { // Q_INIT_RESOURCE(images); // QFile file(":/images/nymea-logo.png"); // if (!file.open(QIODevice::ReadOnly)) { // qCWarning(dcKodi) << "could not open" << file.fileName(); // return; // } // QByteArray nymeaLogoByteArray = file.readAll(); // if (nymeaLogoByteArray.isEmpty()) { // qCWarning(dcKodi) << "could not read" << file.fileName(); // return; // } // m_logo = nymeaLogoByteArray; } IntegrationPluginKodi::~IntegrationPluginKodi() { hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer); delete m_serviceBrowser; delete m_httpServiceBrowser; } void IntegrationPluginKodi::init() { m_serviceBrowser = hardwareManager()->zeroConfController()->createServiceBrowser("_xbmc-jsonrpc._tcp"); m_httpServiceBrowser = hardwareManager()->zeroConfController()->createServiceBrowser("_http._tcp"); m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(10); connect(m_pluginTimer, &PluginTimer::timeout, this, &IntegrationPluginKodi::onPluginTimer); } void IntegrationPluginKodi::setupThing(ThingSetupInfo *info) { Thing *thing = info->thing(); qCDebug(dcKodi) << "Setup Kodi" << thing->paramValue(kodiThingIpParamTypeId).toString(); QUuid kodiUuid = thing->paramValue(kodiThingUuidParamTypeId).toUuid(); // The IP string is optional, we'll try to discover it in any case via zeroconf, however, if it's // set in the params, we'll always fall back to that in case we can't find it on zeroconf. // The recommended way is to not store an IP in the settings as with DHCP lease times (or IPv6 privacy // extension address randomization) an IP might expire eventually and it'll stop working. // So actually the params should *only* store the UUID, but we'll support manually entering IP, port and http port // for setups that can't use ZeroConf for whatever reason. QString ipString = thing->paramValue(kodiThingIpParamTypeId).toString(); int port = thing->paramValue(kodiThingPortParamTypeId).toInt(); int httpPort = thing->paramValue(kodiThingHttpPortParamTypeId).toInt(); if (!kodiUuid.isNull()) { foreach (const ZeroConfServiceEntry &entry, m_serviceBrowser->serviceEntries()) { if (entry.hostAddress().protocol() == QAbstractSocket::IPv6Protocol && entry.hostAddress().toString().startsWith("fe80")) { // We don't support link-local ipv6 addresses yet. skip those entries continue; } QString uuid; foreach (const QString &txt, entry.txt()) { if (txt.startsWith("uuid")) { uuid = txt.split("=").last(); break; } } if (QUuid(uuid) == kodiUuid) { ipString = entry.hostAddress().toString(); port = entry.port(); break; } } foreach (const ZeroConfServiceEntry avahiEntry, m_httpServiceBrowser->serviceEntries()) { QString uuid; foreach (const QString &txt, avahiEntry.txt()) { if (txt.startsWith("uuid")) { uuid = txt.split("=").last(); break; } } if (QUuid(uuid) == kodiUuid) { httpPort = avahiEntry.port(); break; } } } if (ipString.isEmpty()) { // Ok, we could not find an ip on zeroconf... Let's try again in a second while setupInfo hasn't timed out. qCDebug(dcKodi()) << "Device not found via ZeroConf... Waiting for a second for it to appear..."; QTimer::singleShot(1000, info, [this, info](){ setupThing(info); }); return; } qCDebug(dcKodi()).nospace().noquote() << "Connecting to kodi on " << ipString << ":" << port << " (HTTP Port " << httpPort << ")"; Kodi *kodi= new Kodi(QHostAddress(ipString), port, httpPort, this); connect(kodi, &Kodi::connectionStatusChanged, this, &IntegrationPluginKodi::onConnectionChanged); connect(kodi, &Kodi::stateChanged, this, &IntegrationPluginKodi::onStateChanged); connect(kodi, &Kodi::actionExecuted, this, &IntegrationPluginKodi::onActionExecuted); connect(kodi, &Kodi::playbackStatusChanged, this, &IntegrationPluginKodi::onPlaybackStatusChanged); connect(kodi, &Kodi::browserItemExecuted, this, &IntegrationPluginKodi::onBrowserItemExecuted); connect(kodi, &Kodi::browserItemActionExecuted, this, &IntegrationPluginKodi::onBrowserItemActionExecuted); connect(kodi, &Kodi::activePlayerChanged, thing, [thing](const QString &playerType){ thing->setStateValue(kodiPlayerTypeStateTypeId, playerType); }); connect(kodi, &Kodi::mediaMetadataChanged, thing, [this, thing](const QString &title, const QString &artist, const QString &collection, const QString &artwork){ thing->setStateValue(kodiTitleStateTypeId, title); thing->setStateValue(kodiArtistStateTypeId, artist); thing->setStateValue(kodiCollectionStateTypeId, collection); Kodi* kodi = m_kodis.key(thing); QNetworkRequest request; QHostAddress hostAddr(kodi->hostAddress().toString()); QString addr; if (hostAddr.protocol() == QAbstractSocket::IPv4Protocol) { addr = hostAddr.toString(); } else { addr = "[" + hostAddr.toString() + "]"; } QString port = thing->paramValue(kodiThingHttpPortParamTypeId).toString(); request.setUrl(QUrl(QString("http://%1:%2/jsonrpc").arg(addr).arg(port))); qCDebug(dcKodi) << "Prepping file dl" << "http://" + addr + ":" + thing->paramValue(kodiThingPortParamTypeId).toString() + "/jsonrpc"; request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); QVariantMap map; map.insert("jsonrpc", "2.0"); map.insert("method", "Files.PrepareDownload"); map.insert("id", QString::number(123)); QVariantMap params; params.insert("path", artwork); map.insert("params", params); QJsonDocument jsonDoc = QJsonDocument::fromVariant(map); QNetworkReply *reply = hardwareManager()->networkManager()->post(request, jsonDoc.toJson(QJsonDocument::Compact)); connect(reply, &QNetworkReply::finished, thing, [thing, reply, addr, port](){ reply->deleteLater(); QJsonDocument jsonDoc = QJsonDocument::fromJson(reply->readAll()); QString fileUrl = "http://" + addr + ":" + port + "/" + jsonDoc.toVariant().toMap().value("result").toMap().value("details").toMap().value("path").toString(); qCDebug(dcKodi()) << "DL result:" << jsonDoc.toJson(); qCDebug(dcKodi()) << "Resolved url:" << fileUrl; thing->setStateValue(kodiArtworkStateTypeId, fileUrl); }); }); connect(kodi, &Kodi::shuffleChanged, thing, [thing](bool shuffle){ thing->setStateValue(kodiShuffleStateTypeId, shuffle); }); connect(kodi, &Kodi::repeatChanged, thing, [thing](const QString &repeat){ if (repeat == "one") { thing->setStateValue(kodiRepeatStateTypeId, "One"); } else if (repeat == "all") { thing->setStateValue(kodiRepeatStateTypeId, "All"); } else { thing->setStateValue(kodiRepeatStateTypeId, "None"); } }); m_kodis.insert(kodi, thing); m_asyncSetups.insert(kodi, info); connect(info, &QObject::destroyed, this, [this, kodi](){ m_asyncSetups.remove(kodi); }); kodi->connectKodi(); } void IntegrationPluginKodi::thingRemoved(Thing *thing) { Kodi *kodi = m_kodis.key(thing); m_kodis.remove(kodi); qCDebug(dcKodi) << "Delete " << thing->name(); kodi->deleteLater(); } void IntegrationPluginKodi::discoverThings(ThingDiscoveryInfo *info) { QTimer::singleShot(5000, info, [this, info](){ QHash descriptors; foreach (const ZeroConfServiceEntry avahiEntry, m_serviceBrowser->serviceEntries()) { QString uuid; foreach (const QString &txt, avahiEntry.txt()) { if (txt.startsWith("uuid")) { uuid = txt.split("=").last(); break; } } if (descriptors.contains(uuid)) { // Might appear multiple times, IPv4 and IPv6 continue; } qCDebug(dcKodi) << "Zeroconf entry:" << avahiEntry; ThingDescriptor descriptor(kodiThingClassId, avahiEntry.name(), avahiEntry.hostName() + " (" + avahiEntry.hostAddress().toString() + ")"); ParamList params; params << Param(kodiThingUuidParamTypeId, uuid); // params << Param(kodiThingIpParamTypeId, avahiEntry.hostAddress().toString()); params << Param(kodiThingPortParamTypeId, avahiEntry.port()); descriptor.setParams(params); Things existing = myThings().filterByParam(kodiThingUuidParamTypeId, uuid); if (existing.count() > 0) { descriptor.setThingId(existing.first()->id()); } descriptors.insert(uuid, descriptor); } foreach (const ZeroConfServiceEntry avahiEntry, m_httpServiceBrowser->serviceEntries()) { qCDebug(dcKodi) << "Zeroconf http entry:" << avahiEntry; QString uuid; foreach (const QString &txt, avahiEntry.txt()) { if (txt.startsWith("uuid")) { uuid = txt.split("=").last(); break; } } if (!descriptors.contains(uuid)) { continue; } qCDebug(dcKodi()) << "Updating http parameter:" << avahiEntry.port(); ThingDescriptor descriptor = descriptors.value(uuid); ParamList params = descriptor.params(); params << Param(kodiThingHttpPortParamTypeId, avahiEntry.port()); descriptor.setParams(params); descriptors[uuid] = descriptor; } foreach (const ThingDescriptor &d, descriptors.values()) { qCDebug(dcKodi()) << "Returning descritpor:" << d.params(); } info->addThingDescriptors(descriptors.values()); info->finish(Thing::ThingErrorNoError); }); } void IntegrationPluginKodi::executeAction(ThingActionInfo *info) { Action action = info->action(); Thing *thing = info->thing(); Kodi *kodi = m_kodis.key(thing); // check connection state if (!kodi->connected()) { return info->finish(Thing::ThingErrorHardwareNotAvailable); } int commandId = -1; if (action.actionTypeId() == kodiNotifyActionTypeId) { commandId = kodi->showNotification( action.param(kodiNotifyActionTitleParamTypeId).value().toString(), action.param(kodiNotifyActionBodyParamTypeId).value().toString(), 8000, action.param(kodiNotifyActionTypeParamTypeId).value().toString()); } else if (action.actionTypeId() == kodiVolumeActionTypeId) { commandId = kodi->setVolume(action.param(kodiVolumeActionVolumeParamTypeId).value().toInt()); } else if (action.actionTypeId() == kodiMuteActionTypeId) { commandId = kodi->setMuted(action.param(kodiMuteActionMuteParamTypeId).value().toBool()); } else if (action.actionTypeId() == kodiNavigateActionTypeId) { commandId = kodi->navigate(action.param(kodiNavigateActionToParamTypeId).value().toString()); } else if (action.actionTypeId() == kodiSystemActionTypeId) { commandId = kodi->systemCommand(action.param(kodiSystemActionSystemCommandParamTypeId).value().toString()); } else if(action.actionTypeId() == kodiSkipBackActionTypeId) { commandId = kodi->navigate("skipprevious"); } else if(action.actionTypeId() == kodiFastRewindActionTypeId) { commandId = kodi->navigate("rewind"); } else if(action.actionTypeId() == kodiStopActionTypeId) { commandId = kodi->navigate("stop"); } else if(action.actionTypeId() == kodiPlayActionTypeId) { commandId = kodi->navigate("play"); } else if(action.actionTypeId() == kodiPauseActionTypeId) { commandId = kodi->navigate("pause"); } else if(action.actionTypeId() == kodiFastForwardActionTypeId) { commandId = kodi->navigate("fastforward"); } else if(action.actionTypeId() == kodiSkipNextActionTypeId) { commandId = kodi->navigate("skipnext"); } else if (action.actionTypeId() == kodiShuffleActionTypeId) { commandId = kodi->setShuffle(action.param(kodiShuffleActionShuffleParamTypeId).value().toBool()); } else if (action.actionTypeId() == kodiRepeatActionTypeId) { QString repeat = action.param(kodiRepeatActionRepeatParamTypeId).value().toString(); if (repeat == "One") { commandId = kodi->setRepeat("one"); } else if (repeat == "All") { commandId = kodi->setRepeat("all"); } else { commandId = kodi->setRepeat("off"); } } else { qWarning(dcKodi()) << "Unhandled action type" << action.actionTypeId(); return info->finish(Thing::ThingErrorActionTypeNotFound); } m_pendingActions.insert(commandId, info); connect(info, &QObject::destroyed, this, [this, commandId](){ m_pendingActions.remove(commandId); }); } void IntegrationPluginKodi::browseThing(BrowseResult *result) { Kodi *kodi = m_kodis.key(result->thing()); if (!kodi) { result->finish(Thing::ThingErrorHardwareNotAvailable); return; } kodi->browse(result); } void IntegrationPluginKodi::browserItem(BrowserItemResult *result) { Kodi *kodi = m_kodis.key(result->thing()); if (!kodi) { result->finish(Thing::ThingErrorHardwareNotAvailable); return; } kodi->browserItem(result); } void IntegrationPluginKodi::executeBrowserItem(BrowserActionInfo *info) { Kodi *kodi = m_kodis.key(info->thing()); if (!kodi) { info->finish(Thing::ThingErrorHardwareNotAvailable); return; } int id = kodi->launchBrowserItem(info->browserAction().itemId()); if (id == -1) { return info->finish(Thing::ThingErrorHardwareFailure); } m_pendingBrowserActions.insert(id, info); connect(info, &QObject::destroyed, this, [this, id](){ m_pendingBrowserActions.remove(id); }); } void IntegrationPluginKodi::executeBrowserItemAction(BrowserItemActionInfo *info) { Kodi *kodi = m_kodis.key(info->thing()); if (!kodi) { return info->finish(Thing::ThingErrorHardwareNotAvailable); } int id = kodi->executeBrowserItemAction(info->browserItemAction().itemId(), info->browserItemAction().actionTypeId()); if (id == -1) { return info->finish(Thing::ThingErrorHardwareFailure); } m_pendingBrowserItemActions.insert(id, info); connect(info, &QObject::destroyed, this, [this, id](){ m_pendingBrowserItemActions.remove(id); }); } void IntegrationPluginKodi::onPluginTimer() { foreach (Kodi *kodi, m_kodis.keys()) { if (!kodi->connected()) { kodi->connectKodi(); continue; } } } void IntegrationPluginKodi::onConnectionChanged(bool connected) { Kodi *kodi = static_cast(sender()); Thing *thing = m_kodis.value(kodi); // Finish setup ThingSetupInfo *info = m_asyncSetups.value(kodi); if (info) { if (connected) { info->finish(Thing::ThingErrorNoError); } else { //: Error setting up thing m_asyncSetups.take(kodi)->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("This installation of Kodi is too old. Please upgrade your Kodi system.")); } } kodi->showNotification("nymea", tr("Connected"), 2000, "info"); thing->setStateValue(kodiConnectedStateTypeId, kodi->connected()); } void IntegrationPluginKodi::onStateChanged() { Kodi *kodi = static_cast(sender()); Thing *thing = m_kodis.value(kodi); // set thing state values thing->setStateValue(kodiVolumeStateTypeId, kodi->volume()); thing->setStateValue(kodiMuteStateTypeId, kodi->muted()); } void IntegrationPluginKodi::onActionExecuted(int actionId, bool success) { if (!m_pendingActions.contains(actionId)) { return; } m_pendingActions.take(actionId)->finish(success ? Thing::ThingErrorNoError : Thing::ThingErrorInvalidParameter); } void IntegrationPluginKodi::onBrowserItemExecuted(int actionId, bool success) { if (!m_pendingBrowserActions.contains(actionId)) { return; } m_pendingBrowserActions.take(actionId)->finish(success ? Thing::ThingErrorNoError : Thing::ThingErrorInvalidParameter); } void IntegrationPluginKodi::onBrowserItemActionExecuted(int actionId, bool success) { if (!m_pendingBrowserItemActions.contains(actionId)) { return; } m_pendingBrowserItemActions.take(actionId)->finish(success ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareFailure); } void IntegrationPluginKodi::onPlaybackStatusChanged(const QString &playbackStatus) { Kodi *kodi = static_cast(sender()); Thing *thing = m_kodis.value(kodi); thing->setStateValue(kodiPlaybackStatusStateTypeId, playbackStatus); // legacy events if (playbackStatus == "Playing") { emit emitEvent(Event(kodiOnPlayerPlayEventTypeId, thing->id())); } else if (playbackStatus == "Paused") { emit emitEvent(Event(kodiOnPlayerPauseEventTypeId, thing->id())); } else { emit emitEvent(Event(kodiOnPlayerStopEventTypeId, thing->id())); } }