diff --git a/.gitignore b/.gitignore index 32ccd080..65c16880 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ coverage.xml .hypothesis/ .pytest_cache/ pytestdebug.log +*.actual # Translations *.mo @@ -530,3 +531,5 @@ Client/imgui.ini # linux build Client/linux/sonyheadphonesclient.build/ + +.superpowers/ diff --git a/Client/BluetoothWrapper.cpp b/Client/BluetoothWrapper.cpp index cd3cb744..ed4ea784 100644 --- a/Client/BluetoothWrapper.cpp +++ b/Client/BluetoothWrapper.cpp @@ -1,5 +1,10 @@ #include "BluetoothWrapper.h" +// How many frames one transaction will read before giving up. The device interleaves ACKs and +// unsolicited notifications with the reply we're after, so a handful of unrelated frames is normal; +// this only bounds a device that never sends what we asked for. +static constexpr int MAX_FRAMES_PER_TRANSACTION = 16; + BluetoothWrapper::BluetoothWrapper(std::unique_ptr connector) { this->_connector.swap(connector); @@ -24,10 +29,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(); @@ -48,10 +53,21 @@ void BluetoothWrapper::connect(const std::string& addr) void BluetoothWrapper::disconnect() noexcept { +#if defined(_WIN32) + // Close the link BEFORE taking the lock. A worker thread can hold _connectorMtx while blocked in + // recv(), and closing the socket is exactly what wakes it up - taking the lock first deadlocks the + // UI thread until the recv timeout expires, and hangs the app on exit. The Windows connector's + // disconnect() is safe to call concurrently with an in-flight recv(). + this->_connector->disconnect(); + std::lock_guard guard(this->_connectorMtx); + this->_seqNumber = 0; + this->_leftoverBytes.clear(); +#else std::lock_guard guard(this->_connectorMtx); this->_seqNumber = 0; this->_leftoverBytes.clear(); this->_connector->disconnect(); +#endif } @@ -65,14 +81,15 @@ 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. - for (int i = 0; i < 16; i++) + for (int i = 0; i < MAX_FRAMES_PER_TRANSACTION; i++) { auto msg = this->_readMessage(); if (msg.dataType == DATA_TYPE::ACK) @@ -80,7 +97,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))) { @@ -92,8 +109,18 @@ Buffer BluetoothWrapper::sendCommandAndReadResponse(const std::vector& byt void BluetoothWrapper::_waitForAck() { - auto msg = this->_readMessage(); - this->_seqNumber = msg.seqNumber; + // Notifications can arrive between a SET and its ACK. _readMessage() acknowledges DATA_MDR frames; + // keep consuming until the device's actual ACK arrives so it cannot poison the next command. + for (int i = 0; i < MAX_FRAMES_PER_TRANSACTION; i++) + { + auto msg = this->_readMessage(); + if (msg.dataType == DATA_TYPE::ACK) + { + this->_seqNumber = msg.seqNumber; + return; + } + } + throw RecoverableException("No acknowledgment received from device", true); } CommandSerializer::Message BluetoothWrapper::_readMessage() @@ -118,19 +145,29 @@ CommandSerializer::Message BluetoothWrapper::_readMessage() { numRecvd = this->_connector->recv(buf, sizeof(buf)); } + if (numRecvd <= 0) + { + throw RecoverableException("Bluetooth connection closed while reading a frame", true); + } - size_t messageStart = 0; - size_t messageEnd = numRecvd; - - for (size_t i = 0; i < numRecvd; i++) + // Every 60/61/62 inside a frame is escaped, so a START_MARKER can only ever begin one. That makes + // resyncing unambiguous, which is what matters after a brief link glitch garbles or truncates a + // chunk: bytes before the first START_MARKER are the tail of a frame we can no longer parse, and + // a second START_MARKER means the frame we were collecting never finished. Both cases resync on + // the new marker here. Previously the first silently concatenated garbage into the message and + // the second threw, in either case leaving the parser stranded mid-stream for the rest of the + // session - every command after that failed and the UI kept snapping back to the old value. + size_t messageStart = ongoingMessage ? 0 : static_cast(numRecvd); + size_t messageEnd = static_cast(numRecvd); + + for (size_t i = 0; i < static_cast(numRecvd); i++) { if (buf[i] == START_MARKER) { - if (ongoingMessage) - { - throw RecoverableException("Invalid: Multiple start markers without an end marker", true); - } + // Whatever we had collected was a truncated frame, not the start of this one. + msgBytes.clear(); messageStart = i + 1; + messageEnd = static_cast(numRecvd); ongoingMessage = true; } else if (ongoingMessage && buf[i] == END_MARKER) @@ -139,20 +176,31 @@ CommandSerializer::Message BluetoothWrapper::_readMessage() ongoingMessage = false; messageFinished = true; // A single recv() can return more than one framed message back-to-back; keep whatever's - // past this message's END_MARKER for the next _waitForAck() call instead of dropping it. + // past this message's END_MARKER for the next read instead of dropping it. this->_leftoverBytes.assign(buf + i + 1, buf + numRecvd); break; } } - msgBytes.insert(msgBytes.end(), buf + messageStart, buf + messageEnd); + if (messageStart < messageEnd) + { + msgBytes.insert(msgBytes.end(), buf + messageStart, buf + messageEnd); + } + + // Bail out on a stream that keeps delivering bytes but never an END_MARKER, so the next read can + // resync on the next START_MARKER instead of growing this buffer forever. + if (msgBytes.size() > MAX_BLUETOOTH_MESSAGE_SIZE) + { + throw RecoverableException("Invalid: message exceeded the maximum size without an end marker", true); + } } while (!messageFinished); auto msg = CommandSerializer::unpackBtMessage(msgBytes); - // 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()); @@ -160,4 +208,3 @@ CommandSerializer::Message BluetoothWrapper::_readMessage() return msg; } - 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/ByteMagic.cpp b/Client/ByteMagic.cpp index 773666a3..910871ff 100644 --- a/Client/ByteMagic.cpp +++ b/Client/ByteMagic.cpp @@ -48,7 +48,7 @@ std::string MACBytesToString(unsigned char addr[]) { // Create hex string from the bytes. std::stringstream mac_addr_str; - for (int i = 0; i < MAC_ADDR_BYTES_LEN; i++) + for (size_t i = 0; i < MAC_ADDR_BYTES_LEN; i++) { mac_addr_str << std::hex << (((addr[MAC_ADDR_BYTES_LEN - 1 - i] & 0xF0) >> 4) & 0xF); mac_addr_str << std::hex << (addr[MAC_ADDR_BYTES_LEN - 1 - i] & 0x0F); diff --git a/Client/CMakeLists.txt b/Client/CMakeLists.txt index 9695cef9..7b42c151 100644 --- a/Client/CMakeLists.txt +++ b/Client/CMakeLists.txt @@ -84,7 +84,10 @@ if (LINUX) endif () if (WIN32) - ADD_DEFINITIONS(-DUNICODE) + target_compile_definitions(SonyHeadphonesClient PRIVATE + UNICODE _UNICODE + NOMINMAX + WIN32_LEAN_AND_MEAN) target_sources(SonyHeadphonesClient PRIVATE windows/WindowsGUI.cpp @@ -94,8 +97,11 @@ if (WIN32) imgui/backends/imgui_impl_win32.cpp ) + # Non-MSVC toolchains have no #pragma comment(lib, ...), so name every import library here. target_link_libraries(SonyHeadphonesClient - PRIVATE d3d11 + PRIVATE d3d11 dxgi d3dcompiler + ws2_32 rpcrt4 bthprops + dwmapi # imgui_impl_win32.cpp ) endif () diff --git a/Client/CommandSerializer.cpp b/Client/CommandSerializer.cpp index c0e4d551..105f1ca4 100644 --- a/Client/CommandSerializer.cpp +++ b/Client/CommandSerializer.cpp @@ -4,7 +4,7 @@ constexpr unsigned char ESCAPED_BYTE_SENTRY = 61; constexpr unsigned char ESCAPED_60 = 44; constexpr unsigned char ESCAPED_61 = 45; constexpr unsigned char ESCAPED_62 = 46; -constexpr int MAX_STEPS_WH_1000_XM3 = 19; +constexpr int MAX_AMBIENT_SOUND_LEVEL_V1 = 20; namespace CommandSerializer { @@ -53,7 +53,7 @@ namespace CommandSerializer { if (i == src.size() - 1) { - throw std::runtime_error("No data left for escaped byte data"); + throw RecoverableException("No data left for escaped byte data", true); } i = i + 1; switch (src[i]) @@ -71,7 +71,7 @@ namespace CommandSerializer break; default: - throw std::runtime_error("Unexpected escaped byte"); + throw RecoverableException("Unexpected escaped byte", true); break; } } @@ -141,7 +141,7 @@ namespace CommandSerializer //1 (type) + 1 (seq) + 4 (size) + 1 (checksum) = 7 bytes minimum, before any payload. if (unescaped.size() < 7) { - throw std::runtime_error("Invalid message: Smaller than the minimum message size"); + throw RecoverableException("Invalid message: Smaller than the minimum message size", true); } unsigned int dataSize = @@ -150,10 +150,14 @@ namespace CommandSerializer ((unsigned int)(unsigned char)unescaped[4] << 8) | ((unsigned int)(unsigned char)unescaped[5]); - if (unescaped.size() < (size_t)6 + dataSize + 1) + if (dataSize > unescaped.size() - 7) { throw RecoverableException("Invalid message: declared size exceeds received data", true); } + if (dataSize != unescaped.size() - 7) + { + throw RecoverableException("Invalid message: trailing data after checksum", true); + } //Checksum covers everything from DATA_TYPE up to (not including) the checksum byte, over the unescaped bytes. if ((unsigned char)unescaped[6 + dataSize] != _sumChecksum(unescaped.data(), 6 + dataSize)) @@ -168,32 +172,18 @@ namespace CommandSerializer return ret; } - NC_DUAL_SINGLE_VALUE getDualSingleForAsmLevel(char asmLevel) + Buffer serializeNcAndAsmSetting(NC_ASM_EFFECT ncAsmEffect, NC_ASM_SETTING_TYPE ncAsmSettingType, NC_DUAL_SINGLE_VALUE ncValue, ASM_SETTING_TYPE asmSettingType, ASM_ID asmId, char asmLevel) { - NC_DUAL_SINGLE_VALUE val = NC_DUAL_SINGLE_VALUE::OFF; - if (asmLevel > MAX_STEPS_WH_1000_XM3) - { - throw std::runtime_error("Exceeded max steps"); - } - else if (asmLevel == 1) - { - val = NC_DUAL_SINGLE_VALUE::SINGLE; - } - else if (asmLevel == 0) + if (asmLevel < 0 || asmLevel > MAX_AMBIENT_SOUND_LEVEL_V1) { - val = NC_DUAL_SINGLE_VALUE::DUAL; + throw std::runtime_error("Ambient sound level must be between 0 and 20"); } - return val; - } - - Buffer serializeNcAndAsmSetting(NC_ASM_EFFECT ncAsmEffect, NC_ASM_SETTING_TYPE ncAsmSettingType, ASM_SETTING_TYPE asmSettingType, ASM_ID asmId, char asmLevel) - { Buffer ret; ret.push_back(static_cast(COMMAND_TYPE::NCASM_SET_PARAM)); ret.push_back(static_cast(NC_ASM_INQUIRED_TYPE::NOISE_CANCELLING_AND_AMBIENT_SOUND_MODE)); ret.push_back(static_cast(ncAsmEffect)); ret.push_back(static_cast(ncAsmSettingType)); - ret.push_back(static_cast(getDualSingleForAsmLevel(asmLevel))); + ret.push_back(static_cast(ncValue)); ret.push_back(static_cast(asmSettingType)); ret.push_back(static_cast(asmId)); ret.push_back(asmLevel); @@ -226,4 +216,3 @@ namespace CommandSerializer } } - diff --git a/Client/CommandSerializer.h b/Client/CommandSerializer.h index 80eb2a30..36418c43 100644 --- a/Client/CommandSerializer.h +++ b/Client/CommandSerializer.h @@ -7,7 +7,6 @@ #include "Exceptions.h" constexpr int MINIMUM_VOICE_FOCUS_STEP = 2; -constexpr unsigned int ASM_LEVEL_DISABLED = -1; namespace CommandSerializer { @@ -37,10 +36,8 @@ namespace CommandSerializer Message unpackBtMessage(const Buffer& src); - NC_DUAL_SINGLE_VALUE getDualSingleForAsmLevel(char asmLevel); - Buffer serializeNcAndAsmSetting(NC_ASM_EFFECT ncAsmEffect, NC_ASM_SETTING_TYPE ncAsmSettingType, ASM_SETTING_TYPE asmSettingType, ASM_ID asmId, char asmLevel); + Buffer serializeNcAndAsmSetting(NC_ASM_EFFECT ncAsmEffect, NC_ASM_SETTING_TYPE ncAsmSettingType, NC_DUAL_SINGLE_VALUE ncValue, ASM_SETTING_TYPE asmSettingType, ASM_ID asmId, char asmLevel); //v2 protocol variant (WH-CH720N, XM5, newer XM4, etc) - different NCASM_SET_PARAM byte layout than v1. Buffer serializeNcAndAsmSettingV2(NC_ASM_EFFECT ncAsmEffect, NC_ASM_SETTING_TYPE_V2 ncAsmSettingType, ASM_ID voicePassthrough, char asmLevel); Buffer serializeVPTSetting(VPT_INQUIRED_TYPE type, unsigned char preset); } - diff --git a/Client/Constants.h b/Client/Constants.h index 91613ca9..7a609e71 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; @@ -14,7 +15,7 @@ inline unsigned char SERVICE_UUID_IN_BYTES[] = { // this is the SERVICE_UUID but 0xb3, 0x2d, 0xe3, 0x16, 0xf5, 0xe0, 0x69, 0xba }; -// Second-generation Sony protocol (WH-CH720N, WF/WH-1000XM5, newer XM4/WF-C700N units, etc). +// Second-generation Sony protocol (WH-CH720N, WF/WH-1000XM5, WF-C700N, etc). inline constexpr auto SERVICE_UUID_V2 = "956C7B26-D49A-4BA8-B03F-B17D393CB6E2"; inline unsigned char SERVICE_UUID_V2_IN_BYTES[] = { // this is the SERVICE_UUID_V2 but in bytes 0x95, 0x6c, 0x7b, 0x26, 0xd4, 0x9a, 0x4b, 0xa8, @@ -27,15 +28,67 @@ enum class SonyProtocolVersion : signed char V2 = 1 }; +// First-generation MDR protocol used by the WH-1000XM4 and earlier models. Several values overlap +// with v2 but mean something entirely different, so call sites must select a namespace only after +// the SDP service UUID has established the protocol version. +namespace V1Command +{ + inline constexpr unsigned char INIT_REQUEST = 0x00; + inline constexpr unsigned char INIT_REPLY = 0x01; + inline constexpr unsigned char FW_GET = 0x04; + inline constexpr unsigned char FW_RET = 0x05; + inline constexpr unsigned char BATTERY_GET = 0x10; + inline constexpr unsigned char BATTERY_RET = 0x11; + inline constexpr unsigned char CODEC_GET = 0x18; + inline constexpr unsigned char CODEC_RET = 0x19; + inline constexpr unsigned char POWER_OFF = 0x22; + inline constexpr unsigned char EQ_GET = 0x56; + inline constexpr unsigned char EQ_RET = 0x57; + inline constexpr unsigned char EQ_SET = 0x58; + inline constexpr unsigned char AMBIENT_GET = 0x66; + inline constexpr unsigned char AMBIENT_RET = 0x67; + inline constexpr unsigned char AMBIENT_SET = 0x68; + inline constexpr unsigned char DSEE_GET = 0xe6; + inline constexpr unsigned char DSEE_RET = 0xe7; + inline constexpr unsigned char DSEE_SET = 0xe8; + inline constexpr unsigned char BTNMODE_GET = 0xf6; + inline constexpr unsigned char BTNMODE_RET = 0xf7; + inline constexpr unsigned char BTNMODE_SET = 0xf8; + inline constexpr unsigned char SUB_AUTO_POWER_OFF = 0x04; + inline constexpr unsigned char SUB_SPEAK_TO_CHAT = 0x05; + inline constexpr unsigned char MANUAL_EQ = 0xff; +} + // Second-generation (v2) inquiry/response command opcodes (the first payload byte). // Byte layouts reverse-engineered by GadgetBridge (SonyProtocolImplV2); verified against WH-CH720N. namespace V2Command { inline constexpr unsigned char INIT_REQUEST = 0x00; // payload: 00 00 inline constexpr unsigned char INIT_REPLY = 0x01; // reply: 01 ... (8 bytes total => v2 device) - inline constexpr unsigned char BATTERY_GET = 0x22; // payload: 22 - inline constexpr unsigned char BATTERY_RET = 0x23; // reply: 23 - inline constexpr unsigned char BATTERY_NTFY = 0x25; // notify: 25 + inline constexpr unsigned char BATTERY_GET = 0x22; // payload: 22 + inline constexpr unsigned char BATTERY_RET = 0x23; // reply: 23 + inline constexpr unsigned char BATTERY_NTFY = 0x25; // notify: 25 + // Battery inquiry sub-types (payload byte 1). A device answers only the ones it actually has, so + // these have to be probed. TWS earbuds report DUAL (or DUAL2 on the WF-C5xx/C700N line) plus CASE; + // everything else reports SINGLE. Values and the DUAL byte layout match GadgetBridge's + // SonyProtocolImplV2.encodeBatteryType() / handleBattery(). + inline constexpr unsigned char BATTERY_SUB_SINGLE = 0x00; // RET 23 00 + inline constexpr unsigned char BATTERY_SUB_DUAL2 = 0x01; // WF-C500 / WF-C510 / WF-C700N + inline constexpr unsigned char BATTERY_SUB_DUAL = 0x09; // WF-1000XM4 / WF-1000XM5 / LinkBuds + inline constexpr unsigned char BATTERY_SUB_CASE = 0x0a; // RET 23 0a + // DUAL/DUAL2 reply: 23 . A level of 0 means that earbud isn't + // reporting (docked in the case / powered down) rather than "empty" - treat it as unknown. + // + // The reply can be LONGER than the reference layout, so size checks must be lower bounds, never + // equality. Captured from a WF-1000XM5 (fw 6.1.0), both earbuds out of the case and full: + // send 22 09 -> 23 09 64 00 64 00 64 64 (8 bytes; documented layout is 6) + // send 22 0a -> 23 0a 64 00 1e (5 bytes; documented layout is 4) + // Confirmed on hardware by docking one earbud and watching the frames change: + // right earbud in the case -> 23 09 64 00 00 00 64 64 (byte [4] = R went 0x64 -> 0x00) + // right earbud back out -> 23 09 64 00 64 00 64 64 + // So bytes [2]/[4] really are the L/R levels, as in the reference layout. The trailing bytes stayed + // fixed (0x64 0x64 on 22 09, 0x1e on 22 0a) across that change, which rules them out as per-earbud + // levels but leaves their meaning unknown - don't reuse them without a sample that moves them. inline constexpr unsigned char EQ_GET = 0x56; // payload: 56 00 inline constexpr unsigned char EQ_RET = 0x57; // reply: 57 00 06 inline constexpr unsigned char EQ_SET = 0x58; // preset: 58 00 00 ; custom: 58 00 A0 06 @@ -55,6 +108,66 @@ 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; + // Noise-control inquired types: 66/67/68 . The WH-1000XM6 reads back via 0x19 (0x17 returns zeros). + inline constexpr unsigned char NCASM_TYPE = 0x17; + inline constexpr unsigned char NCASM_TYPE_XM6 = 0x19; + // Ten-band equalizer inquired type (WH-1000XM6): 56/57/58 04, 10 raw bands 0..12, no clear bass. + inline constexpr unsigned char EQ_TEN_BAND = 0x04; + // 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; + // SET d8 00 <00=ON/01=OFF>. For the multipoint slot the change is committed with the fixed + // system-control frame 98 00 06 01 (identical bytes for ON and OFF); its RET (97 00 06 xx) always reports + // 01, so it is never used as a state source. Order and bytes from GadgetBridge's hardware-verified + // WH-ULT900N support (SonyProtocolImplV2, commits 8cbc309346, e8520865f3, 5d7b37d1b7). + inline constexpr unsigned char GS_SET_PARAM = 0xd8; + inline constexpr unsigned char SYSCTL_SET = 0x98; + inline constexpr unsigned char SUB_SYSCTL_MULTIPOINT = 0x06; +} + +// 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/CrossPlatformGUI.cpp b/Client/CrossPlatformGUI.cpp index 993d9a85..7b52bda7 100644 --- a/Client/CrossPlatformGUI.cpp +++ b/Client/CrossPlatformGUI.cpp @@ -1,9 +1,12 @@ #include "CrossPlatformGUI.h" +#include "imgui_internal.h" // ImGui::RegisterUserTexture() + #include #include #include #include +#include #define STB_IMAGE_IMPLEMENTATION #define STBI_ONLY_PNG @@ -192,6 +195,8 @@ void CrossPlatformGUI::_pumpConnectionState() try { this->_headphones.requestAmbientState(); } catch (const std::exception&) {} if (this->_isV2()) { + // Lets requestBattery() probe the per-earbud layout first on TWS models (WF-*/LinkBuds). + this->_headphones.setDeviceName(this->_connectedDevice.name); try { this->_headphones.requestBattery(); } catch (const std::exception&) {} try { this->_headphones.requestEqualizer(); } catch (const std::exception&) {} try { this->_headphones.requestDsee(); } catch (const std::exception&) {} @@ -241,7 +246,26 @@ void CrossPlatformGUI::_pumpConnectionState() if (!this->_pollFuture.valid() && ++this->_pollCounter >= DYNAMIC_POLL_FRAMES) { this->_pollCounter = 0; - this->_pollFuture.setFromAsync([this]() { this->_headphones.requestAmbientState(); }); + + // Battery is otherwise read once, in the post-connect phase, and never again: the header keeps + // showing the level from the moment you connected for as long as the app stays open. Refresh it + // on the same worker, on its own slower schedule. + bool withBattery = false; + this->_batteryPollCounter += DYNAMIC_POLL_FRAMES; + if (this->_isV2() && this->_batteryPollCounter >= BATTERY_POLL_FRAMES) + { + this->_batteryPollCounter = 0; + withBattery = true; + } + + this->_pollFuture.setFromAsync([this, withBattery]() { + this->_headphones.requestAmbientState(); + // Best effort: a device that doesn't answer the battery inquiry must not kill the poll. + if (withBattery) + { + try { this->_headphones.requestBattery(); } catch (const std::exception&) {} + } + }); } } @@ -317,8 +341,12 @@ const CrossPlatformGUI::DeviceTexture& CrossPlatformGUI::_deviceTexture(const st ImTextureData* tex = IM_NEW(ImTextureData)(); tex->Create(ImTextureFormat_RGBA32, w, h); memcpy(tex->GetPixels(), pixels, (size_t)w * h * 4); - tex->SetStatus(ImTextureStatus_WantCreate); - ImGui::GetPlatformIO().Textures.push_back(tex); + // ImGui::UpdateTexturesEndFrame() clears and rebuilds PlatformIO.Textures from the font atlases + // and the registered user textures on every frame, so pushing into it directly was dropped again + // before the renderer backend ever saw the WantCreate request: the hero image stayed blank in + // Release builds and tripped the "ImDrawCmd is referring to ImTextureData that wasn't uploaded" + // assert in Debug. RegisterUserTexture() is the supported way in (imgui >= 1.92). + ImGui::RegisterUserTexture(tex); out.ref = tex->GetTexRef(); out.w = w; @@ -357,9 +385,19 @@ void CrossPlatformGUI::_drawStatusHeader() if (this->_headphones.hasDualBattery()) { - ImGui::Text("Battery L %d%% R %d%%", this->_headphones.getBatteryLeft(), this->_headphones.getBatteryRight()); + // An earbud that isn't reporting (docked in the case / powered down) reads -1; show a dash. + auto budText = [](int level, bool charging) { + char buf[32]; + if (level < 0) snprintf(buf, sizeof(buf), "--"); + else snprintf(buf, sizeof(buf), "%d%%%s", level, charging ? "+" : ""); + return std::string(buf); + }; + ImGui::Text("Battery L %s R %s", + budText(this->_headphones.getBatteryLeft(), this->_headphones.isBatteryLeftCharging()).c_str(), + budText(this->_headphones.getBatteryRight(), this->_headphones.isBatteryRightCharging()).c_str()); if (this->_headphones.getBatteryCase() >= 0) - ImGui::Text("Case %d%%", this->_headphones.getBatteryCase()); + ImGui::Text("Case %d%%%s", this->_headphones.getBatteryCase(), + this->_headphones.isBatteryCaseCharging() ? " (charging)" : ""); } else { @@ -447,6 +485,9 @@ void CrossPlatformGUI::_drawEqualizer() { this->_uiEqPreset = presets[i].val; int p = presets[i].val; + // setEqualizerPreset() re-reads the bands that go with the new preset (the device restores the + // stored custom curve for Manual), so pull them into the sliders once the command lands. + this->_eqBandsNeedSync = true; this->_sendFeatureCommand([this, p]() { this->_headphones.setEqualizerPreset((EQ_PRESET)p); }); } if (sel) ImGui::PopStyleColor(); @@ -462,9 +503,11 @@ void CrossPlatformGUI::_drawEqualizer() for (int i = 0; i < 6; ++i) { std::string id = "##eqband" + std::to_string(i); - std::string fmt = std::string(bandNames[i]) + " %d"; + std::string fmt = std::string(bandNames[i]) + " %+d"; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); - if (ImGui::SliderInt(id.c_str(), values[i], 0, 10, fmt.c_str())) + // Range is -10..+10 on the wire: Headphones::setEqualizerCustom() clamps to that and sends + // value+10, and requestEqualizer() decodes byte-10. A 0..10 slider could only ever boost. + if (ImGui::SliderInt(id.c_str(), values[i], -10, 10, fmt.c_str())) changed = true; } if (changed) @@ -565,6 +608,15 @@ void CrossPlatformGUI::_sendPendingASMChanges() try { this->_featureCommandFuture.get(); } catch (const RecoverableException& e) { if (e.shouldDisconnect) this->_bt.disconnect(); this->_mq.addMessage(e.what()); } catch (const std::exception& e) { this->_mq.addMessage(e.what()); } + + if (this->_eqBandsNeedSync) + { + this->_eqBandsNeedSync = false; + this->_uiEqPreset = (int)(unsigned char)this->_headphones.getEqualizerPreset(); + for (int i = 0; i < 5; ++i) + this->_uiEqBands[i] = this->_headphones.getEqualizerBand(i); + this->_uiClearBass = this->_headphones.getClearBass(); + } } if (this->_sendCommandFuture.valid() && this->_sendCommandFuture.ready()) diff --git a/Client/CrossPlatformGUI.h b/Client/CrossPlatformGUI.h index f396939f..aceac4a0 100644 --- a/Client/CrossPlatformGUI.h +++ b/Client/CrossPlatformGUI.h @@ -26,6 +26,9 @@ const auto WINDOW_COLOR = ImVec4(0.09f, 0.09f, 0.11f, 1.00f); // Physical-button changes (ASM) are polled roughly every two seconds so the app stays in sync. constexpr auto DYNAMIC_POLL_FRAMES = FPS * 2; +// Battery moves slowly and each read costs a round trip, so it rides along with the ambient poll on a +// much slower schedule instead of getting a request of its own. +constexpr auto BATTERY_POLL_FRAMES = FPS * 30; class CrossPlatformGUI { @@ -81,6 +84,7 @@ class CrossPlatformGUI bool _synced = false; bool _probed = false; int _pollCounter = 0; + int _batteryPollCounter = 0; // UI state, synced from the device once after connect; user edits are pushed back to the headphones. bool _uiAsmOn = false; @@ -90,6 +94,9 @@ class CrossPlatformGUI int _uiEqPreset = 0; std::array _uiEqBands = { 0, 0, 0, 0, 0 }; int _uiClearBass = 0; + // Set when a preset write is in flight, so the band sliders take the values the device reports for + // that preset once it completes instead of keeping the previous preset's. + bool _eqBandsNeedSync = false; bool _uiDsee = false; int _uiAutoPowerOff = 0; bool _uiSpeakToChat = false; diff --git a/Client/Headphones.cpp b/Client/Headphones.cpp index 54d010f2..10b20c64 100644 --- a/Client/Headphones.cpp +++ b/Client/Headphones.cpp @@ -5,6 +5,7 @@ #include #include #include +#include Headphones::Headphones(BluetoothWrapper& conn) : _conn(conn) { @@ -18,11 +19,13 @@ void Headphones::setAmbientSoundControl(bool val) bool Headphones::getAmbientSoundControl() { + std::lock_guard guard(this->_propertyMtx); return this->_ambientSoundControl.current; } bool Headphones::isFocusOnVoiceAvailable() { + std::lock_guard guard(this->_propertyMtx); return this->_ambientSoundControl.current && this->_asmLevel.current > MINIMUM_VOICE_FOCUS_STEP; } @@ -34,22 +37,25 @@ void Headphones::setFocusOnVoice(bool val) bool Headphones::getFocusOnVoice() { + std::lock_guard guard(this->_propertyMtx); return this->_focusOnVoice.current; } bool Headphones::isSetAsmLevelAvailable() { + std::lock_guard guard(this->_propertyMtx); return this->_ambientSoundControl.current; } void Headphones::setAsmLevel(int val) { std::lock_guard guard(this->_propertyMtx); - this->_asmLevel.desired = val; + this->_asmLevel.desired = std::clamp(val, 0, 20); } int Headphones::getAsmLevel() { + std::lock_guard guard(this->_propertyMtx); return this->_asmLevel.current; } @@ -61,6 +67,7 @@ void Headphones::setSurroundPosition(SOUND_POSITION_PRESET val) SOUND_POSITION_PRESET Headphones::getSurroundPosition() { + std::lock_guard guard(this->_propertyMtx); return this->_surroundPosition.current; } @@ -72,79 +79,225 @@ void Headphones::setVptType(int val) int Headphones::getVptType() { + std::lock_guard guard(this->_propertyMtx); return this->_vptType.current; } 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( - { (char)V2Command::INIT_REQUEST, 0x00 }, - V2Command::INIT_REPLY + // Both protocol generations expect an init exchange before they answer state inquiries. On v2 the reply + // (INIT_REPLY, 8 bytes) also says whether the device has command table 2, which gates every multipoint read. + auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; + auto resp = this->_conn.sendCommandAndReadResponse( + { (char)(isV1 ? V1Command::INIT_REQUEST : V2Command::INIT_REQUEST), 0x00 }, + isV1 ? V1Command::INIT_REPLY : V2Command::INIT_REPLY ); + std::lock_guard guard(this->_propertyMtx); + this->_initReply = resp; + this->_table2Supported = !isV1 && resp.size() > V2Command::INIT_REPLY_TABLE2_INDEX + && resp[V2Command::INIT_REPLY_TABLE2_INDEX] == 0x00; } -void Headphones::requestBattery() +void Headphones::setDeviceName(const std::string& name) { - // Single battery (over-ear and most models): GET 22 00 -> RET 23 00 + std::lock_guard guard(this->_propertyMtx); + this->_deviceName = name; +} + +// True for Sony's true-wireless earbuds, which report a battery per earbud instead of one for the +// whole device. Only used to pick the probe order - an unrecognised name still finds both layouts, +// just one timed-out inquiry slower. +bool Headphones::_looksLikeEarbuds() const +{ + std::string name; + for (char c : this->_deviceName) name.push_back((char)std::tolower((unsigned char)c)); + return name.find("wf-") != std::string::npos || name.find("linkbuds") != std::string::npos; +} + +bool Headphones::_isWh1000Xm6() const +{ + std::string name; + for (char c : this->_deviceName) name.push_back((char)std::tolower((unsigned char)c)); + return name.find("wh-1000xm6") != std::string::npos || name.find("wh1000xm6") != std::string::npos; +} + +// Reads the per-earbud (and case) levels. Returns false if the device didn't answer this sub-type or +// answered with nothing usable, so the caller can fall back to the other layout. +bool Headphones::_requestDualBattery(unsigned char subType) +{ + Buffer resp; try { - auto resp = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BATTERY_GET, 0x00 }, V2Command::BATTERY_RET, 0x00); - if (resp.size() >= 4) { - std::lock_guard guard(this->_propertyMtx); - this->_batteryLevel = (unsigned char)resp[2]; - this->_batteryCharging = resp[3] == 1; - return; // found a single battery; don't waste time probing the TWS types - } - } catch (...) {} + resp = this->_conn.sendCommandAndReadResponse( + { (char)V2Command::BATTERY_GET, (char)subType }, V2Command::BATTERY_RET, subType); + } catch (const std::exception&) { + return false; + } + if (resp.size() < 6) return false; + + const int left = (unsigned char)resp[2]; + const int right = (unsigned char)resp[4]; + + // Both zero is still a real answer: both earbuds are docked (level 0 means "not reporting", not empty). + // Rejecting it would fall through to the DUAL2 and single probes, which time out on these models and + // stall the command queue for ~5s on every battery poll while the buds sit in the case. + std::lock_guard guard(this->_propertyMtx); + this->_hasDualBattery = true; + this->_hasBattery = true; + this->_batteryLeft = left > 0 ? left : -1; + this->_batteryRight = right > 0 ? right : -1; + this->_batteryLeftCharging = resp[3] == 1; + this->_batteryRightCharging = resp[5] == 1; + // Headline level: the emptier of the two earbuds that are actually reporting. + if (this->_batteryLeft >= 0 && this->_batteryRight >= 0) + this->_batteryLevel = std::min(this->_batteryLeft, this->_batteryRight); + else + this->_batteryLevel = std::max(this->_batteryLeft, this->_batteryRight); // -1 when both are docked + this->_batteryCharging = this->_batteryLeftCharging || this->_batteryRightCharging; + return true; +} - // TWS earbuds: dual L/R (22 09 -> 23 09 ) and case (22 0a -> 23 0a ). +bool Headphones::_requestSingleBattery(unsigned char subType, int& levelOut, bool& chargingOut) +{ + Buffer resp; try { - auto resp = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BATTERY_GET, 0x09 }, V2Command::BATTERY_RET, 0x09); - if (resp.size() >= 6) { - std::lock_guard guard(this->_propertyMtx); - this->_hasDualBattery = true; - this->_batteryLeft = (unsigned char)resp[2]; - this->_batteryRight = (unsigned char)resp[4]; - this->_batteryLevel = std::min(this->_batteryLeft, this->_batteryRight); + resp = this->_conn.sendCommandAndReadResponse( + { (char)V2Command::BATTERY_GET, (char)subType }, V2Command::BATTERY_RET, subType); + } catch (const std::exception&) { + return false; + } + if (resp.size() < 4) return false; + levelOut = (unsigned char)resp[2]; + chargingOut = resp[3] == 1; + return true; +} + +void Headphones::requestBattery() +{ + if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1) + { + // v1 (including WH-1000XM4): 10 00 is the single-battery inquiry. Never substitute the + // v2 22 00 command here: 0x22 is POWER_OFF on this protocol generation. + auto resp = this->_conn.sendCommandAndReadResponse( + { (char)V1Command::BATTERY_GET, 0x00 }, + V1Command::BATTERY_RET, + 0x00 + ); + if (resp.size() < 4) throw std::runtime_error("Invalid v1 battery response"); + + std::lock_guard guard(this->_propertyMtx); + this->_batteryLevel = (unsigned char)resp[2]; + this->_batteryCharging = resp[3] == 1; + this->_hasBattery = true; + return; + } + + // A device only answers the battery sub-types it supports, and an unsupported inquiry costs a + // full recv timeout, so probe the layout this model is likely to use first. Crucially the dual + // probe must come before the single one on earbuds: some TWS models *do* answer the single + // inquiry (with a combined or zero level), and taking that answer would hide the per-earbud + // levels entirely. + if (this->_looksLikeEarbuds()) + { + if (this->_requestDualBattery(V2Command::BATTERY_SUB_DUAL) + || this->_requestDualBattery(V2Command::BATTERY_SUB_DUAL2)) + { + int caseLevel = -1; bool caseCharging = false; + if (this->_requestSingleBattery(V2Command::BATTERY_SUB_CASE, caseLevel, caseCharging)) { + std::lock_guard guard(this->_propertyMtx); + // The case reports 0 while it's disconnected from the buds; keep the last known value. + if (caseLevel > 0) { + this->_batteryCase = caseLevel; + this->_batteryCaseCharging = caseCharging; + } + } + return; } - } catch (...) {} + } - try { - auto resp = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BATTERY_GET, 0x0a }, V2Command::BATTERY_RET, 0x0a); - if (resp.size() >= 4) { - std::lock_guard guard(this->_propertyMtx); - this->_batteryCase = (unsigned char)resp[2]; + // Single battery (over-ear and most other models). + int level = -1; bool charging = false; + if (this->_requestSingleBattery(V2Command::BATTERY_SUB_SINGLE, level, charging)) { + std::lock_guard guard(this->_propertyMtx); + this->_batteryLevel = level; + this->_batteryCharging = charging; + this->_hasBattery = true; + return; + } + + // Unrecognised name that turned out to be earbuds after all. + if (!this->_looksLikeEarbuds()) { + if (this->_requestDualBattery(V2Command::BATTERY_SUB_DUAL) + || this->_requestDualBattery(V2Command::BATTERY_SUB_DUAL2)) + { + int caseLevel = -1; bool caseCharging = false; + if (this->_requestSingleBattery(V2Command::BATTERY_SUB_CASE, caseLevel, caseCharging)) { + std::lock_guard guard(this->_propertyMtx); + if (caseLevel > 0) { + this->_batteryCase = caseLevel; + this->_batteryCaseCharging = caseCharging; + } + } } - } catch (...) {} + } } +bool Headphones::hasBattery() { std::lock_guard guard(this->_propertyMtx); return this->_hasBattery; } int Headphones::getBatteryLevel() { + std::lock_guard guard(this->_propertyMtx); return this->_batteryLevel; } bool Headphones::isBatteryCharging() { + std::lock_guard guard(this->_propertyMtx); return this->_batteryCharging; } -bool Headphones::hasDualBattery() { return this->_hasDualBattery; } -int Headphones::getBatteryLeft() { return this->_batteryLeft; } -int Headphones::getBatteryRight() { return this->_batteryRight; } -int Headphones::getBatteryCase() { return this->_batteryCase; } +bool Headphones::hasDualBattery() { std::lock_guard guard(this->_propertyMtx); return this->_hasDualBattery; } +int Headphones::getBatteryLeft() { std::lock_guard guard(this->_propertyMtx); return this->_batteryLeft; } +int Headphones::getBatteryRight() { std::lock_guard guard(this->_propertyMtx); return this->_batteryRight; } +int Headphones::getBatteryCase() { std::lock_guard guard(this->_propertyMtx); return this->_batteryCase; } +bool Headphones::isBatteryLeftCharging() { std::lock_guard guard(this->_propertyMtx); return this->_batteryLeftCharging; } +bool Headphones::isBatteryRightCharging() { std::lock_guard guard(this->_propertyMtx); return this->_batteryRightCharging; } +bool Headphones::isBatteryCaseCharging() { std::lock_guard guard(this->_propertyMtx); return this->_batteryCaseCharging; } void Headphones::requestEqualizer() { - // GET: 56 00 -> RET: 57 00 06 + auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; + if (!isV1 && this->_isWh1000Xm6()) + { + // GET 56 04 -> RET 57 04 , raw device steps 0..12 with no bias and no + // clear-bass slot. Layout from a Sound Connect btsnoop capture, verified on a WH-1000XM6 (fw 3.1.5) + // by sony-device-center (marconvcm/sony-device-center#44). The legacy 56 00 frame is ACKed but ignored. + auto resp = this->_conn.sendCommandAndReadResponse( + { (char)V2Command::EQ_GET, (char)V2Command::EQ_TEN_BAND }, V2Command::EQ_RET, V2Command::EQ_TEN_BAND); + if (resp.size() < 4) return; + size_t count = (unsigned char)resp[3]; + if (resp.size() < 4 + count) return; + std::lock_guard guard(this->_propertyMtx); + this->_eqPreset = static_cast((unsigned char)resp[2]); + this->_hasEqualizer = true; + this->_eqClearBass = 0; + this->_eqBands.assign(resp.begin() + 4, resp.begin() + 4 + count); + for (auto& band : this->_eqBands) band = (unsigned char)band; + return; + } + auto subtype = isV1 ? 0x01 : 0x00; + // v1 uses subtype 01; v2 uses subtype 00. Both return the same six-band value layout. auto resp = this->_conn.sendCommandAndReadResponse( - { (char)V2Command::EQ_GET, 0x00 }, - V2Command::EQ_RET + { (char)(isV1 ? V1Command::EQ_GET : V2Command::EQ_GET), (char)subtype }, + isV1 ? V1Command::EQ_RET : V2Command::EQ_RET, + subtype ); if (resp.size() >= 3) { std::lock_guard guard(this->_propertyMtx); - this->_eqPreset = static_cast((unsigned char)resp[2]); + auto rawPreset = (unsigned char)resp[2]; + this->_eqPreset = (isV1 && rawPreset == V1Command::MANUAL_EQ) + ? EQ_PRESET::MANUAL + : static_cast(rawPreset); + this->_hasEqualizer = true; // When band data is present (57 00 06 ), decode the -10..10 values. if (resp.size() >= 10) { @@ -157,32 +310,69 @@ void Headphones::requestEqualizer() } } +bool Headphones::hasEqualizer() { std::lock_guard guard(this->_propertyMtx); return this->_hasEqualizer; } EQ_PRESET Headphones::getEqualizerPreset() { + std::lock_guard guard(this->_propertyMtx); return this->_eqPreset; } void Headphones::setEqualizerPreset(EQ_PRESET preset) { - // SET preset: 58 00 00 + if (!this->hasEqualizer()) throw std::logic_error("Equalizer capability has not been confirmed"); + auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; + auto rawPreset = (unsigned char)preset; + if (isV1 && preset == EQ_PRESET::MANUAL) rawPreset = V1Command::MANUAL_EQ; + // SET preset: v1 58 01 00; v2 58 00 00; ten-band v2 58 04 00. this->_conn.sendCommand({ - (char)V2Command::EQ_SET, - 0x00, - (char)static_cast(preset), + (char)(isV1 ? V1Command::EQ_SET : V2Command::EQ_SET), + (char)(isV1 ? 0x01 : this->_isWh1000Xm6() ? V2Command::EQ_TEN_BAND : 0x00), + (char)rawPreset, 0x00 }); - std::lock_guard guard(this->_propertyMtx); - this->_eqPreset = preset; + { + std::lock_guard guard(this->_propertyMtx); + this->_eqPreset = preset; + } + // The band values that go with the new preset live on the device, not here: selecting MANUAL restores + // the custom curve the user last stored on the headphones, and every other preset has its own fixed + // curve. Without this read-back the cached bands stay whatever the *previous* preset had, so switching + // to Manual showed the wrong slider positions. A failed read-back mustn't fail the write that already + // went through - the cached preset above is still correct, only the bands would be stale. + try + { + this->requestEqualizer(); + } + catch (const std::exception&) + { + } } void Headphones::setEqualizerCustom(int clearBass, const std::vector& bands) { - // SET custom: 58 00 A0 06 (0xA0 = MANUAL preset; values clamped to [-10,10]) + if (!this->hasEqualizer()) throw std::logic_error("Equalizer capability has not been confirmed"); + auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; + if (!isV1 && this->_isWh1000Xm6()) + { + // SET 58 04 a0 , raw 0..12, no clear-bass slot. + std::vector raw(bands.begin(), bands.end()); + for (auto& band : raw) band = std::clamp(band, 0, 12); + Buffer cmd = { (char)V2Command::EQ_SET, (char)V2Command::EQ_TEN_BAND, + (char)static_cast(EQ_PRESET::MANUAL), (char)raw.size() }; + for (int band : raw) cmd.push_back((char)band); + this->_conn.sendCommand(cmd); + + std::lock_guard guard(this->_propertyMtx); + this->_eqPreset = EQ_PRESET::MANUAL; + this->_eqBands = raw; + return; + } + // v1 selects custom EQ with 0xff/subtype 01; v2 uses 0xa0/subtype 00. auto clamp = [](int v) { return (char)(unsigned char)(std::max(-10, std::min(10, v)) + 10); }; Buffer cmd = { - (char)V2Command::EQ_SET, - 0x00, - (char)static_cast(EQ_PRESET::MANUAL), + (char)(isV1 ? V1Command::EQ_SET : V2Command::EQ_SET), + (char)(isV1 ? 0x01 : 0x00), + (char)(isV1 ? V1Command::MANUAL_EQ : static_cast(EQ_PRESET::MANUAL)), 0x06, clamp(clearBass) }; @@ -194,50 +384,74 @@ void Headphones::setEqualizerCustom(int clearBass, const std::vector& bands std::lock_guard guard(this->_propertyMtx); this->_eqPreset = EQ_PRESET::MANUAL; - this->_eqClearBass = clearBass; + this->_eqClearBass = std::clamp(clearBass, -10, 10); for (int i = 0; i < 5; i++) { - this->_eqBands[i] = i < (int)bands.size() ? bands[i] : 0; + this->_eqBands[i] = std::clamp(i < (int)bands.size() ? bands[i] : 0, -10, 10); } } int Headphones::getClearBass() { + std::lock_guard guard(this->_propertyMtx); return this->_eqClearBass; } int Headphones::getEqualizerBand(int index) { + std::lock_guard guard(this->_propertyMtx); return (index >= 0 && index < (int)this->_eqBands.size()) ? this->_eqBands[index] : 0; } +int Headphones::getEqualizerBandCount() +{ + std::lock_guard guard(this->_propertyMtx); + return (int)this->_eqBands.size(); +} + +bool Headphones::isTenBandEqualizer() +{ + std::lock_guard guard(this->_propertyMtx); + return this->_conn.getProtocolVersion() == SonyProtocolVersion::V2 && this->_isWh1000Xm6(); +} + void Headphones::requestDsee() { - // GET: e6 01 -> RET: e7 01 + auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; + auto subtype = isV1 ? 0x02 : 0x01; auto resp = this->_conn.sendCommandAndReadResponse( - { (char)V2Command::DSEE_GET, 0x01 }, - V2Command::DSEE_RET + { (char)(isV1 ? V1Command::DSEE_GET : V2Command::DSEE_GET), (char)subtype }, + isV1 ? V1Command::DSEE_RET : V2Command::DSEE_RET, + subtype ); - if (resp.size() >= 3) + if ((!isV1 && resp.size() >= 3) || (isV1 && resp.size() >= 4)) { std::lock_guard guard(this->_propertyMtx); - this->_dsee = resp[2] != 0; + this->_dsee = resp[isV1 ? 3 : 2] != 0; + this->_hasDsee = true; } } +bool Headphones::hasDsee() { std::lock_guard guard(this->_propertyMtx); return this->_hasDsee; } bool Headphones::getDsee() { + std::lock_guard guard(this->_propertyMtx); return this->_dsee; } void Headphones::setDsee(bool enabled) { - // SET: e8 01 - this->_conn.sendCommand({ - (char)V2Command::DSEE_SET, - 0x01, - (char)(enabled ? 0x01 : 0x00) - }); + if (!this->hasDsee()) throw std::logic_error("DSEE capability has not been confirmed"); + if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1) + { + // v1: e8 02 00 + this->_conn.sendCommand({ (char)V1Command::DSEE_SET, 0x02, 0x00, (char)(enabled ? 0x01 : 0x00) }); + } + else + { + // v2: e8 01 + this->_conn.sendCommand({ (char)V2Command::DSEE_SET, 0x01, (char)(enabled ? 0x01 : 0x00) }); + } std::lock_guard guard(this->_propertyMtx); this->_dsee = enabled; } @@ -250,29 +464,57 @@ void Headphones::requestAmbientState() if (protocolVersion == SonyProtocolVersion::V2) { // GET: 66 17 -> RET: 67 17 01 - auto resp = this->_conn.sendCommandAndReadResponse({ 0x66, 0x17 }, 0x67); - if (resp.size() >= 7) + // Match the sub-type too: 0x67 also carries unsolicited notifications with a different layout, + // and accepting one of those parsed / out of unrelated bytes. + // The WH-1000XM6 answers 66 17 with all zeros in every mode - parsing that as "off" is what snapped its + // UI back to Off (issue #11). It reports the same first seven bytes (plus two trailing ones) under + // inquired type 0x19, verified on fw 3.1.5 (marconvcm/sony-device-center#74). + unsigned char inquired = this->_isWh1000Xm6() ? V2Command::NCASM_TYPE_XM6 : V2Command::NCASM_TYPE; + auto resp = this->_conn.sendCommandAndReadResponse({ 0x66, (char)inquired }, 0x67, inquired); + // Byte [2] is always 01 in a real reply; anything else (like the XM6's zeroed 0x17 answer) is not state. + if (resp.size() >= 7 && resp[2] == 0x01) { bool on = resp[3] != 0; bool ambient = resp[4] != 0; bool voice = resp[5] != 0; int level = (unsigned char)resp[6]; std::lock_guard guard(this->_propertyMtx); - // Update both current and desired so the UI reflects reality and isChanged() stays false. - this->_ambientSoundControl.current = this->_ambientSoundControl.desired = on; - this->_asmLevel.current = this->_asmLevel.desired = ambient ? level : 0; - this->_focusOnVoice.current = this->_focusOnVoice.desired = voice; + // A change the user just made may not be on the wire yet - the UI writes the desired values + // and only then queues the send - and this reply was produced before it. Adopting it would + // both revert the user's choice and clear the pending change (isChanged() goes false), so the + // command would never be sent at all. Leave everything alone until setChanges() has flushed it. + if (this->_ambientSoundControl.isFulfilled() && this->_asmLevel.isFulfilled() && this->_focusOnVoice.isFulfilled()) + { + // Update both current and desired so the UI reflects reality and isChanged() stays false. + this->_ambientSoundControl.current = this->_ambientSoundControl.desired = on; + this->_asmLevel.current = this->_asmLevel.desired = ambient ? level : 0; + this->_focusOnVoice.current = this->_focusOnVoice.desired = voice; + } } } else { - // v1 (e.g. WH-1000XM4): upstream drove these settings fire-and-forget, with no read-back at all. - // We still issue the GET because v1 devices need valid protocol traffic after connect to stay - // powered on, and we read the reply to drain it from the socket - but we deliberately do NOT parse - // it back into the NC/Ambient/level state. The v1 reply layout is unverified, and letting a - // mis-parsed 2s poll overwrite the user's choice is exactly what made Ambient snap back to Noise - // Cancelling and the level slider jump while being dragged. Trust the user's selection instead. - this->_conn.sendCommandAndReadResponse({ 0x66, 0x02 }, 0x67); + // v1: 67 02 . + auto resp = this->_conn.sendCommandAndReadResponse( + { (char)V1Command::AMBIENT_GET, 0x02 }, V1Command::AMBIENT_RET, 0x02); + if (resp.size() == 8) + { + bool enabled = resp[2] != 0; + auto settingType = (unsigned char)resp[3]; + auto settingValue = (unsigned char)resp[4]; + int level = (unsigned char)resp[7]; + bool ambient = enabled && settingValue == 0x00 && level > 0; + bool voice = resp[6] != 0; + + std::lock_guard guard(this->_propertyMtx); + if (settingType <= static_cast(NC_ASM_SETTING_TYPE::DUAL_SINGLE_OFF)) + this->_ncAsmSettingType = static_cast(settingType); + if ((unsigned char)resp[5] <= static_cast(ASM_SETTING_TYPE::LEVEL_ADJUSTMENT)) + this->_asmSettingType = static_cast((unsigned char)resp[5]); + this->_ambientSoundControl.current = this->_ambientSoundControl.desired = enabled; + this->_asmLevel.current = this->_asmLevel.desired = ambient ? level : 0; + this->_focusOnVoice.current = this->_focusOnVoice.desired = ambient && voice; + } } } @@ -312,6 +554,30 @@ void Headphones::probeCapabilities() { // Each GET is best-effort: an unsupported feature times out (recv timeout -> throw) and stays unsupported, // so we never expose or send a command the device can't handle. + if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1) + { + try { + auto r = this->_conn.sendCommandAndReadResponse({ (char)V1Command::FW_GET, 0x02 }, V1Command::FW_RET, 0x02); + if (r.size() > 3) { std::lock_guard g(this->_propertyMtx); this->_firmware = std::string(r.begin() + 3, r.end()); this->_hasFirmware = true; } + } catch (...) {} + + try { + auto r = this->_conn.sendCommandAndReadResponse({ (char)V1Command::CODEC_GET, 0x00 }, V1Command::CODEC_RET, 0x00); + if (r.size() >= 3) { auto n = codecName((unsigned char)r[2]); if (!n.empty()) { std::lock_guard g(this->_propertyMtx); this->_codec = n; this->_hasCodec = true; } } + } catch (...) {} + + try { + auto r = this->_conn.sendCommandAndReadResponse({ (char)V1Command::BTNMODE_GET, (char)V1Command::SUB_AUTO_POWER_OFF }, V1Command::BTNMODE_RET, V1Command::SUB_AUTO_POWER_OFF); + if (r.size() >= 5) { std::lock_guard g(this->_propertyMtx); this->_autoPowerOff = apoIndexFromCode((unsigned char)r[3], (unsigned char)r[4]); this->_hasAutoPowerOff = true; } + } catch (...) {} + + try { + auto r = this->_conn.sendCommandAndReadResponse({ (char)V1Command::BTNMODE_GET, (char)V1Command::SUB_SPEAK_TO_CHAT }, V1Command::BTNMODE_RET, V1Command::SUB_SPEAK_TO_CHAT); + if (r.size() >= 4 && r[2] == 0x01) { std::lock_guard g(this->_propertyMtx); this->_speakToChat = r[3] != 0; this->_hasSpeakToChat = true; } + } catch (...) {} + return; + } + try { auto r = this->_conn.sendCommandAndReadResponse({ (char)V2Command::FW_GET, 0x02 }, V2Command::FW_RET); if (r.size() > 3) { std::lock_guard g(this->_propertyMtx); this->_firmware = std::string(r.begin() + 3, r.end()); this->_hasFirmware = true; } @@ -338,105 +604,460 @@ void Headphones::probeCapabilities() } catch (...) {} } -bool Headphones::hasAutoPowerOff() { return this->_hasAutoPowerOff; } -int Headphones::getAutoPowerOff() { return this->_autoPowerOff; } +bool Headphones::hasAutoPowerOff() { std::lock_guard guard(this->_propertyMtx); return this->_hasAutoPowerOff; } +int Headphones::getAutoPowerOff() { std::lock_guard guard(this->_propertyMtx); return this->_autoPowerOff; } void Headphones::setAutoPowerOff(int index) { - if (index < 0 || index > 5) return; - this->_conn.sendCommand({ (char)V2Command::APO_SET, 0x05, (char)APO_CODES[index].first, (char)APO_CODES[index].second }); + if (index < 0 || index > 5) throw std::invalid_argument("Invalid auto power-off option"); + if (!this->hasAutoPowerOff()) throw std::logic_error("Auto power-off capability has not been confirmed"); + if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1) + { + this->_conn.sendCommand({ (char)V1Command::BTNMODE_SET, (char)V1Command::SUB_AUTO_POWER_OFF, 0x01, (char)APO_CODES[index].first, (char)APO_CODES[index].second }); + } + else + { + this->_conn.sendCommand({ (char)V2Command::APO_SET, 0x05, (char)APO_CODES[index].first, (char)APO_CODES[index].second }); + } std::lock_guard guard(this->_propertyMtx); this->_autoPowerOff = index; } -bool Headphones::hasFirmware() { return this->_hasFirmware; } -std::string Headphones::getFirmware() { return this->_firmware; } -bool Headphones::hasCodec() { return this->_hasCodec; } -std::string Headphones::getCodec() { return this->_codec; } +bool Headphones::hasFirmware() { std::lock_guard guard(this->_propertyMtx); return this->_hasFirmware; } +std::string Headphones::getFirmware() { std::lock_guard guard(this->_propertyMtx); return this->_firmware; } +bool Headphones::hasCodec() { std::lock_guard guard(this->_propertyMtx); return this->_hasCodec; } +std::string Headphones::getCodec() { std::lock_guard guard(this->_propertyMtx); return this->_codec; } -bool Headphones::hasSpeakToChat() { return this->_hasSpeakToChat; } -bool Headphones::getSpeakToChat() { return this->_speakToChat; } +bool Headphones::hasSpeakToChat() { std::lock_guard guard(this->_propertyMtx); return this->_hasSpeakToChat; } +bool Headphones::getSpeakToChat() { std::lock_guard guard(this->_propertyMtx); return this->_speakToChat; } void Headphones::setSpeakToChat(bool enabled) { - // SET: f8 0c 01 (enable bit is inverted on v2) - this->_conn.sendCommand({ (char)V2Command::BTNMODE_SET, (char)V2Command::SUB_SPEAK_TO_CHAT, (char)(enabled ? 0x00 : 0x01), 0x01 }); + if (!this->hasSpeakToChat()) throw std::logic_error("Speak-to-Chat capability has not been confirmed"); + if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1) + { + this->_conn.sendCommand({ (char)V1Command::BTNMODE_SET, (char)V1Command::SUB_SPEAK_TO_CHAT, 0x01, (char)(enabled ? 0x01 : 0x00) }); + } + else + { + // v2 enable bit is inverted. + this->_conn.sendCommand({ (char)V2Command::BTNMODE_SET, (char)V2Command::SUB_SPEAK_TO_CHAT, (char)(enabled ? 0x00 : 0x01), 0x01 }); + } std::lock_guard guard(this->_propertyMtx); this->_speakToChat = enabled; } -bool Headphones::hasAdaptiveVolume() { return this->_hasAdaptiveVolume; } -bool Headphones::getAdaptiveVolume() { return this->_adaptiveVolume; } +bool Headphones::hasAdaptiveVolume() { std::lock_guard guard(this->_propertyMtx); return this->_hasAdaptiveVolume; } +bool Headphones::getAdaptiveVolume() { std::lock_guard guard(this->_propertyMtx); return this->_adaptiveVolume; } void Headphones::setAdaptiveVolume(bool enabled) { + if (this->_conn.getProtocolVersion() != SonyProtocolVersion::V2 || !this->hasAdaptiveVolume()) + throw std::logic_error("Adaptive volume capability has not been confirmed"); // SET: f8 0a (inverted) this->_conn.sendCommand({ (char)V2Command::BTNMODE_SET, (char)V2Command::SUB_ADAPTIVE_VOLUME, (char)(enabled ? 0x00 : 0x01) }); std::lock_guard guard(this->_propertyMtx); this->_adaptiveVolume = enabled; } +// --- Multipoint --- + +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; } + +void Headphones::setMultipoint(bool enabled) +{ + int slot; + { + std::lock_guard guard(this->_propertyMtx); + if (!this->_hasMultipointSetting) throw RecoverableException("This headset doesn't report a multipoint setting."); + slot = this->_multipointSlot; + } + // The state frame goes first (d8 00 <00=on/01=off>), then the fixed apply commit 98 00 06 01. + // sendCommand waits for each ACK, which reproduces GadgetBridge's frame/ack interleaving. + this->_conn.sendCommand({ (char)V2Command::GS_SET_PARAM, (char)slot, 0x00, (char)(enabled ? 0x00 : 0x01) }); + this->_conn.sendCommand({ (char)V2Command::SYSCTL_SET, 0x00, (char)V2Command::SUB_SYSCTL_MULTIPOINT, 0x01 }); + std::lock_guard guard(this->_propertyMtx); + this->_multipointEnabled = enabled; +} + +// requestAmbientState() deliberately refuses to adopt device state while a change is pending, so a write +// that never reached the device would otherwise block the read-back for the rest of the session - the UI +// would freeze on a value the headphones never took. Dropping the pending change lets polling resume. +void Headphones::discardAmbientChanges() +{ + std::lock_guard guard(this->_propertyMtx); + this->_ambientSoundControl.desired = this->_ambientSoundControl.current; + this->_asmLevel.desired = this->_asmLevel.current; + this->_focusOnVoice.desired = this->_focusOnVoice.current; +} + bool Headphones::isChanged() { - return !(this->_ambientSoundControl.isFulfilled() && this->_asmLevel.isFulfilled() && this->_focusOnVoice.isFulfilled() && this->_surroundPosition.isFulfilled() && this->_vptType.isFulfilled()); + std::lock_guard guard(this->_propertyMtx); + auto ambientChanged = !(this->_ambientSoundControl.isFulfilled() && this->_asmLevel.isFulfilled() && this->_focusOnVoice.isFulfilled()); + auto vptChanged = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1 && + !(this->_surroundPosition.isFulfilled() && this->_vptType.isFulfilled()); + return ambientChanged || vptChanged; } void Headphones::setChanges() { auto protocolVersion = this->_conn.getProtocolVersion(); + Property ambientSoundControl; + Property focusOnVoice; + Property asmLevelProperty; + NC_ASM_SETTING_TYPE ncAsmSettingType; + ASM_SETTING_TYPE asmSettingType; + { + std::lock_guard guard(this->_propertyMtx); + ambientSoundControl = this->_ambientSoundControl; + focusOnVoice = this->_focusOnVoice; + asmLevelProperty = this->_asmLevel; + ncAsmSettingType = this->_ncAsmSettingType; + asmSettingType = this->_asmSettingType; + } - if (!(this->_ambientSoundControl.isFulfilled() && this->_focusOnVoice.isFulfilled() && this->_asmLevel.isFulfilled())) + if (!(ambientSoundControl.isFulfilled() && focusOnVoice.isFulfilled() && asmLevelProperty.isFulfilled())) { if (protocolVersion == SonyProtocolVersion::V2) { // v2 has no separate "disabled" level - NC vs Ambient Sound is its own field, and level is 1-based. - char asmLevel = static_cast(this->_asmLevel.desired > 0 ? this->_asmLevel.desired : 1); + char asmLevel = static_cast(asmLevelProperty.desired > 0 ? asmLevelProperty.desired : 1); this->_conn.sendCommand(CommandSerializer::serializeNcAndAsmSettingV2( - this->_ambientSoundControl.desired ? NC_ASM_EFFECT::ON : NC_ASM_EFFECT::OFF, - this->_asmLevel.desired > 0 ? NC_ASM_SETTING_TYPE_V2::AMBIENT_SOUND : NC_ASM_SETTING_TYPE_V2::NOISE_CANCELLING, - this->_focusOnVoice.desired ? ASM_ID::VOICE : ASM_ID::NORMAL, + ambientSoundControl.desired ? NC_ASM_EFFECT::ON : NC_ASM_EFFECT::OFF, + asmLevelProperty.desired > 0 ? NC_ASM_SETTING_TYPE_V2::AMBIENT_SOUND : NC_ASM_SETTING_TYPE_V2::NOISE_CANCELLING, + focusOnVoice.desired ? ASM_ID::VOICE : ASM_ID::NORMAL, asmLevel )); } else { - auto ncAsmEffect = this->_ambientSoundControl.desired ? NC_ASM_EFFECT::ADJUSTMENT_COMPLETION : NC_ASM_EFFECT::OFF; - auto asmId = this->_focusOnVoice.desired ? ASM_ID::VOICE : ASM_ID::NORMAL; - auto asmLevel = this->_ambientSoundControl.desired ? this->_asmLevel.desired : ASM_LEVEL_DISABLED; + auto ncAsmEffect = ambientSoundControl.desired ? NC_ASM_EFFECT::ADJUSTMENT_COMPLETION : NC_ASM_EFFECT::OFF; + auto asmId = focusOnVoice.desired ? ASM_ID::VOICE : ASM_ID::NORMAL; + auto asmLevel = ambientSoundControl.desired ? asmLevelProperty.desired : 0; + auto noiseCancelling = ambientSoundControl.desired && asmLevelProperty.desired == 0; + auto ncValue = noiseCancelling + ? (ncAsmSettingType == NC_ASM_SETTING_TYPE::DUAL_SINGLE_OFF ? NC_DUAL_SINGLE_VALUE::DUAL : NC_DUAL_SINGLE_VALUE::SINGLE) + : NC_DUAL_SINGLE_VALUE::OFF; this->_conn.sendCommand(CommandSerializer::serializeNcAndAsmSetting( ncAsmEffect, - NC_ASM_SETTING_TYPE::LEVEL_ADJUSTMENT, - ASM_SETTING_TYPE::LEVEL_ADJUSTMENT, + ncAsmSettingType, + ncValue, + asmSettingType, asmId, asmLevel )); } std::lock_guard guard(this->_propertyMtx); - this->_ambientSoundControl.fulfill(); - this->_asmLevel.fulfill(); - this->_focusOnVoice.fulfill(); + if (this->_ambientSoundControl.desired == ambientSoundControl.desired) this->_ambientSoundControl.fulfill(); + if (this->_asmLevel.desired == asmLevelProperty.desired) this->_asmLevel.fulfill(); + if (this->_focusOnVoice.desired == focusOnVoice.desired) this->_focusOnVoice.fulfill(); } // VPT/Surround has no known v2 command and no hardware on v2-only devices like the CH720N - v1 only. - if (protocolVersion == SonyProtocolVersion::V1 && !(this->_vptType.isFulfilled() && this->_surroundPosition.isFulfilled())) { + Property vptType; + Property surroundPosition; + { + std::lock_guard guard(this->_propertyMtx); + vptType = this->_vptType; + surroundPosition = this->_surroundPosition; + } + if (protocolVersion == SonyProtocolVersion::V1 && !(vptType.isFulfilled() && surroundPosition.isFulfilled())) { VPT_INQUIRED_TYPE command; unsigned char preset; - if (this->_vptType.desired != 0) { + if (vptType.desired != 0) { command = VPT_INQUIRED_TYPE::VPT; - preset = static_cast(this->_vptType.desired); + preset = static_cast(vptType.desired); } - else if (this->_surroundPosition.desired != SOUND_POSITION_PRESET::OFF) { + else if (surroundPosition.desired != SOUND_POSITION_PRESET::OFF) { command = VPT_INQUIRED_TYPE::SOUND_POSITION; - preset = static_cast(this->_surroundPosition.desired); + preset = static_cast(surroundPosition.desired); } else { // Just used that one because it seems like it disables both - if (this->_surroundPosition.current != SOUND_POSITION_PRESET::OFF) { + if (surroundPosition.current != SOUND_POSITION_PRESET::OFF) { command = VPT_INQUIRED_TYPE::SOUND_POSITION; preset = static_cast(SOUND_POSITION_PRESET::OFF); } - else if (this->_vptType.current != 0) { + else if (vptType.current != 0) { command = VPT_INQUIRED_TYPE::VPT; preset = 0; } @@ -448,7 +1069,7 @@ void Headphones::setChanges() this->_conn.sendCommand(CommandSerializer::serializeVPTSetting(command, preset)); std::lock_guard guard(this->_propertyMtx); - this->_vptType.fulfill(); - this->_surroundPosition.fulfill(); + if (this->_vptType.desired == vptType.desired) this->_vptType.fulfill(); + if (this->_surroundPosition.desired == surroundPosition.desired) this->_surroundPosition.fulfill(); } } diff --git a/Client/Headphones.h b/Client/Headphones.h index 6197d569..fa69c279 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 { @@ -34,30 +46,43 @@ class Headphones { void setVptType(int val); int getVptType(); - // Handshake the device expects before it answers inquiry (GET) commands on the v2 protocol. + // Handshake the device expects before it answers inquiry (GET) commands. void initDevice(); - // Battery (v2 inquiry). getBatteryLevel() returns -1 until requestBattery() succeeds. + // Model name of the connected device (e.g. "WF-1000XM5"). Only a hint: it picks which battery + // layout to probe first, so set it before the first requestBattery() to save a timed-out inquiry. + void setDeviceName(const std::string& name); + + // Battery. getBatteryLevel() returns -1 until requestBattery() succeeds. // For TWS earbuds requestBattery() also fills the per-earbud + case levels (hasDualBattery()). void requestBattery(); - int getBatteryLevel(); + bool hasBattery(); + int getBatteryLevel(); // for earbuds: the emptier of the two bool isBatteryCharging(); bool hasDualBattery(); int getBatteryLeft(); // -1 if n/a int getBatteryRight(); int getBatteryCase(); + bool isBatteryLeftCharging(); + bool isBatteryRightCharging(); + bool isBatteryCaseCharging(); - // Equalizer (v2). requestEqualizer() reads current state; setEqualizerPreset() pushes a preset immediately. + // Equalizer. Wire subtypes and the custom-preset byte differ between protocol generations. void requestEqualizer(); + bool hasEqualizer(); EQ_PRESET getEqualizerPreset(); void setEqualizerPreset(EQ_PRESET preset); - // Custom (manual) EQ: 5 band values + clear bass, each in [-10, 10]. Selects the MANUAL preset. + // Custom (manual) EQ. Selects the MANUAL preset. Standard layout: 5 bands + clear bass, each in [-10, 10]. + // Ten-band layout (WH-1000XM6): 10 raw device steps in [0, 12], no clear bass (ignored). void setEqualizerCustom(int clearBass, const std::vector& bands); int getClearBass(); - int getEqualizerBand(int index); // 0..4 + int getEqualizerBand(int index); + int getEqualizerBandCount(); + bool isTenBandEqualizer(); - // DSEE / audio upsampling (v2). + // DSEE / audio upsampling. void requestDsee(); + bool hasDsee(); bool getDsee(); void setDsee(bool enabled); @@ -86,25 +111,69 @@ class Headphones { bool getAdaptiveVolume(); void setAdaptiveVolume(bool enabled); + // Multipoint (v2). requestMultipoint() sends only GETs, 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); + // "Connect to 2 devices". The setting is device-persistent, so the app never enforces a default. Throws + // (without sending) unless requestMultipoint() found the MULTIPOINT_SETTING general-settings slot. + void setMultipoint(bool enabled); + bool isChanged(); void setChanges(); + + // Rolls a failed ambient/NC write back to the last known device state. Must be called when + // setChanges() throws - see the comment on the definition. + void discardAmbientChanges(); private: - Property _ambientSoundControl = { 0 }; - Property _focusOnVoice = { 0 }; - Property _asmLevel = { 0 }; + Property _ambientSoundControl = { false, false }; + Property _focusOnVoice = { false, false }; + Property _asmLevel = { 0, 0 }; + NC_ASM_SETTING_TYPE _ncAsmSettingType = NC_ASM_SETTING_TYPE::DUAL_SINGLE_OFF; + ASM_SETTING_TYPE _asmSettingType = ASM_SETTING_TYPE::LEVEL_ADJUSTMENT; Property _surroundPosition = { SOUND_POSITION_PRESET::OUT_OF_RANGE, SOUND_POSITION_PRESET::OFF }; - Property _vptType = { 0 }; + Property _vptType = { 0, 0 }; int _batteryLevel = -1; + bool _hasBattery = false; bool _batteryCharging = false; bool _hasDualBattery = false; int _batteryLeft = -1; int _batteryRight = -1; int _batteryCase = -1; + bool _batteryLeftCharging = false; + bool _batteryRightCharging = false; + bool _batteryCaseCharging = false; + std::string _deviceName; + + bool _looksLikeEarbuds() const; + // The WH-1000XM6 speaks a different layout for two v2 commands: noise-control state is read via inquired + // type 0x19 (0x17 answers all zeros), and the equalizer uses inquired type 0x04 with 10 raw bands. + bool _isWh1000Xm6() const; + // Both return false if the device didn't answer that battery sub-type. + bool _requestDualBattery(unsigned char subType); + bool _requestSingleBattery(unsigned char subType, int& levelOut, bool& chargingOut); EQ_PRESET _eqPreset = EQ_PRESET::OFF; + bool _hasEqualizer = false; std::vector _eqBands = { 0, 0, 0, 0, 0 }; int _eqClearBass = 0; bool _dsee = false; + bool _hasDsee = false; bool _hasAutoPowerOff = false; int _autoPowerOff = 0; bool _hasFirmware = false; std::string _firmware; @@ -112,6 +181,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/fuzz/AUDIT_FINDINGS.md b/Client/fuzz/AUDIT_FINDINGS.md new file mode 100644 index 00000000..767072af --- /dev/null +++ b/Client/fuzz/AUDIT_FINDINGS.md @@ -0,0 +1,138 @@ +# Adversarial static audit — inbound Bluetooth frame parser + +Scope traced: `CommandSerializer.cpp/.h`, `ByteMagic.cpp/.h`, `Headphones.cpp/.h`, +`BluetoothWrapper.cpp/.h` (framing layer feeding the parser), `Constants.h`, `Exceptions.h`. +Trust boundary: raw bytes an attacker-controlled/spoofed headset writes on the RFCOMM channel. + +## Bottom line + +I could not find a memory-safety bug, integer-overflow bug, escape-handling bug, or +round-trip divergence on the inbound path. The frame (de)serializer and the framing layer +are unusually careful. Below are (a) the specific defenses I verified by tracing concrete +byte sequences, and (b) three low/informational observations that are NOT exploitable as +written but are worth knowing. Nothing here is CONFIRMED-exploitable. + +The fuzz harness (`FuzzCommandSerializer.cpp`) only drives `unpackBtMessage` / +`packageDataForBt`. It never reaches `BluetoothWrapper::_readMessage` (the marker/leftover +framing) or the `Headphones::request*` consumers — so I put most reading effort there. +Those paths are also clean; details below. + +--- + +## Verified defenses (traced with concrete inputs) + +### D1 — `unpackBtMessage` length math cannot underflow or over-read +`CommandSerializer.cpp:136-173`. +- `unescaped.size() < 7` is rejected first (line 142), so indices `[0..6]` used to read + `dataType`, `seq`, the 4 size bytes, and the checksum byte always exist. +- `dataSize` is assembled as `unsigned int` from 4 bytes (max `0xFFFFFFFF`). Line 153 + `dataSize > unescaped.size() - 7`: `size` is `size_t >= 7`, so `size - 7` never wraps; + the comparison promotes `dataSize` to `size_t`, so a huge declared size is rejected, not + truncated. +- Line 157 forces `dataSize == size - 7` exactly (rejects trailing bytes). Consequently the + checksum index `unescaped[6 + dataSize]` (line 163) is exactly `size - 1` — always the + last valid byte — and the payload slice `[begin+6, begin+6+dataSize)` (line 171) ends + exactly at `size - 1`. No off-by-one. +- Trigger bytes I checked: minimal empty-payload frame body (unescaped) + `00 00 00 00 00 00 00` → dataSize 0, checksum at [6], payload empty: accepted, no OOB. + Body `3E 3E 3E` (len 3 < 7) → clean throw. Declared-size overflow body + `00 00 FF FF FF FF CK` → rejected at line 153. + +### D2 — escape/unescape is symmetric and bounded +`_escapeSpecials` / `_unescapeSpecials` (`CommandSerializer.cpp:11-85`). +- A trailing lone sentry `0x3D` at end of buffer is explicitly rejected + (`i == src.size() - 1` → throw, line 54) — no read past the end for the escaped byte. +- Sentry followed by any byte other than `44/45/46` throws ("Unexpected escaped byte") — + an escape can never synthesize an out-of-range value; it only ever emits `60/61/62`. +- The three escapable values (`60/61/62`) and the sentry (`61`) are all `< 128`, so the + `char`-vs-int `switch`/`==` comparisons are sign-safe (no negative-`char` mismatch). +- Delimiters `START=62` / `END=60` are themselves escaped inside the body, so a well-formed + body never contains a raw delimiter; the framing layer strips markers before unescape. +- Trigger checked: `3D` (single byte) → throw, no over-read; `3D 44` → `60`; `3D 99` → throw. + +### D3 — checksum is computed over identical bytes on both sides +`_sumChecksum` (`CommandSerializer.cpp:87-100`) accumulates into `unsigned char`. Pack sums +`[dataType..payload]` (pre-escape) and appends it; unpack sums `unescaped[0 .. 6+dataSize)` += the same logical bytes. The signed-`char` element promotion is identical on both sides, so +`unpack(pack(x))` reproduces the same checksum. No signedness bypass, no round-trip drift. + +### D4 — `_readMessage` framing cannot overrun `buf[2048]` +`BluetoothWrapper.cpp:109-182`. +- `recv(buf, sizeof(buf))` and the leftover replay both cap at `sizeof(buf)` via + `std::min(_leftoverBytes.size(), sizeof(buf))` (line 123); `numRecvd <= 2048` always. +- `messageStart`/`messageEnd` stay within `[0, numRecvd]`; the only way `messageStart` is set + is `i+1` on a START at index `i < numRecvd` (so `<= numRecvd`), and an END that sets + `messageEnd = i` is only reached *after* a START in the same scan (`i` strictly greater), + so the `insert(buf+messageStart, buf+messageEnd)` range is never inverted. +- `_leftoverBytes.assign(buf+i+1, buf+numRecvd)` uses `i+1 <= numRecvd` — valid, possibly + empty. +- Total accumulation is bounded: `msgBytes.size() > MAX_BLUETOOTH_MESSAGE_SIZE` throws, so an + attacker who never sends END cannot grow memory without bound (DoS-bounded). +- A second START while a message is ongoing → clean `RecoverableException(shouldDisconnect)`. + +### D5 — no attacker-controlled value is ever used as an array index +Every payload-derived array access in `Headphones.cpp` uses a *fixed* loop bound, not a +payload value: `_eqBands[i]` and `resp[5+i]` (i in 0..4, guarded by `resp.size() >= 10`, +lines 186-192); `APO_CODES[index]` is only reached via `setAutoPowerOff`, which validates +`index` in 0..5 (line 440), and `apoIndexFromCode` returns a value found by a fixed 0..5 scan +(lines 359-366). Enum values decoded from the wire (`DATA_TYPE`, `EQ_PRESET`) are only ever +compared or displayed, never used to index a table → no enum-driven OOB. + +### D6 — every consumer bounds-checks before indexing, and every call site catches +`std::exception` +Traced all `request*`/`probeCapabilities` readers: each guards with `resp.size()` before the +highest index it reads (`requestBattery` `<4`/`>=4`/`>=6`; `requestEqualizer` `>=3`/`>=10`; +`requestAmbientState` v2 `>=7`, v1 `==8`; `requestDsee` `>=3`/`>=4`; `probeCapabilities` +`>3`/`>=3`/`>=4`/`>=5`). `sendCommandAndReadResponse` only returns a payload after checking +`!payload.empty()` (so `payload[0]` is safe) and, for subtype matches, `payload.size() >= 2`. +Every invocation in `CrossPlatformGUI.cpp` and `macos/HeadphonesBridge.mm` is wrapped in +`catch (const std::exception&)` / `catch (const RecoverableException&)`, so the mix of thrown +types (see O1) cannot escape to terminate the process. + +--- + +## Observations (low / informational — not exploitable as written) + +### O1 — INFO — inconsistent exception type in the parser (not a vuln, verified caught) +`CommandSerializer.cpp:56, 74, 144` throw plain `std::runtime_error`, while the size/checksum +rejections at 155/159/165 throw `RecoverableException`. If any call site ever caught *only* +`RecoverableException`, a malformed frame (e.g. body `3D` → "No data left for escaped byte +data", or a `< 7`-byte body) would become an uncaught exception → `std::terminate` → remote +DoS. I checked every reachable call site (`CrossPlatformGUI.cpp:126/157/189-244/566-586`, +`HeadphonesBridge.mm:169-361`, `windows/main.cpp:18`, `linux/main.cpp:17`): all catch +`std::exception`, so today this is **safe**. Flagged only because the invariant is implicit — +a future call site that narrows to `RecoverableException` would silently turn any bad frame +into a crash. Recommend making the parser throw `RecoverableException` uniformly. +Status: SUSPECTED-DoS in theory, CONFIRMED-not-reachable in this tree. + +### O2 — LOW — unvalidated device-reported scalar values (cosmetic, no memory impact) +Battery level (`Headphones.cpp:109/120/133/144`), EQ band values, DSEE/adaptive flags, etc. +are stored straight from the wire with no range validation (e.g. a spoofed headset can report +battery `0xFF` → "255%"). Purely a UI-correctness issue; no bounds or state-corruption +consequence. Worth a `clamp`/range check for polish, not a security fix. + +### O3 — LOW — attacker-controlled firmware/codec strings rendered in UI +`probeCapabilities` builds `_firmware = std::string(r.begin()+3, r.end())` +(`Headphones.cpp:390/412`) from up to ~2041 attacker-chosen bytes, including NULs and control +bytes, and displays it. ImGui renders text literally (no escape-sequence interpretation), so +this is not a terminal-injection vector in the shipped GUI; it would only matter if the string +were ever echoed to a raw terminal/log. Informational. + +--- + +## Out-of-scope-but-checked + +`ByteMagic::MACBytesToString` reads a fixed 6 bytes and `MACStringToLong` parses a config +string; both are Windows-only and fed by OS device-enumeration / user config, not by inbound +frame bytes — outside this trust boundary and outside the macOS target. No issue on the +inbound path. + +## Round-trip + +`unpack(pack(x)) == x` holds for `(dataType, seqNumber, payload)`: pack writes +`static_cast(dataType)`, the low byte of `seqNumber`, a 4-byte BE size, the +payload, and a checksum, then canonically escapes; unpack reverses each step and the exact +`dataSize == size-7` check rejects any non-canonical framing. I could not construct an +accepted frame that fails to re-pack-and-re-decode identically (oversized-after-escape frames +are rejected by `packageDataForBt`'s `MAX_BLUETOOTH_MESSAGE_SIZE` guard, which the harness +already treats as expected). No divergence found. diff --git a/Client/fuzz/FuzzCommandSerializer.cpp b/Client/fuzz/FuzzCommandSerializer.cpp new file mode 100644 index 00000000..fb4e929b --- /dev/null +++ b/Client/fuzz/FuzzCommandSerializer.cpp @@ -0,0 +1,91 @@ +#include "CommandSerializer.h" + +#include +#include +#include +#include + +namespace +{ + using CommandSerializer::Message; + + [[noreturn]] void failInvariant() + { + std::abort(); + } + + void requireEqual(const Message& expected, const Message& actual) + { + if (expected.dataType != actual.dataType || + expected.seqNumber != actual.seqNumber || + expected.payload != actual.payload) + { + failInvariant(); + } + } +} + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + if (size > MAX_BLUETOOTH_MESSAGE_SIZE) + { + return 0; + } + + Buffer input; + if (size != 0) + { + input.assign(reinterpret_cast(data), + reinterpret_cast(data) + size); + } + + // Exercise the untrusted inbound parser directly. Malformed frames are expected + // to throw; sanitizer findings and invariant failures are not. + try + { + auto decoded = CommandSerializer::unpackBtMessage(input); + try + { + auto packaged = CommandSerializer::packageDataForBt( + decoded.payload, decoded.dataType, static_cast(decoded.seqNumber)); + Buffer body(packaged.begin() + 1, packaged.end() - 1); + requireEqual(decoded, CommandSerializer::unpackBtMessage(body)); + } + catch (const std::exception&) + { + // An accepted inbound frame can be larger than the outbound framing limit + // after canonical escaping. That size rejection is expected behavior. + } + } + catch (const std::exception&) + { + } + + // Turn arbitrary bytes into structured payloads to reach the packager and its + // escaping logic quickly, then verify every package that fits can be decoded. + if (size >= 2) + { + Buffer payload; + if (size > 2) + { + payload.assign(reinterpret_cast(data) + 2, + reinterpret_cast(data) + size); + } + const auto dataType = static_cast(static_cast(data[0])); + const auto sequence = data[1]; + + try + { + auto packaged = CommandSerializer::packageDataForBt(payload, dataType, sequence); + Buffer body(packaged.begin() + 1, packaged.end() - 1); + Message expected{ dataType, sequence, payload }; + requireEqual(expected, CommandSerializer::unpackBtMessage(body)); + } + catch (const std::exception&) + { + // Oversized escaped output is an expected packager rejection. + } + } + + return 0; +} diff --git a/Client/fuzz/README.md b/Client/fuzz/README.md new file mode 100644 index 00000000..c45caffe --- /dev/null +++ b/Client/fuzz/README.md @@ -0,0 +1,36 @@ +# Protocol fuzzing + +`FuzzCommandSerializer.cpp` targets the raw frame body accepted by +`CommandSerializer::unpackBtMessage`. It checks two properties: + +- malformed input is rejected without memory errors or undefined behavior; +- valid packets retain their type, sequence, and payload after a package/decode + round trip. + +The corpus is generated in a temporary directory from reviewed hexadecimal seeds. +It includes empty, minimal valid, typical XM4, escaped-byte, truncated escape, and +oversized-length inputs. `sony_protocol.dict` supplies framing bytes and safe XM4 +v1 inquiry opcodes. + +Run the sanitizer campaign on macOS: + +```sh +Client/fuzz/run-macos.sh +``` + +Override the default 60-second smoke duration with `FUZZ_SECONDS`. Crash artifacts +are intentionally kept outside the repository; minimize and reproduce any finding, +then add the smallest input to the permanent protocol regression tests. + +The script prefers a libFuzzer-capable clang (`CXX`, else a Homebrew LLVM). Apple's +`/usr/bin/clang++` ships without the libFuzzer runtime; when no capable compiler is +found, the script builds `driver_main.cpp` instead — a deterministic seed-replay and +structured-mutation driver over the same `LLVMFuzzerTestOneInput` entry point, still +under ASan/UBSan. Override its default 1.5M iterations with `FUZZ_ITERATIONS`. +`lsan.supp` suppresses libFuzzer's own never-joined RSS-monitor thread so leak +detection stays enabled for the parser under test. + +MemorySanitizer is not included because it requires a fully instrumented C++ +standard library. ThreadSanitizer is not applicable to this single-threaded parser +target; connection concurrency is covered by the protocol integration tests and +Xcode static analysis. diff --git a/Client/fuzz/dictionaries/sony_protocol.dict b/Client/fuzz/dictionaries/sony_protocol.dict new file mode 100644 index 00000000..30b64f21 --- /dev/null +++ b/Client/fuzz/dictionaries/sony_protocol.dict @@ -0,0 +1,23 @@ +# Sony RFCOMM framing, escape, data-type, and XM4 v1 command tokens. +start="\x3e" +end="\x3c" +escape="\x3d" +escaped_start="\x3d\x2e" +escaped_end="\x3d\x2c" +escaped_escape="\x3d\x2d" +ack="\x01" +data_mdr="\x0c" +zero_size="\x00\x00\x00\x00" +size_two="\x00\x00\x00\x02" +size_eight="\x00\x00\x00\x08" +size_max="\x00\x00\x07\xf9" +size_overflow="\xff\xff\xff\xff" +init="\x00\x00" +firmware="\x04\x02" +battery="\x10\x00" +codec="\x18\x00" +equalizer="\x56\x01" +ambient="\x66\x02" +dsee="\xe6\x02" +auto_power_off="\xf6\x04" +speak_to_chat="\xf6\x05" diff --git a/Client/fuzz/driver_main.cpp b/Client/fuzz/driver_main.cpp new file mode 100644 index 00000000..815d8f4e --- /dev/null +++ b/Client/fuzz/driver_main.cpp @@ -0,0 +1,187 @@ +// Standalone deterministic driver for toolchains without libFuzzer. +// Replays every corpus seed plus the empty input, then runs bounded +// structured mutation (bit/byte flips, truncation, length-field tampering, +// escape/marker injection, seed splicing) through LLVMFuzzerTestOneInput. +// Build with -fsanitize=address,undefined -fno-sanitize-recover=all. +// Not compiled into the libFuzzer target; see run-macos.sh fallback path. + +#include +#include +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size); + +namespace +{ + constexpr size_t kMaxInput = 2048; // mirrors MAX_BLUETOOTH_MESSAGE_SIZE + + uint64_t rngState = 0x9e3779b97f4a7c15ull; + + uint64_t nextRand() + { + // xorshift64* — deterministic across runs and platforms. + rngState ^= rngState >> 12; + rngState ^= rngState << 25; + rngState ^= rngState >> 27; + return rngState * 0x2545f4914f6cdd1dull; + } + + void runOne(const std::vector& input) + { + LLVMFuzzerTestOneInput(input.data(), input.size()); + } + + void mutate(std::vector& buf) + { + // Bytes the protocol treats specially: markers, escape sentry, escape codes. + static const uint8_t special[] = { 0x3c, 0x3d, 0x3e, 0x2c, 0x2d, 0x2e, 0x00, 0xff }; + + switch (nextRand() % 8) + { + case 0: // bit flip + if (!buf.empty()) + { + buf[nextRand() % buf.size()] ^= static_cast(1u << (nextRand() % 8)); + } + break; + + case 1: // byte overwrite with random value + if (!buf.empty()) + { + buf[nextRand() % buf.size()] = static_cast(nextRand()); + } + break; + + case 2: // truncate + if (!buf.empty()) + { + buf.resize(nextRand() % buf.size()); + } + break; + + case 3: // length-field tampering (bytes 2..5 of the unescaped header) + if (buf.size() >= 6) + { + buf[2 + nextRand() % 4] = static_cast(nextRand()); + } + break; + + case 4: // escape/marker injection at a random position + { + size_t pos = buf.empty() ? 0 : nextRand() % buf.size(); + buf.insert(buf.begin() + pos, special[nextRand() % sizeof(special)]); + } + break; + + case 5: // overwrite with a special byte + if (!buf.empty()) + { + buf[nextRand() % buf.size()] = special[nextRand() % sizeof(special)]; + } + break; + + case 6: // append random tail + { + size_t extra = nextRand() % 16 + 1; + for (size_t i = 0; i < extra && buf.size() < kMaxInput; i++) + { + buf.push_back(static_cast(nextRand())); + } + } + break; + + case 7: // duplicate a random chunk (grows structure, keeps checksummed prefixes) + if (!buf.empty()) + { + size_t start = nextRand() % buf.size(); + size_t len = nextRand() % (buf.size() - start) + 1; + if (buf.size() + len <= kMaxInput) + { + std::vector chunk(buf.begin() + start, buf.begin() + start + len); + buf.insert(buf.begin() + start, chunk.begin(), chunk.end()); + } + } + break; + } + + if (buf.size() > kMaxInput) + { + buf.resize(kMaxInput); + } + } +} + +int main(int argc, char** argv) +{ + std::vector> seeds; + seeds.push_back({}); // empty input is a required boundary case + + for (int i = 1; i < argc; i++) + { + if (std::strncmp(argv[i], "-iterations=", 12) == 0) + { + continue; // handled below + } + std::ifstream f(argv[i], std::ios::binary); + if (!f) + { + std::fprintf(stderr, "cannot read seed %s\n", argv[i]); + return 2; + } + std::vector data((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + seeds.push_back(std::move(data)); + } + + unsigned long long iterations = 1500000; + for (int i = 1; i < argc; i++) + { + if (std::strncmp(argv[i], "-iterations=", 12) == 0) + { + iterations = std::strtoull(argv[i] + 12, nullptr, 10); + } + } + + // Replay every seed unmodified first. + for (const auto& s : seeds) + { + runOne(s); + } + + // Structured mutation: pick a seed (or a splice of two), apply 1-4 mutations. + std::vector buf; + for (unsigned long long i = 0; i < iterations; i++) + { + const auto& base = seeds[nextRand() % seeds.size()]; + buf = base; + if (nextRand() % 4 == 0 && seeds.size() > 1) + { + const auto& other = seeds[nextRand() % seeds.size()]; + size_t cut = buf.empty() ? 0 : nextRand() % (buf.size() + 1); + buf.resize(cut); + if (!other.empty()) + { + size_t take = nextRand() % (other.size() + 1); + buf.insert(buf.end(), other.begin(), other.begin() + take); + } + } + size_t rounds = nextRand() % 4 + 1; + for (size_t r = 0; r < rounds; r++) + { + mutate(buf); + } + runOne(buf); + + if ((i + 1) % 250000 == 0) + { + std::fprintf(stderr, "iter %llu\n", i + 1); + } + } + + std::fprintf(stderr, "done: %llu mutation iterations + %zu seed replays\n", + iterations, seeds.size()); + return 0; +} diff --git a/Client/fuzz/lsan.supp b/Client/fuzz/lsan.supp new file mode 100644 index 00000000..952d4547 --- /dev/null +++ b/Client/fuzz/lsan.supp @@ -0,0 +1,7 @@ +# libFuzzer's own runtime allocates at exit in ways LSan flags but that are not +# under-test code: the RSS-monitor std::thread is heap-allocated and never joined +# (fuzzer::StartRssThread), and the coverage/PC printer allocates in the symbolizer +# when new PCs are observed. Both stacks are entirely inside fuzzer:: internals with +# the parser nowhere on them. Suppress the whole libFuzzer runtime so detect_leaks=1 +# stays live for CommandSerializer/ByteMagic (their frames never contain "fuzzer::"). +leak:fuzzer:: diff --git a/Client/fuzz/run-macos.sh b/Client/fuzz/run-macos.sh new file mode 100644 index 00000000..2cefcb08 --- /dev/null +++ b/Client/fuzz/run-macos.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -eu + +client_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +fuzz_dir="$client_dir/fuzz" +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/sonybridge-fuzz.XXXXXX") +trap 'rm -rf "$work_dir"' EXIT INT TERM + +binary="$work_dir/fuzz-command-serializer" +corpus="$work_dir/corpus" +artifacts="$work_dir/artifacts" +mkdir -p "$corpus" "$artifacts" + +# Empty input is a required parser boundary seed. +: > "$corpus/empty" +for seed in "$fuzz_dir"/seeds/*.hex; do + name=$(basename "$seed" .hex) + xxd -r -p "$seed" "$corpus/$name" +done + +# Apple's clang++ ships without the libFuzzer runtime; the failure only shows at +# link time, so probe with a real link. Fall back to a Homebrew LLVM clang++, and +# if none exists run the deterministic mutation driver instead of libFuzzer. +probe="$work_dir/fuzzer-probe" +printf 'extern "C" int LLVMFuzzerTestOneInput(const unsigned char*, unsigned long) { return 0; }\n' > "$probe.cpp" +cxx="${CXX:-clang++}" +have_libfuzzer=1 +if ! "$cxx" -fsanitize=fuzzer "$probe.cpp" -o "$probe" 2>/dev/null; then + have_libfuzzer=0 + for candidate in /opt/homebrew/opt/llvm*/bin/clang++ /usr/local/opt/llvm*/bin/clang++; do + if [ -x "$candidate" ] && "$candidate" -fsanitize=fuzzer "$probe.cpp" -o "$probe" 2>/dev/null; then + cxx="$candidate" + have_libfuzzer=1 + break + fi + done +fi + +# Keep -max_len in lockstep with MAX_BLUETOOTH_MESSAGE_SIZE in Constants.h. +max_len=$(sed -n 's/.*MAX_BLUETOOTH_MESSAGE_SIZE = \([0-9]*\).*/\1/p' "$client_dir/Constants.h") + +export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" + +if [ "$have_libfuzzer" -eq 1 ]; then + # Apple's ASan lacks leak detection; only enable it for LLVM clang builds. + export ASAN_OPTIONS="abort_on_error=1:detect_leaks=1:symbolize=1" + export LSAN_OPTIONS="suppressions=$fuzz_dir/lsan.supp" + + "$cxx" -std=c++20 -g -O1 \ + -fsanitize=fuzzer,address,undefined \ + -fno-sanitize-recover=all -fno-omit-frame-pointer \ + -I"$client_dir" \ + "$fuzz_dir/FuzzCommandSerializer.cpp" \ + "$client_dir/ByteMagic.cpp" \ + "$client_dir/CommandSerializer.cpp" \ + -o "$binary" + + "$binary" \ + -max_total_time="${FUZZ_SECONDS:-60}" \ + -max_len="$max_len" \ + -timeout=5 \ + -use_value_profile=1 \ + -print_final_stats=1 \ + -dict="$fuzz_dir/dictionaries/sony_protocol.dict" \ + -artifact_prefix="$artifacts/" \ + "$corpus" +else + echo "libFuzzer unavailable; running deterministic mutation driver" >&2 + export ASAN_OPTIONS="abort_on_error=1:symbolize=1" + "$cxx" -std=c++20 -g -O1 \ + -fsanitize=address,undefined \ + -fno-sanitize-recover=all -fno-omit-frame-pointer \ + -I"$client_dir" \ + "$fuzz_dir/driver_main.cpp" \ + "$fuzz_dir/FuzzCommandSerializer.cpp" \ + "$client_dir/ByteMagic.cpp" \ + "$client_dir/CommandSerializer.cpp" \ + -o "$binary" + + "$binary" -iterations="${FUZZ_ITERATIONS:-1500000}" "$corpus"/* +fi diff --git a/Client/fuzz/seeds/ack.hex b/Client/fuzz/seeds/ack.hex new file mode 100644 index 00000000..db9ee083 --- /dev/null +++ b/Client/fuzz/seeds/ack.hex @@ -0,0 +1 @@ +01010000000002 diff --git a/Client/fuzz/seeds/ambient_level1.hex b/Client/fuzz/seeds/ambient_level1.hex new file mode 100644 index 00000000..2149fc46 --- /dev/null +++ b/Client/fuzz/seeds/ambient_level1.hex @@ -0,0 +1 @@ +0c0000000008680211020001000193 diff --git a/Client/fuzz/seeds/dangling_escape.hex b/Client/fuzz/seeds/dangling_escape.hex new file mode 100644 index 00000000..8a06c661 --- /dev/null +++ b/Client/fuzz/seeds/dangling_escape.hex @@ -0,0 +1 @@ +3d diff --git a/Client/fuzz/seeds/declared_size_overflow.hex b/Client/fuzz/seeds/declared_size_overflow.hex new file mode 100644 index 00000000..9978e831 --- /dev/null +++ b/Client/fuzz/seeds/declared_size_overflow.hex @@ -0,0 +1 @@ +0c00ffffffff00 diff --git a/Client/fuzz/seeds/escape_triplet.hex b/Client/fuzz/seeds/escape_triplet.hex new file mode 100644 index 00000000..35c9202a --- /dev/null +++ b/Client/fuzz/seeds/escape_triplet.hex @@ -0,0 +1 @@ +0c00000000033d2c3d2d3d2ec6 diff --git a/Client/fuzz/seeds/init.hex b/Client/fuzz/seeds/init.hex new file mode 100644 index 00000000..b095c536 --- /dev/null +++ b/Client/fuzz/seeds/init.hex @@ -0,0 +1 @@ +0c000000000200000e 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/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_1024.png index d04c24d2..1549669a 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_1024.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_128.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_128.png index 69b33b67..0be9d293 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_128.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_16.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_16.png index 4a18806e..45d16911 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_16.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_256.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_256.png index f4b375c0..35f8a1f7 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_256.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_32.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_32.png index c2ca3816..ba31dce3 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_32.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_512.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_512.png index 53d9f1e6..d7487410 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_512.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_64.png b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_64.png index 52e19827..823960fc 100644 Binary files a/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_64.png and b/Client/macos/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/Client/macos/ContentView.swift b/Client/macos/ContentView.swift index 45b0388a..22773ca5 100644 --- a/Client/macos/ContentView.swift +++ b/Client/macos/ContentView.swift @@ -6,22 +6,16 @@ import SwiftUI import AppKit -private enum Theme { - static let bg = Color(red: 0.07, green: 0.07, blue: 0.086) - static let card = Color(red: 0.13, green: 0.13, blue: 0.16) - static let cardHi = Color(red: 0.20, green: 0.20, blue: 0.24) - static let accent = Color(red: 0.62, green: 0.62, blue: 0.96) - static let secondary = Color(white: 0.62) -} - -@available(macOS 11.0, *) struct ContentView: View { - @StateObject private var model = HeadphonesModel() + @StateObject private var model = HeadphonesModel.shared @State private var showAbout = false + @AppStorage(AppTheme.storageKey) private var theme = AppTheme.refinedDark + @Environment(\.colorScheme) private var colorScheme + + private var palette: Palette { theme.palette(colorScheme) } var body: some View { ZStack { - Theme.bg.ignoresSafeArea() if model.connected { connectedView } else { @@ -29,6 +23,10 @@ struct ContentView: View { } } .frame(minWidth: 360, minHeight: 500) + .background(palette.background) + .environment(\.appTheme, theme) + .onAppear { theme.applyToApp() } + .onChange(of: theme) { $0.applyToApp() } } // MARK: - Disconnected @@ -38,57 +36,81 @@ struct ContentView: View { Spacer() Image(systemName: "headphones") .font(.system(size: 64, weight: .thin)) - .foregroundColor(Theme.secondary) + .foregroundColor(palette.secondary) Text("No headphones connected") .font(.system(size: 15, weight: .medium)) - .foregroundColor(Theme.secondary) + .foregroundColor(palette.secondary) if let error = model.errorMessage { Text(error) .font(.system(size: 12)) - .foregroundColor(.red.opacity(0.9)) + .foregroundColor(palette.danger) .multilineTextAlignment(.center) .padding(.horizontal, 32) } - Button(action: model.connect) { - Text(model.connecting ? "Connecting…" : "Connect headphones") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Theme.accent) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(PlainButtonStyle()) - .disabled(model.connecting) - .padding(.horizontal, 40) + ConnectButton(model: model) + .padding(.horizontal, 40) Spacer() } } // MARK: - Connected + // Two independently scrolling columns once the window is wide enough, otherwise one. The pair is capped + // and centered so cards don't stretch on a very wide window. private var connectedView: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 16) { - deviceHero - ambientCard - if model.mode == .ambientSound { - ambientLevelCard - } - if model.supportsEqualizer { - equalizerCard - dseeCard - } - if model.hasAdaptiveVolume || model.hasSpeakToChat || model.hasAutoPowerOff { - settingsCard + GeometryReader { proxy in + if proxy.size.width >= 720 { + HStack(alignment: .top, spacing: 0) { + column { primaryCards } + .frame(width: 380) + column(padding: [.vertical, .trailing]) { secondaryCards } + .frame(maxWidth: 580) } - if let error = model.errorMessage { - Text(error) - .font(.system(size: 12)) - .foregroundColor(.red.opacity(0.9)) + .frame(maxWidth: .infinity) + } else { + column { + primaryCards + secondaryCards } } - .padding(20) + } + } + + private func column(padding edges: Edge.Set = .all, + @ViewBuilder _ content: () -> Content) -> some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 16, content: content) + .padding(edges, 20) + } + } + + @ViewBuilder + private var primaryCards: some View { + deviceHero + ambientCard + if model.mode == .ambientSound { + ambientLevelCard + } + } + + @ViewBuilder + private var secondaryCards: some View { + if model.multipointLoading || model.hasPairedDevices || model.hasPlaybackPinState { + devicesCard + } + if model.supportsEqualizer { + equalizerCard + } + if model.supportsDsee { + dseeCard + } + if model.hasAdaptiveVolume || model.hasSpeakToChat || model.hasAutoPowerOff || model.hasMultipointSetting { + settingsCard + } + if let error = model.errorMessage { + Text(error) + .font(.system(size: 12)) + .foregroundColor(palette.danger) } } @@ -97,125 +119,54 @@ struct ContentView: View { HStack(spacing: 10) { Spacer() Button(action: { showAbout = true }) { - Image(systemName: "ellipsis") - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(Theme.secondary) - .frame(width: 32, height: 32) - .background(Theme.card) - .clipShape(Circle()) + roundIcon("ellipsis", size: 15) } .buttonStyle(PlainButtonStyle()) .popover(isPresented: $showAbout, arrowEdge: .bottom) { aboutView } Button(action: model.disconnect) { - Image(systemName: "power") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Theme.secondary) - .frame(width: 32, height: 32) - .background(Theme.card) - .clipShape(Circle()) + roundIcon("power", size: 14) } .buttonStyle(PlainButtonStyle()) } // Product-style banner. Generic headphone graphic (device-specific Sony renders are - // copyrighted and can't be bundled) over a soft accent halo, mirroring Sony's app layout. + // copyrighted and can't be bundled) over the theme's backdrop, mirroring Sony's app layout. ZStack { - // Soft accent glow behind the product for depth. - Circle() - .fill( - RadialGradient( - gradient: Gradient(colors: [Theme.accent.opacity(0.22), Theme.accent.opacity(0.0)]), - center: .center, startRadius: 4, endRadius: 104 - ) - ) - .frame(width: 210, height: 210) - - if let image = deviceImage() { - // Background-removed product cutout floating on the dark UI. - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 190, height: 190) - } else { - Image(systemName: "headphones") - .font(.system(size: 92, weight: .thin)) - .foregroundColor(.white) - } + HeroBackdrop() + ProductImage(deviceName: model.deviceName, size: 190) } Text(model.deviceName) .font(.system(size: 24, weight: .bold)) - .foregroundColor(.white) + .foregroundColor(palette.primary) - HStack(spacing: 7) { - if model.hasDualBattery { - Image(systemName: "battery.100") - .font(.system(size: 13)) - .foregroundColor(Theme.accent) - Text("L \(model.batteryLeft)% R \(model.batteryRight)%") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(Theme.secondary) - } else if model.batteryLevel >= 0 { - Image(systemName: model.batteryCharging ? "bolt.fill" : batterySymbol(model.batteryLevel)) - .font(.system(size: 13)) - .foregroundColor(model.batteryLevel <= 20 ? .red.opacity(0.9) : Theme.accent) - Text("\(model.batteryLevel)%") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Theme.secondary) - } else { - Circle().fill(Theme.accent).frame(width: 7, height: 7) - Text("Connected") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(Theme.secondary) - } - if !model.codec.isEmpty { - Text("·").foregroundColor(Theme.secondary) - Text(model.codec) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(Theme.secondary) - } - } + StatusChips(model: model) } .padding(.bottom, 4) } - private var ambientCard: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Ambient Sound Control") - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.white) - HStack(spacing: 0) { - modeButton(.noiseCanceling, "speaker.slash.fill", "Noise\nCanceling") - modeButton(.ambientSound, "wind", "Ambient\nSound") - modeButton(.off, "circle", "Off") - } - } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.card) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + private func roundIcon(_ symbol: String, size: CGFloat) -> some View { + Image(systemName: symbol) + .font(.system(size: size, weight: .semibold)) + .foregroundColor(palette.secondary) + .frame(width: 32, height: 32) + .background(palette.raised, in: Circle()) + .overlay(Circle().strokeBorder(palette.cardStroke, lineWidth: 1)) } - private func modeButton(_ mode: SHCAmbientMode, _ symbol: String, _ label: String) -> some View { - let selected = model.mode == mode - return Button(action: { model.setMode(mode) }) { - VStack(spacing: 8) { - ZStack { - Circle() - .fill(selected ? Theme.accent : Theme.cardHi) - .frame(width: 58, height: 58) - Image(systemName: symbol) - .font(.system(size: 22, weight: .medium)) - .foregroundColor(selected ? .white : Theme.secondary) - } - Text(label) - .font(.system(size: 11, weight: .medium)) - .multilineTextAlignment(.center) - .foregroundColor(selected ? .white : Theme.secondary) - } - .frame(maxWidth: .infinity) + private func cardTitle(_ title: String) -> some View { + Text(theme.isSignature ? title.uppercased() : title) + .font(.system(size: theme.isSignature ? 11 : 15, weight: .semibold)) + .tracking(theme.isSignature ? 1.2 : 0) + .foregroundColor(theme.isSignature ? palette.secondary : palette.primary) + } + + private var ambientCard: some View { + VStack(alignment: .leading, spacing: 16) { + cardTitle("Ambient Sound Control") + AmbientModeControl(model: model) } - .buttonStyle(.plain) + .themedCard() } private var ambientLevelCard: some View { @@ -223,104 +174,76 @@ struct ContentView: View { HStack { Text("Ambient Sound Level") .font(.system(size: 13, weight: .medium)) - .foregroundColor(Theme.secondary) + .foregroundColor(palette.secondary) Spacer() Text("\(model.ambientLevel)") - .font(.system(size: 20, weight: .bold, design: .rounded)) - .foregroundColor(Theme.accent) + .font(.system(size: 20, weight: .bold, design: theme.isSignature ? .monospaced : .rounded)) + .foregroundColor(palette.accentText) } - Slider( - value: Binding( - get: { Double(model.ambientLevel) }, - set: { model.setLevel(Int($0.rounded())) } - ), - in: 1...Double(model.maxAmbientLevel), - step: 1 - ) - .accentColor(Theme.accent) + AmbientLevelSlider(model: model) if model.focusOnVoiceAvailable { - Toggle(isOn: Binding( - get: { model.focusOnVoice }, - set: { model.setFocusOnVoice($0) } - )) { - Text("Focus on Voice") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white) - } - .toggleStyle(SwitchToggleStyle(tint: Theme.accent)) + FocusOnVoiceSwitch(model: model) } } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.card) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .themedCard() } - private let eqPresets: [(String, Int)] = [ - ("Off", 0x00), ("Bright", 0x10), ("Excited", 0x11), - ("Mellow", 0x12), ("Relaxed", 0x13), ("Vocal", 0x14), - ("Treble", 0x15), ("Bass", 0x16), ("Speech", 0x17), - ("Manual", 0xA0) - ] private let eqBandLabels = ["400", "1k", "2.5k", "6.3k", "16k"] + private let tenBandEqLabels = ["31", "63", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"] private var equalizerCard: some View { VStack(alignment: .leading, spacing: 14) { - Text("Equalizer") - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.white) + cardTitle("Equalizer") + EQPreview(bands: model.eqBands, range: model.tenBandEqualizer ? 0...12 : -10...10) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { - ForEach(eqPresets, id: \.1) { preset in + ForEach(EQPresets.list(tenBand: model.tenBandEqualizer), id: \.1) { preset in eqChip(preset.0, preset.1) } } if model.eqPreset == 0xA0 { - Divider().background(Theme.cardHi) - ForEach(0..<5, id: \.self) { i in - eqBandRow(eqBandLabels[i], value: Binding( + Divider().background(palette.cardStroke) + let labels = model.tenBandEqualizer ? tenBandEqLabels : eqBandLabels + ForEach(0.., display: Int, accent: Bool = false) -> some View { + // raw: the ten-band layout's 0...12 device steps (no dB mapping is known yet), shown unsigned. + private func eqBandRow(_ label: String, value: Binding, display: Int, accent: Bool = false, raw: Bool = false) -> some View { HStack(spacing: 10) { Text(label) .font(.system(size: 11, weight: .medium)) - .foregroundColor(accent ? Theme.accent : Theme.secondary) + .foregroundColor(accent ? palette.accentText : palette.secondary) .frame(width: 34, alignment: .leading) - Slider(value: value, in: -10...10, step: 1).accentColor(Theme.accent) - Text("\(display > 0 ? "+" : "")\(display)") + Slider(value: value, in: raw ? 0...12 : -10...10, step: 1).accentColor(palette.accent) + Text(raw ? "\(display)" : "\(display > 0 ? "+" : "")\(display)") .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundColor(.white) + .foregroundColor(palette.primary) .frame(width: 26, alignment: .trailing) } } private var dseeCard: some View { - Toggle(isOn: Binding(get: { model.dsee }, set: { model.setDsee($0) })) { + SwitchRow("DSEE", isOn: Binding(get: { model.dsee }, set: { model.setDsee($0) })) { VStack(alignment: .leading, spacing: 2) { - Text("DSEE").font(.system(size: 14, weight: .semibold)).foregroundColor(.white) - Text("Upscale compressed audio").font(.system(size: 11)).foregroundColor(Theme.secondary) + Text("DSEE").font(.system(size: 14, weight: .semibold)).foregroundColor(palette.primary) + Text("Upscale compressed audio").font(.system(size: 11)).foregroundColor(palette.secondary) } } - .toggleStyle(SwitchToggleStyle(tint: Theme.accent)) - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.card) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .themedCard() } private func eqChip(_ name: String, _ code: Int) -> some View { @@ -328,11 +251,9 @@ struct ContentView: View { return Button(action: { model.setEqualizer(code) }) { Text(name) .font(.system(size: 12, weight: .medium)) - .foregroundColor(selected ? .white : Theme.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 8) - .background(selected ? Theme.accent : Theme.cardHi) - .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .selectable(selected, filledWhenIdle: true) } .buttonStyle(PlainButtonStyle()) } @@ -341,9 +262,7 @@ struct ContentView: View { private var settingsCard: some View { VStack(alignment: .leading, spacing: 14) { - Text("Settings") - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.white) + cardTitle("Settings") if model.hasAdaptiveVolume { settingToggle("Adaptive Volume", "Auto-adjust volume by surroundings", on: model.adaptiveVolume) { model.setAdaptiveVolume($0) } @@ -351,12 +270,23 @@ struct ContentView: View { if model.hasSpeakToChat { settingToggle("Speak-to-Chat", "Pause music when you talk", on: model.speakToChat) { model.setSpeakToChat($0) } + SwitchRow("Keep off on connect", isOn: $model.keepSpeakToChatOff) { + Text("Keep off on connect") + .font(.system(size: 11)) + .foregroundColor(palette.secondary) + } + .controlSize(.small) + .padding(.leading, 12) + } + if model.hasMultipointSetting { + settingToggle("Connect to 2 devices", "Stay paired to two devices at once", + on: model.multipointEnabled) { model.setMultipoint($0) } } if model.hasAutoPowerOff { HStack { VStack(alignment: .leading, spacing: 2) { - Text("Auto Power-Off").font(.system(size: 14, weight: .semibold)).foregroundColor(.white) - Text("Turn off when idle").font(.system(size: 11)).foregroundColor(Theme.secondary) + Text("Auto Power-Off").font(.system(size: 14, weight: .semibold)).foregroundColor(palette.primary) + Text("Turn off when idle").font(.system(size: 11)).foregroundColor(palette.secondary) } Spacer() Picker("", selection: Binding( @@ -367,24 +297,172 @@ struct ContentView: View { } .pickerStyle(MenuPickerStyle()) .frame(width: 130) - .accentColor(Theme.accent) + .accentColor(palette.accent) } } } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.card) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .themedCard() } private func settingToggle(_ title: String, _ subtitle: String, on: Bool, action: @escaping (Bool) -> Void) -> some View { - Toggle(isOn: Binding(get: { on }, set: { action($0) })) { + SwitchRow(title, isOn: Binding(get: { on }, set: { action($0) })) { VStack(alignment: .leading, spacing: 2) { - Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(.white) - Text(subtitle).font(.system(size: 11)).foregroundColor(Theme.secondary) + Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(palette.primary) + Text(subtitle).font(.system(size: 11)).foregroundColor(palette.secondary) + } + } + } + + // 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 { + cardTitle("Devices") + 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(palette.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(palette.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + if model.hasPlaybackPinState { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Playback device").font(.system(size: 13, weight: .medium)).foregroundColor(palette.primary) + Text(model.playbackPinned ? "Fixed" : "Switches automatically") + .font(.system(size: 11)).foregroundColor(palette.secondary) + } + Spacer() + CapsuleButton(model.playbackPinned ? "Unfix" : "Fix") { + model.setPlaybackPinned(!model.playbackPinned) + } + .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 { + sectionLabel("Connected") + ForEach(connected) { deviceRow($0) } + } + if !paired.isEmpty { + sectionLabel("Paired") + ForEach(paired) { deviceRow($0) } + } + } else if !model.multipointLoading { + Text("This headset didn't return a device list.") + .font(.system(size: 12)) + .foregroundColor(palette.secondary) + } + if !model.multipointLoading { + devicesFooter + } + } + .themedCard() + } + + private var devicesFooter: some View { + VStack(alignment: .leading, spacing: 14) { + Divider().background(palette.cardStroke) + HStack { + Text(model.multipointBusy ? "Sending…" : "Switching and fixing are experimental") + .font(.system(size: 11)) + .foregroundColor(palette.secondary.opacity(0.7)) + Spacer() + Button(action: copyMultipointDiagnostics) { + Text("Copy diagnostics") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(palette.accentText) + } + .buttonStyle(PlainButtonStyle()) } } - .toggleStyle(SwitchToggleStyle(tint: Theme.accent)) + } + + private func sectionLabel(_ text: String) -> some View { + Text(text.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .foregroundColor(palette.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 ? palette.primary : palette.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 ? palette.primary : palette.secondary) + .lineLimit(1) + if device.isThisMac { + Text("This Mac").font(.system(size: 11)).foregroundColor(palette.accentText) + } + } + Spacer() + if device.playback { + if model.playbackPinned { + Image(systemName: "lock.fill") + .font(.system(size: 11)) + .foregroundColor(palette.accent) + } + Image(systemName: "speaker.wave.2.fill") + .font(.system(size: 12)) + .foregroundColor(palette.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. @@ -392,12 +470,15 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 0) { Text(model.deviceName) .font(.system(size: 15, weight: .bold)) - .foregroundColor(.white) + .foregroundColor(palette.primary) .padding(.bottom, 10) aboutRow("Status", model.connected ? "Connected" : "Disconnected") if model.hasDualBattery { - aboutRow("Battery L / R", "\(model.batteryLeft)% / \(model.batteryRight)%") - if model.batteryCase >= 0 { aboutRow("Case", "\(model.batteryCase)%") } + aboutRow("Battery L", batteryText(model.batteryLeft, model.batteryLeftCharging)) + aboutRow("Battery R", batteryText(model.batteryRight, model.batteryRightCharging)) + if model.batteryCase >= 0 { + aboutRow("Case", batteryText(model.batteryCase, model.batteryCaseCharging)) + } } else if model.batteryLevel >= 0 { aboutRow("Battery", "\(model.batteryLevel)%\(model.batteryCharging ? " (charging)" : "")") } @@ -405,29 +486,175 @@ struct ContentView: View { if !model.firmware.isEmpty { aboutRow("Firmware", model.firmware) } if !model.protocolVersion.isEmpty { aboutRow("Protocol", model.protocolVersion) } if !model.deviceMac.isEmpty { aboutRow("Bluetooth", model.deviceMac) } - Divider().background(Theme.cardHi).padding(.vertical, 10) + sectionLabel("Appearance") + .padding(.top, 12) + .padding(.bottom, 8) + HStack(spacing: 6) { + ForEach(AppTheme.allCases) { option in + themeOption(option) + } + } + Divider().background(palette.cardStroke).padding(.vertical, 10) Text("SonyBridge · not affiliated with Sony") .font(.system(size: 10)) - .foregroundColor(Theme.secondary.opacity(0.7)) + .foregroundColor(palette.secondary.opacity(0.7)) } .padding(18) - .frame(width: 260) - .background(Theme.card) + .frame(width: 290) + .background(palette.card) + } + + private func themeOption(_ option: AppTheme) -> some View { + let selected = option == theme + return Button(action: { theme = option }) { + VStack(spacing: 5) { + ThemeSwatch(theme: option) + Text(option.displayName) + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(selected ? palette.primary : palette.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(5) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(selected ? palette.accent : .clear, lineWidth: 2)) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + + private func batteryText(_ level: Int, _ charging: Bool) -> String { + guard level >= 0 else { return "—" } + return "\(level)%\(charging ? " (charging)" : "")" } private func aboutRow(_ label: String, _ value: String) -> some View { HStack { - Text(label).font(.system(size: 12)).foregroundColor(Theme.secondary) + Text(label).font(.system(size: 12)).foregroundColor(palette.secondary) Spacer() - Text(value).font(.system(size: 12, weight: .medium)).foregroundColor(.white) + Text(value).font(.system(size: 12, weight: .medium)).foregroundColor(palette.primary) } .padding(.vertical, 3) } +} + +// MARK: - Shared with the menu-bar panel + +enum EQPresets { + static let fiveBand: [(String, Int)] = [ + ("Off", 0x00), ("Bright", 0x10), ("Excited", 0x11), + ("Mellow", 0x12), ("Relaxed", 0x13), ("Vocal", 0x14), + ("Treble", 0x15), ("Bass", 0x16), ("Speech", 0x17), + ("Manual", 0xA0) + ] + // WH-1000XM6: only Off and Custom have confirmed preset codes on the ten-band layout; its other preset + // bytes (0x20, 0x30-0x33, 0xa1-0xa2) are unnamed, and the 5-band codes above don't apply to it. + static let tenBand: [(String, Int)] = [("Off", 0x00), ("Custom", 0xA0)] + + static func list(tenBand: Bool) -> [(String, Int)] { tenBand ? Self.tenBand : fiveBand } +} + +extension SHCAmbientMode { + var displayName: String { + switch self { + case .noiseCanceling: return "Noise Canceling" + case .ambientSound: return "Ambient" + default: return "Off" + } + } +} + +// The bundled product cutout matching the device name (e.g. "WH-CH720N" -> "wh-ch720n"), or a generic +// headphones glyph for models without one. +struct ProductImage: View { + let deviceName: String + let size: CGFloat + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + if let image = NSImage(named: deviceName.lowercased().replacingOccurrences(of: " ", with: "-")) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + } else { + Image(systemName: "headphones") + .font(.system(size: size * 0.48, weight: .thin)) + .foregroundColor(theme.palette(colorScheme).primary) + .frame(width: size, height: size) + } + } +} + +// Battery, codec and (optionally) the current mode as capsule chips. +struct StatusChips: View { + @ObservedObject var model: HeadphonesModel + var showsMode = true + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + private var palette: Palette { theme.palette(colorScheme) } + + var body: some View { + HStack(spacing: 6) { + chip { + if model.hasDualBattery { + // Earbuds report a battery each, plus the case when it's connected. An earbud that + // isn't reporting (docked / powered down) comes through as -1 and is left out. + budBattery("L", model.batteryLeft, charging: model.batteryLeftCharging) + Text("·") + budBattery("R", model.batteryRight, charging: model.batteryRightCharging) + if model.batteryCase >= 0 { + Text("·") + budBattery("Case", model.batteryCase, charging: model.batteryCaseCharging) + } + } else if model.batteryLevel >= 0 { + Image(systemName: model.batteryCharging ? "bolt.fill" : batterySymbol(model.batteryLevel)) + .foregroundColor(model.batteryLevel <= 20 ? palette.danger : palette.accent) + chipText("\(model.batteryLevel)%") + } else { + Circle().fill(palette.accent).frame(width: 6, height: 6) + chipText("Connected") + } + } + if !model.codec.isEmpty { + chip { chipText(model.codec) } + } + if showsMode { + chip { chipText(model.mode.displayName) } + } + } + } + + private func chip(@ViewBuilder _ content: () -> Content) -> some View { + HStack(spacing: 4, content: content) + .font(.system(size: theme.isSignature ? 10 : 11, weight: .semibold)) + .foregroundColor(palette.secondary) + .lineLimit(1) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(palette.raised, in: Capsule()) + .overlay(Capsule().strokeBorder(palette.cardStroke, lineWidth: 1)) + } + + private func chipText(_ text: String) -> Text { + Text(theme.isSignature ? text.uppercased() : text).tracking(theme.isSignature ? 0.8 : 0) + } - // Matches the connected device name to a bundled product image (e.g. "WH-CH720N" -> "wh-ch720n"). - private func deviceImage() -> NSImage? { - let slug = model.deviceName.lowercased().replacingOccurrences(of: " ", with: "-") - return NSImage(named: slug) + // One "L 82%" reading in the battery chip, with a bolt in place of the battery glyph while that unit + // is charging. A docked/powered-down earbud reports no level; show it dimmed as "R --" rather than + // dropping it, so it reads as "in the case" instead of looking like the app lost track of it. + private func budBattery(_ label: String, _ level: Int, charging: Bool) -> some View { + HStack(spacing: 3) { + Image(systemName: level < 0 ? "battery.0" : (charging ? "bolt.fill" : batterySymbol(level))) + .foregroundColor(level < 0 ? palette.secondary.opacity(0.5) + : (level <= 20 && !charging ? palette.danger : palette.accent)) + chipText(level < 0 ? "\(label) --" : "\(label) \(level)%") + .foregroundColor(palette.secondary.opacity(level < 0 ? 0.5 : 1.0)) + } } private func batterySymbol(_ level: Int) -> String { @@ -439,8 +666,106 @@ struct ContentView: View { } } +// Noise Canceling / Ambient / Off: segments in a recessed track, or separate pills in Sony Signature. +struct AmbientModeControl: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + private var palette: Palette { theme.palette(colorScheme) } + + var body: some View { + let track = RoundedRectangle(cornerRadius: 11, style: .continuous) + HStack(spacing: theme.isSignature ? 6 : 0) { + modeButton(.noiseCanceling) + modeButton(.ambientSound) + modeButton(.off) + } + .padding(theme.isSignature ? 0 : 3) + .background(palette.track, in: track) + .overlay(track.strokeBorder(theme.isSignature ? .clear : palette.cardStroke, lineWidth: 1)) + } + + private func modeButton(_ mode: SHCAmbientMode) -> some View { + let selected = model.mode == mode + return Button(action: { model.setMode(mode) }) { + Text(mode.displayName) + .font(.system(size: 11.5, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .selectable(selected) + .shadow(color: selected ? palette.selectedShadow : .clear, radius: 8) + } + .buttonStyle(.plain) + } +} + +struct AmbientLevelSlider: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Slider( + value: Binding( + get: { Double(model.ambientLevel) }, + set: { model.setLevel(Int($0.rounded())) } + ), + in: 1...Double(model.maxAmbientLevel), + step: 1 + ) + .accentColor(theme.palette(colorScheme).accent) + } +} + +struct FocusOnVoiceSwitch: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + SwitchRow("Focus on Voice", isOn: Binding( + get: { model.focusOnVoice }, + set: { model.setFocusOnVoice($0) } + )) { + Text("Focus on Voice") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(theme.palette(colorScheme).primary) + } + } +} + +struct ConnectButton: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let palette = theme.palette(colorScheme) + // Activate first: from the menu-bar panel the app isn't frontmost, and the device picker the + // connect shows would open behind other apps. + Button(action: { NSApp.activate(ignoringOtherApps: true); model.connect() }) { + Text(model.connecting ? "Connecting…" : "Connect headphones") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(palette.onAccent) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(palette.accent) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(model.connecting) + } +} + // Factory so the Obj-C++ ViewController can instantiate the SwiftUI hierarchy. -@available(macOS 11.0, *) @objc final class HeadphonesUIFactory: NSObject { @objc static func makeViewController() -> NSViewController { return NSHostingController(rootView: ContentView()) diff --git a/Client/macos/HeadphonesBridge.h b/Client/macos/HeadphonesBridge.h index dfbdd039..76ac43ac 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; @@ -23,7 +33,7 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { // Only meaningful while connected. @property (nonatomic, readonly) BOOL supportsVpt; // v1 protocol devices only -@property (nonatomic, readonly) NSInteger maxAmbientLevel; // 19 (v1) or 20 (v2) +@property (nonatomic, readonly) NSInteger maxAmbientLevel; // 1...20 on supported devices // Current on-device state, mirrored from the last successful command. @property (nonatomic, readonly) SHCAmbientMode mode; @@ -37,9 +47,15 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) NSInteger batteryLeft; // -1 if n/a @property (nonatomic, readonly) NSInteger batteryRight; @property (nonatomic, readonly) NSInteger batteryCase; +@property (nonatomic, readonly) BOOL batteryLeftCharging; +@property (nonatomic, readonly) BOOL batteryRightCharging; +@property (nonatomic, readonly) BOOL batteryCaseCharging; @property (nonatomic, readonly) NSInteger eqPreset; // raw preset byte (EQ_PRESET) -@property (nonatomic, readonly) BOOL supportsEqualizer; // v2 devices only +@property (nonatomic, readonly) BOOL supportsEqualizer; // true after a successful read-only probe @property (nonatomic, readonly) NSInteger clearBass; // -10..10 +@property (nonatomic, readonly) NSInteger equalizerBandCount; // 5, or 10 on the ten-band layout +@property (nonatomic, readonly) BOOL tenBandEqualizer; // WH-1000XM6: raw 0..12 bands, no clear bass +@property (nonatomic, readonly) BOOL supportsDsee; // true after a successful read-only probe @property (nonatomic, readonly) BOOL dsee; // DSEE / audio upsampling // Optional features (present only if the device answered the capability probe on connect). @@ -54,6 +70,15 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) BOOL hasAdaptiveVolume; @property (nonatomic, readonly) BOOL adaptiveVolume; +// Multipoint (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; @@ -84,11 +109,24 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { - (void)setAutoPowerOff:(NSInteger)index completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; - (void)setSpeakToChat:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; - (void)setAdaptiveVolume:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; +- (void)setMultipoint:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; // Re-reads the fast-changing state (ambient/NC, level, EQ, DSEE) so changes made with the headphone's // own button show up in the app. Called on a timer while connected. - (void)refreshDynamicWithCompletion:(void (^)(void))completion; +// 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..90678d39 100644 --- a/Client/macos/HeadphonesBridge.mm +++ b/Client/macos/HeadphonesBridge.mm @@ -7,6 +7,7 @@ #import #import +#include #include #include "MacOSBluetoothConnector.h" #include "BluetoothWrapper.h" @@ -14,20 +15,46 @@ // RecoverableException comes in transitively via BluetoothWrapper.h -> IBluetoothConnector.h -> Exceptions.h. // (Exceptions.h isn't a project file reference, so it can't be #included directly from this directory.) +// The dynamic poll fires every 2s; re-read the battery every 15th tick (~30s), which is far more often +// than a headphone battery meaningfully moves but cheap enough not to crowd out the ambient/NC polling. +static const unsigned kBatteryPollEveryNTicks = 15; + +@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; + // Queued operations retain the current device state so disconnecting cannot invalidate a raw pointer + // while a Bluetooth read or write is still completing. + std::shared_ptr _hp; NSString *_deviceName; NSString *_deviceMac; - BOOL _initialized; - // 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). + std::atomic_bool _initialized; + // Only touched inside blocks on _cmdQueue, which is serial. + unsigned _dynamicTick; + // All device I/O runs on this serial queue. Bluetooth commands are stateful, and serializing reads with + // writes also prevents a slow capability probe from racing a user action or dynamic refresh. dispatch_queue_t _cmdQueue; } - (instancetype)init { if ((self = [super init])) { _bt = std::make_unique(std::make_unique()); + _initialized = false; _cmdQueue = dispatch_queue_create("com.sonybridge.commands", DISPATCH_QUEUE_SERIAL); } return self; @@ -56,8 +83,7 @@ - (BOOL)supportsVpt { } - (NSInteger)maxAmbientLevel { - if (self.connected && _bt->getProtocolVersion() == SonyProtocolVersion::V2) return 20; - return 19; + return 20; } - (SHCAmbientMode)mode { @@ -90,13 +116,16 @@ - (BOOL)hasDualBattery { return _hp && _hp->hasDualBattery(); } - (NSInteger)batteryLeft { return _hp ? _hp->getBatteryLeft() : -1; } - (NSInteger)batteryRight { return _hp ? _hp->getBatteryRight() : -1; } - (NSInteger)batteryCase { return _hp ? _hp->getBatteryCase() : -1; } +- (BOOL)batteryLeftCharging { return _hp && _hp->isBatteryLeftCharging(); } +- (BOOL)batteryRightCharging { return _hp && _hp->isBatteryRightCharging(); } +- (BOOL)batteryCaseCharging { return _hp && _hp->isBatteryCaseCharging(); } - (NSInteger)eqPreset { return _hp ? (NSInteger)_hp->getEqualizerPreset() : 0; } - (BOOL)supportsEqualizer { - return self.connected && _bt->getProtocolVersion() == SonyProtocolVersion::V2; + return _hp && _hp->hasEqualizer(); } - (NSInteger)clearBass { @@ -107,6 +136,18 @@ - (BOOL)dsee { return _hp ? _hp->getDsee() : NO; } +- (BOOL)supportsDsee { + return _hp && _hp->hasDsee(); +} + +- (NSInteger)equalizerBandCount { + return _hp ? _hp->getEqualizerBandCount() : 5; +} + +- (BOOL)tenBandEqualizer { + return _hp && _hp->isTenBandEqualizer(); +} + - (NSInteger)equalizerBandAtIndex:(NSInteger)index { return _hp ? _hp->getEqualizerBand((int)index) : 0; } @@ -122,9 +163,36 @@ - (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"]; + NSArray *prefixes = @[@"WH-", @"WF-", @"WI-", @"MDR-", @"XB", @"LinkBuds", @"ULT"]; for (NSString *p in prefixes) { if ([name hasPrefix:p] || [name containsString:p]) return YES; } @@ -141,6 +209,15 @@ - (void)scanAndConnectWithCompletion:(void (^)(BOOL, NSString * _Nullable))compl break; } } + // BLE/audio-only headsets (e.g. ULT WEAR) report isConnected == NO; fall back to any paired Sony headset. + if (!device) { + for (IOBluetoothDevice *paired in [IOBluetoothDevice pairedDevices]) { + if (SHCLooksLikeSonyHeadset([paired name])) { + device = paired; + break; + } + } + } if (!device) { // Fall back to the native picker if we can't auto-identify a connected Sony device. @@ -157,8 +234,16 @@ - (void)scanAndConnectWithCompletion:(void (^)(BOOL, NSString * _Nullable))compl return; } + // A device without an address string would hand std::string a null char* below (undefined + // behaviour); getConnectedDevices() applies the same guard. + NSString *address = [device addressString]; + if (!address) { + completion(NO, @"The selected device has no Bluetooth address."); + return; + } + try { - _bt->connect([[device addressString] UTF8String]); + _bt->connect([address UTF8String]); } catch (RecoverableException &exc) { completion(NO, @(exc.what())); return; @@ -180,7 +265,12 @@ - (void)scanAndConnectWithCompletion:(void (^)(BOOL, NSString * _Nullable))compl _deviceName = [device nameOrAddress]; _deviceMac = [device addressString]; - _hp = std::make_unique(*_bt); + _hp = std::make_shared(*_bt); + // Lets requestBattery() probe the per-earbud layout first on TWS models (WF-*/LinkBuds). + _hp->setDeviceName(_deviceName ? [_deviceName UTF8String] : ""); + // A link the headset dropped on its own never went through -disconnect, so reset here: every new + // Headphones needs the init handshake (its reply also gates the multipoint reads). + _initialized = false; completion(YES, nil); } @@ -189,52 +279,130 @@ - (void)disconnect { _hp.reset(); _deviceName = nil; _deviceMac = nil; - _initialized = NO; + _initialized = false; } - (void)refreshStatusWithCompletion:(void (^)(void))completion { if (!_hp || !self.connected) { completion(); return; } - Headphones *hp = _hp.get(); - // CRITICAL: the init/battery/EQ inquiries below use v2 opcodes. Opcode 0x22 is BATTERY_LEVEL_REQUEST - // on v2 but POWER_OFF on v1 - sending it to a v1 device (e.g. WH-1000XM4) powers the headphones off. - // A v1 device still needs *some* valid handshake traffic right after connect or it drops/powers off on - // its own, so read its ambient state (read-only 66 02) instead of sending nothing. - if (_bt->getProtocolVersion() != SonyProtocolVersion::V2) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - try { hp->requestAmbientState(); } catch (std::exception &exc) {} - dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); - }); - return; - } - // Reads run on the global queue (not the serial command queue) so they don't block quick user taps; - // per-call the connector mutex still serializes actual I/O. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - // Fast, always-supported reads first, then update the UI immediately... - try { - if (!self->_initialized) { - hp->initDevice(); - self->_initialized = YES; - } - hp->requestBattery(); - hp->requestEqualizer(); - hp->requestDsee(); - } catch (std::exception &exc) {} + auto hp = _hp; + dispatch_async(_cmdQueue, ^{ + // Every method chooses its command family from the SDP-confirmed protocol version. This is + // essential for v1: 0x22 powers off an XM4 but requests battery state on v2. + if (!self->_initialized.load()) { + try { hp->initDevice(); self->_initialized = true; } catch (std::exception &exc) {} + } + try { hp->requestAmbientState(); } catch (std::exception &exc) {} + try { hp->requestBattery(); } catch (std::exception &exc) {} + try { hp->requestEqualizer(); } catch (std::exception &exc) {} + try { hp->requestDsee(); } 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. + // ...then the optional-feature probes and the multipoint discovery (its general-settings slot search), + // 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. requestMultipoint() is a no-op on v1. try { hp->probeCapabilities(); } catch (std::exception &exc) {} + 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.load()) { + completion(); + return; + } + auto hp = _hp; + dispatch_async(_cmdQueue, ^{ + 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.load()) { + completion(NO, @"Not connected."); + return; + } + auto hp = _hp; + dispatch_async(_cmdQueue, ^{ + NSString *error = nil; + try { + error = SHCSourceSwitchError(write(hp.get())); + } 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 ?: ""; + [self runMultipointWrite:^int(Headphones *hp) { + // Decide from the state as of this queued write, not the tap: a poll or the phone may have moved + // playback in between, and a stale "already playing" would fix playback to the wrong device. + bool alreadyPlaying = false; + for (const auto &device : hp->getPairedDevices()) { + if (device.playback && strcasecmp(device.address.c_str(), target.c_str()) == 0) alreadyPlaying = true; + } + bool pinned = hp->isPlaybackPinned(); + 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(); - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + auto hp = _hp; + BOOL isV2 = _bt->getProtocolVersion() == SonyProtocolVersion::V2; + dispatch_async(_cmdQueue, ^{ // The headphone's physical button only changes ambient/NC, so that's all we poll (keeps traffic low). // requestAmbientState() is protocol-aware (v2 uses 66 17, v1 uses 66 02), so this is safe on both. try { hp->requestAmbientState(); } catch (std::exception &exc) {} + // Battery drains, earbuds get docked and the case charges them, so re-read it too - but only + // every Nth poll, since it costs up to three inquiries on TWS. v2 only: 0x22 is POWER_OFF on v1. + if (isV2 && self->_dynamicTick++ % kBatteryPollEveryNTicks == 0) { + try { hp->requestBattery(); } catch (std::exception &exc) {} + } dispatch_async(dispatch_get_main_queue(), ^{ completion(); }); }); } @@ -244,12 +412,11 @@ - (void)setEqualizerPreset:(NSInteger)preset completion:(void (^)(BOOL, NSString completion(NO, @"Not connected."); return; } - // The v2 EQ SET opcode differs from v1; only issue it on confirmed v2 devices. - if (_bt->getProtocolVersion() != SonyProtocolVersion::V2) { + if (!_hp->hasEqualizer()) { completion(NO, @"Equalizer control isn't supported on this device yet."); return; } - Headphones *hp = _hp.get(); + auto hp = _hp; dispatch_async(_cmdQueue, ^{ NSString *error = nil; BOOL ok = YES; @@ -266,14 +433,14 @@ - (void)setEqualizerPreset:(NSInteger)preset completion:(void (^)(BOOL, NSString } - (void)setCustomEqualizerBass:(NSInteger)bass bands:(NSArray *)bands completion:(void (^)(BOOL, NSString * _Nullable))completion { - if (!_hp || !self.connected || _bt->getProtocolVersion() != SonyProtocolVersion::V2) { + if (!_hp || !self.connected || !_hp->hasEqualizer()) { completion(NO, @"Equalizer control isn't supported on this device yet."); return; } std::vector cbands; for (NSNumber *n in bands) cbands.push_back((int)n.integerValue); int cbass = (int)bass; - Headphones *hp = _hp.get(); + auto hp = _hp; dispatch_async(_cmdQueue, ^{ NSString *error = nil; BOOL ok = YES; try { @@ -284,11 +451,11 @@ - (void)setCustomEqualizerBass:(NSInteger)bass bands:(NSArray *)band } - (void)setDsee:(BOOL)enabled completion:(void (^)(BOOL, NSString * _Nullable))completion { - if (!_hp || !self.connected || _bt->getProtocolVersion() != SonyProtocolVersion::V2) { + if (!_hp || !self.connected || !_hp->hasDsee()) { completion(NO, @"Not supported on this device."); return; } - Headphones *hp = _hp.get(); + auto hp = _hp; dispatch_async(_cmdQueue, ^{ NSString *error = nil; BOOL ok = YES; try { @@ -307,27 +474,26 @@ - (void)applyMode:(SHCAmbientMode)mode return; } - switch (mode) { - case SHCAmbientModeOff: - _hp->setAmbientSoundControl(false); - break; - case SHCAmbientModeNoiseCanceling: - _hp->setAmbientSoundControl(true); - _hp->setAsmLevel(0); - _hp->setFocusOnVoice(false); - break; - case SHCAmbientModeAmbientSound: - _hp->setAmbientSoundControl(true); - _hp->setAsmLevel((int)MAX(level, 1)); - _hp->setFocusOnVoice(focusVoice); - break; - } - - Headphones *hp = _hp.get(); + auto hp = _hp; dispatch_async(_cmdQueue, ^{ NSString *error = nil; BOOL ok = YES; try { + switch (mode) { + case SHCAmbientModeOff: + hp->setAmbientSoundControl(false); + break; + case SHCAmbientModeNoiseCanceling: + hp->setAmbientSoundControl(true); + hp->setAsmLevel(0); + hp->setFocusOnVoice(false); + break; + case SHCAmbientModeAmbientSound: + hp->setAmbientSoundControl(true); + hp->setAsmLevel((int)MAX(level, 1)); + hp->setFocusOnVoice(focusVoice); + break; + } if (hp->isChanged()) hp->setChanges(); } catch (RecoverableException &exc) { ok = NO; @@ -336,6 +502,9 @@ - (void)applyMode:(SHCAmbientMode)mode ok = NO; error = @(exc.what()); } + // The write never landed, so stop it counting as pending - otherwise the ambient read-back stays + // suppressed and the UI freezes on a value the headphones never took. + if (!ok) hp->discardAmbientChanges(); dispatch_async(dispatch_get_main_queue(), ^{ completion(ok, error); }); @@ -344,7 +513,7 @@ - (void)applyMode:(SHCAmbientMode)mode - (void)setAutoPowerOff:(NSInteger)index completion:(void (^)(BOOL, NSString * _Nullable))completion { if (!_hp || !self.connected) { completion(NO, @"Not connected."); return; } - Headphones *hp = _hp.get(); + auto hp = _hp; dispatch_async(_cmdQueue, ^{ NSString *error = nil; BOOL ok = YES; try { hp->setAutoPowerOff((int)index); } catch (std::exception &exc) { ok = NO; error = @(exc.what()); } @@ -354,7 +523,7 @@ - (void)setAutoPowerOff:(NSInteger)index completion:(void (^)(BOOL, NSString * _ - (void)setSpeakToChat:(BOOL)enabled completion:(void (^)(BOOL, NSString * _Nullable))completion { if (!_hp || !self.connected) { completion(NO, @"Not connected."); return; } - Headphones *hp = _hp.get(); + auto hp = _hp; dispatch_async(_cmdQueue, ^{ NSString *error = nil; BOOL ok = YES; try { hp->setSpeakToChat(enabled); } catch (std::exception &exc) { ok = NO; error = @(exc.what()); } @@ -364,7 +533,7 @@ - (void)setSpeakToChat:(BOOL)enabled completion:(void (^)(BOOL, NSString * _Null - (void)setAdaptiveVolume:(BOOL)enabled completion:(void (^)(BOOL, NSString * _Nullable))completion { if (!_hp || !self.connected) { completion(NO, @"Not connected."); return; } - Headphones *hp = _hp.get(); + auto hp = _hp; dispatch_async(_cmdQueue, ^{ NSString *error = nil; BOOL ok = YES; try { hp->setAdaptiveVolume(enabled); } catch (std::exception &exc) { ok = NO; error = @(exc.what()); } @@ -372,4 +541,14 @@ - (void)setAdaptiveVolume:(BOOL)enabled completion:(void (^)(BOOL, NSString * _N }); } +- (void)setMultipoint:(BOOL)enabled completion:(void (^)(BOOL, NSString * _Nullable))completion { + if (!_hp || !self.connected) { completion(NO, @"Not connected."); return; } + auto hp = _hp; + dispatch_async(_cmdQueue, ^{ + NSString *error = nil; BOOL ok = YES; + try { hp->setMultipoint(enabled); } catch (std::exception &exc) { ok = NO; error = @(exc.what()); } + dispatch_async(dispatch_get_main_queue(), ^{ completion(ok, error); }); + }); +} + @end diff --git a/Client/macos/HeadphonesModel.swift b/Client/macos/HeadphonesModel.swift index cda475ad..add153d0 100644 --- a/Client/macos/HeadphonesModel.swift +++ b/Client/macos/HeadphonesModel.swift @@ -6,7 +6,24 @@ import Foundation import Combine +private let keepSpeakToChatOffKey = "KeepSpeakToChatOffOnConnect" + +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 @@ -26,10 +43,15 @@ final class HeadphonesModel: ObservableObject { @Published var batteryLeft = -1 @Published var batteryRight = -1 @Published var batteryCase = -1 + @Published var batteryLeftCharging = false + @Published var batteryRightCharging = false + @Published var batteryCaseCharging = false @Published var eqPreset = 0 @Published var supportsEqualizer = false @Published var eqBands = [0, 0, 0, 0, 0] + @Published var tenBandEqualizer = false @Published var clearBass = 0 + @Published var supportsDsee = false @Published var dsee = false @Published var hasAutoPowerOff = false @@ -38,16 +60,52 @@ final class HeadphonesModel: ObservableObject { @Published var codec = "" @Published var hasSpeakToChat = false @Published var speakToChat = false + // Force Speak-to-Chat off each time we connect (it pauses audio whenever it hears + // speech, which is hostile to dictation). Flipping this takes effect next connect. + @Published var keepSpeakToChatOff = UserDefaults.standard.object(forKey: keepSpeakToChatOffKey) as? Bool ?? true { + didSet { UserDefaults.standard.set(keepSpeakToChatOff, forKey: keepSpeakToChatOffKey) } + } @Published var hasAdaptiveVolume = false @Published var adaptiveVolume = false @Published var deviceMac = "" @Published var protocolVersion = "" + @Published var hasPairedDevices = false + @Published var pairedDevices: [PairedDeviceInfo] = [] + @Published var hasPlaybackPinState = false + @Published var playbackPinned = false + // Multipoint ("Connect to 2 devices"). Device-persistent - no app-enforced default; the + // toggle just mirrors the on-device state. + @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? private var dynamicTimer: Timer? + // Slider-drag coalescing (mirrors the ImGui in-flight gate in CrossPlatformGUI). + // While an exchange is in flight, only the latest value is remembered; when the + // exchange completes, at most one more send fires with that latest value. All + // state is main-thread-confined (bridge completions arrive on the main queue). + private var pushInFlight = false + private var pendingPush: (mode: SHCAmbientMode, level: Int, focusVoice: Bool)? + private var eqInFlight = false + private var pendingEq: (bass: Int, bands: [Int])? + + // Armed on each successful connect; consumed once Speak-to-Chat capability is + // confirmed, so the off-on-connect enforcement fires at most once per connection. + private var s2cAutoDisablePending = false + func connect() { connecting = true errorMessage = nil @@ -56,6 +114,7 @@ final class HeadphonesModel: ObservableObject { self.bridge.scanAndConnect { ok, error in self.connecting = false if ok { + self.s2cAutoDisablePending = true self.syncFromBridge() self.startWatchingConnection() self.refreshStatus() @@ -69,16 +128,14 @@ final class HeadphonesModel: ObservableObject { func refreshStatus() { bridge.refreshStatus { - self.batteryLevel = self.bridge.batteryLevel - self.batteryCharging = self.bridge.batteryCharging - self.hasDualBattery = self.bridge.hasDualBattery - self.batteryLeft = self.bridge.batteryLeft - self.batteryRight = self.bridge.batteryRight - self.batteryCase = self.bridge.batteryCase + self.syncBatteryFromBridge() self.eqPreset = self.bridge.eqPreset + self.supportsEqualizer = self.bridge.supportsEqualizer self.clearBass = self.bridge.clearBass + self.supportsDsee = self.bridge.supportsDsee self.dsee = self.bridge.dsee - self.eqBands = (0..<5).map { self.bridge.equalizerBand(at: $0) } + self.tenBandEqualizer = self.bridge.tenBandEqualizer + self.eqBands = self.readEqBands() self.hasAutoPowerOff = self.bridge.hasAutoPowerOff self.autoPowerOff = self.bridge.autoPowerOff self.firmware = self.bridge.firmware ?? "" @@ -87,9 +144,76 @@ final class HeadphonesModel: ObservableObject { self.speakToChat = self.bridge.speakToChat self.hasAdaptiveVolume = self.bridge.hasAdaptiveVolume self.adaptiveVolume = self.bridge.adaptiveVolume + self.syncMultipointFromBridge() + // Speak-to-Chat capability is only confirmed by the probeCapabilities pass + // (the second completion), so this stays armed until then and fires at most + // once per connect. The device stays untouched unless it reports S2C on. + if self.s2cAutoDisablePending && self.hasSpeakToChat { + self.s2cAutoDisablePending = false + if self.keepSpeakToChatOff && self.speakToChat { + self.setSpeakToChat(false) + } + } } } + 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 + } + + private func readEqBands() -> [Int] { + (0..(_ keyPath: ReferenceWritableKeyPath, _ newValue: T) { + if self[keyPath: keyPath] != newValue { + self[keyPath: keyPath] = newValue + } + } + // 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 + // Only publish what refreshDynamic actually re-read: ambient/NC state and the battery. + // EQ and DSEE aren't re-read on this path - they'd come straight back from the C++ cache, + // so a failed write would snap back two seconds later and an EQ drag would fight the timer. + self.assignIfChanged(\.mode, self.bridge.mode) let level = self.bridge.ambientLevel - if level > 0 { self.ambientLevel = level } - self.eqPreset = self.bridge.eqPreset - self.clearBass = self.bridge.clearBass - self.dsee = self.bridge.dsee - self.eqBands = (0..<5).map { self.bridge.equalizerBand(at: $0) } + if level > 0 { self.assignIfChanged(\.ambientLevel, level) } + self.syncBatteryFromBridge() } } } @@ -130,11 +276,27 @@ final class HeadphonesModel: ObservableObject { dynamicTimer = nil } + // The controls below update optimistically and are corrected here if the write failed, rather than + // being silently overwritten by the next poll tick. func setEqualizer(_ preset: Int) { + if preset == 0xA0 { + applyCustomEq() + return + } eqPreset = preset errorMessage = nil bridge.setEqualizerPreset(preset) { ok, error in - if !ok, let error = error { self.errorMessage = error } + if !ok { + self.eqPreset = self.bridge.eqPreset + if let error = error { self.errorMessage = error } + return + } + // The bridge re-reads the EQ after the write, so this picks up the band values that belong to + // the preset just selected - in particular the custom curve the device restores for Manual, + // which is what the sliders below the chips show. + self.eqPreset = self.bridge.eqPreset + self.clearBass = self.bridge.clearBass + self.eqBands = self.readEqBands() } } @@ -142,7 +304,24 @@ final class HeadphonesModel: ObservableObject { func applyCustomEq() { eqPreset = 0xA0 errorMessage = nil - bridge.setCustomEqualizerBass(clearBass, bands: eqBands.map { NSNumber(value: $0) }) { ok, error in + if eqInFlight { + pendingEq = (clearBass, eqBands) + return + } + sendCustomEq((clearBass, eqBands)) + } + + private func sendCustomEq(_ sent: (bass: Int, bands: [Int])) { + eqInFlight = true + bridge.setCustomEqualizerBass(sent.bass, bands: sent.bands.map { NSNumber(value: $0) }) { ok, error in + self.eqInFlight = false + if let pending = self.pendingEq { + self.pendingEq = nil + if pending != sent { + self.sendCustomEq(pending) + return + } + } if !ok, let error = error { self.errorMessage = error } } } @@ -151,16 +330,23 @@ final class HeadphonesModel: ObservableObject { dsee = on errorMessage = nil bridge.setDsee(on) { ok, error in - if !ok, let error = error { self.errorMessage = error } + if !ok { + self.dsee = self.bridge.dsee + if let error = error { self.errorMessage = error } + } } } func disconnect() { stopWatchingConnection() stopDynamicPolling() + pendingPush = nil + pendingEq = nil + s2cAutoDisablePending = false 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 @@ -175,6 +361,10 @@ final class HeadphonesModel: ObservableObject { self.errorMessage = "Headphones disconnected." self.stopWatchingConnection() self.stopDynamicPolling() + // Release the dead session too, so a reconnect starts clean and the device list doesn't + // linger with Fix/Switch enabled. + self.bridge.disconnect() + self.syncMultipointFromBridge() } } } @@ -201,27 +391,63 @@ final class HeadphonesModel: ObservableObject { private func pushState() { errorMessage = nil - bridge.applyMode(mode, level: ambientLevel, focusVoice: focusOnVoice) { ok, error in - if ok { - self.syncFromBridge() - } else if let error = error { - self.errorMessage = error + if pushInFlight { + pendingPush = (mode, ambientLevel, focusOnVoice) + return + } + sendPush((mode, ambientLevel, focusOnVoice)) + } + + private func sendPush(_ sent: (mode: SHCAmbientMode, level: Int, focusVoice: Bool)) { + pushInFlight = true + bridge.applyMode(sent.mode, level: sent.level, focusVoice: sent.focusVoice) { ok, error in + self.pushInFlight = false + if let pending = self.pendingPush { + self.pendingPush = nil + if pending != sent { + self.sendPush(pending) + return + } } + // Sync either way: on failure the bridge has rolled the pending change back, so this shows + // what the headphones are actually set to instead of leaving the UI lying. + self.syncFromBridge() + if !ok, let error = error { self.errorMessage = error } } } private func syncFromBridge() { - connected = bridge.connected - deviceName = bridge.deviceName ?? "" - deviceMac = bridge.deviceMac ?? "" - protocolVersion = bridge.protocolVersionString ?? "" - supportsVpt = bridge.supportsVpt - supportsEqualizer = bridge.supportsEqualizer - maxAmbientLevel = bridge.maxAmbientLevel - mode = bridge.mode + assignIfChanged(\.connected, bridge.connected) + assignIfChanged(\.deviceName, bridge.deviceName ?? "") + assignIfChanged(\.deviceMac, bridge.deviceMac ?? "") + assignIfChanged(\.protocolVersion, bridge.protocolVersionString ?? "") + assignIfChanged(\.supportsVpt, bridge.supportsVpt) + assignIfChanged(\.supportsEqualizer, bridge.supportsEqualizer) + assignIfChanged(\.supportsDsee, bridge.supportsDsee) + assignIfChanged(\.maxAmbientLevel, bridge.maxAmbientLevel) + assignIfChanged(\.mode, bridge.mode) let level = bridge.ambientLevel - if level > 0 { ambientLevel = level } - focusOnVoice = bridge.focusOnVoice - focusOnVoiceAvailable = bridge.focusOnVoiceAvailable + if level > 0 { assignIfChanged(\.ambientLevel, level) } + assignIfChanged(\.focusOnVoice, bridge.focusOnVoice) + assignIfChanged(\.focusOnVoiceAvailable, bridge.focusOnVoiceAvailable) + } +} + +// Playback-pin shortcuts shared by the menu-bar menu and the quick-controls panel. +extension HeadphonesModel { + var thisMac: PairedDeviceInfo? { + pairedDevices.first { $0.isThisMac && $0.connected } + } + + var playbackPinnedToThisMac: Bool { + playbackPinned && thisMac?.playback == true + } + + var playbackDevice: PairedDeviceInfo? { + connected ? pairedDevices.first { $0.playback } : nil + } + + func pinPlaybackToThisMac() { + if let thisMac = thisMac { switchPlayback(to: thisMac, fix: true) } } } diff --git a/Client/macos/MacOSBluetoothConnector.h b/Client/macos/MacOSBluetoothConnector.h index 186b06a4..7e87aa16 100644 --- a/Client/macos/MacOSBluetoothConnector.h +++ b/Client/macos/MacOSBluetoothConnector.h @@ -24,6 +24,21 @@ class MacOSBluetoothConnector final : public IBluetoothConnector virtual void closeConnection(); virtual SonyProtocolVersion getProtocolVersion() noexcept; + // Called by the Objective-C RFCOMM delegate when the peer closes the channel. Delegate callbacks + // are delivered on the main thread's run loop (the connector thread never pumps one), so this + // must only signal shutdown; disconnect()'s close-and-join runs on whichever thread invokes it. + void handleChannelClosed() noexcept; + + // Called by the Objective-C RFCOMM delegate for every received data frame. Drops zero-length + // frames (recv() returning 0 reads as "connection closed" upstream) and caps the total buffered + // bytes so a device streaming unsolicited frames while nothing is reading can't grow memory + // without bound. Public so the offline buffer tests can drive it without a live channel. + void enqueueReceivedData(const unsigned char* data, size_t length); + + // recv() only runs during a command exchange, so the queue must be bounded. Protocol frames are + // at most MAX_BLUETOOTH_MESSAGE_SIZE; 16 of them is far more than any legitimate burst. + static constexpr size_t RECEIVE_BUFFER_CAP = MAX_BLUETOOTH_MESSAGE_SIZE * 16; + virtual std::vector getConnectedDevices() noexcept(false); std::deque> receivedBytes; std::mutex receiveDataMutex; @@ -36,7 +51,13 @@ class MacOSBluetoothConnector final : public IBluetoothConnector SonyProtocolVersion protocolVersion = SonyProtocolVersion::V1; private: - void *rfcommDevice; - void *rfcommchannel; + void *rfcommDevice = nullptr; + // Owning (+1, CFBridgingRetain) reference to the open IOBluetoothRFCOMMChannel, or null. Guarded by + // channelMtx: readers take their own strong reference under the lock, so a remote close on the main + // run loop can never free the channel under an in-flight send(). + void *rfcommchannel = nullptr; + std::mutex channelMtx; std::thread uthread; + //Total bytes across receivedBytes; guarded by receiveDataMutex. + size_t receivedBytesTotal = 0; }; diff --git a/Client/macos/MacOSBluetoothConnector.mm b/Client/macos/MacOSBluetoothConnector.mm index bd2db624..44465736 100644 --- a/Client/macos/MacOSBluetoothConnector.mm +++ b/Client/macos/MacOSBluetoothConnector.mm @@ -6,10 +6,9 @@ } MacOSBluetoothConnector::~MacOSBluetoothConnector() { - // onclose event - if (isConnected()){ - disconnect(); - } + // A failed or remotely closed connection can leave a finished-but-joinable std::thread even when + // isConnected() is false. Always perform the idempotent cleanup before the thread is destroyed. + disconnect(); } @interface AsyncCommDelegate : NSObject { @@ -24,7 +23,7 @@ - (void)rfcommChannelClosed:(IOBluetoothRFCOMMChannel *)rfcommChannel{ #ifdef SHC_DEBUG_PROTOCOL fprintf(stderr, "[connect] rfcommChannelClosed\n"); #endif - delegateCPP->disconnect(); + delegateCPP->handleChannelClosed(); } #ifdef SHC_DEBUG_PROTOCOL @@ -35,13 +34,7 @@ - (void)rfcommChannelOpenComplete:(IOBluetoothRFCOMMChannel *)rfcommChannel stat -(void)rfcommChannelData:(IOBluetoothRFCOMMChannel *)rfcommChannel data:(void *)dataPointer length:(size_t)dataLength { - std::lock_guard g(delegateCPP->receiveDataMutex); - - unsigned char* buffer = (unsigned char*)dataPointer; - std::vector vectorBuffer(buffer, buffer+dataLength); - - delegateCPP->receivedBytes.push_back(vectorBuffer); - delegateCPP->receiveDataConditionVariable.notify_one(); + delegateCPP->enqueueReceivedData((const unsigned char*)dataPointer, dataLength); } @@ -64,7 +57,13 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) #ifdef SHC_DEBUG_PROTOCOL _debugHexDump("send", buf, length); #endif - [(__bridge IOBluetoothRFCOMMChannel*)rfcommchannel writeSync:(void*)buf length:length]; + IOBluetoothRFCOMMChannel *channel = nil; + { + std::lock_guard g(channelMtx); + channel = (__bridge IOBluetoothRFCOMMChannel*)rfcommchannel; + } + if (channel == nil) throw RecoverableException("Bluetooth connection is closed", false); + [channel writeSync:(void*)buf length:length]; return (int)length; } @@ -116,38 +115,45 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) AsyncCommDelegate* asyncCommDelegate = [[AsyncCommDelegate alloc] init]; asyncCommDelegate->delegateCPP = macOSBluetoothConnector; // try to open channel + macOSBluetoothConnector->running = true; IOReturn openResult = [device openRFCOMMChannelAsync:&channel withChannelID:rfcommChannelID delegate:asyncCommDelegate]; #ifdef SHC_DEBUG_PROTOCOL fprintf(stderr, "[connect] openRFCOMMChannelAsync -> 0x%x\n", openResult); #endif if ( openResult != kIOReturnSuccess ) { + macOSBluetoothConnector->running = false; RecoverableException exc = RecoverableException("Could not open the rfcomm.", false); std::exception_ptr excPtr = std::make_exception_ptr(exc); connectPromise.set_exception(excPtr); return; } // store the channel - macOSBluetoothConnector->rfcommchannel = (__bridge void*) channel; + { + std::lock_guard g(macOSBluetoothConnector->channelMtx); + macOSBluetoothConnector->rfcommchannel = (void*)CFBridgingRetain(channel); + } macOSBluetoothConnector->protocolVersion = protocolVersion; - macOSBluetoothConnector->running = true; - // tell the other tread that we are done connecting connectPromise.set_value(); - // keep thread running, until we are disconnected + // Park until disconnect. Every delegate callback (data and channel-closed) is delivered on + // the main thread's run loop — this thread's run loop has no sources, so there is nothing + // to pump here. This frame's `channel` reference keeps the RFCOMM channel alive for the + // whole connection; the wait returns exactly once, when a running=false setter notifies. + // Setters must store running=false while holding disconnectionMutex (see disconnect()), + // or a notify landing between this predicate check and the block would be lost forever. std::unique_lock lk(macOSBluetoothConnector->disconnectionMutex); - while (macOSBluetoothConnector->running) { - [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:.1]]; - - macOSBluetoothConnector->disconnectionConditionVariable.wait_for( - lk, std::chrono::milliseconds(1000), - [&]() { return !macOSBluetoothConnector->running; }); - } + macOSBluetoothConnector->disconnectionConditionVariable.wait( + lk, [&]() { return !macOSBluetoothConnector->running; }); lk.unlock(); } void MacOSBluetoothConnector::connect(const std::string& addrStr){ + // Reconnects must reap the previous worker first. Assigning over a joinable std::thread terminates. + if (uthread.joinable()) { + disconnect(); + } // convert mac address to nsstring NSString *addressNSString = [NSString stringWithCString:addrStr.c_str() encoding:[NSString defaultCStringEncoding]]; // get device based on mac address @@ -163,8 +169,43 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) rfcommDevice = (__bridge void*) device; uthread = std::thread(MacOSBluetoothConnector::connectToMac, this, std::move(connectPromise)); - // wait till the device is connected - connectFuture.get(); + // Wait until SDP lookup and the asynchronous open request have started. If setup fails, reap the + // worker before propagating the exception so destruction cannot encounter a joinable thread. + try { + connectFuture.get(); + } catch (...) { + // running=false must be stored under disconnectionMutex: connectToMac's untimed wait + // would otherwise miss a notify racing its predicate check and never wake. + { + std::lock_guard g(disconnectionMutex); + running = false; + } + disconnectionConditionVariable.notify_all(); + receiveDataConditionVariable.notify_all(); + if (uthread.joinable()) uthread.join(); + throw; + } +} + +void MacOSBluetoothConnector::enqueueReceivedData(const unsigned char* data, size_t length) +{ + // A zero-length RFCOMM frame carries no message bytes; queuing it would make recv() return 0, + // which BluetoothWrapper treats as a closed connection. + if (length == 0) return; + + std::lock_guard g(receiveDataMutex); + receivedBytes.emplace_back(data, data + length); + receivedBytesTotal += length; + + // Nothing drains the queue between command exchanges, so a flooding device would otherwise grow + // it without bound. Drop the oldest chunks past the cap (never the newest one); the wrapper's + // START/END marker scan resynchronizes on whatever survives. + while (receivedBytesTotal > RECEIVE_BUFFER_CAP && receivedBytes.size() > 1) { + receivedBytesTotal -= receivedBytes.front().size(); + receivedBytes.pop_front(); + } + + receiveDataConditionVariable.notify_one(); } int MacOSBluetoothConnector::recv(char* buf, size_t length) @@ -173,24 +214,29 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) // feature) or a dropped link doesn't block the caller forever. std::unique_lock g(receiveDataMutex); bool gotData = receiveDataConditionVariable.wait_for(g, std::chrono::milliseconds(2500), - [this]{ return !receivedBytes.empty(); }); - if (!gotData) { + [this]{ return !receivedBytes.empty() || !running; }); + if (!running && receivedBytes.empty()) { + throw RecoverableException("Bluetooth connection closed", false); + } + if (!gotData || receivedBytes.empty()) { throw RecoverableException("recv timed out", false); } // fill the buf with the new data std::vector receivedVector = receivedBytes.front(); receivedBytes.pop_front(); - + receivedBytesTotal -= receivedVector.size(); + size_t lengthCopied = std::min(length, receivedVector.size()); // copy the first amount of bytes std::memcpy(buf, receivedVector.data(), lengthCopied); - + // too much data, save it for next time if (receivedVector.size() > length){ receivedVector.erase(receivedVector.begin(), receivedVector.begin() + lengthCopied); receivedBytes.push_front(receivedVector); + receivedBytesTotal += receivedVector.size(); } #ifdef SHC_DEBUG_PROTOCOL @@ -225,27 +271,67 @@ static void _debugHexDump(const char* label, const char* buf, size_t length) void MacOSBluetoothConnector::disconnect() noexcept { - // close connection - closeConnection(); - running = false; - // notify the other thread that we are done disconnecting + // running=false must be stored under disconnectionMutex: connectToMac's untimed wait + // would otherwise miss a notify racing its predicate check and never wake. + { + std::lock_guard g(disconnectionMutex); + running = false; + } disconnectionConditionVariable.notify_all(); - // wait for the thread to finish - uthread.join(); + receiveDataConditionVariable.notify_all(); + closeConnection(); + + if (uthread.joinable()) { + // The delegate only calls handleChannelClosed(), but retain this guard so future callbacks cannot + // accidentally reintroduce a self-join termination. + if (uthread.get_id() == std::this_thread::get_id()) { + uthread.detach(); + } else { + uthread.join(); + } + } + + std::lock_guard g(receiveDataMutex); + receivedBytes.clear(); + receivedBytesTotal = 0; } void MacOSBluetoothConnector::closeConnection() { - // get the channel - IOBluetoothRFCOMMChannel *chan = (__bridge IOBluetoothRFCOMMChannel*) rfcommchannel; + // Only one close path owns the channel pointer. A remote-close callback clears it first. + IOBluetoothRFCOMMChannel *chan = nil; + { + std::lock_guard g(channelMtx); + chan = (IOBluetoothRFCOMMChannel*)CFBridgingRelease(rfcommchannel); + rfcommchannel = nullptr; + } + if (chan == nil) return; [chan setDelegate: nil]; // close the channel [chan closeChannel]; } +void MacOSBluetoothConnector::handleChannelClosed() noexcept +{ + { + std::lock_guard g(channelMtx); + if (rfcommchannel) CFRelease(rfcommchannel); + rfcommchannel = nullptr; + } + // running=false must be stored under disconnectionMutex: connectToMac's untimed wait + // would otherwise miss a notify racing its predicate check and never wake. + { + std::lock_guard g(disconnectionMutex); + running = false; + } + disconnectionConditionVariable.notify_all(); + receiveDataConditionVariable.notify_all(); +} + bool MacOSBluetoothConnector::isConnected() noexcept { if (!running) return false; - IOBluetoothRFCOMMChannel *chan = (__bridge IOBluetoothRFCOMMChannel*) rfcommchannel; - return chan.isOpen; + std::lock_guard g(channelMtx); + IOBluetoothRFCOMMChannel *chan = (__bridge IOBluetoothRFCOMMChannel*)rfcommchannel; + return chan != nil && chan.isOpen; } diff --git a/Client/macos/MenuBarController.swift b/Client/macos/MenuBarController.swift new file mode 100644 index 00000000..140e97f9 --- /dev/null +++ b/Client/macos/MenuBarController.swift @@ -0,0 +1,190 @@ +// +// MenuBarController.swift +// Status-bar item: a click opens the quick-controls panel, a right-click (or control-click) the menu. +// + +import AppKit +import Combine +import SwiftUI + +// 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 let menu = NSMenu() + private let popover = NSPopover() + private var cancellables = Set() + + @objc static func install() { + guard instance == nil else { return } + // The panel can open before the window has appeared, so don't wait for it to apply the theme. + AppTheme.stored.applyToApp() + instance = MenuBarController() + } + + private override init() { + super.init() + menu.delegate = self + menu.autoenablesItems = false + + let panel = NSHostingController(rootView: QuickControlsView( + openWindow: { [weak self] in self?.popover.performClose(nil); Self.openMainWindow() }, + quit: { NSApp.terminate(nil) } + )) + if #available(macOS 13.0, *) { panel.sizingOptions = .preferredContentSize } + popover.contentViewController = panel + popover.behavior = .transient + + statusItem.button?.target = self + statusItem.button?.action = #selector(statusItemClicked(_:)) + statusItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) + 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) + } + + @objc private func statusItemClicked(_ sender: NSStatusBarButton) { + let event = NSApp.currentEvent + if event?.type == .rightMouseUp || event?.modifierFlags.contains(.control) == true { + popover.performClose(nil) + // A status item with a menu attached never sends its action, so attach it for this click only. + statusItem.menu = menu + sender.performClick(nil) + statusItem.menu = nil + } else if popover.isShown { + popover.performClose(nil) + } else { + refreshMultipointIfIdle() + if let panel = popover.contentViewController, #unavailable(macOS 13.0) { + popover.contentSize = panel.view.fittingSize + } + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } + + static func openMainWindow() { + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first { $0.contentViewController != nil && !($0 is NSPanel) }?.makeKeyAndOrderFront(nil) + } + + // Re-read so the panel or menu reflects changes made from the phone or the headset. + private func refreshMultipointIfIdle() { + if model.connected && !model.multipointBusy && !model.refreshingMultipoint { + model.refreshMultipoint() + } + } + + // 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 = model.playbackPinnedToThisMac ? "headphones.circle.fill" : "headphones" + let device = model.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 model.thisMac != nil { + let pinned = model.playbackPinnedToThisMac + let pin = actionItem(pinned ? "Playback Fixed to This Mac" : "Pin Playback to This Mac", #selector(pinToThisMac)) + pin.state = pinned ? .on : .off + pin.isEnabled = !pinned && !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)) + } + refreshMultipointIfIdle() + } 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() { model.pinPlaybackToThisMac() } + @objc private func unfixPlayback() { model.setPlaybackPinned(false) } + @objc private func openWindow() { Self.openMainWindow() } + @objc private func quit() { NSApp.terminate(nil) } + + @objc private func connectHeadphones() { + NSApp.activate(ignoringOtherApps: true) + model.connect() + } + + 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 + } +} diff --git a/Client/macos/QuickControlsView.swift b/Client/macos/QuickControlsView.swift new file mode 100644 index 00000000..98f6c0c6 --- /dev/null +++ b/Client/macos/QuickControlsView.swift @@ -0,0 +1,181 @@ +// +// QuickControlsView.swift +// The menu-bar panel: the everyday controls without opening the window. +// + +import SwiftUI + +struct QuickControlsView: View { + let openWindow: () -> Void + let quit: () -> Void + + @ObservedObject private var model = HeadphonesModel.shared + @AppStorage(AppTheme.storageKey) private var theme = AppTheme.refinedDark + @Environment(\.colorScheme) private var colorScheme + + private var palette: Palette { theme.palette(colorScheme) } + + // 320pt gives each mode segment a little more room than the window's at its 360pt minimum, so + // "Noise Canceling" fits within the mode control's minimum scale. The height follows the content. + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if model.connected { + connectedContent + } else { + disconnectedContent + } + Divider().background(palette.cardStroke) + footer + } + .padding(14) + .frame(width: 320) + .background(palette.background) + .environment(\.appTheme, theme) + } + + private var disconnectedContent: some View { + VStack(spacing: 12) { + Image(systemName: "headphones") + .font(.system(size: 34, weight: .thin)) + .foregroundColor(palette.secondary) + Text("No headphones connected") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.secondary) + errorText + ConnectButton(model: model) + } + .frame(maxWidth: .infinity) + .padding(.top, 6) + } + + @ViewBuilder + private var connectedContent: some View { + HStack(spacing: 12) { + ProductImage(deviceName: model.deviceName, size: 56) + VStack(alignment: .leading, spacing: 6) { + Text(model.deviceName) + .font(.system(size: 15, weight: .bold)) + .foregroundColor(palette.primary) + .lineLimit(1) + StatusChips(model: model, showsMode: false) + } + } + + AmbientModeControl(model: model) + + if model.mode == .ambientSound { + HStack(spacing: 10) { + Text("Level") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(palette.secondary) + AmbientLevelSlider(model: model) + Text("\(model.ambientLevel)") + .font(.system(size: 12, weight: .semibold, design: theme.isSignature ? .monospaced : .rounded)) + .foregroundColor(palette.accentText) + .frame(width: 22, alignment: .trailing) + } + if model.focusOnVoiceAvailable { + FocusOnVoiceSwitch(model: model) + } + } + + if model.supportsEqualizer || model.hasPlaybackPinState { + VStack(alignment: .leading, spacing: 12) { + if model.supportsEqualizer { + equalizerRow + } + if model.supportsEqualizer && model.hasPlaybackPinState { + Divider().background(palette.cardStroke) + } + if model.hasPlaybackPinState { + playbackRow + } + } + .themedCard(compact: true) + } + + errorText + } + + private var equalizerRow: some View { + HStack { + Text("Equalizer") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + Spacer() + Picker("Equalizer", selection: Binding( + get: { model.eqPreset }, + set: { model.setEqualizer($0) } + )) { + ForEach(EQPresets.list(tenBand: model.tenBandEqualizer), id: \.1) { preset in + Text(preset.0).tag(preset.1) + } + } + .pickerStyle(MenuPickerStyle()) + .labelsHidden() + .frame(width: 120) + .accentColor(palette.accent) + } + } + + private var playbackRow: some View { + let canPin = model.thisMac != nil && !model.playbackPinnedToThisMac + return VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(systemName: "speaker.wave.2.fill") + .font(.system(size: 12)) + .foregroundColor(palette.accent) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(model.playbackPinned ? "Playback fixed to" : "Playing on") + .font(.system(size: 11)) + .foregroundColor(palette.secondary) + Text(model.playbackDevice.map { $0.name.isEmpty ? $0.address : $0.name } ?? "No playback device") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + .lineLimit(1) + } + } + if canPin || model.playbackPinned { + HStack(spacing: 8) { + if canPin { + CapsuleButton("Pin to this Mac", action: model.pinPlaybackToThisMac) + } + if model.playbackPinned { + CapsuleButton("Unfix") { model.setPlaybackPinned(false) } + } + } + .disabled(model.multipointBusy) + } + } + } + + @ViewBuilder + private var errorText: some View { + if let error = model.errorMessage { + Text(error) + .font(.system(size: 11)) + .foregroundColor(palette.danger) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + } + + private var footer: some View { + HStack { + Button(action: openWindow) { + Text("Open SonyBridge") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(palette.accentText) + } + Spacer() + Button(action: quit) { + Text("Quit") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(palette.secondary) + } + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj b/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj index 2ce1c62e..fc1b8bd5 100644 --- a/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj +++ b/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj @@ -23,6 +23,9 @@ CC0000000000000000000001 /* HeadphonesBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000002 /* HeadphonesBridge.mm */; }; CC0000000000000000000004 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000005 /* ContentView.swift */; }; CC0000000000000000000006 /* HeadphonesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000007 /* HeadphonesModel.swift */; }; + CC00000000000000000000A1 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00000000000000000000A2 /* Theme.swift */; }; + CC00000000000000000000A3 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00000000000000000000A4 /* MenuBarController.swift */; }; + CC00000000000000000000A5 /* QuickControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00000000000000000000A6 /* QuickControlsView.swift */; }; DD0000000000000000000001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DD0000000000000000000002 /* Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -58,6 +61,9 @@ CC0000000000000000000003 /* HeadphonesBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HeadphonesBridge.h; sourceTree = ""; }; CC0000000000000000000005 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; CC0000000000000000000007 /* HeadphonesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadphonesModel.swift; sourceTree = ""; }; + CC00000000000000000000A2 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + CC00000000000000000000A4 /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = ""; }; + CC00000000000000000000A6 /* QuickControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickControlsView.swift; sourceTree = ""; }; CC0000000000000000000008 /* SonyHeadphonesClient-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SonyHeadphonesClient-Bridging-Header.h"; sourceTree = ""; }; DD0000000000000000000002 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ @@ -107,6 +113,9 @@ CC0000000000000000000002 /* HeadphonesBridge.mm */, CC0000000000000000000005 /* ContentView.swift */, CC0000000000000000000007 /* HeadphonesModel.swift */, + CC00000000000000000000A2 /* Theme.swift */, + CC00000000000000000000A4 /* MenuBarController.swift */, + CC00000000000000000000A6 /* QuickControlsView.swift */, CC0000000000000000000008 /* SonyHeadphonesClient-Bridging-Header.h */, DD0000000000000000000002 /* Assets.xcassets */, A8DC790325769A6600447738 /* Frameworks */, @@ -203,6 +212,9 @@ CC0000000000000000000001 /* HeadphonesBridge.mm in Sources */, CC0000000000000000000004 /* ContentView.swift in Sources */, CC0000000000000000000006 /* HeadphonesModel.swift in Sources */, + CC00000000000000000000A1 /* Theme.swift in Sources */, + CC00000000000000000000A3 /* MenuBarController.swift in Sources */, + CC00000000000000000000A5 /* QuickControlsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -260,7 +272,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -302,18 +314,20 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 2; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -336,7 +350,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; OTHER_CPLUSPLUSFLAGS = ( "-std=c++17", "-stdlib=libc++", @@ -367,7 +381,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; OTHER_CPLUSPLUSFLAGS = ( "-std=c++17", "-stdlib=libc++", @@ -378,6 +392,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; SWIFT_VERSION = 5.0; SWIFT_OBJC_BRIDGING_HEADER = "SonyHeadphonesClient-Bridging-Header.h"; + SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Release; diff --git a/Client/macos/Theme.swift b/Client/macos/Theme.swift new file mode 100644 index 00000000..6d23d38b --- /dev/null +++ b/Client/macos/Theme.swift @@ -0,0 +1,391 @@ +// +// Theme.swift +// The app's three looks: palettes, the shared card and control styles, and the theme-specific drawings. +// + +import SwiftUI +import AppKit + +enum AppTheme: String, CaseIterable, Identifiable { + case refinedDark, nativeGlass, sonySignature + + var id: String { rawValue } + + var displayName: String { + switch self { + case .refinedDark: return "Refined Dark" + case .nativeGlass: return "Native Glass" + case .sonySignature: return "Sony Signature" + } + } + + // Native Glass follows the system appearance; the other two are designed dark-only. + var colorScheme: ColorScheme? { self == .nativeGlass ? nil : .dark } + + static let storageKey = "appTheme" + + static var stored: AppTheme { + UserDefaults.standard.string(forKey: storageKey).flatMap(AppTheme.init(rawValue:)) ?? .refinedDark + } + + // Applied app-wide so the window, its popovers and the menu-bar panel all follow the theme. Not + // .preferredColorScheme: on macOS, once it has forced a window dark, setting it back to nil doesn't + // return the window to the system appearance, so Native Glass would stay dark until relaunch. + func applyToApp() { + NSApp.appearance = colorScheme == .dark ? NSAppearance(named: .darkAqua) : nil + } + + // Sony Signature changes shapes as well as colors: pill buttons, uppercase tracked labels, + // monospaced numbers, EQ as bars and rings behind the product. + var isSignature: Bool { self == .sonySignature } + + func palette(_ scheme: ColorScheme) -> Palette { + switch self { + case .refinedDark: return .refinedDark + case .nativeGlass: return scheme == .dark ? .glassDark : .glassLight + case .sonySignature: return .sonySignature + } + } +} + +struct Palette { + let background: AnyShapeStyle + let card: AnyShapeStyle + let cardStroke: Color + let raised: AnyShapeStyle // status chips and round icon buttons + let track: Color // recessed fill behind mode segments and idle EQ presets + let primary: Color + let secondary: Color + let accent: Color + let accentText: Color + let onAccent: Color + let danger: Color + let selectedFill: Color + let selectedText: Color + let selectedStroke: Color + let selectedShadow: Color + let glow: Color // light behind the product image +} + +extension Palette { + static let refinedDark: Palette = { + let lavender = Color(hex: 0x8f8ff5) + return Palette( + background: AnyShapeStyle(Color(hex: 0x0d0d11)), + card: AnyShapeStyle(LinearGradient(colors: [Color(hex: 0x17171d), Color(hex: 0x131318)], + startPoint: .top, endPoint: .bottom)), + cardStroke: Color(hex: 0x23232b), + raised: AnyShapeStyle(Color(hex: 0x1b1b22)), + track: Color.black.opacity(0.35), + primary: Color(hex: 0xf2f2f6), + secondary: Color(hex: 0x8d8d9a), + accent: lavender, + accentText: lavender, + onAccent: .white, + danger: Color.red.opacity(0.9), + selectedFill: lavender, + selectedText: .white, + selectedStroke: .clear, + selectedShadow: lavender.opacity(0.45), + glow: lavender.opacity(0.32) + ) + }() + + static let glassLight = glass(dark: false) + static let glassDark = glass(dark: true) + + private static func glass(dark: Bool) -> Palette { + // controlAccentColor rather than Color.accentColor, which would resolve through the + // .accentColor(_:) the controls are given from this palette. + let accent = Color(NSColor.controlAccentColor) + return Palette( + background: AnyShapeStyle(LinearGradient( + colors: dark ? [Color(hex: 0x1a1d26), Color(hex: 0x15161b), Color(hex: 0x1c1924)] + : [Color(hex: 0xdfe6f3), Color(hex: 0xeef0f6), Color(hex: 0xe6e2f0)], + startPoint: .topLeading, endPoint: .bottomTrailing)), + // ponytail: materials, not .glassEffect(); switch to .glassEffect() behind `if #available(macOS 26, *)` + // once the project builds with the macOS 26+ SDK in Xcode. + card: AnyShapeStyle(.regularMaterial), + cardStroke: Color.white.opacity(dark ? 0.1 : 0.8), + raised: AnyShapeStyle(.thinMaterial), + track: dark ? Color.black.opacity(0.25) : Color.gray.opacity(0.16), + primary: .primary, + secondary: .secondary, + accent: accent, + accentText: accent, + onAccent: .white, + danger: .red, + selectedFill: dark ? Color.white.opacity(0.2) : .white, + selectedText: dark ? .primary : accent, + selectedStroke: .clear, + selectedShadow: Color.black.opacity(dark ? 0.3 : 0.12), + glow: Color.white.opacity(dark ? 0.12 : 0.95) + ) + } + + static let sonySignature: Palette = { + let amber = Color(hex: 0xffa032) + let amberText = Color(hex: 0xffb456) + return Palette( + background: AnyShapeStyle(Color(hex: 0x050505)), + card: AnyShapeStyle(Color(hex: 0x101010)), + cardStroke: Color(hex: 0x1f1f1f), + raised: AnyShapeStyle(Color.clear), + track: .clear, + primary: .white, + secondary: Color(hex: 0x8a8a8a), + accent: amber, + accentText: amberText, + onAccent: .black, + danger: Color.red.opacity(0.9), + selectedFill: amber.opacity(0.14), + selectedText: amberText, + selectedStroke: amber, + selectedShadow: .clear, + glow: amber.opacity(0.22) + ) + }() +} + +private struct AppThemeKey: EnvironmentKey { + static let defaultValue = AppTheme.refinedDark +} + +extension EnvironmentValues { + var appTheme: AppTheme { + get { self[AppThemeKey.self] } + set { self[AppThemeKey.self] = newValue } + } +} + +private struct ThemedCard: ViewModifier { + let compact: Bool + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + let palette = theme.palette(colorScheme) + let shape = RoundedRectangle(cornerRadius: compact ? 12 : 16, style: .continuous) + return content + .padding(compact ? 12 : 18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.card, in: shape) + .overlay(shape.strokeBorder(palette.cardStroke, lineWidth: 1)) + } +} + +// Selected/idle look shared by the mode control and the EQ presets: pills in Sony Signature, rounded +// segments otherwise. Pills must be a real Capsule: a continuous RoundedRectangle with a radius near half +// the height has no room for its corner curves and renders flat-sided ends. +private struct Selectable: ViewModifier { + let selected: Bool + let filledWhenIdle: Bool + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + @ViewBuilder + func body(content: Content) -> some View { + if theme.isSignature { + chrome(content, in: Capsule()) + } else { + chrome(content, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + + // The content shape keeps clear (idle) segments clickable. + private func chrome(_ content: Content, in shape: S) -> some View { + let palette = theme.palette(colorScheme) + return content + .foregroundColor(selected ? palette.selectedText : palette.secondary) + .background(selected ? palette.selectedFill : (filledWhenIdle ? palette.track : .clear), in: shape) + .overlay(shape.strokeBorder(selected ? palette.selectedStroke + : (theme.isSignature ? palette.cardStroke : .clear), lineWidth: 1)) + .contentShape(shape) + } +} + +extension View { + // compact: the menu-bar panel's tighter card. + func themedCard(compact: Bool = false) -> some View { modifier(ThemedCard(compact: compact)) } + + func selectable(_ selected: Bool, filledWhenIdle: Bool = false) -> some View { + modifier(Selectable(selected: selected, filledWhenIdle: filledWhenIdle)) + } +} + +// Label on the left, switch on the trailing edge, so every switch lines up. +// The title stays on the hidden Toggle label for VoiceOver. +struct SwitchRow: View { + private let title: String + private let isOn: Binding + private let label: Label + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + init(_ title: String, isOn: Binding, @ViewBuilder label: () -> Label) { + self.title = title + self.isOn = isOn + self.label = label() + } + + var body: some View { + HStack { + label + Spacer() + Toggle(title, isOn: isOn) + .labelsHidden() + .toggleStyle(SwitchToggleStyle(tint: theme.palette(colorScheme).accent)) + } + } +} + +// Small secondary action, e.g. Fix / Unfix playback. +struct CapsuleButton: View { + private let title: String + private let action: () -> Void + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + init(_ title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + let palette = theme.palette(colorScheme) + Button(action: action) { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(palette.primary) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(palette.raised, in: Capsule()) + .overlay(Capsule().strokeBorder(palette.cardStroke, lineWidth: 1)) + } + .buttonStyle(PlainButtonStyle()) + } +} + +// Light behind the product image: a soft glow, or Sony Signature's warm rim and rings. +struct HeroBackdrop: View { + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let palette = theme.palette(colorScheme) + ZStack { + if theme.isSignature { + Circle() + .fill(RadialGradient(colors: [palette.glow.opacity(0), palette.glow], + center: .center, startRadius: 40, endRadius: 105)) + // Outermost ring is the frame itself, so none reach the button row or the device name. + ForEach(0..<3) { ring in + Circle() + .strokeBorder(palette.accent.opacity(0.3 - Double(ring) * 0.08), lineWidth: 1) + .frame(width: 210 - CGFloat(ring) * 24, height: 210 - CGFloat(ring) * 24) + } + } else { + Circle() + .fill(RadialGradient(colors: [palette.glow, palette.glow.opacity(0)], + center: .center, startRadius: 4, endRadius: 104)) + } + } + .frame(width: 210, height: 210) + } +} + +// Small picture of the EQ: a filled curve, or bars in Sony Signature. `range` is the band value range. +struct EQPreview: View { + let bands: [Int] + let range: ClosedRange + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let accent = theme.palette(colorScheme).accent + let span = range.upperBound - range.lowerBound + let levels = bands.map { min(max((Double($0) - range.lowerBound) / span, 0), 1) } + Group { + if theme.isSignature { + HStack(alignment: .bottom, spacing: 6) { + ForEach(levels.indices, id: \.self) { i in + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(accent.opacity(0.5 + 0.4 * levels[i])) + .frame(maxWidth: 34) + .frame(height: 6 + 48 * levels[i]) + .frame(maxWidth: .infinity) + } + } + } else { + ZStack { + EQCurve(levels: levels, closed: true).fill(accent.opacity(0.15)) + EQCurve(levels: levels, closed: false) + .stroke(accent, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + } + } + } + .frame(height: 54, alignment: .bottom) + } +} + +// Levels are 0...1, spread evenly left to right. Horizontal tangents at each band keep the curve +// from overshooting between them. +private struct EQCurve: Shape { + let levels: [Double] + let closed: Bool + + func path(in rect: CGRect) -> Path { + var path = Path() + let levels = self.levels.count == 1 ? self.levels + self.levels : self.levels + guard levels.count > 1 else { return path } + let inset: CGFloat = 3 + let step = rect.width / CGFloat(levels.count - 1) + let points = levels.enumerated().map { i, level in + CGPoint(x: rect.minX + CGFloat(i) * step, + y: rect.maxY - inset - CGFloat(level) * (rect.height - 2 * inset)) + } + path.move(to: points[0]) + for (from, to) in zip(points, points.dropFirst()) { + let midX = (from.x + to.x) / 2 + path.addCurve(to: to, control1: CGPoint(x: midX, y: from.y), control2: CGPoint(x: midX, y: to.y)) + } + if closed { + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + } + return path + } +} + +// Mini preview for the theme picker. Native Glass is shown in its light look. +struct ThemeSwatch: View { + let theme: AppTheme + + var body: some View { + let palette = theme.palette(.light) + let bar = RoundedRectangle(cornerRadius: 3, style: .continuous) + VStack(alignment: .leading, spacing: 6) { + bar.fill(palette.cardStroke).frame(height: 8) + bar.fill(palette.accent).frame(width: 24, height: 8) + bar.fill(palette.cardStroke).frame(height: 8) + } + .padding(8) + .frame(height: 56) + .background(palette.background, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + } +} + +private extension Color { + init(hex: UInt32) { + self.init(.sRGB, + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255) + } +} diff --git a/Client/macos/ViewController.mm b/Client/macos/ViewController.mm index 6577dc7e..dfcc5aa4 100644 --- a/Client/macos/ViewController.mm +++ b/Client/macos/ViewController.mm @@ -30,14 +30,20 @@ - (void)viewWillAppear { NSWindow *window = self.view.window; if (!window) return; - // The storyboard pins the window to a small fixed size; open it up to a portrait layout. + // The storyboard pins the window to a small fixed size; open it up to a tall portrait layout that the + // user can widen into two columns. The size they leave it at is remembered. Appearance comes from the + // selected theme (ContentView sets NSApp.appearance), so the window must not force one of its own. window.contentMinSize = NSMakeSize(360, 500); - window.contentMaxSize = NSMakeSize(560, 900); + window.contentMaxSize = NSMakeSize(1200, CGFLOAT_MAX); window.styleMask |= NSWindowStyleMaskResizable; - [window setContentSize:NSMakeSize(380, 560)]; + if (![window setFrameUsingName:@"SonyBridgeMain"]) { + CGFloat visibleHeight = NSHeight(window.screen.visibleFrame) - 60; + [window setContentSize:NSMakeSize(400, MIN(820, MAX(500, visibleHeight)))]; + [window center]; + } + window.frameAutosaveName = @"SonyBridgeMain"; window.titlebarAppearsTransparent = YES; window.titleVisibility = NSWindowTitleHidden; - window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; } // Legacy storyboard action connections resolve to these no-op stubs (their controls are removed above). diff --git a/Client/macos/info.plist b/Client/macos/info.plist index f2a16284..80ad76cc 100644 --- a/Client/macos/info.plist +++ b/Client/macos/info.plist @@ -12,12 +12,16 @@ SonyHeadphonesClient CFBundlePackageType APPL + CFBundleShortVersionString + 0.4.0 + CFBundleVersion + 0.4.0 LSApplicationCategoryType public.app-category.utilities - NSHighResolutionCapable - True NSBluetoothAlwaysUsageDescription This app uses Bluetooth in order to connect to your headphones. + NSHighResolutionCapable + True NSMainStoryboardFile Main NSPrincipalClass diff --git a/Client/tests/HeadphonesProtocolTests.cpp b/Client/tests/HeadphonesProtocolTests.cpp new file mode 100644 index 00000000..1517a049 --- /dev/null +++ b/Client/tests/HeadphonesProtocolTests.cpp @@ -0,0 +1,377 @@ +#include "BluetoothWrapper.h" +#include "CommandSerializer.h" +#include "Exceptions.h" +#include "Headphones.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +class FakeV1Connector final : public IBluetoothConnector +{ +public: + int send(char* buf, size_t length) override + { + Buffer frame(buf, buf + length); + if (frame.size() < 2 || frame.front() != START_MARKER || frame.back() != END_MARKER) + throw std::runtime_error("invalid outbound frame"); + + Buffer body(frame.begin() + 1, frame.end() - 1); + auto message = CommandSerializer::unpackBtMessage(body); + if (message.dataType != DATA_TYPE::DATA_MDR) return static_cast(length); + + sentPayloads.push_back(message.payload); + if (notificationBeforeNextAck) { + queue({ (char)V1Command::AMBIENT_RET, 0x02, 0x11, 0x02, 0x02, 0x01, 0x00, 0x00 }, DATA_TYPE::DATA_MDR, 0); + notificationBeforeNextAck = false; + } + queue({}, DATA_TYPE::ACK, 1 - message.seqNumber); + queueResponse(message.payload); + return static_cast(length); + } + + int recv(char* buf, size_t length) override + { + if (incoming.empty()) throw std::runtime_error("fake connector has no queued response"); + auto frame = std::move(incoming.front()); + incoming.pop_front(); + if (!recvPrefix.empty()) { + frame.insert(frame.begin(), recvPrefix.begin(), recvPrefix.end()); + recvPrefix.clear(); + } + if (frame.size() > length) throw std::runtime_error("fake response exceeds receive buffer"); + std::memcpy(buf, frame.data(), frame.size()); + return static_cast(frame.size()); + } + + void connect(const std::string&) override { connected = true; } + void disconnect() noexcept override { connected = false; } + bool isConnected() noexcept override { return connected; } + std::vector getConnectedDevices() override { return {}; } + SonyProtocolVersion getProtocolVersion() noexcept override { return SonyProtocolVersion::V1; } + + std::vector sentPayloads; + Buffer recvPrefix; + bool notificationBeforeNextAck = false; + +private: + void queue(const Buffer& payload, DATA_TYPE type, unsigned char sequence) + { + incoming.push_back(CommandSerializer::packageDataForBt(payload, type, sequence)); + } + + void queueResponse(const Buffer& request) + { + if (request.empty()) return; + Buffer response; + switch ((unsigned char)request[0]) + { + case V1Command::INIT_REQUEST: response = { (char)V1Command::INIT_REPLY, 0x00, 0x40, 0x10 }; break; + case V1Command::FW_GET: response = { (char)V1Command::FW_RET, 0x02, 0x05, '3', '.', '0', '.', '1' }; break; + case V1Command::BATTERY_GET: response = { (char)V1Command::BATTERY_RET, 0x00, 87, 0x00 }; break; + case V1Command::CODEC_GET: response = { (char)V1Command::CODEC_RET, 0x00, 0x10 }; break; + case V1Command::EQ_GET: response = { (char)V1Command::EQ_RET, 0x01, 0x10, 0x06, 10, 10, 10, 10, 10, 10 }; break; + case V1Command::DSEE_GET: response = { (char)V1Command::DSEE_RET, 0x02, 0x00, 0x01 }; break; + case V1Command::AMBIENT_GET: response = { (char)V1Command::AMBIENT_RET, 0x02, 0x11, 0x02, 0x02, 0x01, 0x00, 0x00 }; break; + case V1Command::BTNMODE_GET: + if (request.size() >= 2 && (unsigned char)request[1] == V1Command::SUB_AUTO_POWER_OFF) + response = { (char)V1Command::BTNMODE_RET, (char)V1Command::SUB_AUTO_POWER_OFF, 0x01, 0x01, 0x01 }; + else if (request.size() >= 2 && (unsigned char)request[1] == V1Command::SUB_SPEAK_TO_CHAT) + response = { (char)V1Command::BTNMODE_RET, (char)V1Command::SUB_SPEAK_TO_CHAT, 0x01, 0x01 }; + break; + default: return; // SET commands only need the ACK already queued above. + } + if (!response.empty()) queue(response, DATA_TYPE::DATA_MDR, 0); + } + + bool connected = true; + std::deque incoming; +}; + +// v2-generation device scripted per test through `reply`: a request it returns an empty buffer for gets +// only the ACK, so the read times out (recv on an empty queue throws), which callers treat as "unsupported". +class FakeV2Connector final : public IBluetoothConnector +{ +public: + int send(char* buf, size_t length) override + { + Buffer frame(buf, buf + length); + if (frame.size() < 2 || frame.front() != START_MARKER || frame.back() != END_MARKER) + throw std::runtime_error("invalid outbound frame"); + + Buffer body(frame.begin() + 1, frame.end() - 1); + auto message = CommandSerializer::unpackBtMessage(body); + if (message.dataType != DATA_TYPE::DATA_MDR) return static_cast(length); + + sentPayloads.push_back(message.payload); + queue({}, DATA_TYPE::ACK, 1 - message.seqNumber); + auto response = reply ? reply(message.payload) : Buffer{}; + if (!response.empty()) queue(response, DATA_TYPE::DATA_MDR, 0); + return static_cast(length); + } + + int recv(char* buf, size_t length) override + { + if (incoming.empty()) throw std::runtime_error("fake connector has no queued response"); + auto frame = std::move(incoming.front()); + incoming.pop_front(); + if (frame.size() > length) throw std::runtime_error("fake response exceeds receive buffer"); + std::memcpy(buf, frame.data(), frame.size()); + return static_cast(frame.size()); + } + + void connect(const std::string&) override { connected = true; } + void disconnect() noexcept override { connected = false; } + bool isConnected() noexcept override { return connected; } + std::vector getConnectedDevices() override { return {}; } + SonyProtocolVersion getProtocolVersion() noexcept override { return SonyProtocolVersion::V2; } + + std::vector sentPayloads; + std::function reply; + + bool sent(const Buffer& payload) const + { + return std::find(sentPayloads.begin(), sentPayloads.end(), payload) != sentPayloads.end(); + } + +private: + void queue(const Buffer& payload, DATA_TYPE type, unsigned char sequence) + { + incoming.push_back(CommandSerializer::packageDataForBt(payload, type, sequence)); + } + + bool connected = true; + std::deque incoming; +}; + +Buffer bytes(std::initializer_list values) +{ + Buffer out; + for (int v : values) out.push_back((char)v); + return out; +} + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +// A malformed inbound frame must surface as a RecoverableException (caught at every call +// site and shown in the UI) rather than a bare std::runtime_error. If a future call site +// narrowed its catch to RecoverableException, a plain runtime_error here would reach +// std::terminate on attacker-controlled input. +template +void expectRecoverable(Fn&& fn, const char* message) +{ + try { fn(); } + catch (const RecoverableException&) { return; } + catch (...) { throw std::runtime_error(message); } + throw std::runtime_error(message); +} +} + +int main() +{ + auto fake = std::make_unique(); + auto* observed = fake.get(); + BluetoothWrapper bluetooth(std::move(fake)); + Headphones headphones(bluetooth); + + // RFCOMM stacks may deliver harmless transport noise before a start marker. It must not become part + // of the checksum-protected Sony frame. + observed->recvPrefix = { 0x00, 0x7f }; + headphones.initDevice(); + headphones.requestAmbientState(); + headphones.requestBattery(); + headphones.requestEqualizer(); + headphones.requestDsee(); + headphones.probeCapabilities(); + + require(headphones.hasBattery(), "v1 battery capability was not recorded"); + require(headphones.getBatteryLevel() == 87, "v1 battery response was not parsed"); + require(headphones.hasEqualizer(), "v1 equalizer capability was not recorded"); + require(headphones.getEqualizerPreset() == EQ_PRESET::BRIGHT, "v1 equalizer response was not parsed"); + require(headphones.hasDsee() && headphones.getDsee(), "v1 DSEE response was not parsed"); + require(headphones.getAmbientSoundControl(), "v1 ANC response was not parsed"); + require(headphones.getAsmLevel() == 0, "v1 ANC response was mistaken for ambient sound"); + require(headphones.hasFirmware() && headphones.getFirmware() == "3.0.1", "v1 firmware response was not parsed"); + require(headphones.hasCodec() && headphones.getCodec() == "LDAC", "v1 codec response was not parsed"); + require(headphones.hasAutoPowerOff() && headphones.getAutoPowerOff() == 2, "v1 auto-power-off response was not parsed"); + require(headphones.hasSpeakToChat() && headphones.getSpeakToChat(), "v1 Speak-to-Chat response was not parsed"); + + const auto hasV1BatteryRequest = std::any_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), [](const Buffer& payload) { + return payload.size() >= 2 && (unsigned char)payload[0] == V1Command::BATTERY_GET && payload[1] == 0x00; + }); + const auto hasDangerousV2BatteryRequest = std::any_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), [](const Buffer& payload) { + return !payload.empty() && (unsigned char)payload[0] == V2Command::BATTERY_GET; + }); + require(hasV1BatteryRequest, "XM4 path did not issue the v1 battery inquiry"); + require(!hasDangerousV2BatteryRequest, "XM4 path emitted v2 battery / v1 power-off opcode 0x22"); + + headphones.setAmbientSoundControl(true); + headphones.setAsmLevel(20); + headphones.setFocusOnVoice(false); + headphones.setChanges(); + const auto hasLevel20 = std::any_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), [](const Buffer& payload) { + return payload.size() == 8 && (unsigned char)payload[0] == V1Command::AMBIENT_SET && + (unsigned char)payload[7] == 20; + }); + require(hasLevel20, + "XM4 ambient level 20 was not serialized correctly"); + + headphones.setAsmLevel(1); + headphones.setChanges(); + const auto& ambientLevelOne = observed->sentPayloads.back(); + require(ambientLevelOne.size() == 8 && (unsigned char)ambientLevelOne[4] == 0x00 && + (unsigned char)ambientLevelOne[7] == 0x01, + "XM4 ambient level 1 was mistaken for wind/noise cancellation"); + + headphones.setEqualizerCustom(3, { -2, -1, 0, 1, 2 }); + require(!observed->sentPayloads.empty(), "custom EQ command was not sent"); + const auto& customEq = observed->sentPayloads.back(); + require(customEq.size() == 10 && (unsigned char)customEq[0] == V1Command::EQ_SET, + "custom EQ did not use the v1 command"); + require((unsigned char)customEq[1] == 0x01 && (unsigned char)customEq[2] == V1Command::MANUAL_EQ, + "custom EQ did not use the v1 subtype and manual preset byte"); + + observed->notificationBeforeNextAck = true; + headphones.setDsee(false); + const auto& dsee = observed->sentPayloads.back(); + require(dsee.size() == 4 && (unsigned char)dsee[0] == V1Command::DSEE_SET && + (unsigned char)dsee[1] == 0x02 && (unsigned char)dsee[3] == 0x00, + "DSEE did not use the v1 command layout"); + + headphones.setSpeakToChat(false); + const auto& speakToChat = observed->sentPayloads.back(); + require(speakToChat.size() == 4 && (unsigned char)speakToChat[0] == V1Command::BTNMODE_SET && + (unsigned char)speakToChat[1] == V1Command::SUB_SPEAK_TO_CHAT && (unsigned char)speakToChat[3] == 0x00, + "Speak-to-Chat did not use the v1 command layout"); + + // Multipoint has no proven v1 frames (GadgetBridge SonyProtocolImplV1: "Connect two devices not + // implemented for V1"), so the XM4 path must never probe or set it. + headphones.requestMultipoint(); + require(!headphones.hasMultipointSetting(), "multipoint capability must stay unconfirmed on v1"); + const auto hasMultipointFrame = std::any_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), [](const Buffer& payload) { + return !payload.empty() && ((unsigned char)payload[0] == V2Command::GS_GET_CAPABILITY || + (unsigned char)payload[0] == V2Command::GS_GET_PARAM || + (unsigned char)payload[0] == V2Command::GS_SET_PARAM || + (unsigned char)payload[0] == V2Command::SYSCTL_SET); + }); + require(!hasMultipointFrame, "XM4 path emitted a v2 multipoint frame (d0/d6/d8/98)"); + bool multipointRejected = false; + try { headphones.setMultipoint(true); } + catch (const std::exception&) { multipointRejected = true; } + require(multipointRejected, "setMultipoint on v1 must throw instead of sending"); + + // --- v2 multipoint ("Connect to 2 devices"), byte layout proven by GadgetBridge on the WH-ULT900N + // (SonyProtocolImplV2 commits 8cbc309346, e8520865f3, 5d7b37d1b7) --- + // The setting lives in a per-model general-settings slot, found by name; the ULT900N uses D1. + auto fakeV2 = std::make_unique(); + auto* observedV2 = fakeV2.get(); + observedV2->reply = [](const Buffer& request) -> Buffer { + if (request == bytes({ 0xd0, 0xd1, 0x01 })) { + std::string name = "MULTIPOINT_SETTING"; + Buffer response = bytes({ 0xd1, 0xd1, 0x00, 0x00, (int)name.size() }); + response.insert(response.end(), name.begin(), name.end()); + response.push_back(0x00); + return response; + } + if (request == bytes({ 0xd6, 0xd1 })) return bytes({ 0xd7, 0xd1, 0x00, 0x00 }); // 00 = ON (inverted) + return {}; + }; + BluetoothWrapper bluetoothV2(std::move(fakeV2)); + Headphones ult900n(bluetoothV2); + ult900n.probeCapabilities(); + ult900n.requestMultipoint(); + + require(ult900n.hasMultipointSetting(), "MULTIPOINT_SETTING slot D1 was not found by name"); + require(ult900n.getMultipointEnabled(), "RET d7 d1 00 00 must parse as multipoint ON (enable bit inverted)"); + require(!ult900n.hasSpeakToChat() && !ult900n.hasAdaptiveVolume(), + "unanswered v2 probes must stay unsupported"); + + ult900n.setMultipoint(false); + require(observedV2->sentPayloads.size() >= 2, "multipoint OFF did not send both frames"); + const auto offState = observedV2->sentPayloads[observedV2->sentPayloads.size() - 2]; + const auto offCommit = observedV2->sentPayloads.back(); + require(offState == Buffer({ (char)0xd8, (char)0xd1, 0x00, 0x01 }), + "multipoint OFF state frame must be d8 d1 00 01"); + require(offCommit == Buffer({ (char)0x98, 0x00, 0x06, 0x01 }), + "multipoint apply commit must be the fixed 98 00 06 01"); + require(!ult900n.getMultipointEnabled(), "multipoint OFF was not mirrored locally"); + + ult900n.setMultipoint(true); + const auto onState = observedV2->sentPayloads[observedV2->sentPayloads.size() - 2]; + const auto onCommit = observedV2->sentPayloads.back(); + require(onState == Buffer({ (char)0xd8, (char)0xd1, 0x00, 0x00 }), + "multipoint ON state frame must be d8 d1 00 00"); + require(onCommit == Buffer({ (char)0x98, 0x00, 0x06, 0x01 }), + "multipoint ON must reuse the fixed apply commit 98 00 06 01"); + require(ult900n.getMultipointEnabled(), "multipoint ON was not mirrored locally"); + + // --- WH-1000XM6 (issue #11): noise control reads via 0x19, the equalizer is the ten-band 0x04 layout. + // Frames captured on fw 3.1.5 by marconvcm/sony-device-center (#44, #74). --- + auto fakeXm6 = std::make_unique(); + auto* observedXm6 = fakeXm6.get(); + observedXm6->reply = [](const Buffer& request) -> Buffer { + if (request == bytes({ 0x66, 0x17 })) return bytes({ 0x67, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00 }); + if (request == bytes({ 0x66, 0x19 })) return bytes({ 0x67, 0x19, 0x01, 0x01, 0x01, 0x00, 0x0a, 0x00, 0x00 }); + if (request == bytes({ 0x56, 0x04 })) return bytes({ 0x57, 0x04, 0xa0, 0x0a, 6, 6, 6, 6, 6, 6, 6, 6, 6, 12 }); + return {}; + }; + BluetoothWrapper bluetoothXm6(std::move(fakeXm6)); + Headphones xm6(bluetoothXm6); + xm6.setDeviceName("WH-1000XM6"); + + xm6.requestAmbientState(); + require(observedXm6->sent(bytes({ 0x66, 0x19 })) && !observedXm6->sent(bytes({ 0x66, 0x17 })), + "XM6 noise control must be read via 66 19"); + require(xm6.getAmbientSoundControl() && xm6.getAsmLevel() == 10, "XM6 67 19 reply (Ambient, level 10) was not parsed"); + + xm6.requestEqualizer(); + require(xm6.hasEqualizer() && xm6.isTenBandEqualizer(), "XM6 ten-band equalizer was not recognised"); + require(xm6.getEqualizerBandCount() == 10 && xm6.getEqualizerBand(0) == 6 && xm6.getEqualizerBand(9) == 12, + "XM6 raw ten-band values were not decoded"); + require(xm6.getEqualizerPreset() == EQ_PRESET::MANUAL, "XM6 custom preset byte was not decoded"); + + xm6.setEqualizerCustom(5, { 0, 1, 2, 3, 4, 5, 6, 7, 8, 13 }); + require(observedXm6->sentPayloads.back() == bytes({ 0x58, 0x04, 0xa0, 0x0a, 0, 1, 2, 3, 4, 5, 6, 7, 8, 12 }), + "XM6 custom EQ must be 58 04 a0 0a <10 raw bands clamped to 0..12>, no clear bass"); + xm6.setEqualizerPreset(EQ_PRESET::OFF); + require(observedXm6->sent(bytes({ 0x58, 0x04, 0x00, 0x00 })), "XM6 preset must use inquired type 04"); + + // Any other v2 headset that answered 66 17 with zeros must not be read as "Off" either. + auto fakeZeros = std::make_unique(); + fakeZeros->reply = [](const Buffer& request) -> Buffer { + if (request == bytes({ 0x66, 0x17 })) return bytes({ 0x67, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00 }); + return {}; + }; + BluetoothWrapper bluetoothZeros(std::move(fakeZeros)); + Headphones zeros(bluetoothZeros); + zeros.setDeviceName("WH-1000XM5"); + zeros.setAmbientSoundControl(true); + zeros.setAsmLevel(7); + zeros.setFocusOnVoice(false); + zeros.setChanges(); + zeros.requestAmbientState(); + require(zeros.getAmbientSoundControl() && zeros.getAsmLevel() == 7, "a zeroed 67 17 reply overwrote the state"); + + // Malformed inbound frames on the untrusted parser path must throw the recoverable type. + // 61 (0x3D) is the escape sentry; a lone one at buffer end and one followed by a + // non-escapable byte are the two unescape rejection paths. + constexpr char ESCAPE_SENTRY = 61; + expectRecoverable([&] { CommandSerializer::unpackBtMessage(Buffer{ ESCAPE_SENTRY }); }, + "dangling escape byte did not throw RecoverableException"); + expectRecoverable([&] { CommandSerializer::unpackBtMessage(Buffer{ ESCAPE_SENTRY, 0x00 }); }, + "unexpected escaped byte did not throw RecoverableException"); + expectRecoverable([] { CommandSerializer::unpackBtMessage(Buffer{ 0x00, 0x00, 0x00 }); }, + "under-minimum-size frame did not throw RecoverableException"); + + std::cout << "XM4 / multipoint / XM6 protocol tests passed\n"; + return 0; +} diff --git a/Client/tests/MacConnectorBufferTests.mm b/Client/tests/MacConnectorBufferTests.mm new file mode 100644 index 00000000..3c913882 --- /dev/null +++ b/Client/tests/MacConnectorBufferTests.mm @@ -0,0 +1,130 @@ +// Offline tests for MacOSBluetoothConnector's receive buffering (enqueueReceivedData/recv). +// No RFCOMM channel is ever opened; frames are injected exactly as the IOBluetooth data +// delegate would deliver them. + +#include "MacOSBluetoothConnector.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +size_t bufferedTotal(MacOSBluetoothConnector& connector) +{ + std::lock_guard g(connector.receiveDataMutex); + size_t total = 0; + for (const auto& chunk : connector.receivedBytes) total += chunk.size(); + return total; +} +} + +int main() +{ + // A zero-length frame must be dropped: recv() returning 0 reads as "connection closed" in + // BluetoothWrapper, so buffering it would fabricate a disconnect. + { + MacOSBluetoothConnector connector; + connector.running = true; + connector.enqueueReceivedData(nullptr, 0); + require(connector.receivedBytes.empty(), "zero-length frame was buffered"); + + const unsigned char payload[] = { 0x3e, 0x01, 0x02, 0x3c }; + connector.enqueueReceivedData(payload, sizeof(payload)); + char buf[16] = { 0 }; + int numRecvd = connector.recv(buf, sizeof(buf)); + require(numRecvd == 4 && std::memcmp(buf, payload, 4) == 0, + "frame after a zero-length frame was not delivered intact"); + } + + // A device streaming unsolicited frames while nothing is reading must not grow the buffer + // without bound; the oldest chunks are dropped and the newest survive. + { + MacOSBluetoothConnector connector; + connector.running = true; + std::vector chunk(1000); + for (int i = 0; i < 200; i++) + { + std::fill(chunk.begin(), chunk.end(), (unsigned char)i); + connector.enqueueReceivedData(chunk.data(), chunk.size()); + } + require(bufferedTotal(connector) <= MacOSBluetoothConnector::RECEIVE_BUFFER_CAP, + "receive buffer exceeded RECEIVE_BUFFER_CAP under flood"); + require(!connector.receivedBytes.empty() && connector.receivedBytes.back().front() == 199, + "flood dropped the newest chunk instead of the oldest"); + } + + // Splitting an oversized chunk across recv() calls must keep the byte accounting consistent, + // so the cap still holds after partial reads. + { + MacOSBluetoothConnector connector; + connector.running = true; + std::vector big(3000); + std::iota(big.begin(), big.end(), 0); + connector.enqueueReceivedData(big.data(), big.size()); + + char buf[2048]; + int first = connector.recv(buf, sizeof(buf)); + require(first == 2048 && (unsigned char)buf[2047] == (unsigned char)2047, + "first partial read returned wrong data"); + require(bufferedTotal(connector) == 3000 - 2048, "split remainder was not re-buffered"); + + int second = connector.recv(buf, sizeof(buf)); + require(second == 3000 - 2048 && (unsigned char)buf[0] == (unsigned char)2048, + "second partial read returned wrong data"); + require(bufferedTotal(connector) == 0, "buffer not empty after draining the split chunk"); + + // The cap must still be measured correctly after the split bookkeeping. + std::vector chunk(1000); + for (int i = 0; i < 200; i++) connector.enqueueReceivedData(chunk.data(), chunk.size()); + require(bufferedTotal(connector) <= MacOSBluetoothConnector::RECEIVE_BUFFER_CAP, + "receive buffer exceeded the cap after split reads"); + } + + // The connector thread parks in an untimed wait on disconnectionConditionVariable until + // running goes false (connectToMac). There is no timed re-poll anymore, so every + // running=false setter must store under disconnectionMutex before notifying — a notify + // landing between the waiter's predicate check and its block would otherwise be lost + // forever. Drive both setters against the exact wait connectToMac performs; iterating + // samples the race window at different waiter/setter interleavings. + { + for (int i = 0; i < 200; i++) + { + MacOSBluetoothConnector connector; + connector.running = true; + + std::promise wokePromise; + std::future woke = wokePromise.get_future(); + std::thread waiter([&] { + std::unique_lock lk(connector.disconnectionMutex); + connector.disconnectionConditionVariable.wait(lk, [&] { return !connector.running; }); + wokePromise.set_value(); + }); + + if (i % 2 == 0) + connector.disconnect(); + else + connector.handleChannelClosed(); + + if (woke.wait_for(std::chrono::seconds(2)) != std::future_status::ready) + { + waiter.detach(); + throw std::runtime_error("disconnect wakeup lost: waiter still parked after 2s"); + } + waiter.join(); + } + } + + std::cout << "macOS connector receive-buffer tests passed\n"; + return 0; +} diff --git a/Client/tests/MetamorphicProtocolTests.cpp b/Client/tests/MetamorphicProtocolTests.cpp new file mode 100644 index 00000000..670052d8 --- /dev/null +++ b/Client/tests/MetamorphicProtocolTests.cpp @@ -0,0 +1,428 @@ +#include "CommandSerializer.h" +#include "Constants.h" +#include "Exceptions.h" + +#include +#include +#include +#include +#include +#include +#include + +// Metamorphic (relational) tests for the Sony v1 wire protocol layer. +// +// The conformance suite pins 18 exact golden frames and the fuzzer proves the +// inbound parser is crash-free; this suite covers the gap between them: +// properties that must hold across *transformations* of programmatically +// generated inputs, not just for pinned examples. +// +// P1 Round-trip: unpack(strip_markers(pack(p, dt, seq))) == (p, dt, seq & 0xFF), +// and pack's output matches an independent re-implementation of the +// documented frame layout (structure, declared size, checksum). +// P2 Escaping transparency: every escapable byte (60/61/62) survives a +// round trip at every payload position, and the packed frame interior +// never contains a raw frame marker (60/62). +// P3 Sequence-number domain: seq 0..255 round-trips exactly; wider values +// are truncated mod 256, byte-identically to packing (seq & 0xFF). +// P4 Framing: dropping or adding a single byte at either end of a valid +// body is rejected, as is unpacking a frame with its markers attached. +// P5 Determinism / inverse consistency: packing is byte-identical across +// calls, and re-packing an unpacked message reproduces the original frame. +// P6 Corruption rejection: for the frames tested here, replacing any single +// body byte with any other value (and, for a large frame, flipping any +// single bit) makes unpack throw RecoverableException. Note: single-byte +// edits of the *unescaped* image are always caught (mod-256 sum changes +// whenever one byte changes); wire-level edits also shift length/framing, +// and this section verifies rejection exhaustively over this corpus. +// P7 Size boundary: pack succeeds iff the escaped frame fits in +// MAX_BLUETOOTH_MESSAGE_SIZE, measured on the escaped size (payload +// bytes that escape count double), and every returned frame fits. +// +// Everything is deterministic: fixed-seed splitmix64, no wall clock, no I/O. + +namespace +{ +size_t g_checks = 0; + +void require(bool condition, const std::string& message) +{ + g_checks++; + if (!condition) throw std::runtime_error(message); +} + +// Fixed-seed splitmix64 so every run sees the exact same corpus. +uint64_t g_rngState = 0x5b7a1e0fd2c3a9e4ULL; + +unsigned char nextByte() +{ + g_rngState += 0x9e3779b97f4a7c15ULL; + uint64_t z = g_rngState; + z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ULL; + z = (z ^ (z >> 27)) * 0x94d049bb133111ebULL; + return static_cast((z ^ (z >> 31)) & 0xff); +} + +Buffer randomPayload(size_t n) +{ + Buffer ret; + ret.reserve(n); + for (size_t i = 0; i < n; i++) + ret.push_back(static_cast(nextByte())); + return ret; +} + +std::string hexDump(const Buffer& b) +{ + std::ostringstream oss; + oss << std::hex; + for (auto c : b) oss << ((unsigned)(unsigned char)c < 16 ? "0" : "") << (unsigned)(unsigned char)c << " "; + return oss.str(); +} + +constexpr unsigned char SENTRY = 61; + +// Independent re-implementation of the documented unescape (from the format +// comment in CommandSerializer.h), used to structurally verify pack's output +// without calling the production _unescapeSpecials. +std::vector refUnescape(const Buffer& body) +{ + std::vector out; + for (size_t i = 0; i < body.size(); i++) + { + auto b = static_cast(body[i]); + if (b == SENTRY) + { + require(i + 1 < body.size(), "packed frame ends with a dangling escape sentry"); + auto n = static_cast(body[++i]); + if (n == 44) out.push_back(60); + else if (n == 45) out.push_back(61); + else if (n == 46) out.push_back(62); + else require(false, "packed frame contains an invalid escape pair"); + } + else + { + out.push_back(b); + } + } + return out; +} + +Buffer stripMarkers(const Buffer& frame) +{ + require(frame.size() >= 2, "packed frame is too small to carry both markers"); + require(frame.front() == START_MARKER, "packed frame does not start with the start marker"); + require(frame.back() == END_MARKER, "packed frame does not end with the end marker"); + return Buffer(frame.begin() + 1, frame.end() - 1); +} + +enum class Outcome { Parsed, Recoverable, OtherError }; + +Outcome tryUnpack(const Buffer& body, CommandSerializer::Message* out = nullptr) +{ + try + { + auto msg = CommandSerializer::unpackBtMessage(body); + if (out) *out = msg; + return Outcome::Parsed; + } + catch (const RecoverableException&) { return Outcome::Recoverable; } + catch (...) { return Outcome::OtherError; } +} + +struct Case +{ + Buffer payload; + unsigned char dataType; + unsigned int seq; +}; + +// Every declared DATA_TYPE value. +const unsigned char DATA_TYPES[] = { 0, 1, 2, 9, 10, 12, 13, 14, 16, 18, 25, 26, 28, 29, 30, 45 }; +const unsigned int SEQS[] = { 0, 1, 2, 60, 61, 62, 127, 128, 254, 255 }; + +std::vector buildCorpus() +{ + std::vector corpus; + const size_t sizes[] = { 0, 1, 2, 3, 4, 7, 8, 16, 63, 64, 255, 256, 257, 1024 }; + size_t dtIdx = 0, seqIdx = 0; + for (auto n : sizes) + { + corpus.push_back({ randomPayload(n), + DATA_TYPES[dtIdx++ % (sizeof(DATA_TYPES))], + SEQS[seqIdx++ % (sizeof(SEQS) / sizeof(SEQS[0]))] }); + } + // Payloads whose checksum byte lands exactly on 60/61/62 (dt=12, seq=1: + // 14 + b == 60/61/62), so the escaped-checksum path is exercised too. + for (unsigned char b : { 46, 47, 48 }) + corpus.push_back({ Buffer{ static_cast(b) }, 12, 1 }); + // Escape-dense and sentry-adjacent patterns. + corpus.push_back({ Buffer{ 60, 61, 62, 60, 61, 62 }, 12, 0 }); + corpus.push_back({ Buffer{ 61, 44, 61, 45, 61, 46 }, 12, 0 }); + corpus.push_back({ Buffer{ 44, 45, 46 }, 12, 0 }); + return corpus; +} + +// P1 + P5 for one case: structural validation against the independent +// re-implementation, production round trip, determinism, and repack identity. +void checkRoundTrip(const Case& c) +{ + const auto frame = CommandSerializer::packageDataForBt(c.payload, static_cast(c.dataType), c.seq); + const auto frameAgain = CommandSerializer::packageDataForBt(c.payload, static_cast(c.dataType), c.seq); + require(frame == frameAgain, "packing the same input twice was not byte-identical"); + require(frame.size() <= MAX_BLUETOOTH_MESSAGE_SIZE, "pack returned a frame larger than MAX_BLUETOOTH_MESSAGE_SIZE"); + + const auto body = stripMarkers(frame); + // The interior must never contain a raw frame marker, or the transport + // could not find frame boundaries unambiguously. + for (auto ch : body) + require(ch != START_MARKER && ch != END_MARKER, + "packed frame interior contains a raw frame marker: " + hexDump(frame)); + + // Structural check against the documented layout, independently unescaped. + const auto ref = refUnescape(body); + require(ref.size() == 7 + c.payload.size(), "unescaped frame size does not match header + payload + checksum"); + require(ref[0] == c.dataType, "frame does not begin with the data type byte"); + require(ref[1] == (c.seq & 0xFF), "frame seq byte does not match the requested sequence number"); + const uint32_t declared = ((uint32_t)ref[2] << 24) | ((uint32_t)ref[3] << 16) | ((uint32_t)ref[4] << 8) | (uint32_t)ref[5]; + require(declared == c.payload.size(), "declared big-endian size does not match the payload length"); + for (size_t i = 0; i < c.payload.size(); i++) + require(ref[6 + i] == static_cast(c.payload[i]), "payload byte was altered by packing"); + unsigned char sum = 0; + for (size_t i = 0; i < 6 + c.payload.size(); i++) sum = static_cast(sum + ref[i]); + require(sum == ref[6 + c.payload.size()], "checksum byte does not equal the mod-256 sum of the frame"); + + // Production round trip. + CommandSerializer::Message msg; + require(tryUnpack(body, &msg) == Outcome::Parsed, "unpack rejected a freshly packed frame: " + hexDump(frame)); + require(static_cast(msg.dataType) == c.dataType, "round-tripped data type differs"); + require(msg.seqNumber == (c.seq & 0xFF), "round-tripped sequence number differs"); + require(msg.payload == c.payload, "round-tripped payload differs: " + hexDump(frame)); + + // Inverse consistency: packing the unpacked message reproduces the frame. + const auto repacked = CommandSerializer::packageDataForBt(msg.payload, msg.dataType, msg.seqNumber); + require(repacked == frame, "re-packing an unpacked message did not reproduce the original frame"); +} + +size_t testRoundTripCorpus(const std::vector& corpus) +{ + for (const auto& c : corpus) checkRoundTrip(c); + return corpus.size(); +} + +// P2: every escapable byte, at every position, in several payload sizes. +size_t testEscapingTransparency() +{ + size_t cases = 0; + size_t dtIdx = 0, seqIdx = 0; + for (unsigned char special : { 60, 61, 62 }) + { + for (size_t n : { 1, 2, 3, 5, 17 }) + { + for (size_t pos = 0; pos < n; pos++) + { + Buffer payload(n, 0x41); + payload[pos] = static_cast(special); + checkRoundTrip({ payload, + DATA_TYPES[dtIdx++ % sizeof(DATA_TYPES)], + SEQS[seqIdx++ % (sizeof(SEQS) / sizeof(SEQS[0]))] }); + cases++; + } + } + // Payloads made entirely of one escapable byte. + for (size_t n : { 1, 2, 3, 8, 64 }) + { + checkRoundTrip({ Buffer(n, static_cast(special)), 12, 3 }); + cases++; + } + } + return cases; +} + +// P3: seq 0..255 round-trips exactly; wider seqs truncate mod 256. +size_t testSeqDomain() +{ + size_t cases = 0; + const Buffer payload{ 0x01, 0x02 }; + for (unsigned int seq = 0; seq <= 255; seq++) + { + checkRoundTrip({ payload, 12, seq }); + cases++; + } + for (unsigned int wide : { 256u, 300u, 65536u + 5u, 0xDEADBEEFu, 0xFFFFFFFFu }) + { + const auto packedWide = CommandSerializer::packageDataForBt(payload, DATA_TYPE::DATA_MDR, wide); + const auto packedTrunc = CommandSerializer::packageDataForBt(payload, DATA_TYPE::DATA_MDR, wide & 0xFF); + require(packedWide == packedTrunc, "wide sequence number was not truncated mod 256 consistently"); + cases++; + } + return cases; +} + +// P4: one-byte framing damage at either end is rejected, and unpack does not +// accept a frame that still carries its start/end markers. +size_t testFraming(const std::vector& corpus) +{ + size_t cases = 0; + for (const auto& c : corpus) + { + const auto frame = CommandSerializer::packageDataForBt(c.payload, static_cast(c.dataType), c.seq); + const auto body = stripMarkers(frame); + + Buffer truncated(body.begin(), body.end() - 1); + require(tryUnpack(truncated) == Outcome::Recoverable, + "body truncated by one byte was not rejected: " + hexDump(frame)); + + Buffer headless(body.begin() + 1, body.end()); + require(tryUnpack(headless) == Outcome::Recoverable, + "body with first byte dropped was not rejected: " + hexDump(frame)); + + for (char extra : { (char)0x00, (char)SENTRY }) + { + Buffer extended = body; + extended.push_back(extra); + require(tryUnpack(extended) == Outcome::Recoverable, + "body extended by one byte was not rejected: " + hexDump(frame)); + } + + Buffer prefixed{ 0x00 }; + prefixed.insert(prefixed.end(), body.begin(), body.end()); + require(tryUnpack(prefixed) == Outcome::Recoverable, + "body with a prepended byte was not rejected: " + hexDump(frame)); + + require(tryUnpack(frame) == Outcome::Recoverable, + "frame with markers still attached was not rejected: " + hexDump(frame)); + cases += 6; + } + return cases; +} + +// P6: exhaustive single-byte corruption. Every replacement of every body byte +// (and every single-bit flip on a large frame) must be rejected with +// RecoverableException - never parsed, never a non-recoverable escape. +size_t testCorruptionRejection() +{ + size_t cases = 0; + const std::vector targets = { + { Buffer{}, 12, 0 }, + { Buffer{ 0x11, 0x22, 0x33 }, 1, 7 }, + { Buffer{ 60, 61, 62, 44, 45, 46 }, 12, 61 }, + { randomPayload(64), 13, 200 }, + }; + for (const auto& c : targets) + { + const auto frame = CommandSerializer::packageDataForBt(c.payload, static_cast(c.dataType), c.seq); + const auto body = stripMarkers(frame); + for (size_t pos = 0; pos < body.size(); pos++) + { + Buffer corrupted = body; + for (int v = 0; v <= 255; v++) + { + if (static_cast(v) == body[pos]) continue; + corrupted[pos] = static_cast(v); + const auto outcome = tryUnpack(corrupted); + if (outcome != Outcome::Recoverable) + { + std::ostringstream oss; + oss << (outcome == Outcome::Parsed ? "silently accepted" : "threw a non-recoverable error for") + << " a corrupted frame: pos " << pos << " -> " << v << " in body " << hexDump(body); + require(false, oss.str()); + } + g_checks++; + cases++; + } + } + } + + // Single-bit flips across a large frame (payload well past one MTU chunk + // of typical commands, still under the 2048 frame cap). + const auto bigPayload = randomPayload(512); + const auto bigFrame = CommandSerializer::packageDataForBt(bigPayload, DATA_TYPE::DATA_MDR, 1); + const auto bigBody = stripMarkers(bigFrame); + for (size_t pos = 0; pos < bigBody.size(); pos++) + { + Buffer corrupted = bigBody; + for (int bit = 0; bit < 8; bit++) + { + corrupted[pos] = static_cast(bigBody[pos] ^ (1 << bit)); + const auto outcome = tryUnpack(corrupted); + if (outcome != Outcome::Recoverable) + { + std::ostringstream oss; + oss << (outcome == Outcome::Parsed ? "silently accepted" : "threw a non-recoverable error for") + << " a bit-flipped frame: pos " << pos << " bit " << bit; + require(false, oss.str()); + } + g_checks++; + cases++; + } + } + return cases; +} + +// P7: the 2048 cap applies to the escaped frame, so the payload budget depends +// on content: plain bytes cost 1 wire byte, escapable bytes cost 2. +size_t testSizeBoundary() +{ + size_t cases = 0; + + // Plain payload: overhead is 9 bytes (2 markers + 6 header + 1 checksum, + // none of which escape for these inputs), so 2039 fits exactly and 2040 + // overflows. + checkRoundTrip({ Buffer(2039, 0x41), 12, 1 }); + const auto maxFrame = CommandSerializer::packageDataForBt(Buffer(2039, 0x41), DATA_TYPE::DATA_MDR, 1); + require(maxFrame.size() == MAX_BLUETOOTH_MESSAGE_SIZE, "largest plain payload did not produce an exactly full frame"); + cases++; + + for (size_t n : { 2040, 2100, 4096 }) + { + bool threw = false; + try { CommandSerializer::packageDataForBt(Buffer(n, 0x41), DATA_TYPE::DATA_MDR, 1); } + catch (const std::exception&) { threw = true; } + require(threw, "oversized plain payload was not rejected"); + cases++; + } + + // All-escapable payload: each byte costs two wire bytes, so the budget + // halves. 1016 bytes of 60 fits (frame 2041); 1020 does not (frame 2049). + checkRoundTrip({ Buffer(1016, 60), 12, 1 }); + cases++; + { + bool threw = false; + try { CommandSerializer::packageDataForBt(Buffer(1020, 60), DATA_TYPE::DATA_MDR, 1); } + catch (const std::exception&) { threw = true; } + require(threw, "oversized escape-heavy payload was not rejected despite escaping inflation"); + cases++; + } + return cases; +} +} + +int main() +{ + try + { + const auto corpus = buildCorpus(); + const auto roundTrips = testRoundTripCorpus(corpus); + const auto escapes = testEscapingTransparency(); + const auto seqs = testSeqDomain(); + const auto framings = testFraming(corpus); + const auto corruptions = testCorruptionRejection(); + const auto boundaries = testSizeBoundary(); + + std::cout << "Sony protocol metamorphic tests passed: " + << roundTrips << " round-trip, " + << escapes << " escaping, " + << seqs << " seq-domain, " + << framings << " framing, " + << corruptions << " corruption, " + << boundaries << " boundary cases (" + << g_checks << " checks)\n"; + return 0; + } + catch (const std::exception& e) + { + std::cerr << "METAMORPHIC TEST FAILURE: " << e.what() << "\n"; + return 1; + } +} diff --git a/Client/tests/ProtocolConformanceTests.cpp b/Client/tests/ProtocolConformanceTests.cpp new file mode 100644 index 00000000..fe1c82dc --- /dev/null +++ b/Client/tests/ProtocolConformanceTests.cpp @@ -0,0 +1,151 @@ +#include "CommandSerializer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +struct Fixture +{ + std::string name; + unsigned int dataType; + unsigned int sequence; + Buffer payload; + Buffer frame; +}; + +Buffer parseHex(const std::string& text) +{ + Buffer bytes; + std::istringstream input(text); + unsigned int value; + while (input >> std::hex >> value) + { + if (value > 0xff) throw std::runtime_error("fixture contains a non-byte value"); + bytes.push_back(static_cast(value)); + } + if (!input.eof()) throw std::runtime_error("fixture contains invalid hex"); + return bytes; +} + +std::string toHex(const Buffer& bytes) +{ + std::ostringstream output; + for (size_t i = 0; i < bytes.size(); i++) + { + if (i != 0) output << ' '; + output << std::hex << std::setw(2) << std::setfill('0') + << static_cast(static_cast(bytes[i])); + } + return output.str(); +} + +std::vector loadFixtures(const std::string& path) +{ + std::ifstream input(path); + if (!input) throw std::runtime_error("could not open golden fixture: " + path); + + std::vector fixtures; + std::string line; + while (std::getline(input, line)) + { + if (line.empty() || line[0] == '#') continue; + std::istringstream row(line); + std::string name, type, sequence, payload, frame; + if (!std::getline(row, name, '|') || !std::getline(row, type, '|') || + !std::getline(row, sequence, '|') || !std::getline(row, payload, '|') || + !std::getline(row, frame)) + throw std::runtime_error("invalid fixture row: " + line); + fixtures.push_back({ name, static_cast(std::stoul(type, nullptr, 16)), + static_cast(std::stoul(sequence)), parseHex(payload), parseHex(frame) }); + } + return fixtures; +} + +std::string renderFixtures(const std::vector& fixtures, const std::vector& frames) +{ + std::ostringstream output; + output << "# id|data_type|sequence|payload|complete_frame\n"; + for (size_t i = 0; i < fixtures.size(); i++) + { + output << fixtures[i].name << '|' << std::hex << std::setw(2) << std::setfill('0') + << fixtures[i].dataType << '|' << std::dec << fixtures[i].sequence << '|' + << toHex(fixtures[i].payload) << '|' << toHex(frames[i]) << '\n'; + } + return output.str(); +} + +void require(bool condition, const std::string& message) +{ + if (!condition) throw std::runtime_error(message); +} + +template +void requireReject(Func operation, const char* name) +{ + try { operation(); } + catch (const std::exception&) { return; } + throw std::runtime_error(std::string("malformed frame was accepted: ") + name); +} +} + +int main(int argc, char** argv) +{ + if (argc != 2) throw std::runtime_error("usage: protocol-conformance-tests "); + const std::string fixturePath = argv[1]; + auto fixtures = loadFixtures(fixturePath); + require(!fixtures.empty(), "golden fixture has no cases"); + + std::vector actualFrames; + bool mismatch = false; + for (const auto& fixture : fixtures) + { + auto actual = CommandSerializer::packageDataForBt(fixture.payload, + static_cast(fixture.dataType), fixture.sequence); + actualFrames.push_back(actual); + if (actual != fixture.frame) + { + mismatch = true; + std::cerr << fixture.name << " golden mismatch\nexpected: " << toHex(fixture.frame) + << "\nactual: " << toHex(actual) << '\n'; + } + + require(actual.front() == START_MARKER && actual.back() == END_MARKER, + fixture.name + " is not framed"); + Buffer body(actual.begin() + 1, actual.end() - 1); + auto decoded = CommandSerializer::unpackBtMessage(body); + require(static_cast(decoded.dataType) == fixture.dataType, + fixture.name + " data type did not round-trip"); + require(decoded.seqNumber == fixture.sequence, fixture.name + " sequence did not round-trip"); + require(decoded.payload == fixture.payload, fixture.name + " payload did not round-trip"); + } + + if (const char* update = std::getenv("UPDATE_GOLDENS"); update && std::string(update) == "1") + { + std::ofstream output(fixturePath, std::ios::trunc); + output << renderFixtures(fixtures, actualFrames); + std::cout << "Updated golden fixture; review the diff before accepting it.\n"; + mismatch = false; + } + else if (mismatch) + { + std::ofstream actual(fixturePath + ".actual", std::ios::trunc); + actual << renderFixtures(fixtures, actualFrames); + throw std::runtime_error("golden mismatch; inspect the .actual file"); + } + + requireReject([] { CommandSerializer::unpackBtMessage({}); }, "empty"); + requireReject([] { CommandSerializer::unpackBtMessage({ 0x3d }); }, "dangling escape"); + requireReject([] { CommandSerializer::unpackBtMessage({ 0x0c, 0, 0, 0, 0, 2, 0, 0 }); }, "missing checksum"); + requireReject([] { CommandSerializer::unpackBtMessage({ 0x0c, 0, 0, 0, 0, 0, 0x0c, 0 }); }, "trailing data"); + requireReject([] { CommandSerializer::unpackBtMessage({ 0x0c, 0, 0, 0, 0, 0, 0x00 }); }, "bad checksum"); + + std::cout << fixtures.size() << " Sony protocol golden cases passed\n"; + return 0; +} diff --git a/Client/tests/conformance/COVERAGE.md b/Client/tests/conformance/COVERAGE.md new file mode 100644 index 00000000..d92cee0c --- /dev/null +++ b/Client/tests/conformance/COVERAGE.md @@ -0,0 +1,15 @@ +# XM4 v1 conformance coverage + +The target contract is the Sony MDR v1 community specification at the revisions pinned in `PROVENANCE.md`. “MUST” here means required for SonyBridge's supported XM4 feature set, not a claim about Sony's unpublished full protocol. + +| Section | MUST | SHOULD | Tested | Passing | Divergent | Score | +|---|---:|---:|---:|---:|---:|---:| +| Framing, escaping, length, checksum, round-trip | 8 | 0 | 8 | 8 | 0 | 100% | +| Safe protocol routing and inquiry payloads | 9 | 0 | 9 | 9 | 0 | 100% | +| Ambient/NC mode and levels 1/20 | 4 | 0 | 4 | 4 | 0 | 100% | +| EQ preset and persistent custom EQ | 3 | 0 | 3 | 3 | 0 | 100% | +| DSEE and Speak-to-Chat mutations | 2 | 0 | 2 | 2 | 0 | 100% | +| Malformed-frame rejection | 5 | 0 | 5 | 5 | 0 | 100% | +| v2 multipoint (WH-ULT900N) GET/RET/SET/apply framing | 6 | 0 | 6 | 6 | 0 | 100% | + +Hardware behavior is deliberately excluded from this file's score. It has a separate live-device verification gate because the public specification is reverse-engineered and Sony firmware can differ. diff --git a/Client/tests/conformance/DISCREPANCIES.md b/Client/tests/conformance/DISCREPANCIES.md new file mode 100644 index 00000000..9ea6ca18 --- /dev/null +++ b/Client/tests/conformance/DISCREPANCIES.md @@ -0,0 +1,5 @@ +# Known conformance divergences + +There are currently no intentional divergences from the pinned XM4 v1 reference for the supported command set. + +Unknown or unimplemented Sony features are out of scope rather than silently skipped. Any future intentional difference must receive a `DISC-NNN` entry with its status, impact, affected tests, and review date. diff --git a/Client/tests/conformance/PROVENANCE.md b/Client/tests/conformance/PROVENANCE.md new file mode 100644 index 00000000..63014f80 --- /dev/null +++ b/Client/tests/conformance/PROVENANCE.md @@ -0,0 +1,12 @@ +# Fixture provenance + +The XM4 v1 payloads are cross-checked against two independently maintained community references: + +- Gadgetbridge `27ed00aa4232eaa360172b78b35d2fb168c190f0`: `PayloadTypeV1.java` and `SonyProtocolImplV1.java`. This is the primary source for command IDs, subtypes, and payload layouts, including DSEE, auto power-off, EQ, ambient sound, battery, firmware, codec, and Speak-to-Chat. +- `tanat/sony-connect-osx` `1a68e3bee54778c2db92db6379fa24662537b1db`: `SonyPacket.swift` and `HeadphonesController.swift`. This is an XM4-specific independent source for frame escaping, checksum rules, strict frame length, and the core XM4 commands. + +The exact frames were generated on 2026-08-24 with Ruby 3.4 from `generate_reference_goldens.rb`. That script is a clean-room transcription of the documented wire rules and pinned payload facts. Regenerate to a temporary file, diff every byte against `fixtures/xm4_v1_frames.golden`, and update this file when either reference revision changes. + +The v2 multipoint payloads in `fixtures/ult900n_v2_multipoint.golden` (generated by the same script with the `ult900n_v2_multipoint` argument) are pinned from Gadgetbridge's ConnectTwoDevices feature as hardware-verified on the WH-ULT900N (ULT WEAR): commits `8cbc309346` ("WIP add ConnectTwoDevices feature"), `e8520865f3` ("fix: setting Connect Two Devices"), and `5d7b37d1b7` ("fix read Connect Two Devices"), all 2026-07-08, files `SonyProtocolImplV2.java` and `SonyHeadphonesProtocol.java`. Pinned facts: the state is read and written through touch-sensor subtype `d1` with an inverted enable bit (`d6 d1` / `d7 d1 00 <00=on|01=off>` / `d8 d1 00 <00=on|01=off>`), a SET is followed by the fixed system-control apply commit `98 00 06 01` for both directions, and the system-control RET (`97 00 06 xx`) always reports `01` so it is never a state source. Gadgetbridge implements no v1 (WH-1000XM4) multipoint frames — `SonyProtocolImplV1.setConnectTwoDevices`/`getConnectTwoDevices` log "not implemented for V1" and return null — so no v1 fixture exists on purpose. + +Both fixtures are deterministic and platform-independent. They use exact byte comparison; no scrubbing or tolerance is appropriate. diff --git a/Client/tests/conformance/fixtures/ult900n_v2_multipoint.golden b/Client/tests/conformance/fixtures/ult900n_v2_multipoint.golden new file mode 100644 index 00000000..24612cea --- /dev/null +++ b/Client/tests/conformance/fixtures/ult900n_v2_multipoint.golden @@ -0,0 +1,7 @@ +# id|data_type|sequence|payload|complete_frame +mp_probe_get|0c|0|d6 d1|3e 0c 00 00 00 00 02 d6 d1 b5 3c +mp_ret_on|0c|0|d7 d1 00 00|3e 0c 00 00 00 00 04 d7 d1 00 00 b8 3c +mp_ret_off|0c|0|d7 d1 00 01|3e 0c 00 00 00 00 04 d7 d1 00 01 b9 3c +mp_set_on|0c|0|d8 d1 00 00|3e 0c 00 00 00 00 04 d8 d1 00 00 b9 3c +mp_set_off|0c|0|d8 d1 00 01|3e 0c 00 00 00 00 04 d8 d1 00 01 ba 3c +mp_apply_commit|0c|0|98 00 06 01|3e 0c 00 00 00 00 04 98 00 06 01 af 3c diff --git a/Client/tests/conformance/fixtures/xm4_v1_frames.golden b/Client/tests/conformance/fixtures/xm4_v1_frames.golden new file mode 100644 index 00000000..5791e1a1 --- /dev/null +++ b/Client/tests/conformance/fixtures/xm4_v1_frames.golden @@ -0,0 +1,19 @@ +# id|data_type|sequence|payload|complete_frame +ack_seq1|01|1||3e 01 01 00 00 00 00 02 3c +escape_triplet|0c|0|3c 3d 3e|3e 0c 00 00 00 00 03 3d 2c 3d 2d 3d 2e c6 3c +init|0c|0|00 00|3e 0c 00 00 00 00 02 00 00 0e 3c +firmware_get|0c|0|04 02|3e 0c 00 00 00 00 02 04 02 14 3c +battery_get|0c|0|10 00|3e 0c 00 00 00 00 02 10 00 1e 3c +codec_get|0c|0|18 00|3e 0c 00 00 00 00 02 18 00 26 3c +eq_get|0c|0|56 01|3e 0c 00 00 00 00 02 56 01 65 3c +eq_preset_bright|0c|0|58 01 10 00|3e 0c 00 00 00 00 04 58 01 10 00 79 3c +eq_custom|0c|0|58 01 ff 06 0d 08 09 0a 0b 0c|3e 0c 00 00 00 00 0a 58 01 ff 06 0d 08 09 0a 0b 0c b3 3c +ambient_get|0c|0|66 02|3e 0c 00 00 00 00 02 66 02 76 3c +ambient_level1|0c|0|68 02 11 02 00 01 00 01|3e 0c 00 00 00 00 08 68 02 11 02 00 01 00 01 93 3c +ambient_level20|0c|0|68 02 11 02 00 01 00 14|3e 0c 00 00 00 00 08 68 02 11 02 00 01 00 14 a6 3c +noise_cancel|0c|0|68 02 11 02 02 01 00 00|3e 0c 00 00 00 00 08 68 02 11 02 02 01 00 00 94 3c +dsee_get|0c|0|e6 02|3e 0c 00 00 00 00 02 e6 02 f6 3c +dsee_off|0c|0|e8 02 00 00|3e 0c 00 00 00 00 04 e8 02 00 00 fa 3c +auto_power_get|0c|0|f6 04|3e 0c 00 00 00 00 02 f6 04 08 3c +speak_get|0c|0|f6 05|3e 0c 00 00 00 00 02 f6 05 09 3c +speak_off|0c|0|f8 05 01 00|3e 0c 00 00 00 00 04 f8 05 01 00 0e 3c diff --git a/Client/tests/conformance/generate_reference_goldens.rb b/Client/tests/conformance/generate_reference_goldens.rb new file mode 100644 index 00000000..206e881b --- /dev/null +++ b/Client/tests/conformance/generate_reference_goldens.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Clean-room generator for the deterministic frame facts documented in PROVENANCE.md. +# Usage: ruby generate_reference_goldens.rb [xm4_v1|ult900n_v2_multipoint] +def frame(data_type, sequence, payload) + body = [data_type, sequence, 0, 0, 0, payload.length] + payload + body << body.sum.modulo(256) + escaped = body.flat_map { |byte| [0x3c, 0x3d, 0x3e].include?(byte) ? [0x3d, byte & 0xef] : [byte] } + [0x3e] + escaped + [0x3c] +end + +XM4_V1_CASES = { + ack_seq1: [0x01, 1, []], escape_triplet: [0x0c, 0, [0x3c, 0x3d, 0x3e]], + init: [0x0c, 0, [0x00, 0x00]], firmware_get: [0x0c, 0, [0x04, 0x02]], + battery_get: [0x0c, 0, [0x10, 0x00]], codec_get: [0x0c, 0, [0x18, 0x00]], + eq_get: [0x0c, 0, [0x56, 0x01]], eq_preset_bright: [0x0c, 0, [0x58, 0x01, 0x10, 0x00]], + eq_custom: [0x0c, 0, [0x58, 0x01, 0xff, 0x06, 13, 8, 9, 10, 11, 12]], + ambient_get: [0x0c, 0, [0x66, 0x02]], ambient_level1: [0x0c, 0, [0x68, 0x02, 0x11, 0x02, 0, 1, 0, 1]], + ambient_level20: [0x0c, 0, [0x68, 0x02, 0x11, 0x02, 0, 1, 0, 20]], + noise_cancel: [0x0c, 0, [0x68, 0x02, 0x11, 0x02, 2, 1, 0, 0]], + dsee_get: [0x0c, 0, [0xe6, 0x02]], dsee_off: [0x0c, 0, [0xe8, 0x02, 0, 0]], + auto_power_get: [0x0c, 0, [0xf6, 0x04]], speak_get: [0x0c, 0, [0xf6, 0x05]], + speak_off: [0x0c, 0, [0xf8, 0x05, 1, 0]] +}.freeze + +# v2 multipoint ("Connect to 2 devices simultaneously") payload facts, pinned from GadgetBridge +# SonyProtocolImplV2 as hardware-verified on the WH-ULT900N (see PROVENANCE.md): +# state rides touch-sensor subtype d1 with an inverted enable bit; SETs are followed by the fixed +# system-control apply commit 98 00 06 01 regardless of direction. +ULT900N_V2_MULTIPOINT_CASES = { + mp_probe_get: [0x0c, 0, [0xd6, 0xd1]], + mp_ret_on: [0x0c, 0, [0xd7, 0xd1, 0x00, 0x00]], + mp_ret_off: [0x0c, 0, [0xd7, 0xd1, 0x00, 0x01]], + mp_set_on: [0x0c, 0, [0xd8, 0xd1, 0x00, 0x00]], + mp_set_off: [0x0c, 0, [0xd8, 0xd1, 0x00, 0x01]], + mp_apply_commit: [0x0c, 0, [0x98, 0x00, 0x06, 0x01]] +}.freeze + +cases = (ARGV[0] || 'xm4_v1') == 'ult900n_v2_multipoint' ? ULT900N_V2_MULTIPOINT_CASES : XM4_V1_CASES + +puts '# id|data_type|sequence|payload|complete_frame' +cases.each do |name, (data_type, sequence, payload)| + hex = ->(bytes) { bytes.map { |byte| format('%02x', byte) }.join(' ') } + puts [name, format('%02x', data_type), sequence, hex.call(payload), hex.call(frame(data_type, sequence, payload))].join('|') +end diff --git a/Client/tests/run-macos.sh b/Client/tests/run-macos.sh new file mode 100755 index 00000000..6d1a9e0c --- /dev/null +++ b/Client/tests/run-macos.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +client_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +test_binary="${TMPDIR:-/tmp}/sonybridge-xm4-protocol-tests" +conformance_binary="${TMPDIR:-/tmp}/sonybridge-protocol-conformance-tests" +metamorphic_binary="${TMPDIR:-/tmp}/sonybridge-metamorphic-protocol-tests" +mac_connector_binary="${TMPDIR:-/tmp}/sonybridge-mac-connector-buffer-tests" + +clang++ -std=c++20 -pthread -I"$client_dir" \ + "$client_dir/tests/HeadphonesProtocolTests.cpp" \ + "$client_dir/BluetoothWrapper.cpp" \ + "$client_dir/ByteMagic.cpp" \ + "$client_dir/CommandSerializer.cpp" \ + "$client_dir/Headphones.cpp" \ + -o "$test_binary" + +"$test_binary" + +clang++ -std=c++20 -Wall -Wextra -Werror -I"$client_dir" \ + "$client_dir/tests/ProtocolConformanceTests.cpp" \ + "$client_dir/ByteMagic.cpp" \ + "$client_dir/CommandSerializer.cpp" \ + -o "$conformance_binary" + +"$conformance_binary" "$client_dir/tests/conformance/fixtures/xm4_v1_frames.golden" +"$conformance_binary" "$client_dir/tests/conformance/fixtures/ult900n_v2_multipoint.golden" + +clang++ -std=c++20 -Wall -Wextra -Werror -I"$client_dir" \ + "$client_dir/tests/MetamorphicProtocolTests.cpp" \ + "$client_dir/ByteMagic.cpp" \ + "$client_dir/CommandSerializer.cpp" \ + -o "$metamorphic_binary" + +"$metamorphic_binary" + +# Offline receive-buffer tests for the macOS connector (no Bluetooth channel is opened). +clang++ -std=c++20 -fobjc-arc -pthread -I"$client_dir" -I"$client_dir/macos" \ + "$client_dir/tests/MacConnectorBufferTests.mm" \ + "$client_dir/macos/MacOSBluetoothConnector.mm" \ + -framework IOBluetooth -framework Foundation \ + -o "$mac_connector_binary" + +"$mac_connector_binary" diff --git a/Client/windows/WindowsBluetoothConnector.cpp b/Client/windows/WindowsBluetoothConnector.cpp index 6967f36f..cfb1c101 100644 --- a/Client/windows/WindowsBluetoothConnector.cpp +++ b/Client/windows/WindowsBluetoothConnector.cpp @@ -1,5 +1,7 @@ #include "WindowsBluetoothConnector.h" +#include + void WSAStartupWrapper() { int iResult; @@ -50,8 +52,7 @@ void WindowsBluetoothConnector::connect(const std::string& addrStr) } // A failed connect() can leave the socket unusable - recreate it before trying the v2 UUID. - ::closesocket(this->_socket); - this->_socket = INVALID_SOCKET; + this->_closeSocket(); this->_initSocket(); if (_tryConnect(SONY_UUID_V2, sab)) @@ -66,25 +67,57 @@ void WindowsBluetoothConnector::connect(const std::string& addrStr) WindowsBluetoothConnector::~WindowsBluetoothConnector() { - if (this->_socket != INVALID_SOCKET) + this->_closeSocket(); +} + +void WindowsBluetoothConnector::_closeSocket() noexcept +{ + SOCKET sock = this->_socket.exchange(INVALID_SOCKET); + if (sock != INVALID_SOCKET) { - ::closesocket(this->_socket); + ::shutdown(sock, SD_BOTH); + ::closesocket(sock); } } int WindowsBluetoothConnector::send(char* buf, size_t length) { - auto bytesSent = ::send(this->_socket, buf, length, 0); - if (bytesSent == SOCKET_ERROR) + SOCKET sock = this->_socket.load(); + if (sock == INVALID_SOCKET) { - throw RecoverableException("Couldn't send (" + std::to_string(WSAGetLastError()) + ")", true); + throw RecoverableException("Not connected", true); + } + + // send() is free to accept only part of the buffer; keep going until the whole frame is out, + // otherwise the device sees a truncated command and never answers. + size_t total = 0; + while (total < length) + { + int chunk = static_cast((std::min)(length - total, static_cast(0x7fffffff))); + int sent = ::send(sock, buf + total, chunk, 0); + if (sent == SOCKET_ERROR) + { + throw RecoverableException("Couldn't send (" + std::to_string(WSAGetLastError()) + ")", true); + } + total += static_cast(sent); } - return bytesSent; + return static_cast(total); } int WindowsBluetoothConnector::recv(char* buf, size_t length) { - auto bytesReceived = ::recv(this->_socket, buf, length, 0); + SOCKET sock = this->_socket.load(); + if (sock == INVALID_SOCKET) + { + throw RecoverableException("Not connected", true); + } + + auto bytesReceived = ::recv(sock, buf, static_cast(length), 0); + if (bytesReceived == 0) + { + // Graceful close by the headphones. Without this the caller loops on an empty buffer forever. + throw RecoverableException("The headphones closed the connection.", true); + } if (bytesReceived == SOCKET_ERROR) { int err = WSAGetLastError(); @@ -142,13 +175,8 @@ std::vector WindowsBluetoothConnector::getConnectedDevices() void WindowsBluetoothConnector::disconnect() noexcept { - if (this->_socket != INVALID_SOCKET) - { - this->_connected = false; - shutdown(this->_socket, SD_BOTH); - closesocket(this->_socket); - this->_socket = INVALID_SOCKET; - } + this->_connected = false; + this->_closeSocket(); } bool WindowsBluetoothConnector::isConnected() noexcept diff --git a/Client/windows/WindowsBluetoothConnector.h b/Client/windows/WindowsBluetoothConnector.h index 824b8e1d..07edb68b 100644 --- a/Client/windows/WindowsBluetoothConnector.h +++ b/Client/windows/WindowsBluetoothConnector.h @@ -1,17 +1,25 @@ #pragma once -#include -#pragma comment(lib, "ws2_32.lib") -#pragma comment(lib, "rpcrt4.lib") -#pragma comment(lib, "Bthprops.lib") + +// winsock2.h must precede windows.h, which bluetoothapis.h pulls in. #include #include -#include -#include +// Lowercase: the MinGW-w64 headers are named this way, and so is the file on a +// case-sensitive filesystem (cross-compiling from Linux). +#include #include -#include "IBluetoothConnector.h" + +#include #include + +#include "IBluetoothConnector.h" #include "ByteMagic.h" -#include + +// Only MSVC understands these; every other toolchain gets the libraries from CMake. +#ifdef _MSC_VER +#pragma comment(lib, "ws2_32.lib") +#pragma comment(lib, "rpcrt4.lib") +#pragma comment(lib, "bthprops.lib") +#endif class WindowsBluetoothConnector final : public IBluetoothConnector { @@ -34,8 +42,11 @@ class WindowsBluetoothConnector final : public IBluetoothConnector std::string _wstringToUtf8(const std::wstring& wstr); bool _tryConnect(const char* uuid, SOCKADDR_BTH& sab); - SOCKET _socket = INVALID_SOCKET; - std::atomic _connected = false; - SonyProtocolVersion _protocolVersion = SonyProtocolVersion::V1; + // disconnect() runs on the UI thread while a worker thread can be inside recv(), so the handle + // itself has to be atomic; closing it is what makes that blocked recv() return. + std::atomic _socket{ INVALID_SOCKET }; + std::atomic _connected{ false }; + std::atomic _protocolVersion{ SonyProtocolVersion::V1 }; void _initSocket(); + void _closeSocket() noexcept; }; \ No newline at end of file diff --git a/Client/windows/WindowsGUI.h b/Client/windows/WindowsGUI.h index b4be8e49..0a764eb4 100644 --- a/Client/windows/WindowsGUI.h +++ b/Client/windows/WindowsGUI.h @@ -18,7 +18,7 @@ #include -#include "Windows.h" +#include #include "CrossPlatformGUI.h" diff --git a/Client/windows/main.cpp b/Client/windows/main.cpp index fd248713..cd164c5a 100644 --- a/Client/windows/main.cpp +++ b/Client/windows/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include "WindowsBluetoothConnector.h" #include "CommandSerializer.h" diff --git a/README.md b/README.md index a67afb1b..a541272c 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@
-SonyBridge connected to a WH-CH720N -   -SonyBridge disconnected state +SonyBridge in its three themes: Refined Dark, Native Glass and Sony Signature + +Three looks, one app: Refined Dark · Native Glass (follows light/dark) · Sony Signature — pick one under ⋯ → Appearance. @@ -51,7 +51,7 @@ on macOS, and a matching modern UI on Windows/Linux. - 🎚️ **Ambient Sound Control** — Noise Cancelling · Ambient Sound (0–20 levels) · Off - 🗣️ **Focus on Voice** passthrough -- 🎛️ **Equalizer** — presets *and* a full **Manual mode** with 5 bands + Clear Bass +- 🎛️ **Equalizer** — presets *and* a full **Manual mode** with 5 bands + Clear Bass (10 bands on the WH-1000XM6), with a live curve preview - ✨ **DSEE** — Sony's audio upscaling for compressed sources - 🔋 **Battery level** — live percentage, including **per-earbud + case** for TWS models - 🎧 **Codec & firmware** readout @@ -59,8 +59,34 @@ on macOS, and a matching modern UI on Windows/Linux. - 🖼️ **Device hero image** — your headphones' official Sony product render - 🔄 **Live button sync** — changes made on the headset reflect in the app - 🔌 **Auto-connect** to your already-paired Sony headset +- 🍫 **Menu-bar quick controls** — click the menu-bar icon for mode, ambient level, Focus on Voice, EQ preset, battery and playback pinning without opening the window (right-click for the menu) +- ↔️ **Resizable window** — a tall single column by default; widen it past 720pt for a two-column layout +- 📱 **Multipoint** — see which devices are connected, switch playback between them, pin playback to this Mac, and turn "Connect to 2 devices" on or off - 🧬 **Dual-protocol** — auto-detects and speaks either protocol generation -- 🌑 **Modern UI** — dark, minimal, shaped after Sony's own app (SwiftUI on macOS, Dear ImGui on Windows/Linux) +- 🌑 **Modern UI** — three themes on macOS (Refined Dark, Native Glass, Sony Signature), shaped after Sony's own app; Dear ImGui on Windows/Linux + +
+📸 Full screenshots +
+ +| Refined Dark | Native Glass (light) | Native Glass (dark) | Sony Signature | +| :---: | :---: | :---: | :---: | +| Refined Dark theme | Native Glass theme, light | Native Glass theme, dark | Sony Signature theme | + +**Wide window (two columns)** + +Two-column window, Refined Dark + +| Menu-bar panel: Refined Dark | Native Glass | Sony Signature | +| :---: | :---: | :---: | +| Menu-bar quick controls, Refined Dark | Menu-bar quick controls, Native Glass | Menu-bar quick controls, Sony Signature | + +**WH-1000XM6 ten-band equalizer** + +Ten-band equalizer on the WH-1000XM6 + +Rendered from the app's own views; the device list shows placeholder names. +
## 📥 Download @@ -72,12 +98,12 @@ on macOS, and a matching modern UI on Windows/Linux. macOS -`brew tap AmitRajput-Dev/tap && brew install --cask sonybridge` +`brew install --cask amitrajput-dev/tap/sonybridge` or [**Download .app**](https://github.com/AmitRajput-Dev/SonyBridge/releases/latest) -macOS 11+ · Apple Silicon & Intel +macOS 12+ · Apple Silicon & Intel Windows diff --git a/docs/MULTIPOINT_RESEARCH.md b/docs/MULTIPOINT_RESEARCH.md new file mode 100644 index 00000000..c24f8d2e --- /dev/null +++ b/docs/MULTIPOINT_RESEARCH.md @@ -0,0 +1,84 @@ +# Multipoint ("Connect to 2 devices simultaneously") — protocol research + +Research date: 2026-08-24. Question: can SonyBridge toggle Sony's multipoint setting without +the phone app, using only wire bytes proven by an existing implementation? + +## Sources examined + +- **Gadgetbridge** (codeberg.org/Freeyourgadget/Gadgetbridge, `master`), path + `app/src/main/java/nodomain/freeyourgadget/gadgetbridge/{devices,service/devices}/sony/headphones/`. + Files pulled raw and cross-checked against the repository's commit API (the GitHub mirror is the + archived 2022 snapshot and predates this feature). +- **Plutoberth/SonyHeadphonesClient** (upstream of this codebase): issues #113 ("Change pairing + mode sony headphone") and #114 ("Feature request: switch bluetooth source") are open feature + requests with no frames and no implementation. No prior multipoint art exists there. + +## Finding 1 — v1 (WH-1000XM4): NOT implemented anywhere. No proven frames exist. + +`SonyProtocolImplV1.java` (protocol/impl/v1) implements the capability interface with an explicit +negative — both methods log and return null: + +```java +public Request setConnectTwoDevices(final ConnectTwoDevices config) { + LOG.warn("Connect two devices not implemented for V1"); + return null; +} +public Request getConnectTwoDevices() { + LOG.warn("Connect two devices not implemented for V1"); + return null; +} +``` + +`PayloadTypeV1.java` contains no multipoint/pairing-management payload type. No v1 device +coordinator declares `SonyHeadphonesCapabilities.ConnectTwoDevices`. + +**Consequence for SonyBridge: the XM4 gets no multipoint support.** Inventing v1 frames to send +at real hardware is out of scope by policy; this repo only ships wire bytes a proven +implementation has sent at a real device. The v1 test suite instead asserts the XM4 path never +emits any of the v2 multipoint opcodes (`d6`/`d8`/`98`). + +## Finding 2 — v2: proven by Gadgetbridge on the WH-ULT900N (ULT WEAR) + +Added 2026-07-08 by David Giron, three commits (in order): + +1. `8cbc309346` "WIP add ConnectTwoDevices feature" — first cut: GET `96 00 06`, + SET `98 00 06 <01|00>` (SYSTEM_CONTROL family, subcode 0x06). +2. `e8520865f3` "fix: setting Connect Two Devices" — hardware testing showed the SET value byte is + ignored; the state is actually driven through the touch-sensor `d1` subtype, and `98 00 06 01` + is a fixed "apply" commit sent after it for **both** directions. +3. `5d7b37d1b7` "fix read Connect Two Devices" — hardware testing showed `SYSTEM_CONTROL_RET` + (`97 00 06 xx`) always reports `01` regardless of actual state, so the GET was changed to the + touch-sensor read, whose RET carries the real value. + +Final proven wire contract (`SonyProtocolImplV2.java` `setConnectTwoDevices`/`getConnectTwoDevices`/ +`setWideAreaTap`/`handleTouchSensor`, `SonyHeadphonesProtocol.java` `PREF_SONY_CONNECT_TWO_DEVICES` +case; only `SonyWHULT900NCoordinator` declares the capability): + +| Step | Payload | Meaning | +|---|---|---| +| GET | `d6 d1` | read state | +| RET | `d7 d1 00 XX` | `XX == 00` → multipoint ON, `01` → OFF (enable bit inverted) | +| SET (1st frame) | `d8 d1 00 XX` | `00` → ON, `01` → OFF | +| SET (2nd frame) | `98 00 06 01` | fixed apply commit, identical bytes for ON and OFF | + +Frame order for SET: the `d8 d1 ...` state frame is sent first (it is the returned +`configRequest`, encoded immediately); the `98 00 06 01` commit is queued and drained on the next +ACK. SonyBridge reproduces this with two sequential `sendCommand` calls, each of which waits for +its ACK. + +### Caveat carried into the implementation + +On devices with real touch-tap hardware (LinkBuds family), touch-sensor subtype `d1` is Wide Area +Tap, not multipoint; Gadgetbridge distinguishes by per-model capability lists. SonyBridge gates by +capability probe instead (house style): the toggle appears only when the device answers `d6 d1`, +and the byte contract is only hardware-proven for the WH-ULT900N family. This is noted at the +probe site in `Headphones.cpp`. + +## What SonyBridge implements + +- v2 only: probe `d6 d1` appended to `probeCapabilities()` (v2 branch), state parsed per the table + above; `setMultipoint()` sends `d8 d1 00 XX` then `98 00 06 01`. +- v1 (XM4): nothing — `hasMultipoint()` stays false, `setMultipoint()` throws. +- Offline proof: `HeadphonesProtocolTests.cpp` (FakeV2Connector GET/RET exchange + exact SET frame + bytes; v1 negative assertions) and `conformance/fixtures/ult900n_v2_multipoint.golden` + (exact framing for all six payloads). diff --git a/docs/QUALITY_AUDIT.md b/docs/QUALITY_AUDIT.md new file mode 100644 index 00000000..7db63456 --- /dev/null +++ b/docs/QUALITY_AUDIT.md @@ -0,0 +1,35 @@ +# Quality audit + +This log records the four-pass hardening work performed for first-class WH-1000XM4 support. + +## Pass 1: surface scan + +- Baseline protocol tests and a strict C++20 build passed. +- Xcode's static analyzer completed without findings. UBS was not available on this machine. +- Fixed RFCOMM shutdown calling `disconnect()` from the connector thread, which could join itself. +- Fixed failed and remotely closed connections leaving a joinable thread that could terminate the app. +- Made connector shutdown idempotent and wake blocked readers immediately. +- Fixed frame parsing so transport noise before a start marker is ignored and oversized frames are rejected. + +## Pass 2: fresh-eyes review + +- Replaced queued raw `Headphones*` captures with retained `shared_ptr` values to prevent use-after-free on disconnect. +- Serialized status reads, dynamic polling, and writes on one device-I/O queue. +- Locked headphone property reads and bounded ambient/EQ values stored in the model. +- Fixed v2 devices remaining permanently “changed” because v1-only VPT state was included. +- Capability-gated optional setters in the C++ core, not only in the UI. +- Fixed interleaved headset notifications being mistaken for command acknowledgments. +- Tightened frame decoding to reject trailing bytes after the checksum. + +## Pass 3: integration + +- Added regression coverage for preamble noise and notification-before-ACK ordering. +- Protocol tests pass with `-Wall -Wextra -Werror`. +- The macOS Debug app builds successfully after the lifecycle and concurrency changes. +- `git diff --check` passes. + +## Pass 4: verification + +- Xcode static analysis passes after all fixes. +- The strict protocol suite passes after the final rescan. +- No deferred bug-hunt findings remain. Protocol conformance, fuzzing, security, hardware, UX, and release checks are tracked as separate ordered passes. diff --git a/docs/XM4_ROADMAP.md b/docs/XM4_ROADMAP.md new file mode 100644 index 00000000..03427d60 --- /dev/null +++ b/docs/XM4_ROADMAP.md @@ -0,0 +1,44 @@ +# WH-1000XM4 macOS Roadmap + +## Product direction + +Turn SonyBridge into a dependable, native macOS companion for the WH-1000XM4. The first milestone favors safe device control and honest capability detection over broad model claims. Firmware installation is intentionally out of scope. + +## Safety boundary + +Sony's first- and second-generation control protocols reuse some opcode values for different operations. In particular, `0x22` requests battery state on v2 devices but powers off a v1 device such as the WH-1000XM4. Every request and mutation must therefore be selected from the connected transport's confirmed protocol version. Unsupported features stay hidden until their read-only inquiry succeeds. + +## Milestones + +### 1. First-class XM4 protocol support + +- Initialize v1 devices before capability inquiries. +- Read battery, firmware, codec, equalizer, DSEE, Speak-to-Chat, and auto-power-off state with v1-specific commands. +- Normalize v1 and v2 equalizer values into one internal model. +- Parse v1 ANC and ambient responses so physical-button changes update the interface. +- Gate mutations behind successful read-only capability probes. +- Add regression coverage proving the XM4 path never emits the v2 battery opcode. + +### 2. Headset verification + +- Verify connection and initial state against a WH-1000XM4 on firmware 3.0.1. +- Exercise one control at a time and capture sanitized protocol diagnostics. +- Confirm state persists and remains synchronized after physical-button changes, reconnects, and idle link drops. + +### 3. Native Mac experience + +- Add a menu-bar surface for battery, ANC, Ambient Sound, and Speak-to-Chat. +- Keep the existing window for EQ and less-frequent settings. +- Add launch-at-login, automatic reconnect, clear loading states, and recoverable errors. +- Improve keyboard access, VoiceOver labels, contrast, and reduced-motion behavior. + +### 4. Distribution quality + +- Add unit tests for framing, protocol routing, parsing, and capability gating. +- Add CI for Apple Silicon and Intel builds. +- Rename the bundle identifier for the fork and document upstream attribution. +- Produce signed/notarized releases only when a dedicated identity is available. + +## Definition of done for the first milestone + +The app builds cleanly, connects to the paired XM4 without powering it off, displays the settings returned by the headset, exposes only verified controls, and safely handles disconnects and unanswered inquiries. diff --git a/docs/connected.png b/docs/connected.png index f5766a04..9f504ba3 100644 Binary files a/docs/connected.png and b/docs/connected.png differ diff --git a/docs/disconnected.png b/docs/disconnected.png deleted file mode 100644 index c1d518ec..00000000 Binary files a/docs/disconnected.png and /dev/null differ diff --git a/docs/index.html b/docs/index.html index d626c207..7f71cd18 100644 --- a/docs/index.html +++ b/docs/index.html @@ -215,7 +215,7 @@

Your Sony headphones,
finally on your desktop.
- $brew install --cask sonybridge + $brew install --cask amitrajput-dev/tap/sonybridge
MIT licensed · builds on the community SonyHeadphonesClient
@@ -337,8 +337,8 @@

Install in a minute.

macOS
-
$brew install --cask sonybridge
-
macOS 11+ · Apple Silicon & Intel · or download the .app
+
$brew install --cask amitrajput-dev/tap/sonybridge
+
macOS 12+ · Apple Silicon & Intel · or download the .app
diff --git a/docs/screenshots/native-glass-dark.png b/docs/screenshots/native-glass-dark.png new file mode 100644 index 00000000..431d0139 Binary files /dev/null and b/docs/screenshots/native-glass-dark.png differ diff --git a/docs/screenshots/native-glass-light.png b/docs/screenshots/native-glass-light.png new file mode 100644 index 00000000..8f2d8df6 Binary files /dev/null and b/docs/screenshots/native-glass-light.png differ diff --git a/docs/screenshots/panel-native-glass-light.png b/docs/screenshots/panel-native-glass-light.png new file mode 100644 index 00000000..eac18c3c Binary files /dev/null and b/docs/screenshots/panel-native-glass-light.png differ diff --git a/docs/screenshots/panel-refined-dark.png b/docs/screenshots/panel-refined-dark.png new file mode 100644 index 00000000..d95faae7 Binary files /dev/null and b/docs/screenshots/panel-refined-dark.png differ diff --git a/docs/screenshots/panel-sony-signature.png b/docs/screenshots/panel-sony-signature.png new file mode 100644 index 00000000..66a7ddcc Binary files /dev/null and b/docs/screenshots/panel-sony-signature.png differ diff --git a/docs/screenshots/refined-dark.png b/docs/screenshots/refined-dark.png new file mode 100644 index 00000000..5dbab0af Binary files /dev/null and b/docs/screenshots/refined-dark.png differ diff --git a/docs/screenshots/sony-signature.png b/docs/screenshots/sony-signature.png new file mode 100644 index 00000000..fd076a08 Binary files /dev/null and b/docs/screenshots/sony-signature.png differ diff --git a/docs/screenshots/themes.png b/docs/screenshots/themes.png new file mode 100644 index 00000000..979e6936 Binary files /dev/null and b/docs/screenshots/themes.png differ diff --git a/docs/screenshots/wide-refined-dark.png b/docs/screenshots/wide-refined-dark.png new file mode 100644 index 00000000..78eb6c5e Binary files /dev/null and b/docs/screenshots/wide-refined-dark.png differ diff --git a/docs/screenshots/xm6-ten-band-eq.png b/docs/screenshots/xm6-ten-band-eq.png new file mode 100644 index 00000000..83c773e1 Binary files /dev/null and b/docs/screenshots/xm6-ten-band-eq.png differ