Skip to content

v0.4.0: community fixes, WH-1000XM6 support, and a new look - #30

Merged
AmitRajput-Dev merged 45 commits into
mainfrom
feature/v0.4-makeover
Oct 28, 2026
Merged

AmitRajput-Dev merged 45 commits into
mainfrom
feature/v0.4-makeover

Conversation

@AmitRajput-Dev

Copy link
Copy Markdown
Owner

SonyBridge in its three themes

v0.4.0: community fixes, WH-1000XM6 support, and a new look

Community contributions

Merged as-is (their commits are kept, so GitHub marks these PRs as merged):

Cherry-picked, with authorship kept:

WH-1000XM6 (closes #11)

  • Noise-control state is read via inquired type 0x19. The XM6 answers 0x17 with zeros, which made the UI snap back to Off.
  • Any zeroed reply is now ignored, on every model, instead of being read as "Off".
  • The 10-band equalizer uses inquired type 0x04 with raw 0–12 bands and no Clear Bass.
  • The byte layouts come from btsnoop captures and hardware re-tests by sony-device-center contributors (#44, #74 there).

macOS app

  • Three themes: Refined Dark, Native Glass (follows light/dark) and Sony Signature. Pick one under ⋯ → Appearance.
  • Menu-bar quick controls: click the menu-bar icon for mode, ambient level, Focus on Voice, EQ preset, battery and playback pinning. Right-click still opens the menu.
  • Taller, resizable window: it remembers its size, and past 720pt wide it splits into two columns. Multipoint (closes Multi Device Support #25).
  • Minimum is now macOS 12, because Xcode 27 no longer builds for 11.

Docs

  • Homebrew: a one-line install that taps automatically (brew install --cask amitrajput-dev/tap/sonybridge, closes Issues when cask to sonybridge #9).
  • Screenshots: high-res screenshots of every theme, the menu-bar panel and the wide window.

Verified

  • Debug and Release builds with Xcode 27.1.
  • Offline suites:
    • XM4, multipoint and XM6 protocol tests;
    • 18 + 6 golden frames;
    • 56,109 metamorphic checks;
    • connector receive-buffer tests.
  • Live on a WH-CH720N: connect, mode switching, theme switching and the multipoint device list.
  • Not tested on hardware here: XM6, XM4 and the Windows/Linux builds.

🤖 Generated with Claude Code

matt and others added 30 commits August 24, 2026 07:28
…en tests

In-progress work from the Codex session: XM4 (WH-1000XM4 v1) protocol support,
14 verified bug fixes (connection-thread crashes, command/notification races),
18 exact Bluetooth wire-format golden tests, and protocol-conformance harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…parser

Found uncommitted in the working tree (authored by the earlier audit pass,
not this session); committed verbatim so the tree is clean and the analysis
is not lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Message lives in the CommandSerializer namespace, and its seqNumber field is
unsigned char; the harness used the unqualified name and narrowed through a
signed char cast, so the libFuzzer target never built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- -max_len referenced MAX_BLUETOOTH_MESSAGE_SIZE as a shell variable, which
  is never set, so 'set -u' killed the script before fuzzing; derive it from
  Constants.h instead.
- Apple's clang++ has no libFuzzer runtime and the failure only appears at
  link time; probe with a real link and fall back to a Homebrew LLVM clang++.
- When no libFuzzer-capable compiler exists at all, build driver_main.cpp: a
  deterministic seed-replay + structured-mutation driver (bit/byte flips,
  truncation, length-field tampering, escape/marker injection, splicing)
  over the same LLVMFuzzerTestOneInput entry point under ASan/UBSan.
- Suppress libFuzzer's own never-joined RSS-monitor thread (lsan.supp) so
  leak detection stays on for the parser; skip detect_leaks on Apple ASan,
  which does not support it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both the fuzz campaign (8.2M execs, zero parser bugs) and the static audit
flagged the same latent issue: three malformed-frame paths in unpackBtMessage
threw bare std::runtime_error instead of the RecoverableException used by the
sibling checks. Safe only because every current call site catches
std::exception; a future site narrowing its catch would turn attacker-
controlled input into std::terminate (remote DoS). Made all three throw
RecoverableException(msg, true), matching the existing malformed-frame throws,
and added regression assertions. Also broadened the LSan suppression from
fuzzer::StartRssThread to fuzzer:: to cover an intermittent symbolizer leak in
libFuzzer's PC printer (parser never on the stack).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…corruption)

Covers the gap between the exact golden fixtures and the crash-only
fuzzer with relational properties over programmatically generated
inputs (fixed-seed splitmix64, fully deterministic):

- P1 round-trip: unpack(strip_markers(pack(p, dt, seq))) recovers
  (p, dt, seq & 0xFF); pack output is also structurally verified
  against an independent re-implementation of the documented layout
  (declared BE size, mod-256 checksum, header bytes).
- P2 escaping transparency: every escapable byte (60/61/62) at every
  payload position round-trips; the frame interior never contains a
  raw marker byte.
- P3 seq domain: 0..255 round-trips exactly; wider values pack
  byte-identically to seq & 0xFF.
- P4 framing: +/-1-byte damage at either end and marker-attached
  frames are all rejected with RecoverableException.
- P5 determinism and inverse consistency: pack is byte-identical
  across calls; repacking an unpacked message reproduces the frame.
- P6 corruption: exhaustive single-byte replacement (all 255 values,
  every position, four frames incl. escape-dense ones) plus per-bit
  flips on a 512-byte-payload frame - 31270 corrupted bodies, all
  rejected recoverably, none silently parsed.
- P7 size boundary: 2039 plain payload bytes fill the 2048 frame
  exactly; 2040 overflows; escape-heavy payloads halve the budget
  (1016 x 0x3C fits, 1020 does not).

Wired into Client/tests/run-macos.sh after the conformance suite,
same clang++ -Wall -Wextra -Werror invocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The RFCOMM data delegate buffered every frame unconditionally, but recv()
only drains the queue during a command exchange, so a device streaming
unsolicited frames grew receivedBytes without bound. Cap the buffered
total at RECEIVE_BUFFER_CAP, dropping the oldest chunks; the wrapper's
marker scan resynchronizes on what survives.

A zero-length frame also made recv() return 0, which BluetoothWrapper
reads as a closed connection - drop those at the delegate boundary
instead of fabricating a disconnect.

Covered by new offline tests that inject frames exactly as the delegate
would, with no Bluetooth channel opened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnKJraRSytaMh5E2G57ifx
[[device addressString] UTF8String] on a nil addressString hands
std::string a null char*, which is undefined behaviour.
getConnectedDevices() already guards the same call; apply the same
check on the scan-and-connect path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnKJraRSytaMh5E2G57ifx
56k relational checks (round-trip, escaping transparency, single-byte corruption
rejection, framing/truncation) over the protocol layer. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bounds the macOS connector receive queue (fixes unbounded attacker-driven
allocation / OOM), drops zero-length frames, guards a nil addressString.
Regression tests for the buffer cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	Client/tests/run-macos.sh
Every integer step of an ambient-level or custom-EQ slider drag was
enqueuing a full Bluetooth exchange on the serial command queue
(~70ms measured per exchange, up to 2500ms worst-case each), so a
20-step drag queued ~1.4s+ of stale intermediate sends that kept
hitting the headset after the user released.

HeadphonesModel now mirrors the in-flight gate the ImGui client
already uses (CrossPlatformGUI _sendFeatureCommand): the first change
sends immediately; changes arriving while an exchange is in flight
only overwrite a pending snapshot; when the exchange completes, at
most one more send fires, and only if the pending snapshot differs
from what was last sent. The pending value is an explicit snapshot
(not re-read from the model at completion time) so syncFromBridge or
the 2s poll overwriting @published state cannot clobber the user's
latest drag value. All gate state is main-thread-confined - bridge
completions arrive on the main queue - so no locks are needed.

Isomorphism: end-state is unchanged - the user's final value always
reaches the device (on failure with a newer pending value, the pending
value is still sent; pending is cleared before resending so it is
never sent twice), single clicks still send immediately with no added
latency, and a drag now costs <=2-3 exchanges instead of one per step.
Discrete controls (mode buttons, toggles) are untouched. disconnect()
drops pending snapshots so an intentional disconnect does not fire a
doomed resend.

Verified: 4 macOS test suites green (protocol safety, 18 golden,
56109 metamorphic checks, connector buffer), 12s fuzz smoke exit 0,
Release xcodebuild clean with no new warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every 2s poll tick and every interaction completion wrote 8-12 @published
properties unconditionally; each setter fires objectWillChange, so the whole
ContentView body re-evaluated every tick even when nothing changed. The wire
poll only refreshes ambient state, so most per-tick writes (EQ/DSEE re-read
from cached bridge values) were provably no-ops that still forced a full
re-render — the recurring 0.4-1.2% idle CPU blips in
.hotspots/evidence/connected_idle_cpu_series.txt, pure waste on battery.

Mechanism: a generic assignIfChanged(keyPath, value) helper writes a
@published property only when the new value differs from the current one,
applied to both the refreshDynamic poll completion and syncFromBridge. The
eqBands candidate array is still built the same way, then compared before
assignment instead of assigned unconditionally.

Isomorphism: reads, read order, and poll cadence are untouched — only writes
became conditional. Equal value -> no write -> visible state identical;
different value -> write fires exactly as before, so every device-side change
still lands in the UI on the same tick it did before. The slider-coalescing
convergence from the previous commit is preserved: after a successful push,
syncFromBridge still overwrites mode/ambientLevel whenever device truth
differs from the UI, and skipping the write when they are equal is
indistinguishable. First sync after connect still fires every write, since
all values transition from their defaults.

Verified: 4 test suites green, fuzz smoke exit 0, Release build succeeded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Release configuration never chose a C++/ObjC++ optimization level:
GCC_OPTIMIZATION_LEVEL was absent (Xcode default -Os), DEAD_CODE_STRIPPING
resolved to NO, and SWIFT_COMPILATION_MODE was unpinned. Every C++ profile
of the protocol stack was bounded by unchosen codegen (HOTSPOTS rank 3).

Changes (Release blocks only, Debug untouched):
- project-level Release: GCC_OPTIMIZATION_LEVEL = 2 (explicit -O2 —
  deliberate speed-over-size for the protocol paths; -O3 skipped, no
  measured win to justify vectorization bloat)
- project-level Release: DEAD_CODE_STRIPPING = YES
- target-level Release: SWIFT_COMPILATION_MODE = wholemodule (pins the
  Xcode-default; SWIFT_OPTIMIZATION_LEVEL already -O)

Considered and skipped: LTO (unmeasured benefit vs real build-time and
debuggability cost), C++ standard cleanup (gnu++14 vs c++17 hygiene, not
this pass's lever).

Isomorphism: config-only, zero source changes — -O2 vs -Os is a
codegen-level change with identical observable semantics, and dead-code
stripping removes only unreferenced symbols.

Verification:
- Resolved Release (was: GCC_OPTIMIZATION_LEVEL unset, DEAD_CODE_STRIPPING
  = NO, no SWIFT_COMPILATION_MODE) → now GCC_OPTIMIZATION_LEVEL = 2,
  DEAD_CODE_STRIPPING = YES, SWIFT_COMPILATION_MODE = wholemodule
- Resolved Debug unchanged (0 / NO / -Onone)
- Clean Release build: BUILD SUCCEEDED, no new warnings
- Binary: 853,392 → 730,304 bytes (-14.4%; dead-strip outweighs -O2 growth)
- Tests: all 4 suites green (protocol safety, 18 golden, 56,109
  metamorphic checks, connector buffer); fuzz smoke exit 0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…a sourceless run loop

Live profiling (10s @1ms, connected to a physical XM4) proved the connectToMac
pump loop dead weight: all 8537/8537 connector-thread samples sat in
__psynch_cvwait at the wait_for, zero in the runUntilDate pump, while inbound
RFCOMM data was delivered by -[IOBluetoothRFCOMMChannel stream:handleEvent:]
on the MAIN thread's run loop (CFSocket source scheduled there). The connector
thread's run loop has no sources; it never delivered anything.

Change: replace the 100ms-pump/1000ms-timed-poll alternation with a plain
cv wait on running. Removing the timed re-poll unmasks a lost-wakeup race
(running is atomic, stored lock-free by notifiers; a notify landing between
the waiter's predicate check and its block was previously bounded at 1s,
now would sleep forever), so every running=false setter — disconnect(),
handleChannelClosed(), connect()'s catch — now stores under
disconnectionMutex before notifying.

Hazards traced:
1. Channel lifetime: the wait keeps connectToMac's frame (and its strong
   `channel` ref) alive exactly as the loop did; release ordering at frame
   exit vs running=false/notify unchanged. closeConnection() retains its own
   strong ref before closeChannel; disconnect() still joins before the
   connector object is destroyed (destructor + connect() catch paths).
2. Open-complete: connectPromise.set_value() fires synchronously after
   openRFCOMMChannelAsync returns success, before the wait — connect() never
   depended on the delegate callback; rfcommChannelOpenComplete: exists only
   under SHC_DEBUG_PROTOCOL and only logs. All delegate callbacks arrive on
   main (profiled), so the pump was load-bearing for nothing.
3. connect() error path kept coherent: catch stores running=false under the
   mutex like the other setters.
4. recv()'s receiveDataConditionVariable path untouched.

Isomorphism: connect, data flow, and disconnect end states unchanged (data
never flowed through this thread). Disconnect wakeup is now immediate instead
of worst-case ~1s, and the thread does zero spurious wakeups (was ~2/s).
Offline test added: both running=false setters wake the exact connectToMac
wait, 200 interleavings, lost wakeup = test failure.

Verified: 4 test suites green, FUZZ_SECONDS=10 fuzz exit 0, Release
xcodebuild BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Speak-to-Chat pauses audio whenever the mics hear speech, which is
hostile to anyone who dictates constantly — every utterance stops the
music. This adds a persisted preference (UserDefaults key
KeepSpeakToChatOffOnConnect) that forces S2C off once per connect.

Mechanism: connect() arms a per-connection flag; the refreshStatus
completion — which fires a second time after probeCapabilities confirms
optional features — consumes the flag the first time hasSpeakToChat is
true, and only then sends a single setSpeakToChat(false) if the pref is
on and the device reports S2C enabled. Devices without S2C, or with it
already off, never receive a write; the flag also clears on disconnect,
so the disable fires at most once per connection. The manual toggle is
untouched: turning S2C on mid-session sticks until the next connect.

The pref defaults to true per this build's user request; upstream may
prefer defaulting to false — it's a one-line change (the `?? true` in
HeadphonesModel.swift). The secondary "Keep off on connect" toggle sits
under the S2C switch and is hidden with it when the capability is
absent, matching the existing gating pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v2 devices only, byte layout proven by Gadgetbridge on the WH-ULT900N
(ULT WEAR): commits 8cbc309346 ("WIP add ConnectTwoDevices feature"),
e8520865f3 ("fix: setting Connect Two Devices"), 5d7b37d1b7 ("fix read
Connect Two Devices"), files SonyProtocolImplV2.java and
SonyHeadphonesProtocol.java. State rides touch-sensor subtype d1 with an
inverted enable bit (GET d6 d1 -> RET d7 d1 00 <00=on/01=off>); a SET
sends d8 d1 00 <00=on/01=off> then the fixed apply commit 98 00 06 01
for both directions. The system-control RET (97 00 06) always reports 01
and is never used as a state source.

v1 (WH-1000XM4) gets no multipoint on purpose: Gadgetbridge's
SonyProtocolImplV1 explicitly logs "Connect two devices not implemented
for V1" and returns null, so no proven v1 frames exist. The v1 test now
asserts the XM4 path never emits any multipoint opcode and that
setMultipoint throws.

The probe (d6 d1) is appended to the v2 probeCapabilities sequence; the
toggle is capability-gated and mirrors the device-persistent state with
no app-enforced default. Full research trail with the negative findings
in docs/MULTIPOINT_RESEARCH.md; exact framing pinned in
conformance/fixtures/ult900n_v2_multipoint.golden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The custom-EQ sliders were limited to 0..10, so every band could only be
boosted — cutting a band was impossible from the Windows/Linux UI, and a
negative value read back from the headphones (`requestEqualizer()` decodes
`byte - 10`) was clamped to 0 by the widget and pushed back on the next edit.

Both ends of the protocol already use the signed range: `setEqualizerCustom()`
clamps to [-10, 10] and sends `value + 10`. The macOS UI uses -10...10 too, so
this also makes the two interfaces agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_deviceTexture()` pushed the freshly created `ImTextureData` straight into
`ImGui::GetPlatformIO().Textures`, but `UpdateTexturesEndFrame()` resizes that
list to 0 each frame and refills it from the font atlases plus the registered
user textures. The entry was therefore gone before the renderer backend could
honour `ImTextureStatus_WantCreate`, and the texture was never uploaded:

- Release: the product image area is blank; every device falls back to nothing.
- Debug: `IM_ASSERT(tex_id != ImTextureID_Invalid && "ImDrawCmd is referring to
  ImTextureData that wasn't uploaded to graphics system...")` fires.

`ImGui::RegisterUserTexture()` (imgui 1.92+) adds it to `g.UserTextures`, which
is what the per-frame rebuild reads from, so the upload request survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n link loss

Three related problems around teardown on Windows:

1. `BluetoothWrapper::disconnect()` took `_connectorMtx` before closing the
   socket, but a worker thread holds that same lock while blocked in `recv()`.
   Closing the socket is what releases that thread, so the UI thread waited on
   the lock — Disconnect froze the window until the 2.5s recv timeout expired,
   and closing the app while a read was in flight hung it. On Windows, close
   first, then take the lock; other platforms keep the old order.

2. `_socket` was written by the UI thread (disconnect) while a worker thread
   read it inside `send()`/`recv()` — a data race that could hand `recv()` a
   closed or recycled handle. It is now `std::atomic<SOCKET>`, swapped through
   a single `_closeSocket()`.

3. `recv()` returning 0 (graceful close by the headphones) was treated as a
   successful read of an empty buffer, so `BluetoothWrapper::_readMessage()`
   looped forever looking for a start marker — one core pegged until the app
   was killed. Both the connector and the wrapper now treat it as a
   disconnect.

Also loops `send()` until the whole frame is out: a partial send left the
device waiting for the rest of a command that never arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Windows target only ever built under MSVC:

- `#include <BluetoothAPIs.h>` — the MinGW-w64 header is `bluetoothapis.h`, and
  the case also matters when cross-compiling from a case-sensitive filesystem.
- `#pragma comment(lib, ...)` is MSVC-only, so ws2_32/rpcrt4/bthprops were never
  linked anywhere else: ~25 undefined references to closesocket, WSAStartup,
  UuidFromStringA, BluetoothFindFirstRadio and friends.
- `#include "Windows.h"` in WindowsGUI.h fails the same way.

Link the import libraries from CMake instead, keep the pragmas for MSVC only,
and move NOMINMAX/WIN32_LEAN_AND_MEAN to the target so every translation unit
gets them, not just CrossPlatformGUI.cpp.

Verified by cross-compiling the Windows target with x86_64-w64-mingw32-g++ 13.
MSVC is unaffected: it keeps both the pragmas and the CMake libraries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`requestBattery()` only runs in the post-connect read, so the percentage in the
header is a snapshot from the moment you pressed Connect. Leave the app open
for a few hours and it still shows that first value while the real headset
drains — and on a charging headset it never climbs.

Piggyback a battery read on the existing ambient poll worker, once every ~30s
(`BATTERY_POLL_FRAMES`), so no extra thread or future is needed. The read is
guarded: a device that doesn't answer the inquiry keeps its stale value instead
of dropping the poll.

Verified against a simulated v2 device whose reported level changes over time:
the header follows it instead of freezing at the connect-time value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The v0.3.3 ULT WEAR fix changed MacOSBluetoothConnector::getConnectedDevices,
but the macOS app connects through HeadphonesBridge.scanAndConnect, which
only auto-selects a paired device that is isConnected AND whose name matches
WH-/WF-/WI-/MDR-/XB/LinkBuds. "ULT WEAR" matches neither, so the app fell
back to IOBluetoothDeviceSelectorController, which lists only discoverable
devices and showed an empty list.

- add "ULT" to the Sony name prefixes
- if no connected match, fall back to any paired device that looks like a
  Sony headset (BLE/audio-only links report isConnected == NO)

Assisted-by: Nikša Barlović <catcam@gmail.com>
Tested-by: Nikša Barlović <catcam@gmail.com>
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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Amit Rajput and others added 15 commits October 27, 2026 15:59
…ning

Conflicts with #28 (multipoint) resolved:
- Multipoint "Connect to 2 devices": keep #28's name-based general-settings
  slot lookup (MULTIPOINT_SETTING in D1..D4) and drop #7's hard-coded 0xd1
  touch-sensor probe; #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 #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 #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 (#21 and #7 both added one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 #7/#28 (by maintainer):
- keep #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>
rfcommchannel was a non-owning __bridge pointer that was never cleared when
the link dropped, so a poll already in flight would writeSync: to a released
channel and crash (EXC_BAD_ACCESS / pointer auth failure). Docking an earbud
was enough to trigger it.

Retain the channel and clear it under a mutex, so a concurrent send sees
either a live channel or nil; send() now raises a recoverable error instead.

Also fixes a self-join: the RFCOMM delegate runs on uthread's own run loop,
so rfcommChannelClosed -> disconnect() was joining that thread from itself.
recv() now wakes on disconnect rather than waiting out its full timeout.

(cherry picked from commit ac78f13)

Conflict resolution on top of #7 (by maintainer): #7 already fixed the self-join
and the recv wake-up; kept its version of those and applied this commit's core
fix to it - the channel pointer is now an owning reference guarded by
channelMtx (send/isConnected take a strong ref under the lock), instead of
#7's unretained atomic pointer, which a remote close could still free under
an in-flight send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The icon was a full-bleed square, which looks wrong in the Dock. Reshape to
Apple's grid: 824x824 body centred in 1024, continuous (not circular) corner
curve, subtle drop shadow. Artwork unchanged.

(cherry picked from commit ae4e0c7)
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 #7/#28 (by maintainer): _waitForAck kept #7's
equivalent ACK loop (now on MAX_FRAMES_PER_TRANSACTION); parser keeps this
commit's resync-on-START_MARKER plus #7's noise skipping; the poll's
change-gated writes and #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>
Selecting Manual showed the previous preset's slider positions, not the
custom curve stored on the device. The bands were only ever read once, on
connect, where the device reports the curve of whatever preset is active -
setEqualizerPreset() then sent 58 00 <preset> 00 and updated the cached
preset byte alone, so the cached bands stayed behind. Touching a slider from
there wrote those stale values back, overwriting the stored curve.

Re-read with requestEqualizer() after the write, so the cache holds the
bands that belong to the preset the device just switched to. The read-back
is guarded: it runs after the property lock is released (requestEqualizer()
takes the same non-recursive mutex), and a failed read leaves the bands
stale rather than reporting the write that already landed as failed.

The macOS model publishes eqPreset/clearBass/eqBands on a successful preset
write, and the ImGui GUI pulls them into its sliders once the command
future completes - neither re-read them on any other path.

(cherry picked from commit 43a9221)
fixes #11)

WH-1000XM6 answers the 66 17 noise-control inquiry with all zeros in every
mode, which the 2s poll parsed as "Off" - so Noise Cancelling / Ambient
switched the headset but the UI snapped straight back. It reports the same
layout under inquired type 0x19. Its equalizer uses inquired type 0x04 with
10 raw bands (0..12) and no Clear Bass slot; the 5-band 58 00 frame is ACKed
but ignored, which is why presets highlighted without changing the sound.

- 66 19 on the XM6; any v2 reply whose byte [2] isn't 01 is ignored, so a
  zeroed answer can no longer read as "Off" on other models either
- 56/58 04 ten-band read/preset/custom on the XM6; band count and the
  ten-band flag exposed to the UI, which shows 10 raw sliders, no Clear
  Bass, and only the Off/Custom presets (other XM6 preset codes are unnamed)
- tests: XM6 frames, the zeroed-0x17 guard, and #7's multipoint test moved
  to the name-based general-settings slot lookup

Layouts from btsnoop captures and hardware re-tests on a WH-1000XM6
(fw 3.1.5) by marconvcm/sony-device-center contributors (#44, #74).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The landing page told people to run `brew install --cask sonybridge`, which
fails: the cask lives in our tap, not homebrew/cask. The fully-qualified
name taps on first use. Also bump the stated minimum to macOS 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same single-window layout; every control, condition and binding unchanged.

- Refined Dark (default): today's look, polished - segmented mode control,
  status chips (battery / codec / mode), EQ curve preview, hairline cards
- Native Glass: follows system light/dark, material cards, system accent
- Sony Signature: near-black with a warm amber accent, rings behind the
  product, pill controls, EQ bars
- Theme picker under Appearance in the device-info popover, remembered
  across launches; applied through NSApp.appearance because
  .preferredColorScheme(nil) doesn't return a macOS window to the system
  appearance once it has been forced dark
- Theme tokens and one card modifier replace the hard-coded colours and
  the six copies of card chrome; macOS 12 is now the minimum (materials;
  Xcode 27 no longer builds for 11)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Sony Signature rings stay inside the hero instead of running into the
  button row and the device name
- mode labels get room inside their segments, so "Noise Canceling" no
  longer runs edge to edge
- every switch sits on the trailing edge of its card
- Sony Signature pills are real capsules: a continuous rounded rectangle
  with a radius near half its height flattens its ends

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Menu bar: a click on the status item now opens a panel with the everyday
controls - mode, ambient level, Focus on Voice, EQ preset, battery and
codec, playback pin/unfix, Open and Quit - so the window isn't needed for
a quick switch. Right-click (or control-click) still shows the menu.
MenuBarController moves out of ContentView.swift into its own file, and
the pin helpers (this Mac / pinned / playback device) move onto the model
so the menu and the panel share them.

Window: opens at 400x820 (fitted to the screen) instead of 380x560,
remembers the size it's left at, and can be widened to 1200pt; past 720pt
the connected view splits into two independently scrolling columns (hero
and controls left; devices, EQ, DSEE and settings right). The window no
longer forces a dark appearance of its own, which had kept Native Glass
from ever showing its light look; the theme is applied app-wide at launch
so the panel matches even before the window appears.

The window and the panel share the mode control, chips, switches, product
image and Connect button instead of duplicating them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rendered from the app's own SwiftUI views with a populated model and
placeholder multipoint devices (no real device names), captured through
the window server at 2x: a three-theme showcase at the top of the README,
full-length shots of each theme, the two-column window, the menu-bar panel
in each theme, and the WH-1000XM6 ten-band equalizer. The landing page's
screenshot (also its social preview) shows the new look; the old
disconnected-state shot is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bundle never carried a version, so Finder's Get Info and the About box
showed none.

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 WH-1000XM6 Ambient Sound Control won't "stick" Issues when cask to sonybridge

5 participants