AdvancedControllerRemoteCPP/lib/remoteclient.cpp

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