Compare commits

...

3 Commits

Author SHA1 Message Date
Roberto Viola
2f0b02ff3d Update project.pbxproj 2026-01-01 07:07:34 +01:00
Roberto Viola
62f4425a20 Merge branch 'master' into claude/analyze-ios-bluetooth-crashes-A6x2l 2026-01-01 07:07:00 +01:00
Claude
adb9efee23 Fix iOS Bluetooth crashes by removing nested event loops
This commit eliminates QEventLoop::exec() calls in Bluetooth write
operations for domyostreadmill and wahookickrsnapbike devices, which
were causing crashes on iOS due to nested event loop incompatibilities.

Changes:
- Replaced synchronous loop.exec() with async queue-based system
- Implemented WriteRequest queue with processWriteQueue() method
- Added timeout management with QTimer for async operations
- Updated characteristicWritten/packetReceived handlers for queue processing
- Removed all #ifdef Q_OS_IOS conditionals in wahookickrsnapbike
- Unified Bluetooth handling code across all platforms using pure Qt

The new architecture:
1. writeCharacteristic() enqueues write requests instead of blocking
2. processWriteQueue() handles requests sequentially
3. Completion signals (characteristicWritten/packetReceived) trigger next item
4. Timeout handling prevents queue stalls without blocking main thread

This eliminates the problematic pattern of calling loop.exec() from
within the main event loop, which was causing watchdog timeouts and
crashes on iOS. The solution is cross-platform compatible and improves
responsiveness on all platforms.

Fixes iOS crash issues related to nested event loops in Bluetooth operations.
2025-12-31 11:07:21 +00:00
5 changed files with 202 additions and 76 deletions

View File

@@ -4579,7 +4579,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "../src/ios/qdomyos-zwift.entitlements";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1237;
CURRENT_PROJECT_VERSION = 1238;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = NO;
@@ -4780,7 +4780,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "../src/ios/qdomyos-zwift.entitlements";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1237;
CURRENT_PROJECT_VERSION = 1238;
DEBUG_INFORMATION_FORMAT = dwarf;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
DEVELOPMENT_TEAM = 6335M7T29D;
@@ -5017,7 +5017,7 @@
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1237;
CURRENT_PROJECT_VERSION = 1238;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -5113,7 +5113,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1237;
CURRENT_PROJECT_VERSION = 1238;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = YES;
@@ -5205,7 +5205,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1237;
CURRENT_PROJECT_VERSION = 1238;
DEVELOPMENT_ASSET_PATHS = "\"watchkit Extension/Preview Content\"";
ENABLE_BITCODE = YES;
ENABLE_PREVIEWS = YES;
@@ -5321,7 +5321,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1237;
CURRENT_PROJECT_VERSION = 1238;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "\"watchkit Extension/Preview Content\"";
ENABLE_BITCODE = YES;
@@ -5431,7 +5431,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = QZWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1237;
CURRENT_PROJECT_VERSION = 1238;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -5522,7 +5522,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = QZWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1237;
CURRENT_PROJECT_VERSION = 1238;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_NS_ASSERTIONS = NO;

View File

@@ -73,50 +73,98 @@ domyostreadmill::domyostreadmill(uint32_t pollDeviceTime, bool noConsole, bool n
initDone = false;
connect(refresh, &QTimer::timeout, this, &domyostreadmill::update);
refresh->start(pollDeviceTime);
// Initialize write timeout timer
writeTimeoutTimer = new QTimer(this);
writeTimeoutTimer->setSingleShot(true);
connect(writeTimeoutTimer, &QTimer::timeout, this, [this]() {
qDebug() << QStringLiteral("writeCharacteristic timeout - processing next in queue");
isWriting = false;
currentWriteWaitingForResponse = false;
processWriteQueue();
});
// Connect packetReceived signal to handle wait_for_response = true case
connect(this, &domyostreadmill::packetReceived, this, [this]() {
// Only process if we were waiting for a response
if (currentWriteWaitingForResponse && isWriting) {
// Stop timeout timer
writeTimeoutTimer->stop();
// Mark writing as complete and process next item in queue
isWriting = false;
currentWriteWaitingForResponse = false;
processWriteQueue();
}
});
}
void domyostreadmill::writeCharacteristic(uint8_t *data, uint8_t data_len, const QString &info, bool disable_log,
bool wait_for_response) {
QEventLoop loop;
QTimer timeout;
// Create write request and add to queue
WriteRequest request;
request.data = QByteArray((const char *)data, data_len);
request.info = info;
request.disable_log = disable_log;
request.wait_for_response = wait_for_response;
if (wait_for_response) {
connect(this, &domyostreadmill::packetReceived, &loop, &QEventLoop::quit);
timeout.singleShot(300ms, &loop, &QEventLoop::quit);
} else {
connect(gattCommunicationChannelService, &QLowEnergyService::characteristicWritten, &loop, &QEventLoop::quit);
timeout.singleShot(300ms, &loop, &QEventLoop::quit);
writeQueue.enqueue(request);
// Start processing if not already writing
processWriteQueue();
}
void domyostreadmill::processWriteQueue() {
// If already writing or queue is empty, do nothing
if (isWriting || writeQueue.isEmpty()) {
return;
}
if (gattCommunicationChannelService->state() != QLowEnergyService::ServiceState::ServiceDiscovered ||
// Check connection state
if (!gattCommunicationChannelService ||
gattCommunicationChannelService->state() != QLowEnergyService::ServiceState::ServiceDiscovered ||
m_control->state() == QLowEnergyController::UnconnectedState) {
qDebug() << QStringLiteral("writeCharacteristic error because the connection is closed");
// Clear the queue on disconnection
writeQueue.clear();
isWriting = false;
return;
}
if (!gattWriteCharacteristic.isValid()) {
qDebug() << QStringLiteral("gattWriteCharacteristic is invalid");
// Clear the queue on invalid characteristic
writeQueue.clear();
isWriting = false;
return;
}
// Get next request from queue
WriteRequest request = writeQueue.dequeue();
isWriting = true;
currentWriteWaitingForResponse = request.wait_for_response;
// Update write buffer
if (writeBuffer) {
delete writeBuffer;
}
writeBuffer = new QByteArray((const char *)data, data_len);
writeBuffer = new QByteArray(request.data);
// Write the characteristic
gattCommunicationChannelService->writeCharacteristic(gattWriteCharacteristic, *writeBuffer);
if (!disable_log) {
if (!request.disable_log) {
qDebug() << QStringLiteral(" >> ") + writeBuffer->toHex(' ')
<< QStringLiteral(" // ") + info;
<< QStringLiteral(" // ") + request.info;
}
loop.exec();
// Start timeout timer (300ms as before)
writeTimeoutTimer->start(300);
if (timeout.isActive() == false) {
qDebug() << QStringLiteral(" exit for timeout");
}
// Note: The actual completion will be signaled by:
// - characteristicWritten (if wait_for_response = false)
// - packetReceived (if wait_for_response = true)
// which will call processWriteQueue() again to process the next item
}
void domyostreadmill::updateDisplay(uint16_t elapsed) {
@@ -823,6 +871,17 @@ void domyostreadmill::characteristicWritten(const QLowEnergyCharacteristic &char
const QByteArray &newValue) {
Q_UNUSED(characteristic);
emit debug(QStringLiteral("characteristicWritten ") + newValue.toHex(' '));
// If the current write is NOT waiting for a response, we can process the next one
if (!currentWriteWaitingForResponse) {
// Stop timeout timer
writeTimeoutTimer->stop();
// Mark writing as complete and process next item in queue
isWriting = false;
processWriteQueue();
}
// Otherwise, we need to wait for packetReceived signal
}
void domyostreadmill::serviceScanDone(void) {

View File

@@ -23,6 +23,7 @@
#include <QtCore/qmutex.h>
#include <QtCore/qscopedpointer.h>
#include <QtCore/qtimer.h>
#include <QtCore/qqueue.h>
#include <QDateTime>
#include <QObject>
@@ -43,6 +44,13 @@ class domyostreadmill : public treadmill {
bool changeFanSpeed(uint8_t speed) override;
private:
// Structure for async write queue
struct WriteRequest {
QByteArray data;
QString info;
bool disable_log;
bool wait_for_response;
};
bool sendChangeFanSpeed(uint8_t speed);
double GetSpeedFromPacket(const QByteArray &packet);
double GetInclinationFromPacket(const QByteArray &packet);
@@ -53,6 +61,7 @@ class domyostreadmill : public treadmill {
void btinit(bool startTape);
void writeCharacteristic(uint8_t *data, uint8_t data_len, const QString &info, bool disable_log = false,
bool wait_for_response = false);
void processWriteQueue();
void startDiscover();
volatile bool incompletePackets = false;
bool noConsole = false;
@@ -75,6 +84,12 @@ class domyostreadmill : public treadmill {
bool initDone = false;
bool initRequest = false;
// Async write queue management
QQueue<WriteRequest> writeQueue;
bool isWriting = false;
bool currentWriteWaitingForResponse = false;
QTimer *writeTimeoutTimer = nullptr;
#ifdef Q_OS_IOS
lockscreen *h = 0;
#endif

View File

@@ -32,6 +32,17 @@ wahookickrsnapbike::wahookickrsnapbike(bool noWriteResistance, bool noHeartServi
connect(refresh, &QTimer::timeout, this, &wahookickrsnapbike::update);
QSettings settings;
refresh->start(settings.value(QZSettings::poll_device_time, QZSettings::default_poll_device_time).toInt());
// Initialize write timeout timer
writeTimeoutTimer = new QTimer(this);
writeTimeoutTimer->setSingleShot(true);
connect(writeTimeoutTimer, &QTimer::timeout, this, [this]() {
qDebug() << QStringLiteral("writeCharacteristic timeout - processing next in queue");
isWriting = false;
currentWriteWaitingForResponse = false;
processWriteQueue();
});
wheelCircumference::GearTable g;
g.printTable();
}
@@ -47,47 +58,69 @@ void wahookickrsnapbike::restoreDefaultWheelDiameter() {
bool wahookickrsnapbike::writeCharacteristic(uint8_t *data, uint8_t data_len, QString info, bool disable_log,
bool wait_for_response) {
#ifndef Q_OS_IOS
QEventLoop loop;
QTimer timeout;
// Create write request and add to queue
WriteRequest request;
request.data = QByteArray((const char *)data, data_len);
request.info = info;
request.disable_log = disable_log;
request.wait_for_response = wait_for_response;
if (gattPowerChannelService == nullptr) {
writeQueue.enqueue(request);
// Start processing if not already writing
processWriteQueue();
return true;
}
void wahookickrsnapbike::processWriteQueue() {
// If already writing or queue is empty, do nothing
if (isWriting || writeQueue.isEmpty()) {
return;
}
// Check connection state
if (!gattPowerChannelService) {
qDebug() << QStringLiteral("gattPowerChannelService not found, write skipping...");
return false;
// Clear the queue on disconnection
writeQueue.clear();
isWriting = false;
return;
}
if (wait_for_response) {
connect(gattPowerChannelService, SIGNAL(characteristicChanged(QLowEnergyCharacteristic, QByteArray)), &loop,
SLOT(quit()));
timeout.singleShot(1000, &loop, SLOT(quit()));
} else {
connect(gattPowerChannelService, SIGNAL(characteristicWritten(QLowEnergyCharacteristic, QByteArray)), &loop,
SLOT(quit()));
timeout.singleShot(1000, &loop, SLOT(quit()));
if (!gattWriteCharacteristic.isValid()) {
qDebug() << QStringLiteral("gattWriteCharacteristic is invalid");
// Clear the queue on invalid characteristic
writeQueue.clear();
isWriting = false;
return;
}
#endif
// Get next request from queue
WriteRequest request = writeQueue.dequeue();
isWriting = true;
currentWriteWaitingForResponse = request.wait_for_response;
// Update write buffer
if (writeBuffer) {
delete writeBuffer;
}
writeBuffer = new QByteArray((const char *)data, data_len);
writeBuffer = new QByteArray(request.data);
#ifdef Q_OS_IOS
#ifndef IO_UNDER_QT
iOS_wahooKickrSnapBike->writeCharacteristic((unsigned char*)writeBuffer->data(), data_len);
#endif
#else
// Write the characteristic
gattPowerChannelService->writeCharacteristic(gattWriteCharacteristic, *writeBuffer);
#endif
if (!disable_log)
debug(" >> " + writeBuffer->toHex(' ') + " // " + info);
if (!request.disable_log) {
debug(" >> " + writeBuffer->toHex(' ') + " // " + request.info);
}
#ifndef Q_OS_IOS
loop.exec();
#endif
// Start timeout timer (1000ms as before, longer than domyostreadmill)
writeTimeoutTimer->start(1000);
return true;
// Note: The actual completion will be signaled by:
// - characteristicWritten (if wait_for_response = false)
// - characteristicChanged (if wait_for_response = true)
// which will call processWriteQueue() again to process the next item
}
QByteArray wahookickrsnapbike::unlockCommand() {
@@ -192,12 +225,10 @@ QByteArray wahookickrsnapbike::setWheelCircumference(double millimeters) {
}
void wahookickrsnapbike::update() {
#ifndef Q_OS_IOS
if (m_control->state() == QLowEnergyController::UnconnectedState) {
if (m_control && m_control->state() == QLowEnergyController::UnconnectedState) {
emit disconnected();
return;
}
#endif
QSettings settings;
bool wahooWithoutWheelDiameter = settings.value(QZSettings::wahoo_without_wheel_diameter, QZSettings::default_wahoo_without_wheel_diameter).toBool();
@@ -231,13 +262,8 @@ void wahookickrsnapbike::update() {
Resistance = 0;
emit resistanceRead(Resistance.value());
initRequest = false;
} else if (
#ifndef Q_OS_IOS
bluetoothDevice.isValid() &&
m_control->state() == QLowEnergyController::DiscoveredState
#else
1
#endif
} else if (m_control &&
(bluetoothDevice.isValid() && m_control->state() == QLowEnergyController::DiscoveredState)
//&&
// gattCommunicationChannelService &&
// gattWriteCharacteristic.isValid() &&
@@ -439,6 +465,17 @@ void wahookickrsnapbike::characteristicChanged(const QLowEnergyCharacteristic &c
void wahookickrsnapbike::handleCharacteristicValueChanged(const QBluetoothUuid &uuid, const QByteArray &newValue) {
// qDebug() << "characteristicChanged" << characteristic.uuid() << newValue << newValue.length();
// Handle async write queue - if we were waiting for a response, process next item
if (currentWriteWaitingForResponse && isWriting) {
// Stop timeout timer
writeTimeoutTimer->stop();
// Mark writing as complete and process next item in queue
isWriting = false;
currentWriteWaitingForResponse = false;
processWriteQueue();
}
QSettings settings;
QString heartRateBeltName =
settings.value(QZSettings::heart_rate_belt_name, QZSettings::default_heart_rate_belt_name).toString();
@@ -654,11 +691,9 @@ void wahookickrsnapbike::handleCharacteristicValueChanged(const QBluetoothUuid &
emit debug(QStringLiteral("Current CrankRevs: ") + QString::number(CrankRevs));
emit debug(QStringLiteral("Last CrankEventTime: ") + QString::number(LastCrankEventTime));
#ifndef Q_OS_IOS
if (m_control->error() != QLowEnergyController::NoError) {
if (m_control && m_control->error() != QLowEnergyController::NoError) {
qDebug() << QStringLiteral("QLowEnergyController ERROR!!") << m_control->errorString();
}
#endif
}
void wahookickrsnapbike::stateChanged(QLowEnergyService::ServiceState state) {
@@ -667,7 +702,6 @@ void wahookickrsnapbike::stateChanged(QLowEnergyService::ServiceState state) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceState>();
emit debug(QStringLiteral("BTLE stateChanged ") + QString::fromLocal8Bit(metaEnum.valueToKey(state)));
#ifndef Q_OS_IOS
for (QLowEnergyService *s : qAsConst(gattCommunicationChannelService)) {
qDebug() << QStringLiteral("stateChanged") << s->serviceUuid() << s->state();
if (s->state() != QLowEnergyService::ServiceDiscovered && s->state() != QLowEnergyService::InvalidService) {
@@ -746,7 +780,6 @@ void wahookickrsnapbike::stateChanged(QLowEnergyService::ServiceState state) {
}
}
}
#endif
// ******************************************* virtual bike init *************************************
if (!firstStateChanged && !this->hasVirtualDevice()
@@ -806,6 +839,17 @@ void wahookickrsnapbike::characteristicWritten(const QLowEnergyCharacteristic &c
const QByteArray &newValue) {
Q_UNUSED(characteristic);
emit debug(QStringLiteral("characteristicWritten ") + newValue.toHex(' '));
// If the current write is NOT waiting for a response, we can process the next one
if (!currentWriteWaitingForResponse) {
// Stop timeout timer
writeTimeoutTimer->stop();
// Mark writing as complete and process next item in queue
isWriting = false;
processWriteQueue();
}
// Otherwise, we need to wait for characteristicChanged signal
}
void wahookickrsnapbike::characteristicRead(const QLowEnergyCharacteristic &characteristic,
@@ -824,7 +868,6 @@ void wahookickrsnapbike::serviceScanDone(void) {
m_control->requestConnectionUpdate(c);
#endif
#ifndef Q_OS_IOS
auto services_list = m_control->services();
zwift_found = false;
wahoo_found = false;
@@ -839,8 +882,7 @@ void wahookickrsnapbike::serviceScanDone(void) {
wahoo_found = true;
}
}
#endif
qDebug() << "zwift service found " << zwift_found << "wahoo service found" << wahoo_found;
if(zwift_found && !wahoo_found) {
@@ -926,10 +968,6 @@ void wahookickrsnapbike::deviceDiscovered(const QBluetoothDeviceInfo &device) {
// Modified connected method to handle iOS
bool wahookickrsnapbike::connected() {
#ifdef Q_OS_IOS
return true;
#endif
if (!m_control) {
return false;
}

View File

@@ -21,6 +21,7 @@
#include <QtCore/qmutex.h>
#include <QtCore/qscopedpointer.h>
#include <QtCore/qtimer.h>
#include <QtCore/qqueue.h>
#include <QDateTime>
#include <QObject>
@@ -69,6 +70,13 @@ class wahookickrsnapbike : public bike {
void handleCharacteristicValueChanged(const QBluetoothUuid &uuid, const QByteArray &newValue);
private:
// Structure for async write queue
struct WriteRequest {
QByteArray data;
QString info;
bool disable_log;
bool wait_for_response;
};
QByteArray unlockCommand();
QByteArray setResistanceMode(double resistance);
QByteArray setStandardMode(uint8_t level);
@@ -82,6 +90,7 @@ class wahookickrsnapbike : public bike {
bool writeCharacteristic(uint8_t *data, uint8_t data_len, QString info, bool disable_log = false,
bool wait_for_response = false);
void processWriteQueue();
uint16_t wattsFromResistance(double resistance);
metric ResistanceFromFTMSAccessory;
void startDiscover();
@@ -90,11 +99,16 @@ class wahookickrsnapbike : public bike {
QTimer *refresh;
virtualbike *virtualBike = nullptr;
#ifndef Q_OS_IOS
// Bluetooth LE services and characteristics (unified for all platforms)
QList<QLowEnergyService *> gattCommunicationChannelService;
QLowEnergyService *gattPowerChannelService = nullptr;
QLowEnergyCharacteristic gattWriteCharacteristic;
#endif
// Async write queue management
QQueue<WriteRequest> writeQueue;
bool isWriting = false;
bool currentWriteWaitingForResponse = false;
QTimer *writeTimeoutTimer = nullptr;
uint8_t sec1Update = 0;
QByteArray lastPacket;