Dokit
Internal development documentation
Loading...
Searching...
No Matches
abstractcommand.cpp
1// SPDX-FileCopyrightText: 2022-2026 Paul Colby <git@colby.id.au>
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4#include "abstractcommand.h"
6
9
10#include <QLocale>
11#include <QTimer>
12#include <QtMath>
13
14#include <cmath>
15#include <ratio>
16
18
19/*!
20 * \class AbstractCommand
21 *
22 * The AbstractCommand class provides a consistent base for the classes that implement CLI commands.
23 */
24
25/*!
26 * Constructs a new command with \a parent.
27 */
30{
36 #if (QT_VERSION < QT_VERSION_CHECK(6, 2, 0))
37 QOverload<PokitDiscoveryAgent::Error>::of(&PokitDiscoveryAgent::error),
38 #else
39 &PokitDiscoveryAgent::errorOccurred,
40 #endif
41 this, [](const PokitDiscoveryAgent::Error &error) {
42 qCWarning(lc).noquote() << tr("Bluetooth discovery error:") << error;
44 QCoreApplication::exit(EXIT_FAILURE);
45 });
46 });
47}
48
49/*!
50 * Returns a list of CLI option names required by this command. The main console appication may
51 * use this list to output an error (and exit) if any of the returned names are not found in the
52 * parsed CLI options.
53 *
54 * The (already parsed) \a parser may be used adjust the returned required options depending on the
55 * value of other options. For example, the `logger` command only requires the `--mode` option if
56 * the `--command` option is `start`.
57 *
58 * This base implementation simply returns an empty list. Derived classes should override this
59 * function to include any required options.
60 */
62{
63 Q_UNUSED(parser)
64 return QStringList();
65}
66
67/*!
68 * Returns a list of CLI option names supported by this command. The main console appication may
69 * use this list to output a warning for any parsed CLI options not included in the returned list.
70 *
71 * The (already parsed) \a parser may be used adjust the returned supported options depending on the
72 * value of other options. For example, the `logger` command only supported the `--timestamp` option
73 * if the `--command` option is `start`.
74 *
75 * This base implementation simply returns requiredOptions(). Derived classes should override this
76 * function to include optional options, such as:
77 *
78 * ```
79 * QStringList Derived::supportedOptions(const QCommandLineParser &parser) const
80 * {
81 * const QStringList list = AbstractCommand::supportedOptions(parser) + QStringList{ ... };
82 * list.sort();
83 * list.removeDuplicates(); // Optional, recommended.
84 * return list;
85 * }
86 * ```
87 */
89{
90 return requiredOptions(parser) + QStringList{
91 u"debug"_s,
92 u"device"_s, u"d"_s,
93 u"output"_s,
94 u"timeout"_s,
95 };
96}
97
98/*!
99 * Returns a human-readable version of \a value, with up-to \a precision decimal digits, and SI unit prefix when
100 * appropiate.
101 *
102 * \note Only supports positive values. Tip: use appendSiPrefix(qAbs(...)) for now.
103 */
104QString AbstractCommand::appendSiPrefix(const double value, int precision) {
105 // Scale the number to an appropriate SI prefix.
106 QStringList siPrefixes{ u""_s, u"m"_s, u"μ"_s };
107 int index = 0;
108 if (value != 0.)
109 for (index=0; (index < siPrefixes.length()-1) && (qAbs(value) * qPow(1000.0, (double)index) < 1); ++index);
110 QString number = QString::number(value * qPow(1000.0, (double)index), 'f', precision);
111
112 // Trim trailing zeros, and decimal indicator if no decimals remain.
113 if (const DOKIT_STRING_INDEX_TYPE decimalPos = number.indexOf(u'.'); decimalPos > 0) {
114 DOKIT_STRING_INDEX_TYPE nonZeroPos;
115 for (nonZeroPos = number.length()-1; (nonZeroPos > decimalPos) && (number.at(nonZeroPos) == u'0'); --nonZeroPos);
116 number.truncate((nonZeroPos == decimalPos) ? nonZeroPos : nonZeroPos+1);
117 }
118 return QString(u"%1%2"_s).arg(number, siPrefixes.at(index));
119}
120
121/*!
122 * Returns an RFC 4180 compliant version of \a field. That is, if \a field contains any of the
123 * the below four characters, than any double quotes are escaped (by addition double-quotes), and
124 * the string itself surrounded in double-quotes. Otherwise, \a field is returned verbatim.
125 *
126 * Some examples:
127 * ```
128 * QCOMPARE(escapeCsvField("abc"), "abc"); // Returned unchanged.
129 * QCOMPARE(escapeCsvField("a,c"), R"("a,c")"); // Wrapped in double-quotes.
130 * QCOMPARE(escapeCsvField(R"(a"c)"), R("("a""c")"); // Existing double-quotes doubled, then wrapped.
131 * ```
132 */
134{
135 if (field.contains(','_L1) || field.contains('\r'_L1) || field.contains('"'_L1) || field.contains('\n'_L1)) {
136 return uR"("%1")"_s.arg(QString(field).replace('"'_L1, uR"("")"_s));
137 } else return field;
138}
139
140/*!
141 * \internal
142 * A (run-time) class approximately equivalent to the compile-time std::ratio template.
143 */
144struct Ratio {
145 std::intmax_t num { 0 }; ///< Numerator.
146 std::intmax_t den { 0 }; ///< Denominator.
147 //! Returns \a true if both #num and #den are non-zero.
148 bool isValid() const { return (num != 0) && (den != 0); }
149};
150
151/*!
152 * \internal
153 * Returns a (run-time) Ratio representation of (compile-time) ratio \a R.
154 */
155template<typename R> constexpr Ratio makeRatio() { return Ratio{ R::num, R::den }; }
156
157/*!
158 * Returns \a value as an integer multiple of the ratio \a R, as number of type \a T. The string \a value
159 * may end with the optional \a unit, such as `V` or `s`, which may also be preceded with a SI unit
160 * prefix such as `m` for `milli`. If \a value contains no SI unit prefix, then the result will be
161 * multiplied by 1,000 enough times to be greater than \a sensibleMinimum. This allows for
162 * convenient use like:
163 *
164 * ```
165 * const quint32 timeout = parseNumber<std::milli>(parser.value("window"), 's', (quint32)500'000);
166 * ```
167 *
168 * So that an unqualified period like "300" will be assumed to be 300 milliseconds, and not 300
169 * microseconds, while a period like "1000" will be assume to be 1 second.
170 *
171 * If conversion fails for any reason, quiet-NaN (if supported by \a T) or 0 is returned.
172 */
173template<typename R, typename T>
174T AbstractCommand::parseNumber(const QString &value, const QString &unit, const T sensibleMinimum)
175{
176 static const QMap<QChar, Ratio> unitPrefixScaleMap {
177 { 'E'_L1, makeRatio<std::exa>() },
178 { 'P'_L1, makeRatio<std::peta>() },
179 { 'T'_L1, makeRatio<std::tera>() },
180 { 'G'_L1, makeRatio<std::giga>() },
181 { 'M'_L1, makeRatio<std::mega>() },
182 { 'K'_L1, makeRatio<std::kilo>() }, // Not official SI unit prefix, but commonly used.
183 { 'k'_L1, makeRatio<std::kilo>() },
184 { 'h'_L1, makeRatio<std::hecto>() },
185 { 'd'_L1, makeRatio<std::deci>() },
186 { 'c'_L1, makeRatio<std::centi>() },
187 { 'm'_L1, makeRatio<std::milli>() },
188 { 'u'_L1, makeRatio<std::micro>() }, // Not official SI unit prefix, but commonly used.
189 { QChar(0x00B5), makeRatio<std::micro>() }, // Unicode micro symbol (µ).
190 { QChar(0x03BC), makeRatio<std::micro>() }, // Unicode lower mu (μ).
191 { 'n'_L1, makeRatio<std::nano>() },
192 { 'p'_L1, makeRatio<std::pico>() },
193 { 'f'_L1, makeRatio<std::femto>() },
194 { 'a'_L1, makeRatio<std::atto>() },
195 };
196
197 // Remove the optional (whole) unit suffix.
198 Ratio ratio;
199 QString number = value.trimmed();
200 if ((!unit.isEmpty()) && (number.endsWith(unit, Qt::CaseInsensitive))) {
201 number.chop(unit.length());
202 ratio = makeRatio<std::ratio<1>>();
203 }
204
205 // Parse, and remove, the optional SI unit prefix.
206 if (!number.isEmpty()) {
207 #if (QT_VERSION >= QT_VERSION_CHECK(5, 10, 0))
208 const QChar siPrefix = number.back(); // QString::back() introduced in Qt 5.10.
209 #else
210 const QChar siPrefix = number.at(number.size() - 1);
211 #endif
212 const auto iter = unitPrefixScaleMap.constFind(siPrefix);
213 if (iter != unitPrefixScaleMap.constEnd()) {
214 Q_ASSERT(iter->isValid());
215 ratio = *iter;
216 number.chop(1);
217 }
218 }
219
220 // Parse the number as an (unsigned) integer.
221 QLocale locale; bool ok;
222 if (const qulonglong integer = locale.toULongLong(number, &ok); (ok)) {
223 if (integer == 0) {
224 return static_cast<T>(integer); // Otherwise the next for loop would be be infinite.
225 }
226 #define DOKIT_RESULT(var) (static_cast<T>(var) * ratio.num * R::den / ratio.den / R::num)
227 if (!ratio.isValid()) {
228 for (ratio = makeRatio<R>(); DOKIT_RESULT(integer) < sensibleMinimum; ratio.num *= 1000);
229 }
230 return DOKIT_RESULT(integer);
231 #undef DOKIT_RESULT
232 }
233
234 // Parse the number as a (double) floating point number, and check that it is positive.
235 if (const double dbl = locale.toDouble(number, &ok); (ok) && (dbl > 0.0)) {
236 if (dbl == 0.) {
237 return static_cast<T>(dbl); // Otherwise the next for loop would be be infinite.
238 }
239 #define DOKIT_RESULT(var) (var * ratio.num * R::den / ratio.den / R::num)
240 if (!ratio.isValid()) {
241 for (ratio = makeRatio<R>(); DOKIT_RESULT(dbl) < sensibleMinimum; ratio.num *= 1000);
242 }
243 return (std::is_integral_v<T>) ? static_cast<T>(std::llround(DOKIT_RESULT(dbl))) : DOKIT_RESULT(dbl);
244 #undef DOKIT_RESULT
245 }
246
247 // Failed to parse as either integer, or float.
248 return (std::numeric_limits<T>::has_quiet_NaN) ? std::numeric_limits<T>::quiet_NaN() : 0;
249}
250
251#define DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(Ratio, Type) template \
252Type AbstractCommand::parseNumber<Ratio>(const QString &value, const QString &unit, const Type sensibleMinimum)
253DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::exa, quint32);
254DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::peta, quint32);
255DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::tera, quint32);
256DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::giga, quint32);
257DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::mega, quint32);
258DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::kilo, quint32);
259DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::hecto, quint32);
260DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::deca, quint32);
261DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::ratio<1>, quint32);
262DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::ratio<1>, float);
263DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::deci, quint32);
264DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::centi, quint32);
265DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::milli, quint32);
266DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::micro, quint32);
267DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::nano, quint32);
268DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::pico, quint32);
269DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::femto, quint32);
270DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::atto, quint32);
271#undef DOKIT_INSTANTIATE_TEMPLATE_FUNCTION
272
273/*!
274 * Processes the relevant options from the command line \a parser.
275 *
276 * On success, returns an empty QStringList, otherwise returns a list of CLI errors that the caller
277 * should report appropriately before exiting.
278 *
279 * This base implementations performs some common checks, such as ensuring that required options are
280 * present. Derived classes should override this function to perform further processing, typically
281 * invoking this base implementation as a first step, such as:
282 *
283 * ```
284 * QStringList CustomCommand::processOptions(const QCommandLineParser &parser)
285 * {
286 * QStringList errors = AbstractCommand::processOptions(parser);
287 * if (!errors.isEmpty()) {
288 * return errors;
289 * }
290 *
291 * // Do further procession of options.
292 *
293 * return errors;
294 * }
295 * ```
296 */
298{
299 // Report any supplied options that are not supported by this command.
300 const QStringList suppliedOptionNames = parser.optionNames();
301 const QStringList supportedOptionNames = supportedOptions(parser);
302 for (const QString &option: suppliedOptionNames) {
303 if (!supportedOptionNames.contains(option)) {
304 qCInfo(lc).noquote() << tr("Ignoring option: %1").arg(option);
305 }
306 }
307 QStringList errors;
308
309 // Parse the device (name/addr/uuid) option.
310 if (parser.isSet(u"device"_s)) {
311 deviceToScanFor = parser.value(u"device"_s);
312 }
313
314 // Parse the output format options (if supported, and supplied).
315 if ((supportedOptionNames.contains(u"output"_s)) && // Derived classes may have removed.
316 (parser.isSet(u"output"_s)))
317 {
318 const QString output = parser.value(u"output"_s).toLower();
319 if (output == u"csv"_s) {
321 } else if (output == u"json"_s) {
323 } else if (output == u"text"_s) {
325 } else {
326 errors.append(tr("Unknown output format: %1").arg(output));
327 }
328 }
329
330 // Parse the device scan timeout option.
331 if (parser.isSet(u"timeout"_s)) {
332 const quint32 timeout = parseNumber<std::milli>(parser.value(u"timeout"_s), u"s"_s, 500);
333 if (timeout == 0) {
334 errors.append(tr("Invalid timeout: %1").arg(parser.value(u"timeout"_s)));
335 } else if (discoveryAgent->lowEnergyDiscoveryTimeout() == -1) {
336 qCWarning(lc).noquote() << tr("Platform does not support Bluetooth scan timeout");
337 } else {
338 discoveryAgent->setLowEnergyDiscoveryTimeout(timeout);
339 qCDebug(lc).noquote() << tr("Set scan timeout to %1").arg(
340 discoveryAgent->lowEnergyDiscoveryTimeout());
341 }
342 }
343
344 // Return errors for any required options that are absent.
345 const QStringList requiredOptionNames = this->requiredOptions(parser);
346 for (const QString &option: requiredOptionNames) {
347 if (!parser.isSet(option)) {
348 errors.append(tr("Missing required option: %1").arg(option));
349 }
350 }
351 return errors;
352}
353
354/*!
355 * \fn virtual bool AbstractCommand::start()
356 *
357 * Begins the functionality of this command, and returns `true` if begun successfully, `false`
358 * otherwise.
359 */
360
361/*!
362 * \fn virtual void AbstractCommand::deviceDiscovered(const QBluetoothDeviceInfo &info) = 0
363 *
364 * Handles PokitDiscoveryAgent::pokitDeviceDiscovered signal. Derived classes must
365 * implement this slot to begin whatever actions are relevant when a Pokit device has been
366 * discovered. For example, the 'scan' command would simply output the \a info details, whereas
367 * most other commands would begin connecting if \a info is the device they're after.
368 */
369
370/*!
371 * \fn virtual void AbstractCommand::deviceDiscoveryFinished() = 0
372 *
373 * Handles PokitDiscoveryAgent::deviceDiscoveryFinished signal. Derived classes must
374 * implement this slot to perform whatever actions are appropriate when discovery is finished.
375 * For example, the 'scan' command would simply exit, whereas most other commands would verify that
376 * an appropriate device was found.
377 */
QString deviceToScanFor
Device (if any) that were passed to processOptions().
AbstractCommand(QObject *const parent=nullptr)
Constructs a new command with parent.
virtual QStringList supportedOptions(const QCommandLineParser &parser) const
Returns a list of CLI option names supported by this command.
static QString appendSiPrefix(const double value, int precision=6)
Returns a human-readable version of value, with up-to precision decimal digits, and SI unit prefix wh...
static T parseNumber(const QString &value, const QString &unit, const T sensibleMinimum=0)
Returns value as an integer multiple of the ratio R, as number of type T.
virtual void deviceDiscovered(const QBluetoothDeviceInfo &info)=0
Handles PokitDiscoveryAgent::pokitDeviceDiscovered signal.
PokitDiscoveryAgent * discoveryAgent
Agent for Pokit device discovery.
OutputFormat format
Selected output format.
@ Text
Plain unstructured text.
@ Csv
RFC 4180 compliant CSV text.
@ Json
RFC 8259 compliant JSON text.
virtual QStringList processOptions(const QCommandLineParser &parser)
Processes the relevant options from the command line parser.
virtual void deviceDiscoveryFinished()=0
Handles PokitDiscoveryAgent::deviceDiscoveryFinished signal.
static QString escapeCsvField(const QString &field)
Returns an RFC 4180 compliant version of field.
virtual QStringList requiredOptions(const QCommandLineParser &parser) const
Returns a list of CLI option names required by this command.
The PokitDiscoveryAgent class discovers nearby Pokit devices.
void pokitDeviceDiscovered(const QBluetoothDeviceInfo &info)
This signal is emitted when the Pokit device described by info is discovered.
Declares the PokitDevice class.
Declares the PokitDiscoveryAgent class.
QBluetoothDeviceDiscoveryAgent::Error error() const const
bool isSet(const QString &name) const const
QStringList optionNames() const const
QString value(const QString &optionName) const const
void exit(int returnCode)
QCoreApplication * instance()
void append(const T &value)
const T & at(int i) const const
int length() const const
double toDouble(const QString &s, bool *ok) const const
qulonglong toULongLong(const QString &s, bool *ok) const const
QMap::const_iterator constEnd() const const
QMap::const_iterator constFind(const Key &key) const const
QObject(QObject *parent)
QMetaObject::Connection connect(const QObject *sender, const char *signal, const QObject *receiver, const char *method, Qt::ConnectionType type)
QObject * parent() const const
QString tr(const char *sourceText, const char *disambiguation, int n)
QString arg(qlonglong a, int fieldWidth, int base, QChar fillChar) const const
const QChar at(int position) const const
QChar back() const const
void chop(int n)
bool contains(QChar ch, Qt::CaseSensitivity cs) const const
bool endsWith(const QString &s, Qt::CaseSensitivity cs) const const
int indexOf(QChar ch, int from, Qt::CaseSensitivity cs) const const
bool isEmpty() const const
int length() const const
QString number(int n, int base)
int size() const const
QString toLower() const const
QString trimmed() const const
void truncate(int position)
bool contains(const QString &str, Qt::CaseSensitivity cs) const const
CaseInsensitive
Declares the DOKIT_USE_STRINGLITERALS macro, and related functions.
#define DOKIT_STRING_INDEX_TYPE
Internal macro for matching the index type used by QString methods.
#define DOKIT_USE_STRINGLITERALS
Internal macro for using either official Qt string literals (added in Qt 6.4), or our own equivalent ...
std::intmax_t num
Numerator.
std::intmax_t den
bool isValid() const
Returns true if both num and den are non-zero.