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