diff --git a/.gitignore b/.gitignore index 32ccd080..67f143f9 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ coverage.xml .hypothesis/ .pytest_cache/ pytestdebug.log +*.actual # Translations *.mo diff --git a/Client/BluetoothWrapper.cpp b/Client/BluetoothWrapper.cpp index cd3cb744..c4b9231c 100644 --- a/Client/BluetoothWrapper.cpp +++ b/Client/BluetoothWrapper.cpp @@ -92,8 +92,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 < 16; 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,11 +128,17 @@ 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; + // Bytes outside START/END markers are transport noise, not message data. When a frame spans recv() + // calls, the next chunk starts inside the frame and therefore begins at byte zero. + size_t messageStart = ongoingMessage ? 0 : static_cast(numRecvd); + size_t messageEnd = static_cast(numRecvd); - for (size_t i = 0; i < numRecvd; i++) + for (int i = 0; i < numRecvd; i++) { if (buf[i] == START_MARKER) { @@ -146,6 +162,10 @@ CommandSerializer::Message BluetoothWrapper::_readMessage() } msgBytes.insert(msgBytes.end(), buf + messageStart, buf + messageEnd); + if (msgBytes.size() > MAX_BLUETOOTH_MESSAGE_SIZE) + { + throw RecoverableException("Bluetooth frame exceeds the maximum message size", true); + } } while (!messageFinished); auto msg = CommandSerializer::unpackBtMessage(msgBytes); @@ -160,4 +180,3 @@ CommandSerializer::Message BluetoothWrapper::_readMessage() return msg; } - 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/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..d10882eb 100644 --- a/Client/Constants.h +++ b/Client/Constants.h @@ -14,7 +14,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,6 +27,37 @@ 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 @@ -55,6 +86,20 @@ 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; + // Touch-sensor family. On the WH-ULT900N (ULT WEAR) subtype 0xd1 carries the multipoint + // ("Connect to 2 devices simultaneously") state; the enable bit is inverted (00=on, 01=off). + // Source: GadgetBridge SonyProtocolImplV2 setWideAreaTap/handleTouchSensor + getConnectTwoDevices + // (commits 8cbc309346, e8520865f3, 5d7b37d1b7 - hardware-verified against the WH-ULT900N). + inline constexpr unsigned char TOUCH_GET = 0xd6; // GET d6 -> RET d7 00 <00=on/01=off> + inline constexpr unsigned char TOUCH_RET = 0xd7; + inline constexpr unsigned char TOUCH_SET = 0xd8; // SET d8 00 <00=on/01=off> + inline constexpr unsigned char SUB_MULTIPOINT = 0xd1; + // System-control family (reboot=0x16, multipoint apply=0x06). Multipoint SETs are committed with + // the fixed frame 98 00 06 01 - identical bytes for both ON and OFF; the touch-sensor frame above + // carries the actual state. The matching RET (97 00 06 xx) always reports 01, so it is never used + // as a state source. + inline constexpr unsigned char SYSCTL_SET = 0x98; + inline constexpr unsigned char SUB_SYSCTL_MULTIPOINT = 0x06; } // v2 equalizer preset ids (byte value sent/received at EQ payload[2]). diff --git a/Client/Headphones.cpp b/Client/Headphones.cpp index 54d010f2..0f96f35f 100644 --- a/Client/Headphones.cpp +++ b/Client/Headphones.cpp @@ -18,11 +18,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 +36,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 +66,7 @@ void Headphones::setSurroundPosition(SOUND_POSITION_PRESET val) SOUND_POSITION_PRESET Headphones::getSurroundPosition() { + std::lock_guard guard(this->_propertyMtx); return this->_surroundPosition.current; } @@ -72,21 +78,40 @@ 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. + // Both protocol generations expect an init exchange before they answer state inquiries. + auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; this->_conn.sendCommandAndReadResponse( - { (char)V2Command::INIT_REQUEST, 0x00 }, - V2Command::INIT_REPLY + { (char)(isV1 ? V1Command::INIT_REQUEST : V2Command::INIT_REQUEST), 0x00 }, + isV1 ? V1Command::INIT_REPLY : V2Command::INIT_REPLY ); } 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; + } + // Single battery (over-ear and most models): GET 22 00 -> RET 23 00 try { auto resp = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BATTERY_GET, 0x00 }, V2Command::BATTERY_RET, 0x00); @@ -94,6 +119,7 @@ void Headphones::requestBattery() std::lock_guard guard(this->_propertyMtx); this->_batteryLevel = (unsigned char)resp[2]; this->_batteryCharging = resp[3] == 1; + this->_hasBattery = true; return; // found a single battery; don't waste time probing the TWS types } } catch (...) {} @@ -107,6 +133,7 @@ void Headphones::requestBattery() this->_batteryLeft = (unsigned char)resp[2]; this->_batteryRight = (unsigned char)resp[4]; this->_batteryLevel = std::min(this->_batteryLeft, this->_batteryRight); + this->_hasBattery = true; } } catch (...) {} @@ -119,32 +146,42 @@ void Headphones::requestBattery() } 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; } void Headphones::requestEqualizer() { - // GET: 56 00 -> RET: 57 00 06 + auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; + 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,18 +194,24 @@ 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. this->_conn.sendCommand({ - (char)V2Command::EQ_SET, - 0x00, - (char)static_cast(preset), + (char)(isV1 ? V1Command::EQ_SET : V2Command::EQ_SET), + (char)(isV1 ? 0x01 : 0x00), + (char)rawPreset, 0x00 }); std::lock_guard guard(this->_propertyMtx); @@ -177,12 +220,14 @@ void Headphones::setEqualizerPreset(EQ_PRESET preset) 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; + // 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 +239,62 @@ 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; } 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; } @@ -266,13 +323,27 @@ void Headphones::requestAmbientState() } 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 +383,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; } @@ -336,107 +431,180 @@ void Headphones::probeCapabilities() auto r = this->_conn.sendCommandAndReadResponse({ (char)V2Command::BTNMODE_GET, (char)V2Command::SUB_SPEAK_TO_CHAT }, V2Command::BTNMODE_RET, V2Command::SUB_SPEAK_TO_CHAT); if (r.size() >= 3) { std::lock_guard g(this->_propertyMtx); this->_speakToChat = (r[2] == 0); this->_hasSpeakToChat = true; } } catch (...) {} + + // Multipoint state rides the touch-sensor 0xd1 subtype (GET d6 d1 -> RET d7 d1 00 <00=on/01=off>); + // the system-control RET (97 00 06) always reports 01 and cannot be used as the state source. + // Proven on the WH-ULT900N by GadgetBridge (SonyProtocolImplV2 getConnectTwoDevices/handleTouchSensor, + // commits 8cbc309346..5d7b37d1b7). Other v2 models may alias 0xd1 to a real touch gesture instead, + // so exposure stays probe-gated and the label is only shown when the device answers this GET. + try { + auto r = this->_conn.sendCommandAndReadResponse({ (char)V2Command::TOUCH_GET, (char)V2Command::SUB_MULTIPOINT }, V2Command::TOUCH_RET, V2Command::SUB_MULTIPOINT); + if (r.size() >= 4) { std::lock_guard g(this->_propertyMtx); this->_multipoint = (r[3] == 0); this->_hasMultipoint = true; } + } 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; } +bool Headphones::hasMultipoint() { std::lock_guard guard(this->_propertyMtx); return this->_hasMultipoint; } +bool Headphones::getMultipoint() { std::lock_guard guard(this->_propertyMtx); return this->_multipoint; } +void Headphones::setMultipoint(bool enabled) +{ + if (this->_conn.getProtocolVersion() != SonyProtocolVersion::V2 || !this->hasMultipoint()) + throw std::logic_error("Multipoint capability has not been confirmed"); + // GadgetBridge's proven order (SonyHeadphonesProtocol PREF_SONY_CONNECT_TWO_DEVICES case): the + // state frame goes first (d8 d1 00 <00=on/01=off>, enable bit inverted), then the fixed apply + // commit 98 00 06 01 - identical bytes for both directions. sendCommand waits for the ACK, so + // two sequential sends reproduce GadgetBridge's frame/ack interleaving. + this->_conn.sendCommand({ (char)V2Command::TOUCH_SET, (char)V2Command::SUB_MULTIPOINT, 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->_multipoint = enabled; +} + 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 +616,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..9788295c 100644 --- a/Client/Headphones.h +++ b/Client/Headphones.h @@ -34,12 +34,13 @@ 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. + // Battery. getBatteryLevel() returns -1 until requestBattery() succeeds. // For TWS earbuds requestBattery() also fills the per-earbud + case levels (hasDualBattery()). void requestBattery(); + bool hasBattery(); int getBatteryLevel(); bool isBatteryCharging(); bool hasDualBattery(); @@ -47,8 +48,9 @@ class Headphones { int getBatteryRight(); int getBatteryCase(); - // 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. @@ -56,8 +58,9 @@ class Headphones { int getClearBass(); int getEqualizerBand(int index); // 0..4 - // DSEE / audio upsampling (v2). + // DSEE / audio upsampling. void requestDsee(); + bool hasDsee(); bool getDsee(); void setDsee(bool enabled); @@ -86,31 +89,43 @@ class Headphones { bool getAdaptiveVolume(); void setAdaptiveVolume(bool enabled); + // Multipoint ("Connect to 2 devices simultaneously"). v2 only; the setting is device-persistent, + // so the app never enforces a default - the toggle just reads and writes the on-device state. + bool hasMultipoint(); + bool getMultipoint(); + void setMultipoint(bool enabled); + bool isChanged(); void setChanges(); 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; 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; bool _hasCodec = false; std::string _codec; bool _hasSpeakToChat = false; bool _speakToChat = false; bool _hasAdaptiveVolume = false; bool _adaptiveVolume = false; + bool _hasMultipoint = false; bool _multipoint = false; std::mutex _propertyMtx; 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/ContentView.swift b/Client/macos/ContentView.swift index 45b0388a..d98952c3 100644 --- a/Client/macos/ContentView.swift +++ b/Client/macos/ContentView.swift @@ -77,9 +77,11 @@ struct ContentView: View { } if model.supportsEqualizer { equalizerCard + } + if model.supportsDsee { dseeCard } - if model.hasAdaptiveVolume || model.hasSpeakToChat || model.hasAutoPowerOff { + if model.hasAdaptiveVolume || model.hasSpeakToChat || model.hasAutoPowerOff || model.hasMultipoint { settingsCard } if let error = model.errorMessage { @@ -351,6 +353,18 @@ struct ContentView: View { if model.hasSpeakToChat { settingToggle("Speak-to-Chat", "Pause music when you talk", on: model.speakToChat) { model.setSpeakToChat($0) } + Toggle(isOn: $model.keepSpeakToChatOff) { + Text("Keep off on connect") + .font(.system(size: 11)) + .foregroundColor(Theme.secondary) + } + .toggleStyle(SwitchToggleStyle(tint: Theme.accent)) + .controlSize(.small) + .padding(.leading, 12) + } + if model.hasMultipoint { + settingToggle("Connect to 2 devices", "Stay paired to two devices at once", + on: model.multipoint) { model.setMultipoint($0) } } if model.hasAutoPowerOff { HStack { diff --git a/Client/macos/HeadphonesBridge.h b/Client/macos/HeadphonesBridge.h index dfbdd039..4a67adf4 100644 --- a/Client/macos/HeadphonesBridge.h +++ b/Client/macos/HeadphonesBridge.h @@ -23,7 +23,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; @@ -38,8 +38,9 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) NSInteger batteryRight; @property (nonatomic, readonly) NSInteger batteryCase; @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) 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). @@ -53,6 +54,8 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) BOOL speakToChat; @property (nonatomic, readonly) BOOL hasAdaptiveVolume; @property (nonatomic, readonly) BOOL adaptiveVolume; +@property (nonatomic, readonly) BOOL hasMultipoint; // v2 "Connect to 2 devices simultaneously" +@property (nonatomic, readonly) BOOL multipoint; // Runs the native Bluetooth device picker (modal, main thread) and connects to the chosen device. // completion is called on the main thread. @@ -84,6 +87,7 @@ 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. diff --git a/Client/macos/HeadphonesBridge.mm b/Client/macos/HeadphonesBridge.mm index 4f1b58d3..77e24c99 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" @@ -16,18 +17,21 @@ @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; + // 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 +60,7 @@ - (BOOL)supportsVpt { } - (NSInteger)maxAmbientLevel { - if (self.connected && _bt->getProtocolVersion() == SonyProtocolVersion::V2) return 20; - return 19; + return 20; } - (SHCAmbientMode)mode { @@ -96,7 +99,7 @@ - (NSInteger)eqPreset { } - (BOOL)supportsEqualizer { - return self.connected && _bt->getProtocolVersion() == SonyProtocolVersion::V2; + return _hp && _hp->hasEqualizer(); } - (NSInteger)clearBass { @@ -107,6 +110,10 @@ - (BOOL)dsee { return _hp ? _hp->getDsee() : NO; } +- (BOOL)supportsDsee { + return _hp && _hp->hasDsee(); +} + - (NSInteger)equalizerBandAtIndex:(NSInteger)index { return _hp ? _hp->getEqualizerBand((int)index) : 0; } @@ -121,6 +128,8 @@ - (BOOL)hasSpeakToChat { return _hp && _hp->hasSpeakToChat(); } - (BOOL)speakToChat { return _hp && _hp->getSpeakToChat(); } - (BOOL)hasAdaptiveVolume { return _hp && _hp->hasAdaptiveVolume(); } - (BOOL)adaptiveVolume { return _hp && _hp->getAdaptiveVolume(); } +- (BOOL)hasMultipoint { return _hp && _hp->hasMultipoint(); } +- (BOOL)multipoint { return _hp && _hp->getMultipoint(); } static BOOL SHCLooksLikeSonyHeadset(NSString *name) { if (name.length == 0) return NO; @@ -157,8 +166,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 +197,7 @@ - (void)scanAndConnectWithCompletion:(void (^)(BOOL, NSString * _Nullable))compl _deviceName = [device nameOrAddress]; _deviceMac = [device addressString]; - _hp = std::make_unique(*_bt); + _hp = std::make_shared(*_bt); completion(YES, nil); } @@ -189,36 +206,22 @@ - (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 @@ -230,8 +233,8 @@ - (void)refreshStatusWithCompletion:(void (^)(void))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; + 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) {} @@ -244,12 +247,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 +268,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 +286,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 +309,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; @@ -344,7 +345,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 +355,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 +365,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 +373,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..280005eb 100644 --- a/Client/macos/HeadphonesModel.swift +++ b/Client/macos/HeadphonesModel.swift @@ -6,6 +6,8 @@ import Foundation import Combine +private let keepSpeakToChatOffKey = "KeepSpeakToChatOffOnConnect" + final class HeadphonesModel: ObservableObject { private let bridge = HeadphonesBridge() @@ -30,6 +32,7 @@ final class HeadphonesModel: ObservableObject { @Published var supportsEqualizer = false @Published var eqBands = [0, 0, 0, 0, 0] @Published var clearBass = 0 + @Published var supportsDsee = false @Published var dsee = false @Published var hasAutoPowerOff = false @@ -38,8 +41,17 @@ 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 + // Multipoint ("Connect to 2 devices"). Device-persistent - no app-enforced default; the + // toggle just mirrors the on-device state read by the capability probe. + @Published var hasMultipoint = false + @Published var multipoint = false @Published var deviceMac = "" @Published var protocolVersion = "" @@ -48,6 +60,19 @@ final class HeadphonesModel: ObservableObject { 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 +81,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() @@ -76,7 +102,9 @@ final class HeadphonesModel: ObservableObject { self.batteryRight = self.bridge.batteryRight self.batteryCase = self.bridge.batteryCase 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.hasAutoPowerOff = self.bridge.hasAutoPowerOff @@ -87,6 +115,17 @@ final class HeadphonesModel: ObservableObject { self.speakToChat = self.bridge.speakToChat self.hasAdaptiveVolume = self.bridge.hasAdaptiveVolume self.adaptiveVolume = self.bridge.adaptiveVolume + self.hasMultipoint = self.bridge.hasMultipoint + self.multipoint = self.bridge.multipoint + // 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) + } + } } } @@ -108,19 +147,36 @@ final class HeadphonesModel: ObservableObject { bridge.setAdaptiveVolume(on) { ok, error in if !ok, let e = error { self.errorMessage = e } } } + func setMultipoint(_ on: Bool) { + multipoint = on + errorMessage = nil + bridge.setMultipoint(on) { ok, error in if !ok, let e = error { self.errorMessage = e } } + } + + // Every @Published setter fires objectWillChange — and a full ContentView body + // re-evaluation — even when the value is unchanged. The 2s poll mostly re-reads + // cached bridge values, so gate writes to real changes. + private func assignIfChanged(_ 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() dynamicTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in guard let self = self, self.connected else { return } self.bridge.refreshDynamic { - self.mode = self.bridge.mode + 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.assignIfChanged(\.eqPreset, self.bridge.eqPreset) + self.assignIfChanged(\.supportsEqualizer, self.bridge.supportsEqualizer) + self.assignIfChanged(\.clearBass, self.bridge.clearBass) + self.assignIfChanged(\.supportsDsee, self.bridge.supportsDsee) + self.assignIfChanged(\.dsee, self.bridge.dsee) + self.assignIfChanged(\.eqBands, (0..<5).map { self.bridge.equalizerBand(at: $0) }) } } } @@ -131,6 +187,10 @@ final class HeadphonesModel: ObservableObject { } func setEqualizer(_ preset: Int) { + if preset == 0xA0 { + applyCustomEq() + return + } eqPreset = preset errorMessage = nil bridge.setEqualizerPreset(preset) { ok, error in @@ -142,7 +202,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 } } } @@ -158,6 +235,9 @@ final class HeadphonesModel: ObservableObject { func disconnect() { stopWatchingConnection() stopDynamicPolling() + pendingPush = nil + pendingEq = nil + s2cAutoDisablePending = false bridge.disconnect() connected = false deviceName = "" @@ -201,7 +281,24 @@ final class HeadphonesModel: ObservableObject { private func pushState() { errorMessage = nil - bridge.applyMode(mode, level: ambientLevel, focusVoice: focusOnVoice) { ok, error in + 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 + } + } if ok { self.syncFromBridge() } else if let error = error { @@ -211,17 +308,18 @@ final class HeadphonesModel: ObservableObject { } 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) } } diff --git a/Client/macos/MacOSBluetoothConnector.h b/Client/macos/MacOSBluetoothConnector.h index 186b06a4..bcb5a898 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,9 @@ class MacOSBluetoothConnector final : public IBluetoothConnector SonyProtocolVersion protocolVersion = SonyProtocolVersion::V1; private: - void *rfcommDevice; - void *rfcommchannel; + void *rfcommDevice = nullptr; + std::atomic rfcommchannel{nullptr}; 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..6e863454 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,9 @@ 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 = (__bridge IOBluetoothRFCOMMChannel*)rfcommchannel.load(); + if (channel == nil) throw RecoverableException("Bluetooth connection is closed", false); + [channel writeSync:(void*)buf length:length]; return (int)length; } @@ -116,38 +111,42 @@ 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; + macOSBluetoothConnector->rfcommchannel.store((__bridge void*) 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 +162,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 +207,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 +264,57 @@ 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 = (__bridge IOBluetoothRFCOMMChannel*)rfcommchannel.exchange(nullptr); + if (chan == nil) return; [chan setDelegate: nil]; // close the channel [chan closeChannel]; } +void MacOSBluetoothConnector::handleChannelClosed() noexcept +{ + rfcommchannel.store(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; + IOBluetoothRFCOMMChannel *chan = (__bridge IOBluetoothRFCOMMChannel*)rfcommchannel.load(); + return chan != nil && chan.isOpen; } diff --git a/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj b/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj index 2ce1c62e..7079b2ce 100644 --- a/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj +++ b/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj @@ -302,11 +302,13 @@ 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; @@ -378,6 +380,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/tests/HeadphonesProtocolTests.cpp b/Client/tests/HeadphonesProtocolTests.cpp new file mode 100644 index 00000000..10320969 --- /dev/null +++ b/Client/tests/HeadphonesProtocolTests.cpp @@ -0,0 +1,305 @@ +#include "BluetoothWrapper.h" +#include "CommandSerializer.h" +#include "Exceptions.h" +#include "Headphones.h" + +#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 that supports multipoint the way GadgetBridge proved it on the WH-ULT900N: +// only the touch-sensor 0xd1 GET answers with the multipoint state; every other capability probe +// times out (recv on an empty queue throws), which the probe treats 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); + // GET d6 d1 -> RET d7 d1 00 <00=on/01=off> (GadgetBridge SonyProtocolImplV2 handleTouchSensor; + // the enable bit is inverted). The fake reports multipoint currently ON. + if (message.payload.size() >= 2 && (unsigned char)message.payload[0] == V2Command::TOUCH_GET + && (unsigned char)message.payload[1] == V2Command::SUB_MULTIPOINT) + queue({ (char)V2Command::TOUCH_RET, (char)V2Command::SUB_MULTIPOINT, 0x00, 0x00 }, 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; + +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; +}; + +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. + require(!headphones.hasMultipoint(), "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::TOUCH_GET || + (unsigned char)payload[0] == V2Command::TOUCH_SET || + (unsigned char)payload[0] == V2Command::SYSCTL_SET); + }); + require(!hasMultipointFrame, "XM4 path emitted a v2 multipoint frame (d6/d8/98)"); + bool multipointRejected = false; + try { headphones.setMultipoint(true); } + catch (const std::logic_error&) { 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) --- + auto fakeV2 = std::make_unique(); + auto* observedV2 = fakeV2.get(); + BluetoothWrapper bluetoothV2(std::move(fakeV2)); + Headphones ult900n(bluetoothV2); + ult900n.probeCapabilities(); + + require(ult900n.hasMultipoint(), "v2 multipoint probe (d6 d1) was not recorded"); + require(ult900n.getMultipoint(), "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.getMultipoint(), "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.getMultipoint(), "multipoint ON was not mirrored locally"); + + // 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 << "WH-1000XM4 protocol safety 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/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.