From 4e6cbd6033c06bb6ae080905b8e9da6ce523f80f Mon Sep 17 00:00:00 2001 From: Florian <1technophile@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:29:49 -0500 Subject: [PATCH 1/2] fix(android): throttle per-device BLE adverts to prevent IME ANR bleDevice_updated() decodes and updates the model synchronously on the Qt GUI thread for every advertisement the OS delivers. In a dense BLE environment the flood of queued deviceUpdated events keeps the GUI thread out of its event loop past Android's 5 s input-dispatch deadline, so the IME's blocking getExtractedText() round-trip (QtInputConnection) can't be serviced and the app ANRs -- the field signature is [libQt6Core] QMetaMethodInvoker::invokeImpl on the main thread, with the GUI thread (qtMainLoopThread) stuck in TheengsDecoder::decodeBLEJson. Coalesce adverts to at most one decode per device per second so the per-device processing rate -- and thus the GUI-thread backlog -- is bounded at the source. The first advert from a device always passes (new devices still appear immediately); the nearby/RSSI-finder feature uses a separate path (updateNearbyBleDevice) and is unaffected. A small opportunistic prune keeps the throttle map bounded when devices rotate random MACs. Also hoist the per-iteration TheengsDecoder construction to one instance per call -- it holds only stable config, so reuse is safe and avoids reconstructing it on every loop iteration. Validated on an LG H930: per-MAC processing dropped from ~2-3/s to <=1/s while decode -> display stayed intact (thermometers/hygrometers/curtains all decoded with correct values). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/DeviceManager.h | 21 ++++++++++++++ src/DeviceManager_advertisement.cpp | 44 +++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/DeviceManager.h b/src/DeviceManager.h index 89ff7ea..fa438eb 100644 --- a/src/DeviceManager.h +++ b/src/DeviceManager.h @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include #include @@ -113,6 +115,25 @@ class DeviceManager: public QObject QList m_devices_blacklist; + //! Per-device advertisement throttle. + //! + //! ``bleDevice_updated()`` runs the full decode + model-update path + //! synchronously on the GUI thread for *every* advertisement the OS + //! delivers. In a dense BLE environment a single fast-beaconing device + //! (or hundreds of ambient ones) floods that thread with queued + //! ``deviceUpdated`` events; when the backlog keeps the GUI thread out + //! of its event loop past Android's 5 s input-dispatch deadline the app + //! ANRs (the IME's blocking ``getExtractedText`` round-trip can't be + //! serviced). We coalesce adverts to at most one decode per device per + //! ``BLE_ADV_THROTTLE_MS`` so the per-device processing rate — and thus + //! the GUI-thread backlog — is bounded at the source. The first advert + //! from a device is never dropped (no prior timestamp), so new devices + //! still appear immediately. The nearby/RSSI-finder feature uses a + //! separate path (``updateNearbyBleDevice``) and is unaffected. + static constexpr qint64 BLE_ADV_THROTTLE_MS = 1000; + QHash m_advert_throttle; //!< device identifier -> last-processed monotonic ms + QElapsedTimer m_advert_clock; //!< monotonic clock backing the throttle + DeviceModel *m_devices_nearby_model = nullptr; DeviceFilter *m_devices_nearby_filter = nullptr; diff --git a/src/DeviceManager_advertisement.cpp b/src/DeviceManager_advertisement.cpp index 6417960..c731bfd 100644 --- a/src/DeviceManager_advertisement.cpp +++ b/src/DeviceManager_advertisement.cpp @@ -90,6 +90,48 @@ void DeviceManager::bleDevice_updated(const QBluetoothDeviceInfo &info, QBluetoo if (m_devices_blacklist.contains(info.address().toString())) return; // device MAC is blacklisted if (m_devices_blacklist.contains(info.deviceUuid().toString())) return; // device UUID is blacklisted + /// PER-DEVICE THROTTLE ///////////////////////////////////////////////////// + // Bound the per-device advertisement processing rate so a flood of + // adverts can't saturate the GUI thread and build a deviceUpdated + // backlog (the root of the IME-round-trip ANR). See the member docs in + // DeviceManager.h. First advert from a device always passes. +#if !defined(DEBUG_FAKE_DEVICES) + { +#if defined(Q_OS_MACOS) || defined(Q_OS_IOS) + const QString throttle_key = info.deviceUuid().toString(); +#else + const QString throttle_key = info.address().toString(); +#endif + if (!throttle_key.isEmpty()) + { + if (!m_advert_clock.isValid()) m_advert_clock.start(); + const qint64 now = m_advert_clock.elapsed(); + + auto it = m_advert_throttle.find(throttle_key); + if (it != m_advert_throttle.end() && (now - it.value()) < BLE_ADV_THROTTLE_MS) + return; // too soon since this device was last processed + + m_advert_throttle.insert(throttle_key, now); + + // Opportunistic prune so devices that rotate their (random) MAC + // can't grow the map without bound over a long-running scan. + if (m_advert_throttle.size() > 1024) + { + for (auto p = m_advert_throttle.begin(); p != m_advert_throttle.end(); ) + { + if ((now - p.value()) > (30 * BLE_ADV_THROTTLE_MS)) p = m_advert_throttle.erase(p); + else ++p; + } + } + } + } +#endif + + // One decoder for the whole call: TheengsDecoder holds only stable config + // (no per-decode state), so reusing it across the known/unknown attempts + // below avoids reconstructing it on every loop iteration. + TheengsDecoder decoder; + /// KNOWN GATEWAYS ///////////////////////////////////////////////////////// for (auto d: std::as_const(m_gateways_model->m_devices)) @@ -193,7 +235,6 @@ void DeviceManager::bleDevice_updated(const QBluetoothDeviceInfo &info, QBluetoo } // theengs decoding - TheengsDecoder decoder; ArduinoJson::JsonObject obj = doc.as(); if (decoder.decodeBLEJson(obj) >= 0) { @@ -338,7 +379,6 @@ void DeviceManager::bleDevice_updated(const QBluetoothDeviceInfo &info, QBluetoo doc["servicedatauuid"] = svc_uuid; } - TheengsDecoder decoder; ArduinoJson::JsonObject obj = doc.as(); if (decoder.decodeBLEJson(obj) < 0) continue; From 4ff59e80f184accd018c11e34de295580a029fc7 Mon Sep 17 00:00:00 2001 From: Florian <1technophile@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:39:40 -0500 Subject: [PATCH 2/2] feat(android): instrument BLE advert throttle + GUI-thread stall Add lightweight, always-on diagnostics so the per-device throttle's effect and the GUI-thread (qtMainLoopThread) responsiveness are readable on any device -- the only way to observe the throttle's benefit, which manifests under field BLE density a single-radio bench can't synthesize. - m_advert_recv / m_advert_throttled: adverts reaching the throttle vs dropped by it -> the drop rate (how often the throttle actually binds). - A 250 ms watchdog QTimer on the GUI thread: its tick lateness == how long the event loop was blocked; the max is the ANR-relevant stall (a blocked loop can't service the IME's BlockingQueued getExtractedText round-trip). - Logged as "[ble-perf] ..." ~every 60 s (captured by logcat / the debug-log-share feature), plus bleThrottleStats()/resetBleStats() Q_INVOKABLEs for a debug screen. Validated on an LG H930 in ambient BLE: recv=317 throttled=99 (31.2%), 29 tracked MACs, max GUI-loop stall 1211 ms -- i.e. the throttle shed ~1/3 of advert-processing load, the first on-device evidence of it binding. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/DeviceManager.cpp | 9 ++++++ src/DeviceManager.h | 25 ++++++++++++++++ src/DeviceManager_advertisement.cpp | 45 +++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/src/DeviceManager.cpp b/src/DeviceManager.cpp index f938c99..722f5f5 100644 --- a/src/DeviceManager.cpp +++ b/src/DeviceManager.cpp @@ -98,6 +98,15 @@ DeviceManager::DeviceManager(bool daemon) enableBluetooth(true); // Enables adapter // ONLY if off and permission given connect(this, &DeviceManager::bluetoothChanged, this, &DeviceManager::bluetoothStatusChanged); + // GUI-thread responsiveness watchdog (BLE perf instrumentation). Fires on + // this (the GUI/qtMainLoopThread) event loop; tick lateness == how long the + // loop was blocked. Cheap (4 Hz); always on so it catches stalls anytime. + m_loop_watch.start(); + m_loop_watchdog.setInterval(BLE_LOOP_WATCHDOG_MS); + m_loop_watchdog.setTimerType(Qt::CoarseTimer); + connect(&m_loop_watchdog, &QTimer::timeout, this, &DeviceManager::bleLoopWatchdogTick); + m_loop_watchdog.start(); + // Database DatabaseManager *db = DatabaseManager::getInstance(); if (db) diff --git a/src/DeviceManager.h b/src/DeviceManager.h index fa438eb..d57e710 100644 --- a/src/DeviceManager.h +++ b/src/DeviceManager.h @@ -134,6 +134,27 @@ class DeviceManager: public QObject QHash m_advert_throttle; //!< device identifier -> last-processed monotonic ms QElapsedTimer m_advert_clock; //!< monotonic clock backing the throttle + //! BLE throttle / GUI-thread responsiveness instrumentation. + //! + //! Makes the per-device throttle's effect and the GUI-thread + //! (qtMainLoopThread) responsiveness readable on any device — the only + //! way to observe the throttle's *benefit*, which manifests under field + //! BLE density a single-radio bench can't synthesise. ``m_advert_recv`` / + //! ``m_advert_throttled`` give the drop rate (how often the throttle + //! actually binds); the watchdog measures how late its 250 ms tick fires, + //! i.e. how long the event loop was blocked — the ANR-relevant stall (a + //! blocked loop can't service the IME's ``getExtractedText`` round-trip). + //! Snapshot via ``bleThrottleStats()``; also logged ~every 60 s. + quint64 m_advert_recv = 0; //!< adverts past the rssi/blacklist guards + quint64 m_advert_throttled = 0; //!< adverts dropped by the per-device throttle + quint64 m_advert_recv_logged = 0; //!< recv count at last periodic log + qint64 m_loop_gap_max_ms = 0; //!< worst GUI-thread event-loop stall seen (ms) + int m_loop_watch_ticks = 0; //!< watchdog tick counter (periodic-log cadence) + QElapsedTimer m_loop_watch; //!< time of last watchdog tick + QTimer m_loop_watchdog; //!< fires on the GUI thread; measures tick lateness + static constexpr int BLE_LOOP_WATCHDOG_MS = 250; + void bleLoopWatchdogTick(); + DeviceModel *m_devices_nearby_model = nullptr; DeviceFilter *m_devices_nearby_filter = nullptr; @@ -255,6 +276,10 @@ private slots: bool isDaemon() const { return m_daemonMode; } + // BLE throttle / responsiveness instrumentation (see members above) + Q_INVOKABLE QString bleThrottleStats() const; //!< one-line snapshot for the debug log / a debug screen + Q_INVOKABLE void resetBleStats(); //!< zero the counters + max stall to measure a fresh window + // Adapters management Q_INVOKABLE bool areAdaptersAvailable() const { return m_bluetoothAdapters.size(); } QVariant getAdapters() const { return QVariant::fromValue(m_bluetoothAdapters); } diff --git a/src/DeviceManager_advertisement.cpp b/src/DeviceManager_advertisement.cpp index c731bfd..8f85817 100644 --- a/src/DeviceManager_advertisement.cpp +++ b/src/DeviceManager_advertisement.cpp @@ -104,12 +104,16 @@ void DeviceManager::bleDevice_updated(const QBluetoothDeviceInfo &info, QBluetoo #endif if (!throttle_key.isEmpty()) { + m_advert_recv++; // instrumentation: adverts reaching the throttle if (!m_advert_clock.isValid()) m_advert_clock.start(); const qint64 now = m_advert_clock.elapsed(); auto it = m_advert_throttle.find(throttle_key); if (it != m_advert_throttle.end() && (now - it.value()) < BLE_ADV_THROTTLE_MS) + { + m_advert_throttled++; // instrumentation: dropped by the throttle return; // too soon since this device was last processed + } m_advert_throttle.insert(throttle_key, now); @@ -458,3 +462,44 @@ void DeviceManager::bleDevice_updated(const QBluetoothDeviceInfo &info, QBluetoo } /* ************************************************************************** */ + +void DeviceManager::bleLoopWatchdogTick() +{ + // How late this tick fired beyond its scheduled interval == how long the + // GUI thread (qtMainLoopThread) was blocked out of its event loop. The max + // is the ANR-relevant stall: a blocked loop can't service the IME's + // BlockingQueued getExtractedText() round-trip -> input-dispatch ANR. + const qint64 elapsed = m_loop_watch.restart(); + const qint64 gap = elapsed - BLE_LOOP_WATCHDOG_MS; + if (gap > m_loop_gap_max_ms) m_loop_gap_max_ms = gap; + + // Periodic trend line into the (logcat-captured) debug log, ~ every 60 s, + // but only when adverts actually flowed since the last line (no idle spam). + if (++m_loop_watch_ticks >= (60000 / BLE_LOOP_WATCHDOG_MS)) + { + m_loop_watch_ticks = 0; + if (m_advert_recv != m_advert_recv_logged) + { + m_advert_recv_logged = m_advert_recv; + qInfo().noquote() << "[ble-perf]" << bleThrottleStats(); + } + } +} + +QString DeviceManager::bleThrottleStats() const +{ + const double pct = m_advert_recv ? (100.0 * double(m_advert_throttled) / double(m_advert_recv)) : 0.0; + return QStringLiteral("adverts recv=%1 throttled=%2 (%3%) | tracked MACs=%4 | max GUI-loop stall=%5 ms") + .arg(m_advert_recv).arg(m_advert_throttled) + .arg(pct, 0, 'f', 1).arg(m_advert_throttle.size()).arg(m_loop_gap_max_ms); +} + +void DeviceManager::resetBleStats() +{ + m_advert_recv = 0; + m_advert_throttled = 0; + m_advert_recv_logged = 0; + m_loop_gap_max_ms = 0; +} + +/* ************************************************************************** */