Skip to content

Commit fb4cb06

Browse files
committed
fix: improve Bluetooth connection handling during device taps
1 parent 4d4b590 commit fb4cb06

8 files changed

Lines changed: 247 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ level the commit history supports; from `v0.1.6` onward, entries are written as
1616

1717
## [Unreleased]
1818

19+
### Fixed
20+
21+
- **Tapping headphones in Bluetooth ▸ Devices often did nothing.** *Harness- and host-tested;
22+
device-unverified.* A tap while the radio was already paging (its own reconnect attempt, or an
23+
earlier tap) was refused by Sony's transmitter and simply dropped, and the row's "connecting"
24+
spinner vanished within a frame, so it looked as if nothing had happened: on 2026-09-15, 23 of 24
25+
taps in 21 seconds were refused that way. A tap during a page is now held and asked for the moment
26+
the radio is free, a refused tap is retried, NFC taps on paired headphones follow the same rule,
27+
and the spinner stays until the device connects or 30 seconds pass, without sending more requests.
28+
1929
## [0.3.7] — 2026-09-15
2030

2131

@@ -67,6 +77,9 @@ level the commit history supports; from `v0.1.6` onward, entries are written as
6777

6878
## [0.3.6] — 2026-09-14
6979

80+
*Tagged (`4e1f76a`) but never pushed or published. v0.3.7 is the first release carrying these
81+
changes, so its links compare against that commit rather than a tag GitHub does not have.*
82+
7083
## [0.3.5] — 2026-09-14
7184

7285
### Added
@@ -1730,8 +1743,8 @@ First tagged release.
17301743
the shell or any mixer control ([`docs/`](docs/)).
17311744

17321745
[Unreleased]: https://github.com/superwilso/Cinder/compare/v0.3.7...HEAD
1733-
[0.3.7]: https://github.com/superwilso/Cinder/compare/v0.3.6...v0.3.7
1734-
[0.3.6]: https://github.com/superwilso/Cinder/compare/v0.3.5...v0.3.6
1746+
[0.3.7]: https://github.com/superwilso/Cinder/compare/4e1f76a...v0.3.7
1747+
[0.3.6]: https://github.com/superwilso/Cinder/compare/v0.3.5...4e1f76a
17351748
[0.3.5]: https://github.com/superwilso/Cinder/compare/v0.3.4...v0.3.5
17361749
[0.3.4]: https://github.com/superwilso/Cinder/compare/v0.3.3...v0.3.4
17371750
[0.3.3]: https://github.com/superwilso/Cinder/compare/v0.3.2...v0.3.3

cinder-home/harness/fake_pst.cpp

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,14 @@ long h_GetPairedDeviceInfo(void*, void* pl, void*, void*) {
163163
// GetConnectInformation returns. The two conventions live side by side in the same client.
164164
bool g_retry_mode = false;
165165

166-
bool connect_refused() { return g_retry_mode; }
166+
// A page on the air until this virtual time, set by cinder_harness_bt_page_until(). While it is, the
167+
// A2DP source reports CONNECTING and refuses any new connect with rc=0 — the busy refusal measured on
168+
// the device for the ladder (2026-08-26) and for 23 of 24 Devices-row taps (2026-09-15).
169+
long long g_page_until_ms = 0;
170+
171+
bool paging() { return cinder_harness_now_ms() < g_page_until_ms; }
172+
173+
bool connect_refused() { return g_retry_mode || paging(); }
167174

168175
// Bring the link up if anything could. A connect against a powered-down radio is ACCEPTED AND
169176
// SILENTLY DROPPED — the device behaviour behind "Bluetooth doesn't connect automatically".
@@ -207,7 +214,7 @@ long h_GetAvSrcConnectionStatus(void*, void*, void*, void*) {
207214
long long scripted = 0;
208215
int st = cinder_harness_scripted("BtXmit::GetAvSrcConnectionStatus", &scripted)
209216
? (int)scripted
210-
: (!g_radio_on ? 0 : (g_connected ? 5 : 2));
217+
: (!g_radio_on ? 0 : (g_connected ? 5 : (paging() ? 3 : 2)));
211218
cinder_harness_record("BtXmit::GetAvSrcConnectionStatus", st);
212219
return st;
213220
}
@@ -340,7 +347,7 @@ int _ZN3pst8services12mediascanner12MediaScanner4ScanEPNS1_21IMediaScannerListen
340347

341348
// ── the fake radio, as a test fixture ────────────────────────────────────────────────────────
342349
void cinder_harness_bt_reset(void) {
343-
g_radio_on = false; g_connected = false; g_retry_mode = false;
350+
g_radio_on = false; g_connected = false; g_retry_mode = false; g_page_until_ms = 0;
344351
g_peer_name.clear(); g_peer_addr.clear(); g_paired.clear();
345352
}
346353

@@ -352,6 +359,10 @@ void cinder_harness_bt_set_radio(int on) { g_radio_on = on != 0; }
352359
void cinder_harness_bt_set_retry_mode(int on) { g_retry_mode = on != 0; }
353360
int cinder_harness_bt_retry_mode(void) { return g_retry_mode ? 1 : 0; }
354361

362+
// Hold a page on the air until `ms` of virtual time: a radio already busy connecting, as the
363+
// ladder's own attempt had left it when the user started tapping on 2026-09-15.
364+
void cinder_harness_bt_page_until(long long ms) { g_page_until_ms = ms; }
365+
355366
// Add a device to the radio's pairing table. `addr_last` is the final byte of a synthetic
356367
// 00:00:5E:00:53:xx address (RFC 7042 documentation range), so two fixtures are distinguishable.
357368
void cinder_harness_bt_add_paired(const char* name, int addr_last) {

cinder-home/harness/harness.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ void cinder_harness_bt_set_radio(int on);
145145
// armed; read it to assert the app cleared it.
146146
void cinder_harness_bt_set_retry_mode(int on);
147147
int cinder_harness_bt_retry_mode(void);
148+
// A page on the air until `ms` of virtual time: the A2DP source reports CONNECTING (3) and refuses
149+
// every connect request with rc=0 until then, as the device does while one is already in flight.
150+
void cinder_harness_bt_page_until(long long ms);
148151
void cinder_harness_bt_add_paired(const char* name, int addr_last);
149152
int cinder_harness_bt_connected(void);
150153
int cinder_harness_bt_radio_on(void);

cinder-home/harness/scenarios.cpp

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,43 @@ static void s_bt_waits_for_a_page_in_flight(void) {
219219

220220
check(cinder_harness_count("BtXmit::GetAvSrcConnectionStatus") >= 1,
221221
"asked the radio whether it was already trying");
222-
// The skip cap is 6, so a handful of attempts do get through — that is deliberate, a status
222+
// The wait is capped at 30 s, so a handful of attempts do get through — that is deliberate, a status
223223
// stuck at 3 must not mean silence. What must not happen is a request every time round.
224224
check_range(cinder_harness_count("BtXmit::RequestLastDeviceConnection")
225225
+ cinder_harness_count("BtXmit::RequestConnection"),
226226
0, 3, "stood aside for the page already in flight");
227227
}
228228

229+
// ── a Devices tap while a page is on the air ─────────────────────────────────────────────────
230+
// 2026-09-15 on the device: 24 taps on the headphones' row in 21 s, 23 of them refused
231+
// `RequestConnection rc=0`, the busy refusal the ladder already stands aside for. The tap path never
232+
// asked, so every tap requested into the page, was refused, and nothing kept the device the user
233+
// wanted. The tap must hand its request to the ladder instead, which asks for THAT device once the
234+
// page ends. (Against the old tap path this fails its first check with five refused requests.)
235+
static void s_bt_tap_waits_for_the_page(void) {
236+
healthy_device();
237+
cinder_harness_bt_set_radio(1);
238+
cinder_harness_bt_add_paired("WH-1000XM4", 0x91); // row 0
239+
cinder_harness_bt_add_paired("WONDERBOOM", 0x92); // row 1, the one tapped
240+
cinder_harness_bt_page_until(40000);
241+
cinder_harness_input_enable();
242+
cinder_harness_script("cinder_tap", 28 /* CINDER_ACT_BT_CONNECT_DEVICE */);
243+
cinder_harness_script("cinder_pending_bt_device", 1);
244+
for (int i = 0; i < 5; i++)
245+
cinder_harness_tap_at(20000 + i * 400, 240, 400); // impatient, like the real log
246+
cinder_harness_set_budget_ms(60000);
247+
cinder_harness_run();
248+
249+
check(cinder_harness_count_between("cinder_tap", 20000, 23000) >= 1, "the taps reached carry_out");
250+
check_eq(cinder_harness_count_between("BtXmit::RequestConnection", 20000, 40000), 0,
251+
"no connect was asked into the page");
252+
check(cinder_harness_count_between("BtXmit::RequestConnection", 40000, 43000) >= 1,
253+
"the device was asked for within a few seconds of the page ending");
254+
check_eq(cinder_harness_arg("BtXmit::RequestConnection", 0), 1,
255+
"…and it was the device that was tapped");
256+
check(cinder_harness_bt_connected() == 1, "and it connected");
257+
}
258+
229259
// ── an idle radio reports 3, and 3 is not a connection ───────────────────────────────────────
230260
// `GetBtStatus` reaches 3 with nothing on the other end (measured 0.61 s after powering an idle
231261
// radio). The route used to be `st == 3`, so it flipped to BLUETOOTH the moment the radio came up:
@@ -1044,6 +1074,7 @@ static const Scenario kScenarios[] = {
10441074
{"bt-stale-jam", s_bt_clears_a_stale_jam, "a retry mode left armed by anything else is cleared"},
10451075
{"bt-idle-not-link", s_bt_idle_radio_is_not_a_link, "GetBtStatus 3 with no peer is not a connection"},
10461076
{"bt-page-in-flight", s_bt_waits_for_a_page_in_flight, "the ladder defers to a connect already on the air"},
1077+
{"bt-tap-in-page", s_bt_tap_waits_for_the_page, "a Devices tap during a page is asked for when it ends"},
10471078
{"bt-idle-poll", s_bt_idle_poll_rate, "the idle Bluetooth poll backs off"},
10481079
{"stalled-bringup", s_stalled_bringup, "bring-up that never completes must not freeze the app"},
10491080
{"dark-playing", s_dark_playing, "panel dark, BT playing: the state the device lives in"},

cinder-home/src/main.cpp

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4522,17 +4522,54 @@ static int bt_avsrc_status() {
45224522
} catch (...) { return -1; }
45234523
}
45244524

4525-
// How many times in a row the ladder will stand aside for an attempt that is already in flight
4526-
// before asking anyway. Bounded so a status wedged at 3 cannot silence the ladder for good — the
4527-
// same rule the rest of this file applies to any service state it cannot verify.
4528-
#define BT_AVSRC_MAX_SKIPS 6
4525+
// How long the ladder will stand aside for an attempt that is already in flight before asking
4526+
// anyway. Bounded so a status wedged at 3 cannot silence the ladder for good — the same rule the
4527+
// rest of this file applies to any service state it cannot verify. A TIME rather than a count of
4528+
// skips (it was 6 skips at 5 s apiece): the ladder now looks every second while a user is waiting,
4529+
// and a count would have made the same wedge five times shorter for exactly that case.
4530+
#define BT_AVSRC_MAX_BUSY_MS 30000
4531+
// How soon the ladder looks again after a Devices tap it could not act on.
4532+
#define BT_RECONNECT_SOON_S 2
4533+
4534+
// A TAP THE RADIO CANNOT TAKE IS HANDED TO THE LADDER, NOT DROPPED. Measured 2026-09-15 on the
4535+
// device, 24 taps on the headphones' row in 21 s:
4536+
//
4537+
// 377.557 bt-paired: row 1: RequestConnection rc=0 (REJECTED — nothing will reach the air …)
4538+
// … 22 more like it …
4539+
// 394.696 bt-paired: row 1: RequestConnection rc=1 (accepted)
4540+
// 401.904 bt-vol: rocker now drives BLUETOOTH (GetBtStatus=3, peer named) <- 7.2 s later
4541+
//
4542+
// rc=0 there is the busy refusal the ladder already stands aside for (see bt_reconnect_tick): a page
4543+
// was on the air, first the ladder's own earlier attempt and then the one tap that got through. The
4544+
// Devices-row path never asked, so each tap requested into the page, was refused, and nothing kept
4545+
// the device the user wanted — while every tap also re-armed the ladder and pushed its next look ten
4546+
// seconds out. The UI's spinner vanished with the list refresh each tap triggers, so nothing on
4547+
// screen said a connect was under way either; hence 24 taps.
4548+
//
4549+
// Now a tap during a page, or one refused for any other reason, makes that device the ladder's
4550+
// target, and the ladder looks every second until the radio is free and then asks for it.
4551+
static bool g_bt_user_pending = false;
45294552

45304553
// Called wherever the user asks for a link, so the retry stops being parked.
45314554
static void bt_reconnect_rearm() {
45324555
g_bt_user_disconnected = false;
45334556
g_bt_reconnect_at = 0;
45344557
g_bt_reconnect_wait_s = 0;
45354558
g_bt_reconnect_tries = 0; // a user gesture buys a fresh set of attempts
4559+
g_bt_user_pending = false; // …and replaces whatever an earlier gesture was still waiting on
4560+
}
4561+
4562+
// The ladder takes over a connect the user asked for and the radio refused. It sets up what the
4563+
// ladder's own first notice of a drop would (retry mode off; connect-wait on, but not while a link is
4564+
// up, which would let a second device walk in), aims at `addr`, and schedules a look soon.
4565+
static void bt_connect_when_free(const std::vector<unsigned char>& addr) {
4566+
g_bt_target_addr = addr;
4567+
bt_service_retry(false, false);
4568+
if (!g_bt_have_name) bt_connect_wait(true);
4569+
g_bt_reconnect_wait_s = BT_RECONNECT_SOON_S;
4570+
g_bt_reconnect_at = now_ms() + BT_RECONNECT_SOON_S * 1000L;
4571+
g_bt_reconnect_tries = 0;
4572+
g_bt_user_pending = true;
45364573
}
45374574

45384575
// Runs from the 1 Hz housekeeping. Cheap: the common paths are two boolean tests.
@@ -4543,6 +4580,7 @@ static void bt_reconnect_tick() {
45434580
// broken.
45444581
bt_connect_wait(false);
45454582
bt_service_retry(false, false);
4583+
g_bt_user_pending = false;
45464584
return;
45474585
}
45484586
if (g_bt_have_name) { // connected — reset so the next drop starts fresh
@@ -4551,6 +4589,7 @@ static void bt_reconnect_tick() {
45514589
g_bt_target_addr.clear(); // reached it; a later drop retries the ordinary way
45524590
g_bt_zeroarg_fails = 0;
45534591
g_bt_reconnect_tries = 0;
4592+
g_bt_user_pending = false;
45544593
if (g_bt_reconnect_wait_s) {
45554594
clog_("bt-reconnect: link is up again — ladder disarmed");
45564595
g_bt_reconnect_at = 0;
@@ -4606,16 +4645,19 @@ static void bt_reconnect_tick() {
46064645
// any non-1 as decisive and jumps to naming a device, on evidence that says nothing about which
46074646
// device is right. Standing aside costs one scalar IPC and keeps the two refusals apart.
46084647
{
4609-
static int avsrc_skips = 0;
4648+
static long avsrc_busy_since = 0;
46104649
const int avsrc = bt_avsrc_status();
4611-
if (avsrc == BT_AVSRC_CONNECTING && avsrc_skips < BT_AVSRC_MAX_SKIPS) {
4612-
avsrc_skips++;
4613-
g_bt_reconnect_at = now + 5000; // look again shortly; the page times out in ~5-20 s
4614-
return;
4615-
}
4616-
if (avsrc_skips >= BT_AVSRC_MAX_SKIPS)
4650+
if (avsrc == BT_AVSRC_CONNECTING) {
4651+
if (avsrc_busy_since == 0) avsrc_busy_since = now;
4652+
if (now - avsrc_busy_since < BT_AVSRC_MAX_BUSY_MS) {
4653+
// The page times out in ~5-20 s. Look again in 5, or in 1 while a user is waiting on
4654+
// a tap, so the device they asked for is asked for the moment the radio is free.
4655+
g_bt_reconnect_at = now + (g_bt_user_pending ? 1000 : 5000);
4656+
return;
4657+
}
46174658
clog_("bt-reconnect: AvSrc has said 'connecting' for 30 s without a link — asking anyway");
4618-
avsrc_skips = 0;
4659+
}
4660+
avsrc_busy_since = 0;
46194661
}
46204662
g_bt_reconnect_tries++;
46214663

@@ -4657,6 +4699,7 @@ static void bt_reconnect_tick() {
46574699
if (rc != 1) g_bt_zeroarg_fails = BT_ZEROARG_GIVEUP; // rejected: decisive
46584700
else g_bt_zeroarg_fails++;
46594701
}
4702+
if (rc == 1) g_bt_user_pending = false; // on the air now; the ordinary backoff takes over
46604703

46614704
if (g_bt_reconnect_wait_s < BT_RECONNECT_MAX_S) {
46624705
g_bt_reconnect_wait_s *= 2;
@@ -5396,6 +5439,28 @@ static int bt_request_connection(const std::vector<unsigned char>& addr, const c
53965439
return rc;
53975440
}
53985441

5442+
// A connect the USER asked for: a Devices row, or an NFC tap on bonded headphones. It never asks into
5443+
// a page already on the air (the ladder's rule, which these paths lacked) and never drops a refusal:
5444+
// either way the device becomes the ladder's target, asked for as soon as the radio is free. See
5445+
// bt_connect_when_free for the 2026-09-15 log that found this.
5446+
static void bt_user_connect(const std::vector<unsigned char>& addr, const char* who) {
5447+
const bool again = g_bt_user_pending && addr == g_bt_target_addr;
5448+
bt_reconnect_rearm(); // an explicit connect re-arms the retry
5449+
char m[160];
5450+
if (bt_avsrc_status() == BT_AVSRC_CONNECTING) {
5451+
bt_connect_when_free(addr);
5452+
if (!again) { // a repeat tap on the same device says nothing new
5453+
std::snprintf(m, sizeof m, "%s: a connect is already on the air — asking for this "
5454+
"device the moment it ends", who);
5455+
clog_(m);
5456+
}
5457+
} else if (bt_request_connection(addr, who) != 1) {
5458+
bt_connect_when_free(addr);
5459+
std::snprintf(m, sizeof m, "%s: refused — asking again once the radio is free", who);
5460+
clog_(m);
5461+
}
5462+
}
5463+
53995464
// Called from the render loop when a tap landed.
54005465
//
54015466
// A TAP IS NOT ALWAYS A PAIRING. This used to call `Pairing` unconditionally, which is right only
@@ -5499,8 +5564,7 @@ static void nfc_service_tap() {
54995564
// must stay armed — for the new one, which bt_request_connection is about to name.
55005565
refresh_bt_connected();
55015566
}
5502-
bt_reconnect_rearm();
5503-
bt_request_connection(addr, "nfc");
5567+
bt_user_connect(addr, "nfc");
55045568
// The link comes up asynchronously; the route poll notices it and refreshes the name.
55055569
return;
55065570
}
@@ -5532,10 +5596,9 @@ void apply_bt_connect_device() {
55325596
int i = cinder_pending_bt_device();
55335597
if (i < 0 || (size_t)i >= g_bt_paired.size()) { clog_("bt-paired: connect for an unknown row"); return; }
55345598
const std::vector<unsigned char> addr = g_bt_paired[(size_t)i];
5535-
bt_reconnect_rearm(); // an explicit connect re-arms the retry
55365599
char who[48];
55375600
std::snprintf(who, sizeof who, "bt-paired: row %d", i);
5538-
bt_request_connection(addr, who);
5601+
bt_user_connect(addr, who);
55395602
// The connection completes asynchronously; the 3 s route poll notices it and refreshes the name.
55405603
refresh_bt_paired();
55415604
}

0 commit comments

Comments
 (0)