LCOV - code coverage report
Current view: top level - src/cli - abstractcommand.cpp (source / functions) Coverage Total Hit
Project: Dokit Lines: 98.0 % 148 145
Version: Functions: 56.4 % 94 53

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2022-2025 Paul Colby <git@colby.id.au>
       2              : // SPDX-License-Identifier: LGPL-3.0-or-later
       3              : 
       4              : #include "abstractcommand.h"
       5              : #include "../stringliterals_p.h"
       6              : 
       7              : #include <qtpokit/pokitdevice.h>
       8              : #include <qtpokit/pokitdiscoveryagent.h>
       9              : 
      10              : #include <QLocale>
      11              : #include <QTimer>
      12              : 
      13              : #include <cmath>
      14              : #include <ratio>
      15              : 
      16              : DOKIT_USE_STRINGLITERALS
      17              : 
      18              : /*!
      19              :  * \class AbstractCommand
      20              :  *
      21              :  * The AbstractCommand class provides a consistent base for the classes that implement CLI commands.
      22              :  */
      23              : 
      24              : /*!
      25              :  * Constructs a new command with \a parent.
      26              :  */
      27        49721 : AbstractCommand::AbstractCommand(QObject * const parent) : QObject(parent),
      28        49721 :     discoveryAgent(new PokitDiscoveryAgent(this))
      29        22524 : {
      30        53432 :     connect(discoveryAgent, &PokitDiscoveryAgent::pokitDeviceDiscovered,
      31        34474 :             this, &AbstractCommand::deviceDiscovered);
      32        53432 :     connect(discoveryAgent, &PokitDiscoveryAgent::finished,
      33        34474 :             this, &AbstractCommand::deviceDiscoveryFinished);
      34        53432 :     connect(discoveryAgent,
      35         8955 :         #if (QT_VERSION < QT_VERSION_CHECK(6, 2, 0))
      36         8955 :         QOverload<PokitDiscoveryAgent::Error>::of(&PokitDiscoveryAgent::error),
      37              :         #else
      38        13569 :         &PokitDiscoveryAgent::errorOccurred,
      39        13569 :         #endif
      40        22662 :         this, [](const PokitDiscoveryAgent::Error &error) {
      41          636 :         qCWarning(lc).noquote() << tr("Bluetooth discovery error:") << error;
      42          147 :         QTimer::singleShot(0, QCoreApplication::instance(), [](){
      43            0 :             QCoreApplication::exit(EXIT_FAILURE);
      44            0 :         });
      45          267 :     });
      46        53432 : }
      47              : 
      48              : /*!
      49              :  * Returns a list of CLI option names required by this command. The main console appication may
      50              :  * use this list to output an error (and exit) if any of the returned names are not found in the
      51              :  * parsed CLI options.
      52              :  *
      53              :  * The (already parsed) \a parser may be used adjust the returned required options depending on the
      54              :  * value of other options. For example, the `logger` command only requires the `--mode` option if
      55              :  * the `--command` option is `start`.
      56              :  *
      57              :  * This base implementation simply returns an empty list. Derived classes should override this
      58              :  * function to include any required options.
      59              :  */
      60        20280 : QStringList AbstractCommand::requiredOptions(const QCommandLineParser &parser) const
      61        19776 : {
      62        19776 :     Q_UNUSED(parser)
      63        40056 :     return QStringList();
      64        19776 : }
      65              : 
      66              : /*!
      67              :  * Returns a list of CLI option names supported by this command. The main console appication may
      68              :  * use this list to output a warning for any parsed CLI options not included in the returned list.
      69              :  *
      70              :  * The (already parsed) \a parser may be used adjust the returned supported options depending on the
      71              :  * value of other options. For example, the `logger` command only supported the `--timestamp` option
      72              :  * if the `--command` option is `start`.
      73              :  *
      74              :  * This base implementation simply returns requiredOptions(). Derived classes should override this
      75              :  * function to include optional options, such as:
      76              :  *
      77              :  * ```
      78              :  * QStringList Derived::supportedOptions(const QCommandLineParser &parser) const
      79              :  * {
      80              :  *     const QStringList list = AbstractCommand::supportedOptions(parser) + QStringList{ ... };
      81              :  *     list.sort();
      82              :  *     list.removeDuplicates(); // Optional, recommended.
      83              :  *     return list;
      84              :  * }
      85              :  * ```
      86              :  */
      87         9880 : QStringList AbstractCommand::supportedOptions(const QCommandLineParser &parser) const
      88         9600 : {
      89        83776 :     return requiredOptions(parser) + QStringList{
      90         9600 :         u"debug"_s,
      91         9600 :         u"device"_s, u"d"_s,
      92         9600 :         u"output"_s,
      93         9600 :         u"timeout"_s,
      94        74352 :     };
      95         9600 : }
      96              : 
      97              : /*!
      98              :  * Returns an RFC 4180 compliant version of \a field. That is, if \a field contains any of the
      99              :  * the below four characters, than any double quotes are escaped (by addition double-quotes), and
     100              :  * the string itself surrounded in double-quotes. Otherwise, \a field is returned verbatim.
     101              :  *
     102              :  * Some examples:
     103              :  * ```
     104              :  * QCOMPARE(escapeCsvField("abc"), "abc");           // Returned unchanged.
     105              :  * QCOMPARE(escapeCsvField("a,c"), R"("a,c")");      // Wrapped in double-quotes.
     106              :  * QCOMPARE(escapeCsvField(R"(a"c)"), R("("a""c")"); // Existing double-quotes doubled, then wrapped.
     107              :  * ```
     108              :  */
     109         7580 : QString AbstractCommand::escapeCsvField(const QString &field)
     110         4284 : {
     111        30494 :     if (field.contains(','_L1) || field.contains('\r'_L1) || field.contains('"'_L1) || field.contains('\n'_L1)) {
     112          294 :         return uR"("%1")"_s.arg(QString(field).replace('"'_L1, uR"("")"_s));
     113         4140 :     } else return field;
     114         4284 : }
     115              : 
     116              : /*!
     117              :  * \internal
     118              :  * A (run-time) class approximately equivalent to the compile-time std::ratio template.
     119              :  */
     120              : struct Ratio {
     121              :     std::intmax_t num { 0 }; ///< Numerator.
     122              :     std::intmax_t den { 0 }; ///< Denominator.
     123              :     //! Returns \a true if both #num and #den are non-zero.
     124        11561 :     bool isValid() const { return (num != 0) && (den != 0); }
     125              : };
     126              : 
     127              : /*!
     128              :  * \internal
     129              :  * Returns a (run-time) Ratio representation of (compile-time) ratio \a R.
     130              :  */
     131        14760 : template<typename R> constexpr Ratio makeRatio() { return Ratio{ R::num, R::den }; }
     132              : 
     133              : /*!
     134              :  * Returns \a value as an integer multiple of the ratio \a R. The string \a value
     135              :  * may end with the optional \a unit, such as `V` or `s`, which may also be preceded with a SI unit
     136              :  * prefix such as `m` for `milli`. If \a value contains no SI unit prefix, then the result will be
     137              :  * multiplied by 1,000 enough times to be greater than \a sensibleMinimum. This allows for
     138              :  * convenient use like:
     139              :  *
     140              :  * ```
     141              :  * const quint32 timeout = parseNumber<std::milli>(parser.value("window"), 's', 500'000);
     142              :  * ```
     143              :  *
     144              :  * So that an unqualified period like "300" will be assumed to be 300 milliseconds, and not 300
     145              :  * microseconds, while a period like "1000" will be assume to be 1 second.
     146              :  *
     147              :  * If conversion fails for any reason, 0 is returned.
     148              :  */
     149              : template<typename R>
     150         8385 : quint32 AbstractCommand::parseNumber(const QString &value, const QString &unit, const quint32 sensibleMinimum)
     151         7272 : {
     152        15807 :     static const QMap<QChar, Ratio> unitPrefixScaleMap {
     153         7272 :         { 'E'_L1,        makeRatio<std::exa>()   },
     154         7272 :         { 'P'_L1,        makeRatio<std::peta>()  },
     155         7272 :         { 'T'_L1,        makeRatio<std::tera>()  },
     156         7272 :         { 'G'_L1,        makeRatio<std::giga>()  },
     157         7272 :         { 'M'_L1,        makeRatio<std::mega>()  },
     158         7272 :         { 'K'_L1,        makeRatio<std::kilo>()  }, // Not official SI unit prefix, but commonly used.
     159         7272 :         { 'k'_L1,        makeRatio<std::kilo>()  },
     160         7272 :         { 'h'_L1,        makeRatio<std::hecto>() },
     161         7272 :         { 'd'_L1,        makeRatio<std::deci>()  },
     162         7272 :         { 'c'_L1,        makeRatio<std::centi>() },
     163         7272 :         { 'm'_L1,        makeRatio<std::milli>() },
     164         7272 :         { 'u'_L1,        makeRatio<std::micro>() }, // Not official SI unit prefix, but commonly used.
     165         7272 :         { QChar(0x00B5), makeRatio<std::micro>() }, // Unicode micro symbol (μ).
     166         7272 :         { 'n'_L1,        makeRatio<std::nano>()  },
     167         7272 :         { 'p'_L1,        makeRatio<std::pico>()  },
     168         7272 :         { 'f'_L1,        makeRatio<std::femto>() },
     169         7272 :         { 'a'_L1,        makeRatio<std::atto>()  },
     170         7272 :     };
     171              : 
     172              :     // Remove the optional (whole) unit suffix.
     173         7272 :     Ratio ratio;
     174         7272 :     QString number = value.trimmed();
     175        15657 :     if ((!unit.isEmpty()) && (number.endsWith(unit, Qt::CaseInsensitive))) {
     176         7465 :         number.chop(unit.length());
     177         3240 :         ratio = makeRatio<std::ratio<1>>();
     178         3240 :     }
     179              : 
     180              :     // Parse, and remove, the optional SI unit prefix.
     181        15657 :     if (!number.isEmpty()) {
     182         6814 :         #if (QT_VERSION >= QT_VERSION_CHECK(5, 10, 0))
     183        12062 :         const QChar siPrefix = number.back(); // QString::back() introduced in Qt 5.10.
     184              :         #else
     185          642 :         const QChar siPrefix = number.at(number.size() - 1);
     186          386 :         #endif
     187         7200 :         const auto iter = unitPrefixScaleMap.constFind(siPrefix);
     188        15520 :         if (iter != unitPrefixScaleMap.constEnd()) {
     189         3072 :             Q_ASSERT(iter->isValid());
     190         7232 :             ratio = *iter;
     191         7232 :             number.chop(1);
     192         3072 :         }
     193         7200 :     }
     194              : 
     195         7776 :     #define DOKIT_RESULT(var) (var * ratio.num * R::den / ratio.den / R::num)
     196              :     // Parse the number as an (unsigned) integer.
     197        15657 :     QLocale locale; bool ok;
     198        10368 :     qulonglong integer = locale.toULongLong(number, &ok);
     199        15657 :     if (ok) {
     200        10191 :         if (integer == 0) {
     201           72 :             return 0;
     202           72 :         }
     203         4464 :         if (!ratio.isValid()) {
     204         4226 :             for (ratio = makeRatio<R>(); DOKIT_RESULT(integer) < sensibleMinimum; ratio.num *= 1000);
     205         1368 :         }
     206        10054 :         return (integer == 0) ? 0u : (quint32)DOKIT_RESULT(integer);
     207         4536 :     }
     208              : 
     209              :     // Parse the number as a (double) floating point number, and check that it is positive.
     210         5466 :     if (const double dbl = locale.toDouble(number, &ok); (ok) && (dbl > 0.0)) {
     211          792 :         if (!ratio.isValid()) {
     212          959 :             for (ratio = makeRatio<R>(); DOKIT_RESULT(dbl) < sensibleMinimum; ratio.num *= 1000);
     213          360 :         }
     214         1507 :         return static_cast<quint32>(std::llround(DOKIT_RESULT(dbl)));
     215          792 :     }
     216         1944 :     #undef DOKIT_RESULT
     217         1944 :     return 0; // Failed to parse as either integer, or float.
     218        11121 : }
     219              : 
     220              : #define DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(type) template \
     221              : quint32 AbstractCommand::parseNumber<type>(const QString &value, const QString &unit, const quint32 sensibleMinimum)
     222              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::exa);
     223              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::peta);
     224              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::tera);
     225              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::giga);
     226              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::mega);
     227              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::kilo);
     228              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::hecto);
     229              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::deca);
     230              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::ratio<1>);
     231              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::deci);
     232              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::centi);
     233              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::milli);
     234              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::micro);
     235              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::nano);
     236              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::pico);
     237              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::femto);
     238              : DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::atto);
     239              : #undef DOKIT_INSTANTIATE_TEMPLATE_FUNCTION
     240              : 
     241              : /*!
     242              :  * Processes the relevant options from the command line \a parser.
     243              :  *
     244              :  * On success, returns an empty QStringList, otherwise returns a list of CLI errors that the caller
     245              :  * should report appropriately before exiting.
     246              :  *
     247              :  * This base implementations performs some common checks, such as ensuring that required options are
     248              :  * present. Derived classes should override this function to perform further processing, typically
     249              :  * invoking this base implementation as a first step, such as:
     250              :  *
     251              :  * ```
     252              :  * QStringList CustomCommand::processOptions(const QCommandLineParser &parser)
     253              :  * {
     254              :  *     QStringList errors = AbstractCommand::processOptions(parser);
     255              :  *     if (!errors.isEmpty()) {
     256              :  *         return errors;
     257              :  *     }
     258              :  *
     259              :  *     // Do further procession of options.
     260              :  *
     261              :  *     return errors;
     262              :  * }
     263              :  * ```
     264              :  */
     265         8515 : QStringList AbstractCommand::processOptions(const QCommandLineParser &parser)
     266         8088 : {
     267              :     // Report any supplied options that are not supported by this command.
     268        16603 :     const QStringList suppliedOptionNames = parser.optionNames();
     269        16603 :     const QStringList supportedOptionNames = supportedOptions(parser);
     270        38747 :     for (const QString &option: suppliedOptionNames) {
     271        30232 :         if (!supportedOptionNames.contains(option)) {
     272          272 :             qCInfo(lc).noquote() << tr("Ignoring option: %1").arg(option);
     273           72 :         }
     274        14112 :     }
     275        13459 :     QStringList errors;
     276              : 
     277              :     // Parse the device (name/addr/uuid) option.
     278        21974 :     if (parser.isSet(u"device"_s)) {
     279          356 :         deviceToScanFor = parser.value(u"device"_s);
     280          144 :     }
     281              : 
     282              :     // Parse the output format options (if supported, and supplied).
     283        27869 :     if ((supportedOptionNames.contains(u"output"_s)) && // Derived classes may have removed.
     284        24569 :         (parser.isSet(u"output"_s)))
     285          936 :     {
     286         2626 :         const QString output = parser.value(u"output"_s).toLower();
     287         1781 :         if (output == u"csv"_s) {
     288          411 :             format = OutputFormat::Csv;
     289         1370 :         } else if (output == u"json"_s) {
     290          411 :             format = OutputFormat::Json;
     291          959 :         } else if (output == u"text"_s) {
     292          411 :             format = OutputFormat::Text;
     293          288 :         } else {
     294          712 :             errors.append(tr("Unknown output format: %1").arg(output));
     295          288 :         }
     296         1248 :     }
     297              : 
     298              :     // Parse the device scan timeout option.
     299        21974 :     if (parser.isSet(u"timeout"_s)) {
     300         2431 :         const quint32 timeout = parseNumber<std::milli>(parser.value(u"timeout"_s), u"s"_s, 500);
     301         1781 :         if (timeout == 0) {
     302         1290 :             errors.append(tr("Invalid timeout: %1").arg(parser.value(u"timeout"_s)));
     303          959 :         } else if (discoveryAgent->lowEnergyDiscoveryTimeout() == -1) {
     304          777 :             qCWarning(lc).noquote() << tr("Platform does not support Bluetooth scan timeout");
     305          504 :         } else {
     306          630 :             discoveryAgent->setLowEnergyDiscoveryTimeout(timeout);
     307          749 :             qCDebug(lc).noquote() << tr("Set scan timeout to %1").arg(
     308            0 :                 discoveryAgent->lowEnergyDiscoveryTimeout());
     309          441 :         }
     310          936 :     }
     311              : 
     312              :     // Return errors for any required options that are absent.
     313        16603 :     const QStringList requiredOptionNames = this->requiredOptions(parser);
     314        27254 :     for (const QString &option: requiredOptionNames) {
     315        18547 :         if (!parser.isSet(option)) {
     316         1766 :             errors.append(tr("Missing required option: %1").arg(option));
     317          600 :         }
     318         8472 :     }
     319        16603 :     return errors;
     320         8088 : }
     321              : 
     322              : /*!
     323              :  * \fn virtual bool AbstractCommand::start()
     324              :  *
     325              :  * Begins the functionality of this command, and returns `true` if begun successfully, `false`
     326              :  * otherwise.
     327              :  */
     328              : 
     329              : /*!
     330              :  * \fn virtual void AbstractCommand::deviceDiscovered(const QBluetoothDeviceInfo &info) = 0
     331              :  *
     332              :  * Handles PokitDiscoveryAgent::pokitDeviceDiscovered signal. Derived classes must
     333              :  * implement this slot to begin whatever actions are relevant when a Pokit device has been
     334              :  * discovered. For example, the 'scan' command would simply output the \a info details, whereas
     335              :  * most other commands would begin connecting if \a info is the device they're after.
     336              :  */
     337              : 
     338              : /*!
     339              :  * \fn virtual void AbstractCommand::deviceDiscoveryFinished() = 0
     340              :  *
     341              :  * Handles PokitDiscoveryAgent::deviceDiscoveryFinished signal. Derived classes must
     342              :  * implement this slot to perform whatever actions are appropriate when discovery is finished.
     343              :  * For example, the 'scan' command would simply exit, whereas most other commands would verify that
     344              :  * an appropriate device was found.
     345              :  */
        

Generated by: LCOV version 2.3.2-1