diff --git a/Client/Constants.h b/Client/Constants.h index 7a609e71..eff86d8e 100644 --- a/Client/Constants.h +++ b/Client/Constants.h @@ -113,6 +113,18 @@ namespace V2Command inline constexpr unsigned char NCASM_TYPE_XM6 = 0x19; // Ten-band equalizer inquired type (WH-1000XM6): 56/57/58 04, 10 raw bands 0..12, no clear bass. inline constexpr unsigned char EQ_TEN_BAND = 0x04; + // EQ + ULT mode inquired type (ULT WEAR / WH-ULT900N): the ULT button's bass mode lives next to the EQ. + // GET 56 03 -> RET 57 03 <400..16k +10> + // SET 58 03 + // Framing from Sound Connect's EqEbbInquiredType::PRESET_EQ_AND_ULT_MODE / EqParamUltMode, as generated in + // mos9527/SonyHeadphonesClient (libmdr ProtocolV2T1.hpp). Only sent after the device answered the GET. + // Verified on a ULT WEAR (fw 2.1.0): 56/58 03 use the usual clear-bass-first order, but this device's 00 + // form is one slot off. Its 57 00 reported <400> <1k> <2.5k> <6.3k> (57 03 read + // 57 03 a0 01 06 11 0c 08 0b 0d 0f while 57 00 decoded "400..16k" as +7 +2 -2 +1 +3), the 58 00 clear-bass + // slot changed nothing, and writing the sixth 58 03 byte moved the treble. So on ULT devices the EQ is + // read and written through 56/58 03 only (presets still use 58 00 00). + inline constexpr unsigned char EQ_ULT_MODE = 0x03; + inline constexpr unsigned char EQ_ULT_BAND_COUNT = 0x06; // five bands + clear bass // INIT_REPLY layout: 01 inline constexpr size_t INIT_REPLY_TABLE2_INDEX = 7; // General settings: up to four device-defined boolean/list settings in slots D1..D4. Which slot holds @@ -185,6 +197,14 @@ enum class EQ_PRESET : unsigned char MANUAL = 0xa0 }; +// ULT bass mode (EqUltModeStatus), the same three states the headset's ULT button cycles through. +enum class ULT_MODE : unsigned char +{ + OFF = 0x00, + ULT1 = 0x01, + ULT2 = 0x02 +}; + #define APP_NAME "SonyBridge " __HEADPHONES_APP_VERSION__ #define APP_NAME_W (L"" APP_NAME) diff --git a/Client/CrossPlatformGUI.cpp b/Client/CrossPlatformGUI.cpp index 7b52bda7..dd3c2095 100644 --- a/Client/CrossPlatformGUI.cpp +++ b/Client/CrossPlatformGUI.cpp @@ -50,6 +50,7 @@ bool CrossPlatformGUI::performGUIPass() this->_drawASMControls(); if (this->_isV2()) { + this->_drawUltMode(); this->_drawEqualizer(); this->_drawDsee(); } @@ -228,6 +229,15 @@ void CrossPlatformGUI::_pumpConnectionState() this->_uiAutoPowerOff = this->_headphones.getAutoPowerOff(); this->_uiSpeakToChat = this->_headphones.getSpeakToChat(); this->_uiAdaptiveVolume = this->_headphones.getAdaptiveVolume(); + this->_uiUltMode = (int)this->_headphones.getUltMode(); + // On ULT devices the probe's 57 03 read is the first to carry the real clear bass (57 00 doesn't). + if (this->_headphones.hasUltMode() && !this->_featureCommandFuture.valid()) + { + this->_uiEqPreset = (int)(unsigned char)this->_headphones.getEqualizerPreset(); + for (int i = 0; i < 5; ++i) + this->_uiEqBands[i] = this->_headphones.getEqualizerBand(i); + this->_uiClearBass = this->_headphones.getClearBass(); + } } // Poll the button-changeable ASM state so the app reflects changes made on the headphone itself. @@ -242,6 +252,13 @@ void CrossPlatformGUI::_pumpConnectionState() if (lvl > 0) this->_uiAsmLevel = lvl; this->_uiFocusOnVoice = this->_headphones.getFocusOnVoice(); } + // The ULT button changes ULT mode on the headset; don't let a read that predates a pending write win. + if (!this->_featureCommandFuture.valid() && !this->_ultModeNeedsSync) + this->_uiUltMode = (int)this->_headphones.getUltMode(); + // The same read carries clear bass (e.g. changed in the phone app); leave it alone mid-drag or mid-write. + if (this->_headphones.hasUltClearBass() && !this->_featureCommandFuture.valid() && !this->_clearBassDirty + && !ImGui::IsAnyItemActive()) + this->_uiClearBass = this->_headphones.getClearBass(); } if (!this->_pollFuture.valid() && ++this->_pollCounter >= DYNAMIC_POLL_FRAMES) { @@ -260,6 +277,11 @@ void CrossPlatformGUI::_pumpConnectionState() this->_pollFuture.setFromAsync([this, withBattery]() { this->_headphones.requestAmbientState(); + // ULT devices only: the probe (phase 2) decides, so other headsets never see this inquiry. + if (this->_headphones.hasUltMode()) + { + try { this->_headphones.requestUltMode(); } catch (const std::exception&) {} + } // Best effort: a device that doesn't answer the battery inquiry must not kill the poll. if (withBattery) { @@ -282,6 +304,7 @@ void CrossPlatformGUI::_syncUIFromHeadphones() this->_uiEqBands[i] = this->_headphones.getEqualizerBand(i); this->_uiClearBass = this->_headphones.getClearBass(); this->_uiDsee = this->_headphones.getDsee(); + this->_uiUltMode = (int)this->_headphones.getUltMode(); this->_uiAutoPowerOff = this->_headphones.getAutoPowerOff(); this->_uiSpeakToChat = this->_headphones.getSpeakToChat(); @@ -314,6 +337,9 @@ const CrossPlatformGUI::DeviceTexture& CrossPlatformGUI::_deviceTexture(const st std::string slug = name; std::transform(slug.begin(), slug.end(), slug.begin(), [](unsigned char ch) { return (char)std::tolower(ch); }); std::replace(slug.begin(), slug.end(), ' ', '-'); + // The ULT WEAR (WH-ULT900N) advertises itself as "Sony ULT WEAR", not by model number. + if (slug.find("ult-wear") != std::string::npos || slug.find("ult900") != std::string::npos) + slug = "wh-ult900n"; auto it = this->_deviceTextures.find(slug); if (it != this->_deviceTextures.end()) @@ -464,6 +490,44 @@ void CrossPlatformGUI::_drawASMControls() this->_endCard(); } +void CrossPlatformGUI::_drawUltMode() +{ + // Only shown once probeCapabilities() saw the headset answer the ULT read (ULT WEAR). + if (!this->_headphones.hasUltMode()) return; + if (!this->_beginCard("##ult")) { this->_endCard(); return; } + // Sound Connect's "Bass Control" on the ULT WEAR: the ULT mode (same as the ULT button) plus Clear Bass. + this->_cardTitle("Bass Control"); + ImGui::TextDisabled("ULT mode, same as the ULT button"); + + static const struct { const char* name; int val; } modes[] = { { "Off", 0 }, { "ULT 1", 1 }, { "ULT 2", 2 } }; + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float cellW = (ImGui::GetContentRegionAvail().x - spacing * 2.f) / 3.f; + for (int i = 0; i < 3; ++i) + { + if (i) ImGui::SameLine(); + bool sel = this->_uiUltMode == modes[i].val; + if (sel) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_ButtonActive]); + if (ImGui::Button(modes[i].name, ImVec2(cellW, 0)) && !sel) + { + this->_uiUltMode = modes[i].val; + this->_ultModeNeedsSync = true; + ULT_MODE mode = (ULT_MODE)modes[i].val; + this->_sendFeatureCommand([this, mode]() { this->_headphones.setUltMode(mode); }); + } + if (sel) ImGui::PopStyleColor(); + } + + if (this->_headphones.hasUltClearBass()) + { + ImGui::Spacing(); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + if (ImGui::SliderInt("##clearbass", &this->_uiClearBass, -10, 10, "Clear Bass %+d")) + this->_clearBassDirty = true; + } + + this->_endCard(); +} + void CrossPlatformGUI::_drawEqualizer() { if (!this->_beginCard("##eq")) { this->_endCard(); return; } @@ -500,7 +564,8 @@ void CrossPlatformGUI::_drawEqualizer() int* values[] = { &this->_uiClearBass, &this->_uiEqBands[0], &this->_uiEqBands[1], &this->_uiEqBands[2], &this->_uiEqBands[3], &this->_uiEqBands[4] }; bool changed = false; - for (int i = 0; i < 6; ++i) + // On the ULT WEAR clear bass sits in the Bass Control card instead, as in Sony's app. + for (int i = this->_headphones.hasUltClearBass() ? 1 : 0; i < 6; ++i) { std::string id = "##eqband" + std::to_string(i); std::string fmt = std::string(bandNames[i]) + " %+d"; @@ -618,6 +683,19 @@ void CrossPlatformGUI::_sendPendingASMChanges() this->_uiClearBass = this->_headphones.getClearBass(); } } + // A ULT click is synced back once no feature write is in flight: on success the device's mode is the one + // clicked, on failure (or a click dropped while the worker was busy) it's what the headset really has. + if (this->_clearBassDirty && !this->_featureCommandFuture.valid()) + { + this->_clearBassDirty = false; + int cb = this->_uiClearBass; + this->_sendFeatureCommand([this, cb]() { this->_headphones.setClearBass(cb); }); + } + if (this->_ultModeNeedsSync && !this->_featureCommandFuture.valid()) + { + this->_ultModeNeedsSync = false; + this->_uiUltMode = (int)this->_headphones.getUltMode(); + } if (this->_sendCommandFuture.valid() && this->_sendCommandFuture.ready()) { diff --git a/Client/CrossPlatformGUI.h b/Client/CrossPlatformGUI.h index aceac4a0..bad98c4c 100644 --- a/Client/CrossPlatformGUI.h +++ b/Client/CrossPlatformGUI.h @@ -45,6 +45,7 @@ class CrossPlatformGUI void _drawDeviceDiscovery(); void _drawStatusHeader(); void _drawASMControls(); + void _drawUltMode(); void _drawEqualizer(); void _drawDsee(); void _drawOptionalFeatures(); @@ -98,6 +99,13 @@ class CrossPlatformGUI // that preset once it completes instead of keeping the previous preset's. bool _eqBandsNeedSync = false; bool _uiDsee = false; + int _uiUltMode = 0; // 0 = Off, 1 = ULT 1, 2 = ULT 2 + // Set when a ULT write was requested, so the control takes the device's value once the feature worker + // is done (or immediately falls back to it if the click was dropped because the worker was busy). + bool _ultModeNeedsSync = false; + // Set when the Clear Bass slider moved and the value hasn't been handed to the worker yet; the latest value + // is sent as soon as the worker is free, so the last position of a drag always reaches the headset. + bool _clearBassDirty = false; int _uiAutoPowerOff = 0; bool _uiSpeakToChat = false; bool _uiAdaptiveVolume = false; diff --git a/Client/Headphones.cpp b/Client/Headphones.cpp index 10b20c64..f1430ceb 100644 --- a/Client/Headphones.cpp +++ b/Client/Headphones.cpp @@ -7,6 +7,23 @@ #include #include +namespace +{ + std::string hexString(const Buffer& bytes) + { + static const char digits[] = "0123456789abcdef"; + std::string out; + for (size_t i = 0; i < bytes.size(); i++) + { + auto b = (unsigned char)bytes[i]; + if (i > 0) out += ' '; + out += digits[b >> 4]; + out += digits[b & 0x0f]; + } + return out; + } +} + Headphones::Headphones(BluetoothWrapper& conn) : _conn(conn) { } @@ -265,6 +282,8 @@ bool Headphones::isBatteryCaseCharging() { std::lock_guard guard(this->_property void Headphones::requestEqualizer() { auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; + // ULT WEAR: read through 56 03 instead (its 57 00 slots are shifted by one). + if (this->_isUltWear() && this->_requestUltEqualizer()) return; if (!isV1 && this->_isWh1000Xm6()) { // GET 56 04 -> RET 57 04 , raw device steps 0..12 with no bias and no @@ -352,6 +371,12 @@ void Headphones::setEqualizerCustom(int clearBass, const std::vector& bands { if (!this->hasEqualizer()) throw std::logic_error("Equalizer capability has not been confirmed"); auto isV1 = this->_conn.getProtocolVersion() == SonyProtocolVersion::V1; + // ULT WEAR: write through 58 03 instead (its 58 00 slots are shifted by one, and clear bass is ignored). + if (this->_isUltWear() && this->hasUltClearBass()) + { + this->_setUltEqualizerCustom(clearBass, bands); + return; + } if (!isV1 && this->_isWh1000Xm6()) { // SET 58 04 a0 , raw 0..12, no clear-bass slot. @@ -456,6 +481,173 @@ void Headphones::setDsee(bool enabled) this->_dsee = enabled; } +// --- ULT WEAR (WH-ULT900N) only: ULT mode, Clear Bass, and its equalizer path --- +// Nothing below runs for any other model: _readUltModeParam() is the only sender of 56 03 and bails out unless +// _isUltWear(), and every 58 03 write needs a confirmed 56 03 reply first. + +bool Headphones::_isUltWear() const +{ + if (this->_conn.getProtocolVersion() != SonyProtocolVersion::V2) return false; + std::string name; + for (char c : this->_deviceName) name.push_back((char)std::tolower((unsigned char)c)); + // Matches "Sony ULT WEAR", "ULT WEAR", "WH-ULT900N" (Gadgetbridge matches ^Sony ULT|ULT WEAR). + return name.rfind("sony ult", 0) == 0 || name.find("ult wear") != std::string::npos + || name.find("ult900") != std::string::npos; +} + +bool Headphones::_readUltModeParam(Buffer& replyOut, bool rethrow) +{ + if (!this->_isUltWear()) return false; + const Buffer request = { (char)V2Command::EQ_GET, (char)V2Command::EQ_ULT_MODE }; + Buffer resp; + try + { + resp = this->_conn.sendCommandAndReadResponse(request, V2Command::EQ_RET, V2Command::EQ_ULT_MODE); + } + catch (const std::exception& exc) + { + { + std::lock_guard guard(this->_propertyMtx); + this->_ultModeLog = this->_ultModeLastOk + "T1 " + hexString(request) + " -> (" + exc.what() + ")\n"; + } + if (rethrow) throw; + return false; + } + + std::lock_guard guard(this->_propertyMtx); + this->_ultModeLastOk = "T1 " + hexString(request) + " -> " + hexString(resp) + "\n"; + this->_ultModeLog = this->_ultModeLastOk; + // 57 03 [ ]. Only the first four bytes are needed to know the mode; + // anything past 02 is not a ULT status we know, so the reply isn't taken as one. + if (resp.size() < 4 || (unsigned char)resp[3] > (unsigned char)ULT_MODE::ULT2) return false; + this->_hasUltMode = true; + this->_ultMode = static_cast((unsigned char)resp[3]); + // With the full curve attached (count 06: clear bass then 400..16k, each +10), this reply is also the + // equalizer state - the one place a ULT WEAR reports it in the right slots. + if (resp.size() >= 5 + (size_t)V2Command::EQ_ULT_BAND_COUNT && (unsigned char)resp[4] == V2Command::EQ_ULT_BAND_COUNT) + { + this->_ultEqLayout = true; + this->_hasEqualizer = true; + this->_eqPreset = static_cast((unsigned char)resp[2]); + this->_eqClearBass = (unsigned char)resp[5] - 10; + this->_eqBands.assign(5, 0); + for (int i = 0; i < 5; i++) this->_eqBands[i] = (unsigned char)resp[6 + i] - 10; + } + replyOut = resp; + return true; +} + +bool Headphones::_requestUltEqualizer() +{ + bool layoutKnown; + { + std::lock_guard guard(this->_propertyMtx); + layoutKnown = this->_ultEqLayout; + } + Buffer reply; + if (layoutKnown) + { + // _readUltModeParam() refreshes the EQ cache; a transport failure propagates like the 56 00 read's. + this->_readUltModeParam(reply, true); + return true; + } + // First read, before probeCapabilities() has run: try 56 03 so the sliders never show the shifted 57 00 + // slots and a Manual write can't go out through 58 00. No band bytes -> the caller falls back to 56 00. + this->_readUltModeParam(reply); + std::lock_guard guard(this->_propertyMtx); + return this->_ultEqLayout; +} + +void Headphones::_setUltEqualizerCustom(int clearBass, const std::vector& bands) +{ + // SET 58 03 a0 06 <400..16k +10>. The SET carries the ULT mode too; the cached one is + // kept current by the 2 s poll, which is fresh enough for a slider drag (re-reading per write would halve its + // rate). + ULT_MODE ultMode; + { + std::lock_guard guard(this->_propertyMtx); + ultMode = this->_ultMode; + } + auto bias = [](int v) { return (char)(unsigned char)(std::clamp(v, -10, 10) + 10); }; + Buffer cmd = { (char)V2Command::EQ_SET, (char)V2Command::EQ_ULT_MODE, + (char)static_cast(EQ_PRESET::MANUAL), (char)(unsigned char)ultMode, + (char)V2Command::EQ_ULT_BAND_COUNT, bias(clearBass) }; + for (int i = 0; i < 5; i++) cmd.push_back(bias(i < (int)bands.size() ? bands[i] : 0)); + this->_conn.sendCommand(cmd); + + std::lock_guard guard(this->_propertyMtx); + this->_eqPreset = EQ_PRESET::MANUAL; + this->_eqClearBass = std::clamp(clearBass, -10, 10); + this->_eqBands.assign(5, 0); + for (int i = 0; i < 5; i++) this->_eqBands[i] = std::clamp(i < (int)bands.size() ? bands[i] : 0, -10, 10); +} + +void Headphones::requestUltMode() +{ + Buffer reply; + this->_readUltModeParam(reply); +} + +bool Headphones::hasUltMode() { std::lock_guard guard(this->_propertyMtx); return this->_hasUltMode; } +bool Headphones::hasUltClearBass() { std::lock_guard guard(this->_propertyMtx); return this->_ultEqLayout; } + +void Headphones::setClearBass(int clearBass) +{ + if (!this->hasUltClearBass()) throw std::logic_error("Clear bass capability has not been confirmed"); + + // Fresh read: the SET carries the preset, ULT mode and the other five bands, any of which may have changed + // since (ULT button, the phone app). Those go back byte for byte; only the clear-bass byte is ours. + Buffer current; + if (!this->_readUltModeParam(current, true)) + throw RecoverableException("The headset didn't report its current equalizer, so Clear Bass wasn't changed."); + const size_t count = V2Command::EQ_ULT_BAND_COUNT; + if (current.size() < 5 + count || (unsigned char)current[4] != count) + throw RecoverableException("Clear Bass can't be changed with the current equalizer preset."); + + Buffer cmd = { (char)V2Command::EQ_SET, (char)V2Command::EQ_ULT_MODE, current[2], current[3], current[4], + (char)(unsigned char)(std::clamp(clearBass, -10, 10) + 10) }; + cmd.insert(cmd.end(), current.begin() + 6, current.begin() + 5 + count); + this->_conn.sendCommand(cmd); + + std::lock_guard guard(this->_propertyMtx); + this->_eqClearBass = std::clamp(clearBass, -10, 10); +} +ULT_MODE Headphones::getUltMode() { std::lock_guard guard(this->_propertyMtx); return this->_ultMode; } + +void Headphones::setUltMode(ULT_MODE mode) +{ + if (!this->hasUltMode()) throw std::logic_error("ULT mode capability has not been confirmed"); + if ((unsigned char)mode > (unsigned char)ULT_MODE::ULT2) throw std::invalid_argument("Invalid ULT mode"); + + // The SET carries the EQ preset (and, for Manual, the custom curve) alongside the ULT byte. The EQ may + // have changed since the last read - through the 58 00 path, or on the phone - so re-read it right + // before writing; a stale copy would silently put the user's old EQ back. + // A dead link rethrows the transport's own exception, so callers still see its shouldDisconnect. + Buffer current; + if (!this->_readUltModeParam(current, true)) + throw RecoverableException("The headset didn't report its current equalizer, so ULT mode wasn't changed."); + + auto preset = (unsigned char)current[2]; + Buffer cmd = { (char)V2Command::EQ_SET, (char)V2Command::EQ_ULT_MODE, (char)preset, (char)(unsigned char)mode }; + if (preset == (unsigned char)EQ_PRESET::MANUAL) + { + // Echo the stored custom curve back byte for byte (count + bands), never a re-encoded copy. + size_t count = current.size() > 4 ? (unsigned char)current[4] : 0; + if (current.size() < 5 || current.size() < 5 + count) + throw RecoverableException("The headset's equalizer reply was incomplete, so ULT mode wasn't changed."); + cmd.insert(cmd.end(), current.begin() + 4, current.begin() + 5 + count); + } + else + { + // Presets carry no bands, as in the 58 00 00 form. + cmd.push_back(0x00); + } + this->_conn.sendCommand(cmd); + + std::lock_guard guard(this->_propertyMtx); + this->_ultMode = mode; +} + void Headphones::requestAmbientState() { // The NCASM inquired-type differs by protocol generation (v2=0x17, v1=0x02), and so does the reply @@ -602,6 +794,12 @@ 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 (...) {} + + // ULT mode + Clear Bass: ULT WEAR only (read-only, and never sent to any other model). + if (this->_isUltWear()) + { + try { this->requestUltMode(); } catch (...) {} + } } bool Headphones::hasAutoPowerOff() { std::lock_guard guard(this->_propertyMtx); return this->_hasAutoPowerOff; } @@ -661,20 +859,6 @@ void Headphones::setAdaptiveVolume(bool enabled) namespace { - std::string hexString(const Buffer& bytes) - { - static const char digits[] = "0123456789abcdef"; - std::string out; - for (size_t i = 0; i < bytes.size(); i++) - { - auto b = (unsigned char)bytes[i]; - if (i > 0) out += ' '; - out += digits[b >> 4]; - out += digits[b & 0x0f]; - } - return out; - } - // 37 { [] } x count // Returns false (and leaves `out` untouched) on any length mismatch, so a misread never reaches the UI. bool parsePairedDevices(const Buffer& resp, bool withClassOfDevice, std::vector& out) @@ -936,7 +1120,23 @@ bool Headphones::hasPlaybackPinState() { std::lock_guard g(this->_propertyMtx); bool Headphones::isPlaybackPinned() { std::lock_guard g(this->_propertyMtx); return this->_playbackPinned; } bool Headphones::hasMultipointSetting() { std::lock_guard g(this->_propertyMtx); return this->_hasMultipointSetting; } bool Headphones::getMultipointEnabled() { std::lock_guard g(this->_propertyMtx); return this->_multipointEnabled; } -std::string Headphones::getMultipointDiagnostics() { std::lock_guard g(this->_propertyMtx); return this->_multipointDiagnostics; } +std::string Headphones::getMultipointDiagnostics() +{ + std::lock_guard g(this->_propertyMtx); + // Stays empty until the first multipoint read has finished (the UI treats empty as "still loading"); after + // that it also carries the ULT-mode exchanges, which is useful when bringing ULT up on a new model. + if (this->_multipointDiagnostics.empty()) return this->_multipointDiagnostics; + std::string ult; + size_t start = 0; + while (start < this->_ultModeLog.size()) + { + size_t end = this->_ultModeLog.find('\n', start); + if (end == std::string::npos) end = this->_ultModeLog.size(); + ult += "ult " + this->_ultModeLog.substr(start, end - start) + "\n"; + start = end + 1; + } + return this->_multipointDiagnostics + ult; +} void Headphones::setMultipoint(bool enabled) { diff --git a/Client/Headphones.h b/Client/Headphones.h index fa69c279..ff302385 100644 --- a/Client/Headphones.h +++ b/Client/Headphones.h @@ -80,6 +80,21 @@ class Headphones { int getEqualizerBandCount(); bool isTenBandEqualizer(); + // ULT bass mode (v2, ULT WEAR). requestUltMode() is the read-only probe (run from probeCapabilities()) and + // the poll; hasUltMode() is true only once the device answered it. setUltMode() re-reads the EQ preset first + // (the SET carries it) and throws without sending unless the probe succeeded. + // Once a 57 03 reply has carried the six band bytes, the equalizer is read and its custom curve written + // through 56/58 03 as well: on the ULT WEAR that's the only path that reaches clear bass. + void requestUltMode(); + bool hasUltMode(); + ULT_MODE getUltMode(); + void setUltMode(ULT_MODE mode); + // Clear bass on its own - Sound Connect's "Bass Control" slider on the ULT WEAR (-10..10). Needs the 56/58 03 + // layout. Re-reads 56 03 and sends back the headset's own preset, ULT mode and bands, changing only clear + // bass; throws without sending if that reply carries no band bytes (getClearBass() is the source of truth). + bool hasUltClearBass(); + void setClearBass(int clearBass); + // DSEE / audio upsampling. void requestDsee(); bool hasDsee(); @@ -175,6 +190,24 @@ class Headphones { bool _dsee = false; bool _hasDsee = false; + // --- ULT WEAR (WH-ULT900N) only. Every 56/58 03 frame goes through these, and _readUltModeParam() refuses + // to send unless _isUltWear(), so no other model ever sees the ULT inquiry. --- + // v2 and a ULT WEAR name ("Sony ULT WEAR", "ULT WEAR", "WH-ULT900N"), like _isWh1000Xm6() for the XM6. + bool _isUltWear() const; + // Reads 56 03. Returns false (state untouched) on any other model or if the reply isn't a ULT reply. A + // transport failure returns false too, unless rethrow is set - writes need the original exception (it + // carries shouldDisconnect). + bool _readUltModeParam(Buffer& replyOut, bool rethrow = false); + // requestEqualizer() / setEqualizerCustom() on the ULT WEAR, whose 56/58 00 slots are shifted by one. + // _requestUltEqualizer() returns false if the 03 layout isn't confirmed (caller falls back to 56 00). + bool _requestUltEqualizer(); + void _setUltEqualizerCustom(int clearBass, const std::vector& bands); + bool _hasUltMode = false; + ULT_MODE _ultMode = ULT_MODE::OFF; + bool _ultEqLayout = false; // a 57 03 reply carried + six band bytes (EQ goes through 56/58 03) + std::string _ultModeLastOk; // hex of the last answered 56 03, kept so a later timeout doesn't hide it + std::string _ultModeLog; // what the diagnostics show: last answered exchange + the latest failure, if any + bool _hasAutoPowerOff = false; int _autoPowerOff = 0; bool _hasFirmware = false; std::string _firmware; bool _hasCodec = false; std::string _codec; diff --git a/Client/macos/Assets.xcassets/wh-ult900n.imageset/Contents.json b/Client/macos/Assets.xcassets/wh-ult900n.imageset/Contents.json new file mode 100644 index 00000000..c353192a --- /dev/null +++ b/Client/macos/Assets.xcassets/wh-ult900n.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "wh-ult900n.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Client/macos/Assets.xcassets/wh-ult900n.imageset/wh-ult900n.png b/Client/macos/Assets.xcassets/wh-ult900n.imageset/wh-ult900n.png new file mode 100644 index 00000000..709bba46 Binary files /dev/null and b/Client/macos/Assets.xcassets/wh-ult900n.imageset/wh-ult900n.png differ diff --git a/Client/macos/ContentView.swift b/Client/macos/ContentView.swift index 22773ca5..de0d8fb3 100644 --- a/Client/macos/ContentView.swift +++ b/Client/macos/ContentView.swift @@ -98,6 +98,9 @@ struct ContentView: View { if model.multipointLoading || model.hasPairedDevices || model.hasPlaybackPinState { devicesCard } + if model.supportsUltMode { + ultCard + } if model.supportsEqualizer { equalizerCard } @@ -189,6 +192,36 @@ struct ContentView: View { .themedCard() } + // Sound Connect's "Bass Control" on the ULT WEAR: the ULT mode (same as the ULT button) plus Clear Bass. + private var ultCard: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 2) { + cardTitle("Bass Control") + Text("ULT mode, same as the ULT button") + .font(.system(size: 11)) + .foregroundColor(palette.secondary) + } + UltModeControl(model: model) + if model.supportsClearBassControl { + HStack(spacing: 10) { + Text("Clear Bass") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(palette.accentText) + .frame(width: 64, alignment: .leading) + Slider(value: Binding( + get: { Double(model.clearBass) }, + set: { model.setClearBass(Int($0.rounded())) } + ), in: -10...10, step: 1).accentColor(palette.accent) + Text("\(model.clearBass > 0 ? "+" : "")\(model.clearBass)") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(palette.primary) + .frame(width: 26, alignment: .trailing) + } + } + } + .themedCard() + } + private let eqBandLabels = ["400", "1k", "2.5k", "6.3k", "16k"] private let tenBandEqLabels = ["31", "63", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"] @@ -210,7 +243,8 @@ struct ContentView: View { set: { model.eqBands[i] = Int($0.rounded()); model.applyCustomEq() } ), display: model.eqBands[i], raw: model.tenBandEqualizer) } - if !model.tenBandEqualizer { + // On the ULT WEAR clear bass sits in the Bass Control card instead, as in Sony's app. + if !model.tenBandEqualizer && !model.supportsClearBassControl { eqBandRow("Bass", value: Binding( get: { Double(model.clearBass) }, set: { model.clearBass = Int($0.rounded()); model.applyCustomEq() } @@ -554,6 +588,11 @@ enum EQPresets { static func list(tenBand: Bool) -> [(String, Int)] { tenBand ? Self.tenBand : fiveBand } } +// ULT bass mode values (EqUltModeStatus), in the order the headset's ULT button cycles them. +enum ULTModes { + static let list: [(String, Int)] = [("Off", 0), ("ULT 1", 1), ("ULT 2", 2)] +} + extension SHCAmbientMode { var displayName: String { switch self { @@ -573,8 +612,17 @@ struct ProductImage: View { @Environment(\.appTheme) private var theme @Environment(\.colorScheme) private var colorScheme + // Image name for a Bluetooth device name: the lowercased name with dashes ("WH-CH720N" -> "wh-ch720n"), + // except for models whose Bluetooth name isn't the model number. + static func assetName(for deviceName: String) -> String { + let slug = deviceName.lowercased().replacingOccurrences(of: " ", with: "-") + // The ULT WEAR (WH-ULT900N) advertises itself as "Sony ULT WEAR". + if slug.contains("ult-wear") || slug.contains("ult900") { return "wh-ult900n" } + return slug + } + var body: some View { - if let image = NSImage(named: deviceName.lowercased().replacingOccurrences(of: " ", with: "-")) { + if let image = NSImage(named: Self.assetName(for: deviceName)) { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fit) @@ -704,6 +752,44 @@ struct AmbientModeControl: View { } } +// ULT bass mode Off / ULT 1 / ULT 2, styled like AmbientModeControl. +struct UltModeControl: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + private var palette: Palette { theme.palette(colorScheme) } + + var body: some View { + let track = RoundedRectangle(cornerRadius: 11, style: .continuous) + HStack(spacing: theme.isSignature ? 6 : 0) { + ForEach(ULTModes.list, id: \.1) { option in + modeButton(option.0, option.1) + } + } + .padding(theme.isSignature ? 0 : 3) + .background(palette.track, in: track) + .overlay(track.strokeBorder(theme.isSignature ? .clear : palette.cardStroke, lineWidth: 1)) + } + + private func modeButton(_ name: String, _ value: Int) -> some View { + let selected = model.ultMode == value + return Button(action: { model.setUltMode(value) }) { + Text(name) + .font(.system(size: 11.5, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .selectable(selected) + .shadow(color: selected ? palette.selectedShadow : .clear, radius: 8) + } + .buttonStyle(.plain) + } +} + struct AmbientLevelSlider: View { @ObservedObject var model: HeadphonesModel diff --git a/Client/macos/HeadphonesBridge.h b/Client/macos/HeadphonesBridge.h index 76ac43ac..efed32c8 100644 --- a/Client/macos/HeadphonesBridge.h +++ b/Client/macos/HeadphonesBridge.h @@ -57,6 +57,9 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) BOOL tenBandEqualizer; // WH-1000XM6: raw 0..12 bands, no clear bass @property (nonatomic, readonly) BOOL supportsDsee; // true after a successful read-only probe @property (nonatomic, readonly) BOOL dsee; // DSEE / audio upsampling +@property (nonatomic, readonly) BOOL supportsUltMode; // ULT WEAR: true once the device answered the ULT read +@property (nonatomic, readonly) NSInteger ultMode; // 0 = Off, 1 = ULT 1, 2 = ULT 2 +@property (nonatomic, readonly) BOOL supportsClearBassControl; // ULT WEAR: clear bass settable on its own (-10..10) // Optional features (present only if the device answered the capability probe on connect). @property (nonatomic, readonly) BOOL hasAutoPowerOff; @@ -106,6 +109,12 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { // Toggles DSEE / audio upsampling. - (void)setDsee:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; +// Sets the ULT bass mode (0 = Off, 1 = ULT 1, 2 = ULT 2), the same setting the headset's ULT button cycles. +- (void)setUltMode:(NSInteger)mode completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; + +// Sets clear bass (-10..10) alone, keeping the headset's preset, ULT mode and bands. Read back via clearBass. +- (void)setClearBass:(NSInteger)value completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; + - (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; diff --git a/Client/macos/HeadphonesBridge.mm b/Client/macos/HeadphonesBridge.mm index 90678d39..2b35de22 100644 --- a/Client/macos/HeadphonesBridge.mm +++ b/Client/macos/HeadphonesBridge.mm @@ -140,6 +140,10 @@ - (BOOL)supportsDsee { return _hp && _hp->hasDsee(); } +- (BOOL)supportsUltMode { return _hp && _hp->hasUltMode(); } +- (NSInteger)ultMode { return _hp ? (NSInteger)_hp->getUltMode() : 0; } +- (BOOL)supportsClearBassControl { return _hp && _hp->hasUltClearBass(); } + - (NSInteger)equalizerBandCount { return _hp ? _hp->getEqualizerBandCount() : 5; } @@ -395,9 +399,13 @@ - (void)refreshDynamicWithCompletion:(void (^)(void))completion { auto hp = _hp; BOOL isV2 = _bt->getProtocolVersion() == SonyProtocolVersion::V2; dispatch_async(_cmdQueue, ^{ - // The headphone's physical button only changes ambient/NC, so that's all we poll (keeps traffic low). + // Only button-changeable state is polled (keeps traffic low): ambient/NC, plus ULT mode below. // 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) {} + // The ULT button changes the ULT mode on the headset itself, so follow it too (ULT devices only). + if (hp->hasUltMode()) { + try { hp->requestUltMode(); } catch (std::exception &exc) {} + } // Battery drains, earbuds get docked and the case charges them, so re-read it too - but only // every Nth poll, since it costs up to three inquiries on TWS. v2 only: 0x22 is POWER_OFF on v1. if (isV2 && self->_dynamicTick++ % kBatteryPollEveryNTicks == 0) { @@ -465,6 +473,41 @@ - (void)setDsee:(BOOL)enabled completion:(void (^)(BOOL, NSString * _Nullable))c }); } +- (void)setUltMode:(NSInteger)mode completion:(void (^)(BOOL, NSString * _Nullable))completion { + if (!_hp || !self.connected || !_hp->hasUltMode()) { + completion(NO, @"ULT mode isn't supported on this device."); + return; + } + if (mode < 0 || mode > 2) { + completion(NO, @"Invalid ULT mode."); + return; + } + auto hp = _hp; + dispatch_async(_cmdQueue, ^{ + NSString *error = nil; BOOL ok = YES; + try { + hp->setUltMode(static_cast((unsigned char)mode)); + } catch (std::exception &exc) { ok = NO; error = @(exc.what()); } + dispatch_async(dispatch_get_main_queue(), ^{ completion(ok, error); }); + }); +} + +- (void)setClearBass:(NSInteger)value completion:(void (^)(BOOL, NSString * _Nullable))completion { + if (!_hp || !self.connected || !_hp->hasUltClearBass()) { + completion(NO, @"Clear Bass isn't adjustable on this device."); + return; + } + int cvalue = (int)MAX(-10, MIN(10, value)); + auto hp = _hp; + dispatch_async(_cmdQueue, ^{ + NSString *error = nil; BOOL ok = YES; + try { + hp->setClearBass(cvalue); + } catch (std::exception &exc) { ok = NO; error = @(exc.what()); } + dispatch_async(dispatch_get_main_queue(), ^{ completion(ok, error); }); + }); +} + - (void)applyMode:(SHCAmbientMode)mode level:(NSInteger)level focusVoice:(BOOL)focusVoice diff --git a/Client/macos/HeadphonesModel.swift b/Client/macos/HeadphonesModel.swift index add153d0..016579dd 100644 --- a/Client/macos/HeadphonesModel.swift +++ b/Client/macos/HeadphonesModel.swift @@ -53,6 +53,10 @@ final class HeadphonesModel: ObservableObject { @Published var clearBass = 0 @Published var supportsDsee = false @Published var dsee = false + @Published var supportsUltMode = false + @Published var ultMode = 0 // 0 = Off, 1 = ULT 1, 2 = ULT 2 + // ULT WEAR: clear bass lives in the Bass Control card (next to ULT) and is set on its own, any preset. + @Published var supportsClearBassControl = false @Published var hasAutoPowerOff = false @Published var autoPowerOff = 0 @@ -101,6 +105,13 @@ final class HeadphonesModel: ObservableObject { private var pendingPush: (mode: SHCAmbientMode, level: Int, focusVoice: Bool)? private var eqInFlight = false private var pendingEq: (bass: Int, bands: [Int])? + // While ULT writes are queued, the poll's read-back could predate them and flip the control back. + // A count, not a flag: two quick taps each queue a write, and the first completion mustn't reopen the gate. + private var ultWritesInFlight = 0 + private var ultInFlight: Bool { ultWritesInFlight > 0 } + // Clear Bass slider drags coalesce like the EQ sliders: one write in flight, then the latest value. + private var clearBassInFlight = false + private var pendingClearBass: 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. @@ -131,9 +142,12 @@ final class HeadphonesModel: ObservableObject { self.syncBatteryFromBridge() self.eqPreset = self.bridge.eqPreset self.supportsEqualizer = self.bridge.supportsEqualizer - self.clearBass = self.bridge.clearBass + self.supportsClearBassControl = self.bridge.supportsClearBassControl + if !self.clearBassInFlight { self.clearBass = self.bridge.clearBass } self.supportsDsee = self.bridge.supportsDsee self.dsee = self.bridge.dsee + self.supportsUltMode = self.bridge.supportsUltMode + if !self.ultInFlight { self.ultMode = self.bridge.ultMode } self.tenBandEqualizer = self.bridge.tenBandEqualizer self.eqBands = self.readEqBands() self.hasAutoPowerOff = self.bridge.hasAutoPowerOff @@ -260,12 +274,20 @@ final class HeadphonesModel: ObservableObject { self.bridge.refreshMultipoint { self.syncMultipointFromBridge() } } self.bridge.refreshDynamic { - // Only publish what refreshDynamic actually re-read: ambient/NC state and the battery. + // Only publish what refreshDynamic actually re-read: ambient/NC state, ULT mode and the battery. // EQ and DSEE aren't re-read on this path - they'd come straight back from the C++ cache, // so a failed write would snap back two seconds later and an EQ drag would fight the timer. self.assignIfChanged(\.mode, self.bridge.mode) let level = self.bridge.ambientLevel if level > 0 { self.assignIfChanged(\.ambientLevel, level) } + if self.supportsUltMode && !self.ultInFlight { + self.assignIfChanged(\.ultMode, self.bridge.ultMode) + } + // The ULT read also carries clear bass, so follow it (e.g. changed in the phone app), unless one + // of our own clear-bass or EQ writes is still queued - the read could predate it. + if self.supportsClearBassControl && !self.clearBassInFlight && self.pendingClearBass == nil && !self.eqInFlight { + self.assignIfChanged(\.clearBass, self.bridge.clearBass) + } self.syncBatteryFromBridge() } } @@ -337,11 +359,58 @@ final class HeadphonesModel: ObservableObject { } } + func setUltMode(_ newMode: Int) { + ultMode = newMode + errorMessage = nil + ultWritesInFlight += 1 + bridge.setUltMode(newMode) { ok, error in + self.ultWritesInFlight = max(0, self.ultWritesInFlight - 1) + if !ok, let error = error { self.errorMessage = error } + // Once the last queued write has landed, show what the headset has: the last tap on success, + // the real state on failure (the bridge re-reads the headset before every write). + if !self.ultInFlight { self.ultMode = self.bridge.ultMode } + } + } + + func setClearBass(_ value: Int) { + guard value != clearBass || clearBassInFlight else { return } + clearBass = value + errorMessage = nil + if clearBassInFlight { + pendingClearBass = value + return + } + sendClearBass(value) + } + + private func sendClearBass(_ sent: Int) { + clearBassInFlight = true + bridge.setClearBass(sent) { ok, error in + self.clearBassInFlight = false + if let pending = self.pendingClearBass { + self.pendingClearBass = nil + if pending != sent { + self.sendClearBass(pending) + return + } + } + if !ok { + // The bridge re-read the headset before writing, so this is what it really has. + self.clearBass = self.bridge.clearBass + if let error = error { self.errorMessage = error } + } + } + } + func disconnect() { stopWatchingConnection() stopDynamicPolling() pendingPush = nil pendingEq = nil + pendingClearBass = nil + ultWritesInFlight = 0 + supportsUltMode = false + supportsClearBassControl = false s2cAutoDisablePending = false bridge.disconnect() connected = false diff --git a/Client/macos/QuickControlsView.swift b/Client/macos/QuickControlsView.swift index 98f6c0c6..55eb06ac 100644 --- a/Client/macos/QuickControlsView.swift +++ b/Client/macos/QuickControlsView.swift @@ -79,12 +79,15 @@ struct QuickControlsView: View { } } - if model.supportsEqualizer || model.hasPlaybackPinState { + if model.supportsUltMode || model.supportsEqualizer || model.hasPlaybackPinState { VStack(alignment: .leading, spacing: 12) { + if model.supportsUltMode { + ultRow + } if model.supportsEqualizer { equalizerRow } - if model.supportsEqualizer && model.hasPlaybackPinState { + if (model.supportsUltMode || model.supportsEqualizer) && model.hasPlaybackPinState { Divider().background(palette.cardStroke) } if model.hasPlaybackPinState { @@ -97,6 +100,27 @@ struct QuickControlsView: View { errorText } + private var ultRow: some View { + HStack { + Text("ULT") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + Spacer() + Picker("ULT", selection: Binding( + get: { model.ultMode }, + set: { model.setUltMode($0) } + )) { + ForEach(ULTModes.list, id: \.1) { option in + Text(option.0).tag(option.1) + } + } + .pickerStyle(MenuPickerStyle()) + .labelsHidden() + .frame(width: 120) + .accentColor(palette.accent) + } + } + private var equalizerRow: some View { HStack { Text("Equalizer") diff --git a/Client/resources/devices/wh-ult900n.png b/Client/resources/devices/wh-ult900n.png new file mode 100644 index 00000000..709bba46 Binary files /dev/null and b/Client/resources/devices/wh-ult900n.png differ diff --git a/Client/tests/HeadphonesProtocolTests.cpp b/Client/tests/HeadphonesProtocolTests.cpp index 1517a049..0cc7d973 100644 --- a/Client/tests/HeadphonesProtocolTests.cpp +++ b/Client/tests/HeadphonesProtocolTests.cpp @@ -268,6 +268,10 @@ int main() try { headphones.setMultipoint(true); } catch (const std::exception&) { multipointRejected = true; } require(multipointRejected, "setMultipoint on v1 must throw instead of sending"); + // ULT mode is a v2-only inquiry; the v1 probe pass must never send it. + require(!headphones.hasUltMode() && std::none_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), + [](const Buffer& p) { return p.size() >= 2 && (unsigned char)p[0] == 0x56 && p[1] == 0x03; }), + "v1 probe must not send the 56 03 ULT inquiry"); // --- v2 multipoint ("Connect to 2 devices"), byte layout proven by GadgetBridge on the WH-ULT900N // (SonyProtocolImplV2 commits 8cbc309346, e8520865f3, 5d7b37d1b7) --- @@ -361,6 +365,202 @@ int main() zeros.requestAmbientState(); require(zeros.getAmbientSoundControl() && zeros.getAsmLevel() == 7, "a zeroed 67 17 reply overwrote the state"); + // --- ULT mode (ULT WEAR), EQ inquired type 03: 57 03 --- + // Layout from Sound Connect's PRESET_EQ_AND_ULT_MODE (mos9527/SonyHeadphonesClient ProtocolV2T1.hpp). + { + Buffer ultReply = bytes({ 0x57, 0x03, 0xa0, 0x01, 0x06, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f }); + auto fakeUlt = std::make_unique(); + auto* observedUlt = fakeUlt.get(); + observedUlt->reply = [&ultReply](const Buffer& request) -> Buffer { + if (request == bytes({ 0x56, 0x03 })) return ultReply; + return {}; + }; + BluetoothWrapper bluetoothUlt(std::move(fakeUlt)); + Headphones ult(bluetoothUlt); + ult.setDeviceName("Sony ULT WEAR"); + ult.probeCapabilities(); + require(ult.hasUltMode() && ult.getUltMode() == ULT_MODE::ULT1, "57 03 a0 01 ... must parse as ULT 1"); + + // Diagnostics stay empty until the first multipoint read (macOS shows "loading" while empty), then + // carry the raw ULT exchange. + require(ult.getMultipointDiagnostics().empty(), "the ULT probe must not make the diagnostics non-empty"); + ult.requestMultipoint(); + require(ult.getMultipointDiagnostics().find("ult T1 56 03 -> 57 03 a0 01 06") != std::string::npos, + "the diagnostics must include the raw 56 03 exchange"); + + // The 57 03 reply is also the EQ state: clear bass first, then 400..16k. + require(ult.hasEqualizer() && ult.getEqualizerPreset() == EQ_PRESET::MANUAL + && ult.getClearBass() == 0 && ult.getEqualizerBand(0) == 1 && ult.getEqualizerBand(4) == 5, + "57 03 a0 01 06 0a 0b 0c 0d 0e 0f must decode as clear bass 0, bands +1..+5"); + observedUlt->sentPayloads.clear(); + ult.requestEqualizer(); + require(observedUlt->sent(bytes({ 0x56, 0x03 })) && !observedUlt->sent(bytes({ 0x56, 0x00 })), + "a ULT device's equalizer must be read through 56 03"); + ult.setEqualizerCustom(3, { -2, -1, 0, 1, 2 }); + require(observedUlt->sentPayloads.back() == bytes({ 0x58, 0x03, 0xa0, 0x01, 0x06, 0x0d, 0x08, 0x09, 0x0a, 0x0b, 0x0c }), + "ULT custom EQ must be 58 03 a0 06 <400..16k +10>"); + require(ult.getClearBass() == 3 && ult.getEqualizerBand(0) == -2, "ULT custom EQ cache was not updated"); + + // Manual EQ: the custom curve is echoed back verbatim (count + bands), after a fresh read. + observedUlt->sentPayloads.clear(); + ult.setUltMode(ULT_MODE::ULT2); + require(observedUlt->sentPayloads.size() == 2 && observedUlt->sentPayloads[0] == bytes({ 0x56, 0x03 }), + "setUltMode must re-read 56 03 right before writing"); + require(observedUlt->sentPayloads[1] == bytes({ 0x58, 0x03, 0xa0, 0x02, 0x06, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f }), + "ULT 2 on Manual EQ must be 58 03 a0 02 "); + require(ult.getUltMode() == ULT_MODE::ULT2, "ULT mode cache was not updated after the write"); + + // The EQ changed elsewhere (now the Bright preset): the fresh read wins, and a preset carries no bands. + ultReply = bytes({ 0x57, 0x03, 0x10, 0x02, 0x06, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a }); + ult.setUltMode(ULT_MODE::OFF); + require(observedUlt->sentPayloads.back() == bytes({ 0x58, 0x03, 0x10, 0x00, 0x00 }), + "ULT off on a preset must be 58 03 00 00"); + + // The poll follows the physical button. + ultReply = bytes({ 0x57, 0x03, 0x10, 0x01, 0x00 }); + ult.requestUltMode(); + require(ult.getUltMode() == ULT_MODE::ULT1, "a ULT button change was not picked up by requestUltMode"); + + // Manual EQ but the reply lacks its bands: refuse rather than wipe the custom curve. + ultReply = bytes({ 0x57, 0x03, 0xa0, 0x01 }); + size_t sentBefore = observedUlt->sentPayloads.size(); + bool truncatedRejected = false; + try { ult.setUltMode(ULT_MODE::ULT2); } catch (const RecoverableException&) { truncatedRejected = true; } + require(truncatedRejected && observedUlt->sentPayloads.size() == sentBefore + 1 + && observedUlt->sentPayloads.back() == bytes({ 0x56, 0x03 }), + "setUltMode must not write when the Manual EQ reply has no bands"); + + // The link dies before the write: the transport's own exception (with shouldDisconnect) reaches the + // caller so the GUI drops the dead connection, nothing is written, and the diagnostics keep the last + // answered exchange next to the failure. + ultReply = {}; + sentBefore = observedUlt->sentPayloads.size(); + // (The fake's own timeout is a plain runtime_error; on a real link it is RecoverableException(..., true).) + std::string writeError; + try { ult.setUltMode(ULT_MODE::OFF); } catch (const std::exception& exc) { writeError = exc.what(); } + require(writeError == "fake connector has no queued response" && observedUlt->sentPayloads.size() == sentBefore + 1, + "a failed re-read must rethrow the transport's exception (not a fresh one) and send no 58 03"); + auto diagnostics = ult.getMultipointDiagnostics(); + require(diagnostics.find("ult T1 56 03 -> 57 03") != std::string::npos + && diagnostics.find("ult T1 56 03 -> (") != std::string::npos, + "a ULT timeout must not hide the last answered exchange in the diagnostics"); + + // Captured from a ULT WEAR (fw 2.1.0). Its 57 00 showed the first five of these values one slot late, and + // writing the sixth byte here moved the treble, so: clear bass +7, then 400..16k. + // The device name alone makes the first equalizer read use 56 03 (never 56 00) on this model. + auto fakeCaptured = std::make_unique(); + fakeCaptured->reply = [](const Buffer& request) -> Buffer { + if (request == bytes({ 0x56, 0x03 })) return bytes({ 0x57, 0x03, 0xa0, 0x01, 0x06, 0x11, 0x0c, 0x08, 0x0b, 0x0d, 0x0f }); + return {}; + }; + auto* observedCaptured = fakeCaptured.get(); + BluetoothWrapper bluetoothCaptured(std::move(fakeCaptured)); + Headphones captured(bluetoothCaptured); + captured.setDeviceName("Sony ULT WEAR"); + captured.requestEqualizer(); + require(observedCaptured->sent(bytes({ 0x56, 0x03 })) && !observedCaptured->sent(bytes({ 0x56, 0x00 })), + "a ULT WEAR's first equalizer read must use 56 03, not the shifted 56 00"); + require(captured.hasEqualizer() && captured.getClearBass() == 7 && captured.getEqualizerBand(0) == 2 + && captured.getEqualizerBand(1) == -2 && captured.getEqualizerBand(2) == 1 && captured.getEqualizerBand(3) == 3 + && captured.getEqualizerBand(4) == 5, + "captured ULT WEAR reply must decode as clear bass +7, then +2 -2 +1 +3 +5"); + + // Clear Bass on its own (Bass Control slider): fresh read, then the headset's own preset, ULT mode and + // five bands go back byte for byte with only the clear-bass byte replaced. + require(captured.hasUltClearBass(), "a 57 03 reply with six band bytes must enable Clear Bass control"); + observedCaptured->sentPayloads.clear(); + captured.setClearBass(-3); + require(observedCaptured->sentPayloads.size() == 2 && observedCaptured->sentPayloads[0] == bytes({ 0x56, 0x03 }) + && observedCaptured->sentPayloads[1] == bytes({ 0x58, 0x03, 0xa0, 0x01, 0x06, 0x07, 0x0c, 0x08, 0x0b, 0x0d, 0x0f }), + "Clear Bass -3 must be 58 03 a0 01 06 07 <400..16k echoed>"); + require(captured.getClearBass() == -3, "Clear Bass cache was not updated"); + + // On a preset whose reply carries its curve, the preset byte goes back unchanged. + auto fakePreset = std::make_unique(); + auto* observedPreset = fakePreset.get(); + Buffer presetReply = bytes({ 0x57, 0x03, 0x10, 0x02, 0x06, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f }); + fakePreset->reply = [&presetReply](const Buffer& request) -> Buffer { + if (request == bytes({ 0x56, 0x03 })) return presetReply; + return {}; + }; + BluetoothWrapper bluetoothPreset(std::move(fakePreset)); + Headphones preset(bluetoothPreset); + preset.setDeviceName("Sony ULT WEAR"); + preset.requestUltMode(); + preset.setClearBass(25); + require(observedPreset->sentPayloads.back() == bytes({ 0x58, 0x03, 0x10, 0x02, 0x06, 0x14, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f }), + "Clear Bass on a preset must keep the preset and ULT bytes, clamp to +10"); + + // A reply without band bytes: refuse rather than guess the curve. + presetReply = bytes({ 0x57, 0x03, 0x10, 0x02, 0x00 }); + size_t presetSent = observedPreset->sentPayloads.size(); + bool noBandsRejected = false; + try { preset.setClearBass(1); } catch (const RecoverableException&) { noBandsRejected = true; } + require(noBandsRejected && observedPreset->sentPayloads.size() == presetSent + 1, + "Clear Bass must not write when the reply carries no band bytes"); + + // An unknown ULT status byte is not a ULT reply. + auto fakeOdd = std::make_unique(); + fakeOdd->reply = [](const Buffer& request) -> Buffer { + if (request == bytes({ 0x56, 0x03 })) return bytes({ 0x57, 0x03, 0x00, 0x07, 0x00 }); + return {}; + }; + BluetoothWrapper bluetoothOdd(std::move(fakeOdd)); + Headphones odd(bluetoothOdd); + odd.setDeviceName("Sony ULT WEAR"); + odd.requestUltMode(); + require(!odd.hasUltMode(), "a ULT status past 02 must not be taken as ULT support"); + bool oddCbRejected = false; + try { odd.setClearBass(1); } catch (const std::logic_error&) { oddCbRejected = true; } + require(!odd.hasUltClearBass() && oddCbRejected, "setClearBass must throw without the six-band ULT layout"); + + // A ULT WEAR that doesn't answer 56 03 never gets a 58 03. + auto fakeNoUlt = std::make_unique(); + auto* observedNoUlt = fakeNoUlt.get(); + BluetoothWrapper bluetoothNoUlt(std::move(fakeNoUlt)); + Headphones noUlt(bluetoothNoUlt); + noUlt.setDeviceName("Sony ULT WEAR"); + noUlt.probeCapabilities(); + bool ultRejected = false; + try { noUlt.setUltMode(ULT_MODE::ULT1); } catch (const std::logic_error&) { ultRejected = true; } + require(!noUlt.hasUltMode() && ultRejected && std::none_of(observedNoUlt->sentPayloads.begin(), + observedNoUlt->sentPayloads.end(), [](const Buffer& p) { return p.size() >= 2 && (unsigned char)p[0] == 0x58; }), + "setUltMode must throw without sending on a device that never answered 56 03"); + } + + // --- ULT code is ULT WEAR only: other v2 models never see 56 03 / 58 03, even if they'd answer --- + for (const char* otherModel : { "WH-1000XM5", "WH-CH720N", "WF-1000XM5", "" }) + { + auto fakeOther = std::make_unique(); + auto* observedOther = fakeOther.get(); + fakeOther->reply = [](const Buffer& request) -> Buffer { + if (request == bytes({ 0x56, 0x03 })) return bytes({ 0x57, 0x03, 0xa0, 0x01, 0x06, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f }); + if (request == bytes({ 0x56, 0x00 })) return bytes({ 0x57, 0x00, 0xa0, 0x06, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f }); + return {}; + }; + BluetoothWrapper bluetoothOther(std::move(fakeOther)); + Headphones other(bluetoothOther); + other.setDeviceName(otherModel); + other.requestEqualizer(); + require(other.getClearBass() == 0 && other.getEqualizerBand(0) == 1, + "a non-ULT model must keep decoding 57 00 as clear bass first"); + other.probeCapabilities(); + other.requestUltMode(); + other.setEqualizerCustom(3, { -2, -1, 0, 1, 2 }); + bool ultWriteRejected = false, clearBassRejected = false; + try { other.setUltMode(ULT_MODE::ULT1); } catch (const std::logic_error&) { ultWriteRejected = true; } + try { other.setClearBass(1); } catch (const std::logic_error&) { clearBassRejected = true; } + bool sentUlt = std::any_of(observedOther->sentPayloads.begin(), observedOther->sentPayloads.end(), + [](const Buffer& p) { return p.size() >= 2 && ((unsigned char)p[0] == 0x56 || (unsigned char)p[0] == 0x58) && p[1] == 0x03; }); + require(!sentUlt && !other.hasUltMode() && !other.hasUltClearBass() && ultWriteRejected && clearBassRejected, + "a non-ULT model must never be sent 56 03 / 58 03 or offer ULT / Clear Bass control"); + require(std::find(observedOther->sentPayloads.begin(), observedOther->sentPayloads.end(), + bytes({ 0x58, 0x00, 0xa0, 0x06, 0x0d, 0x08, 0x09, 0x0a, 0x0b, 0x0c })) != observedOther->sentPayloads.end(), + "a non-ULT model must keep writing custom EQ as 58 00 a0 06 "); + require(other.getMultipointDiagnostics().empty() || other.getMultipointDiagnostics().find("ult ") == std::string::npos, + "a non-ULT model's diagnostics must not mention ULT"); + } + // 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. @@ -372,6 +572,6 @@ int main() expectRecoverable([] { CommandSerializer::unpackBtMessage(Buffer{ 0x00, 0x00, 0x00 }); }, "under-minimum-size frame did not throw RecoverableException"); - std::cout << "XM4 / multipoint / XM6 protocol tests passed\n"; + std::cout << "XM4 / multipoint / XM6 / ULT mode protocol tests passed\n"; return 0; } diff --git a/README.md b/README.md index a541272c..b1b71460 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ on macOS, and a matching modern UI on Windows/Linux. - ๐ŸŽš๏ธ **Ambient Sound Control** โ€” Noise Cancelling ยท Ambient Sound (0โ€“20 levels) ยท Off - ๐Ÿ—ฃ๏ธ **Focus on Voice** passthrough - ๐ŸŽ›๏ธ **Equalizer** โ€” presets *and* a full **Manual mode** with 5 bands + Clear Bass (10 bands on the WH-1000XM6), with a live curve preview +- ๐Ÿ”Š **Bass Control** (ULT WEAR) โ€” ULT mode (Off ยท ULT 1 ยท ULT 2, the same bass boost the ULT button cycles, kept in sync when you press it) and the Clear Bass slider, as in Sony's app - โœจ **DSEE** โ€” Sony's audio upscaling for compressed sources - ๐Ÿ”‹ **Battery level** โ€” live percentage, including **per-earbud + case** for TWS models - ๐ŸŽง **Codec & firmware** readout @@ -59,7 +60,7 @@ on macOS, and a matching modern UI on Windows/Linux. - ๐Ÿ–ผ๏ธ **Device hero image** โ€” your headphones' official Sony product render - ๐Ÿ”„ **Live button sync** โ€” changes made on the headset reflect in the app - ๐Ÿ”Œ **Auto-connect** to your already-paired Sony headset -- ๐Ÿซ **Menu-bar quick controls** โ€” click the menu-bar icon for mode, ambient level, Focus on Voice, EQ preset, battery and playback pinning without opening the window (right-click for the menu) +- ๐Ÿซ **Menu-bar quick controls** โ€” click the menu-bar icon for mode, ambient level, Focus on Voice, ULT mode, EQ preset, battery and playback pinning without opening the window (right-click for the menu) - โ†”๏ธ **Resizable window** โ€” a tall single column by default; widen it past 720pt for a two-column layout - ๐Ÿ“ฑ **Multipoint** โ€” see which devices are connected, switch playback between them, pin playback to this Mac, and turn "Connect to 2 devices" on or off - ๐Ÿงฌ **Dual-protocol** โ€” auto-detects and speaks either protocol generation