fix: reserve Classic reconnect airtime with two Switch 2 links

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 18:39:30 -06:00
commit ed8f3a7c7f
9 changed files with 74 additions and 44 deletions

View file

@ -819,13 +819,26 @@ from 197 host requests without queue drops; the user confirmed both sides.
Normal GATT-client busy responses now retain queued output instead of
disconnecting the controller.
Two Switch 2 BLE links plus Classic now select 30 ms Switch 2 connection
intervals automatically; other topologies retain 7.5 ms. This trades Switch 2
delivery latency for Classic airtime without changing HD encoding or bonds.
Physical DualSense power-off/reconnect verified both interval transitions
without losing the Joy-Con links. A 30-second production mixed-rumble run
kept native DualSense streaming, with no send failures or ingress drops,
14 Joy-Con output-stage discards and nine PCM skips. It is not lossless.
Two or more Switch 2 BLE links now select 30 ms connection intervals even
before Classic connects; a single Switch 2 link retains 7.5 ms. Paired and
Individual Joy-Cons use the same physical-link policy. This trades Switch 2
delivery latency for Classic paging and streaming airtime without changing
HD encoding or bonds. The earlier policy waited for a Classic link and passed
one physical power-off/reconnect check, but subsequent use exposed intermittent
DualSense connection failures with two Joy-Cons already active. Regression
coverage now requires airtime before Classic admission and after its departure.
The user confirmed reconnects and actuator output/clean stops at 30 ms.
A same-workload 15/30 ms comparison at 300 MHz rejected 15 ms for native
haptics: it timed out around four seconds, with 111 PCM sends, 38 skips and
one send failure. At 30 ms the full 30-second run retained all three links
and native streaming: 1,384 PCM sends, 47 skips, no send failures or host-update/
Switch 2 ingress drops, and seven Joy-Con output-stage drops. The isolated
DualSense fixture delivered all 288 packets without skips. Release images keep
30 ms; long-duration reliability remains unqualified. All 349 tests and four
affected firmware builds passed for the preconnection policy.
A prior 30-second production mixed-rumble run kept native DualSense streaming,
with no send failures or ingress drops, 14 Joy-Con output-stage discards and
nine PCM skips. It is not lossless.
The native-HD checkpoint passed 289 tests and all five firmware builds; its
40 pre-existing profiles and metadata survived. Its inventory then contained

View file

@ -481,13 +481,16 @@ results do **not** establish lossless arbitrary workloads or perceptual
equivalence. All 40 profiles, names, active selections and adapter
configuration were preserved during this upgrade.
**Automatic mixed-controller scheduling:** when at least two physical Switch 2
BLE links coexist with a Classic Bluetooth link, the firmware requests a
30 ms connection interval for the Switch 2 links. Otherwise it requests
7.5 ms. A merged Joy-Con pair counts as two links. Unrelated BLE controllers
**Automatic mixed-controller scheduling:** whenever at least two physical
Switch 2 BLE links are present, the firmware requests a 30 ms connection
interval for those links, even before a Classic Bluetooth controller connects.
A single Switch 2 link uses 7.5 ms. A merged Joy-Con pair counts as two links;
Individual mode uses the same physical-link policy. Unrelated BLE controllers
are not retimed; bonds, profiles, HD encoding and the DualSense timeout stay
unchanged. The policy runs during connection setup, before native DualSense
attachment, and reconciles asynchronous negotiation and topology changes.
unchanged. The policy reconciles asynchronous negotiation and topology changes.
Waiting for a Classic connection before reserving its airtime left a reconnect
timing hole: DualSense could fail to connect with both Joy-Cons already active.
Keeping 30 ms after Classic disconnect also leaves airtime for its next attempt.
This trades additional Switch 2 input, gyro and rumble delivery latency for
Classic radio time. Multiple reports can travel per connection event, so
@ -497,13 +500,24 @@ both negotiated 30 ms and DualSense started without manual re-arming.
A production-firmware 30-second simultaneous rumble run retained all three
connections and native streaming: zero ingress drops or send failures,
14 Joy-Con output-stage discards and nine DualSense PCM skips. This is not
a lossless or long-duration qualification. Lifecycle regressions cover
Classic arrival/departure, a missing half, handle reuse, late negotiation
completion, clock wrap and transient request rejection.
In the physical power-off/reconnect check, both Joy-Cons negotiated 7.5 ms
after DualSense shut down, then 30 ms after it reconnected with PS. Their
Bluetooth handles stayed unchanged; native DualSense streaming restarted
automatically and sent 1,715 packets without skips at the observation point.
a lossless or long-duration qualification. Lifecycle regressions cover airtime
reservation before Classic arrival in Paired and Individual modes, Classic
departure/reconnect, a missing half, handle reuse, late negotiation completion,
clock wrap and transient request rejection.
The earlier policy passed one physical DualSense power-off/reconnect check,
but subsequent use exposed intermittent connection failures. The preconnection
airtime regression fails under that policy and passes with the current one.
The user confirmed reconnects and correct actuator output/stops with the
preconnection 30 ms policy. A 15 ms trial also reconnected, but its native
DualSense stream timed out even while sending silence. Under the same
125 Hz-per-slot held-effect/silence workload at 300 MHz, 15 ms failed around
four seconds (111 PCM packets sent, 38 skipped, one send failure); 30 ms
completed 30 seconds (1,384 sent, 47 skipped, no send failures). All three
controllers remained connected. The 30 ms run had zero host-update or Switch 2
ingress drops and seven Joy-Con output-stage drops; its separate DualSense
fixture delivered all 288 packets without skips. This supports keeping 30 ms,
not a lossless or long-duration claim. The 15 ms trial is not the release default.
The preconnection policy passed all 349 tests and four affected firmware builds.
### Rumble per controller

Binary file not shown.

Binary file not shown.

View file

@ -48,7 +48,7 @@ constexpr uint32_t kDefaultPairingWindowDurationMs =
constexpr uint32_t kPairingResetFeedbackDurationMs = 2000;
// Bluetooth Classic units are 0.625 ms: 0x1900 = 4 seconds.
constexpr uint16_t kClassicLinkSupervisionTimeout = 0x1900;
// LE units are 1.25 ms. Two fast Switch 2 links starve Classic native PCM.
// LE units are 1.25 ms. Reserve airtime for Classic paging and native PCM.
constexpr uint16_t kSwitch2FastInterval = 6;
constexpr uint16_t kSwitch2MixedInterval = 24;
constexpr uint32_t kSwitch2IntervalSettleMs = 1000;
@ -591,11 +591,11 @@ void stop_background_scan() {
g_background_scan_active = false;
}
}
// Core 1 only. Count physical links, including Classic setup, rather than
// logical players: a merged Joy-Con pair still consumes two LE connections.
// Core 1 only. Count physical links, not logical players: a merged Joy-Con
// pair still consumes two LE connections. Reserve Classic airtime before an
// incoming connection exists; waiting for its ACL handle is too late for paging.
void apply_radio_connection_policy() {
unsigned switch2_links = 0;
bool classic_link = false;
uni_hid_device_t* ready[kSlotCount]{};
for (const BackendSlot& slot : g_slots) {
uni_hid_device_t* targets[] = {slot.device, slot.companion};
@ -609,12 +609,10 @@ void apply_radio_connection_policy() {
// The parser requests its initial interval during setup.
// Do not race that request by changing a pending device here.
if (slot.active) ready[index] = target;
} else if (type == GAP_CONNECTION_ACL) {
classic_link = true;
}
}
}
const uint16_t desired = switch2_links >= 2 && classic_link
const uint16_t desired = switch2_links >= 2
? kSwitch2MixedInterval
: kSwitch2FastInterval;
const uint32_t now_ms = btstack_run_loop_get_time_ms();

View file

@ -3034,33 +3034,37 @@ void test_switch2_admission() {
"Pro2 must preserve vertical normal controls and ingest remappable extras");
}
void test_switch2_radio_policy() {
void test_switch2_radio_policy(bool individual) {
start_backend();
if (individual) set_runtime_joycon_mode(JoyConMode::kIndividual);
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto other_ble = device(3, true, UNI_BT_CONN_PROTOCOL_BLE);
ready_switch2(left);
require(negotiated_intervals[left.conn.handle] == 6,
"a single Switch2 link must retain fast scheduling");
ready_switch2(right);
require(incoming_connections &&
negotiated_intervals[left.conn.handle] == 24 &&
negotiated_intervals[right.conn.handle] == 24,
"two Switch2 links must reserve Classic reconnect airtime before a Classic device exists");
platform_on_device_connected(&other_ble);
require(platform_on_device_ready(&other_ble) == UNI_ERROR_SUCCESS,
"unrelated BLE controller must join");
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6,
"a pair without Classic must retain fast intervals");
platform_on_device_connected(&classic);
require(negotiated_intervals[left.conn.handle] == 24 &&
negotiated_intervals[right.conn.handle] == 24 &&
negotiated_intervals[other_ble.conn.handle] == 6,
"Classic setup must relax both physical halves before native attachment, not unrelated BLE");
"Classic setup must retain reconnect airtime without retiming unrelated BLE");
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic controller must complete setup alongside the pair");
const auto pair = slot_snapshot(0);
platform_on_device_disconnected(&classic);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6 &&
require(negotiated_intervals[left.conn.handle] == 24 &&
negotiated_intervals[right.conn.handle] == 24 &&
slot_snapshot(0).connection_generation == pair.connection_generation,
"Classic departure must restore fast intervals without rebinding the pair");
"Classic departure must preserve reconnect airtime without rebinding the pair");
platform_on_device_connected(&classic);
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic reconnect must complete");
@ -3090,22 +3094,20 @@ void test_switch2_radio_settling() {
"pending Switch2 setup must retain ownership of its initial interval");
require(platform_on_device_ready(&right) == UNI_ERROR_SUCCESS,
"second Switch2 connection must become ready");
platform_on_device_disconnected(&classic);
// The controller completes the old update after the topology reversed.
platform_on_device_disconnected(&right);
// The surviving controller completes the old update after losing its mate.
for (auto* half : {&left, &right}) {
negotiated_intervals[half->conn.handle] = pending_intervals[half->conn.handle];
}
now_ms += 50;
process_configuration_timer(&g_configuration_timer);
for (auto* half : {&left, &right}) {
negotiated_intervals[half->conn.handle] = pending_intervals[half->conn.handle];
require(negotiated_intervals[half->conn.handle] == 6,
"late mixed-mode completion must not strand a pair at slow intervals across clock wrap");
}
negotiated_intervals[left.conn.handle] = pending_intervals[left.conn.handle];
require(negotiated_intervals[left.conn.handle] == 6,
"late coexistence update must not strand a solo at slow intervals across clock wrap");
process_configuration_timer(&g_configuration_timer);
defer_interval_updates = false;
reject_interval_updates = true;
platform_on_device_connected(&classic);
ready_switch2(right);
const unsigned attempts = interval_requests[left.conn.handle];
now_ms += 999;
process_configuration_timer(&g_configuration_timer);
@ -5477,7 +5479,9 @@ int main(int argc, char** argv) {
} else if (scenario == "switch2-admission") {
test_switch2_admission();
} else if (scenario == "switch2-radio-policy") {
test_switch2_radio_policy();
test_switch2_radio_policy(false);
} else if (scenario == "switch2-radio-individual") {
test_switch2_radio_policy(true);
} else if (scenario == "switch2-radio-settling") {
test_switch2_radio_settling();
} else if (scenario == "switch2-mate-reconnect") {

View file

@ -127,6 +127,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"switch2-pair-replacement",
"switch2-admission",
"switch2-radio-policy",
"switch2-radio-individual",
"switch2-radio-settling",
"switch2-mate-reconnect",
"switch2-mate-pending",