diff --git a/CMakeLists.txt b/CMakeLists.txt index b348de5..621b810 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,12 +34,12 @@ find_package(Qt6 QUIET COMPONENTS Core NO_SYSTEM_ENVIRONMENT_PATH) if (Qt6_FOUND) find_package(Qt6 REQUIRED COMPONENTS Network Test) - find_package(Qt6 QUIET COMPONENTS Zlib) + find_package(Qt6 QUIET COMPONENTS Widgets Zlib) set(Qt_DIR ${QT6_INSTALL_PREFIX}) set(Qt_VERSION ${Qt6_VERSION}) else() find_package(Qt5 5.15 REQUIRED COMPONENTS Network Test) - find_package(Qt5 QUIET COMPONENTS Zlib) + find_package(Qt5 QUIET COMPONENTS Widgets Zlib) set(Qt_VERSION ${Qt5_VERSION}) set(Qt_DIR ${Qt5_DIR}) endif() @@ -99,6 +99,7 @@ add_subdirectory(http) add_subdirectory(mdns) add_subdirectory(qnc) add_subdirectory(ssdp) +add_subdirectory(upnp) add_subdirectory(xml) if (NOT IOS) # FIXME Figure out code signing on Github diff --git a/mdns/CMakeLists.txt b/mdns/CMakeLists.txt index 87e4bd4..dceeae6 100644 --- a/mdns/CMakeLists.txt +++ b/mdns/CMakeLists.txt @@ -12,8 +12,8 @@ target_link_libraries(QncMdns PUBLIC QncCore) if (NOT IOS) # FIXME Figure out code signing on Github - qnc_add_executable(MDNSResolverDemo TYPE tool mdnsresolverdemo.cpp) - target_link_libraries(MDNSResolverDemo PRIVATE QncMdns) + qnc_add_executable(mdnsresolverdemo TYPE tool mdnsresolverdemo.cpp) + target_link_libraries(mdnsresolverdemo PRIVATE QncMdns) endif() #if (WIN32) diff --git a/ssdp/CMakeLists.txt b/ssdp/CMakeLists.txt index adf944e..c6a028f 100644 --- a/ssdp/CMakeLists.txt +++ b/ssdp/CMakeLists.txt @@ -7,6 +7,6 @@ qnc_add_library( target_link_libraries(QncSsdp PUBLIC QncHttp) if (NOT IOS) # FIXME Figure out code signing on Github - qnc_add_executable(SSDPResolverDemo TYPE tool ssdpresolverdemo.cpp) - target_link_libraries(SSDPResolverDemo PRIVATE QncSsdp) + qnc_add_executable(ssdpresolverdemo TYPE tool ssdpresolverdemo.cpp) + target_link_libraries(ssdpresolverdemo PRIVATE QncSsdp) endif() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0653827..ec45685 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1 +1,2 @@ add_subdirectory(auto) +add_subdirectory(manual) diff --git a/tests/manual/CMakeLists.txt b/tests/manual/CMakeLists.txt new file mode 100644 index 0000000..49f5e01 --- /dev/null +++ b/tests/manual/CMakeLists.txt @@ -0,0 +1,2 @@ +qnc_add_executable(upnpparsertest upnpparsertest.cpp) +target_link_libraries(upnpparsertest PRIVATE QncUpnp) diff --git a/tests/manual/upnpparsertest.cpp b/tests/manual/upnpparsertest.cpp new file mode 100644 index 0000000..338e31d --- /dev/null +++ b/tests/manual/upnpparsertest.cpp @@ -0,0 +1,157 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ + +// QtNetworkCrumbs headers +#include "qncliterals.h" +#include "upnpresolver.h" + +// Qt headers +#include +#include +#include + +namespace qnc::upnp::tests { +namespace { + +class ParserTest : public QCoreApplication +{ +public: + using QCoreApplication::QCoreApplication; + + int run() + { + const auto ssdpDir = QCommandLineOption{"ssdp"_L1, tr("directory with SSDP files"), tr("DIRPATH")}; + const auto scpdDir = QCommandLineOption{"scpd"_L1, tr("directory with SCPD files"), tr("DIRPATH")}; + const auto controlDir = QCommandLineOption{"control"_L1, tr("directory with control files"), tr("DIRPATH")}; + const auto eventingDir = QCommandLineOption{"eventing"_L1, tr("directory with eventing files"), tr("DIRPATH")}; + + auto commandLine = QCommandLineParser{}; + + commandLine.addOption(ssdpDir); + commandLine.addOption(scpdDir); + commandLine.addOption(controlDir); + commandLine.addOption(eventingDir); + commandLine.addHelpOption(); + + commandLine.process(arguments()); + + if (!commandLine.positionalArguments().isEmpty()) { + commandLine.showHelp(EXIT_FAILURE); + return EXIT_FAILURE; + } + + if (commandLine.isSet(ssdpDir) && !readTestData(commandLine.value(ssdpDir), &ssdpParse)) + return EXIT_FAILURE; + if (commandLine.isSet(scpdDir) && !readTestData(commandLine.value(scpdDir), &scpdParse)) + return EXIT_FAILURE; + + return EXIT_SUCCESS; + } + +private: + static bool readTestData(const QString &path, bool (* parse)(QFile *)) + { + const auto fileInfo = QFileInfo{path}; + + if (fileInfo.isDir()) { + const auto &fileInfoList = QDir{path}.entryInfoList(QDir::Files); + + for (const auto &fileInfo : fileInfoList) { + if (!readFile(fileInfo, parse)) + return false; + } + + return true; + } else { + return readFile(fileInfo, parse); + } + } + + static bool readFile(const QFileInfo &fileInfo, bool (* parse)(QFile *)) + { + qInfo() << QUrl::fromPercentEncoding(fileInfo.fileName().toUtf8()); + + auto file = QFile{fileInfo.filePath()}; + qInfo() << file.fileName(); + + if (!file.open(QFile::ReadOnly)) { + qWarning() << file.errorString(); + return false; + } + + if (!parse(&file)) + return false; + + return true; + } + + static bool ssdpParse(QFile *file) + { + const auto &deviceList = DeviceDescription::parse(file); + + if (deviceList.isEmpty()) + return false; + + for (const auto &device : deviceList) { + qInfo() << "-" + << device.specVersion + << device.displayName + << device.deviceType; + + for (const auto &service : device.services) { + qInfo() << " -" + << service.type + << service.id; + } + } + + return true; + } + + static bool scpdParse(QFile *file) + { + const auto controlPoint = ControlPointDescription::parse(file); + + if (!controlPoint) + return false; + + qInfo() << "- actions:"; + for (const auto &action : controlPoint->actions) { + qInfo() << " -" + << action.name + << action.flags; + + for (const auto &argument : action.arguments) { + qInfo() << " -" + << argument.name + // FIXME: << argument.direction + << argument.stateVariable; + } + } + + qInfo() << "- variables:"; + for (const auto &variable : controlPoint->stateVariables) { + qInfo() << " -" + << variable.name + // FIXME: << variable.dataType + << variable.defaultValue + << variable.flags + << variable.allowedValues + << variable.valueRange.minimum + << variable.valueRange.maximum + << variable.valueRange.step; + } + + return true; + } +}; + +} // namespace +} // namespace qnc::upnp::tests + +int main(int argc, char *argv[]) +{ + return qnc::upnp::tests::ParserTest{argc, argv}.run(); +} + diff --git a/upnp/CMakeLists.txt b/upnp/CMakeLists.txt new file mode 100644 index 0000000..6ead639 --- /dev/null +++ b/upnp/CMakeLists.txt @@ -0,0 +1,19 @@ +qnc_add_library( + QncUpnp STATIC + upnpresolver.cpp + upnpresolver.h + upnptreemodel.cpp + upnptreemodel.h +) + +target_link_libraries(QncUpnp PUBLIC QncSsdp QncXml) + +if (NOT IOS) # FIXME Figure out code signing on Github + qnc_add_executable(upnpresolverdemo TYPE tool upnpresolverdemo.cpp) + target_link_libraries(upnpresolverdemo PRIVATE QncUpnp) + + if (TARGET Qt::Widgets) + qnc_add_executable(upnpviewer TYPE tool upnpviewer.cpp) + target_link_libraries(upnpviewer PRIVATE QncUpnp Qt::Widgets) + endif() +endif() diff --git a/upnp/upnpresolver.cpp b/upnp/upnpresolver.cpp new file mode 100644 index 0000000..9ce274f --- /dev/null +++ b/upnp/upnpresolver.cpp @@ -0,0 +1,671 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#include "upnpresolver.h" + +// QtNetworkCrumbs headers +#include "httpparser.h" +#include "qncliterals.h" +#include "xmlparser.h" + +// Qt headers +#include +#include +#include + +namespace qnc::xml { + +using namespace qnc::upnp; + +template <> DeviceModel ¤tObject(DeviceDescription &device) { return device.model; } +template <> DeviceManufacturer ¤tObject(DeviceDescription &device) { return device.manufacturer; } +template <> IconDescription ¤tObject(DeviceDescription &device) { return device.icons.last(); } +template <> ServiceDescription ¤tObject(DeviceDescription &device) { return device.services.last(); } +template <> ActionDescription ¤tObject(ControlPointDescription &service) { return service.actions.last(); } +template <> ArgumentDescription ¤tObject(ControlPointDescription &service) { return service.actions.last().arguments.last(); } +template <> StateVariableDescription ¤tObject(ControlPointDescription &service) { return service.stateVariables.last(); } +template <> ValueRangeDescription ¤tObject(ControlPointDescription &service) { return service.stateVariables.last().valueRange; } + +template <> constexpr std::size_t keyCount = 2; +template <> constexpr std::size_t keyCount = 25; + +template <> +constexpr KeyValueMap keyValueMap<>() +{ + using Direction = upnp::ArgumentDescription::Direction; + + return { + { + {Direction::Input, u"in"}, + {Direction::Output, u"out"}, + } + }; +} + +template <> +constexpr KeyValueMap keyValueMap<>() +{ + using DataType = upnp::StateVariableDescription::DataType; + + return { + { + {DataType::Int8, u"i1"}, + {DataType::Int16, u"i2"}, + {DataType::Int32, u"i4"}, + {DataType::Int64, u"i8"}, + {DataType::UInt8, u"ui1"}, + {DataType::UInt16, u"ui2"}, + {DataType::UInt32, u"ui4"}, + {DataType::UInt64, u"ui8"}, + {DataType::Int, u"int"}, + {DataType::Float, u"r4"}, + {DataType::Double, u"r8"}, + {DataType::Double, u"number"}, + {DataType::Fixed, u"fixed.14.4"}, + {DataType::Char, u"char"}, + {DataType::String, u"string"}, + {DataType::Date, u"date"}, + {DataType::DateTime, u"datetime"}, + {DataType::LocalDateTime, u"datetime.tz"}, + {DataType::Time, u"time"}, + {DataType::LocalTime, u"time.tz"}, + {DataType::Bool, u"boolean"}, + {DataType::Uri, u"uri"}, + {DataType::Uuid, u"uuid"}, + {DataType::Base64, u"bin.base64"}, + {DataType::BinHex, u"bin.hex"}, + } + }; +} + +static_assert(keyValueMap().size() == 25); + +} // namespace qnc::xml + +namespace qnc::upnp { + +namespace { + +Q_LOGGING_CATEGORY(lcResolver, "qnc.upnp.resolver") +Q_LOGGING_CATEGORY(lcParserSsdp, "qnc.upnp.parser.ssdp", QtInfoMsg) +Q_LOGGING_CATEGORY(lcParserScpd, "qnc.upnp.parser.scpd", QtInfoMsg) + +static constexpr auto s_upnpDeviceNamespace = u"urn:schemas-upnp-org:device-1-0"; +static constexpr auto s_upnpServiceNamespace = u"urn:schemas-upnp-org:service-1-0"; + +#if QT_VERSION_MAJOR < 6 +using xml::qHash; +#endif // QT_VERSION_MAJOR < 6 + +inline namespace states { + +Q_NAMESPACE + +enum class DeviceDescriptionState { + Document, + Root, + SpecVersion, + DeviceList, + Device, + IconList, + Icon, + ServiceList, + Service, +}; + +Q_ENUM_NS(DeviceDescriptionState) + +enum class ControlPointDescriptionState { + Document, + Root, + SpecVersion, + ActionList, + Action, + ArgumentList, + Argument, + ServiceStateTable, + StateVariable, + AllowedValueList, + AllowedValueRange, +}; + +Q_ENUM_NS(ControlPointDescriptionState) + +} // namespace states + +class DeviceDescriptionParser : public xml::Parser +{ +public: + using Parser::Parser; + + [[nodiscard]] QList read(const QUrl &deviceUrl, State initialState = State::Document); +}; + +class ControlPointDescriptionParser : public xml::Parser +{ +public: + using Parser::Parser; + + [[nodiscard]] std::optional read(State initialState = State::Document); +}; + +class DetailLoader : public QObject +{ + Q_OBJECT + +public: + explicit DetailLoader(DeviceDescription &&device, + QNetworkAccessManager *networkAccessManager, + QObject *parent = nullptr) + : QObject{parent} + , m_networkAccessManager{networkAccessManager} + , m_device{std::move(device)} + {} + + void loadIcons(); + void loadServiceDescription(); + + const DeviceDescription &device() const { return m_device; } + QList pendingReplies() const { return m_pendingReplies; } + +signals: + void finished(); + +private: + template + void load(const char *topic, Container *container); + + void checkIfAllRequestsFinished(); + + QNetworkAccessManager *const m_networkAccessManager; + QList m_pendingReplies = {}; + DeviceDescription m_device; +}; + +QList DeviceDescriptionParser::read(const QUrl &deviceUrl, State initialState) +{ + auto device = DeviceDescription{}; + device.url = deviceUrl; + device.baseUrl = deviceUrl; + + auto deviceList = QList{}; + deviceList.resize(1); // just reserving memory now to avoid dangling pointers; will update on success + + const auto parsers = StateTable { + { + State::Document, { + {u"root", transition()}, + } + }, { + State::Root, { + {u"URLBase", assign<&DeviceDescription::baseUrl>(device)}, + {u"specVersion", transition()}, + {u"device", transition()}, + } + }, { + State::SpecVersion, { + {u"major", assign<&DeviceDescription::specVersion, xml::VersionSegment::Major>(device)}, + {u"minor", assign<&DeviceDescription::specVersion, xml::VersionSegment::Minor>(device)}, + } + }, { + State::DeviceList, { + { + u"device", [this, &device, &deviceList] { + deviceList += read(device.baseUrl, State::Device); + } + }, + } + }, { + State::Device, { + {u"deviceType", assign<&DeviceDescription::deviceType>(device)}, + {u"friendlyName", assign<&DeviceDescription::displayName>(device)}, + {u"manufacturer", assign<&DeviceManufacturer::name>(device)}, + {u"manufacturerURL", assign<&DeviceManufacturer::url>(device)}, + {u"modelDescription", assign<&DeviceModel::description>(device)}, + {u"modelName", assign<&DeviceModel::name>(device)}, + {u"modelNumber", assign<&DeviceModel::number>(device)}, + {u"modelURL", assign<&DeviceModel::url>(device)}, + {u"presentationURL", assign<&DeviceDescription::presentationUrl>(device)}, + {u"serialNumber", assign<&DeviceDescription::serialNumber>(device)}, + {u"UDN", assign<&DeviceDescription::uniqueDeviceName>(device)}, + {u"UPC", assign<&DeviceModel::universalProductCode>(device)}, + + {u"deviceList", transition()}, + {u"iconList", transition()}, + {u"serviceList", transition()}, + } + }, { + State::IconList, { + {u"icon", transition(device)}, + } + }, { + State::Icon, { + {u"mimetype", assign<&IconDescription::mimeType>(device)}, + {u"width", assign<&IconDescription::size, &QSize::setWidth>(device)}, + {u"height", assign<&IconDescription::size, &QSize::setHeight>(device)}, + {u"depth", assign<&IconDescription::depth>(device)}, + {u"url", assign<&IconDescription::url>(device)}, + } + }, { + State::ServiceList, { + {u"service", transition(device)}, + } + }, { + State::Service, { + {u"serviceId", assign<&ServiceDescription::id>(device)}, + {u"serviceType", assign<&ServiceDescription::type>(device)}, + {u"SCPDURL", assign<&ServiceDescription::scpdUrl>(device)}, + {u"controlURL", assign<&ServiceDescription::controlUrl>(device)}, + {u"eventSubURL", assign<&ServiceDescription::eventingUrl>(device)}, + } + } + }; + + if (!Parser::parse(lcParserSsdp(), initialState, {{s_upnpDeviceNamespace, parsers}})) + return {}; + + deviceList.first() = std::move(device); + + return deviceList; +} + +std::optional ControlPointDescriptionParser::read(State initialState) +{ + auto service = ControlPointDescription{}; + + const auto parsers = StateTable { + { + State::Document, { + {u"scpd", transition()}, + } + }, { + State::Root, { + {u"specVersion", transition()}, + {u"actionList", transition()}, + {u"serviceStateTable", transition()}, + } + }, { + State::SpecVersion, { + {u"major", assign<&ControlPointDescription::spec, xml::VersionSegment::Major>(service)}, + {u"minor", assign<&ControlPointDescription::spec, xml::VersionSegment::Minor>(service)}, + } + }, { + State::ActionList, { + {u"action", transition(service)}, + } + }, { + State::Action, { + {u"name", assign<&ActionDescription::name>(service)}, + {u"argumentList", transition()}, + {u"Optional", assign<&ActionDescription::flags, ActionDescription::Flag::Optional>(service)}, + } + }, { + State::ArgumentList, { + {u"argument", transition(service)}, + } + }, { + State::Argument, { + {u"name", assign<&ArgumentDescription::name>(service)}, + {u"direction", assign<&ArgumentDescription::direction>(service)}, + {u"retval", assign<&ArgumentDescription::flags, ArgumentDescription::Flag::ReturnValue>(service)}, + {u"relatedStateVariable", assign<&ArgumentDescription::stateVariable>(service)}, + } + }, { + State::ServiceStateTable, { + {u"stateVariable", transition(service)}, + } + }, { + State::StateVariable, { + {u"name", assign<&StateVariableDescription::name>(service)}, + {u"dataType", assign<&StateVariableDescription::dataType>(service)}, + {u"defaultValue", assign<&StateVariableDescription::defaultValue>(service)}, + {u"allowedValueList", transition()}, + {u"allowedValueRange", transition()}, + {u"@sendEvents", assign<&StateVariableDescription::flags, + StateVariableDescription::Flag::SendEvents>(service)} + } + }, { + State::AllowedValueList, { + {u"allowedValue", append<&StateVariableDescription::allowedValues>(service)}, + } + }, { + State::AllowedValueRange, { + {u"minimum", assign<&ValueRangeDescription::minimum>(service)}, + {u"maximum", assign<&ValueRangeDescription::maximum>(service)}, + {u"step", assign<&ValueRangeDescription::step>(service)}, + } + } + }; + + if (!Parser::parse(lcParserScpd(), initialState, {{s_upnpServiceNamespace, parsers}})) + return {}; + + return service; +} + +QUrl itemUrl(const IconDescription &icon) +{ + return icon.url; +} + +QUrl itemUrl(const ServiceDescription &service) +{ + return service.scpdUrl; +} + +bool itemHasData(const IconDescription &icon) +{ + return !icon.data.isEmpty(); +} + +bool itemHasData(const ServiceDescription &service) +{ + return service.scpd.has_value(); +} + +void updateItem(IconDescription &icon, QNetworkReply *reply) +{ + icon.data = reply->readAll(); +} + +void updateItem(ServiceDescription &service, QNetworkReply *reply) +{ + service.scpd = ControlPointDescription::parse(reply); +} + +template +void DetailLoader::load(const char *topic, Container *container) +{ + const auto first = container->cbegin(); + const auto last = container->cend(); + + for (auto it = first; it != last; ++it) { + const auto &url = itemUrl(*it); + + if (url.isEmpty()) + continue; + if (itemHasData(*it)) + continue; + + const auto request = QNetworkRequest{m_device.url.resolved(url)}; + const auto reply = m_networkAccessManager->get(request); + const auto index = it - first; + + qCDebug(lcResolver, + "Downloading %s for %ls from <%ls>", topic, + qUtf16Printable(m_device.uniqueDeviceName), + qUtf16Printable(request.url().toDisplayString())); + + connect(reply, &QNetworkReply::finished, this, [this, container, index, reply] { + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcResolver, "Could not download detail for %ls from %ls: %ls", + qUtf16Printable(m_device.uniqueDeviceName), + qUtf16Printable(reply->url().toDisplayString()), + qUtf16Printable(reply->errorString())); + } else { + updateItem((*container)[index], reply); + } + + checkIfAllRequestsFinished(); + }); + + m_pendingReplies += reply; + } +} + +void DetailLoader::loadIcons() +{ + load("icons", &m_device.icons); +} + +void DetailLoader::loadServiceDescription() +{ + load("services", &m_device.services); +} + +void DetailLoader::checkIfAllRequestsFinished() +{ + for (const auto reply : m_pendingReplies) + if (reply && reply->isRunning()) + return; + + qCDebug(lcResolver, + "All details downloaded for %ls", + qUtf16Printable(m_device.uniqueDeviceName)); + + emit finished(); +} + +} // namespace + +Resolver::Resolver(QObject *parent) + : ssdp::Resolver{parent} +{ + connect(this, &Resolver::serviceFound, this, &Resolver::onServiceFound); +} + +Resolver::Resolver(QNetworkAccessManager *manager, QObject *parent) + : Resolver{parent} +{ + setNetworkAccessManager(manager); +} + +Resolver::~Resolver() +{ + abortPendingReplies(); +} + +Resolver::Behaviors Resolver::behaviors() const +{ + return m_behaviors; +} + +void Resolver::setBehaviors(Behaviors newBehaviors) +{ + if (const auto oldBehaviors = std::exchange(m_behaviors, newBehaviors); oldBehaviors != newBehaviors) + emit behaviorsChanged(m_behaviors); +} + +void Resolver::setBehavior(Behavior behavior, bool enabled) +{ + auto newBehaviors = behaviors(); + newBehaviors.setFlag(behavior, enabled); + setBehaviors(newBehaviors); +} + +void Resolver::setNetworkAccessManager(QNetworkAccessManager *newManager) +{ + if (const auto oldManager = std::exchange(m_networkAccessManager, newManager); oldManager != newManager) { + if (oldManager) + oldManager->disconnect(this); + + abortPendingReplies(); + + emit networkAccessManagerChanged(m_networkAccessManager); + } +} + +QNetworkAccessManager *Resolver::networkAccessManager() const +{ + return m_networkAccessManager; +} + +void Resolver::onServiceFound(const ssdp::ServiceDescription &service) +{ + const auto &locationList = service.locations(); + + for (const auto &url : locationList) { + if (m_networkAccessManager) { + qCDebug(lcResolver, + "Downloading device description for %ls from <%ls>", + qUtf16Printable(service.name()), qUtf16Printable(url.toDisplayString())); + + const auto reply = m_networkAccessManager->get(QNetworkRequest{url}); + + connect(reply, &QNetworkReply::finished, this, [this, reply] { + onDeviceDescriptionReceived(reply); + }); + + m_pendingReplies += reply; + } else { + qCDebug(lcResolver, + "Directly reporting %ls without downloading from <%ls>", + qUtf16Printable(service.name()), qUtf16Printable(url.toDisplayString())); + + auto device = DeviceDescription{}; + + device.url = url; + device.deviceType = service.type(); + device.uniqueDeviceName = service.name(); + + emit deviceFound(device); + } + } +} + +void Resolver::onDeviceDescriptionReceived(QNetworkReply *reply) +{ + m_pendingReplies.removeOne(reply); + + if (reply->error() == QNetworkReply::NoError) { + qCDebug(lcResolver, + "Device description received from <%ls>", + qUtf16Printable(reply->url().toDisplayString())); + } else { + qCWarning(lcResolver, + "Could not download device description received from <%ls>: %ls", + qUtf16Printable(reply->url().toDisplayString()), + qUtf16Printable(reply->errorString())); + } + + auto deviceList = DeviceDescription::parse(reply, reply->url()); + + for (auto &device : deviceList) { + if (m_behaviors && m_networkAccessManager) { + const auto detailLoader = new DetailLoader{std::move(device), m_networkAccessManager, this}; + + if (m_behaviors.testFlag(Behavior::LoadIcons)) + detailLoader->loadIcons(); + + if (m_behaviors.testFlag(Behavior::LoadServiceDescription)) + detailLoader->loadServiceDescription(); + + m_pendingReplies += detailLoader->pendingReplies(); + + connect(detailLoader, &DetailLoader::finished, + this, [this, detailLoader, device] { + emit deviceFound(detailLoader->device()); + detailLoader->deleteLater(); + }); + } else { + emit deviceFound(device); + } + } +} + +void Resolver::abortPendingReplies() +{ + const auto &abortedReplies = std::exchange(m_pendingReplies, {}); + + for (const auto reply : abortedReplies) + reply->abort(); +} + +QList DeviceDescription::parse(QIODevice *device, const QUrl &deviceUrl) +{ + auto xml = QXmlStreamReader{device}; + return DeviceDescriptionParser{&xml}.read(deviceUrl); +} + +std::optional ControlPointDescription::parse(QIODevice *device) +{ + auto xml = QXmlStreamReader{device}; + return ControlPointDescriptionParser{&xml}.read(); +} + +} // namespace qnc::upnp + +QDebug operator<<(QDebug debug, const qnc::upnp::DeviceDescription &device) +{ + const auto _ = QDebugStateSaver{debug}; + + const auto verbose = (debug.verbosity() > QDebug::DefaultVerbosity); + const auto silent = (debug.verbosity() < QDebug::DefaultVerbosity); + + if (verbose) + debug.nospace() << device.staticMetaObject.className(); + + debug << "(udn=" << device.uniqueDeviceName + << ", type=" << device.deviceType; + + if (verbose) + debug << ", spec=" << device.specVersion; + + if (!device.displayName.isEmpty()) + debug << ", name=" << device.displayName; + + debug << ", manufacturer=" << device.manufacturer + << ", model=" << device.model; + + if (!device.presentationUrl.isEmpty()) + debug << ", presentationUrl=" << device.presentationUrl; + if (!device.serialNumber.isEmpty()) + debug << ", serialNumber=" << device.serialNumber; + if (!device.url.isEmpty()) + debug << ", url=" << device.url; + + if (!silent) { + // QList icons = {}; + // QList services = {}; + } + + return debug << ')'; +} + +QDebug operator<<(QDebug debug, const qnc::upnp::DeviceManufacturer &manufacturer) +{ + const auto _ = QDebugStateSaver{debug}; + + const auto verbose = (debug.verbosity() > QDebug::DefaultVerbosity); + const auto silent = (debug.verbosity() < QDebug::DefaultVerbosity); + + if (verbose) + debug.nospace() << manufacturer.staticMetaObject.className(); + + debug << '('; + + if (!manufacturer.name.isEmpty()) + debug << ", name=" << manufacturer.name; + if (!silent && !manufacturer.url.isEmpty()) + debug << ", url=" << manufacturer.url; + + return debug << ')'; +} + +QDebug operator<<(QDebug debug, const qnc::upnp::DeviceModel &model) +{ + const auto _ = QDebugStateSaver{debug}; + + const auto verbose = (debug.verbosity() > QDebug::DefaultVerbosity); + const auto silent = (debug.verbosity() < QDebug::DefaultVerbosity); + + if (verbose) + debug.nospace() << model.staticMetaObject.className(); + + debug << '('; + + if (!model.universalProductCode.isEmpty()) + debug << ", universalProductCode=" << model.universalProductCode; + if (!model.description.isEmpty()) + debug << ", description=" << model.description; + if (!model.name.isEmpty()) + debug << ", name=" << model.name; + if (!model.number.isEmpty()) + debug << ", number=" << model.number; + if (!silent && !model.url.isEmpty()) + debug << ", url=" << model.url; + + return debug << ')'; +} + +#include "upnpresolver.moc" diff --git a/upnp/upnpresolver.h b/upnp/upnpresolver.h new file mode 100644 index 0000000..f5341b5 --- /dev/null +++ b/upnp/upnpresolver.h @@ -0,0 +1,275 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#ifndef QNCUPNP_UPNPRESOLVER_H +#define QNCUPNP_UPNPRESOLVER_H + +// QtNetworkCrumbs headers +#include "ssdpresolver.h" + +// Qt headers +#include +#include +#include + +class QXmlStreamReader; + +namespace qnc::upnp { + +struct IconDescription +{ + QString mimeType = {}; + QSize size = {}; + int depth = {}; + QUrl url = {}; + QByteArray data = {}; + + Q_GADGET +}; + +inline namespace scpd { + +struct ArgumentDescription +{ + enum class Direction { + Input, + Output, + }; + + Q_ENUM(Direction) + + enum class Flag { + ReturnValue = 1 << 0, + }; + + Q_FLAG(Flag) + Q_DECLARE_FLAGS(Flags, Flag) + + QString name = {}; + Direction direction = Direction::Output; + Flags flags = {}; + QString stateVariable = {}; + + Q_GADGET +}; + +struct ActionDescription +{ + enum class Flag { + Optional = 1 << 0, + }; + + Q_FLAG(Flag) + Q_DECLARE_FLAGS(Flags, Flag) + + QString name = {}; + Flags flags = {}; + QList arguments = {}; + + Q_GADGET +}; + +struct ValueRangeDescription +{ + // FIXME: instead of resorting to qint64 this must depend on dataType; at least it should be a variant + + qint64 minimum = {}; + qint64 maximum = {}; + qint64 step = {}; + + Q_GADGET +}; + +struct StateVariableDescription +{ + enum class Flag { + SendEvents = 1 << 0, + }; + + Q_FLAG(Flag) + Q_DECLARE_FLAGS(Flags, Flag) + + enum class DataType { // FIXME obey Qt naming conventions + Int8, + Int16, + Int32, + Int64, + + UInt8, + UInt16, + UInt32, + UInt64, + + Int, + Float, + Double, + Fixed, + + Char, + String, + + Date, + DateTime, + LocalDateTime, + + Time, + LocalTime, + + Bool, + Uri, + Uuid, + + Base64, + BinHex, + + I1 = Int8, + I2 = Int16, + I4 = Int32, + I8 = Int64, + + UI1 = UInt8, + UI2 = UInt16, + UI4 = UInt32, + UI8 = UInt64, + + R4 = Float, + R8 = Double, + Number = Double, + }; + + Q_ENUM(DataType) + + using DataTypeVariant = std::variant; + + QString name; + Flags flags; + DataTypeVariant dataType; + QString defaultValue; + QStringList allowedValues; // FIXME: make optional + ValueRangeDescription valueRange; // FIXME: make optional + + Q_GADGET +}; + +struct ControlPointDescription +{ + QVersionNumber spec = {}; + QList actions = {}; + QList stateVariables = {}; + + [[nodiscard]] static std::optional parse(QIODevice *device); + + Q_GADGET +}; + +} // inline namespace scpd + +struct ServiceDescription +{ + QString id = {}; + QString type = {}; + QUrl scpdUrl = {}; + QUrl controlUrl = {}; + QUrl eventingUrl = {}; + + std::optional scpd = {}; + + Q_GADGET +}; + +struct DeviceManufacturer +{ + QString name = {}; + QUrl url = {}; + + Q_GADGET +}; + +struct DeviceModel +{ + QString description = {}; + QString name = {}; + QString number = {}; + QUrl url = {}; + QString universalProductCode = {}; + + Q_GADGET +}; + +struct DeviceDescription +{ + QUrl url = {}; + QUrl baseUrl = {}; + QVersionNumber specVersion = {}; + QString uniqueDeviceName = {}; + QString deviceType = {}; + QString displayName = {}; + DeviceManufacturer manufacturer = {}; + DeviceModel model = {}; + QUrl presentationUrl = {}; + QString serialNumber = {}; + + QList icons = {}; + QList services = {}; + + [[nodiscard]] static QList parse(QIODevice *device, const QUrl &deviceUrl = {}); + + Q_GADGET +}; + +class Resolver : public ssdp::Resolver +{ + Q_OBJECT + Q_PROPERTY(Behaviors behaviors READ behaviors + WRITE setBehaviors NOTIFY behaviorsChanged FINAL) + Q_PROPERTY(QNetworkAccessManager *networkAccessManager READ networkAccessManager + WRITE setNetworkAccessManager NOTIFY networkAccessManagerChanged FINAL) + +public: + enum class Behavior + { + LoadIcons = (1 << 0), + LoadServiceDescription = (1 << 1), + }; + + Q_FLAG(Behavior) + Q_DECLARE_FLAGS(Behaviors, Behavior) + + explicit Resolver(QObject *parent = nullptr); + explicit Resolver(QNetworkAccessManager *manager, QObject *parent = nullptr); + virtual ~Resolver(); + + [[nodiscard]] Behaviors behaviors() const; + [[nodiscard]] QNetworkAccessManager *networkAccessManager() const; + +public slots: + void setBehaviors(Behaviors newBehaviors); + void setBehavior(Behavior behavior, bool enable = true); + void setNetworkAccessManager(QNetworkAccessManager *newManager); + +signals: + void behaviorsChanged(Behaviors behaviors); + void networkAccessManagerChanged(QNetworkAccessManager *manager); + void deviceFound(const qnc::upnp::DeviceDescription &device); + +private: + void onServiceFound(const ssdp::ServiceDescription &service); + void onDeviceDescriptionReceived(QNetworkReply *reply); + void abortPendingReplies(); + + QPointer m_networkAccessManager = {}; + QList m_pendingReplies = {}; + Behaviors m_behaviors = {}; +}; + +} // namespace qnc::upnp + +QDebug operator<<(QDebug debug, const qnc::upnp::DeviceDescription &device); +QDebug operator<<(QDebug debug, const qnc::upnp::DeviceManufacturer &manufacturer); +QDebug operator<<(QDebug debug, const qnc::upnp::DeviceModel &model); +/* +QDebug operator<<(QDebug debug, const qnc::upnp::IconDescription &icon); +QDebug operator<<(QDebug debug, const qnc::upnp::ServiceDescription &service); + */ + +#endif // QNCUPNP_UPNPRESOLVER_H diff --git a/upnp/upnpresolverdemo.cpp b/upnp/upnpresolverdemo.cpp new file mode 100644 index 0000000..10b4af9 --- /dev/null +++ b/upnp/upnpresolverdemo.cpp @@ -0,0 +1,106 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ + +// QtNetworkCrumbs headers +#include "qncliterals.h" +#include "upnpresolver.h" + +// Qt headers +#include +#include + +namespace qnc::upnp::demo { +namespace { + +Q_LOGGING_CATEGORY(lcDemo, "upnp.demo.resolver", QtInfoMsg) + +class ResolverDemo : public QCoreApplication +{ +public: + using QCoreApplication::QCoreApplication; + + int run() + { + const auto resolver = new Resolver{this}; + resolver->setNetworkAccessManager(new QNetworkAccessManager{this}); + + connect(resolver, &Resolver::deviceFound, this, [resolver](const auto &device) { + qCInfo(lcDemo).verbosity(QDebug::MinimumVerbosity) + << "device found:" + << device.displayName + << device.deviceType; + + const auto saveUrl = [device, resolver](const QString &subDir, QUrl url) { + if (url.isEmpty()) + return; + + if (url.isRelative()) + url = device.url.resolved(url); // FIXME: also deprecated baseUrl somehow + + qInfo(lcDemo) << subDir << url; + +#if 0 // FIXME: make this a behavior + const auto request = QNetworkRequest{url}; + const auto reply = resolver->networkAccessManager()->get(request); + connect(reply, &QNetworkReply::finished, resolver, [reply, subDir] { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcDemo) << subDir << reply->url() << "failed:" << reply->errorString(); + return; + } + + qInfo() << "GOOD!" << reply->url(); + + if (auto dir = QDir::current(); !dir.mkpath(subDir)) { + qCWarning(lcDemo) << "Could not create dir" << subDir; + return; + } + + const auto &fileName = QString::fromLatin1(QUrl::toPercentEncoding(reply->url().toString())); + if (auto file = QFile{QDir{subDir}.filePath(fileName)}; !file.open(QFile::WriteOnly)) { + qCWarning(lcDemo) << "Coudl not create file:" << file.fileName() << file.errorString(); + } else { + qInfo() << "WRITE!" << file.fileName() << reply->url(); + qInfo() << "ABS:" << QFileInfo{file.fileName()}.absoluteFilePath(); + file.write(reply->readAll()); + } + }); +#endif + }; + + for (const auto &icon : device.icons) + saveUrl("upnp/icons"_L1, icon.url); + + for (const auto &service : device.services) { + saveUrl("upnp/scpd"_L1, service.scpdUrl); + saveUrl("upnp/control"_L1, service.controlUrl); + saveUrl("upnp/eventing"_L1, service.eventingUrl); + } + }); + + connect(resolver, &Resolver::serviceLost, + this, [](const auto &serviceName) { + qCInfo(lcDemo).verbosity(QDebug::MinimumVerbosity) + << "service lost:" + << serviceName; + }); + + resolver->lookupService("upnp:rootdevice"_L1); + /* + resolver->lookupService("urn:schemas-upnp-org:device:MediaRenderer:1"_L1); + resolver->lookupService("urn:schemas-sony-com:service:IRCC:1"_L1); + resolver->lookupService("urn:schemas-sony-com:service:ScalarWebAPI:1"_L1); + */ + return exec(); + } +}; + +} // namespace +} // namespace qnc::upnp::demo + +int main(int argc, char *argv[]) +{ + return qnc::upnp::demo::ResolverDemo{argc, argv}.run(); +} diff --git a/upnp/upnptreemodel.cpp b/upnp/upnptreemodel.cpp new file mode 100644 index 0000000..04fbde5 --- /dev/null +++ b/upnp/upnptreemodel.cpp @@ -0,0 +1,443 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#include "upnptreemodel.h" + +// QtNetworkCrumbs headers +#include "upnpresolver.h" + +// Qt headers +#include + +namespace qnc::upnp { + +namespace { + +Q_LOGGING_CATEGORY(lcTreeModel, "qnc.upnp.treemodel") + +template +QVariant makeVariant(const std::optional &optional) +{ + if (optional.has_value()) + return QVariant::fromValue(optional.value()); + + return {}; +} + +} // namespace + +using ssdp::ServiceLookupRequest; + +// --------------------------------------------------------------------------------------------------------------------- + +struct TreeModel::DeviceType +{ + QString id; +}; + +// --------------------------------------------------------------------------------------------------------------------- + +class TreeModel::Node +{ +public: + using Pointer = std::unique_ptr; + + explicit Node(const Node *parent); + virtual ~Node() = default; + + [[nodiscard]] virtual Qt::ItemFlags flags() const { return Qt::ItemIsEnabled | Qt::ItemIsSelectable; } + [[nodiscard]] virtual QVariant data(Role role) const; + + [[nodiscard]] virtual QVariant displayText() const { return {}; } + [[nodiscard]] virtual QVariant value() const { return {}; } + [[nodiscard]] virtual std::optional device() const; + [[nodiscard]] virtual QUrl deviceUrl() const; + [[nodiscard]] virtual TreeModel *treeModel() const; + + [[nodiscard]] const Node *parent() const { return m_parent; } + [[nodiscard]] const std::vector &children() const { return m_children; } + [[nodiscard]] int childCount() const { return static_cast(m_children.size()); } + [[nodiscard]] const Node *child(int index) const; + [[nodiscard]] int index() const; + + using Predicate = std::function; + [[nodiscard]] Node *findChild(const Predicate &predicate) const; + + template + NodeType *addChild(Args &&...args); + + template + NodeType *updateOrAddChild(Args &&...args, const Predicate &predicate); + + template + NodeType *updateOrAddChild(const ValueType &value); + + template + void updateOrAddChildren(const ContainerType &container); + +private: + const Node *const m_parent; + std::vector m_children = {}; +}; + +// --------------------------------------------------------------------------------------------------------------------- + +class TreeModel::RootNode : public Node +{ +public: + explicit RootNode(TreeModel *model) + : Node{nullptr} + , m_model{model} + {} + + TreeModel *treeModel() const override { return m_model; } + +private: + TreeModel *const m_model; +}; + +// --------------------------------------------------------------------------------------------------------------------- + +template +class TreeModel::ValueNode : public Node +{ +public: + explicit ValueNode(const ValueType &value, const Node *parent) + : Node{parent} + , m_value{value} + {} + + QVariant displayText() const override; + QVariant value() const override { return QVariant::fromValue(m_value); } + std::optional device() const override { return Node::device(); } + + [[nodiscard]] operator const ValueType &() const { return m_value; } + void update(const ValueType &value); + +private: + void updateChildren() {} + + ValueType m_value; +}; + +// --------------------------------------------------------------------------------------------------------------------- + +TreeModel::Node::Node(const Node *parent) + : m_parent{parent} +{} + +QVariant TreeModel::Node::data(Role role) const +{ + switch (role) { + case Role::Display: + return displayText(); + case Role::Value: + return value(); + case Role::Device: + return makeVariant(device()); + case Role::DeviceUrl: + return deviceUrl(); + } + + return {}; +} + +std::optional TreeModel::Node::device() const +{ + if (m_parent) + return m_parent->device(); + + return {}; +} + +QUrl TreeModel::Node::deviceUrl() const +{ + if (const auto optionalDevice = device()) + return optionalDevice->url; + + return {}; +} + +TreeModel *TreeModel::Node::treeModel() const +{ + if (m_parent) + return m_parent->treeModel(); + + return nullptr; +} + +const TreeModel::Node *TreeModel::Node::child(int index) const +{ + if (index >= 0 && index < childCount()) + return m_children.at(static_cast(index)).get(); + + return nullptr; +} + +int TreeModel::Node::index() const +{ + if (m_parent) { + const auto first = m_parent->m_children.cbegin(); + const auto last = m_parent->m_children.end(); + const auto it = std::find_if(first, last, [this](const auto &ptr) { + return ptr.get() == this; + }); + + if (Q_UNLIKELY(it == last)) + return -1; + + return static_cast(it - first); + } + + return 0; +} + +template +NodeType *TreeModel::Node::addChild(Args &&...args) +{ + const auto model = treeModel(); + Q_ASSERT(model != nullptr); + + const auto newRow = static_cast(m_children.size()); + model->beginInsertRows(model->indexForNode(this), newRow, newRow); + + qInfo() << "BEGIN INSERT ROWS" + << QMetaType::fromType().name() + << QMetaType::fromType().name() + << model->indexForNode(this).isValid() + << model->indexForNode(this) << newRow; + + auto node = std::make_unique(std::forward(args)..., this); + const auto nodePointer = node.get(); + m_children.emplace_back(std::move(node)); + + model->endInsertRows(); + return nodePointer; +} + +template +NodeType *TreeModel::Node::updateOrAddChild(Args &&...args, const Predicate &predicate) +{ + if (const auto child = dynamic_cast(findChild(predicate))) { + child->update(std::forward(args)...); + return child; + } else { + return addChild(std::forward(args)...); + } +} + +template +NodeType *TreeModel::Node::updateOrAddChild(const ValueType &value) +{ + const auto predicate = [expectedId = value.*IdField](const Pointer &p) { + if (const auto node = dynamic_cast(p.get())) { + const auto &actualId = static_cast(*node).*IdField; + return expectedId == actualId; + } else { + return false; + } + }; + + return updateOrAddChild(value, predicate); +} + +template +void TreeModel::Node::updateOrAddChildren(const ContainerType &container) +{ + for (const auto &value : container) + updateOrAddChild(value); +} + +TreeModel::Node *TreeModel::Node::findChild(const std::function &predicate) const +{ + if (const auto it = std::find_if(m_children.cbegin(), m_children.cend(), predicate); it != m_children.cend()) + return it->get(); + + return nullptr; +} + +// --------------------------------------------------------------------------------------------------------------------- + +template <> +QVariant TreeModel::ValueNode::displayText() const +{ + return m_value.id; +} + +// --------------------------------------------------------------------------------------------------------------------- + +template <> +QVariant TreeModel::ValueNode::displayText() const +{ + return m_value.uniqueDeviceName; +} + +template <> +std::optional TreeModel::ValueNode::device() const +{ + return m_value; +} + +template <> +void TreeModel::ValueNode::updateChildren() +{ + updateOrAddChildren(m_value.services); +} + +// --------------------------------------------------------------------------------------------------------------------- + +template <> +QVariant TreeModel::ValueNode::displayText() const +{ + return m_value.type; +} + +template <> +void TreeModel::ValueNode::updateChildren() +{ + qInfo() << m_value.id << m_value.scpd.has_value(); + if (const auto &scpd = m_value.scpd) { + qInfo() << m_value.id << scpd->actions.count() << scpd->stateVariables.count(); + updateOrAddChildren(scpd->actions); + updateOrAddChildren(scpd->stateVariables); + } +} + +// --------------------------------------------------------------------------------------------------------------------- + +template <> +QVariant TreeModel::ValueNode::displayText() const +{ + return m_value.name; +} + +// --------------------------------------------------------------------------------------------------------------------- + +template <> +QVariant TreeModel::ValueNode::displayText() const +{ + return m_value.name; +} + +// --------------------------------------------------------------------------------------------------------------------- + +template +void TreeModel::ValueNode::update(const ValueType &value) +{ + const auto model = treeModel(); + Q_ASSERT(model != nullptr); + + m_value = value; + + const auto &modelIndex = model->indexForNode(this); + emit model->dataChanged(modelIndex, modelIndex); + + updateChildren(); +} + +// --------------------------------------------------------------------------------------------------------------------- + +TreeModel::TreeModel(QObject *parent) + : QAbstractItemModel{parent} + , m_root{new RootNode{this}} + , m_resolver{new Resolver{this}} +{ + connect(m_resolver, &Resolver::deviceFound, this, [this](const DeviceDescription &device) { + const auto deviceType = DeviceType{device.deviceType}; + const auto node = m_root->updateOrAddChild(deviceType); + node->updateOrAddChild(device); + }); + + connect(m_resolver, &Resolver::serviceLost, this, [](const auto &serviceName) { + qCInfo(lcTreeModel) << "service lost:" << serviceName; + }); + + m_resolver->setBehavior(Resolver::Behavior::LoadServiceDescription); +} + +void TreeModel::addQuery(const QString &serviceName) +{ + m_resolver->lookupService(serviceName); +} + +void TreeModel::setNetworkAccessManager(QNetworkAccessManager *newManager) +{ + m_resolver->setNetworkAccessManager(newManager); +} + +QNetworkAccessManager *TreeModel::networkAccessManager() const +{ + return m_resolver->networkAccessManager(); +} + +QModelIndex TreeModel::index(int row, int column, const QModelIndex &parent) const +{ + if (const auto node = nodeForIndex(parent)) + if (const auto child = node->child(row)) + if (column >= 0 && column <= 1) + return createIndex(row, column, child); + + return {}; +} + +QModelIndex TreeModel::parent(const QModelIndex &child) const +{ + if (const auto childNode = nodeForIndex(child)) + return indexForNode(childNode->parent()); + + return {}; +} + +int TreeModel::rowCount(const QModelIndex &parent) const +{ + if (const auto node = nodeForIndex(parent)) + return node->childCount(); + + return 0; +} + +int TreeModel::columnCount(const QModelIndex &parent) const +{ + if (const auto node = nodeForIndex(parent)) + return 1; + + return 0; +} + +QVariant TreeModel::data(const QModelIndex &index, int role) const +{ + if (const auto node = nodeForIndex(index)) + return node->data(static_cast(role)); + + return {}; +} + +Qt::ItemFlags TreeModel::flags(const QModelIndex &index) const +{ + if (const auto node = nodeForIndex(index)) + return node->flags(); + + return {}; +} + +const TreeModel::Node *TreeModel::nodeForIndex(const QModelIndex &index) const +{ + if (!index.isValid()) + return m_root; + else if (checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::DoNotUseParent)) + return static_cast(index.constInternalPointer()); + else + return nullptr; +} + +QModelIndex TreeModel::indexForNode(const Node *node) const +{ + if (Q_UNLIKELY(node == nullptr)) + return {}; + if (Q_UNLIKELY(node == m_root)) + return {}; + + return createIndex(node->index(), 0, node); +} + +} // namespace qnc::upnp diff --git a/upnp/upnptreemodel.h b/upnp/upnptreemodel.h new file mode 100644 index 0000000..c37a526 --- /dev/null +++ b/upnp/upnptreemodel.h @@ -0,0 +1,77 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#ifndef QNCUPNP_UPNPTREEMODEL_H +#define QNCUPNP_UPNPTREEMODEL_H + +// Qt headers +#include +#include + +class QNetworkAccessManager; + +namespace qnc::upnp { + +inline namespace scpd { +struct ActionDescription; +struct StateVariableDescription; +} // inline namespace scpd + +struct DeviceDescription; +struct ServiceDescription; + +class Resolver; + +class TreeModel : public QAbstractItemModel +{ + Q_OBJECT + +public: + enum class Role { + Display = Qt::DisplayRole, + Value = Qt::UserRole + 1024, + Device, + DeviceUrl, + }; + + Q_ENUM(Role) + + explicit TreeModel(QObject *parent = nullptr); + + void addQuery(const QString &serviceName); + + void setNetworkAccessManager(QNetworkAccessManager *newManager); + QNetworkAccessManager *networkAccessManager() const; + +public: // QAbstractItemModel interface + QModelIndex index(int row, int column, const QModelIndex &parent) const override; + QModelIndex parent(const QModelIndex &child) const override; + int rowCount(const QModelIndex &parent) const override; + int columnCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + +private: + struct DeviceType; + + class Node; + class RootNode; + + template class ValueNode; + + using DeviceTypeNode = ValueNode; + using DeviceNode = ValueNode; + using ServiceNode = ValueNode; + using ActionNode = ValueNode; + using VariableNode = ValueNode; + + const Node *nodeForIndex(const QModelIndex &index) const; + QModelIndex indexForNode(const Node *node) const; + + RootNode *const m_root; + Resolver *const m_resolver; +}; + +} // namespace qnc::upnp + +#endif // QNCUPNP_UPNPTREEMODEL_H diff --git a/upnp/upnpviewer.cpp b/upnp/upnpviewer.cpp new file mode 100644 index 0000000..63aa3c5 --- /dev/null +++ b/upnp/upnpviewer.cpp @@ -0,0 +1,529 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ + +// QtNetworkCrumbs headers +#include "qncliterals.h" +#include "upnpresolver.h" +#include "upnptreemodel.h" + +// Qt headers +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qnc::upnp::demo { +namespace { + +using ssdp::ServiceLookupRequest; + +template +[[nodiscard]] QVariant modelData(const QModelIndex &index) +{ + return index.data(static_cast(Role)); +} + +template +[[nodiscard]] QVariant modelData(const QModelIndex &index) +{ + return index.siblingAtColumn(static_cast(Column)).data(static_cast(Role)); +} + +struct Path +{ + static constexpr int BitsPerLength = 2; + static constexpr int BitsPerRow = 10; + static constexpr int MaximumLength = (1 << BitsPerLength) - 1; + static constexpr int MaximumRow = (1 << BitsPerRow) - 1; + static constexpr quintptr LengthMask = static_cast(MaximumLength); + static constexpr quintptr RowMask = static_cast(MaximumRow); + static constexpr quintptr IndexMask = ~LengthMask; + + quintptr value; + + Q_IMPLICIT constexpr Path(quintptr path = 0) noexcept : value{path} {} + Q_IMPLICIT constexpr operator quintptr() const noexcept { return value; } + + explicit constexpr Path(Path parent, int row) noexcept : value{make(parent, row).value} {} + explicit constexpr Path(const QModelIndex &index, int row) noexcept : Path{index.internalId(), row} {} + + [[nodiscard]] constexpr static Path make(Path parent, int row) noexcept + { + if (row < 0 || row > MaximumRow) + return 0; + if (parent.length() >= MaximumLength) + return 0; + + const auto shift = parent.length() * BitsPerRow + BitsPerLength; + + return (parent.value & IndexMask) + | static_cast(row << shift) + | static_cast(parent.length() + 1); + } + + [[nodiscard]] constexpr int length() const noexcept + { + return static_cast(value & LengthMask); + } + + [[nodiscard]] constexpr int at(int index) const noexcept + { + if (Q_UNLIKELY(index < 0 || index >= length())) + return -1; + + const auto shift = index * BitsPerRow + BitsPerLength; + return static_cast((value >> shift) & RowMask); + } + + [[nodiscard]] constexpr int last() const noexcept + { + return at(length() - 1); + } + + [[nodiscard]] constexpr Path parent() const noexcept + { + if (Q_UNLIKELY(length() < 1)) + return {}; + + const auto shift = (length() - 1) * BitsPerRow + BitsPerLength; + const auto prefix = (value & ~(RowMask << shift)) & IndexMask; + return prefix | static_cast(length() - 1); + } + + [[nodiscard]] friend constexpr bool operator==(const Path &l, const Path &r) noexcept { return l.value == r.value; } +}; + +static_assert(Path{} .length() == 0); +static_assert(Path{0x00}.length() == 0); +static_assert(Path{0x01}.length() == 1); +static_assert(Path{0x11}.length() == 1); +static_assert(Path{0x13}.length() == 3); + +static_assert(Path{} .at(0) == -1); +static_assert(Path{0x0011}.at(0) == 4); +static_assert(Path{0x0012}.at(1) == 0); + +static_assert(Path{0x841602f}.length() == 3); +static_assert(Path{0x841602f}.at(0) == 11); +static_assert(Path{0x841602f}.at(1) == 22); +static_assert(Path{0x841602f}.at(2) == 33); +static_assert(Path{0x841602f}.at(3) == -1); + +static_assert(Path{0x841602f}.parent().length() == 2); +static_assert(Path{0x841602f}.parent().at(0) == 11); +static_assert(Path{0x841602f}.parent().at(1) == 22); +static_assert(Path{0x841602f}.parent().at(2) == -1); + +static_assert(Path{0x1602e}.length() == 2); +static_assert(Path{0x1602e}.at(0) == 11); +static_assert(Path{0x1602e}.at(1) == 22); +static_assert(Path{0x1602e}.at(2) == -1); + +static_assert(Path{0x1602e, 33}.length() == 3); +static_assert(Path{0x1602e, 33}.at(0) == 11); +static_assert(Path{0x1602e, 33}.at(1) == 22); +static_assert(Path{0x1602e, 33}.at(2) == 33); +static_assert(Path{0x1602e, 33}.at(3) == -1); + +static_assert(Path{0x841602f}.parent() == Path{0x1602e}); +static_assert(Path{0x841602f} == Path{0x1602e, 33}); + +class DetailModel : public QAbstractItemModel +{ + Q_OBJECT + +public: + enum class Column + { + Name, + Value, + }; + + enum class Role + { + Display = Qt::DisplayRole, + Value = Qt::UserRole + 1024, + }; + + struct Row + { + QString name; + QVariant value; + + [[nodiscard]] QVariant data(Column column, Role role) const; + [[nodiscard]] QVariant displayData(Column column) const; + [[nodiscard]] QVariant valueData(Column column) const; + }; + + using RowList = QList; + + using QAbstractItemModel::QAbstractItemModel; + + void reset(const RowList &rows); + +public: // QAbstractItemModel interface + QModelIndex index(int row, int column, const QModelIndex &parent) const override; + QModelIndex parent(const QModelIndex &child) const override; + int rowCount(const QModelIndex &parent) const override; + int columnCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + +private: + QVariant value(const QModelIndex &index) const; + + RowList m_rows; +}; + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + +private: + void onSelectionChanged(const QItemSelection &selected); + void onDetailActivated(const QModelIndex &index); + + template + static DetailModel::RowList details(const T &value); + + template + static DetailModel::RowList details(const T &value, const QModelIndex &index); + + TreeModel *const m_treeModel = new TreeModel{this}; + DetailModel *const m_detailModel = new DetailModel{this}; + + QSplitter *const m_splitter = new QSplitter{this}; + QTreeView *const m_treeView = new QTreeView{m_splitter}; + QTreeView *const m_detailView = new QTreeView{m_splitter}; +}; + +class Viewer : public QApplication +{ + Q_OBJECT + +public: + using QApplication::QApplication; + + int run(); +}; + +void DetailModel::reset(const RowList &rows) +{ + beginResetModel(); + m_rows = rows; + endResetModel(); +} + +QModelIndex DetailModel::index(int row, int column, const QModelIndex &parent) const +{ + if (parent.isValid()) + return createIndex(row, column, Path{parent, parent.row()}); + + return createIndex(row, column, Path{}); +} + +QModelIndex DetailModel::parent(const QModelIndex &child) const +{ + if (const auto path = Path{child.internalId()}; path.length() > 0) + return createIndex(path.last(), 0, path.parent()); + + return {}; +} + +int DetailModel::rowCount(const QModelIndex &parent) const +{ + if (!parent.isValid()) + return static_cast(m_rows.size()); + else if (const auto &data = value(parent); data.canConvert()) + return static_cast(qvariant_cast(data).size()); + else + return 0; +} + +int DetailModel::columnCount(const QModelIndex &) const +{ + return 2; +} + +QVariant DetailModel::Row::data(Column column, Role role) const +{ + switch (static_cast(role)) { + case Role::Display: + return displayData(column); + case Role::Value: + return valueData(column); + } + + return {}; +} + +QVariant DetailModel::Row::displayData(Column column) const +{ + switch (column) { + case Column::Name: + return name; + case Column::Value: + return value.toString(); + } + + return {}; +} + +QVariant DetailModel::Row::valueData(Column column) const +{ + switch (column) { + case Column::Name: + return name; + case Column::Value: + return value; + } + + return {}; +} + +QVariant DetailModel::data(const QModelIndex &index, int role) const +{ + if (checkIndex(index, CheckIndexOption::IndexIsValid)) { + const auto path = Path{index.internalId()}; + auto rowList = m_rows; + + for (auto i = 0; i < path.length(); ++i) { + const auto &row = rowList.at(path.at(i)); + Q_ASSERT(row.value.canConvert()); + rowList = qvariant_cast(row.value); + } + + const auto &row = rowList.at(index.row()); + const auto column = static_cast(index.column()); + return row.data(column, static_cast(role)); + } + + return {}; +} + +QVariant DetailModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { + switch (static_cast(section)) { + case Column::Name: + return tr("Property"); + case Column::Value: + return tr("Value"); + } + } + + return {}; +} + +QVariant DetailModel::value(const QModelIndex &index) const +{ + return modelData(index); +} + +void applySizePolicy(QWidget *widget, std::function setter) +{ + auto policy = widget->sizePolicy(); + setter(policy); + widget->setSizePolicy(policy); +} + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow{parent} +{ + m_treeModel->setNetworkAccessManager(new QNetworkAccessManager{this}); + m_treeModel->addQuery("ssdp:all"_L1); + // m_treeModel->addQuery("upnp:rootdevice"_L1); + // m_treeModel->addQuery("urn:schemas-upnp-org:device:MediaRenderer:1"_L1); + // m_treeModel->addQuery("urn:schemas-sony-com:service:IRCC:1"_L1); + // m_treeModel->addQuery("urn:schemas-sony-com:service:ScalarWebAPI:1"_L1); + + setCentralWidget(m_splitter); + + const auto sortedTreeModel = new QSortFilterProxyModel{this}; + sortedTreeModel->setSourceModel(m_treeModel); + + m_treeView->setHeaderHidden(true); + m_treeView->setModel(sortedTreeModel); + + m_detailView->header()->setStretchLastSection(true); + m_detailView->setSelectionBehavior(QTreeView::SelectRows); + m_detailView->setAlternatingRowColors(true); + m_detailView->setModel(m_detailModel); + + applySizePolicy(m_treeView, [](auto &policy) { + policy.setHorizontalStretch(1); + }); + + applySizePolicy(m_detailView, [](auto &policy) { + policy.setHorizontalStretch(2); + }); + + connect(m_detailView, &QTreeView::activated, + this, &MainWindow::onDetailActivated); + connect(m_treeView->selectionModel(), &QItemSelectionModel::selectionChanged, + this, &MainWindow::onSelectionChanged); +} + +template <> +DetailModel::RowList MainWindow::details(const DeviceManufacturer &manufacturer) +{ + return { + {tr("name"), manufacturer.name}, + {tr("url"), manufacturer.url}, + }; +} + +template <> +DetailModel::RowList MainWindow::details(const DeviceModel &model) +{ + return { + {tr("universalProductCode"), model.universalProductCode}, + {tr("description"), model.description}, + {tr("name"), model.name}, + {tr("number"), model.number}, + {tr("url"), model.url}, + }; +} + +template <> +DetailModel::RowList MainWindow::details(const DeviceDescription &device) +{ + return { + {tr("url"), device.url}, + {tr("specVersion"), QVariant::fromValue(device.specVersion)}, + {tr("uniqueDeviceName"), device.uniqueDeviceName}, + {tr("deviceType"), device.deviceType}, + {tr("displayName"), device.displayName}, + {tr("manufacturer"), QVariant::fromValue(details(device.manufacturer))}, + {tr("model"), QVariant::fromValue(details(device.model))}, + {tr("presentationUrl"), device.presentationUrl}, + {tr("serialNumber"), device.serialNumber}, + }; +} + +template <> +DetailModel::RowList MainWindow::details(const ServiceDescription &service, const QModelIndex &index) +{ + const auto &baseUrl = qvariant_cast(modelData(index)); + + return { + {tr("id"), service.id}, + {tr("type"), service.type}, + {tr("scpdUrl"), baseUrl.resolved(service.scpdUrl)}, + {tr("controlUrl"), baseUrl.resolved(service.controlUrl)}, + {tr("eventingUrl"), baseUrl.resolved(service.eventingUrl)}, + }; +} + +template <> +DetailModel::RowList MainWindow::details(const ArgumentDescription &argument) +{ + return { + {tr("direction"), QVariant::fromValue(argument.direction)}, + {tr("flags"), QVariant::fromValue(argument.flags)}, + {tr("stateVariable"), argument.stateVariable}, + }; +} + +template <> +DetailModel::RowList MainWindow::details(const QList &argumentList) +{ + auto rows = DetailModel::RowList{}; + rows.reserve(argumentList.size()); + + for (const auto &argument : argumentList) + rows.emplaceBack(DetailModel::Row{argument.name, QVariant::fromValue(details(argument))}); + + return rows; +} + +template <> +DetailModel::RowList MainWindow::details(const ActionDescription &action) +{ + return { + {tr("name"), action.name}, + {tr("flags"), QVariant::fromValue(action.flags)}, + {tr("arguments"), QVariant::fromValue(details(action.arguments))}, + }; +} + +template <> +DetailModel::RowList MainWindow::details(const ValueRangeDescription &valueRange) +{ + return { + {tr("minimum"), valueRange.minimum}, + {tr("maximum"), valueRange.maximum}, + {tr("step"), valueRange.step}, + }; +} + +template <> +DetailModel::RowList MainWindow::details(const StateVariableDescription &variable) +{ + return { + {tr("name"), variable.name}, + {tr("flags"), QVariant::fromValue(variable.flags)}, + {tr("dataType"), QVariant::fromStdVariant(variable.dataType)}, + {tr("defaultValue"), variable.defaultValue}, + {tr("allowedValues"), variable.allowedValues}, + {tr("valueRange"), QVariant::fromValue(details(variable.valueRange))}, + }; +} + +void MainWindow::onSelectionChanged(const QItemSelection &selected) +{ + const auto &indexes = selected.indexes(); + + if (indexes.isEmpty()) + return; + + const auto &index = indexes.constFirst(); + const auto &data = modelData(index); + + if (data.canConvert()) { + m_detailModel->reset(details(qvariant_cast(data))); + } else if (data.canConvert()) { + m_detailModel->reset(details(qvariant_cast(data), index)); + } else if (data.canConvert()) { + m_detailModel->reset(details(qvariant_cast(data))); + } else if (data.canConvert()) { + m_detailModel->reset(details(qvariant_cast(data))); + } else { + m_detailModel->reset({}); + } + + m_detailView->expandAll(); + m_detailView->header()->resizeSections(QHeaderView::ResizeToContents); +} + +void MainWindow::onDetailActivated(const QModelIndex &index) +{ + const auto &data = modelData(index); + if (const auto &url = qvariant_cast(data); !url.isEmpty()) + QDesktopServices::openUrl(url); +} + +int Viewer::run() +{ + auto window = MainWindow{}; + window.show(); + return exec(); +} + +} // namespace +} // namespace qnc::upnp::demo + +int main(int argc, char *argv[]) +{ + return qnc::upnp::demo::Viewer{argc, argv}.run(); +} + +#include "upnpviewer.moc"