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 :
6 : #include <qtpokit/pokitdevice.h>
7 : #include <qtpokit/pokitdiscoveryagent.h>
8 :
9 : #include <QLocale>
10 : #include <QTimer>
11 :
12 : #include <cmath>
13 : #include <ratio>
14 :
15 : /*!
16 : * \class AbstractCommand
17 : *
18 : * The AbstractCommand class provides a consistent base for the classes that implement CLI commands.
19 : */
20 :
21 : /*!
22 : * Constructs a new command with \a parent.
23 : */
24 27003 : AbstractCommand::AbstractCommand(QObject * const parent) : QObject(parent),
25 27003 : discoveryAgent(new PokitDiscoveryAgent(this))
26 14993 : {
27 33951 : connect(discoveryAgent, &PokitDiscoveryAgent::pokitDeviceDiscovered,
28 18817 : this, &AbstractCommand::deviceDiscovered);
29 33951 : connect(discoveryAgent, &PokitDiscoveryAgent::finished,
30 18817 : this, &AbstractCommand::deviceDiscoveryFinished);
31 33951 : connect(discoveryAgent,
32 6899 : #if (QT_VERSION < QT_VERSION_CHECK(6, 2, 0))
33 6899 : QOverload<PokitDiscoveryAgent::Error>::of(&PokitDiscoveryAgent::error),
34 : #else
35 8094 : &PokitDiscoveryAgent::errorOccurred,
36 8094 : #endif
37 15085 : this, [](const PokitDiscoveryAgent::Error &error) {
38 364 : qCWarning(lc).noquote() << tr("Bluetooth discovery error:") << error;
39 75 : QTimer::singleShot(0, QCoreApplication::instance(), [](){
40 0 : QCoreApplication::exit(EXIT_FAILURE);
41 0 : });
42 167 : });
43 33951 : }
44 :
45 : /*!
46 : * Returns a list of CLI option names required by this command. The main console appication may
47 : * use this list to output an eror (and exit) if any of the returned names are not found in the
48 : * parsed CLI options.
49 : *
50 : * The (already parsed) \a parser may be used adjust the returned required options depending on the
51 : * value of other options. For example, the `logger` command only requires the `--mode` option if
52 : * the `--command` option is `start`.
53 : *
54 : * This base implementation simply returns an empty list. Derived classes should override this
55 : * function to include any required options.
56 : */
57 12480 : QStringList AbstractCommand::requiredOptions(const QCommandLineParser &parser) const
58 12984 : {
59 12984 : Q_UNUSED(parser)
60 25464 : return QStringList();
61 12984 : }
62 :
63 : /*!
64 : * Returns a list of CLI option names supported by this command. The main console appication may
65 : * use this list to output a warning for any parsed CLI options not included in the returned list.
66 : *
67 : * The (already parsed) \a parser may be used adjust the returned supported options depending on the
68 : * value of other options. For example, the `logger` command only supported the `--timestamp` option
69 : * if the `--command` option is `start`.
70 : *
71 : * This base implementation simply returns requiredOptions(). Derived classes should override this
72 : * function to include optional options, such as:
73 : *
74 : * ```
75 : * QStringList Derived::supportedOptions(const QCommandLineParser &parser) const
76 : * {
77 : * const QStringList list = AbstractCommand::supportedOptions(parser) + QStringList{ ... };
78 : * list.sort();
79 : * list.removeDuplicates(); // Optional, recommended.
80 : * return list;
81 : * }
82 : * ```
83 : */
84 6080 : QStringList AbstractCommand::supportedOptions(const QCommandLineParser &parser) const
85 6304 : {
86 50992 : return requiredOptions(parser) + QStringList{
87 6304 : QLatin1String("debug"),
88 6304 : QLatin1String("device"), QLatin1String("d"),
89 6304 : QLatin1String("output"),
90 6304 : QLatin1String("timeout"),
91 45672 : };
92 9648 : }
93 :
94 : /*!
95 : * Returns an RFC 4180 compliant version of \a field. That is, if \a field contains any of the
96 : * the below four characters, than any double quotes are escaped (by addition double-quotes), and
97 : * the string itself surrounded in double-quotes. Otherwise, \a field is returned verbatim.
98 : *
99 : * Some examples:
100 : * ```
101 : * QCOMPARE(escapeCsvField("abc"), "abc"); // Returned unchanged.
102 : * QCOMPARE(escapeCsvField("a,c"), R"("a,c")"); // Wrapped in double-quotes.
103 : * QCOMPARE(escapeCsvField(R"(a"c)"), R("("a""c")"); // Existing double-quotes doubled, then wrapped.
104 : * ```
105 : */
106 4630 : QString AbstractCommand::escapeCsvField(const QString &field)
107 2891 : {
108 7481 : if (field.contains(QLatin1Char(','))||field.contains(QLatin1Char('\r'))||
109 16193 : field.contains(QLatin1Char('"'))||field.contains(QLatin1Char('\n')))
110 94 : {
111 208 : return QString::fromLatin1(R"("%1")").arg(
112 178 : QString(field).replace(QLatin1Char('"'), QLatin1String(R"("")")));
113 2797 : } else return field;
114 2891 : }
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 7359 : 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 9751 : 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 unqalified 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 5160 : quint32 AbstractCommand::parseNumber(const QString &value, const QString &unit, const quint32 sensibleMinimum)
151 4803 : {
152 10193 : static const QMap<QChar, Ratio> unitPrefixScaleMap {
153 4803 : { QLatin1Char('E'), makeRatio<std::exa>() },
154 4803 : { QLatin1Char('P'), makeRatio<std::peta>() },
155 4803 : { QLatin1Char('T'), makeRatio<std::tera>() },
156 4803 : { QLatin1Char('G'), makeRatio<std::giga>() },
157 4803 : { QLatin1Char('M'), makeRatio<std::mega>() },
158 4803 : { QLatin1Char('K'), makeRatio<std::kilo>() }, // Not official SI unit prefix, but commonly used.
159 4803 : { QLatin1Char('k'), makeRatio<std::kilo>() },
160 4803 : { QLatin1Char('h'), makeRatio<std::hecto>() },
161 4803 : { QLatin1Char('d'), makeRatio<std::deci>() },
162 4803 : { QLatin1Char('c'), makeRatio<std::centi>() },
163 4803 : { QLatin1Char('m'), makeRatio<std::milli>() },
164 4803 : { QLatin1Char('u'), makeRatio<std::micro>() }, // Not official SI unit prefix, but commonly used.
165 4803 : { QChar (0x00B5), makeRatio<std::micro>() }, // Unicode micro symbol (μ).
166 4803 : { QLatin1Char('n'), makeRatio<std::nano>() },
167 4803 : { QLatin1Char('p'), makeRatio<std::pico>() },
168 4803 : { QLatin1Char('f'), makeRatio<std::femto>() },
169 4803 : { QLatin1Char('a'), makeRatio<std::atto>() },
170 4803 : };
171 :
172 : // Remove the optional (whole) unit suffix.
173 4803 : Ratio ratio;
174 4803 : QString number = value.trimmed();
175 9963 : if ((!unit.isEmpty()) && (number.endsWith(unit, Qt::CaseInsensitive))) {
176 4755 : number.chop(unit.length());
177 2155 : ratio = makeRatio<std::ratio<1>>();
178 2155 : }
179 :
180 : // Parse, and remove, the optional SI unit prefix.
181 9963 : if (!number.isEmpty()) {
182 4456 : #if (QT_VERSION >= QT_VERSION_CHECK(5, 10, 0))
183 6632 : const QChar siPrefix = number.back(); // QString::back() introduced in Qt 5.10.
184 : #else
185 556 : const QChar siPrefix = number.at(number.size() - 1);
186 300 : #endif
187 4756 : const auto iter = unitPrefixScaleMap.constFind(siPrefix);
188 9876 : if (iter != unitPrefixScaleMap.constEnd()) {
189 2048 : Q_ASSERT(iter->isValid());
190 4608 : ratio = *iter;
191 4608 : number.chop(1);
192 2048 : }
193 4756 : }
194 :
195 5136 : #define DOKIT_RESULT(var) (var * ratio.num * R::den / ratio.den / R::num)
196 : // Parse the number as an (unsigned) integer.
197 9963 : QLocale locale; bool ok;
198 7770 : qulonglong integer = locale.toULongLong(number, &ok);
199 9963 : if (ok) {
200 6489 : if (integer == 0) {
201 47 : return 0;
202 47 : }
203 2962 : if (!ratio.isValid()) {
204 2688 : for (ratio = makeRatio<R>(); DOKIT_RESULT(integer) < sensibleMinimum; ratio.num *= 1000);
205 901 : }
206 6402 : return (integer == 0) ? 0u : (quint32)DOKIT_RESULT(integer);
207 3009 : }
208 :
209 : // Parse the number as a (double) floating point number, and check that it is positive.
210 3474 : if (const double dbl = locale.toDouble(number, &ok); (ok) && (dbl > 0.0)) {
211 517 : if (!ratio.isValid()) {
212 609 : for (ratio = makeRatio<R>(); DOKIT_RESULT(dbl) < sensibleMinimum; ratio.num *= 1000);
213 235 : }
214 957 : return static_cast<quint32>(std::llround(DOKIT_RESULT(dbl)));
215 517 : }
216 1277 : #undef DOKIT_RESULT
217 1277 : return 0; // Failed to parse as either integer, or float.
218 6954 : }
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 : * inovking 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 5240 : QStringList AbstractCommand::processOptions(const QCommandLineParser &parser)
266 5317 : {
267 : // Report any supplied options that are not supported by this command.
268 10557 : const QStringList suppliedOptionNames = parser.optionNames();
269 10557 : const QStringList supportedOptionNames = supportedOptions(parser);
270 24476 : for (const QString &option: suppliedOptionNames) {
271 19236 : if (!supportedOptionNames.contains(option)) {
272 154 : qCInfo(lc).noquote() << tr("Ignoring option: %1").arg(option);
273 47 : }
274 9316 : }
275 7544 : QStringList errors;
276 :
277 : // Parse the device (name/addr/uuid) option.
278 12784 : if (parser.isSet(QLatin1String("device"))) {
279 208 : deviceToScanFor = parser.value(QLatin1String("device"));
280 94 : }
281 :
282 : // Parse the output format options (if supported, and supplied).
283 10819 : if ((supportedOptionNames.contains(QLatin1String("output"))) && // Derived classes may have removed.
284 12784 : (parser.isSet(QLatin1String("output"))))
285 611 : {
286 1651 : const QString output = parser.value(QLatin1String("output")).toLower();
287 1122 : if (output == QLatin1String("csv")) {
288 261 : format = OutputFormat::Csv;
289 861 : } else if (output == QLatin1String("json")) {
290 261 : format = OutputFormat::Json;
291 600 : } else if (output == QLatin1String("text")) {
292 261 : format = OutputFormat::Text;
293 188 : } else {
294 416 : errors.append(tr("Unknown output format: %1").arg(output));
295 188 : }
296 910 : }
297 :
298 : // Parse the device scan timeout option.
299 12784 : if (parser.isSet(QLatin1String("timeout"))) {
300 1352 : const quint32 timeout = parseNumber<std::milli>(parser.value(QLatin1String("timeout")), QLatin1String("s"), 500);
301 1131 : if (timeout == 0) {
302 774 : errors.append(tr("Invalid timeout: %1").arg(parser.value(QLatin1String("timeout"))));
303 609 : } else if (discoveryAgent->lowEnergyDiscoveryTimeout() == -1) {
304 525 : qCWarning(lc).noquote() << tr("Platform does not support Bluetooth scan timeout");
305 329 : } else {
306 364 : discoveryAgent->setLowEnergyDiscoveryTimeout(timeout);
307 399 : qCDebug(lc).noquote() << tr("Set scan timeout to %1").arg(
308 0 : discoveryAgent->lowEnergyDiscoveryTimeout());
309 273 : }
310 611 : }
311 :
312 : // Return errors for any required options that are absent.
313 10557 : const QStringList requiredOptionNames = this->requiredOptions(parser);
314 17165 : for (const QString &option: requiredOptionNames) {
315 11805 : if (!parser.isSet(option)) {
316 1024 : errors.append(tr("Missing required option: %1").arg(option));
317 397 : }
318 5605 : }
319 10557 : return errors;
320 5317 : }
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 appropraite 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 : */
|