From f2ce53668ddcf3342e3113c48c8ab667459a668e Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 22 Oct 2026 19:08:53 -0300 Subject: [PATCH 1/2] feat(macos): multipoint device list, playback switching and fixing (v2) Adds Sound Connect's "Switch playback device and fix" for v2 headsets, so playback can be pinned to the Mac (e.g. for calls) and the phone can't take it over. Transport: BluetoothWrapper can now send and match on command table 2 (DATA_MDR_NO2, 0x0e) and ACKs table 2 frames. The tables are separate command spaces - in table 1, 0x30-0x3f are the firmware-update commands - so the multipoint opcodes only ever go out on 0x0e. Discovery (GETs only, v2 only, gated on the init reply advertising table 2): support functions (06 00), device list (36 02 / 36 00), fixed state (36 01), and the MULTIPOINT_SETTING general-setting slot found by name (d0 d1..d4), then its value (d6). Raw replies are kept for "Copy diagnostics". Writes (each refused locally unless discovery saw function 0x31): - switch playback: 3c 01
-> 3d 01
- fix / unfix: 38 01 00 / 01 -> 39 01 "Switch and fix" = unfix if fixed, switch, fix; if the switch is refused it re-fixes the old device. A plain switch unfixes afterwards, since the headset carries "fixed" across a switch. State is re-read after every write. UI: Devices card (connected/paired, playback and lock icons, This Mac tag, Fix/Unfix, per-device switch menu) and a menu-bar item with "Pin Playback to This Mac" whose icon shows the playback device (phone, computer or slot number). The device list is re-read every 10 s so the icon follows changes made from the phone. Opcodes and layouts from mos9527/SonyHeadphonesClient (ProtocolV2T2.hpp) and nullpoint. Verified on a WH-1000XM5 fw 2.5.1: list, switch, fix, unfix, and the FAIL_CALLING refusal (39 01 00 02) during a call. --- Client/BluetoothWrapper.cpp | 18 +- Client/BluetoothWrapper.h | 15 +- Client/Constants.h | 49 +++++ Client/Headphones.cpp | 292 +++++++++++++++++++++++++- Client/Headphones.h | 43 ++++ Client/macos/AppDelegate.mm | 2 + Client/macos/ContentView.swift | 323 ++++++++++++++++++++++++++++- Client/macos/HeadphonesBridge.h | 31 +++ Client/macos/HeadphonesBridge.mm | 137 ++++++++++++ Client/macos/HeadphonesModel.swift | 75 +++++++ 10 files changed, 967 insertions(+), 18 deletions(-) diff --git a/Client/BluetoothWrapper.cpp b/Client/BluetoothWrapper.cpp index cd3cb744..21d2b7c6 100644 --- a/Client/BluetoothWrapper.cpp +++ b/Client/BluetoothWrapper.cpp @@ -24,10 +24,10 @@ BluetoothWrapper& BluetoothWrapper::operator=(BluetoothWrapper&& other) noexcept return *this; } -int BluetoothWrapper::sendCommand(const std::vector& bytes) +int BluetoothWrapper::sendCommand(const std::vector& bytes, DATA_TYPE dataType) { std::lock_guard guard(this->_connectorMtx); - auto data = CommandSerializer::packageDataForBt(bytes, DATA_TYPE::DATA_MDR, this->_seqNumber++); + auto data = CommandSerializer::packageDataForBt(bytes, dataType, this->_seqNumber++); auto bytesSent = this->_connector->send(data.data(), data.size()); this->_waitForAck(); @@ -65,10 +65,11 @@ SonyProtocolVersion BluetoothWrapper::getProtocolVersion() noexcept return this->_connector->getProtocolVersion(); } -Buffer BluetoothWrapper::sendCommandAndReadResponse(const std::vector& bytes, unsigned char retCommandId, int retSubType) +Buffer BluetoothWrapper::sendCommandAndReadResponse(const std::vector& bytes, unsigned char retCommandId, int retSubType, + DATA_TYPE dataType) { std::lock_guard guard(this->_connectorMtx); - auto data = CommandSerializer::packageDataForBt(bytes, DATA_TYPE::DATA_MDR, this->_seqNumber++); + auto data = CommandSerializer::packageDataForBt(bytes, dataType, this->_seqNumber++); this->_connector->send(data.data(), data.size()); // The device replies with an ACK and then the RET frame; unrelated notifications may interleave. @@ -80,7 +81,7 @@ Buffer BluetoothWrapper::sendCommandAndReadResponse(const std::vector& byt this->_seqNumber = msg.seqNumber; continue; } - if (msg.dataType == DATA_TYPE::DATA_MDR && !msg.payload.empty() + if (msg.dataType == dataType && !msg.payload.empty() && (unsigned char)msg.payload[0] == retCommandId && (retSubType < 0 || (msg.payload.size() >= 2 && (unsigned char)msg.payload[1] == retSubType))) { @@ -150,9 +151,10 @@ CommandSerializer::Message BluetoothWrapper::_readMessage() auto msg = CommandSerializer::unpackBtMessage(msgBytes); - // The device retransmits any DATA_MDR frame it sends until the host ACKs it. The ack carries the - // toggled 1-bit sequence number (1 - deviceSeq), matching the device's own ack-to-us behaviour. - if (msg.dataType == DATA_TYPE::DATA_MDR) + // The device retransmits any DATA_MDR / DATA_MDR_NO2 frame it sends until the host ACKs it. The ack + // carries the toggled 1-bit sequence number (1 - deviceSeq), matching the device's own ack-to-us behaviour. + // Both tables share the one sequence counter. + if (msg.dataType == DATA_TYPE::DATA_MDR || msg.dataType == DATA_TYPE::DATA_MDR_NO2) { auto ack = CommandSerializer::packageDataForBt({}, DATA_TYPE::ACK, (unsigned char)(1 - msg.seqNumber)); this->_connector->send(ack.data(), ack.size()); diff --git a/Client/BluetoothWrapper.h b/Client/BluetoothWrapper.h index 612790c9..7a335a66 100644 --- a/Client/BluetoothWrapper.h +++ b/Client/BluetoothWrapper.h @@ -21,12 +21,15 @@ class BluetoothWrapper BluetoothWrapper(BluetoothWrapper&& other) noexcept; BluetoothWrapper& operator=(BluetoothWrapper&& other) noexcept; - int sendCommand(const std::vector& bytes); - - // Sends an inquiry command and returns the payload of the first DATA_MDR response whose command id - // (payload[0]) matches retCommandId, skipping the ACK and any unrelated notifications. When retSubType - // is >= 0, payload[1] must also match it (for multiplexed command families that share one opcode). - Buffer sendCommandAndReadResponse(const std::vector& bytes, unsigned char retCommandId, int retSubType = -1); + // dataType selects the command table: DATA_MDR (0x0c, table 1) or DATA_MDR_NO2 (0x0e, table 2). + // The two tables are separate command spaces - the same opcode means different things in each. + int sendCommand(const std::vector& bytes, DATA_TYPE dataType = DATA_TYPE::DATA_MDR); + + // Sends an inquiry command and returns the payload of the first response of the same dataType whose + // command id (payload[0]) matches retCommandId, skipping the ACK and any unrelated notifications. When + // retSubType is >= 0, payload[1] must also match it (for multiplexed command families that share one opcode). + Buffer sendCommandAndReadResponse(const std::vector& bytes, unsigned char retCommandId, int retSubType = -1, + DATA_TYPE dataType = DATA_TYPE::DATA_MDR); bool isConnected() noexcept; //Try to connect to the headphones diff --git a/Client/Constants.h b/Client/Constants.h index 91613ca9..8f3fa7e7 100644 --- a/Client/Constants.h +++ b/Client/Constants.h @@ -1,5 +1,6 @@ #pragma once +#include #include inline constexpr auto MAX_BLUETOOTH_MESSAGE_SIZE = 2048; @@ -55,6 +56,54 @@ namespace V2Command inline constexpr unsigned char BTNMODE_SET = 0xf8; // SET f8 inline constexpr unsigned char SUB_ADAPTIVE_VOLUME = 0x0a; inline constexpr unsigned char SUB_SPEAK_TO_CHAT = 0x0c; + // INIT_REPLY layout: 01 + inline constexpr size_t INIT_REPLY_TABLE2_INDEX = 7; + // General settings: up to four device-defined boolean/list settings in slots D1..D4. Which slot holds + // what is per-model, so the slot is found by name via GS_GET_CAPABILITY. Values: 00=ON, 01=OFF. + inline constexpr unsigned char GS_GET_CAPABILITY = 0xd0; // d0 + inline constexpr unsigned char GS_RET_CAPABILITY = 0xd1; // d1 name summary + inline constexpr unsigned char GS_GET_PARAM = 0xd6; // d6 + inline constexpr unsigned char GS_RET_PARAM = 0xd7; // d7 + inline constexpr unsigned char GS_SLOT_FIRST = 0xd1; + inline constexpr unsigned char GS_SLOT_LAST = 0xd4; + inline constexpr unsigned char GS_DISPLAY_LANGUAGE_ENGLISH = 0x01; +} + +// Second command table (frame DATA_TYPE::DATA_MDR_NO2 = 0x0e) on v2 devices: multipoint / paired-device +// management. Layouts from mos9527/SonyHeadphonesClient (ProtocolV2T2.hpp, generated from Sound Connect) +// and WF-1000XM5 / WH-1000XM6 captures. +// NEVER send these with DATA_MDR (0x0c): in table 1, 0x30-0x3f are the firmware-update (UPDT) commands. +namespace V2Table2Command +{ + inline constexpr unsigned char SUPPORT_FUNCTION_GET = 0x06; // 06 00 + inline constexpr unsigned char SUPPORT_FUNCTION_RET = 0x07; // 07 00 ( ) x n + inline constexpr unsigned char PERI_GET_PARAM = 0x36; // 36 + inline constexpr unsigned char PERI_RET_PARAM = 0x37; + inline constexpr unsigned char PERI_SET_PARAM = 0x38; // 38 01 <00=fix playback, 01=free> + inline constexpr unsigned char PERI_NTFY_PARAM = 0x39; // 39 01 + inline constexpr unsigned char PERI_SET_EXTENDED_PARAM = 0x3c; // 3c 01 + inline constexpr unsigned char PERI_NTFY_EXTENDED_PARAM = 0x3d; // 3d 01 + + // Source-switch results (switch / fix writes). NO_CONFIRMATION is ours: no notify within the timeout. + inline constexpr int RESULT_SUCCESS = 0x00; + inline constexpr int RESULT_FAIL = 0x01; + inline constexpr int RESULT_FAIL_CALLING = 0x02; + inline constexpr int RESULT_FAIL_A2DP_NOT_CONNECTED = 0x03; + inline constexpr int RESULT_FAIL_VOICE_ASSISTANT = 0x04; + inline constexpr int RESULT_NO_CONFIRMATION = -1; + + // Inquired types (payload[1] of the PERI_* commands). + inline constexpr unsigned char PERI_DEVICES_CLASSIC = 0x00; // device list without class of device + inline constexpr unsigned char PERI_SOURCE_SWITCH = 0x01; // 37 01 <1=switching free, 0=playback fixed> + inline constexpr unsigned char PERI_DEVICES_WITH_COD = 0x02; // device list with class of device + + // Function ids in the SUPPORT_FUNCTION_RET list. + inline constexpr unsigned char FN_DEVICES_CLASSIC = 0x30; + inline constexpr unsigned char FN_SOURCE_SWITCH = 0x31; + inline constexpr unsigned char FN_DEVICES_WITH_COD = 0x32; + inline constexpr unsigned char FN_DEVICES_WITH_COD_LE = 0x33; + + inline constexpr size_t BT_ADDRESS_LENGTH = 17; // ASCII "XX:XX:XX:XX:XX:XX", no terminator } // v2 equalizer preset ids (byte value sent/received at EQ payload[2]). diff --git a/Client/Headphones.cpp b/Client/Headphones.cpp index 54d010f2..13a0ce71 100644 --- a/Client/Headphones.cpp +++ b/Client/Headphones.cpp @@ -5,6 +5,7 @@ #include #include #include +#include Headphones::Headphones(BluetoothWrapper& conn) : _conn(conn) { @@ -77,12 +78,16 @@ int Headphones::getVptType() void Headphones::initDevice() { - // v2 devices expect an init handshake before they answer GET inquiries. Best-effort: the reply - // (INIT_REPLY, 8 bytes on v2) is ignored here - we only need the device to enter the ready state. - this->_conn.sendCommandAndReadResponse( + // v2 devices expect an init handshake before they answer GET inquiries. The reply (INIT_REPLY, 8 bytes + // on v2) also says whether the device has command table 2, which gates every multipoint read. + auto resp = this->_conn.sendCommandAndReadResponse( { (char)V2Command::INIT_REQUEST, 0x00 }, V2Command::INIT_REPLY ); + std::lock_guard guard(this->_propertyMtx); + this->_initReply = resp; + this->_table2Supported = resp.size() > V2Command::INIT_REPLY_TABLE2_INDEX + && resp[V2Command::INIT_REPLY_TABLE2_INDEX] == 0x00; } void Headphones::requestBattery() @@ -373,6 +378,287 @@ void Headphones::setAdaptiveVolume(bool enabled) this->_adaptiveVolume = enabled; } +// --- Multipoint (read-only) --- + +namespace +{ + std::string hexString(const Buffer& bytes) + { + static const char digits[] = "0123456789abcdef"; + std::string out; + for (size_t i = 0; i < bytes.size(); i++) + { + auto b = (unsigned char)bytes[i]; + if (i > 0) out += ' '; + out += digits[b >> 4]; + out += digits[b & 0x0f]; + } + return out; + } + + // 37 { [] } x count + // Returns false (and leaves `out` untouched) on any length mismatch, so a misread never reaches the UI. + bool parsePairedDevices(const Buffer& resp, bool withClassOfDevice, std::vector& out) + { + if (resp.size() < 4) return false; + size_t pos = 2; + size_t count = (unsigned char)resp[pos++]; + std::vector devices; + for (size_t i = 0; i < count; i++) + { + size_t fixed = V2Table2Command::BT_ADDRESS_LENGTH + 1 + (withClassOfDevice ? 3 : 0) + 1; + if (pos + fixed > resp.size()) return false; + PairedDevice device; + device.address.assign(resp.begin() + pos, resp.begin() + pos + V2Table2Command::BT_ADDRESS_LENGTH); + pos += V2Table2Command::BT_ADDRESS_LENGTH; + device.slot = (unsigned char)resp[pos++]; + if (withClassOfDevice) + { + device.classOfDevice = ((unsigned char)resp[pos] << 16) | ((unsigned char)resp[pos + 1] << 8) | (unsigned char)resp[pos + 2]; + pos += 3; + } + size_t nameLength = (unsigned char)resp[pos++]; + if (pos + nameLength > resp.size()) return false; + device.name.assign(resp.begin() + pos, resp.begin() + pos + nameLength); + pos += nameLength; + devices.push_back(device); + } + if (pos + 1 != resp.size()) return false; + // The trailing byte is the slot (1/2) that holds playback, not an index into the list. + int playbackSlot = (unsigned char)resp[pos]; + for (auto& device : devices) + { + device.playback = device.slot != 0 && device.slot == playbackSlot; + } + out = devices; + return true; + } +} + +void Headphones::requestMultipoint() +{ + if (this->_conn.getProtocolVersion() != SonyProtocolVersion::V2) return; + + Buffer initReply; + bool table2Supported; + bool functionsRead; + std::vector functions; + bool slotSearched; + int slot; + std::string discoveryLog; + { + std::lock_guard guard(this->_propertyMtx); + initReply = this->_initReply; + table2Supported = this->_table2Supported; + functionsRead = this->_table2FunctionsRead; + functions = this->_table2Functions; + slotSearched = this->_multipointSlotSearched; + slot = this->_multipointSlot; + discoveryLog = this->_multipointDiscoveryLog; + } + + // One-time discovery reads (support functions, slot search) go to discoveryLog so a later refresh + // still shows them; per-refresh reads go to log. + std::string log; + std::string* target = &discoveryLog; + auto read = [&](const Buffer& cmd, unsigned char retCommandId, int retSubType, DATA_TYPE dataType) -> Buffer { + std::string label = dataType == DATA_TYPE::DATA_MDR_NO2 ? "T2 " : "T1 "; + try + { + auto resp = this->_conn.sendCommandAndReadResponse(cmd, retCommandId, retSubType, dataType); + *target += label + hexString(cmd) + " -> " + hexString(resp) + "\n"; + return resp; + } + catch (const std::exception& exc) + { + *target += label + hexString(cmd) + " -> (" + exc.what() + ")\n"; + return {}; + } + }; + + bool hasPairedDevices = false; std::vector pairedDevices; + bool hasPinState = false; bool pinned = false; + if (!table2Supported) + { + log += "table 2 not advertised in the init reply; skipped the device list\n"; + } + else + { + if (!functionsRead) + { + target = &discoveryLog; + // 07 00 ( ) x n + auto resp = read({ (char)V2Table2Command::SUPPORT_FUNCTION_GET, 0x00 }, + V2Table2Command::SUPPORT_FUNCTION_RET, 0x00, DATA_TYPE::DATA_MDR_NO2); + if (resp.size() >= 3) + { + size_t count = (unsigned char)resp[2]; + for (size_t i = 0; i < count && 3 + i * 2 < resp.size(); i++) + { + functions.push_back((unsigned char)resp[3 + i * 2]); + } + functionsRead = true; + } + } + target = &log; + auto supports = [&](unsigned char function) { + return std::find(functions.begin(), functions.end(), function) != functions.end(); + }; + + bool withClassOfDevice = supports(V2Table2Command::FN_DEVICES_WITH_COD) || supports(V2Table2Command::FN_DEVICES_WITH_COD_LE); + if (withClassOfDevice || supports(V2Table2Command::FN_DEVICES_CLASSIC)) + { + unsigned char type = withClassOfDevice ? V2Table2Command::PERI_DEVICES_WITH_COD : V2Table2Command::PERI_DEVICES_CLASSIC; + auto resp = read({ (char)V2Table2Command::PERI_GET_PARAM, (char)type }, + V2Table2Command::PERI_RET_PARAM, type, DATA_TYPE::DATA_MDR_NO2); + hasPairedDevices = parsePairedDevices(resp, withClassOfDevice, pairedDevices); + if (!resp.empty() && !hasPairedDevices) log += "device list: unexpected layout\n"; + } + + if (supports(V2Table2Command::FN_SOURCE_SWITCH)) + { + // 37 01 : 1 = playback switches freely, 0 = playback device fixed. + auto resp = read({ (char)V2Table2Command::PERI_GET_PARAM, (char)V2Table2Command::PERI_SOURCE_SWITCH }, + V2Table2Command::PERI_RET_PARAM, V2Table2Command::PERI_SOURCE_SWITCH, DATA_TYPE::DATA_MDR_NO2); + if (resp.size() >= 3) + { + hasPinState = true; + pinned = resp[2] == 0x00; + } + } + } + + // The multipoint on/off switch is a table 1 general setting whose slot (D1..D4) differs per model: + // find it by name once per connection, then just read its value. + if (!slotSearched) + { + target = &discoveryLog; + for (int candidate = V2Command::GS_SLOT_FIRST; candidate <= V2Command::GS_SLOT_LAST; candidate++) + { + // d1 + auto resp = read({ (char)V2Command::GS_GET_CAPABILITY, (char)candidate, (char)V2Command::GS_DISPLAY_LANGUAGE_ENGLISH }, + V2Command::GS_RET_CAPABILITY, candidate, DATA_TYPE::DATA_MDR); + if (resp.size() < 5 || resp[2] != 0x00) continue; + size_t nameLength = (unsigned char)resp[4]; + if (5 + nameLength > resp.size()) continue; + if (std::string(resp.begin() + 5, resp.begin() + 5 + nameLength) == "MULTIPOINT_SETTING") + { + slot = candidate; + break; + } + } + slotSearched = true; + } + target = &log; + + bool hasMultipointSetting = false; bool multipointEnabled = false; + if (slot > 0) + { + // d7 + auto resp = read({ (char)V2Command::GS_GET_PARAM, (char)slot }, V2Command::GS_RET_PARAM, slot, DATA_TYPE::DATA_MDR); + if (resp.size() >= 4 && resp[2] == 0x00) + { + hasMultipointSetting = true; + multipointEnabled = resp[3] == 0x00; + } + } + + std::lock_guard guard(this->_propertyMtx); + this->_table2FunctionsRead = functionsRead; + this->_table2Functions = functions; + this->_multipointSlotSearched = slotSearched; + this->_multipointSlot = slot; + this->_hasPairedDevices = hasPairedDevices; + this->_pairedDevices = pairedDevices; + this->_hasPlaybackPinState = hasPinState; + this->_playbackPinned = pinned; + this->_hasMultipointSetting = hasMultipointSetting; + this->_multipointEnabled = multipointEnabled; + this->_multipointDiscoveryLog = discoveryLog; + this->_multipointDiagnostics = "init " + (initReply.empty() ? std::string("(no reply)") : hexString(initReply)) + "\n" + + discoveryLog + this->_multipointWriteLog + log; +} + +int Headphones::switchPlaybackDevice(const std::string& address) +{ + std::string target; + { + std::lock_guard guard(this->_propertyMtx); + bool supported = this->_table2Supported && std::find(this->_table2Functions.begin(), this->_table2Functions.end(), + V2Table2Command::FN_SOURCE_SWITCH) != this->_table2Functions.end(); + if (!supported) throw RecoverableException("This headset doesn't support switching the playback device."); + auto sameAddress = [&](const std::string& other) { + return other.size() == address.size() && std::equal(other.begin(), other.end(), address.begin(), + [](char a, char b) { return std::toupper((unsigned char)a) == std::toupper((unsigned char)b); }); + }; + for (const auto& device : this->_pairedDevices) + { + if (device.slot != 0 && sameAddress(device.address)) target = device.address; + } + } + if (target.size() != V2Table2Command::BT_ADDRESS_LENGTH) + { + throw RecoverableException("That device isn't connected to the headset."); + } + + // SET: 3c 01 -> NTFY: 3d 01
+ Buffer cmd = { (char)V2Table2Command::PERI_SET_EXTENDED_PARAM, (char)V2Table2Command::PERI_SOURCE_SWITCH }; + cmd.insert(cmd.end(), target.begin(), target.end()); + int result = V2Table2Command::RESULT_NO_CONFIRMATION; + std::string line = "W T2 " + hexString(cmd) + " -> "; + try + { + auto resp = this->_conn.sendCommandAndReadResponse(cmd, V2Table2Command::PERI_NTFY_EXTENDED_PARAM, + V2Table2Command::PERI_SOURCE_SWITCH, DATA_TYPE::DATA_MDR_NO2); + line += hexString(resp); + if (resp.size() >= 3) result = (unsigned char)resp[2]; + } + catch (const std::exception& exc) + { + line += std::string("(") + exc.what() + ")"; + } + std::lock_guard guard(this->_propertyMtx); + this->_multipointWriteLog += line + "\n"; + return result; +} + +int Headphones::setPlaybackPinned(bool pinned) +{ + { + std::lock_guard guard(this->_propertyMtx); + if (!this->_hasPlaybackPinState) throw RecoverableException("This headset doesn't support fixing the playback device."); + } + + // SET: 38 01 <00=fixed, 01=free> -> NTFY: 39 01 + char value = pinned ? 0x00 : 0x01; + Buffer cmd = { (char)V2Table2Command::PERI_SET_PARAM, (char)V2Table2Command::PERI_SOURCE_SWITCH, value }; + int result = V2Table2Command::RESULT_NO_CONFIRMATION; + std::string line = "W T2 " + hexString(cmd) + " -> "; + try + { + auto resp = this->_conn.sendCommandAndReadResponse(cmd, V2Table2Command::PERI_NTFY_PARAM, + V2Table2Command::PERI_SOURCE_SWITCH, DATA_TYPE::DATA_MDR_NO2); + line += hexString(resp); + if (resp.size() >= 4) result = (unsigned char)resp[3]; + else if (resp.size() == 3) result = resp[2] == value ? V2Table2Command::RESULT_SUCCESS : V2Table2Command::RESULT_FAIL; + } + catch (const std::exception& exc) + { + line += std::string("(") + exc.what() + ")"; + } + std::lock_guard guard(this->_propertyMtx); + this->_multipointWriteLog += line + "\n"; + return result; +} + +bool Headphones::hasPairedDevices() { std::lock_guard g(this->_propertyMtx); return this->_hasPairedDevices; } +std::vector Headphones::getPairedDevices() { std::lock_guard g(this->_propertyMtx); return this->_pairedDevices; } +bool Headphones::hasPlaybackPinState() { std::lock_guard g(this->_propertyMtx); return this->_hasPlaybackPinState; } +bool Headphones::isPlaybackPinned() { std::lock_guard g(this->_propertyMtx); return this->_playbackPinned; } +bool Headphones::hasMultipointSetting() { std::lock_guard g(this->_propertyMtx); return this->_hasMultipointSetting; } +bool Headphones::getMultipointEnabled() { std::lock_guard g(this->_propertyMtx); return this->_multipointEnabled; } +std::string Headphones::getMultipointDiagnostics() { std::lock_guard g(this->_propertyMtx); return this->_multipointDiagnostics; } + 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..19520ce2 100644 --- a/Client/Headphones.h +++ b/Client/Headphones.h @@ -3,6 +3,18 @@ #include "Constants.h" #include +#include +#include + +// One entry of the headset's paired-device list (multipoint, v2 table 2). +struct PairedDevice +{ + std::string address; // "XX:XX:XX:XX:XX:XX", as the headset reports it + std::string name; // may be empty + int slot = 0; // multipoint slot: 0 = paired but not connected, 1/2 = connected + int classOfDevice = -1; // 24-bit Bluetooth class of device; -1 if not reported, 0xFFFFFF for paired-only + bool playback = false; // currently holds the playback (A2DP) right +}; template struct Property { @@ -86,6 +98,26 @@ class Headphones { bool getAdaptiveVolume(); void setAdaptiveVolume(bool enabled); + // Multipoint (v2, read-only). Only GETs are sent, and the table 2 reads only when initDevice()'s reply + // says the device has command table 2. Each piece is exposed only if the device answered it. + void requestMultipoint(); + bool hasPairedDevices(); + std::vector getPairedDevices(); + bool hasPlaybackPinState(); + bool isPlaybackPinned(); + bool hasMultipointSetting(); + bool getMultipointEnabled(); + // Hex log of the init reply and every multipoint request/reply from the last requestMultipoint(), + // for bringing the protocol up on models nobody has captured yet. + std::string getMultipointDiagnostics(); + + // Multipoint writes. Each sends exactly one command and returns a V2Table2Command::RESULT_* code. They + // throw RecoverableException (without sending) unless requestMultipoint() saw the device advertise + // source-switch control. Call requestMultipoint() afterwards to read back the real state. + // switchPlaybackDevice: the address must be a connected device from the last device list. + int switchPlaybackDevice(const std::string& address); + int setPlaybackPinned(bool pinned); + bool isChanged(); void setChanges(); private: @@ -112,6 +144,17 @@ class Headphones { bool _hasSpeakToChat = false; bool _speakToChat = false; bool _hasAdaptiveVolume = false; bool _adaptiveVolume = false; + Buffer _initReply; + bool _table2Supported = false; + bool _table2FunctionsRead = false; std::vector _table2Functions; + bool _hasPairedDevices = false; std::vector _pairedDevices; + bool _hasPlaybackPinState = false; bool _playbackPinned = false; + bool _multipointSlotSearched = false; int _multipointSlot = -1; + bool _hasMultipointSetting = false; bool _multipointEnabled = false; + std::string _multipointDiscoveryLog; + std::string _multipointWriteLog; + std::string _multipointDiagnostics; + std::mutex _propertyMtx; BluetoothWrapper& _conn; diff --git a/Client/macos/AppDelegate.mm b/Client/macos/AppDelegate.mm index 5d09dc3e..7339a465 100644 --- a/Client/macos/AppDelegate.mm +++ b/Client/macos/AppDelegate.mm @@ -6,6 +6,7 @@ // #import "AppDelegate.h" +#import "SonyHeadphonesClient-Swift.h" @interface AppDelegate () @@ -15,6 +16,7 @@ @interface AppDelegate () @implementation AppDelegate - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { _window = [[[NSApplication sharedApplication] windows] firstObject]; + [MenuBarController install]; } - (BOOL)applicationShouldHandleReopen:(NSApplication *)theApplication hasVisibleWindows:(BOOL)flag diff --git a/Client/macos/ContentView.swift b/Client/macos/ContentView.swift index 45b0388a..e7ceb6cb 100644 --- a/Client/macos/ContentView.swift +++ b/Client/macos/ContentView.swift @@ -5,6 +5,7 @@ import SwiftUI import AppKit +import Combine private enum Theme { static let bg = Color(red: 0.07, green: 0.07, blue: 0.086) @@ -16,7 +17,7 @@ private enum Theme { @available(macOS 11.0, *) struct ContentView: View { - @StateObject private var model = HeadphonesModel() + @StateObject private var model = HeadphonesModel.shared @State private var showAbout = false var body: some View { @@ -82,6 +83,9 @@ struct ContentView: View { if model.hasAdaptiveVolume || model.hasSpeakToChat || model.hasAutoPowerOff { settingsCard } + if !model.multipointDiagnostics.isEmpty { + devicesCard + } if let error = model.errorMessage { Text(error) .font(.system(size: 12)) @@ -387,6 +391,156 @@ struct ContentView: View { .toggleStyle(SwitchToggleStyle(tint: Theme.accent)) } + // Multipoint device list. Read-only for now: nothing here sends a command that changes the headset. + private var devicesCard: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Devices") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + Spacer() + Button(action: model.refreshMultipoint) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Theme.secondary) + } + .buttonStyle(PlainButtonStyle()) + .disabled(model.refreshingMultipoint) + .opacity(model.refreshingMultipoint ? 0.4 : 1) + } + if model.hasMultipointSetting { + devicesInfoRow("Connect to 2 devices", model.multipointEnabled ? "On" : "Off") + } + if model.hasPlaybackPinState { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Playback device").font(.system(size: 13, weight: .medium)).foregroundColor(.white) + Text(model.playbackPinned ? "Fixed" : "Switches automatically") + .font(.system(size: 11)).foregroundColor(Theme.secondary) + } + Spacer() + Button(action: { model.setPlaybackPinned(!model.playbackPinned) }) { + Text(model.playbackPinned ? "Unfix" : "Fix") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(Theme.cardHi) + .clipShape(Capsule()) + } + .buttonStyle(PlainButtonStyle()) + .disabled(model.multipointBusy) + } + } + if model.hasPairedDevices { + let connected = model.pairedDevices.filter { $0.connected }.sorted { $0.slot < $1.slot } + let paired = model.pairedDevices.filter { !$0.connected } + if !connected.isEmpty { + devicesSectionLabel("Connected") + ForEach(connected) { deviceRow($0) } + } + if !paired.isEmpty { + devicesSectionLabel("Paired") + ForEach(paired) { deviceRow($0) } + } + } else { + Text("This headset didn't return a device list.") + .font(.system(size: 12)) + .foregroundColor(Theme.secondary) + } + Divider().background(Theme.cardHi) + HStack { + Text(model.multipointBusy ? "Sending…" : "Switching and fixing are experimental") + .font(.system(size: 11)) + .foregroundColor(Theme.secondary.opacity(0.7)) + Spacer() + Button(action: copyMultipointDiagnostics) { + Text("Copy diagnostics") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Theme.accent) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private func devicesInfoRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label).font(.system(size: 13, weight: .medium)).foregroundColor(.white) + Spacer() + Text(value).font(.system(size: 12)).foregroundColor(Theme.secondary) + } + } + + private func devicesSectionLabel(_ text: String) -> some View { + Text(text.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Theme.secondary.opacity(0.8)) + .padding(.top, 2) + } + + private func deviceRow(_ device: PairedDeviceInfo) -> some View { + HStack(spacing: 12) { + Image(systemName: deviceSymbol(device.classOfDevice)) + .font(.system(size: 15)) + .foregroundColor(device.connected ? .white : Theme.secondary) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(device.name.isEmpty ? device.address : device.name) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(device.connected ? .white : Theme.secondary) + .lineLimit(1) + if device.isThisMac { + Text("This Mac").font(.system(size: 11)).foregroundColor(Theme.accent) + } + } + Spacer() + if device.playback { + if model.playbackPinned { + Image(systemName: "lock.fill") + .font(.system(size: 11)) + .foregroundColor(Theme.accent) + } + Image(systemName: "speaker.wave.2.fill") + .font(.system(size: 12)) + .foregroundColor(Theme.accent) + } + if device.connected && model.hasPlaybackPinState { + Menu { + Button("Switch playback device and fix") { model.switchPlayback(to: device, fix: true) } + if !device.playback { + Button("Switch playback device") { model.switchPlayback(to: device, fix: false) } + } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(BorderlessButtonMenuStyle()) + .fixedSize() + .disabled(model.multipointBusy) + } + } + } + + // Major device class lives in bits 8-12 of the class of device (1 = computer, 2 = phone). + private func deviceSymbol(_ classOfDevice: Int) -> String { + guard classOfDevice >= 0, classOfDevice != 0xFFFFFF else { return "dot.radiowaves.left.and.right" } + switch (classOfDevice >> 8) & 0x1F { + case 1: return "laptopcomputer" + case 2: return "iphone" + default: return "dot.radiowaves.left.and.right" + } + } + + private func copyMultipointDiagnostics() { + let header = "\(model.deviceName) · fw \(model.firmware) · \(model.protocolVersion)\n" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(header + model.multipointDiagnostics, forType: .string) + } + // ⋯ About / device info popover. private var aboutView: some View { VStack(alignment: .leading, spacing: 0) { @@ -439,6 +593,173 @@ struct ContentView: View { } } +// MARK: - Menu bar + +// Status-bar item for pinning playback to this Mac without opening the window. Installed once from +// AppDelegate; reads and drives the same HeadphonesModel as the window. The icon shows the playback device +// next to the headphones (phone, computer, or its multipoint slot number), and the headphones fill in +// while playback is fixed to this Mac. +@objc final class MenuBarController: NSObject, NSMenuDelegate { + private static var instance: MenuBarController? + + private let model = HeadphonesModel.shared + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private var cancellables = Set() + + @objc static func install() { + guard instance == nil else { return } + instance = MenuBarController() + } + + private override init() { + super.init() + let menu = NSMenu() + menu.delegate = self + menu.autoenablesItems = false + statusItem.menu = menu + updateIcon() + // objectWillChange fires before the new values land, so read them on the next main-loop turn. + model.objectWillChange + .sink { [weak self] _ in DispatchQueue.main.async { self?.updateIcon() } } + .store(in: &cancellables) + } + + private var thisMac: PairedDeviceInfo? { + model.pairedDevices.first { $0.isThisMac && $0.connected } + } + + private var pinnedToThisMac: Bool { + model.playbackPinned && thisMac?.playback == true + } + + private var playbackDevice: PairedDeviceInfo? { + model.connected ? model.pairedDevices.first { $0.playback } : nil + } + + // Phone if the name says so, computer for this Mac (or another Mac), otherwise the slot number. + private func playbackSymbol(for device: PairedDeviceInfo) -> String { + let name = device.name.lowercased() + if name.contains("phone") { return "iphone" } + if device.isThisMac || name.contains("mac") { return "laptopcomputer" } + return "\(device.slot).circle" + } + + private func updateIcon() { + let base = pinnedToThisMac ? "headphones.circle.fill" : "headphones" + let device = playbackDevice + statusItem.button?.image = Self.icon(base: base, accessory: device.map(playbackSymbol(for:))) + statusItem.button?.appearsDisabled = !model.connected + if let device = device { + let name = device.name.isEmpty ? device.address : device.name + statusItem.button?.toolTip = "Playing on \(name)" + (model.playbackPinned ? " (fixed)" : "") + } else { + statusItem.button?.toolTip = model.connected ? model.deviceName : "SonyBridge" + } + } + + // Draws the headphones symbol with an optional second symbol beside it, as one template image. + private static func icon(base: String, accessory: String?) -> NSImage? { + let baseConfig = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) + let accessoryConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular) + guard let baseImage = NSImage(systemSymbolName: base, accessibilityDescription: "SonyBridge")? + .withSymbolConfiguration(baseConfig) else { return nil } + guard let accessory = accessory, + let accessoryImage = NSImage(systemSymbolName: accessory, accessibilityDescription: nil)? + .withSymbolConfiguration(accessoryConfig) else { + baseImage.isTemplate = true + return baseImage + } + let spacing: CGFloat = 2 + let size = NSSize(width: baseImage.size.width + spacing + accessoryImage.size.width, + height: max(baseImage.size.height, accessoryImage.size.height)) + let image = NSImage(size: size, flipped: false) { _ in + baseImage.draw(in: NSRect(x: 0, y: (size.height - baseImage.size.height) / 2, + width: baseImage.size.width, height: baseImage.size.height)) + accessoryImage.draw(in: NSRect(x: baseImage.size.width + spacing, y: (size.height - accessoryImage.size.height) / 2, + width: accessoryImage.size.width, height: accessoryImage.size.height)) + return true + } + image.isTemplate = true + image.accessibilityDescription = "SonyBridge" + return image + } + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + if model.connected { + menu.addItem(disabledItem(model.deviceName)) + if model.hasPlaybackPinState { + if thisMac != nil { + let pin = actionItem(pinnedToThisMac ? "Playback Fixed to This Mac" : "Pin Playback to This Mac", + #selector(pinToThisMac)) + pin.state = pinnedToThisMac ? .on : .off + pin.isEnabled = !pinnedToThisMac && !model.multipointBusy + menu.addItem(pin) + } else { + menu.addItem(disabledItem("This Mac isn't a connected device")) + } + if model.playbackPinned { + let unfix = actionItem("Unfix Playback", #selector(unfixPlayback)) + unfix.isEnabled = !model.multipointBusy + menu.addItem(unfix) + } + } + if model.multipointBusy { + menu.addItem(disabledItem("Sending…")) + } else if let error = model.errorMessage { + menu.addItem(disabledItem(error)) + } + // Re-read so the next open reflects changes made from the phone or the headset. + if !model.multipointBusy && !model.refreshingMultipoint { + model.refreshMultipoint() + } + } else { + menu.addItem(disabledItem("No headphones connected")) + let connect = actionItem(model.connecting ? "Connecting…" : "Connect Headphones", #selector(connectHeadphones)) + connect.isEnabled = !model.connecting + menu.addItem(connect) + } + menu.addItem(.separator()) + menu.addItem(actionItem("Open SonyBridge", #selector(openWindow))) + menu.addItem(actionItem("Quit SonyBridge", #selector(quit), key: "q")) + } + + @objc private func pinToThisMac() { + guard let thisMac = thisMac else { return } + model.switchPlayback(to: thisMac, fix: true) + } + + @objc private func unfixPlayback() { + model.setPlaybackPinned(false) + } + + @objc private func connectHeadphones() { + NSApp.activate(ignoringOtherApps: true) + model.connect() + } + + @objc private func openWindow() { + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first { $0.contentViewController != nil }?.makeKeyAndOrderFront(nil) + } + + @objc private func quit() { + NSApp.terminate(nil) + } + + private func actionItem(_ title: String, _ action: Selector, key: String = "") -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: key) + item.target = self + return item + } + + private func disabledItem(_ title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } +} + // Factory so the Obj-C++ ViewController can instantiate the SwiftUI hierarchy. @available(macOS 11.0, *) @objc final class HeadphonesUIFactory: NSObject { diff --git a/Client/macos/HeadphonesBridge.h b/Client/macos/HeadphonesBridge.h index dfbdd039..a049b9d3 100644 --- a/Client/macos/HeadphonesBridge.h +++ b/Client/macos/HeadphonesBridge.h @@ -14,6 +14,16 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { SHCAmbientModeAmbientSound = 2, }; +// One entry of the headset's paired-device list (multipoint). +@interface SHCPairedDevice : NSObject +@property (nonatomic, copy, readonly) NSString *name; +@property (nonatomic, copy, readonly) NSString *address; // "XX:XX:XX:XX:XX:XX" +@property (nonatomic, readonly) NSInteger slot; // 0 = paired only, 1/2 = connected +@property (nonatomic, readonly) NSInteger classOfDevice; // 24-bit Bluetooth CoD, -1 if not reported +@property (nonatomic, readonly) BOOL playback; // holds the playback right +@property (nonatomic, readonly) BOOL isThisMac; +@end + @interface HeadphonesBridge : NSObject @property (nonatomic, readonly) BOOL connected; @@ -54,6 +64,15 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) BOOL hasAdaptiveVolume; @property (nonatomic, readonly) BOOL adaptiveVolume; +// Multipoint (read-only; v2 devices that answered the reads on connect or on refreshMultipoint). +@property (nonatomic, readonly) BOOL hasPairedDevices; +@property (nonatomic, copy, readonly) NSArray *pairedDevices; +@property (nonatomic, readonly) BOOL hasPlaybackPinState; +@property (nonatomic, readonly) BOOL playbackPinned; +@property (nonatomic, readonly) BOOL hasMultipointSetting; +@property (nonatomic, readonly) BOOL multipointEnabled; +@property (nonatomic, copy, readonly) NSString *multipointDiagnostics; // hex log of the last multipoint reads + // Runs the native Bluetooth device picker (modal, main thread) and connects to the chosen device. // completion is called on the main thread. - (void)scanAndConnectWithCompletion:(void (^)(BOOL ok, NSString * _Nullable error))completion; @@ -89,6 +108,18 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { // own button show up in the app. Called on a timer while connected. - (void)refreshDynamicWithCompletion:(void (^)(void))completion; +// Re-reads the paired-device list, pinned state and multipoint switch (GETs only); completion on main. +- (void)refreshMultipointWithCompletion:(void (^)(void))completion; + +// Moves playback to a connected device (address from pairedDevices). With fix=YES it is Sound Connect's +// "Switch playback device and fix": unfix if fixed, switch (skipped if it already plays), then fix - +// stopping at the first refused step. With fix=NO it switches, then unfixes if playback was fixed, so it +// ends switching freely. State is re-read from the headset afterwards; completion on main. +- (void)switchPlaybackToAddress:(NSString *)address fix:(BOOL)fix completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; + +// Fixes (pins) or frees the current playback device; one write, then a re-read. Completion on main. +- (void)setPlaybackPinned:(BOOL)pinned completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; + @end NS_ASSUME_NONNULL_END diff --git a/Client/macos/HeadphonesBridge.mm b/Client/macos/HeadphonesBridge.mm index 4f1b58d3..12a399a6 100644 --- a/Client/macos/HeadphonesBridge.mm +++ b/Client/macos/HeadphonesBridge.mm @@ -14,6 +14,23 @@ // 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.) +@interface SHCPairedDevice () +@property (nonatomic, copy, readwrite) NSString *name; +@property (nonatomic, copy, readwrite) NSString *address; +@property (nonatomic, readwrite) NSInteger slot; +@property (nonatomic, readwrite) NSInteger classOfDevice; +@property (nonatomic, readwrite) BOOL playback; +@property (nonatomic, readwrite) BOOL isThisMac; +@end + +@implementation SHCPairedDevice +@end + +// IOBluetooth formats addresses as "aa-bb-cc-dd-ee-ff"; the headset reports "AA:BB:CC:DD:EE:FF". +static NSString *SHCNormalizedAddress(NSString *address) { + return [[address uppercaseString] stringByReplacingOccurrencesOfString:@"-" withString:@":"]; +} + @implementation HeadphonesBridge { std::unique_ptr _bt; std::unique_ptr _hp; @@ -23,12 +40,16 @@ @implementation HeadphonesBridge { // 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; + // Multipoint reads can take several seconds (unanswered slot probes time out), so they get their own + // serial queue instead of holding up user taps on _cmdQueue. + dispatch_queue_t _multipointQueue; } - (instancetype)init { if ((self = [super init])) { _bt = std::make_unique(std::make_unique()); _cmdQueue = dispatch_queue_create("com.sonybridge.commands", DISPATCH_QUEUE_SERIAL); + _multipointQueue = dispatch_queue_create("com.sonybridge.multipoint", DISPATCH_QUEUE_SERIAL); } return self; } @@ -122,6 +143,33 @@ - (BOOL)speakToChat { return _hp && _hp->getSpeakToChat(); } - (BOOL)hasAdaptiveVolume { return _hp && _hp->hasAdaptiveVolume(); } - (BOOL)adaptiveVolume { return _hp && _hp->getAdaptiveVolume(); } +- (BOOL)hasPairedDevices { return _hp && _hp->hasPairedDevices(); } +- (BOOL)hasPlaybackPinState { return _hp && _hp->hasPlaybackPinState(); } +- (BOOL)playbackPinned { return _hp && _hp->isPlaybackPinned(); } +- (BOOL)hasMultipointSetting { return _hp && _hp->hasMultipointSetting(); } +- (BOOL)multipointEnabled { return _hp && _hp->getMultipointEnabled(); } +- (NSString *)multipointDiagnostics { return _hp ? @(_hp->getMultipointDiagnostics().c_str()) : @""; } + +- (NSArray *)pairedDevices { + if (!_hp) return @[]; + NSString *hostAddress = SHCNormalizedAddress([[IOBluetoothHostController defaultController] addressAsString] ?: @""); + NSMutableArray *result = [NSMutableArray array]; + for (const auto &device : _hp->getPairedDevices()) { + SHCPairedDevice *item = [SHCPairedDevice new]; + // Names are UTF-8 as far as we know; fall back to Latin-1 so a bad byte can't drop the row. + NSString *name = [[NSString alloc] initWithBytes:device.name.data() length:device.name.size() encoding:NSUTF8StringEncoding] + ?: [[NSString alloc] initWithBytes:device.name.data() length:device.name.size() encoding:NSISOLatin1StringEncoding]; + item.name = name ?: @""; + item.address = SHCNormalizedAddress(@(device.address.c_str())); + item.slot = device.slot; + item.classOfDevice = device.classOfDevice; + item.playback = device.playback; + item.isThisMac = hostAddress.length > 0 && [item.address isEqualToString:hostAddress]; + [result addObject:item]; + } + return result; +} + static BOOL SHCLooksLikeSonyHeadset(NSString *name) { if (name.length == 0) return NO; NSArray *prefixes = @[@"WH-", @"WF-", @"WI-", @"MDR-", @"XB", @"LinkBuds"]; @@ -225,9 +273,98 @@ - (void)refreshStatusWithCompletion:(void (^)(void))completion { // device that doesn't support them. Update the UI again once they've settled. try { hp->probeCapabilities(); } catch (std::exception &exc) {} dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); + + // Multipoint reads need the init reply (table 2 support), so they run after it, serialized with + // any manual refresh. + dispatch_sync(self->_multipointQueue, ^{ + try { hp->requestMultipoint(); } catch (std::exception &exc) {} + }); + dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); }); } +- (void)refreshMultipointWithCompletion:(void (^)(void))completion { + // GETs only, and v2 only: on v1 these opcodes are unverified (0x22-style collisions are possible). + if (!_hp || !self.connected || _bt->getProtocolVersion() != SonyProtocolVersion::V2 || !_initialized) { + completion(); + return; + } + Headphones *hp = _hp.get(); + dispatch_async(_multipointQueue, ^{ + try { hp->requestMultipoint(); } catch (std::exception &exc) {} + dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); + }); +} + +static NSString *_Nullable SHCSourceSwitchError(int result) { + switch (result) { + case V2Table2Command::RESULT_SUCCESS: return nil; + case V2Table2Command::RESULT_FAIL_CALLING: return @"The headset refused: a call is in progress."; + case V2Table2Command::RESULT_FAIL_A2DP_NOT_CONNECTED: return @"The headset refused: that device isn't connected for audio."; + case V2Table2Command::RESULT_FAIL_VOICE_ASSISTANT: return @"The headset refused: the voice assistant has priority."; + case V2Table2Command::RESULT_NO_CONFIRMATION: return @"The headset didn't confirm the change. Showing the state it reports now."; + default: return [NSString stringWithFormat:@"The headset refused the change (result %d).", result]; + } +} + +- (void)runMultipointWrite:(int (^)(Headphones *hp))write completion:(void (^)(BOOL, NSString * _Nullable))completion { + if (!_hp || !self.connected || _bt->getProtocolVersion() != SonyProtocolVersion::V2 || !_initialized) { + completion(NO, @"Not connected."); + return; + } + Headphones *hp = _hp.get(); + dispatch_async(_multipointQueue, ^{ + NSString *error = nil; + try { + error = SHCSourceSwitchError(write(hp)); + } catch (std::exception &exc) { + error = @(exc.what()); + } + try { hp->requestMultipoint(); } catch (std::exception &exc) {} + dispatch_async(dispatch_get_main_queue(), ^{ completion(error == nil, error); }); + }); +} + +- (void)switchPlaybackToAddress:(NSString *)address fix:(BOOL)fix completion:(void (^)(BOOL, NSString * _Nullable))completion { + std::string target = address.UTF8String ?: ""; + BOOL alreadyPlaying = NO; + for (SHCPairedDevice *device in self.pairedDevices) { + if ([device.address isEqualToString:address] && device.playback) alreadyPlaying = YES; + } + BOOL pinned = self.playbackPinned; + [self runMultipointWrite:^int(Headphones *hp) { + if (!fix) { + // The headset keeps "fixed" on across a switch (the fix moves to the new device), so a plain + // switch frees it afterwards. A refused switch leaves the fix untouched. + int result = hp->switchPlaybackDevice(target); + if (result != V2Table2Command::RESULT_SUCCESS || !pinned) return result; + return hp->setPlaybackPinned(false); + } + // A fixed playback device may refuse a switch, so free it first. + BOOL unfixed = NO; + if (pinned && !alreadyPlaying) { + int result = hp->setPlaybackPinned(false); + if (result != V2Table2Command::RESULT_SUCCESS) return result; + unfixed = YES; + } + if (!alreadyPlaying) { + int result = hp->switchPlaybackDevice(target); + if (result != V2Table2Command::RESULT_SUCCESS) { + // Don't leave the old device unfixed after a refused switch (e.g. mid-call). + if (unfixed) hp->setPlaybackPinned(true); + return result; + } + } + return hp->setPlaybackPinned(true); + } completion:completion]; +} + +- (void)setPlaybackPinned:(BOOL)pinned completion:(void (^)(BOOL, NSString * _Nullable))completion { + [self runMultipointWrite:^int(Headphones *hp) { + return hp->setPlaybackPinned(pinned); + } completion:completion]; +} + - (void)refreshDynamicWithCompletion:(void (^)(void))completion { if (!_hp || !self.connected) { completion(); return; } Headphones *hp = _hp.get(); diff --git a/Client/macos/HeadphonesModel.swift b/Client/macos/HeadphonesModel.swift index cda475ad..4e991aaa 100644 --- a/Client/macos/HeadphonesModel.swift +++ b/Client/macos/HeadphonesModel.swift @@ -6,7 +6,22 @@ import Foundation import Combine +struct PairedDeviceInfo: Identifiable, Equatable { + let name: String + let address: String + let slot: Int // 0 = paired only, 1/2 = connected + let classOfDevice: Int // -1 if not reported + let playback: Bool + let isThisMac: Bool + + var id: String { address } + var connected: Bool { slot != 0 } +} + final class HeadphonesModel: ObservableObject { + // One connection per app: the window and the menu-bar item drive the same model. + static let shared = HeadphonesModel() + private let bridge = HeadphonesBridge() @Published var connected = false @@ -43,6 +58,16 @@ final class HeadphonesModel: ObservableObject { @Published var deviceMac = "" @Published var protocolVersion = "" + @Published var hasPairedDevices = false + @Published var pairedDevices: [PairedDeviceInfo] = [] + @Published var hasPlaybackPinState = false + @Published var playbackPinned = false + @Published var hasMultipointSetting = false + @Published var multipointEnabled = false + @Published var multipointDiagnostics = "" + @Published var refreshingMultipoint = false + @Published var multipointBusy = false + @Published var errorMessage: String? private var pollTimer: Timer? @@ -87,9 +112,51 @@ final class HeadphonesModel: ObservableObject { self.speakToChat = self.bridge.speakToChat self.hasAdaptiveVolume = self.bridge.hasAdaptiveVolume self.adaptiveVolume = self.bridge.adaptiveVolume + self.syncMultipointFromBridge() + } + } + + func refreshMultipoint() { + refreshingMultipoint = true + bridge.refreshMultipoint { + self.refreshingMultipoint = false + self.syncMultipointFromBridge() + } + } + + func switchPlayback(to device: PairedDeviceInfo, fix: Bool) { + multipointBusy = true + errorMessage = nil + bridge.switchPlayback(toAddress: device.address, fix: fix) { ok, error in + self.multipointBusy = false + if !ok, let error = error { self.errorMessage = error } + self.syncMultipointFromBridge() } } + func setPlaybackPinned(_ pinned: Bool) { + multipointBusy = true + errorMessage = nil + bridge.setPlaybackPinned(pinned) { ok, error in + self.multipointBusy = false + if !ok, let error = error { self.errorMessage = error } + self.syncMultipointFromBridge() + } + } + + private func syncMultipointFromBridge() { + hasPairedDevices = bridge.hasPairedDevices + pairedDevices = bridge.pairedDevices.map { + PairedDeviceInfo(name: $0.name, address: $0.address, slot: $0.slot, + classOfDevice: $0.classOfDevice, playback: $0.playback, isThisMac: $0.isThisMac) + } + hasPlaybackPinState = bridge.hasPlaybackPinState + playbackPinned = bridge.playbackPinned + hasMultipointSetting = bridge.hasMultipointSetting + multipointEnabled = bridge.multipointEnabled + multipointDiagnostics = bridge.multipointDiagnostics + } + func setAutoPowerOff(_ index: Int) { autoPowerOff = index errorMessage = nil @@ -111,8 +178,15 @@ final class HeadphonesModel: ObservableObject { // Poll the button-changeable state so the app stays in sync when you use the headphone's own controls. private func startDynamicPolling() { stopDynamicPolling() + var tick = 0 dynamicTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in guard let self = self, self.connected else { return } + // Every 5th tick (10 s), also re-read which device has playback, so the menu-bar icon follows + // changes made from the phone. Skipped while a multipoint write or refresh is in flight. + tick += 1 + if tick % 5 == 0 && self.hasPairedDevices && !self.multipointBusy && !self.refreshingMultipoint { + self.bridge.refreshMultipoint { self.syncMultipointFromBridge() } + } self.bridge.refreshDynamic { self.mode = self.bridge.mode let level = self.bridge.ambientLevel @@ -161,6 +235,7 @@ final class HeadphonesModel: ObservableObject { bridge.disconnect() connected = false deviceName = "" + syncMultipointFromBridge() } // The headset can drop the RFCOMM link on its own (idle power-save). Poll so the UI reflects reality From 9efb44ac6b0e2595c04754c4b0e8c76ab045ab69 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 22 Oct 2026 19:22:59 -0300 Subject: [PATCH 2/2] feat(macos): show the playback device sooner, with a loading state - Read the multipoint state (device list, fixed state) right after battery/EQ/DSEE instead of after the capability probes, so the playback device appears with the battery rather than after any probe timeouts. - Devices card shows a "Reading devices from the headset..." placeholder until the first read lands, and a spinner in its header during refreshes, switches and fixes. - Move the Devices card up, right after Ambient Sound Control. --- Client/macos/ContentView.swift | 52 ++++++++++++++++++++++-------- Client/macos/HeadphonesBridge.mm | 13 ++++---- Client/macos/HeadphonesModel.swift | 6 ++++ 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/Client/macos/ContentView.swift b/Client/macos/ContentView.swift index e7ceb6cb..07109e8b 100644 --- a/Client/macos/ContentView.swift +++ b/Client/macos/ContentView.swift @@ -76,6 +76,9 @@ struct ContentView: View { if model.mode == .ambientSound { ambientLevelCard } + if model.multipointLoading || !model.multipointDiagnostics.isEmpty { + devicesCard + } if model.supportsEqualizer { equalizerCard dseeCard @@ -83,9 +86,6 @@ struct ContentView: View { if model.hasAdaptiveVolume || model.hasSpeakToChat || model.hasAutoPowerOff { settingsCard } - if !model.multipointDiagnostics.isEmpty { - devicesCard - } if let error = model.errorMessage { Text(error) .font(.system(size: 12)) @@ -399,14 +399,30 @@ struct ContentView: View { .font(.system(size: 15, weight: .semibold)) .foregroundColor(.white) Spacer() - Button(action: model.refreshMultipoint) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 12, weight: .semibold)) + if model.multipointLoading || model.refreshingMultipoint || model.multipointBusy { + ProgressView() + .scaleEffect(0.5) + .frame(width: 16, height: 16) + } else { + Button(action: model.refreshMultipoint) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Theme.secondary) + } + .buttonStyle(PlainButtonStyle()) + } + } + if model.multipointLoading { + HStack(spacing: 10) { + ProgressView() + .scaleEffect(0.6) + .frame(width: 18, height: 18) + Text("Reading devices from the headset…") + .font(.system(size: 12)) .foregroundColor(Theme.secondary) } - .buttonStyle(PlainButtonStyle()) - .disabled(model.refreshingMultipoint) - .opacity(model.refreshingMultipoint ? 0.4 : 1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) } if model.hasMultipointSetting { devicesInfoRow("Connect to 2 devices", model.multipointEnabled ? "On" : "Off") @@ -443,11 +459,23 @@ struct ContentView: View { devicesSectionLabel("Paired") ForEach(paired) { deviceRow($0) } } - } else { + } else if !model.multipointLoading { Text("This headset didn't return a device list.") .font(.system(size: 12)) .foregroundColor(Theme.secondary) } + if !model.multipointLoading { + devicesFooter + } + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private var devicesFooter: some View { + VStack(alignment: .leading, spacing: 14) { Divider().background(Theme.cardHi) HStack { Text(model.multipointBusy ? "Sending…" : "Switching and fixing are experimental") @@ -462,10 +490,6 @@ struct ContentView: View { .buttonStyle(PlainButtonStyle()) } } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.card) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } private func devicesInfoRow(_ label: String, _ value: String) -> some View { diff --git a/Client/macos/HeadphonesBridge.mm b/Client/macos/HeadphonesBridge.mm index 12a399a6..a51755c2 100644 --- a/Client/macos/HeadphonesBridge.mm +++ b/Client/macos/HeadphonesBridge.mm @@ -267,19 +267,18 @@ - (void)refreshStatusWithCompletion:(void (^)(void))completion { hp->requestEqualizer(); hp->requestDsee(); } catch (std::exception &exc) {} + // The multipoint reads (device list, fixed state) belong with the fast reads so the playback device + // shows up with the battery instead of after the probe timeouts. They need the init reply (table 2 + // support), so they come after it, serialized with any manual refresh. + dispatch_sync(self->_multipointQueue, ^{ + try { hp->requestMultipoint(); } catch (std::exception &exc) {} + }); dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); // ...then the optional-feature probes, which can each take a couple seconds to time out on a // device that doesn't support them. Update the UI again once they've settled. try { hp->probeCapabilities(); } catch (std::exception &exc) {} dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); - - // Multipoint reads need the init reply (table 2 support), so they run after it, serialized with - // any manual refresh. - dispatch_sync(self->_multipointQueue, ^{ - try { hp->requestMultipoint(); } catch (std::exception &exc) {} - }); - dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); }); } diff --git a/Client/macos/HeadphonesModel.swift b/Client/macos/HeadphonesModel.swift index 4e991aaa..14136c46 100644 --- a/Client/macos/HeadphonesModel.swift +++ b/Client/macos/HeadphonesModel.swift @@ -68,6 +68,12 @@ final class HeadphonesModel: ObservableObject { @Published var refreshingMultipoint = false @Published var multipointBusy = false + // True from connect until the first multipoint read has finished. Those reads run after the + // capability probes, so on a v2 headset this can take several seconds. + var multipointLoading: Bool { + connected && protocolVersion == "v2" && multipointDiagnostics.isEmpty + } + @Published var errorMessage: String? private var pollTimer: Timer?