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..07109e8b 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 { @@ -75,6 +76,9 @@ struct ContentView: View { if model.mode == .ambientSound { ambientLevelCard } + if model.multipointLoading || !model.multipointDiagnostics.isEmpty { + devicesCard + } if model.supportsEqualizer { equalizerCard dseeCard @@ -387,6 +391,180 @@ 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() + 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) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + 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 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") + .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()) + } + } + } + + 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 +617,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..a51755c2 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"]; @@ -219,6 +267,12 @@ - (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 @@ -228,6 +282,88 @@ - (void)refreshStatusWithCompletion:(void (^)(void))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..14136c46 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,22 @@ 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 + + // 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? @@ -87,7 +118,49 @@ 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) { @@ -111,8 +184,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 +241,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