612 lines
18 KiB
C++
612 lines
18 KiB
C++
// remoteclient.cpp - client for the AdvancedController remote-control protocol (see remoteclient.h)
|
|
|
|
#include "remoteclient.h"
|
|
|
|
#include <QSerialPort>
|
|
#include <QSerialPortInfo>
|
|
#include <QThread>
|
|
|
|
#include <algorithm>
|
|
#include <utility>
|
|
|
|
namespace AdvCtrl {
|
|
|
|
namespace {
|
|
|
|
// Commands that block the controller for a long time (the reply comes after completion).
|
|
// Key = beginning of the upper-case request line; the first match counts.
|
|
struct LongTimeout
|
|
{
|
|
const char *prefix;
|
|
int ms;
|
|
};
|
|
|
|
const LongTimeout LongTimeouts[] = {
|
|
{"RESET", 10000}, // CAM_RESET: all cameras, ~6 s
|
|
{"FOCUS", 10000}, // crosshair reset, ~5 s
|
|
{"KEY CAMRESET", 10000},
|
|
{"KEY FOCUS", 10000},
|
|
{"PRESET", 3000},
|
|
{"KEY LOAD1", 3000}, {"KEY LOAD2", 3000}, {"KEY SAVE1", 3000}, {"KEY SAVE2", 3000},
|
|
{"KEY XH1", 3000}, {"KEY XH2", 3000}, // crosshair on/off: 11 mask commands (~0.7 s)
|
|
{"KEY UP", 2000}, {"KEY DOWN", 2000}, // in crosshair edit mode one press = ~0.7 s
|
|
{"KEY LEFT", 2000}, {"KEY RIGHT", 2000},
|
|
};
|
|
|
|
// Commands that may safely be repeated after a timeout (no side effect on repetition).
|
|
// KEY / RESET / FOCUS / PRESET are NOT repeated automatically.
|
|
const char *const Idempotent[] = {
|
|
"PING", "VER", "ID", "STATUS", "LEDS", "IDENTIFY", "CAM", "BR", "AGC", "ZOOM",
|
|
"FLIP", "MIR", "TEMP", "LED"
|
|
};
|
|
|
|
const int WriteTimeoutMs = 1000;
|
|
const int WaitSliceMs = 50; // a request to stop the thread is noticed within this time
|
|
|
|
bool isIdempotent(const QString &firstWord)
|
|
{
|
|
for (const char *name : Idempotent) {
|
|
if (firstWord == QLatin1String(name))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool isHexDigits(const QString &text)
|
|
{
|
|
for (const QChar c : text) {
|
|
if (!((c >= QLatin1Char('0') && c <= QLatin1Char('9'))
|
|
|| (c >= QLatin1Char('a') && c <= QLatin1Char('f'))
|
|
|| (c >= QLatin1Char('A') && c <= QLatin1Char('F'))))
|
|
return false;
|
|
}
|
|
return !text.isEmpty();
|
|
}
|
|
|
|
bool isDigits(const QString &text)
|
|
{
|
|
for (const QChar c : text) {
|
|
if (c < QLatin1Char('0') || c > QLatin1Char('9'))
|
|
return false;
|
|
}
|
|
return !text.isEmpty();
|
|
}
|
|
|
|
// e.g. "/dev/ttyUSB2" before "/dev/ttyUSB10": compare the text before the trailing number, then the number.
|
|
bool naturalLess(const QString &a, const QString &b)
|
|
{
|
|
auto split = [](const QString &s, QString *head, qlonglong *number) {
|
|
qsizetype i = s.size();
|
|
while (i > 0 && s.at(i - 1).isDigit())
|
|
--i;
|
|
*head = s.left(i);
|
|
*number = i < s.size() ? s.mid(i).toLongLong() : -1;
|
|
};
|
|
QString headA, headB;
|
|
qlonglong numberA, numberB;
|
|
split(a, &headA, &numberA);
|
|
split(b, &headB, &numberB);
|
|
if (headA != headB)
|
|
return headA < headB;
|
|
if (numberA != numberB)
|
|
return numberA < numberB;
|
|
return a < b;
|
|
}
|
|
|
|
// "OK[ data]" or "ERR <code> <text>"
|
|
Reply parseReply(const QString &line)
|
|
{
|
|
Reply r;
|
|
r.line = line;
|
|
if (line.toUpper().startsWith(QLatin1String("OK"))) {
|
|
r.status = Reply::Ok;
|
|
r.data = line.mid(2).trimmed();
|
|
return r;
|
|
}
|
|
r.status = Reply::DeviceError;
|
|
const qsizetype space1 = line.indexOf(QLatin1Char(' '));
|
|
const QString rest = space1 < 0 ? QString() : line.mid(space1 + 1);
|
|
const qsizetype space2 = rest.indexOf(QLatin1Char(' '));
|
|
const QString code = space2 < 0 ? rest : rest.left(space2);
|
|
r.code = isDigits(code) ? code.toInt() : 0;
|
|
r.text = space2 < 0 ? errorText(r.code) : rest.mid(space2 + 1);
|
|
return r;
|
|
}
|
|
|
|
// "KEY=VALUE KEY=VALUE ..." -> fields
|
|
Fields parseFields(const QStringList &items)
|
|
{
|
|
Fields fields;
|
|
for (const QString &item : items) {
|
|
const qsizetype eq = item.indexOf(QLatin1Char('='));
|
|
if (eq > 0)
|
|
fields.insert(item.left(eq), item.mid(eq + 1));
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// ---------------------------------------------------------------------- helpers
|
|
QString Reply::toString() const
|
|
{
|
|
switch (status) {
|
|
case Ok:
|
|
return data.isEmpty() ? QStringLiteral("OK") : QStringLiteral("OK ") + data;
|
|
case DeviceError:
|
|
return QStringLiteral("ERR %1 %2").arg(code).arg(text);
|
|
default:
|
|
return text;
|
|
}
|
|
}
|
|
|
|
QString errorText(int code)
|
|
{
|
|
switch (code) {
|
|
case 1: return QStringLiteral("unknown command");
|
|
case 2: return QStringLiteral("wrong arguments");
|
|
case 3: return QStringLiteral("value out of range");
|
|
case 4: return QStringLiteral("line too long");
|
|
case 5: return QStringLiteral("checksum mismatch");
|
|
default: return QString();
|
|
}
|
|
}
|
|
|
|
QString nmeaChecksum(const QString &text)
|
|
{
|
|
unsigned x = 0;
|
|
for (const char c : text.toLatin1())
|
|
x ^= static_cast<unsigned char>(c);
|
|
return QStringLiteral("%1").arg(x, 2, 16, QLatin1Char('0')).toUpper();
|
|
}
|
|
|
|
// ------------------------------------------------------------------------- port
|
|
RemoteClient::RemoteClient() = default;
|
|
|
|
RemoteClient::~RemoteClient()
|
|
{
|
|
close();
|
|
}
|
|
|
|
QList<PortInfo> RemoteClient::listPorts()
|
|
{
|
|
QList<PortInfo> ports;
|
|
const QList<QSerialPortInfo> infos = QSerialPortInfo::availablePorts();
|
|
for (const QSerialPortInfo &info : infos) {
|
|
PortInfo port;
|
|
port.name = info.systemLocation(); // "/dev/ttyUSB0"
|
|
port.description = info.description().isEmpty() ? QStringLiteral("n/a") : info.description();
|
|
ports.append(port);
|
|
}
|
|
std::sort(ports.begin(), ports.end(), [](const PortInfo &a, const PortInfo &b) {
|
|
return naturalLess(a.name, b.name);
|
|
});
|
|
return ports;
|
|
}
|
|
|
|
QString RemoteClient::findController(const QStringList &ports, int baudRate)
|
|
{
|
|
QStringList names = ports;
|
|
if (names.isEmpty()) {
|
|
const QList<PortInfo> present = listPorts();
|
|
for (const PortInfo &port : present)
|
|
names.append(port.name);
|
|
}
|
|
for (const QString &name : std::as_const(names)) {
|
|
RemoteClient rc;
|
|
if (!rc.open(name, baudRate))
|
|
continue; // busy or gone
|
|
if (rc.detect())
|
|
return name;
|
|
}
|
|
return QString();
|
|
}
|
|
|
|
bool RemoteClient::open(const QString &portName, int baudRate)
|
|
{
|
|
close();
|
|
m_portName = portName;
|
|
m_errorString.clear();
|
|
m_failed = false;
|
|
m_stale = 0;
|
|
m_last = Reply();
|
|
m_lastRx.invalidate();
|
|
|
|
m_port = std::make_unique<QSerialPort>();
|
|
m_port->setPortName(portName);
|
|
m_port->setBaudRate(baudRate);
|
|
m_port->setDataBits(QSerialPort::Data8);
|
|
m_port->setParity(QSerialPort::NoParity);
|
|
m_port->setStopBits(QSerialPort::OneStop);
|
|
m_port->setFlowControl(QSerialPort::NoFlowControl);
|
|
if (!m_port->open(QIODevice::ReadWrite)) {
|
|
m_errorString = m_port->errorString();
|
|
m_port.reset();
|
|
return false;
|
|
}
|
|
// DTR and RTS on - the line state the USB-RS485 adapters were tested with
|
|
// (adapters with automatic direction control do not use them).
|
|
m_port->setDataTerminalReady(true);
|
|
m_port->setRequestToSend(true);
|
|
|
|
QThread::msleep(100); // some USB adapters need a moment after open
|
|
flush();
|
|
return true;
|
|
}
|
|
|
|
void RemoteClient::close()
|
|
{
|
|
if (m_port) {
|
|
if (m_port->isOpen())
|
|
m_port->close();
|
|
m_port.reset();
|
|
}
|
|
m_rx.clear();
|
|
}
|
|
|
|
bool RemoteClient::isOpen() const
|
|
{
|
|
return m_port && m_port->isOpen();
|
|
}
|
|
|
|
QString RemoteClient::portName() const
|
|
{
|
|
return m_portName;
|
|
}
|
|
|
|
QString RemoteClient::errorString() const
|
|
{
|
|
return m_errorString;
|
|
}
|
|
|
|
bool RemoteClient::portFailed() const
|
|
{
|
|
return m_failed;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------- options
|
|
void RemoteClient::setTimeout(int ms)
|
|
{
|
|
m_timeoutMs = ms;
|
|
}
|
|
|
|
int RemoteClient::timeout() const
|
|
{
|
|
return m_timeoutMs;
|
|
}
|
|
|
|
void RemoteClient::setChecksum(bool on)
|
|
{
|
|
m_checksum = on;
|
|
}
|
|
|
|
bool RemoteClient::checksum() const
|
|
{
|
|
return m_checksum;
|
|
}
|
|
|
|
void RemoteClient::setRetries(int count)
|
|
{
|
|
m_retries = count;
|
|
}
|
|
|
|
int RemoteClient::retries() const
|
|
{
|
|
return m_retries;
|
|
}
|
|
|
|
void RemoteClient::setLog(std::function<void(const QString &)> log)
|
|
{
|
|
m_log = std::move(log);
|
|
}
|
|
|
|
void RemoteClient::log(const QString &text) const
|
|
{
|
|
if (m_log)
|
|
m_log(text);
|
|
}
|
|
|
|
// -------------------------------------------------------------------- detection
|
|
bool RemoteClient::detect(int attempts, int timeoutMs)
|
|
{
|
|
for (int i = 0; i < attempts; ++i) {
|
|
const Reply r = raw(QStringLiteral("PING"), timeoutMs);
|
|
if (r.status == Reply::PortError)
|
|
return false;
|
|
if (r.line.toUpper().startsWith(QStringLiteral("OK PONG ") + DeviceId))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --------------------------------------------------------------------- requests
|
|
int RemoteClient::timeoutFor(const QString &line) const
|
|
{
|
|
const QString upper = line.trimmed().toUpper();
|
|
for (const LongTimeout &t : LongTimeouts) {
|
|
if (upper.startsWith(QLatin1String(t.prefix)))
|
|
return t.ms;
|
|
}
|
|
return m_timeoutMs;
|
|
}
|
|
|
|
Reply RemoteClient::raw(const QString &line, int timeoutMs)
|
|
{
|
|
const QString request = m_checksum
|
|
? line.trimmed() + QLatin1Char('*') + nmeaChecksum(line.trimmed())
|
|
: line.trimmed();
|
|
if (timeoutMs < 0)
|
|
timeoutMs = timeoutFor(line);
|
|
|
|
if (!isOpen())
|
|
return failure(Reply::PortError, m_failed ? m_errorString : QStringLiteral("port not open"));
|
|
if (QThread::currentThread()->isInterruptionRequested())
|
|
return failure(Reply::PortError, QStringLiteral("interrupted"));
|
|
|
|
if (m_stale > 0)
|
|
drainLate();
|
|
waitTurnaround();
|
|
flush();
|
|
log(QStringLiteral("> ") + request);
|
|
if (!writeLine(request.toLatin1() + "\r\n"))
|
|
return failure(Reply::PortError, m_errorString);
|
|
|
|
const QDeadlineTimer deadline(timeoutMs);
|
|
QString reply;
|
|
for (;;) {
|
|
switch (readLineUntil(deadline, &reply)) {
|
|
case ReadResult::Line:
|
|
break;
|
|
case ReadResult::Timeout:
|
|
++m_stale;
|
|
return failure(Reply::Timeout, QStringLiteral("no reply to '%1' within %2 s")
|
|
.arg(request).arg(timeoutMs / 1000.0, 0, 'f', 1));
|
|
case ReadResult::Interrupted:
|
|
return failure(Reply::PortError, QStringLiteral("interrupted"));
|
|
case ReadResult::PortFailure:
|
|
return failure(Reply::PortError, m_errorString);
|
|
}
|
|
const QString head = reply.toUpper();
|
|
if (head.startsWith(QLatin1String("OK")) || head.startsWith(QLatin1String("ERR")))
|
|
break;
|
|
// not a reply (garbage at power-on etc.) - ignore
|
|
}
|
|
|
|
const qsizetype star = reply.lastIndexOf(QLatin1Char('*'));
|
|
if (star >= 0) {
|
|
const QString body = reply.left(star);
|
|
if (reply.mid(star + 1).trimmed().toUpper() != nmeaChecksum(body))
|
|
return failure(Reply::BadReply, QStringLiteral("bad checksum in reply '%1'").arg(reply));
|
|
reply = body;
|
|
}
|
|
return finish(parseReply(reply));
|
|
}
|
|
|
|
Reply RemoteClient::command(const QString &line, int timeoutMs)
|
|
{
|
|
const QString first = line.trimmed().toUpper().section(QLatin1Char(' '), 0, 0);
|
|
const int attempts = 1 + (isIdempotent(first) ? m_retries : 0);
|
|
Reply r;
|
|
for (int i = 0; i < attempts; ++i) {
|
|
r = raw(line, timeoutMs);
|
|
if (r.status != Reply::Timeout)
|
|
break;
|
|
}
|
|
return finish(r);
|
|
}
|
|
|
|
bool RemoteClient::readLine(int timeoutMs, QString *line)
|
|
{
|
|
return readLineUntil(QDeadlineTimer(timeoutMs), line) == ReadResult::Line;
|
|
}
|
|
|
|
RemoteClient::ReadResult RemoteClient::readLineUntil(const QDeadlineTimer &deadline, QString *line)
|
|
{
|
|
for (;;) {
|
|
const qsizetype newline = m_rx.indexOf('\n');
|
|
if (newline >= 0) {
|
|
QByteArray bytes;
|
|
for (qsizetype i = 0; i < newline; ++i) {
|
|
if (m_rx.at(i) != '\r')
|
|
bytes.append(m_rx.at(i));
|
|
}
|
|
m_rx.remove(0, newline + 1);
|
|
if (bytes.isEmpty())
|
|
continue; // empty lines are skipped
|
|
*line = QString::fromLatin1(bytes);
|
|
log(QStringLiteral("< ") + *line);
|
|
return ReadResult::Line;
|
|
}
|
|
if (!isOpen() || m_failed)
|
|
return ReadResult::PortFailure;
|
|
if (QThread::currentThread()->isInterruptionRequested())
|
|
return ReadResult::Interrupted;
|
|
const qint64 remaining = deadline.remainingTime();
|
|
if (remaining <= 0)
|
|
return ReadResult::Timeout;
|
|
m_port->clearError();
|
|
if (!m_port->waitForReadyRead(int(std::min<qint64>(remaining, WaitSliceMs))) && !checkPort())
|
|
return ReadResult::PortFailure;
|
|
const QByteArray chunk = m_port->readAll();
|
|
if (!chunk.isEmpty()) {
|
|
m_rx += chunk;
|
|
m_lastRx.start();
|
|
}
|
|
}
|
|
}
|
|
|
|
// RS485 half duplex: do not start a request while the controller may still hold the line
|
|
// after its reply (see TurnaroundMs).
|
|
void RemoteClient::waitTurnaround()
|
|
{
|
|
if (!m_lastRx.isValid())
|
|
return;
|
|
const qint64 wait = TurnaroundMs - m_lastRx.elapsed();
|
|
if (wait > 0)
|
|
QThread::msleep(static_cast<unsigned long>(wait));
|
|
}
|
|
|
|
bool RemoteClient::writeLine(const QByteArray &bytes)
|
|
{
|
|
m_port->clearError();
|
|
if (m_port->write(bytes) != bytes.size()) {
|
|
m_failed = true;
|
|
m_errorString = m_port->errorString();
|
|
return false;
|
|
}
|
|
while (m_port->bytesToWrite() > 0) {
|
|
if (!m_port->waitForBytesWritten(WriteTimeoutMs)) {
|
|
m_failed = true;
|
|
m_errorString = m_port->error() == QSerialPort::TimeoutError
|
|
? QStringLiteral("write timeout") : m_port->errorString();
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// true = the port is still usable (no error, or only the timeout of a wait)
|
|
bool RemoteClient::checkPort()
|
|
{
|
|
const QSerialPort::SerialPortError e = m_port->error();
|
|
if (e == QSerialPort::NoError || e == QSerialPort::TimeoutError)
|
|
return true;
|
|
m_failed = true;
|
|
m_errorString = m_port->errorString();
|
|
return false;
|
|
}
|
|
|
|
void RemoteClient::flush()
|
|
{
|
|
if (isOpen()) {
|
|
m_port->clear(QSerialPort::Input);
|
|
m_port->readAll();
|
|
}
|
|
m_rx.clear();
|
|
}
|
|
|
|
void RemoteClient::drainLate(int quietMs)
|
|
{
|
|
const QDeadlineTimer deadline(quietMs);
|
|
QString line;
|
|
while (readLineUntil(deadline, &line) == ReadResult::Line)
|
|
log(QStringLiteral("~ ") + line); // late reply, dropped
|
|
m_stale = 0;
|
|
flush();
|
|
}
|
|
|
|
Reply RemoteClient::finish(const Reply &reply)
|
|
{
|
|
m_last = reply;
|
|
return reply;
|
|
}
|
|
|
|
Reply RemoteClient::failure(Reply::Status status, const QString &text)
|
|
{
|
|
Reply r;
|
|
r.status = status;
|
|
r.text = text;
|
|
return finish(r);
|
|
}
|
|
|
|
// --------------------------------------------------------------------- commands
|
|
const Reply &RemoteClient::lastReply() const
|
|
{
|
|
return m_last;
|
|
}
|
|
|
|
bool RemoteClient::run(const QString &line)
|
|
{
|
|
return command(line).ok();
|
|
}
|
|
|
|
// A reply of the wrong shape is a late reply of an earlier request: the real one may follow.
|
|
bool RemoteClient::rejectReply(const QString &text)
|
|
{
|
|
++m_stale;
|
|
m_last.status = Reply::BadReply;
|
|
m_last.text = text;
|
|
return false;
|
|
}
|
|
|
|
bool RemoteClient::ping()
|
|
{
|
|
const Reply r = command(QStringLiteral("PING"));
|
|
return r.ok() && r.data.toUpper().startsWith(QStringLiteral("PONG ") + DeviceId);
|
|
}
|
|
|
|
bool RemoteClient::identify(Fields *id)
|
|
{
|
|
const Reply r = command(QStringLiteral("ID"));
|
|
if (!r.ok())
|
|
return false;
|
|
const QStringList parts = r.data.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
|
if (parts.isEmpty() || parts.first().toUpper() != DeviceId)
|
|
return rejectReply(QStringLiteral("unexpected reply to ID: '%1'").arg(parts.join(QLatin1Char(' '))));
|
|
Fields result = parseFields(parts.mid(1));
|
|
result.insert(QStringLiteral("ID"), parts.first());
|
|
if (id)
|
|
*id = result;
|
|
return true;
|
|
}
|
|
|
|
bool RemoteClient::version(QString *firmware)
|
|
{
|
|
const Reply r = command(QStringLiteral("VER"));
|
|
if (r.ok() && firmware)
|
|
*firmware = r.data;
|
|
return r.ok();
|
|
}
|
|
|
|
bool RemoteClient::status(Fields *fields)
|
|
{
|
|
const Reply r = command(QStringLiteral("STATUS"));
|
|
if (r.ok() && fields)
|
|
*fields = parseFields(r.data.split(QLatin1Char(' '), Qt::SkipEmptyParts));
|
|
return r.ok();
|
|
}
|
|
|
|
bool RemoteClient::leds(quint32 *mask)
|
|
{
|
|
const Reply r = command(QStringLiteral("LEDS"));
|
|
if (!r.ok())
|
|
return false;
|
|
if (r.data.size() != 6 || !isHexDigits(r.data))
|
|
return rejectReply(QStringLiteral("unexpected reply to LEDS: '%1'").arg(r.data));
|
|
if (mask)
|
|
*mask = r.data.toUInt(nullptr, 16);
|
|
return true;
|
|
}
|
|
|
|
bool RemoteClient::ledsOn(QStringList *names)
|
|
{
|
|
quint32 mask = 0;
|
|
if (!leds(&mask))
|
|
return false;
|
|
if (names) {
|
|
names->clear();
|
|
for (int bit = 0; bit < LedNames.size(); ++bit) {
|
|
if (mask & (1u << bit))
|
|
names->append(LedNames.at(bit));
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool RemoteClient::identifyBlink() { return run(QStringLiteral("IDENTIFY")); }
|
|
bool RemoteClient::key(const QString &name) { return run(QStringLiteral("KEY ") + name); }
|
|
bool RemoteClient::cam(int number) { return run(QStringLiteral("CAM %1").arg(number)); }
|
|
bool RemoteClient::brightness(int value) { return run(QStringLiteral("BR %1").arg(value)); }
|
|
bool RemoteClient::agc(int value) { return run(QStringLiteral("AGC %1").arg(value)); }
|
|
bool RemoteClient::zoom(int value) { return run(QStringLiteral("ZOOM %1").arg(value)); }
|
|
bool RemoteClient::flip(bool on) { return run(QStringLiteral("FLIP %1").arg(on ? 1 : 0)); }
|
|
bool RemoteClient::mirror(bool on) { return run(QStringLiteral("MIR %1").arg(on ? 1 : 0)); }
|
|
bool RemoteClient::tempOsd(bool on) { return run(QStringLiteral("TEMP %1").arg(on ? 1 : 0)); }
|
|
bool RemoteClient::led(int level) { return run(QStringLiteral("LED %1").arg(level)); }
|
|
bool RemoteClient::ledAuto(bool on) { return run(QStringLiteral("LED AUTO %1").arg(on ? 1 : 0)); }
|
|
bool RemoteClient::presetLoad(int number) { return run(QStringLiteral("PRESET LOAD %1").arg(number)); }
|
|
bool RemoteClient::presetSave(int number) { return run(QStringLiteral("PRESET SAVE %1").arg(number)); }
|
|
bool RemoteClient::reset() { return run(QStringLiteral("RESET")); }
|
|
bool RemoteClient::focus() { return run(QStringLiteral("FOCUS")); }
|
|
|
|
} // namespace AdvCtrl
|