Dokit
Internal development documentation
Loading...
Searching...
No Matches
abstractcommand.cpp
1// SPDX-FileCopyrightText: 2022-2024 Paul Colby <git@colby.id.au>
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4#include "abstractcommand.h"
5
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 */
25 discoveryAgent(new PokitDiscoveryAgent(this))
26{
32 #if (QT_VERSION < QT_VERSION_CHECK(6, 2, 0))
33 QOverload<PokitDiscoveryAgent::Error>::of(&PokitDiscoveryAgent::error),
34 #else
35 &PokitDiscoveryAgent::errorOccurred,
36 #endif
37 this, [](const PokitDiscoveryAgent::Error &error) {
38 qCWarning(lc).noquote() << tr("Bluetooth discovery error:") << error;
40 QCoreApplication::exit(EXIT_FAILURE);
41 });
42 });
43}
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 */
58{
59 Q_UNUSED(parser)
60 return QStringList();
61}
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 */
85{
86 return requiredOptions(parser) + QStringList{
87 QLatin1String("debug"),
88 QLatin1String("device"), QLatin1String("d"),
89 QLatin1String("output"),
90 QLatin1String("timeout"),
91 };
92}
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 */
107{
108 if (field.contains(QLatin1Char(','))||field.contains(QLatin1Char('\r'))||
109 field.contains(QLatin1Char('"'))||field.contains(QLatin1Char('\n')))
110 {
111 return QString::fromLatin1(R"("%1")").arg(
112 QString(field).replace(QLatin1Char('"'), QLatin1String(R"("")")));
113 } else return field;
114}
115
116/*!
117 * \internal
118 * A (run-time) class approximately equivalent to the compile-time std::ratio template.
119 */
120struct 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 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 */
131template<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 */
149template<typename R>
150quint32 AbstractCommand::parseNumber(const QString &value, const QString &unit, const quint32 sensibleMinimum)
151{
152 static const QMap<QChar, Ratio> unitPrefixScaleMap {
153 { QLatin1Char('E'), makeRatio<std::exa>() },
154 { QLatin1Char('P'), makeRatio<std::peta>() },
155 { QLatin1Char('T'), makeRatio<std::tera>() },
156 { QLatin1Char('G'), makeRatio<std::giga>() },
157 { QLatin1Char('M'), makeRatio<std::mega>() },
158 { QLatin1Char('K'), makeRatio<std::kilo>() }, // Not official SI unit prefix, but commonly used.
159 { QLatin1Char('k'), makeRatio<std::kilo>() },
160 { QLatin1Char('h'), makeRatio<std::hecto>() },
161 { QLatin1Char('d'), makeRatio<std::deci>() },
162 { QLatin1Char('c'), makeRatio<std::centi>() },
163 { QLatin1Char('m'), makeRatio<std::milli>() },
164 { QLatin1Char('u'), makeRatio<std::micro>() }, // Not official SI unit prefix, but commonly used.
165 { QChar (0x00B5), makeRatio<std::micro>() }, // Unicode micro symbol (μ).
166 { QLatin1Char('n'), makeRatio<std::nano>() },
167 { QLatin1Char('p'), makeRatio<std::pico>() },
168 { QLatin1Char('f'), makeRatio<std::femto>() },
169 { QLatin1Char('a'), makeRatio<std::atto>() },
170 };
171
172 // Remove the optional (whole) unit suffix.
173 Ratio ratio;
174 QString number = value.trimmed();
175 if ((!unit.isEmpty()) && (number.endsWith(unit, Qt::CaseInsensitive))) {
176 number.chop(unit.length());
177 ratio = makeRatio<std::ratio<1>>();
178 }
179
180 // Parse, and remove, the optional SI unit prefix.
181 if (!number.isEmpty()) {
182 #if (QT_VERSION >= QT_VERSION_CHECK(5, 10, 0))
183 const QChar siPrefix = number.back(); // QString::back() introduced in Qt 5.10.
184 #else
185 const QChar siPrefix = number.at(number.size() - 1);
186 #endif
187 const auto iter = unitPrefixScaleMap.constFind(siPrefix);
188 if (iter != unitPrefixScaleMap.constEnd()) {
189 Q_ASSERT(iter->isValid());
190 ratio = *iter;
191 number.chop(1);
192 }
193 }
194
195 #define DOKIT_RESULT(var) (var * ratio.num * R::den / ratio.den / R::num)
196 // Parse the number as an (unsigned) integer.
197 QLocale locale; bool ok;
198 qulonglong integer = locale.toULongLong(number, &ok);
199 if (ok) {
200 if (integer == 0) {
201 return 0;
202 }
203 if (!ratio.isValid()) {
204 for (ratio = makeRatio<R>(); DOKIT_RESULT(integer) < sensibleMinimum; ratio.num *= 1000);
205 }
206 return (integer == 0) ? 0u : (quint32)DOKIT_RESULT(integer);
207 }
208
209 // Parse the number as a (double) floating point number, and check that it is positive.
210 if (const double dbl = locale.toDouble(number, &ok); (ok) && (dbl > 0.0)) {
211 if (!ratio.isValid()) {
212 for (ratio = makeRatio<R>(); DOKIT_RESULT(dbl) < sensibleMinimum; ratio.num *= 1000);
213 }
214 return static_cast<quint32>(std::llround(DOKIT_RESULT(dbl)));
215 }
216 #undef DOKIT_RESULT
217 return 0; // Failed to parse as either integer, or float.
218}
219
220#define DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(type) template \
221quint32 AbstractCommand::parseNumber<type>(const QString &value, const QString &unit, const quint32 sensibleMinimum)
222DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::exa);
223DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::peta);
224DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::tera);
225DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::giga);
226DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::mega);
227DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::kilo);
228DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::hecto);
229DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::deca);
230DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::ratio<1>);
231DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::deci);
232DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::centi);
233DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::milli);
234DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::micro);
235DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::nano);
236DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::pico);
237DOKIT_INSTANTIATE_TEMPLATE_FUNCTION(std::femto);
238DOKIT_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 */
266{
267 // Report any supplied options that are not supported by this command.
268 const QStringList suppliedOptionNames = parser.optionNames();
269 const QStringList supportedOptionNames = supportedOptions(parser);
270 for (const QString &option: suppliedOptionNames) {
271 if (!supportedOptionNames.contains(option)) {
272 qCInfo(lc).noquote() << tr("Ignoring option: %1").arg(option);
273 }
274 }
275 QStringList errors;
276
277 // Parse the device (name/addr/uuid) option.
278 if (parser.isSet(QLatin1String("device"))) {
279 deviceToScanFor = parser.value(QLatin1String("device"));
280 }
281
282 // Parse the output format options (if supported, and supplied).
283 if ((supportedOptionNames.contains(QLatin1String("output"))) && // Derived classes may have removed.
284 (parser.isSet(QLatin1String("output"))))
285 {
286 const QString output = parser.value(QLatin1String("output")).toLower();
287 if (output == QLatin1String("csv")) {
289 } else if (output == QLatin1String("json")) {
291 } else if (output == QLatin1String("text")) {
293 } else {
294 errors.append(tr("Unknown output format: %1").arg(output));
295 }
296 }
297
298 // Parse the device scan timeout option.
299 if (parser.isSet(QLatin1String("timeout"))) {
300 const quint32 timeout = parseNumber<std::milli>(parser.value(QLatin1String("timeout")), QLatin1String("s"), 500);
301 if (timeout == 0) {
302 errors.append(tr("Invalid timeout: %1").arg(parser.value(QLatin1String("timeout"))));
303 } else if (discoveryAgent->lowEnergyDiscoveryTimeout() == -1) {
304 qCWarning(lc).noquote() << tr("Platform does not support Bluetooth scan timeout");
305 } else {
307 qCDebug(lc).noquote() << tr("Set scan timeout to %1").arg(
309 }
310 }
311
312 // Return errors for any required options that are absent.
313 const QStringList requiredOptionNames = this->requiredOptions(parser);
314 for (const QString &option: requiredOptionNames) {
315 if (!parser.isSet(option)) {
316 errors.append(tr("Missing required option: %1").arg(option));
317 }
318 }
319 return errors;
320}
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 */
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 quint32 parseNumber(const QString &value, const QString &unit, const quint32 sensibleMinimum=0)
Returns value as an integer multiple of the ratio R.
virtual void deviceDiscovered(const QBluetoothDeviceInfo &info)=0
Handles PokitDiscoveryAgent::pokitDeviceDiscovered signal.
PokitDiscoveryAgent * discoveryAgent
Agent for Pokit device descovery.
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
int lowEnergyDiscoveryTimeout() const const
void setLowEnergyDiscoveryTimeout(int timeout)
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)
double toDouble(const QString &s, bool *ok) const const
qulonglong toULongLong(const QString &s, bool *ok) const const
QMetaObject::Connection connect(const QObject *sender, const char *signal, const QObject *receiver, const char *method, Qt::ConnectionType type)
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
QString fromLatin1(const char *str, int size)
bool isEmpty() const const
int length() const const
int size() const const
QString toLower() const const
QString trimmed() const const
bool contains(const QString &str, Qt::CaseSensitivity cs) const const
CaseInsensitive
std::intmax_t num
Numerator.
std::intmax_t den
bool isValid() const
Returns true if both num and den are non-zero.