QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
UnicoreProtocol.cc
Go to the documentation of this file.
1#include <algorithm>
2#include <array>
3#include <chrono>
4#include <cmath>
5#include <cstdint>
6#include <optional>
7#include <string_view>
8#include <utility>
9#include <variant>
10
11#include <QtCore/QByteArray>
12#include <QtCore/QByteArrayView>
13#include <QtCore/QList>
14#include <QtCore/QString>
15
16#include "GPSASCIILog.h"
17#include "GPSCommandChannel.h"
19#include "GPSProtocolMath.h"
20#include "GPSReceiverConfig.h"
21#include "GPSReceiverFamilies.h"
22#include "GPSStreamDemux.h"
23#include "GPSTime.h"
24#include "MonotonicClock.h"
25#include "NMEASentence.h"
26#include "QGCLoggingCategory.h"
27#include "Unicore/UnicorePlan.h"
28
29QGC_LOGGING_CATEGORY(UnicoreProtocolLog, "GPS.Protocols.Unicore")
30
31// Independent implementation of Unicore N4 Commands and Logs Reference Book, EN R1.6:
32// https://en.unicore.com/uploads/file/Unicore%20Reference%20Commands%20Manual%20For%20N4%20High%20Precision%20Products_V2_EN_R1.6.pdf
33// Sections 3, 7.3.1, 7.3.27, 7.3.44; Appendices 1/2 and position/solution status tables.
34// Captured command-reply framing (XOR includes '$'):
35// https://s-taka.org/control-command-for-gnss-receiver-um982/
36namespace {
37
38namespace Plan = Unicore::Plan;
39
40std::string_view unquote(std::string_view field)
41{
42 if (field.size() < 2 || field.front() != '"' || field.back() != '"') {
43 return {};
44 }
45 field.remove_prefix(1);
46 field.remove_suffix(1);
47 return field.find('"') == std::string_view::npos ? field : std::string_view{};
48}
49
50bool supportedFirmware(std::string_view model, std::string_view firmware)
51{
52 constexpr std::string_view PREFIX = "R4.10Build";
53 if (!firmware.starts_with(PREFIX)) {
54 return false;
55 }
56 const auto build = NMEA::number<unsigned>(firmware.substr(PREFIX.size()));
57 // N4 R1.6 section 3.6 explicitly gives these minimum builds for the rover modes.
58 return build && ((model == "UM980" && *build >= 7923) || (model == "UM982" && *build >= 7650));
59}
60
63bool validXorChecksum(std::string_view line)
64{
65 const auto star = line.rfind('*');
66 return star != std::string_view::npos && star + 3 == line.size() &&
67 NMEA::hexNumber(line.substr(star + 1)) == NMEA::checksum(line.substr(0, star));
68}
69
71bool modeMatches(std::string_view mode, Plan::Reply reply)
72{
73 const auto starts = [mode](std::string_view name) {
74 return mode == name || (mode.starts_with(name) && mode.size() > name.size() && mode[name.size()] == ' ');
75 };
76 switch (reply) {
77 case Plan::Reply::RoverMode:
78 return starts("MODE ROVER");
79 case Plan::Reply::AveragingMode:
80 return starts("MODE BASE TIME");
81 default:
82 return mode == "MODE BASE";
83 }
84}
85
86bool samePosition(const GPSProtocolMath::Ecef& left, const GPSProtocolMath::Ecef& right)
87{
88 // ECEF command/readback values have four decimal places; this is not survey accuracy.
89 return GPSProtocolMath::distance(left, right) < 0.02;
90}
91
101class Protocol final : public GPSNMEAFamilyProtocol
102{
103public:
104 bool configure(GPSCommandChannel& channel, GPSConfig config, unsigned& baud) override;
105
107 QString identity() const override { return gpsReceiverIdentity(_model, _firmware); }
108
110 bool armDecodeOnly(const GPSConfig& config, GPSDecodeContext&) override
111 {
112 if (std::holds_alternative<GPSBaseStationConfig::SurveyIn>(config.base.mode)) {
113 return false;
114 }
115 _setBase(config.base.mode);
116 _monitorBase = true;
117 _ready = true;
118 return true;
119 }
120
121 std::chrono::milliseconds nextReadSlice(std::chrono::milliseconds timeout) const override
122 {
123 return (std::min) (timeout, STATUS_POLL);
124 }
125
128 bool probe(GPSCommandChannel& channel) override { return _identify(channel).succeeded() || !_model.isEmpty(); }
129
130private:
132 void _startAttempt(GPSStreamDemux& stream, GPSDecodeContext& context);
134 void _setBase(const GPSBaseStationConfig::Mode& mode);
138 GPSCommandSequence _sequence(const QList<Plan::Command>& commands);
139 GPSCommandOutcome _match(const Plan::Command& command, std::string_view line);
141 void _monitorBaseStatus(GPSDecodeContext& context);
143 bool _failAttempt(GPSCommandChannel& channel);
144 GPSReceiveUpdates _decodeLine(std::string_view line, GPSDecodeContext& context) override;
146 bool _handleVersion(std::string_view body, GPSDecodeContext& context);
147 bool _handleMode(std::string_view body, GPSDecodeContext& context);
148 bool _handlePosition(std::string_view body, GPSDecodeContext& context);
149 void _publishBase(GPSDecodeContext& context, bool active, bool valid);
151 void _invalidateBase(GPSDecodeContext& context, const QString& reason);
153 void _expire(GPSDecodeContext& context) override;
154
156 Plan::Reply _expectedMode = Plan::Reply::AveragingMode;
157 QByteArray _model;
158 QByteArray _firmware;
160 QString _rejection;
162 QByteArray _reportedMode;
163 GPSProtocolMath::Ecef _reportedPosition;
164 bool _reportedFixed = false;
166 bool _acknowledgementMatched = false;
168 GPSProtocolMath::Ecef _fixedECEF;
169 GPSProtocolMath::Ecef _baseECEF;
170 uint64_t _lastBaseStatus = 0;
171 std::optional<uint64_t> _lastBaseEpoch;
172 bool _monitorBase = false;
173 bool _baseValid = false;
174 bool _averaging = false;
175};
176
177bool Protocol::configure(GPSCommandChannel& channel, GPSConfig config, unsigned& baud)
178{
179 _startAttempt(channel.stream(), channel.context());
180 (void) channel.flush();
181 const auto* fixed = std::get_if<GPSBaseStationConfig::Fixed>(&config.base.mode);
182 const auto* averaging = std::get_if<GPSBaseStationConfig::ReceiverAveraging>(&config.base.mode);
183 if (!fixed && !averaging) {
184 channel.failControl(
185 QStringLiteral("Unicore requires receiver-managed averaging with a duration between 1 and 3600 seconds"));
186 return _failAttempt(channel);
187 }
188 _setBase(config.base.mode);
189 const auto scope = channel.deadlineScope(Plan::CONFIGURATION_TIMEOUT);
191 const auto detection = channel.detectBaud(Plan::BAUD_RATES, baud, [this, &channel, &identity]() -> GPSBaudProbe {
192 _nmea.reset(channel.stream());
193 identity = _identify(channel);
194 if (identity.succeeded()) {
195 return GPSBaudProbe::Found;
196 }
197 // A receiver that reported its model answered at this rate.
198 return _model.isEmpty() ? GPSBaudProbe::TryNext : GPSBaudProbe::Stop;
199 });
200 if (!detection.found) {
201 if (!_rejection.isEmpty()) {
202 channel.failControl(_rejection);
203 } else if (identity.outcome == GPSCommandOutcome::Rejected) {
204 channel.failSequence(identity);
205 } else {
206 channel.failNoAnswer(detection.probed);
207 }
208 return _failAttempt(channel);
209 }
210 baud = detection.baud;
211 qCDebug(UnicoreProtocolLog).noquote() << "Unicore" << _model << "firmware" << _firmware;
212 const auto plan = fixed ? Plan::fixedBase(_fixedECEF) : Plan::averagingBase(averaging->maximumDuration);
213 if (!channel.runRequired(_sequence(plan.role))) {
214 return _failAttempt(channel);
215 }
216 _monitorBaseStatus(channel.context());
217 if (!channel.runRequired(_sequence(plan.output))) {
218 return _failAttempt(channel);
219 }
220 // Corrections flow while the base is valid.
221 _ready = true;
222 _nmea.setRTCMEnabled(_baseValid);
223 return true;
224}
225
226void Protocol::_startAttempt(GPSStreamDemux& stream, GPSDecodeContext& context)
227{
228 _ready = false;
229 _monitorBase = false;
230 _baseValid = false;
231 _lastBaseEpoch.reset();
232 _nmea.setRTCMEnabled(false);
233 _nmea.reset(stream);
234 _model.clear();
235 _firmware.clear();
236 _rejection.clear();
237 _publishBase(context, false, false);
238}
239
240void Protocol::_setBase(const GPSBaseStationConfig::Mode& mode)
241{
242 const auto* fixed = std::get_if<GPSBaseStationConfig::Fixed>(&mode);
243 _averaging = !fixed;
244 _expectedMode = fixed ? Plan::Reply::FixedMode : Plan::Reply::AveragingMode;
245 if (fixed) {
246 _fixedECEF = GPSProtocolMath::toEcef(fixed->position);
247 }
248}
249
250GPSCommandSequence::Result Protocol::_identify(GPSCommandChannel& channel)
251{
252 _rejection.clear();
253 return channel.runSequence(_sequence({Plan::identify()}));
254}
255
256GPSCommandSequence Protocol::_sequence(const QList<Plan::Command>& commands)
257{
258 GPSCommandSequence result;
259 for (const Plan::Command& command : commands) {
260 result.steps.push_back(gpsCommand(command.text + "\r\n", Plan::COMMAND_TIMEOUT,
261 [this, command](std::string_view line) { return _match(command, line); },
262 {.label = command.label}));
263 }
264 return result;
265}
266
267GPSCommandOutcome Protocol::_match(const Plan::Command& command, std::string_view line)
268{
269 if (line.starts_with("$command,")) {
270 const auto response = line.find(",response: ", 9);
271 if (response == std::string_view::npos ||
272 QByteArrayView(line.substr(9, response - 9)).compare(command.text, Qt::CaseInsensitive) != 0) {
274 }
275 _acknowledgementMatched = true;
276 if (line.substr(response + 11, line.rfind('*') - response - 11) != "OK") {
278 }
279 return command.reply == Plan::Reply::Acknowledgement ? GPSCommandOutcome::Acknowledged
281 }
282 switch (command.reply) {
283 case Plan::Reply::Acknowledgement:
285 case Plan::Reply::Version:
286 if (!line.starts_with("#VERSIONA,")) {
288 }
289 if (supportedFirmware(_model.toStdString(), _firmware.toStdString())) {
291 }
292 _rejection = QStringLiteral(
293 "Unsupported Unicore receiver '%1' firmware '%2'; requires UM980 R4.10Build7923+ "
294 "or UM982 R4.10Build7650+")
295 .arg(QString::fromUtf8(_model), QString::fromUtf8(_firmware));
297 case Plan::Reply::RoverMode:
298 case Plan::Reply::AveragingMode:
299 case Plan::Reply::FixedMode:
300 if (!line.starts_with("#MODE,")) {
302 }
303 return modeMatches(_reportedMode.toStdString(), command.reply) ? GPSCommandOutcome::ReadbackVerified
305 case Plan::Reply::FixedPosition:
306 if (!line.starts_with("#BESTNAVXYZA,") || !_reportedFixed) {
308 }
309 return samePosition(_reportedPosition, _fixedECEF) ? GPSCommandOutcome::ReadbackVerified
311 }
313}
314
315void Protocol::_monitorBaseStatus(GPSDecodeContext& context)
316{
317 _monitorBase = true;
318 _publishBase(context, _averaging, false);
319}
320
321bool Protocol::_failAttempt(GPSCommandChannel& channel)
322{
323 if (_monitorBase) {
324 _publishBase(channel.context(), false, false);
325 }
326 _ready = false;
327 _monitorBase = false;
328 _baseValid = false;
329 _nmea.setRTCMEnabled(false);
330 (void) channel.flush();
331 return false;
332}
333
334GPSReceiveUpdates Protocol::_decodeLine(std::string_view line, GPSDecodeContext& context)
335{
336 if (line.starts_with("$command,")) {
337 if (!context.replyPending() || !validXorChecksum(line)) {
338 return {};
339 }
340 _acknowledgementMatched = false;
341 context.offerReply(line);
342 return _acknowledgementMatched ? GPSReceiveUpdates(GPSReceiveUpdate::Activity) : GPSReceiveUpdates{};
343 }
344 if (!line.starts_with('#')) {
345 return {};
346 }
347 const auto comma = line.find(',');
348 const auto name = line.substr(1, comma - 1);
349 if (name != "VERSIONA" && name != "MODE" && name != "BESTNAVXYZA") {
350 return {};
351 }
352 // MODE carries an XOR checksum; the other logs a CRC-32.
353 if (name == "MODE" ? !validXorChecksum(line) : !gpsASCIILog(line)) {
354 return {};
355 }
356 const auto star = line.rfind('*');
357 const auto semicolon = line.find(';');
358 if (semicolon == std::string_view::npos || semicolon >= star) {
359 return {};
360 }
361 std::array<std::string_view, 10> header{};
362 if (NMEA::splitFields(line.substr(1, semicolon - 1), header) != header.size()) {
363 return {};
364 }
365 const auto body = line.substr(semicolon + 1, star - semicolon - 1);
366 bool valid = false;
367 if (name == "VERSIONA") {
368 valid = _handleVersion(body, context);
369 } else if (name == "MODE") {
370 valid = _handleMode(body, context);
371 } else {
372 if (!_monitorBase) {
374 }
375 const auto week = NMEA::number<uint16_t>(header[4]);
376 const auto milliseconds = NMEA::number<uint32_t>(header[5]);
377 if (header[2] != "GPS" || header[3] != "FINE" || !week || !milliseconds || *milliseconds >= GPSTime::WEEK_MS) {
378 return {};
379 }
380 const uint64_t epoch = GPSTime::epochMs(*week, *milliseconds);
381 if (_lastBaseEpoch && epoch < *_lastBaseEpoch) {
382 _invalidateBase(context, QStringLiteral("Unicore base status went back in time; the receiver may have "
383 "restarted"));
385 }
386 if (_lastBaseEpoch && epoch == *_lastBaseEpoch) {
388 }
389 _lastBaseEpoch = epoch;
390 valid = _handlePosition(body, context);
391 }
392 if (valid) {
393 context.offerReply(line);
394 }
396}
397
398bool Protocol::_handleVersion(std::string_view body, GPSDecodeContext& context)
399{
400 std::array<std::string_view, 6> fields{};
401 if (NMEA::splitFields(body, fields) != fields.size()) {
402 return false;
403 }
404 const auto model = unquote(fields[0]);
405 const auto firmware = unquote(fields[1]);
406 if (model.empty() || model.size() > 32 || firmware.empty() || firmware.size() > 32) {
407 return false;
408 }
409 if (context.replyPending()) {
410 _model = QByteArrayView(model).toByteArray();
411 _firmware = QByteArrayView(firmware).toByteArray();
412 } else if (_ready) {
413 // An unsolicited identity report can indicate a reboot; never retain old base validity.
414 _invalidateBase(context,
415 QStringLiteral("Unicore receiver reported its identity unasked; it may have restarted"));
416 }
417 return true;
418}
419
420bool Protocol::_handleMode(std::string_view body, GPSDecodeContext& context)
421{
422 const auto mode = body.substr(0, body.find(','));
423 _reportedMode = QByteArrayView(mode).toByteArray();
424 if (_ready && !modeMatches(mode, _expectedMode)) {
425 _invalidateBase(context, QStringLiteral("Unicore receiver left the configured base mode"));
426 }
427 return true;
428}
429
430bool Protocol::_handlePosition(std::string_view body, GPSDecodeContext& context)
431{
432 std::array<std::string_view, 28> fields{};
433 if (NMEA::splitFields(body, fields) != fields.size()) {
434 return false;
435 }
436 const auto x = NMEA::number<double>(fields[2]);
437 const auto y = NMEA::number<double>(fields[3]);
438 const auto z = NMEA::number<double>(fields[4]);
439 if (!x || !y || !z) {
440 return false;
441 }
442 const GPSProtocolMath::Ecef coordinates{*x, *y, *z};
443 const bool fixed =
444 fields[0] == "SOL_COMPUTED" && fields[1] == "FIXEDPOS" && GPSProtocolMath::nearEarthSurface(coordinates);
445 _reportedPosition = coordinates;
446 _reportedFixed = fixed;
447 const bool matches = _averaging || samePosition(coordinates, _fixedECEF);
448 if (_ready && _baseValid && (!fixed || !matches || !samePosition(coordinates, _baseECEF))) {
449 _invalidateBase(context, QStringLiteral("Unicore base position was lost or changed"));
450 return true;
451 }
452 _lastBaseStatus = context.nowUs();
453 _baseValid = fixed && matches;
454 if (_baseValid) {
455 _baseECEF = coordinates;
456 }
457 _nmea.setRTCMEnabled(_ready && _baseValid);
458 _publishBase(context, _averaging && !_baseValid, _baseValid);
459 return true;
460}
461
462void Protocol::_publishBase(GPSDecodeContext& context, bool active, bool valid)
463{
464 // BESTNAV's instantaneous sigmas and BASEPOS monitoring are not averaging accuracy or elapsed time.
465 context.publishSurvey(active, valid, {}, valid ? GPSProtocolMath::fromEcef(_baseECEF) : GPSEllipsoidPosition{});
466}
467
468void Protocol::_invalidateBase(GPSDecodeContext& context, const QString& reason)
469{
470 _baseValid = false;
471 _monitorBase = false;
472 _ready = false;
473 _nmea.setRTCMEnabled(false);
474 _publishBase(context, false, false);
475 context.failControl(reason);
476}
477
478void Protocol::_expire(GPSDecodeContext& context)
479{
480 if (_ready && (context.failed() ||
481 (_baseValid && !MonotonicClock::withinAge(_lastBaseStatus, context.nowUs(), STATUS_MAX_AGE)))) {
482 _invalidateBase(context, QStringLiteral("Unicore base status stopped arriving"));
483 }
484}
485
487QLatin1StringView signature(const GPSFrame& frame)
488{
489 const std::string_view text = frame.text();
490 if (frame.kind != GPSFrameKind::ASCIILine) {
491 return {};
492 }
493 if (text.starts_with("$command,") && text.find(",response: ") != std::string_view::npos && validXorChecksum(text)) {
494 return QLatin1StringView("Unicore command replies");
495 }
496 const auto log = gpsASCIILog(text);
497 const bool unicore = log && !log->source.empty() && std::ranges::all_of(log->source, NMEA::isAsciiDigit);
498 return unicore ? QLatin1StringView("Unicore ASCII logs") : QLatin1StringView();
499}
500
501} // namespace
502
503namespace Unicore {
504
506 .type = GPSType::unicore,
507 .logCategory = &UnicoreProtocolLog,
508 .stream = GPSNMEAStream::STREAM,
509 .baudCandidates = Plan::BAUD_RATES,
510 .create = &gpsCreateProtocol<Protocol>,
511 .signature = &signature,
512};
513
514} // namespace Unicore
std::optional< GPSASCIILog > gpsASCIILog(std::string_view line)
Definition GPSASCIILog.h:21
GPSBaudProbe
@ Stop
The receiver answered but is not usable, so no other rate is tried.
GPSCommandOutcome
Definition GPSCommand.h:33
@ Protocol
The receiver violated or rejected the control protocol.
GPSCommandSequence::Command gpsCommand(QByteArray wire, std::chrono::milliseconds timeout, std::variant< GPSTextMatcher, GPSCommandSequence::RawReply > reply, GPSCommandOptions options={})
Definition GPSCommand.h:162
QString gpsReceiverIdentity(const QByteArray &model, const QByteArray &firmware)
A receiver identity as GPSFamilyProtocol::identity() reports it: "<model> <firmware>",...
@ ASCIILine
Printable lines up to CR/LF, without the terminator; the decoder checks checksums.
Config config
#define QGC_LOGGING_CATEGORY(name, categoryStr)
GPSBaudDetection detectBaud(std::span< const unsigned > candidates, unsigned baud, GPSBaudProbeFunction probe)
GPSDecodeContext & context()
void failSequence(const GPSCommandSequence::Result &result)
void failControl(GPSProtocolError failure=GPSProtocolError::Protocol)
GPSDeadlineScope deadlineScope(std::chrono::milliseconds timeout)
GPSReceiveUpdates flush()
Flushes decoder state and delivers the resulting batch.
GPSStreamDemux & stream()
bool runRequired(const GPSCommandSequence &sequence)
Runs sequence and fails control over a failed step as failSequence() does.
GPSCommandSequence::Result runSequence(const GPSCommandSequence &sequence)
Runs sequence in order until a required step fails or I/O fails; failed attempts retry up to their co...
void failNoAnswer(std::span< const unsigned > rates)
void offerReply(std::string_view reply)
Passes one decoded text reply to the outstanding text matcher, if any.
void failControl(const QString &detail)
Records a protocol failure described by detail unless a failure is already recorded,...
uint64_t nowUs() const
bool replyPending() const
True while a transact() reply is outstanding and unresolved.
void publishSurvey(GPSSurveyReport &status)
Stamps status with the receipt time and publishes a copy. Survey progress is not an update flag.
virtual bool probe(GPSCommandChannel &channel)
virtual bool armDecodeOnly(const GPSConfig &config, GPSDecodeContext &context)
virtual bool configure(GPSCommandChannel &channel, GPSConfig config, unsigned &baud)=0
virtual QString identity() const
Model and firmware reported during configuration; empty when unknown.
virtual std::chrono::milliseconds nextReadSlice(std::chrono::milliseconds timeout) const
The longest single read in a receive cycle of timeout; the cycle deadline still bounds it.
virtual GPSReceiveUpdates _decodeLine(std::string_view line, GPSDecodeContext &context)=0
Decodes one line after standard NMEA did.
virtual void _expire(GPSDecodeContext &context)
Drops state that is stale or that a recorded failure invalidated; runs before each decode.
static constexpr std::chrono::milliseconds STATUS_POLL
The longest read of a family that expires its status, so expiry runs on a silent link too.
static constexpr GPSStreamConfig STREAM
Framers an ASCII family's descriptor declares.
constexpr std::array BAUD_RATES
Rates the port query probes, in order. Trimble receivers default to 115200.
Definition AshtechPlan.h:31
QByteArray line(std::string_view command)
command as one CR/LF-terminated line.
Ecef toEcef(const GPSEllipsoidPosition &position)
bool nearEarthSurface(const Ecef &position)
double distance(const Ecef &left, const Ecef &right)
Straight-line distance between left and right, in metres.
GPSEllipsoidPosition fromEcef(const Ecef &position)
constexpr uint64_t epochMs(uint32_t week, uint32_t towMs)
Milliseconds since the GPS epoch of towMs into week.
Definition GPSTime.h:12
constexpr uint32_t WEEK_MS
Milliseconds in a GPS week; every time of week is below it.
Definition GPSTime.h:9
constexpr bool withinAge(uint64_t timestampUs, uint64_t nowUs, std::chrono::microseconds maximumAge)
Whether a receipt is at most maximumAge old, its limit included.
std::optional< uint32_t > hexNumber(std::string_view digits)
The value of digits when every one is a hexadecimal digit, with no sign or prefix; nullopt otherwise.
unsigned char checksum(std::string_view body)
constexpr bool isAsciiDigit(char ch)
Locale-independent ASCII character classes for receiver text.
size_t splitFields(std::string_view text, std::span< std::string_view > fields)
Preserve empty fields, including the final field. Return zero on storage overflow.
std::optional< Frame > frame(std::string_view text)
GPSCommandSequence::Command command(QByteArrayView body, GPSTextMatcher reply, std::chrono::milliseconds timeout)
body labelled by its text, framed as a sentence, and completed by reply within timeout.
BaseStation averagingBase(std::chrono::seconds maximum)
Receiver-managed averaging for at most maximum.
const GPSReceiverFamily FAMILY
std::variant< SurveyIn, Fixed, ReceiverAveraging > Mode
std::variant< GPSTextMatcher, RawReply > reply
Definition GPSCommand.h:135
GPSCommandOutcome outcome
The failed command's last outcome.
Definition GPSCommand.h:145
std::vector< Command > steps
Definition GPSCommand.h:150
The receiver configuration requested for one configure() attempt.
WGS84 geodetic position with ellipsoid height, not mean-sea-level altitude.