Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 18 additions & 34 deletions .github/workflows/xcodebuild.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,37 @@ name: macOS
on:
push:
branches:
- master
- main
- 'feature/**'
pull_request:
branches:
- master
- main
workflow_dispatch:

jobs:
build:
strategy:
matrix:
os: ["macos-latest"]
include:
- os: macos-latest
binary_path: Client/build/SonyHeadphonesClient.zip
artifact_name: "SonyHeadphonesClient-macOS.zip"
runs-on: ${{ matrix.os }}
runs-on: macos-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
submodules: true

- name: Create build directory
- name: Protocol tests
working-directory: ${{github.workspace}}/Client
run: mkdir build
run: sh tests/run-macos.sh

- name: Build
working-directory: ${{github.workspace}}/Client/build
# Execute the build.
run: xcodebuild -project '../macos/SonyHeadphonesClient.xcodeproj' -config Release -scheme 'SonyHeadphonesClient' -archivePath ./archive archive | xcpretty
working-directory: ${{github.workspace}}/Client/macos
# Ad-hoc signed like the released app, so no signing identity is needed.
run: xcodebuild -project SonyHeadphonesClient.xcodeproj -scheme SonyHeadphonesClient -configuration Release -derivedDataPath ../build CODE_SIGN_IDENTITY=- build

- name: Export archive to .app
working-directory: ${{github.workspace}}/Client/build
# Exports the archive to a .app file
run: xcodebuild -exportArchive -archivePath ./archive.xcarchive -exportPath ./ -exportOptionsPlist ../macos/exportOptions.plist | xcpretty
- name: Zip the app
working-directory: ${{github.workspace}}/Client/build/Build/Products/Release
run: ditto -c -k --keepParent SonyHeadphonesClient.app ${{github.workspace}}/SonyHeadphonesClient-macOS.zip

- name: Zip the exported file
working-directory: ${{github.workspace}}/Client/build
# Zip the application for easier downloading
run: zip -r SonyHeadphonesClient.zip SonyHeadphonesClient.app

- name: Upload the zipped application
uses: actions/upload-artifact@v3
- name: Upload the zipped app
uses: actions/upload-artifact@v4
with:
# Artifact name
name: ${{ matrix.artifact_name }}
# A file, directory or wildcard pattern that describes what to upload
path: ${{github.workspace}}/${{matrix.binary_path}}
# The desired behavior if no files are found using the provided path.


name: SonyHeadphonesClient-macOS
path: ${{github.workspace}}/SonyHeadphonesClient-macOS.zip
31 changes: 31 additions & 0 deletions Client/Constants.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,37 @@ namespace V1Command
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;

// Supported-function list: 06 00 -> 07 00 <count> <function ids...>.
inline constexpr unsigned char SUPPORT_FUNCTION_GET = 0x06;
inline constexpr unsigned char SUPPORT_FUNCTION_RET = 0x07;
// General settings, in the slots (d1..d4) the function list advertises. Same opcodes as v2, different
// layout, captured from a WH-1000XM4 on fw 3.0.1 and matching acouplet (baglayan/acouplet PR #5):
// d0 <slot> 01 -> d1 <slot> 02(boolean) <len>name <len>summary 01 00
// d6 <slot> -> d7 <slot> 01 <01=on/00=off>
// d8 <slot> 01 <01=on/00=off>
inline constexpr unsigned char GS_GET_CAPABILITY = 0xd0;
inline constexpr unsigned char GS_RET_CAPABILITY = 0xd1;
inline constexpr unsigned char GS_GET_PARAM = 0xd6;
inline constexpr unsigned char GS_RET_PARAM = 0xd7;
inline constexpr unsigned char GS_SET_PARAM = 0xd8;
inline constexpr unsigned char GS_TYPE_BOOLEAN = 0x02;
inline constexpr unsigned char GS_PARAM_BOOLEAN = 0x01;
inline constexpr unsigned char GS_SLOT_FIRST = 0xd1;
inline constexpr unsigned char GS_SLOT_LAST = 0xd4;
inline constexpr unsigned char GS_DISPLAY_LANGUAGE_ENGLISH = 0x01;
// A setting that drops the Bluetooth links (turning multipoint on/off) is held until confirmed:
// the device sends 99 01 <message> <action type> and applies it on 98 01 <message> 01. It only asks
// a client that has sent 94 01 00 (Sound Connect does, once per connection); otherwise it declines the
// change with a d9 notification of the old value. From an iPhone Sound Connect capture, XM4 fw 3.0.1.
inline constexpr unsigned char ALERT_SET_STATUS = 0x94;
inline constexpr unsigned char ALERT_STATUS_ENABLE = 0x00;
inline constexpr unsigned char ALERT_NTFY = 0x99;
inline constexpr unsigned char ALERT_SET = 0x98;
inline constexpr unsigned char ALERT_FORMAT_FIXED = 0x01;
inline constexpr unsigned char ALERT_MULTIPOINT_ON = 0x06;
inline constexpr unsigned char ALERT_MULTIPOINT_OFF = 0x07;
inline constexpr unsigned char ALERT_ACTION_POSITIVE = 0x01;
}

// Second-generation (v2) inquiry/response command opcodes (the first payload byte).
Expand Down
123 changes: 121 additions & 2 deletions Client/Headphones.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,9 @@ void Headphones::probeCapabilities()

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; }
// f7 05 <kind> <on>. A WH-1000XM4 on fw 3.0.1 answers this GET with kind 00 (f7 05 00 01 when on)
// and uses kind 01 only in its change notifications (f9 05 01 01), so accept either.
if (r.size() >= 4 && (r[2] == 0x00 || r[2] == 0x01)) { std::lock_guard g(this->_propertyMtx); this->_speakToChat = r[3] != 0; this->_hasSpeakToChat = true; }
} catch (...) {}
return;
}
Expand Down Expand Up @@ -716,7 +718,11 @@ namespace

void Headphones::requestMultipoint()
{
if (this->_conn.getProtocolVersion() != SonyProtocolVersion::V2) return;
if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1)
{
this->_requestGeneralSettingsV1();
return;
}

Buffer initReply;
bool table2Supported;
Expand Down Expand Up @@ -936,6 +942,87 @@ 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; }

// The v1 general settings have no device list or playback pin (those are v2 table 2); only the settings
// slots, found by name once per connection from the function list, then just read.
void Headphones::_requestGeneralSettingsV1()
{
bool searched; int multipointSlot; int touchPanelSlot;
{
std::lock_guard guard(this->_propertyMtx);
searched = this->_multipointSlotSearched;
multipointSlot = this->_multipointSlot;
touchPanelSlot = this->_touchPanelSlot;
}

if (!searched)
{
try {
auto functions = this->_conn.sendCommandAndReadResponse({ (char)V1Command::SUPPORT_FUNCTION_GET, 0x00 },
V1Command::SUPPORT_FUNCTION_RET, 0x00);
size_t end = functions.size() >= 3 ? std::min(functions.size(), (size_t)3 + (unsigned char)functions[2]) : 0;
for (size_t i = 3; i < end; i++)
{
auto slot = (unsigned char)functions[i];
if (slot < V1Command::GS_SLOT_FIRST || slot > V1Command::GS_SLOT_LAST) continue;
try {
auto resp = this->_conn.sendCommandAndReadResponse(
{ (char)V1Command::GS_GET_CAPABILITY, (char)slot, (char)V1Command::GS_DISPLAY_LANGUAGE_ENGLISH },
V1Command::GS_RET_CAPABILITY, slot);
if (resp.size() < 4 || (unsigned char)resp[2] != V1Command::GS_TYPE_BOOLEAN) continue;
size_t nameLength = (unsigned char)resp[3];
if (4 + nameLength > resp.size()) continue;
std::string name(resp.begin() + 4, resp.begin() + 4 + nameLength);
if (name == "MULTIPOINT_SETTING") multipointSlot = slot;
else if (name == "TOUCH_PANEL_SETTING") touchPanelSlot = slot;
} catch (...) {}
}
} catch (...) {}
}

// d7 <slot> 01 <01=on/00=off>; anything else leaves the setting unconfirmed (hidden).
auto read = [this](int slot, bool& has, bool& enabled) {
has = false; enabled = false;
if (slot < 0) return;
try {
auto resp = this->_conn.sendCommandAndReadResponse({ (char)V1Command::GS_GET_PARAM, (char)slot },
V1Command::GS_RET_PARAM, slot);
if (resp.size() >= 4 && (unsigned char)resp[2] == V1Command::GS_PARAM_BOOLEAN && (unsigned char)resp[3] <= 1)
{
has = true;
enabled = resp[3] == 0x01;
}
} catch (...) {}
};
bool hasMultipoint, multipointEnabled, hasTouchPanel, touchPanelEnabled;
read(multipointSlot, hasMultipoint, multipointEnabled);
read(touchPanelSlot, hasTouchPanel, touchPanelEnabled);

std::lock_guard guard(this->_propertyMtx);
this->_multipointSlotSearched = true;
this->_multipointSlot = multipointSlot;
this->_touchPanelSlot = touchPanelSlot;
this->_hasMultipointSetting = hasMultipoint;
this->_multipointEnabled = multipointEnabled;
this->_hasTouchPanelSetting = hasTouchPanel;
this->_touchPanelEnabled = touchPanelEnabled;
}

bool Headphones::hasTouchPanelSetting() { std::lock_guard g(this->_propertyMtx); return this->_hasTouchPanelSetting; }
bool Headphones::getTouchPanelEnabled() { std::lock_guard g(this->_propertyMtx); return this->_touchPanelEnabled; }

void Headphones::setTouchPanel(bool enabled)
{
int slot;
{
std::lock_guard guard(this->_propertyMtx);
if (!this->_hasTouchPanelSetting) throw RecoverableException("This headset doesn't report a touch controls setting.");
slot = this->_touchPanelSlot;
}
this->_conn.sendCommand({ (char)V1Command::GS_SET_PARAM, (char)slot, (char)V1Command::GS_PARAM_BOOLEAN, (char)(enabled ? 0x01 : 0x00) });
std::lock_guard guard(this->_propertyMtx);
this->_touchPanelEnabled = enabled;
}
std::string Headphones::getMultipointDiagnostics() { std::lock_guard g(this->_propertyMtx); return this->_multipointDiagnostics; }

void Headphones::setMultipoint(bool enabled)
Expand All @@ -946,6 +1033,38 @@ void Headphones::setMultipoint(bool enabled)
if (!this->_hasMultipointSetting) throw RecoverableException("This headset doesn't report a multipoint setting.");
slot = this->_multipointSlot;
}
if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1)
{
// Applying this restarts the headset, so it acknowledges the write and then asks for confirmation
// (99 01 06/07 <action>), holding the change until answered. Only a multipoint alert is answered.
// It asks only once alerts are enabled (94 01 00), which is sent here rather than on every connect
// so no other confirmation prompts, which nothing here answers, get switched on.
this->_conn.sendCommand({ (char)V1Command::ALERT_SET_STATUS, (char)V1Command::ALERT_FORMAT_FIXED, (char)V1Command::ALERT_STATUS_ENABLE });
Buffer alert;
try {
alert = this->_conn.sendCommandAndReadResponse(
{ (char)V1Command::GS_SET_PARAM, (char)slot, (char)V1Command::GS_PARAM_BOOLEAN, (char)(enabled ? 0x01 : 0x00) },
V1Command::ALERT_NTFY, V1Command::ALERT_FORMAT_FIXED);
} catch (...) {}
if (alert.size() >= 4 && ((unsigned char)alert[2] == V1Command::ALERT_MULTIPOINT_ON || (unsigned char)alert[2] == V1Command::ALERT_MULTIPOINT_OFF)
&& ((unsigned char)alert[3] == 0x01 || (unsigned char)alert[3] == 0x02))
{
this->_conn.sendCommand({ (char)V1Command::ALERT_SET, (char)V1Command::ALERT_FORMAT_FIXED, alert[2], (char)V1Command::ALERT_ACTION_POSITIVE });
std::lock_guard guard(this->_propertyMtx);
this->_multipointEnabled = enabled;
return;
}
// No confirmation asked: read back what the device kept. A WH-1000XM4 on fw 3.0.1 answers d8 d2 01 00
// with d9 d2 01 01 (still on) and no alert, i.e. it declines the change.
auto resp = this->_conn.sendCommandAndReadResponse({ (char)V1Command::GS_GET_PARAM, (char)slot }, V1Command::GS_RET_PARAM, slot);
bool kept = resp.size() >= 4 && resp[3] == 0x01;
{
std::lock_guard guard(this->_propertyMtx);
this->_multipointEnabled = kept;
}
if (kept != enabled) throw RecoverableException("The headphones didn't accept the change.");
return;
}
// The state frame goes first (d8 <slot> 00 <00=on/01=off>), then the fixed apply commit 98 00 06 01.
// sendCommand waits for each ACK, which reproduces GadgetBridge's frame/ack interleaving.
this->_conn.sendCommand({ (char)V2Command::GS_SET_PARAM, (char)slot, 0x00, (char)(enabled ? 0x00 : 0x01) });
Expand Down
9 changes: 9 additions & 0 deletions Client/Headphones.h
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,14 @@ class Headphones {
int setPlaybackPinned(bool pinned);
// "Connect to 2 devices". The setting is device-persistent, so the app never enforces a default. Throws
// (without sending) unless requestMultipoint() found the MULTIPOINT_SETTING general-settings slot.
// On v1 the headset restarts to apply it, dropping this connection.
void setMultipoint(bool enabled);

// Touch controls on/off (v1 TOUCH_PANEL_SETTING general setting, found by requestMultipoint()).
bool hasTouchPanelSetting();
bool getTouchPanelEnabled();
void setTouchPanel(bool enabled);

bool isChanged();
void setChanges();

Expand Down Expand Up @@ -165,6 +171,8 @@ class Headphones {
// The WH-1000XM6 speaks a different layout for two v2 commands: noise-control state is read via inquired
// type 0x19 (0x17 answers all zeros), and the equalizer uses inquired type 0x04 with 10 raw bands.
bool _isWh1000Xm6() const;
// requestMultipoint() on v1: the multipoint and touch panel general settings (GETs only).
void _requestGeneralSettingsV1();
// Both return false if the device didn't answer that battery sub-type.
bool _requestDualBattery(unsigned char subType);
bool _requestSingleBattery(unsigned char subType, int& levelOut, bool& chargingOut);
Expand All @@ -188,6 +196,7 @@ class Headphones {
bool _hasPlaybackPinState = false; bool _playbackPinned = false;
bool _multipointSlotSearched = false; int _multipointSlot = -1;
bool _hasMultipointSetting = false; bool _multipointEnabled = false;
int _touchPanelSlot = -1; bool _hasTouchPanelSetting = false; bool _touchPanelEnabled = false;
std::string _multipointDiscoveryLog;
std::string _multipointWriteLog;
std::string _multipointDiagnostics;
Expand Down
1 change: 0 additions & 1 deletion Client/macos/AppDelegate.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,5 @@
#import "MacOSBluetoothConnector.h"

@interface AppDelegate : NSObject <NSApplicationDelegate>
@property (weak) NSWindow* window;

@end
11 changes: 3 additions & 8 deletions Client/macos/AppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,15 @@ @interface AppDelegate ()
@end

@implementation AppDelegate
// A menu-bar app (LSUIElement): no Dock icon or window; everything lives in the menu-bar panel.
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
_window = [[[NSApplication sharedApplication] windows] firstObject];
[MenuBarController install];
}

- (BOOL)applicationShouldHandleReopen:(NSApplication *)theApplication hasVisibleWindows:(BOOL)flag
{
if (flag) {
return NO;
}
else {
[_window makeKeyAndOrderFront:self];
return YES;
}
[MenuBarController showPanel];
return NO;
}

- (void)applicationWillTerminate:(NSNotification *)aNotification {
Expand Down
Loading