// remoteclient.cpp - client for the AdvancedController remote-control protocol (see remoteclient.h) #include "remoteclient.h" #include #include #include #include #include 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 " 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(c); return QStringLiteral("%1").arg(x, 2, 16, QLatin1Char('0')).toUpper(); } // ------------------------------------------------------------------------- port RemoteClient::RemoteClient() = default; RemoteClient::~RemoteClient() { close(); } QList RemoteClient::listPorts() { QList ports; const QList 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 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(); 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 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(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(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