Skip to content

macOS: multipoint device list, switch playback device and fix it (closes #25) - #28

Merged
AmitRajput-Dev merged 2 commits into
AmitRajput-Dev:mainfrom
fredybp:multipoint-playback-switching
Oct 28, 2026
Merged

AmitRajput-Dev merged 2 commits into
AmitRajput-Dev:mainfrom
fredybp:multipoint-playback-switching

Conversation

@fredybp

@fredybp fredybp commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Closes #25

Adds Sound Connect's multipoint controls to the macOS app: see which devices are connected to the headset, which one has playback, switch playback to another device, and "fix" (pin) it — e.g. pin playback to the Mac before a call so the phone can't take over the headphones.

What's added

Devices card (v2 headsets that advertise it), right after Ambient Sound Control:

  • Connected and paired devices, with the playback device (speaker icon), the fixed state (lock) and a "This Mac" tag
  • Fix / Unfix playback
  • Per-device menu: "Switch playback device and fix" (Sound Connect's action) and "Switch playback device"
  • "Connect to 2 devices" on/off (read-only for now)
  • Loading state until the first read lands; spinner in the header during refreshes and switches
  • "Copy diagnostics": hex log of every multipoint request/reply, to help bring up other models

Menu-bar item

  • Pin Playback to This Mac, Unfix Playback, Connect, Open SonyBridge, Quit
  • The icon shows the playback device next to the headphones (phone, computer, or its slot number) and fills in while playback is fixed to this Mac
  • The device list is re-read every 10 s so the icon follows changes made from the phone

Protocol

The multipoint commands live in command table 2 (frame type 0x0e, DATA_MDR_NO2). BluetoothWrapper can now send on it, match replies on it and ACK its frames; existing table 1 calls are unchanged (new default parameter).

Request Reply
Support functions 06 00 07 00 n (function, priority)… — needs 0x31 and 0x32/0x30
Device list 36 02 (36 00 fallback) 37 02 n {addr[17] slot cod[3] len name}… playbackSlot
Fixed state 36 01 37 01 v (0 = fixed, 1 = free)
Switch playback 3c 01 addr[17] 3d 01 result addr
Fix / unfix 38 01 00 / 38 01 01 39 01 value result
Multipoint setting (table 1) d0 d1..d4 01 to find MULTIPOINT_SETTING, then d6 slot d7 slot 00 v (0 = on)

Safety:

  • Table 2 commands only go out with frame type 0x0e (in table 1, 0x30–0x3f are the firmware-update commands), only to v2 devices, and only if the init reply advertises table 2. v1 devices get nothing new.
  • Discovery is read-only. Writes are refused locally unless the headset advertised function 0x31, and switching only targets devices the headset lists as connected.
  • "Switch and fix" = unfix if fixed → switch → fix; if the switch is refused, it re-fixes the previous device. A plain switch unfixes afterwards, since the headset keeps "fixed" on across a switch.
  • The state is re-read from the headset after every write.

Opcodes and layouts come from mos9527/SonyHeadphonesClient (ProtocolV2T2.hpp, generated from Sound Connect) and mehrshaad/nullpoint.

Tested

WH-1000XM5, firmware 2.5.1, on macOS:

  • Device list, playback device and fixed state match Sound Connect
  • Switch to the iPhone and back, switch and fix, plain switch ends unfixed
  • Unfix during a call is refused by the headset (39 01 00 02, FAIL_CALLING), so playback stays fixed to the Mac
  • Multipoint setting found in slot D2 (ON)

I also replayed the WF-1000XM5 captures from mos9527's repo through the parser.

Not covered: the Windows/Linux UI (the core changes are shared and keep the existing call sites, but I only built the macOS app), disconnect/unpair, and writing the multipoint on/off setting.

macOS 12 needed to build with Xcode 27.2

Xcode 27.2 no longer builds the project as-is: it rejects MACOSX_DEPLOYMENT_TARGET = 11.0 ("the range of supported deployment target versions is 12.0 to 27.2.x"). To build and test this I raised it to 12.0 in all four build configurations of SonyHeadphonesClient.xcodeproj. I left that change out of this PR so the app keeps targeting macOS 11 — it's a one-line-per-config change in this commit on my fork if you want to take it too.

Disclosure

This was mainly vibe coded (written with an AI assistant; I drove it and tested it on my headphones). If that's a problem for the project, feel free to reject it.

Adds Sound Connect's "Switch playback device and fix" for v2 headsets, so
playback can be pinned to the Mac (e.g. for calls) and the phone can't take
it over.

Transport: BluetoothWrapper can now send and match on command table 2
(DATA_MDR_NO2, 0x0e) and ACKs table 2 frames. The tables are separate
command spaces - in table 1, 0x30-0x3f are the firmware-update commands - so
the multipoint opcodes only ever go out on 0x0e.

Discovery (GETs only, v2 only, gated on the init reply advertising table 2):
support functions (06 00), device list (36 02 / 36 00), fixed state (36 01),
and the MULTIPOINT_SETTING general-setting slot found by name (d0 d1..d4),
then its value (d6). Raw replies are kept for "Copy diagnostics".

Writes (each refused locally unless discovery saw function 0x31):
- switch playback: 3c 01 <address> -> 3d 01 <result> <address>
- fix / unfix:     38 01 00 / 01   -> 39 01 <value> <result>
"Switch and fix" = unfix if fixed, switch, fix; if the switch is refused it
re-fixes the old device. A plain switch unfixes afterwards, since the
headset carries "fixed" across a switch. State is re-read after every write.

UI: Devices card (connected/paired, playback and lock icons, This Mac tag,
Fix/Unfix, per-device switch menu) and a menu-bar item with "Pin Playback
to This Mac" whose icon shows the playback device (phone, computer or slot
number). The device list is re-read every 10 s so the icon follows changes
made from the phone.

Opcodes and layouts from mos9527/SonyHeadphonesClient (ProtocolV2T2.hpp)
and nullpoint. Verified on a WH-1000XM5 fw 2.5.1: list, switch, fix,
unfix, and the FAIL_CALLING refusal (39 01 00 02) during a call.
- Read the multipoint state (device list, fixed state) right after
  battery/EQ/DSEE instead of after the capability probes, so the playback
  device appears with the battery rather than after any probe timeouts.
- Devices card shows a "Reading devices from the headset..." placeholder
  until the first read lands, and a spinner in its header during refreshes,
  switches and fixes.
- Move the Devices card up, right after Ambient Sound Control.
@AmitRajput-Dev

Copy link
Copy Markdown
Owner

Merged via #30. Thank you, multipoint was the most requested feature (#25).

A review before merging turned up a few issues, all fixed in the merge:

  • Possible crash on disconnect: the multipoint queue captured a raw Headphones*, which could be freed while a read was still running. Everything now runs on the single serial I/O queue with a shared_ptr.
  • Slower first read: the general-settings slot search ran before the first battery/EQ update, which could delay it by ~10 s. It now runs after the capability probes.
  • Lost after a reconnect: after the headset dropped the link, a reconnect skipped the init handshake, so table 2 and multipoint went missing.
  • Stale switch/fix decisions: they read the device list on tap. They're now made inside the queued write.
  • Card shown too broadly: the Devices card only appears when the headset actually returned a device list or a pin state.

The menu-bar item now opens a quick-controls panel, and your menu is still there on right-click. Ships in v0.4.0.

fredybp pushed a commit to fredybp/SonyBridge that referenced this pull request Nov 4, 2026
… fuzzing/hardening

Conflicts with AmitRajput-Dev#28 (multipoint) resolved:
- Multipoint "Connect to 2 devices": keep AmitRajput-Dev#28's name-based general-settings
  slot lookup (MULTIPOINT_SETTING in D1..D4) and drop AmitRajput-Dev#7's hard-coded 0xd1
  touch-sensor probe; AmitRajput-Dev#7's GadgetBridge write (d8 <slot> 00 <v> + 98 00 06 01)
  now targets the discovered slot. One set of bridge/model properties.
- Bridge: keep AmitRajput-Dev#7's shared_ptr + single serial I/O queue; multipoint runs on
  it too (its own queue bought nothing behind the connector mutex). Slot
  search moved after the capability probes so battery/EQ show immediately.
- initDevice: v1/v2 init from AmitRajput-Dev#7; table-2 flag only on v2.
- Reset _initialized on every new session and release the dead session when
  the headset drops the link, so a reconnect re-runs the handshake.
- Switch/fix decisions read playback/pin state inside the queued write.
- Devices card shown only when the headset returned a list or pin state.
- BluetoothWrapper: one zero-byte recv guard (AmitRajput-Dev#21 and AmitRajput-Dev#7 both added one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fredybp pushed a commit to fredybp/SonyBridge that referenced this pull request Nov 4, 2026
requestBattery() probed the single-battery sub-type first and returned on
any reply, so TWS earbuds never reached the dual probe and L/R stayed unset.
Probe order now follows the model name (WF-*/LinkBuds try dual first), with
a fallback to the other layout either way.

- add DUAL2 (0x01) for the WF-C500/C510/C700N line
- keep the per-earbud charging flags instead of discarding them
- treat a level of 0 as "not reporting" (docked), not a flat battery
- read the charging case (0x0a)
- re-read battery on the dynamic poll so it stops going stale (v2 only:
  0x22 is POWER_OFF on v1)

Sub-types and byte offsets verified against a WF-1000XM5 by docking one
earbud and watching byte [4] track it; captured frames are in Constants.h.

(cherry picked from commit ff1605d)

Conflict resolution on top of AmitRajput-Dev#7/AmitRajput-Dev#28 (by maintainer):
- keep AmitRajput-Dev#7's v1 battery branch (10 00); per-earbud probing is v2-only
- a 0/0 dual reply is a real answer (both earbuds docked), not "unsupported":
  rejecting it fell through to DUAL2/single probes that time out, stalling
  the command queue ~5s on every battery poll
- battery re-read runs on the serial command queue (no _dynamicTick race)
  and model writes are change-gated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fredybp pushed a commit to fredybp/SonyBridge that referenced this pull request Nov 4, 2026
A short audio dropout was enough to desync the response stream for the rest
of the session: changing NC/Ambient or an EQ preset reverted a second or two
later, and DSEE wouldn't toggle at all.

_readMessage() left the parser stranded whenever a chunk arrived garbled or
truncated - bytes before the first START_MARKER were concatenated into the
message, and a second start marker threw. 60/61/62 are escaped inside a
frame, so a start marker unambiguously begins one; resync on it in both
cases, and cap the buffer so a stream that never delivers an END_MARKER
can't grow it forever.

_waitForAck() read exactly one frame and assumed it was the ACK. The device
pushes unsolicited notifications on the same stream (a state change, a press
of the headset's own button), so it could take _seqNumber from the wrong
frame - and the device dedupes on that sequence number, silently discarding
the next command as a duplicate. Wait for an actual ACK frame instead;
skipped notifications are still acked back by _readMessage().

Also:

- requestAmbientState() matches sub-type 0x17 rather than any 0x67, so a
  notification with a different layout is no longer parsed as the reply
- the read-back no longer overwrites a change that hasn't been sent yet.
  It cleared the pending change too (isChanged() went false), so the command
  was never sent at all. discardAmbientChanges() releases that state when a
  write genuinely fails, so polling can't stay suppressed
- the 2s poll stopped republishing dsee/eqPreset/eqBands, which it never
  re-read - they came straight back from the C++ cache, which only updates
  once a write succeeds. Failed writes roll back explicitly with the error
  instead, and the EQ sliders no longer fight the timer mid-drag

(cherry picked from commit 40e65d4)

Conflict resolution on top of AmitRajput-Dev#7/AmitRajput-Dev#28 (by maintainer): _waitForAck kept AmitRajput-Dev#7's
equivalent ACK loop (now on MAX_FRAMES_PER_TRANSACTION); parser keeps this
commit's resync-on-START_MARKER plus AmitRajput-Dev#7's noise skipping; the poll's
change-gated writes and AmitRajput-Dev#7's slider coalescing are kept, with this commit's
"sync either way" on a failed ambient write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi Device Support

2 participants