diff --git a/Client/BluetoothWrapper.cpp b/Client/BluetoothWrapper.cpp index cd3cb744..18ba7136 100644 --- a/Client/BluetoothWrapper.cpp +++ b/Client/BluetoothWrapper.cpp @@ -1,5 +1,10 @@ #include "BluetoothWrapper.h" +// How many frames one transaction will read before giving up. The device interleaves ACKs and +// unsolicited notifications with the reply we're after, so a handful of unrelated frames is normal; +// this only bounds a device that never sends what we asked for. +static constexpr int MAX_FRAMES_PER_TRANSACTION = 16; + BluetoothWrapper::BluetoothWrapper(std::unique_ptr connector) { this->_connector.swap(connector); @@ -72,7 +77,7 @@ Buffer BluetoothWrapper::sendCommandAndReadResponse(const std::vector& byt this->_connector->send(data.data(), data.size()); // The device replies with an ACK and then the RET frame; unrelated notifications may interleave. - for (int i = 0; i < 16; i++) + for (int i = 0; i < MAX_FRAMES_PER_TRANSACTION; i++) { auto msg = this->_readMessage(); if (msg.dataType == DATA_TYPE::ACK) @@ -92,8 +97,22 @@ Buffer BluetoothWrapper::sendCommandAndReadResponse(const std::vector& byt void BluetoothWrapper::_waitForAck() { - auto msg = this->_readMessage(); - this->_seqNumber = msg.seqNumber; + // The device answers a command with an ACK, but it also pushes unsolicited notifications of its own + // (a state change, a press of the headset's own button) that can arrive first. Reading exactly one + // frame and assuming it was the ACK left the response stream one frame behind for the rest of the + // session, and took _seqNumber from the wrong frame - the device dedupes on that sequence number, so + // it then silently ignored the next command while the UI snapped back to the old value. + // Skipped notifications are still acked back to the device by _readMessage(). + for (int i = 0; i < MAX_FRAMES_PER_TRANSACTION; i++) + { + auto msg = this->_readMessage(); + if (msg.dataType == DATA_TYPE::ACK) + { + this->_seqNumber = msg.seqNumber; + return; + } + } + throw RecoverableException("No ack received from device", true); } CommandSerializer::Message BluetoothWrapper::_readMessage() @@ -119,18 +138,24 @@ CommandSerializer::Message BluetoothWrapper::_readMessage() numRecvd = this->_connector->recv(buf, sizeof(buf)); } - size_t messageStart = 0; - size_t messageEnd = numRecvd; - - for (size_t i = 0; i < numRecvd; i++) + // Every 60/61/62 inside a frame is escaped, so a START_MARKER can only ever begin one. That makes + // resyncing unambiguous, which is what matters after a brief link glitch garbles or truncates a + // chunk: bytes before the first START_MARKER are the tail of a frame we can no longer parse, and + // a second START_MARKER means the frame we were collecting never finished. Both cases resync on + // the new marker here. Previously the first silently concatenated garbage into the message and + // the second threw, in either case leaving the parser stranded mid-stream for the rest of the + // session - every command after that failed and the UI kept snapping back to the old value. + size_t messageStart = ongoingMessage ? 0 : static_cast(numRecvd); + size_t messageEnd = static_cast(numRecvd); + + for (size_t i = 0; i < static_cast(numRecvd); i++) { if (buf[i] == START_MARKER) { - if (ongoingMessage) - { - throw RecoverableException("Invalid: Multiple start markers without an end marker", true); - } + // Whatever we had collected was a truncated frame, not the start of this one. + msgBytes.clear(); messageStart = i + 1; + messageEnd = static_cast(numRecvd); ongoingMessage = true; } else if (ongoingMessage && buf[i] == END_MARKER) @@ -139,13 +164,23 @@ CommandSerializer::Message BluetoothWrapper::_readMessage() ongoingMessage = false; messageFinished = true; // A single recv() can return more than one framed message back-to-back; keep whatever's - // past this message's END_MARKER for the next _waitForAck() call instead of dropping it. + // past this message's END_MARKER for the next read instead of dropping it. this->_leftoverBytes.assign(buf + i + 1, buf + numRecvd); break; } } - msgBytes.insert(msgBytes.end(), buf + messageStart, buf + messageEnd); + if (messageStart < messageEnd) + { + msgBytes.insert(msgBytes.end(), buf + messageStart, buf + messageEnd); + } + + // Bail out on a stream that keeps delivering bytes but never an END_MARKER, so the next read can + // resync on the next START_MARKER instead of growing this buffer forever. + if (msgBytes.size() > MAX_BLUETOOTH_MESSAGE_SIZE) + { + throw RecoverableException("Invalid: message exceeded the maximum size without an end marker", true); + } } while (!messageFinished); auto msg = CommandSerializer::unpackBtMessage(msgBytes); diff --git a/Client/Constants.h b/Client/Constants.h index 91613ca9..e893917b 100644 --- a/Client/Constants.h +++ b/Client/Constants.h @@ -33,15 +33,48 @@ namespace V2Command { inline constexpr unsigned char INIT_REQUEST = 0x00; // payload: 00 00 inline constexpr unsigned char INIT_REPLY = 0x01; // reply: 01 ... (8 bytes total => v2 device) - inline constexpr unsigned char BATTERY_GET = 0x22; // payload: 22 - inline constexpr unsigned char BATTERY_RET = 0x23; // reply: 23 - inline constexpr unsigned char BATTERY_NTFY = 0x25; // notify: 25 + inline constexpr unsigned char BATTERY_GET = 0x22; // payload: 22 + inline constexpr unsigned char BATTERY_RET = 0x23; // reply: 23 + inline constexpr unsigned char BATTERY_NTFY = 0x25; // notify: 25 + // Battery inquiry sub-types (payload byte 1). A device answers only the ones it actually has, so + // these have to be probed. TWS earbuds report DUAL (or DUAL2 on the WF-C5xx/C700N line) plus CASE; + // everything else reports SINGLE. Values and the DUAL byte layout match GadgetBridge's + // SonyProtocolImplV2.encodeBatteryType() / handleBattery(). + inline constexpr unsigned char BATTERY_SUB_SINGLE = 0x00; // RET 23 00 + inline constexpr unsigned char BATTERY_SUB_DUAL2 = 0x01; // WF-C500 / WF-C510 / WF-C700N + inline constexpr unsigned char BATTERY_SUB_DUAL = 0x09; // WF-1000XM4 / WF-1000XM5 / LinkBuds + inline constexpr unsigned char BATTERY_SUB_CASE = 0x0a; // RET 23 0a + // DUAL/DUAL2 reply: 23 . A level of 0 means that earbud isn't + // reporting (docked in the case / powered down) rather than "empty" - treat it as unknown. + // + // The reply can be LONGER than the reference layout, so size checks must be lower bounds, never + // equality. Captured from a WF-1000XM5 (fw 6.1.0), both earbuds out of the case and full: + // send 22 09 -> 23 09 64 00 64 00 64 64 (8 bytes; documented layout is 6) + // send 22 0a -> 23 0a 64 00 1e (5 bytes; documented layout is 4) + // Confirmed on hardware by docking one earbud and watching the frames change: + // right earbud in the case -> 23 09 64 00 00 00 64 64 (byte [4] = R went 0x64 -> 0x00) + // right earbud back out -> 23 09 64 00 64 00 64 64 + // So bytes [2]/[4] really are the L/R levels, as in the reference layout. The trailing bytes stayed + // fixed (0x64 0x64 on 22 09, 0x1e on 22 0a) across that change, which rules them out as per-earbud + // levels but leaves their meaning unknown - don't reuse them without a sample that moves them. inline constexpr unsigned char EQ_GET = 0x56; // payload: 56 00 inline constexpr unsigned char EQ_RET = 0x57; // reply: 57 00 06 inline constexpr unsigned char EQ_SET = 0x58; // preset: 58 00 00 ; custom: 58 00 A0 06 - inline constexpr unsigned char DSEE_GET = 0xe6; // payload: e6 01 - inline constexpr unsigned char DSEE_RET = 0xe7; // reply: e7 01 - inline constexpr unsigned char DSEE_SET = 0xe8; // payload: e8 01 + // AUDIO parameter family. The opcode trio is shared by every audio parameter; the sub-type byte + // (payload[1]) picks which one, so DSEE and the sound-quality mode differ only in that byte. + inline constexpr unsigned char AUDIO_GET = 0xe6; // GET e6 -> RET e7 + inline constexpr unsigned char AUDIO_RET = 0xe7; + inline constexpr unsigned char AUDIO_SET = 0xe8; // SET e8 + inline constexpr unsigned char DSEE_GET = AUDIO_GET; // payload: e6 01 + inline constexpr unsigned char DSEE_RET = AUDIO_RET; // reply: e7 01 + inline constexpr unsigned char DSEE_SET = AUDIO_SET; // payload: e8 01 + // AUDIO sub-types (Sony's AudioInquiredType). Only the ones we actually send are listed. + inline constexpr unsigned char SUB_CONNECTION_MODE = 0x00; // sound quality mode, plain + inline constexpr unsigned char SUB_UPSCALING = 0x01; // DSEE + inline constexpr unsigned char SUB_CONNECTION_MODE_LDAC = 0x02; // same, on LDAC-capable models + // Deliberately NOT sent: 0x05 (CONNECTION_MODE_CLASSIC_AUDIO_LE_AUDIO). Its SET carries a fourth byte + // that also switches the device between Classic and LE Audio, which would drop the link for reasons + // the user didn't ask for. Devices that only answer 0x05 simply report the feature as unsupported. inline constexpr unsigned char FW_GET = 0x04; // payload: 04 02 -> RET 05 02 inline constexpr unsigned char FW_RET = 0x05; inline constexpr unsigned char CODEC_GET = 0x12; // payload: 12 02 -> RET 13 02 @@ -57,6 +90,15 @@ namespace V2Command inline constexpr unsigned char SUB_SPEAK_TO_CHAT = 0x0c; } +// Sound quality mode ("Bluetooth connection quality" in the Sony app) - the value byte of the +// AUDIO CONNECTION_MODE sub-types. Sony's PriorMode enum also has LOW_LATENCY_PRIOR_BETA (0x02), which +// no shipping model exposes in its app, so we don't offer it. +enum class PRIOR_MODE : unsigned char +{ + SOUND_QUALITY = 0x00, + STABLE_CONNECTION = 0x01 +}; + // v2 equalizer preset ids (byte value sent/received at EQ payload[2]). enum class EQ_PRESET : unsigned char { diff --git a/Client/CrossPlatformGUI.cpp b/Client/CrossPlatformGUI.cpp index 993d9a85..0b807d60 100644 --- a/Client/CrossPlatformGUI.cpp +++ b/Client/CrossPlatformGUI.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #define STB_IMAGE_IMPLEMENTATION #define STBI_ONLY_PNG @@ -50,6 +51,7 @@ bool CrossPlatformGUI::performGUIPass() this->_drawEqualizer(); this->_drawDsee(); } + this->_drawSoundQualityMode(); this->_drawOptionalFeatures(); if (!this->_isV2()) this->_drawSurroundControls(); @@ -192,6 +194,8 @@ void CrossPlatformGUI::_pumpConnectionState() try { this->_headphones.requestAmbientState(); } catch (const std::exception&) {} if (this->_isV2()) { + // Lets requestBattery() probe the per-earbud layout first on TWS models (WF-*/LinkBuds). + this->_headphones.setDeviceName(this->_connectedDevice.name); try { this->_headphones.requestBattery(); } catch (const std::exception&) {} try { this->_headphones.requestEqualizer(); } catch (const std::exception&) {} try { this->_headphones.requestDsee(); } catch (const std::exception&) {} @@ -223,6 +227,7 @@ void CrossPlatformGUI::_pumpConnectionState() this->_uiAutoPowerOff = this->_headphones.getAutoPowerOff(); this->_uiSpeakToChat = this->_headphones.getSpeakToChat(); this->_uiAdaptiveVolume = this->_headphones.getAdaptiveVolume(); + this->_uiPrioritizeSoundQuality = this->_headphones.getSoundQualityMode() == PRIOR_MODE::SOUND_QUALITY; } // Poll the button-changeable ASM state so the app reflects changes made on the headphone itself. @@ -262,6 +267,7 @@ void CrossPlatformGUI::_syncUIFromHeadphones() this->_uiAutoPowerOff = this->_headphones.getAutoPowerOff(); this->_uiSpeakToChat = this->_headphones.getSpeakToChat(); this->_uiAdaptiveVolume = this->_headphones.getAdaptiveVolume(); + this->_uiPrioritizeSoundQuality = this->_headphones.getSoundQualityMode() == PRIOR_MODE::SOUND_QUALITY; } std::string CrossPlatformGUI::_resourceBase() @@ -357,9 +363,19 @@ void CrossPlatformGUI::_drawStatusHeader() if (this->_headphones.hasDualBattery()) { - ImGui::Text("Battery L %d%% R %d%%", this->_headphones.getBatteryLeft(), this->_headphones.getBatteryRight()); + // An earbud that isn't reporting (docked in the case / powered down) reads -1; show a dash. + auto budText = [](int level, bool charging) { + char buf[32]; + if (level < 0) snprintf(buf, sizeof(buf), "--"); + else snprintf(buf, sizeof(buf), "%d%%%s", level, charging ? "+" : ""); + return std::string(buf); + }; + ImGui::Text("Battery L %s R %s", + budText(this->_headphones.getBatteryLeft(), this->_headphones.isBatteryLeftCharging()).c_str(), + budText(this->_headphones.getBatteryRight(), this->_headphones.isBatteryRightCharging()).c_str()); if (this->_headphones.getBatteryCase() >= 0) - ImGui::Text("Case %d%%", this->_headphones.getBatteryCase()); + ImGui::Text("Case %d%%%s", this->_headphones.getBatteryCase(), + this->_headphones.isBatteryCaseCharging() ? " (charging)" : ""); } else { @@ -447,6 +463,9 @@ void CrossPlatformGUI::_drawEqualizer() { this->_uiEqPreset = presets[i].val; int p = presets[i].val; + // setEqualizerPreset() re-reads the bands that go with the new preset (the device restores the + // stored custom curve for Manual), so pull them into the sliders once the command lands. + this->_eqBandsNeedSync = true; this->_sendFeatureCommand([this, p]() { this->_headphones.setEqualizerPreset((EQ_PRESET)p); }); } if (sel) ImGui::PopStyleColor(); @@ -492,6 +511,31 @@ void CrossPlatformGUI::_drawDsee() this->_endCard(); } +void CrossPlatformGUI::_drawSoundQualityMode() +{ + if (!this->_headphones.hasSoundQualityMode()) + return; + + if (!this->_beginCard("##sqmode")) { this->_endCard(); return; } + this->_cardTitle("Bluetooth Connection"); + + if (this->_headphones.hasCodec()) + ImGui::TextDisabled("Current codec: %s", this->_headphones.getCodec().c_str()); + + int mode = this->_uiPrioritizeSoundQuality ? 0 : 1; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + if (ImGui::Combo("##sqprio", &mode, "Prioritize Sound Quality\0Prioritize Stable Connection\0\0")) + { + this->_uiPrioritizeSoundQuality = (mode == 0); + PRIOR_MODE v = mode == 0 ? PRIOR_MODE::SOUND_QUALITY : PRIOR_MODE::STABLE_CONNECTION; + this->_sendFeatureCommand([this, v]() { this->_headphones.setSoundQualityMode(v); }); + } + + ImGui::TextDisabled("The headphones reconnect to apply this."); + + this->_endCard(); +} + void CrossPlatformGUI::_drawOptionalFeatures() { const bool any = this->_headphones.hasAutoPowerOff() || this->_headphones.hasSpeakToChat() || @@ -565,6 +609,15 @@ void CrossPlatformGUI::_sendPendingASMChanges() try { this->_featureCommandFuture.get(); } catch (const RecoverableException& e) { if (e.shouldDisconnect) this->_bt.disconnect(); this->_mq.addMessage(e.what()); } catch (const std::exception& e) { this->_mq.addMessage(e.what()); } + + if (this->_eqBandsNeedSync) + { + this->_eqBandsNeedSync = false; + this->_uiEqPreset = (int)(unsigned char)this->_headphones.getEqualizerPreset(); + for (int i = 0; i < 5; ++i) + this->_uiEqBands[i] = this->_headphones.getEqualizerBand(i); + this->_uiClearBass = this->_headphones.getClearBass(); + } } if (this->_sendCommandFuture.valid() && this->_sendCommandFuture.ready()) diff --git a/Client/CrossPlatformGUI.h b/Client/CrossPlatformGUI.h index f396939f..a40008a2 100644 --- a/Client/CrossPlatformGUI.h +++ b/Client/CrossPlatformGUI.h @@ -44,6 +44,7 @@ class CrossPlatformGUI void _drawASMControls(); void _drawEqualizer(); void _drawDsee(); + void _drawSoundQualityMode(); void _drawOptionalFeatures(); void _drawSurroundControls(); @@ -90,10 +91,14 @@ class CrossPlatformGUI int _uiEqPreset = 0; std::array _uiEqBands = { 0, 0, 0, 0, 0 }; int _uiClearBass = 0; + // Set when a preset write is in flight, so the band sliders take the values the device reports for + // that preset once it completes instead of keeping the previous preset's. + bool _eqBandsNeedSync = false; bool _uiDsee = false; int _uiAutoPowerOff = 0; bool _uiSpeakToChat = false; bool _uiAdaptiveVolume = false; + bool _uiPrioritizeSoundQuality = true; int _uiSoundPosition = 0; int _uiVptType = 0; diff --git a/Client/Headphones.cpp b/Client/Headphones.cpp index 54d010f2..0ef6ecf3 100644 --- a/Client/Headphones.cpp +++ b/Client/Headphones.cpp @@ -5,6 +5,7 @@ #include #include #include +#include Headphones::Headphones(BluetoothWrapper& conn) : _conn(conn) { @@ -85,38 +86,119 @@ void Headphones::initDevice() ); } -void Headphones::requestBattery() +void Headphones::setDeviceName(const std::string& name) +{ + std::lock_guard guard(this->_propertyMtx); + this->_deviceName = name; +} + +// True for Sony's true-wireless earbuds, which report a battery per earbud instead of one for the +// whole device. Only used to pick the probe order - an unrecognised name still finds both layouts, +// just one timed-out inquiry slower. +bool Headphones::_looksLikeEarbuds() const +{ + std::string name; + for (char c : this->_deviceName) name.push_back((char)std::tolower((unsigned char)c)); + return name.find("wf-") != std::string::npos || name.find("linkbuds") != std::string::npos; +} + +// Reads the per-earbud (and case) levels. Returns false if the device didn't answer this sub-type or +// answered with nothing usable, so the caller can fall back to the other layout. +bool Headphones::_requestDualBattery(unsigned char subType) { - // Single battery (over-ear and most models): GET 22 00 -> RET 23 00 + Buffer resp; try { - auto resp = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BATTERY_GET, 0x00 }, V2Command::BATTERY_RET, 0x00); - if (resp.size() >= 4) { - std::lock_guard guard(this->_propertyMtx); - this->_batteryLevel = (unsigned char)resp[2]; - this->_batteryCharging = resp[3] == 1; - return; // found a single battery; don't waste time probing the TWS types - } - } catch (...) {} + resp = this->_conn.sendCommandAndReadResponse( + { (char)V2Command::BATTERY_GET, (char)subType }, V2Command::BATTERY_RET, subType); + } catch (const std::exception&) { + return false; + } + if (resp.size() < 6) return false; - // TWS earbuds: dual L/R (22 09 -> 23 09 ) and case (22 0a -> 23 0a ). + const int left = (unsigned char)resp[2]; + const int right = (unsigned char)resp[4]; + // Both zero means neither earbud is reporting - an empty answer, not a pair of flat batteries. + if (left == 0 && right == 0) return false; + + std::lock_guard guard(this->_propertyMtx); + this->_hasDualBattery = true; + this->_batteryLeft = left > 0 ? left : -1; + this->_batteryRight = right > 0 ? right : -1; + this->_batteryLeftCharging = resp[3] == 1; + this->_batteryRightCharging = resp[5] == 1; + // Headline level: the emptier of the two earbuds that are actually reporting. + if (this->_batteryLeft >= 0 && this->_batteryRight >= 0) + this->_batteryLevel = std::min(this->_batteryLeft, this->_batteryRight); + else + this->_batteryLevel = std::max(this->_batteryLeft, this->_batteryRight); + this->_batteryCharging = this->_batteryLeftCharging || this->_batteryRightCharging; + return true; +} + +bool Headphones::_requestSingleBattery(unsigned char subType, int& levelOut, bool& chargingOut) +{ + Buffer resp; try { - auto resp = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BATTERY_GET, 0x09 }, V2Command::BATTERY_RET, 0x09); - if (resp.size() >= 6) { - std::lock_guard guard(this->_propertyMtx); - this->_hasDualBattery = true; - this->_batteryLeft = (unsigned char)resp[2]; - this->_batteryRight = (unsigned char)resp[4]; - this->_batteryLevel = std::min(this->_batteryLeft, this->_batteryRight); + resp = this->_conn.sendCommandAndReadResponse( + { (char)V2Command::BATTERY_GET, (char)subType }, V2Command::BATTERY_RET, subType); + } catch (const std::exception&) { + return false; + } + if (resp.size() < 4) return false; + levelOut = (unsigned char)resp[2]; + chargingOut = resp[3] == 1; + return true; +} + +void Headphones::requestBattery() +{ + // A device only answers the battery sub-types it supports, and an unsupported inquiry costs a + // full recv timeout, so probe the layout this model is likely to use first. Crucially the dual + // probe must come before the single one on earbuds: some TWS models *do* answer the single + // inquiry (with a combined or zero level), and taking that answer would hide the per-earbud + // levels entirely. + if (this->_looksLikeEarbuds()) + { + if (this->_requestDualBattery(V2Command::BATTERY_SUB_DUAL) + || this->_requestDualBattery(V2Command::BATTERY_SUB_DUAL2)) + { + int caseLevel = -1; bool caseCharging = false; + if (this->_requestSingleBattery(V2Command::BATTERY_SUB_CASE, caseLevel, caseCharging)) { + std::lock_guard guard(this->_propertyMtx); + // The case reports 0 while it's disconnected from the buds; keep the last known value. + if (caseLevel > 0) { + this->_batteryCase = caseLevel; + this->_batteryCaseCharging = caseCharging; + } + } + return; } - } catch (...) {} + } - try { - auto resp = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BATTERY_GET, 0x0a }, V2Command::BATTERY_RET, 0x0a); - if (resp.size() >= 4) { - std::lock_guard guard(this->_propertyMtx); - this->_batteryCase = (unsigned char)resp[2]; + // Single battery (over-ear and most other models). + int level = -1; bool charging = false; + if (this->_requestSingleBattery(V2Command::BATTERY_SUB_SINGLE, level, charging)) { + std::lock_guard guard(this->_propertyMtx); + this->_batteryLevel = level; + this->_batteryCharging = charging; + return; + } + + // Unrecognised name that turned out to be earbuds after all. + if (!this->_looksLikeEarbuds()) { + if (this->_requestDualBattery(V2Command::BATTERY_SUB_DUAL) + || this->_requestDualBattery(V2Command::BATTERY_SUB_DUAL2)) + { + int caseLevel = -1; bool caseCharging = false; + if (this->_requestSingleBattery(V2Command::BATTERY_SUB_CASE, caseLevel, caseCharging)) { + std::lock_guard guard(this->_propertyMtx); + if (caseLevel > 0) { + this->_batteryCase = caseLevel; + this->_batteryCaseCharging = caseCharging; + } + } } - } catch (...) {} + } } int Headphones::getBatteryLevel() @@ -133,6 +215,9 @@ bool Headphones::hasDualBattery() { return this->_hasDualBattery; } int Headphones::getBatteryLeft() { return this->_batteryLeft; } int Headphones::getBatteryRight() { return this->_batteryRight; } int Headphones::getBatteryCase() { return this->_batteryCase; } +bool Headphones::isBatteryLeftCharging() { return this->_batteryLeftCharging; } +bool Headphones::isBatteryRightCharging() { return this->_batteryRightCharging; } +bool Headphones::isBatteryCaseCharging() { return this->_batteryCaseCharging; } void Headphones::requestEqualizer() { @@ -171,8 +256,22 @@ void Headphones::setEqualizerPreset(EQ_PRESET preset) (char)static_cast(preset), 0x00 }); - std::lock_guard guard(this->_propertyMtx); - this->_eqPreset = preset; + { + std::lock_guard guard(this->_propertyMtx); + this->_eqPreset = preset; + } + // The band values that go with the new preset live on the device, not here: selecting MANUAL restores + // the custom curve the user last stored on the headphones, and every other preset has its own fixed + // curve. Without this read-back the cached bands stay whatever the *previous* preset had, so switching + // to Manual showed the wrong slider positions. A failed read-back mustn't fail the write that already + // went through - the cached preset above is still correct, only the bands would be stale. + try + { + this->requestEqualizer(); + } + catch (const std::exception&) + { + } } void Headphones::setEqualizerCustom(int clearBass, const std::vector& bands) @@ -250,7 +349,9 @@ void Headphones::requestAmbientState() if (protocolVersion == SonyProtocolVersion::V2) { // GET: 66 17 -> RET: 67 17 01 - auto resp = this->_conn.sendCommandAndReadResponse({ 0x66, 0x17 }, 0x67); + // Match the sub-type too: 0x67 also carries unsolicited notifications with a different layout, + // and accepting one of those parsed / out of unrelated bytes. + auto resp = this->_conn.sendCommandAndReadResponse({ 0x66, 0x17 }, 0x67, 0x17); if (resp.size() >= 7) { bool on = resp[3] != 0; @@ -258,10 +359,17 @@ void Headphones::requestAmbientState() bool voice = resp[5] != 0; int level = (unsigned char)resp[6]; std::lock_guard guard(this->_propertyMtx); - // Update both current and desired so the UI reflects reality and isChanged() stays false. - this->_ambientSoundControl.current = this->_ambientSoundControl.desired = on; - this->_asmLevel.current = this->_asmLevel.desired = ambient ? level : 0; - this->_focusOnVoice.current = this->_focusOnVoice.desired = voice; + // A change the user just made may not be on the wire yet - the UI writes the desired values + // and only then queues the send - and this reply was produced before it. Adopting it would + // both revert the user's choice and clear the pending change (isChanged() goes false), so the + // command would never be sent at all. Leave everything alone until setChanges() has flushed it. + if (this->_ambientSoundControl.isFulfilled() && this->_asmLevel.isFulfilled() && this->_focusOnVoice.isFulfilled()) + { + // Update both current and desired so the UI reflects reality and isChanged() stays false. + this->_ambientSoundControl.current = this->_ambientSoundControl.desired = on; + this->_asmLevel.current = this->_asmLevel.desired = ambient ? level : 0; + this->_focusOnVoice.current = this->_focusOnVoice.desired = voice; + } } } else @@ -336,6 +444,25 @@ void Headphones::probeCapabilities() auto r = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BTNMODE_GET, (char)V2Command::SUB_SPEAK_TO_CHAT }, V2Command::BTNMODE_RET, V2Command::SUB_SPEAK_TO_CHAT); if (r.size() >= 3) { std::lock_guard g(this->_propertyMtx); this->_speakToChat = (r[2] == 0); this->_hasSpeakToChat = true; } } catch (...) {} + + // Sound quality mode. A model exposes it under exactly one sub-type - LDAC-capable devices + // (WF/WH-1000XM*) answer the LDAC variant, the rest the plain one - so probe both and keep whichever + // replied. Both share the same 3-byte layout: RET e7 . + for (unsigned char sub : { V2Command::SUB_CONNECTION_MODE_LDAC, V2Command::SUB_CONNECTION_MODE }) + { + try { + auto r = this->_conn.sendCommandAndReadResponse({ (char)V2Command::AUDIO_GET, (char)sub }, V2Command::AUDIO_RET, sub); + // Only 0x00/0x01 are modes we can round-trip; anything else means we've misread the layout. + if (r.size() >= 3 && (r[2] == 0x00 || r[2] == 0x01)) + { + std::lock_guard g(this->_propertyMtx); + this->_soundQualityModeSubType = sub; + this->_soundQualityMode = (PRIOR_MODE)(unsigned char)r[2]; + this->_hasSoundQualityMode = true; + break; + } + } catch (...) {} + } } bool Headphones::hasAutoPowerOff() { return this->_hasAutoPowerOff; } @@ -373,6 +500,30 @@ void Headphones::setAdaptiveVolume(bool enabled) this->_adaptiveVolume = enabled; } +bool Headphones::hasSoundQualityMode() { return this->_hasSoundQualityMode; } +PRIOR_MODE Headphones::getSoundQualityMode() { return this->_soundQualityMode; } +void Headphones::setSoundQualityMode(PRIOR_MODE mode) +{ + if (!this->_hasSoundQualityMode) return; + // SET: e8 , on the same sub-type the probe found. + // Fire-and-forget on purpose: the device tears the A2DP link down to re-negotiate the codec, which + // takes the RFCOMM channel with it, so waiting for a reply here would just time out. + this->_conn.sendCommand({ (char)V2Command::AUDIO_SET, (char)this->_soundQualityModeSubType, (char)mode }); + std::lock_guard guard(this->_propertyMtx); + this->_soundQualityMode = mode; +} + +// requestAmbientState() deliberately refuses to adopt device state while a change is pending, so a write +// that never reached the device would otherwise block the read-back for the rest of the session - the UI +// would freeze on a value the headphones never took. Dropping the pending change lets polling resume. +void Headphones::discardAmbientChanges() +{ + std::lock_guard guard(this->_propertyMtx); + this->_ambientSoundControl.desired = this->_ambientSoundControl.current; + this->_asmLevel.desired = this->_asmLevel.current; + this->_focusOnVoice.desired = this->_focusOnVoice.current; +} + bool Headphones::isChanged() { return !(this->_ambientSoundControl.isFulfilled() && this->_asmLevel.isFulfilled() && this->_focusOnVoice.isFulfilled() && this->_surroundPosition.isFulfilled() && this->_vptType.isFulfilled()); diff --git a/Client/Headphones.h b/Client/Headphones.h index 6197d569..2197a032 100644 --- a/Client/Headphones.h +++ b/Client/Headphones.h @@ -37,15 +37,22 @@ class Headphones { // Handshake the device expects before it answers inquiry (GET) commands on the v2 protocol. void initDevice(); + // Model name of the connected device (e.g. "WF-1000XM5"). Only a hint: it picks which battery + // layout to probe first, so set it before the first requestBattery() to save a timed-out inquiry. + void setDeviceName(const std::string& name); + // Battery (v2 inquiry). getBatteryLevel() returns -1 until requestBattery() succeeds. // For TWS earbuds requestBattery() also fills the per-earbud + case levels (hasDualBattery()). void requestBattery(); - int getBatteryLevel(); + int getBatteryLevel(); // for earbuds: the emptier of the two bool isBatteryCharging(); bool hasDualBattery(); int getBatteryLeft(); // -1 if n/a int getBatteryRight(); int getBatteryCase(); + bool isBatteryLeftCharging(); + bool isBatteryRightCharging(); + bool isBatteryCaseCharging(); // Equalizer (v2). requestEqualizer() reads current state; setEqualizerPreset() pushes a preset immediately. void requestEqualizer(); @@ -86,8 +93,19 @@ class Headphones { bool getAdaptiveVolume(); void setAdaptiveVolume(bool enabled); + // Sound quality mode: prioritise sound quality vs. a stable connection. Changing it makes the + // headphones re-negotiate the A2DP link, so the RFCOMM connection drops right after the write - + // callers should expect a disconnect rather than treat it as a failure. + bool hasSoundQualityMode(); + PRIOR_MODE getSoundQualityMode(); + void setSoundQualityMode(PRIOR_MODE mode); + bool isChanged(); void setChanges(); + + // Rolls a failed ambient/NC write back to the last known device state. Must be called when + // setChanges() throws - see the comment on the definition. + void discardAmbientChanges(); private: Property _ambientSoundControl = { 0 }; Property _focusOnVoice = { 0 }; @@ -101,6 +119,15 @@ class Headphones { int _batteryLeft = -1; int _batteryRight = -1; int _batteryCase = -1; + bool _batteryLeftCharging = false; + bool _batteryRightCharging = false; + bool _batteryCaseCharging = false; + std::string _deviceName; + + bool _looksLikeEarbuds() const; + // Both return false if the device didn't answer that battery sub-type. + bool _requestDualBattery(unsigned char subType); + bool _requestSingleBattery(unsigned char subType, int& levelOut, bool& chargingOut); EQ_PRESET _eqPreset = EQ_PRESET::OFF; std::vector _eqBands = { 0, 0, 0, 0, 0 }; int _eqClearBass = 0; @@ -111,6 +138,10 @@ class Headphones { bool _hasCodec = false; std::string _codec; bool _hasSpeakToChat = false; bool _speakToChat = false; bool _hasAdaptiveVolume = false; bool _adaptiveVolume = false; + // Which AUDIO sub-type this device answered on: SET has to go back out on the same one. + bool _hasSoundQualityMode = false; + unsigned char _soundQualityModeSubType = V2Command::SUB_CONNECTION_MODE; + PRIOR_MODE _soundQualityMode = PRIOR_MODE::SOUND_QUALITY; std::mutex _propertyMtx; diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_1024.png index d04c24d2..1549669a 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_1024.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_128.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_128.png index 69b33b67..0be9d293 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_128.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_16.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_16.png index 4a18806e..45d16911 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_16.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_256.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_256.png index f4b375c0..35f8a1f7 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_256.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_32.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_32.png index c2ca3816..ba31dce3 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_32.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_512.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_512.png index 53d9f1e6..d7487410 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_512.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_64.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_64.png index 52e19827..823960fc 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_64.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/Client/macos/ContentView.swift b/Client/macos/ContentView.swift index 45b0388a..e79c9944 100644 --- a/Client/macos/ContentView.swift +++ b/Client/macos/ContentView.swift @@ -79,6 +79,9 @@ struct ContentView: View { equalizerCard dseeCard } + if model.hasSoundQualityMode { + soundQualityCard + } if model.hasAdaptiveVolume || model.hasSpeakToChat || model.hasAutoPowerOff { settingsCard } @@ -149,12 +152,13 @@ struct ContentView: View { HStack(spacing: 7) { if model.hasDualBattery { - Image(systemName: "battery.100") - .font(.system(size: 13)) - .foregroundColor(Theme.accent) - Text("L \(model.batteryLeft)% R \(model.batteryRight)%") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(Theme.secondary) + // Earbuds report a battery each, plus the case when it's connected. An earbud that + // isn't reporting (docked / powered down) comes through as -1 and is left out. + budBattery("L", model.batteryLeft, charging: model.batteryLeftCharging) + budBattery("R", model.batteryRight, charging: model.batteryRightCharging) + if model.batteryCase >= 0 { + budBattery("Case", model.batteryCase, charging: model.batteryCaseCharging) + } } else if model.batteryLevel >= 0 { Image(systemName: model.batteryCharging ? "bolt.fill" : batterySymbol(model.batteryLevel)) .font(.system(size: 13)) @@ -323,6 +327,50 @@ struct ContentView: View { .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } + private var soundQualityCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text("Bluetooth Connection") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + Spacer() + if !model.codec.isEmpty { + Text(model.codec) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(Theme.secondary) + } + } + HStack(spacing: 8) { + priorityChip("Sound Quality", selected: model.prioritizeSoundQuality) { + model.setPrioritizeSoundQuality(true) + } + priorityChip("Stable Connection", selected: !model.prioritizeSoundQuality) { + model.setPrioritizeSoundQuality(false) + } + } + Text("The headphones reconnect to apply this.") + .font(.system(size: 11)) + .foregroundColor(Theme.secondary) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private func priorityChip(_ name: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(name) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(selected ? .white : Theme.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(selected ? Theme.accent : Theme.cardHi) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + } + .buttonStyle(PlainButtonStyle()) + } + private func eqChip(_ name: String, _ code: Int) -> some View { let selected = model.eqPreset == code return Button(action: { model.setEqualizer(code) }) { @@ -396,8 +444,11 @@ struct ContentView: View { .padding(.bottom, 10) aboutRow("Status", model.connected ? "Connected" : "Disconnected") if model.hasDualBattery { - aboutRow("Battery L / R", "\(model.batteryLeft)% / \(model.batteryRight)%") - if model.batteryCase >= 0 { aboutRow("Case", "\(model.batteryCase)%") } + aboutRow("Battery L", batteryText(model.batteryLeft, model.batteryLeftCharging)) + aboutRow("Battery R", batteryText(model.batteryRight, model.batteryRightCharging)) + if model.batteryCase >= 0 { + aboutRow("Case", batteryText(model.batteryCase, model.batteryCaseCharging)) + } } else if model.batteryLevel >= 0 { aboutRow("Battery", "\(model.batteryLevel)%\(model.batteryCharging ? " (charging)" : "")") } @@ -415,6 +466,11 @@ struct ContentView: View { .background(Theme.card) } + private func batteryText(_ level: Int, _ charging: Bool) -> String { + guard level >= 0 else { return "—" } + return "\(level)%\(charging ? " (charging)" : "")" + } + private func aboutRow(_ label: String, _ value: String) -> some View { HStack { Text(label).font(.system(size: 12)).foregroundColor(Theme.secondary) @@ -430,6 +486,21 @@ struct ContentView: View { return NSImage(named: slug) } + // One "L 82%" chip, with a bolt in place of the battery glyph while that unit is charging. + // A docked/powered-down earbud reports no level; show it dimmed as "R --" rather than dropping the + // chip, so it reads as "in the case" instead of looking like the app lost track of it. + private func budBattery(_ label: String, _ level: Int, charging: Bool) -> some View { + HStack(spacing: 3) { + Image(systemName: level < 0 ? "battery.0" : (charging ? "bolt.fill" : batterySymbol(level))) + .font(.system(size: 12)) + .foregroundColor(level < 0 ? Theme.secondary.opacity(0.5) + : (level <= 20 && !charging ? .red.opacity(0.9) : Theme.accent)) + Text(level < 0 ? "\(label) --" : "\(label) \(level)%") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Theme.secondary.opacity(level < 0 ? 0.5 : 1.0)) + } + } + private func batterySymbol(_ level: Int) -> String { switch level { case ...15: return "battery.0" diff --git a/Client/macos/HeadphonesBridge.h b/Client/macos/HeadphonesBridge.h index dfbdd039..c8d5aeca 100644 --- a/Client/macos/HeadphonesBridge.h +++ b/Client/macos/HeadphonesBridge.h @@ -37,6 +37,9 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) NSInteger batteryLeft; // -1 if n/a @property (nonatomic, readonly) NSInteger batteryRight; @property (nonatomic, readonly) NSInteger batteryCase; +@property (nonatomic, readonly) BOOL batteryLeftCharging; +@property (nonatomic, readonly) BOOL batteryRightCharging; +@property (nonatomic, readonly) BOOL batteryCaseCharging; @property (nonatomic, readonly) NSInteger eqPreset; // raw preset byte (EQ_PRESET) @property (nonatomic, readonly) BOOL supportsEqualizer; // v2 devices only @property (nonatomic, readonly) NSInteger clearBass; // -10..10 @@ -53,6 +56,8 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) BOOL speakToChat; @property (nonatomic, readonly) BOOL hasAdaptiveVolume; @property (nonatomic, readonly) BOOL adaptiveVolume; +@property (nonatomic, readonly) BOOL hasSoundQualityMode; +@property (nonatomic, readonly) BOOL prioritizeSoundQuality; // NO = prioritize stable connection // Runs the native Bluetooth device picker (modal, main thread) and connects to the chosen device. // completion is called on the main thread. @@ -85,6 +90,10 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { - (void)setSpeakToChat:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; - (void)setAdaptiveVolume:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; +// Switches the Bluetooth connection priority. The headphones re-negotiate the audio link, so the +// connection normally drops a moment after this returns - that is the device working as intended. +- (void)setPrioritizeSoundQuality:(BOOL)prioritize completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; + // Re-reads the fast-changing state (ambient/NC, level, EQ, DSEE) so changes made with the headphone's // own button show up in the app. Called on a timer while connected. - (void)refreshDynamicWithCompletion:(void (^)(void))completion; diff --git a/Client/macos/HeadphonesBridge.mm b/Client/macos/HeadphonesBridge.mm index 4f1b58d3..72601ff1 100644 --- a/Client/macos/HeadphonesBridge.mm +++ b/Client/macos/HeadphonesBridge.mm @@ -14,12 +14,17 @@ // RecoverableException comes in transitively via BluetoothWrapper.h -> IBluetoothConnector.h -> Exceptions.h. // (Exceptions.h isn't a project file reference, so it can't be #included directly from this directory.) +// The dynamic poll fires every 2s; re-read the battery every 15th tick (~30s), which is far more often +// than a headphone battery meaningfully moves but cheap enough not to crowd out the ambient/NC polling. +static const unsigned kBatteryPollEveryNTicks = 15; + @implementation HeadphonesBridge { std::unique_ptr _bt; std::unique_ptr _hp; NSString *_deviceName; NSString *_deviceMac; BOOL _initialized; + unsigned _dynamicTick; // All user-initiated commands run on this SERIAL queue so quick successive taps reach the device in // order (a concurrent queue let them race and land out of order). dispatch_queue_t _cmdQueue; @@ -90,6 +95,9 @@ - (BOOL)hasDualBattery { return _hp && _hp->hasDualBattery(); } - (NSInteger)batteryLeft { return _hp ? _hp->getBatteryLeft() : -1; } - (NSInteger)batteryRight { return _hp ? _hp->getBatteryRight() : -1; } - (NSInteger)batteryCase { return _hp ? _hp->getBatteryCase() : -1; } +- (BOOL)batteryLeftCharging { return _hp && _hp->isBatteryLeftCharging(); } +- (BOOL)batteryRightCharging { return _hp && _hp->isBatteryRightCharging(); } +- (BOOL)batteryCaseCharging { return _hp && _hp->isBatteryCaseCharging(); } - (NSInteger)eqPreset { return _hp ? (NSInteger)_hp->getEqualizerPreset() : 0; @@ -121,6 +129,8 @@ - (BOOL)hasSpeakToChat { return _hp && _hp->hasSpeakToChat(); } - (BOOL)speakToChat { return _hp && _hp->getSpeakToChat(); } - (BOOL)hasAdaptiveVolume { return _hp && _hp->hasAdaptiveVolume(); } - (BOOL)adaptiveVolume { return _hp && _hp->getAdaptiveVolume(); } +- (BOOL)hasSoundQualityMode { return _hp && _hp->hasSoundQualityMode(); } +- (BOOL)prioritizeSoundQuality { return _hp && _hp->getSoundQualityMode() == PRIOR_MODE::SOUND_QUALITY; } static BOOL SHCLooksLikeSonyHeadset(NSString *name) { if (name.length == 0) return NO; @@ -181,6 +191,8 @@ - (void)scanAndConnectWithCompletion:(void (^)(BOOL, NSString * _Nullable))compl _deviceName = [device nameOrAddress]; _deviceMac = [device addressString]; _hp = std::make_unique(*_bt); + // Lets requestBattery() probe the per-earbud layout first on TWS models (WF-*/LinkBuds). + _hp->setDeviceName(_deviceName ? [_deviceName UTF8String] : ""); completion(YES, nil); } @@ -231,10 +243,16 @@ - (void)refreshStatusWithCompletion:(void (^)(void))completion { - (void)refreshDynamicWithCompletion:(void (^)(void))completion { if (!_hp || !self.connected) { completion(); return; } Headphones *hp = _hp.get(); + BOOL isV2 = _bt->getProtocolVersion() == SonyProtocolVersion::V2; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ // The headphone's physical button only changes ambient/NC, so that's all we poll (keeps traffic low). // requestAmbientState() is protocol-aware (v2 uses 66 17, v1 uses 66 02), so this is safe on both. try { hp->requestAmbientState(); } catch (std::exception &exc) {} + // Battery drains, earbuds get docked and the case charges them, so re-read it too - but only + // every Nth poll, since it costs up to three inquiries on TWS. v2 only: 0x22 is POWER_OFF on v1. + if (isV2 && self->_dynamicTick++ % kBatteryPollEveryNTicks == 0) { + try { hp->requestBattery(); } catch (std::exception &exc) {} + } dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); }); } @@ -336,6 +354,9 @@ - (void)applyMode:(SHCAmbientMode)mode ok = NO; error = @(exc.what()); } + // The write never landed, so stop it counting as pending - otherwise the ambient read-back stays + // suppressed and the UI freezes on a value the headphones never took. + if (!ok) hp->discardAmbientChanges(); dispatch_async(dispatch_get_main_queue(), ^{ completion(ok, error); }); @@ -372,4 +393,16 @@ - (void)setAdaptiveVolume:(BOOL)enabled completion:(void (^)(BOOL, NSString * _N }); } +- (void)setPrioritizeSoundQuality:(BOOL)prioritize completion:(void (^)(BOOL, NSString * _Nullable))completion { + if (!_hp || !self.connected) { completion(NO, @"Not connected."); return; } + Headphones *hp = _hp.get(); + dispatch_async(_cmdQueue, ^{ + NSString *error = nil; BOOL ok = YES; + try { + hp->setSoundQualityMode(prioritize ? PRIOR_MODE::SOUND_QUALITY : PRIOR_MODE::STABLE_CONNECTION); + } catch (std::exception &exc) { ok = NO; error = @(exc.what()); } + dispatch_async(dispatch_get_main_queue(), ^{ completion(ok, error); }); + }); +} + @end diff --git a/Client/macos/HeadphonesModel.swift b/Client/macos/HeadphonesModel.swift index cda475ad..b2d18e4e 100644 --- a/Client/macos/HeadphonesModel.swift +++ b/Client/macos/HeadphonesModel.swift @@ -26,6 +26,9 @@ final class HeadphonesModel: ObservableObject { @Published var batteryLeft = -1 @Published var batteryRight = -1 @Published var batteryCase = -1 + @Published var batteryLeftCharging = false + @Published var batteryRightCharging = false + @Published var batteryCaseCharging = false @Published var eqPreset = 0 @Published var supportsEqualizer = false @Published var eqBands = [0, 0, 0, 0, 0] @@ -40,6 +43,8 @@ final class HeadphonesModel: ObservableObject { @Published var speakToChat = false @Published var hasAdaptiveVolume = false @Published var adaptiveVolume = false + @Published var hasSoundQualityMode = false + @Published var prioritizeSoundQuality = true @Published var deviceMac = "" @Published var protocolVersion = "" @@ -47,6 +52,8 @@ final class HeadphonesModel: ObservableObject { private var pollTimer: Timer? private var dynamicTimer: Timer? + // Set when we issue a write that we know makes the device drop the link on purpose. + private var expectingLinkReset = false func connect() { connecting = true @@ -69,12 +76,7 @@ final class HeadphonesModel: ObservableObject { func refreshStatus() { bridge.refreshStatus { - self.batteryLevel = self.bridge.batteryLevel - self.batteryCharging = self.bridge.batteryCharging - self.hasDualBattery = self.bridge.hasDualBattery - self.batteryLeft = self.bridge.batteryLeft - self.batteryRight = self.bridge.batteryRight - self.batteryCase = self.bridge.batteryCase + self.syncBatteryFromBridge() self.eqPreset = self.bridge.eqPreset self.clearBass = self.bridge.clearBass self.dsee = self.bridge.dsee @@ -87,9 +89,23 @@ final class HeadphonesModel: ObservableObject { self.speakToChat = self.bridge.speakToChat self.hasAdaptiveVolume = self.bridge.hasAdaptiveVolume self.adaptiveVolume = self.bridge.adaptiveVolume + self.hasSoundQualityMode = self.bridge.hasSoundQualityMode + self.prioritizeSoundQuality = self.bridge.prioritizeSoundQuality } } + private func syncBatteryFromBridge() { + batteryLevel = bridge.batteryLevel + batteryCharging = bridge.batteryCharging + hasDualBattery = bridge.hasDualBattery + batteryLeft = bridge.batteryLeft + batteryRight = bridge.batteryRight + batteryCase = bridge.batteryCase + batteryLeftCharging = bridge.batteryLeftCharging + batteryRightCharging = bridge.batteryRightCharging + batteryCaseCharging = bridge.batteryCaseCharging + } + func setAutoPowerOff(_ index: Int) { autoPowerOff = index errorMessage = nil @@ -108,19 +124,36 @@ final class HeadphonesModel: ObservableObject { bridge.setAdaptiveVolume(on) { ok, error in if !ok, let e = error { self.errorMessage = e } } } + func setPrioritizeSoundQuality(_ on: Bool) { + prioritizeSoundQuality = on + errorMessage = nil + // The headphones re-negotiate the audio link for the new priority, which drops RFCOMM with it. + // Arm the connection watcher so that shows up as an explanation rather than as a bare failure. + expectingLinkReset = true + bridge.setPrioritizeSoundQuality(on) { ok, error in + if !ok { + self.expectingLinkReset = false + self.prioritizeSoundQuality = self.bridge.prioritizeSoundQuality + if let error = error { self.errorMessage = error } + } + } + } + // Poll the button-changeable state so the app stays in sync when you use the headphone's own controls. private func startDynamicPolling() { stopDynamicPolling() dynamicTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in guard let self = self, self.connected else { return } self.bridge.refreshDynamic { + // Only publish what refreshDynamic actually re-read: ambient/NC state and the battery. + // The EQ and DSEE values used to be assigned here too, but nothing re-reads them on this + // path - they came straight back from the C++ cache, which is only updated once a write + // succeeds. So a write that failed had its control snap back to the old value two + // seconds later, and dragging an EQ slider fought this timer. self.mode = self.bridge.mode let level = self.bridge.ambientLevel if level > 0 { self.ambientLevel = level } - self.eqPreset = self.bridge.eqPreset - self.clearBass = self.bridge.clearBass - self.dsee = self.bridge.dsee - self.eqBands = (0..<5).map { self.bridge.equalizerBand(at: $0) } + self.syncBatteryFromBridge() } } } @@ -130,11 +163,23 @@ final class HeadphonesModel: ObservableObject { dynamicTimer = nil } + // The controls below update optimistically and are corrected here if the write failed, rather than + // being silently overwritten by the next poll tick. func setEqualizer(_ preset: Int) { eqPreset = preset errorMessage = nil bridge.setEqualizerPreset(preset) { ok, error in - if !ok, let error = error { self.errorMessage = error } + if !ok { + self.eqPreset = self.bridge.eqPreset + if let error = error { self.errorMessage = error } + return + } + // The bridge re-reads the EQ after the write, so this picks up the band values that belong to + // the preset just selected - in particular the custom curve the device restores for Manual, + // which is what the sliders below the chips show. + self.eqPreset = self.bridge.eqPreset + self.clearBass = self.bridge.clearBass + self.eqBands = (0..<5).map { self.bridge.equalizerBand(at: $0) } } } @@ -142,6 +187,8 @@ final class HeadphonesModel: ObservableObject { func applyCustomEq() { eqPreset = 0xA0 errorMessage = nil + // Deliberately no roll-back here: this fires continuously while a slider is dragged, and yanking + // the bands back mid-drag is the exact jumpiness we're fixing. The next drag event corrects it. bridge.setCustomEqualizerBass(clearBass, bands: eqBands.map { NSNumber(value: $0) }) { ok, error in if !ok, let error = error { self.errorMessage = error } } @@ -151,7 +198,10 @@ final class HeadphonesModel: ObservableObject { dsee = on errorMessage = nil bridge.setDsee(on) { ok, error in - if !ok, let error = error { self.errorMessage = error } + if !ok { + self.dsee = self.bridge.dsee + if let error = error { self.errorMessage = error } + } } } @@ -172,7 +222,10 @@ final class HeadphonesModel: ObservableObject { if self.connected && !self.bridge.connected { self.connected = false self.deviceName = "" - self.errorMessage = "Headphones disconnected." + self.errorMessage = self.expectingLinkReset + ? "Sound quality mode changed — the headphones reconnect on their own. Connect again in a few seconds." + : "Headphones disconnected." + self.expectingLinkReset = false self.stopWatchingConnection() self.stopDynamicPolling() } @@ -202,11 +255,10 @@ final class HeadphonesModel: ObservableObject { private func pushState() { errorMessage = nil bridge.applyMode(mode, level: ambientLevel, focusVoice: focusOnVoice) { ok, error in - if ok { - self.syncFromBridge() - } else if let error = error { - self.errorMessage = error - } + // Sync either way: on failure the bridge has rolled the pending change back, so this shows + // what the headphones are actually set to instead of leaving the UI lying. + self.syncFromBridge() + if !ok, let error = error { self.errorMessage = error } } } diff --git a/Client/macos/MacOSBluetoothConnector.h b/Client/macos/MacOSBluetoothConnector.h index 186b06a4..7ad6679d 100644 --- a/Client/macos/MacOSBluetoothConnector.h +++ b/Client/macos/MacOSBluetoothConnector.h @@ -37,6 +37,9 @@ class MacOSBluetoothConnector final : public IBluetoothConnector private: void *rfcommDevice; - void *rfcommchannel; + // Retained (CFBridgingRetain) so the channel object stays alive as long as we hold the pointer, and + // guarded by channelMtx because the run-loop thread clears it on close while command threads send. + void *rfcommchannel = nullptr; + std::mutex channelMtx; std::thread uthread; }; diff --git a/Client/macos/MacOSBluetoothConnector.mm b/Client/macos/MacOSBluetoothConnector.mm index bd2db624..0ba317db 100644 --- a/Client/macos/MacOSBluetoothConnector.mm +++ b/Client/macos/MacOSBluetoothConnector.mm @@ -64,7 +64,18 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) #ifdef SHC_DEBUG_PROTOCOL _debugHexDump("send", buf, length); #endif - [(__bridge IOBluetoothRFCOMMChannel*)rfcommchannel writeSync:(void*)buf length:length]; + // Take a strong reference under the lock: the earbuds can drop the link at any moment (docking a bud + // in the case does it), and the run-loop thread clears rfcommchannel from rfcommChannelClosed. Without + // this, a poll already in flight would writeSync: to a freed channel and crash with EXC_BAD_ACCESS. + IOBluetoothRFCOMMChannel *chan = nil; + { + std::lock_guard g(channelMtx); + chan = (__bridge IOBluetoothRFCOMMChannel*) rfcommchannel; + } + if (chan == nil || !chan.isOpen) { + throw RecoverableException("The headphones disconnected.", true); + } + [chan writeSync:(void*)buf length:length]; return (int)length; } @@ -126,8 +137,11 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) connectPromise.set_exception(excPtr); return; } - // store the channel - macOSBluetoothConnector->rfcommchannel = (__bridge void*) channel; + // store the channel (retained; released in closeConnection) + { + std::lock_guard g(macOSBluetoothConnector->channelMtx); + macOSBluetoothConnector->rfcommchannel = (void*) CFBridgingRetain(channel); + } macOSBluetoothConnector->protocolVersion = protocolVersion; macOSBluetoothConnector->running = true; @@ -172,9 +186,11 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) // wait for newly received data, but time out so an unanswered inquiry (probing an unsupported // feature) or a dropped link doesn't block the caller forever. std::unique_lock g(receiveDataMutex); - bool gotData = receiveDataConditionVariable.wait_for(g, std::chrono::milliseconds(2500), - [this]{ return !receivedBytes.empty(); }); - if (!gotData) { + // Also wake on disconnect, so a dropped link surfaces immediately instead of after the full timeout. + receiveDataConditionVariable.wait_for(g, std::chrono::milliseconds(2500), + [this]{ return !receivedBytes.empty() || !running; }); + if (receivedBytes.empty()) { + if (!running) throw RecoverableException("The headphones disconnected.", true); throw RecoverableException("recv timed out", false); } @@ -225,20 +241,37 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) void MacOSBluetoothConnector::disconnect() noexcept { - // close connection - closeConnection(); + // Clear running first: closeConnection() wakes any blocked recv(), and that waiter's predicate + // checks this flag, so it has to be false by the time the notify lands. running = false; + closeConnection(); // notify the other thread that we are done disconnecting disconnectionConditionVariable.notify_all(); - // wait for the thread to finish - uthread.join(); + // wait for the thread to finish. The RFCOMM delegate callbacks run on uthread's own run loop, so + // rfcommChannelClosed -> disconnect() lands here on uthread itself; joining that would deadlock. + // Setting running=false above is enough to end its loop, so just let it unwind. + if (uthread.joinable()) { + if (uthread.get_id() != std::this_thread::get_id()) uthread.join(); + else uthread.detach(); + } } void MacOSBluetoothConnector::closeConnection() { - // get the channel - IOBluetoothRFCOMMChannel *chan = (__bridge IOBluetoothRFCOMMChannel*) rfcommchannel; + // Hand the retain over to ARC and clear the member under the lock, so any concurrent send()/recv() + // either sees the live channel or sees nil - never a dangling pointer. + IOBluetoothRFCOMMChannel *chan = nil; + { + std::lock_guard g(channelMtx); + chan = (IOBluetoothRFCOMMChannel*) CFBridgingRelease(rfcommchannel); + rfcommchannel = nullptr; + } + if (chan == nil) return; // already closed [chan setDelegate: nil]; - // close the channel [chan closeChannel]; + // Wake anyone blocked in recv() so they observe the disconnect instead of waiting out the timeout. + { + std::lock_guard g(receiveDataMutex); + receiveDataConditionVariable.notify_all(); + } } @@ -246,6 +279,7 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) { if (!running) return false; + std::lock_guard g(channelMtx); IOBluetoothRFCOMMChannel *chan = (__bridge IOBluetoothRFCOMMChannel*) rfcommchannel; - return chan.isOpen; + return chan != nil && chan.isOpen; }