#ifdef HAVE_HIDAPI #include "HidEncoderManager.h" #include "core/AppSettings.h" #include "core/LogManager.h" #include #include #include #include #include #include // ── TMate 2 display helpers ──────────────────────────────────────────────── // // Digit encoding for the TMate 2 LCD. Tables and byte layout are derived // from OpenTMate2Lib (D:\Code\OpenTMate2Lib), which reverse-engineered the // protocol from TMATE2_DLL.dll via USBPcap captures (2026-06-05). // // Main 9-digit display (freq in Hz): // Each digit d (1=units, 9=100 MHz) occupies two LCDVector bytes: // high = 22 - 2*d bits: A=0x01 B=0x02 C=0x04 // low = 21 - 2*d bits: F=0x01 G=0x02 E=0x04 D=0x08 // // Small 3-digit display (S-meter / TX power): // Each digit d (1=units, 3=hundreds) occupies two LCDVector bytes: // high = 21 + 2*d bits: A=0x80 B=0x40 C=0x20 D=0x10 // low = 22 + 2*d bits: E=0x20 F=0x80 G=0x40 // (bit ordering is reversed vs. main display — hardware quirk) namespace { static const uint8_t kMainHigh[10] = { 0x07, 0x06, 0x03, 0x07, 0x06, 0x05, 0x05, 0x07, 0x07, 0x07 }; static const uint8_t kMainLow[10] = { 0x0D, 0x00, 0x0E, 0x0A, 0x03, 0x0B, 0x0F, 0x00, 0x0F, 0x0B }; static const uint8_t kSmallHigh[10] = { 0xF0, 0x60, 0xD0, 0xF0, 0x60, 0xB0, 0xB0, 0xE0, 0xF0, 0xF0 }; static const uint8_t kSmallLow[10] = { 0xA0, 0x00, 0x60, 0x40, 0xC0, 0xC0, 0xE0, 0x00, 0xE0, 0xC0 }; struct MainGlyph { uint8_t high; uint8_t low; }; struct SmallGlyph { uint8_t high; uint8_t low; }; static MainGlyph tmate2MainGlyph(QChar ch) { if (ch.isDigit()) { const int digit = ch.digitValue(); return { kMainHigh[digit], kMainLow[digit] }; } switch (ch.toLatin1()) { case 'A': case 'a': return { 0x07, 0x07 }; case 'C': case 'c': return { 0x01, 0x0D }; case 'D': case 'd': return { 0x06, 0x0E }; // lowercase-d: B+C + D+E+G case 'E': case 'e': return { 0x01, 0x0F }; case 'F': case 'f': return { 0x01, 0x07 }; case 'G': case 'g': return { 0x05, 0x0D }; case 'H': case 'h': return { 0x06, 0x07 }; case 'I': case 'i': return { 0x00, 0x04 }; case 'K': case 'k': return { 0x00, 0x0F }; case 'L': case 'l': return { 0x00, 0x0D }; case 'M': case 'm': return { 0x04, 0x06 }; case 'N': case 'n': return { 0x04, 0x06 }; case 'O': case 'o': return { 0x07, 0x0D }; case 'P': case 'p': return { 0x03, 0x07 }; case 'R': case 'r': return { 0x00, 0x06 }; case 'S': case 's': return { 0x05, 0x0B }; case 'T': case 't': return { 0x00, 0x0F }; case 'U': case 'u': return { 0x06, 0x0D }; case 'V': case 'v': return { 0x06, 0x0D }; case 'W': case 'w': return { 0x06, 0x0D }; case 'X': case 'x': return { 0x06, 0x07 }; case 'Y': case 'y': return { 0x06, 0x0B }; case '-': return { 0x00, 0x02 }; case ' ': return { 0x00, 0x00 }; default: return { 0x00, 0x00 }; } } static SmallGlyph tmate2SmallGlyph(QChar ch) { if (ch.isDigit()) { const int digit = ch.digitValue(); return { kSmallHigh[digit], kSmallLow[digit] }; } switch (ch.toLatin1()) { case 'k': case 'K': // Seven-segment approximation: left strokes, center, and lower-right. return { 0x20, 0xE0 }; case '-': return { 0x00, 0x40 }; case ' ': return { 0x00, 0x00 }; default: return { 0x00, 0x00 }; } } // Indicator segment lookup: {byte_index, bitmask}. // Derived from USB captures (session_20260605_051711/segments_*.csv). // Byte range 0-31 only; indicator bits never overlap with digit A-G bits. struct SegEntry { uint8_t byte; uint8_t mask; }; static constexpr SegEntry kSeg_RX = { 0, 0x04 }; static constexpr SegEntry kSeg_TX = { 0, 0x08 }; static constexpr SegEntry kSeg_E1 = { 0, 0x80 }; static constexpr SegEntry kSeg_E2 = { 8, 0x10 }; static constexpr SegEntry kSeg_S = { 0, 0x10 }; static constexpr SegEntry kSeg_VOL = { 1, 0x80 }; static constexpr SegEntry kSeg_RFG = { 2, 0x80 }; static constexpr SegEntry kSeg_SQL = { 3, 0x10 }; static constexpr SegEntry kSeg_DRV = { 4, 0x10 }; static constexpr SegEntry kSeg_NR2 = { 5, 0x10 }; static constexpr SegEntry kSeg_NB2 = { 6, 0x10 }; static constexpr SegEntry kSeg_AN2 = { 7, 0x10 }; static constexpr SegEntry kSeg_SMETER_LINE = { 2, 0x01 }; static constexpr SegEntry kSeg_SMETER_DB_MINUS = {28, 0x10 }; static constexpr SegEntry kSeg_DIG_PLUS = { 21, 0x04 }; static constexpr SegEntry kSeg_DIG_MINUS = { 21, 0x08 }; static constexpr SegEntry kSeg_DSB = { 21, 0x10 }; static constexpr SegEntry kSeg_FM = { 21, 0x20 }; static constexpr SegEntry kSeg_USB = { 21, 0x40 }; static constexpr SegEntry kSeg_SAM = { 21, 0x80 }; static constexpr SegEntry kSeg_DIG = { 22, 0x02 }; static constexpr SegEntry kSeg_DBM = { 22, 0x10 }; static constexpr SegEntry kSeg_CW = { 22, 0x20 }; static constexpr SegEntry kSeg_LSB = { 22, 0x40 }; static constexpr SegEntry kSeg_AM = { 22, 0x80 }; static constexpr SegEntry kSeg_DOT1 = { 9, 0x10 }; // after digit 3 (kHz/Hz) static constexpr SegEntry kSeg_DOT2 = { 15, 0x10 }; // after digit 6 (MHz/kHz) static constexpr SegEntry kSeg_HZ = { 23, 0x01 }; static constexpr SegEntry kSeg_W = { 20, 0x20 }; static constexpr SegEntry kSeg_LOW = { 11, 0x10 }; static constexpr SegEntry kSeg_HIGH = { 10, 0x10 }; static constexpr SegEntry kSeg_SHIFT = { 12, 0x10 }; static constexpr SegEntry kSeg_RIT = { 13, 0x10 }; static constexpr SegEntry kSeg_XIT = { 14, 0x10 }; static constexpr SegEntry kSeg_SMETER_1 = { 1, 0x10 }; static constexpr SegEntry kSeg_SMETER_3 = { 2, 0x10 }; static constexpr SegEntry kSeg_SMETER_5 = { 2, 0x08 }; static constexpr SegEntry kSeg_SMETER_7 = { 2, 0x04 }; static constexpr SegEntry kSeg_SMETER_9 = { 2, 0x02 }; static constexpr SegEntry kSeg_SMETER_PLUS20 = { 9, 0x20 }; static constexpr SegEntry kSeg_SMETER_PLUS40 = { 15, 0x20 }; static constexpr SegEntry kSeg_SMETER_PLUS60 = { 18, 0x20 }; static constexpr SegEntry kSeg_SMETER_20 = { 10, 0x20 }; static constexpr SegEntry kSeg_SMETER_40 = { 16, 0x20 }; static constexpr SegEntry kSeg_SMETER_60 = { 19, 0x20 }; // 15-segment S-meter bargraph (BAR1=weakest, BAR15=strongest) static constexpr SegEntry kSMeterBars[15] = { { 1, 0x08 }, { 1, 0x04 }, { 1, 0x02 }, // BAR1-3 { 31, 0x80 }, { 31, 0x40 }, { 31, 0x20 }, { 31, 0x10 }, // BAR4-7 { 30, 0x10 }, { 30, 0x20 }, { 30, 0x40 }, { 30, 0x80 }, // BAR8-11 { 29, 0x80 }, { 29, 0x40 }, { 29, 0x20 }, { 29, 0x10 }, // BAR12-15 }; // Set or clear one segment bit in the LCDVector. static void tmate2Seg(uint8_t* v, SegEntry s, bool on) { if (on) v[s.byte] |= s.mask; else v[s.byte] &= static_cast(~s.mask); } // Convert dBm to S-meter bargraph bar count (0-15). // S1(-121 dBm)=1, S9(-73 dBm)=9; above S9: +10 dB per bar up to S9+60 dB(=15). static int smeterBars(float dbm) { if (dbm < -121.0f) return 0; if (dbm <= -73.0f) return static_cast((dbm + 121.0f) / 6.0f) + 1; return std::min(15, 9 + static_cast((dbm + 73.0f) / 10.0f)); } static int powerBars(float watts, float fullScaleWatts) { if (!std::isfinite(watts) || !std::isfinite(fullScaleWatts) || fullScaleWatts <= 0.0f) return 0; const float clampedWatts = std::clamp(watts, 0.0f, fullScaleWatts); return std::clamp(static_cast(std::lround((clampedWatts / fullScaleWatts) * 15.0f)), 0, 15); } static void setSmeterScaleSegs(uint8_t* lcd, bool on) { tmate2Seg(lcd, kSeg_SMETER_1, on); tmate2Seg(lcd, kSeg_SMETER_3, on); tmate2Seg(lcd, kSeg_SMETER_5, on); tmate2Seg(lcd, kSeg_SMETER_7, on); tmate2Seg(lcd, kSeg_SMETER_9, on); tmate2Seg(lcd, kSeg_SMETER_PLUS20, on); tmate2Seg(lcd, kSeg_SMETER_PLUS40, on); tmate2Seg(lcd, kSeg_SMETER_PLUS60, on); tmate2Seg(lcd, kSeg_SMETER_20, on); tmate2Seg(lcd, kSeg_SMETER_40, on); tmate2Seg(lcd, kSeg_SMETER_60, on); } static void clearTMate2EncoderActionSegs(uint8_t* lcd) { tmate2Seg(lcd, kSeg_E1, false); tmate2Seg(lcd, kSeg_E2, false); tmate2Seg(lcd, kSeg_VOL, false); tmate2Seg(lcd, kSeg_RFG, false); tmate2Seg(lcd, kSeg_SQL, false); tmate2Seg(lcd, kSeg_DRV, false); tmate2Seg(lcd, kSeg_NR2, false); tmate2Seg(lcd, kSeg_NB2, false); tmate2Seg(lcd, kSeg_AN2, false); tmate2Seg(lcd, kSeg_LOW, false); tmate2Seg(lcd, kSeg_HIGH, false); tmate2Seg(lcd, kSeg_SHIFT, false); } static void setTMate2EncoderActionSegs(uint8_t* lcd, const QString& encoder1Action, const QString& encoder2Action) { clearTMate2EncoderActionSegs(lcd); const bool e1Assigned = !encoder1Action.isEmpty() && encoder1Action != QLatin1String("None"); const bool e2Assigned = !encoder2Action.isEmpty() && encoder2Action != QLatin1String("None"); tmate2Seg(lcd, kSeg_E1, e1Assigned); tmate2Seg(lcd, kSeg_E2, e2Assigned); // E1 has the left-side function words from the TMate 2 silkscreen. tmate2Seg(lcd, kSeg_VOL, encoder1Action == QLatin1String("WheelVolume") || encoder1Action == QLatin1String("WheelMasterAf") || encoder1Action == QLatin1String("WheelSliceAudio") || encoder1Action == QLatin1String("WheelHeadphoneVolume")); tmate2Seg(lcd, kSeg_DRV, encoder1Action == QLatin1String("WheelPower")); tmate2Seg(lcd, kSeg_SQL, encoder1Action == QLatin1String("WheelAgcT")); tmate2Seg(lcd, kSeg_RFG, false); tmate2Seg(lcd, kSeg_NR2, false); tmate2Seg(lcd, kSeg_NB2, false); tmate2Seg(lcd, kSeg_AN2, false); // E2 has only the right-side function words. tmate2Seg(lcd, kSeg_HIGH, encoder2Action == QLatin1String("WheelPower") || encoder2Action == QLatin1String("WheelAgcT") || encoder2Action == QLatin1String("WheelApf")); tmate2Seg(lcd, kSeg_LOW, false); tmate2Seg(lcd, kSeg_SHIFT, encoder2Action == QLatin1String("WheelFrequency")); } static void applyModeSegs(uint8_t* lcd, const QString& mode) { tmate2Seg(lcd, kSeg_USB, false); tmate2Seg(lcd, kSeg_LSB, false); tmate2Seg(lcd, kSeg_AM, false); tmate2Seg(lcd, kSeg_SAM, false); tmate2Seg(lcd, kSeg_DSB, false); tmate2Seg(lcd, kSeg_FM, false); tmate2Seg(lcd, kSeg_CW, false); tmate2Seg(lcd, kSeg_DIG, false); tmate2Seg(lcd, kSeg_DIG_PLUS, false); tmate2Seg(lcd, kSeg_DIG_MINUS, false); if (mode == "USB") tmate2Seg(lcd, kSeg_USB, true); else if (mode == "LSB") tmate2Seg(lcd, kSeg_LSB, true); else if (mode == "AM") tmate2Seg(lcd, kSeg_AM, true); else if (mode == "SAM") tmate2Seg(lcd, kSeg_SAM, true); else if (mode == "DSB") tmate2Seg(lcd, kSeg_DSB, true); else if (mode == "FM" || mode == "DFM") tmate2Seg(lcd, kSeg_FM, true); else if (mode == "CW" || mode == "CWL" || mode == "CWU") tmate2Seg(lcd, kSeg_CW, true); else if (mode == "DIGU") { tmate2Seg(lcd, kSeg_DIG, true); tmate2Seg(lcd, kSeg_DIG_PLUS, true); } else if (mode == "DIGL") { tmate2Seg(lcd, kSeg_DIG, true); tmate2Seg(lcd, kSeg_DIG_MINUS, true); } else if (mode == "RTTY" || mode.startsWith("DIG")) { tmate2Seg(lcd, kSeg_DIG, true); } } // LCDVector byte offsets (matches OpenTMate2Lib and Delphi LCD_SEGMENT_DEF) static constexpr int kTM2_LED = 32; static constexpr int kTM2_BL_R = 33; static constexpr int kTM2_BL_G = 34; static constexpr int kTM2_BL_B = 35; static constexpr int kTM2_CONTRAST = 36; static constexpr int kTM2_REFRESH = 37; static constexpr int kTM2_SPEED1 = 38; static constexpr int kTM2_SPEED2 = 39; static constexpr int kTM2_SPEED3 = 40; static constexpr int kTM2_THR12 = 41; static constexpr int kTM2_THR23 = 42; static constexpr int kTM2_EVAL = 43; // Write frequency (Hz) to bytes 3..20 of the LCDVector. // Only the seven A-G segment bits per digit are touched; indicator bits that // share those bytes (underlines, DRV, NR2, …) are preserved. static void tmate2WriteMainDisplay(uint8_t* v, uint32_t hz) { if (hz > 999999999u) hz = 999999999u; for (int d = 1; d <= 9; ++d) { uint8_t hi = static_cast(22 - 2 * d); uint8_t lo = static_cast(21 - 2 * d); v[hi] &= 0xF8u; v[lo] &= 0xF0u; if (hz == 0u && d > 1) continue; v[hi] |= kMainHigh[hz % 10u]; v[lo] |= kMainLow [hz % 10u]; hz /= 10u; } } static void tmate2WriteMainDisplayText(uint8_t* v, const QString& text) { const QString clean = text.left(9).rightJustified(9, QLatin1Char(' ')); for (int d = 1; d <= 9; ++d) { uint8_t hi = static_cast(22 - 2 * d); uint8_t lo = static_cast(21 - 2 * d); const MainGlyph glyph = tmate2MainGlyph(clean.at(9 - d)); v[hi] &= 0xF8u; v[lo] &= 0xF0u; v[hi] |= glyph.high; v[lo] |= glyph.low; } } // Write a value (mod 1000) to bytes 23..28 of the LCDVector. static void tmate2WriteSmallDisplay(uint8_t* v, uint32_t val) { val %= 1000u; for (int d = 1; d <= 3; ++d) { uint8_t hi = static_cast(21 + 2 * d); uint8_t lo = static_cast(22 + 2 * d); v[hi] &= 0x0Fu; v[lo] &= 0x1Fu; if (val == 0u && d > 1) continue; v[hi] |= kSmallHigh[val % 10u]; v[lo] |= kSmallLow [val % 10u]; val /= 10u; } } static void tmate2WriteSmallDisplayText(uint8_t* v, const QString& text) { const QString clean = text.left(3).rightJustified(3, QLatin1Char(' ')); for (int d = 1; d <= 3; ++d) { uint8_t hi = static_cast(21 + 2 * d); uint8_t lo = static_cast(22 + 2 * d); const SmallGlyph glyph = tmate2SmallGlyph(clean.at(3 - d)); v[hi] &= 0x0Fu; v[lo] &= 0x1Fu; v[hi] |= glyph.high; v[lo] |= glyph.low; } } } // namespace namespace AetherSDR { // HID logging now uses lcDevices from LogManager (shared with serial, FlexControl, MIDI) // RC-28 mapping is stored as one nested-JSON blob under "RC28Mapping" // (Principle V / Principle XIV). Reads default-fill missing fields; writes // regenerate the full object and persist atomically (single setValue+save). QString HidEncoderManager::rc28MappingField(const QString& field, const QString& dflt) { const QByteArray raw = AppSettings::instance().value("RC28Mapping", "{}").toString().toUtf8(); const QJsonObject obj = QJsonDocument::fromJson(raw).object(); const QJsonValue v = obj.value(field); return v.isString() ? v.toString() : dflt; } void HidEncoderManager::setRc28MappingField(const QString& field, const QString& value) { auto& s = AppSettings::instance(); QJsonObject obj = QJsonDocument::fromJson(s.value("RC28Mapping", "{}").toString().toUtf8()).object(); obj.insert(field, value); s.setValue("RC28Mapping", QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact))); s.save(); } HidEncoderManager::HidEncoderManager(QObject* parent) : QObject(parent) { hid_init(); m_pollTimer = new QTimer(this); m_pollTimer->setInterval(POLL_INTERVAL_MS); connect(m_pollTimer, &QTimer::timeout, this, &HidEncoderManager::poll); m_hotplugTimer = new QTimer(this); m_hotplugTimer->setInterval(HOTPLUG_INTERVAL_MS); connect(m_hotplugTimer, &QTimer::timeout, this, &HidEncoderManager::hotplugCheck); } HidEncoderManager::~HidEncoderManager() { close(); hid_exit(); } QString HidEncoderManager::detectDevice() { const auto* devices = HidDeviceParser::supportedDevices(); int count = HidDeviceParser::supportedDeviceCount(); for (int i = 0; i < count; ++i) { auto* info = hid_enumerate(devices[i].vid, devices[i].pid); if (info) { QString name = devices[i].name; hid_free_enumeration(info); return name; } } return {}; } bool HidEncoderManager::open(uint16_t vid, uint16_t pid) { if (m_device) close(); // Block if more than one RC-28-compatible device is connected — interleaved // events from two encoders would produce unpredictable tuning behaviour. // macOS reports each HID usage collection as a separate enumeration entry, so // counting raw entries would over-count a single device. We group by a stable // physical-device key: the USB serial number when present, otherwise the // hidapi path. The RC-28 exposes no serial, but all usage-collection entries // of one physical interface share the same path, while two separate devices // get distinct paths — so the path fallback distinguishes them correctly. if (isRC28CompatibleId(vid, pid)) { bool multiplePhysical = false; QString firstKey; bool firstSeen = false; if (auto* info = hid_enumerate(vid, pid)) { for (auto* cur = info; cur; cur = cur->next) { const QString key = (cur->serial_number && cur->serial_number[0] != L'\0') ? QStringLiteral("sn:") + QString::fromWCharArray(cur->serial_number) : QStringLiteral("path:") + QString::fromLatin1(cur->path ? cur->path : ""); if (!firstSeen) { firstKey = key; firstSeen = true; } else if (key != firstKey) { multiplePhysical = true; break; } } hid_free_enumeration(info); } if (multiplePhysical) { const auto* devices = HidDeviceParser::supportedDevices(); int count = HidDeviceParser::supportedDeviceCount(); QString name; for (int i = 0; i < count; ++i) { if (devices[i].vid == vid && devices[i].pid == pid) { name = devices[i].name; break; } } // open() is retried every hotplug tick while two devices remain, so // warn + emit only on the transition into the blocked state. if (!m_multipleDetected.load(std::memory_order_acquire)) { // Write the name before the release store so the main thread // sees a valid QString when it reads m_multipleDetected as true. m_blockedDeviceName = name; m_multipleDetected.store(true, std::memory_order_release); qCWarning(lcDevices) << "HidEncoderManager: multiple" << name << "devices detected — blocking until only one is present"; emit multipleDevicesDetected(name); } return false; } m_blockedDeviceName.clear(); m_multipleDetected.store(false, std::memory_order_release); } m_device = hid_open(vid, pid, nullptr); if (!m_device) { qCDebug(lcDevices) << "HidEncoderManager: failed to open" << QString("0x%1:0x%2").arg(vid, 4, 16, QChar('0')).arg(pid, 4, 16, QChar('0')); return false; } hid_set_nonblocking(m_device, 1); // Capture device info via enumerate — works on all hidapi versions unlike // hid_get_device_info() which requires >= 0.13.0. (#3323) if (auto* info = hid_enumerate(vid, pid)) { m_devicePath = QString::fromLatin1(info->path ? info->path : ""); m_serialNumber = info->serial_number ? QString::fromWCharArray(info->serial_number) : QString{}; m_releaseNumber = info->release_number; hid_free_enumeration(info); } m_parser = HidDeviceParser::create(vid, pid); if (!m_parser) { qCWarning(lcDevices) << "HidEncoderManager: no parser for" << QString("0x%1:0x%2").arg(vid, 4, 16, QChar('0')).arg(pid, 4, 16, QChar('0')); m_devicePath.clear(); m_serialNumber.clear(); m_releaseNumber = 0; hid_close(m_device); m_device = nullptr; return false; } m_openVid = vid; m_openPid = pid; m_hotplugTimer->stop(); // Find device name const auto* devices = HidDeviceParser::supportedDevices(); int count = HidDeviceParser::supportedDeviceCount(); for (int i = 0; i < count; ++i) { if (devices[i].vid == vid && devices[i].pid == pid) { m_deviceName = devices[i].name; break; } } m_pollTimer->start(); // Initialise TMate 2 LCDVector state and push a blank display. // Timing defaults from OpenTMate2Lib protocol docs (captures 2026-06-05). // Backlight is restored from AppSettings (saved by Preferences dialog). if (isTMate2()) { std::memset(m_lcdVector, 0, sizeof(m_lcdVector)); // Matches the original Delphi app / TMate2Probe captures. 0x28 here // overdrives the LCD on some units and can make the display unreadable. m_lcdVector[kTM2_CONTRAST] = 0x00; m_lcdVector[kTM2_REFRESH] = 0x28; m_lcdVector[kTM2_SPEED1] = 0x01; m_lcdVector[kTM2_SPEED2] = 0x05; m_lcdVector[kTM2_SPEED3] = 0x0A; m_lcdVector[kTM2_THR12] = 0x0F; m_lcdVector[kTM2_THR23] = 0x19; m_lcdVector[kTM2_EVAL] = 0x0A; const auto& s = AppSettings::instance(); m_lcdVector[kTM2_BL_R] = static_cast(s.value("TMate2BacklightR", "0").toInt()); m_lcdVector[kTM2_BL_G] = static_cast(s.value("TMate2BacklightG", "50").toInt()); m_lcdVector[kTM2_BL_B] = static_cast(s.value("TMate2BacklightB", "255").toInt()); // Always-on static indicators for a frequency display tmate2Seg(m_lcdVector, kSeg_SMETER_LINE, true); tmate2Seg(m_lcdVector, kSeg_DOT1, true); tmate2Seg(m_lcdVector, kSeg_DOT2, true); tmate2Seg(m_lcdVector, kSeg_HZ, true); tmate2Seg(m_lcdVector, kSeg_VOL, true); sendTMate2(); } qCDebug(lcDevices) << "HidEncoderManager: opened" << m_deviceName << QString("0x%1:0x%2").arg(vid, 4, 16, QChar('0')).arg(pid, 4, 16, QChar('0')); emit connectionChanged(true, m_deviceName); return true; } void HidEncoderManager::close() { m_pollTimer->stop(); m_hotplugTimer->stop(); if (m_device) { // Extinguish RC-28 LEDs / TMate 2 backlight on clean close. // hid_write may return EIO on surprise-disconnect; safe to ignore. setRC28Leds(RC28_LEDS_OFF); setTMate2Backlight(0, 0, 0); hid_close(m_device); m_device = nullptr; } m_parser.reset(); if (!m_deviceName.isEmpty()) { qCDebug(lcDevices) << "HidEncoderManager: closed" << m_deviceName; m_deviceName.clear(); m_devicePath.clear(); m_serialNumber.clear(); m_releaseNumber = 0; emit connectionChanged(false, {}); } } void HidEncoderManager::poll() { if (!m_device || !m_parser) return; // Read all pending reports while (true) { int res = hid_read(m_device, m_buf, m_parser->reportSize()); if (res < 0) { // Device disconnected qCDebug(lcDevices) << "HidEncoderManager: device disconnected, starting hotplug"; close(); m_hotplugTimer->start(); return; } if (res == 0) break; // no more data auto event = m_parser->parse(m_buf, static_cast(res)); switch (event.type) { case HidEvent::Rotate: emit tuneSteps(event.encoderIndex, m_invertDirection ? -event.steps : event.steps); break; case HidEvent::Button: emit buttonPressed(event.button, event.action); break; case HidEvent::None: break; } } } void HidEncoderManager::hotplugCheck() { if (m_device) { m_hotplugTimer->stop(); return; } if (m_openVid && m_openPid) { if (open(m_openVid, m_openPid)) m_hotplugTimer->stop(); return; } // No VID/PID recorded: device was never opened (started without encoder // attached). Scan all supported devices so a late-connect is picked up. const auto* devices = HidDeviceParser::supportedDevices(); int count = HidDeviceParser::supportedDeviceCount(); for (int i = 0; i < count; ++i) { if (open(devices[i].vid, devices[i].pid)) { m_hotplugTimer->stop(); return; } } } void HidEncoderManager::setKeyImages(const QVector& jpegImages) { for (int i = 0; i < jpegImages.size(); ++i) setKeyImage(i, jpegImages[i]); } void HidEncoderManager::setKeyImage(int key, const QByteArray& jpegData) { if (!m_device || !isStreamDeckPlus()) return; // StreamDeck+ LCD image write: 1024-byte feature reports (report ID 0x02), // command 0x07 (set key image). Protocol verified against python-elgato-streamdeck. constexpr int PACKET_SIZE = 1024; constexpr int HEADER_SIZE = 8; constexpr int PAYLOAD_SIZE = PACKET_SIZE - HEADER_SIZE; const int totalBytes = jpegData.size(); int offset = 0; int pageNumber = 0; while (offset < totalBytes) { uint8_t pkt[PACKET_SIZE] = {}; const int chunkLen = std::min(PAYLOAD_SIZE, totalBytes - offset); const bool isLast = (offset + chunkLen >= totalBytes); pkt[0] = 0x02; // report ID pkt[1] = 0x07; // command: set key image pkt[2] = static_cast(key); pkt[3] = isLast ? 1 : 0; pkt[4] = static_cast(chunkLen & 0xFF); pkt[5] = static_cast((chunkLen >> 8) & 0xFF); pkt[6] = static_cast(pageNumber & 0xFF); pkt[7] = static_cast((pageNumber >> 8) & 0xFF); std::memcpy(pkt + HEADER_SIZE, jpegData.constData() + offset, chunkLen); // Bail on write failure so we don't spin through the remaining packets // writing into a dead handle. The next poll() will catch the bad // handle via hid_read() < 0 and trigger close() + hotplug reopen, // which correlates the user-visible "deck went blank" with logs. (#3248) const int written = hid_write(m_device, pkt, PACKET_SIZE); if (written < 0) { qCWarning(lcDevices) << "HidEncoderManager::setKeyImage: hid_write failed" << "key=" << key << "page=" << pageNumber << "— device disconnected? Will retry on hotplug."; return; } offset += chunkLen; pageNumber++; } } void HidEncoderManager::setTouchscreenImage(const QByteArray& jpegData, int x_pos, int y_pos, int width, int height) { if (!m_device || !isStreamDeckPlus()) return; // Touchscreen write: 1024-byte packets, 16-byte header, command 0x0c. // Protocol verified against python-elgato-streamdeck StreamDeckPlus.set_touchscreen_image(). constexpr int PACKET_SIZE = 1024; constexpr int HEADER_SIZE = 16; constexpr int PAYLOAD_SIZE = PACKET_SIZE - HEADER_SIZE; const int totalBytes = jpegData.size(); int offset = 0; int pageNumber = 0; while (offset < totalBytes) { uint8_t pkt[PACKET_SIZE] = {}; const int chunkLen = std::min(PAYLOAD_SIZE, totalBytes - offset); const bool isLast = (offset + chunkLen >= totalBytes); pkt[0] = 0x02; pkt[1] = 0x0c; pkt[2] = static_cast(x_pos & 0xff); pkt[3] = static_cast((x_pos >> 8) & 0xff); pkt[4] = static_cast(y_pos & 0xff); pkt[5] = static_cast((y_pos >> 8) & 0xff); pkt[6] = static_cast(width & 0xff); pkt[7] = static_cast((width >> 8) & 0xff); pkt[8] = static_cast(height & 0xff); pkt[9] = static_cast((height >> 8) & 0xff); pkt[10] = isLast ? 1 : 0; pkt[11] = static_cast(pageNumber & 0xff); pkt[12] = static_cast((pageNumber >> 8) & 0xff); pkt[13] = static_cast(chunkLen & 0xff); pkt[14] = static_cast((chunkLen >> 8) & 0xff); pkt[15] = 0x00; std::memcpy(pkt + HEADER_SIZE, jpegData.constData() + offset, chunkLen); // Same bail-on-failure pattern as setKeyImage above. (#3248) const int written = hid_write(m_device, pkt, PACKET_SIZE); if (written < 0) { qCWarning(lcDevices) << "HidEncoderManager::setTouchscreenImage: hid_write failed" << "page=" << pageNumber << "— device disconnected? Will retry on hotplug."; return; } offset += chunkLen; pageNumber++; } } void HidEncoderManager::sendTMate2() { // Build the 64-byte output report from the persistent LCDVector state. // Bytes 0-43 = LCDVector (segments, status, backlight, timing). // Bytes 44-63 = zero padding required by the protocol. uint8_t report[64] = {}; std::memcpy(report, m_lcdVector, sizeof(m_lcdVector)); // hidapi write buffers include a leading report ID byte. The TMate 2 OUT // payload captured on USB is the 64-byte LCDVector report itself, so prepend // report ID 0 to avoid shifting the vector left by one byte on Windows. uint8_t hidReport[65] = {}; std::memcpy(hidReport + 1, report, sizeof(report)); const int written = hid_write(m_device, hidReport, sizeof(hidReport)); if (written < 0) { qCWarning(lcDevices) << "HidEncoderManager::sendTMate2: hid_write failed"; } } void HidEncoderManager::setTMate2Backlight(uint8_t r, uint8_t g, uint8_t b) { if (!m_device || !isTMate2()) return; m_lcdVector[kTM2_BL_R] = r; m_lcdVector[kTM2_BL_G] = g; m_lcdVector[kTM2_BL_B] = b; sendTMate2(); } void HidEncoderManager::setTMate2Status(uint8_t led_byte) { if (!m_device || !isTMate2()) return; m_lcdVector[kTM2_LED] = led_byte; sendTMate2(); } void HidEncoderManager::setTMate2Display(uint32_t freq_hz, uint32_t small_val) { if (!m_device || !isTMate2()) return; tmate2WriteMainDisplay(m_lcdVector, freq_hz); tmate2WriteSmallDisplay(m_lcdVector, small_val); sendTMate2(); } void HidEncoderManager::setTMate2DisplayText(uint32_t freq_hz, const QString& small_text) { if (!m_device || !isTMate2()) return; tmate2WriteMainDisplay(m_lcdVector, freq_hz); tmate2WriteSmallDisplayText(m_lcdVector, small_text); sendTMate2(); } void HidEncoderManager::setTMate2OverlayDisplay(const QString& main_text) { if (!m_device || !isTMate2()) return; tmate2WriteMainDisplayText(m_lcdVector, main_text); tmate2WriteSmallDisplayText(m_lcdVector, QString()); sendTMate2(); } void HidEncoderManager::setTMate2Indicators(bool tx, const QString& mode, float smeter_dbm, bool rit, bool xit, const QString& encoder1Action, const QString& encoder2Action, float txPowerWatts, float txPowerFullScaleWatts) { if (!m_device || !isTMate2()) return; // RX / TX tmate2Seg(m_lcdVector, kSeg_RX, !tx); tmate2Seg(m_lcdVector, kSeg_TX, tx); tmate2Seg(m_lcdVector, kSeg_W, tx); tmate2Seg(m_lcdVector, kSeg_DBM, !tx); tmate2Seg(m_lcdVector, kSeg_S, !tx); tmate2Seg(m_lcdVector, kSeg_SMETER_LINE, !tx); tmate2Seg(m_lcdVector, kSeg_SMETER_DB_MINUS, !tx && smeter_dbm < 0.0f); setSmeterScaleSegs(m_lcdVector, !tx); setTMate2EncoderActionSegs(m_lcdVector, encoder1Action, encoder2Action); // Static frequency-display decorations (decimal dots + Hz unit). These are // always on in the normal view; re-assert them here — not only in open() — // so they survive after an overlay or idle-blank cleared them. tmate2Seg(m_lcdVector, kSeg_DOT1, true); tmate2Seg(m_lcdVector, kSeg_DOT2, true); tmate2Seg(m_lcdVector, kSeg_HZ, true); // Mode — clear all mode bits first, then set the matching one applyModeSegs(m_lcdVector, mode); // S-meter bargraph — set bars 1..N on, rest off const int bars = tx ? powerBars(txPowerWatts, txPowerFullScaleWatts) : smeterBars(smeter_dbm); for (int i = 0; i < 15; ++i) tmate2Seg(m_lcdVector, kSMeterBars[i], i < bars); // RIT / XIT double as function-label segments for the E1/E2 encoders. const bool ritMapped = encoder1Action == QLatin1String("WheelRit") || encoder2Action == QLatin1String("WheelRit"); const bool xitMapped = encoder1Action == QLatin1String("WheelXit") || encoder2Action == QLatin1String("WheelXit"); tmate2Seg(m_lcdVector, kSeg_RIT, rit || ritMapped); tmate2Seg(m_lcdVector, kSeg_XIT, xit || xitMapped); sendTMate2(); } void HidEncoderManager::setTMate2OverlayIndicators(const QString& overlayType, int overlayValue, const QString& /* mode */) { if (!m_device || !isTMate2()) return; const bool isVolume = overlayType == QLatin1String("volume"); const bool isPower = overlayType == QLatin1String("power"); const bool isSpeed = overlayType == QLatin1String("speed"); const bool isRit = overlayType == QLatin1String("rit"); const bool isXit = overlayType == QLatin1String("xit"); const bool isShift = overlayType == QLatin1String("shift"); const bool isLevel = overlayType == QLatin1String("agc") || overlayType == QLatin1String("apf"); const bool isHzValue = isSpeed || isRit || isXit || isShift; tmate2Seg(m_lcdVector, kSeg_SMETER_LINE, false); setSmeterScaleSegs(m_lcdVector, false); clearTMate2EncoderActionSegs(m_lcdVector); tmate2Seg(m_lcdVector, kSeg_DOT1, false); tmate2Seg(m_lcdVector, kSeg_DOT2, false); tmate2Seg(m_lcdVector, kSeg_VOL, isVolume); tmate2Seg(m_lcdVector, kSeg_W, isPower); tmate2Seg(m_lcdVector, kSeg_HZ, isHzValue); tmate2Seg(m_lcdVector, kSeg_RIT, isRit); tmate2Seg(m_lcdVector, kSeg_XIT, isXit); tmate2Seg(m_lcdVector, kSeg_SHIFT, isShift); tmate2Seg(m_lcdVector, kSeg_SMETER_DB_MINUS, (isRit || isXit || isShift) && overlayValue < 0); tmate2Seg(m_lcdVector, kSeg_DBM, false); tmate2Seg(m_lcdVector, kSeg_S, false); tmate2Seg(m_lcdVector, kSeg_RX, false); tmate2Seg(m_lcdVector, kSeg_TX, false); // Overlays are transient numeric readouts; blank normal mode labels // (USB/LSB/DIG+/...) so they do not visually mix with the feedback value. applyModeSegs(m_lcdVector, QString()); const int bars = (isVolume || isPower || isLevel) ? std::clamp((std::clamp(overlayValue, 0, 100) * 15 + 99) / 100, 0, 15) : 0; for (int i = 0; i < 15; ++i) tmate2Seg(m_lcdVector, kSMeterBars[i], i < bars); sendTMate2(); } void HidEncoderManager::clearTMate2Indicators() { if (!m_device || !isTMate2()) return; tmate2Seg(m_lcdVector, kSeg_RX, false); tmate2Seg(m_lcdVector, kSeg_TX, false); tmate2Seg(m_lcdVector, kSeg_S, false); tmate2Seg(m_lcdVector, kSeg_VOL, false); tmate2Seg(m_lcdVector, kSeg_SMETER_LINE, false); tmate2Seg(m_lcdVector, kSeg_SMETER_DB_MINUS, false); setSmeterScaleSegs(m_lcdVector, false); clearTMate2EncoderActionSegs(m_lcdVector); tmate2Seg(m_lcdVector, kSeg_DOT1, false); tmate2Seg(m_lcdVector, kSeg_DOT2, false); tmate2Seg(m_lcdVector, kSeg_HZ, false); tmate2Seg(m_lcdVector, kSeg_W, false); tmate2Seg(m_lcdVector, kSeg_RIT, false); tmate2Seg(m_lcdVector, kSeg_XIT, false); tmate2Seg(m_lcdVector, kSeg_DBM, false); applyModeSegs(m_lcdVector, QString()); for (const auto& bar : kSMeterBars) tmate2Seg(m_lcdVector, bar, false); sendTMate2(); } void HidEncoderManager::setRC28Leds(uint8_t ledByte) { if (!m_device || !isRC28Compatible()) return; // Output report: [0x00=reportID, 0x01=cmd, ledByte, zeros...], 33 bytes total. // Format verified against FlexRC-28 Node.js driver (_sendLED) and // wfview src/usbcontroller.cpp (RC28 featureLEDControl path). // Active-low: bit0=TX, bit1=F1, bit2=F2, bit3=LINK; 0x0F = all off. uint8_t report[33] = {}; report[0] = 0x00; report[1] = 0x01; report[2] = ledByte; hid_write(m_device, report, sizeof(report)); } void HidEncoderManager::loadSettings() { auto& s = AppSettings::instance(); m_invertDirection = s.value("HidEncoderInvertDir", "False").toString() == "True"; // Callers (MainWindow startup + Preferences OK) gate on HidEncoderEnabled, so // loadSettings() always scans for a device when called. The isOpen() guard // makes repeated calls from Preferences idempotent: invert-dir is refreshed // above, but we skip the scan+open cycle if the device is already connected. // Replacing the old HidEncoderAutoDetect check prevents users who had that // flag set to "False" from getting stuck in a "can't re-enable" state. (#3323) if (isOpen()) return; const auto* devices = HidDeviceParser::supportedDevices(); int count = HidDeviceParser::supportedDeviceCount(); for (int i = 0; i < count; ++i) { if (open(devices[i].vid, devices[i].pid)) return; } // No device found — start hotplug timer to watch for connect m_hotplugTimer->start(); } } // namespace AetherSDR #endif