Skip to content
Merged
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
6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ var targets: [Target] = [
swiftSettings: [.swiftLanguageMode(.v6)]),
.testTarget(name: "Switch2KitCTests", dependencies: ["Switch2Kit", "Switch2KitC", "Switch2KitCABI"],
swiftSettings: [.swiftLanguageMode(.v6)]),
// Compile the actual native SDL fixture during swift test, not just its separate CMake build.
.testTarget(name: "Switch2KitSDLFixtureTests", dependencies: ["Switch2Kit", "Switch2KitC", "Switch2KitCABI"],
path: "tests/sdl-inprocess",
exclude: ["CMakeLists.txt", "Clock.cpp", "Clock.hpp", "main.cpp", "motion.cpp", "verify.sh"],
sources: ["Fixture.swift", "FixtureTests.swift"],
swiftSettings: [.swiftLanguageMode(.v6)]),
.target(name: "Switch2Kit", dependencies: radioDependencies, path: "Sources/Switch2Kit", swiftSettings: [.swiftLanguageMode(.v6)]),
.testTarget(name: "Switch2KitTests", dependencies: ["Switch2Kit"], path: "Tests/Switch2KitTests",
swiftSettings: [.swiftLanguageMode(.v6)])
Expand Down
20 changes: 19 additions & 1 deletion Sources/Switch2KitC/Context.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package protocol ControllerSource: Sendable {
func start()
func stop(completion: @escaping @Sendable () -> Void)
func discover(seconds: Double)
func setAutomaticDiscovery(_ enabled: Bool)
func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool)
func rumble(id: Switch2ControllerID, connection: UUID, strong: Double, weak: Double, duration: Double?, feedback: Bool)
func player(id: Switch2ControllerID, connection: UUID, number: Int)
Expand All @@ -17,7 +18,12 @@ package protocol ControllerSource: Sendable {
// All mutable ownership/lifecycle state is mutex protected. Read has a single
// logical host reader; start/stop/control calls can arrive from other threads.
package final class CContext: Sendable {
private struct Lifecycle: Sendable { var running = false; var stopping = false; var closed = false }
private struct Lifecycle: Sendable {
var running = false
var stopping = false
var closed = false
var automaticDiscovery = false
}
private let lifecycle = Mutex(Lifecycle())
package let source: any ControllerSource
private let reader: ControllerEventReader
Expand Down Expand Up @@ -47,6 +53,18 @@ package final class CContext: Sendable {
reader.cancel()
}
deinit { reader.cancel() }
package func setAutomaticDiscovery(_ enabled: Bool) -> Int32 {
lifecycle.withLock { value in
guard !value.closed else { return 1 }
guard !value.stopping else { return 5 }
guard value.automaticDiscovery != enabled else { return 0 }
// Configuration only: never start a stopped source, consume input,
// or disturb ready sessions. The source enqueues on its radio queue.
value.automaticDiscovery = enabled
source.setAutomaticDiscovery(enabled)
return 0
}
}
package func discover(_ seconds: Double) -> Int32 {
guard seconds.isFinite, (0.1...300).contains(seconds) else { return 1 }
return lifecycle.withLock { value in
Expand Down
9 changes: 8 additions & 1 deletion Sources/Switch2KitC/Exports.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ public func stopContext(_ handle: OpaquePointer?) -> Int32 { context(handle)?.st
@_cdecl("s2k_discover")
public func discoverContext(_ handle: OpaquePointer?, _ seconds: Double) -> Int32 { context(handle)?.discover(seconds) ?? 1 }

/// C ABI entry point; ownership, threading and argument rules are specified in Switch2KitC.h.
@_cdecl("s2k_set_automatic_discovery")
public func setAutomaticDiscoveryContext(_ handle: OpaquePointer?, _ enabled: UInt32) -> Int32 {
guard enabled <= 1 else { return 1 }
return context(handle)?.setAutomaticDiscovery(enabled == 1) ?? 1
}

/// C ABI entry point; ownership, threading and argument rules are specified in Switch2KitC.h.
@_cdecl("s2k_read")
public func readContext(_ handle: OpaquePointer?, _ events: UnsafeMutablePointer<S2KEvent>?,
Expand Down Expand Up @@ -111,4 +118,4 @@ public func pulseRumble(_ handle: OpaquePointer?, _ id: UnsafePointer<S2KID>?, _
public func setPlayer(_ handle: OpaquePointer?, _ id: UnsafePointer<S2KID>?, _ connection: UnsafePointer<S2KID>?, _ number: UInt32) -> Int32 {
guard (1...8).contains(number) else { return 1 }
return control(handle, id, connection) { c, source in source.player(id: c.id, connection: c.connectionID, number: Int(number)); return 0 }
}
}
3 changes: 3 additions & 0 deletions Sources/Switch2KitC/ManagerSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ package final class ManagerSource: ControllerSource {
package func start() { manager.start() }
package func stop(completion: @escaping @Sendable () -> Void) { manager.stop(completion: completion) }
package func discover(seconds: Double) { try? manager.discover(for: seconds) }
package func setAutomaticDiscovery(_ enabled: Bool) {
manager.configureDiscovery(enabled ? .automatic : .onDemand)
}
package func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool) {
manager.transport.disconnect(id, forget: forget, expectedConnection: connection)
}
Expand Down
17 changes: 15 additions & 2 deletions Sources/Switch2KitCABI/include/Switch2KitC.h
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ enum { S2K_LINK_LOST=1, S2K_REQUESTED, S2K_FORGOTTEN, S2K_STOPPED,
* not overwrite ordinary events before they have been processed. */
enum { S2K_READ_RESYNC=1u, S2K_READ_MORE=2u };
/** Creation configuration; initialize every field. Event capacity is 1..256, controllers 1..64.
* The facade uses on-demand discovery and does not persist identities or preferences. */
* The facade defaults to on-demand discovery and does not persist identities or preferences. */
typedef struct S2KConfig {
uint32_t abi_version, struct_size, maximum_controllers, event_capacity;
} S2KConfig;
Expand All @@ -142,6 +142,19 @@ S2KResult s2k_start(S2KContext *context);
S2KResult s2k_stop(S2KContext *context);
/** Open/replace a 0.1..300 second scan window after start; ready controllers are unaffected. */
S2KResult s2k_discover(S2KContext *context, double seconds);
/** Opt in to continuous discovery of available supported controllers (enabled=1),
* including reconnect after link loss; enabled=0 restores on-demand discovery.
* Default is 0. Any other value is INVALID_ARGUMENT. Idempotent and thread-safe.
* Configuration is queued; it does not start Bluetooth or revive stopped support.
* Call start separately. BUSY while asynchronous stop is finishing.
* Switching to 0 cancels automatic scanning, not ready connections. An already
* admitted handshake may finish. Use stop to cancel attempts and disconnect all.
* Automatic mode has no discovery-window deadline and resumes scanning when the
* radio/capacity permits. The host need not periodically renew discover calls.
* The choice survives stop/start on this context, not destroy/create. Hosts own
* user consent and persistence. New additive ABI-v1 symbol; link a matching SDK.
*/
S2KResult s2k_set_automatic_discovery(S2KContext *context, uint32_t enabled);
/** Read without blocking or invoking callbacks. One logical reader per handle.
* events may be NULL only when capacity is zero. event_stride must equal sizeof(S2KEvent);
* snapshot_size must equal sizeof(S2KSnapshot). count/snapshot/flags must be non-NULL.
Expand Down Expand Up @@ -171,4 +184,4 @@ S2KResult s2k_set_player(S2KContext *context, const S2KID *id, const S2KID *conn
#ifdef __cplusplus
}
#endif
#endif
#endif
83 changes: 83 additions & 0 deletions Tests/Switch2KitCTests/AutomaticDiscoveryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Foundation
import XCTest
import Switch2Kit
import Switch2KitCABI
@testable import Switch2KitC

final class AutomaticDiscoveryTests: XCTestCase {
func testValidationDefaultAndRadioFreeConfiguration() throws {
let source = TestSource(), context = try CContext(source: source, capacity: 16)
let handle = retainedHandle(context)
defer { destroyContext(handle) }
XCTAssertEqual(s2k_set_automatic_discovery(nil, 1), 1)
XCTAssertEqual(s2k_set_automatic_discovery(handle, 2), 1)
XCTAssertEqual(s2k_set_automatic_discovery(handle, UInt32.max), 1)
XCTAssertFalse(source.state.withLock { $0.automaticDiscovery })
XCTAssertTrue(source.state.withLock { $0.calls.isEmpty })
XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0)
XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0)
XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0)
XCTAssertEqual(source.state.withLock { $0.calls }, ["automatic"])
XCTAssertFalse(source.state.withLock { $0.running })
XCTAssertFalse(context.read(maximum: 0).snapshot.isRunning)
}

func testTogglePreservesReadySessionAndInputReader() throws {
let source = TestSource(), context = try CContext(source: source, capacity: 16)
let handle = retainedHandle(context)
defer { destroyContext(handle) }
XCTAssertEqual(startContext(handle), 0)
source.emit(pressed: true)
let before = context.read(maximum: 16).snapshot.controllers[0]
source.emit(sequence: 2, pressed: false)
XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0)
XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0)
let batch = context.read(maximum: 16)
XCTAssertFalse(batch.resync)
XCTAssertEqual(batch.events.count, 1)
XCTAssertEqual(batch.snapshot.controllers[0].connectionID, before.connectionID)
XCTAssertEqual(batch.snapshot.controllers[0].state.sequence, 2)
XCTAssertEqual(source.state.withLock { $0.calls }, ["automatic", "on-demand"])
}

func testStopIsAuthoritativeAndChoiceSurvivesRestart() throws {
let source = TestSource(), context = try CContext(source: source, capacity: 16)
let handle = retainedHandle(context)
defer { destroyContext(handle) }
XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0)
XCTAssertEqual(startContext(handle), 0)
XCTAssertEqual(stopContext(handle), 0)
XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 5)
XCTAssertEqual(startContext(handle), 5)
XCTAssertTrue(source.state.withLock { $0.automaticDiscovery })
source.finishStop()
XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0)
XCTAssertFalse(context.read(maximum: 0).snapshot.isRunning)
XCTAssertEqual(startContext(handle), 0)
XCTAssertTrue(source.state.withLock { $0.automaticDiscovery })
XCTAssertEqual(stopContext(handle), 0)
source.finishStop()
XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0)
XCTAssertFalse(source.state.withLock { $0.running })
context.close()
XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 1)
}

func testConcurrentConfigurationCannotRestartStoppedSource() throws {
let source = TestSource(), context = try CContext(source: source, capacity: 16)
XCTAssertEqual(context.start(), 0)
DispatchQueue.concurrentPerform(iterations: 200) { index in
if index == 100 {
XCTAssertEqual(context.stop(), 0)
} else {
let result = context.setAutomaticDiscovery(index % 2 == 0)
XCTAssertTrue(result == 0 || result == 5)
}
}
XCTAssertFalse(source.state.withLock { $0.running })
XCTAssertTrue(context.read(maximum: 0).stopping)
source.finishStop()
XCTAssertEqual(context.setAutomaticDiscovery(true), 0)
XCTAssertFalse(source.state.withLock { $0.running })
}
}
7 changes: 7 additions & 0 deletions Tests/Switch2KitCTests/TestSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class TestSource: ControllerSource {
let hub = ControllerEventHub()
struct State: Sendable {
var running = false
var automaticDiscovery = false
var controllers: [Int: Switch2Controller] = [:]
var lifetimes: [Int: SessionLifetime] = [:]
var calls: [String] = []
Expand All @@ -33,6 +34,12 @@ final class TestSource: ControllerSource {
completion?()
}
func discover(seconds: Double) { state.withLock { $0.calls.append("discover") } }
func setAutomaticDiscovery(_ enabled: Bool) {
state.withLock {
$0.automaticDiscovery = enabled
$0.calls.append(enabled ? "automatic" : "on-demand")
}
}
func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool) { state.withLock { $0.calls.append("disconnect") } }
func rumble(id: Switch2ControllerID, connection: UUID, strong: Double, weak: Double, duration: Double?, feedback: Bool) {
state.withLock { $0.calls.append(feedback ? "feedback" : "rumble") }
Expand Down
28 changes: 28 additions & 0 deletions Tests/Switch2KitTests/AutomaticDiscoveryPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation
import XCTest
@testable import Switch2Kit

final class AutomaticDiscoveryPolicyTests: XCTestCase {
func testAutomaticDiscoverySurvivesLongAbsenceWithoutRenewal() {
let queue = DispatchQueue(label: "test.automatic-discovery")
queue.sync {
let policy = ControllerDiscoveryPolicy(queue: queue, mode: .onDemand) {}
let id = UUID()
XCTAssertFalse(policy.shouldScan(readyIDs: [], now: 0))
policy.configure(mode: .automatic, remembered: [])
XCTAssertTrue(policy.shouldScan(readyIDs: [], now: 0))
XCTAssertTrue(policy.shouldScan(readyIDs: [id], now: 60))
// Controller powers off while the emulator is paused for a day.
XCTAssertTrue(policy.shouldScan(readyIDs: [], now: 86_400))
XCTAssertNil(policy.deadline)
XCTAssertTrue(policy.shouldScan(readyIDs: [id], now: 86_401))
policy.cancelWindow()
XCTAssertTrue(policy.shouldScan(readyIDs: [], now: 172_800))
policy.configure(mode: .onDemand, remembered: [])
XCTAssertFalse(policy.shouldScan(readyIDs: [id], now: 172_801))
XCTAssertTrue(policy.openWindow(seconds: 60, now: 172_801))
XCTAssertTrue(policy.shouldScan(readyIDs: [], now: 172_860))
XCTAssertFalse(policy.shouldScan(readyIDs: [], now: 172_861))
}
}
}
44 changes: 44 additions & 0 deletions docs/switch2kit/automatic-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Automatic discovery for native hosts

The C facade defaults to a finite, explicitly requested discovery window. That
is appropriate for manual pairing, but a controller that powers off and returns
after the window expires cannot be rediscovered until the host requests another
window. Pausing emulation does not extend that window.

A native host can now opt in with `s2k_set_automatic_discovery(context, 1)` and
then call `s2k_start(context)`. This selects the existing Swift `.automatic`
policy: supported advertising controllers can connect whenever the transport
has capacity, including after a long absence. It is not a known-device allowlist.
It does not pair arbitrary Bluetooth devices or change the host's port mappings.

Discovery is event-driven on the private Bluetooth queue. The host does not need
a timer that repeatedly calls `s2k_discover`, or to reopen its settings window.
The transport still serializes handshakes, uses duplicate-filtered scans, bounds
retry work and pauses discovery while connecting or at capacity. Continuous
scanning still uses radio resources; hosts should make this an explicit option.

The setter is idempotent, accepts only 0/1, and is serialized with lifecycle
commands. It does not start a stopped manager or consume input. Configuration
while asynchronous stop is finishing returns `S2K_BUSY`. The mode survives
stop/start on the same context. Destroy/create restores the off default; the
host owns saved preferences and main-thread creation.

Setting 0 returns to on-demand discovery without disconnecting ready sessions.
An already admitted handshake can finish. For an explicit Disconnect action,
call `s2k_stop` and keep the host's stopped-state fence: do not automatically
restart from an input poll, settings refresh or resume event. Only a deliberate
start, or a later application launch with prior user consent, should restart it.

The function is an additive ABI-v1 symbol; all existing structures and defaults
are unchanged. Hosts using it must pin/link a library revision that supplies it.
Existing C/C++ consumers that do not call it retain manual discovery behavior.

## Validation boundary

The regression suite checks argument validation through the C declaration,
radio-free configuration, live-session/input-reader preservation, stop/restart,
concurrent configuration and simulated long-absence policy behavior. These are
not physical Bluetooth tests. On a Mac, verify controller power-off/wake after
more than 60 seconds, repeated cycles, a paused game, Bluetooth off/on, two
controllers reconnecting in reverse order, and explicit Disconnect followed by
wake. Initial pairing, permissions and actual hardware availability still apply.
11 changes: 10 additions & 1 deletion tests/sdl-inprocess/Fixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import Switch2KitC
import Switch2KitCABI

// This module is a test fixture, not part of the SDK or its distributions.
private final class SDLTestSource: ControllerSource {
final class SDLTestSource: ControllerSource {
let hub = ControllerEventHub()
struct State: Sendable {
var controllers: [Int32: Switch2Controller] = [:]
var lifetimes: [Int32: SessionLifetime] = [:]
var calls: [Switch2ControllerID: (count: UInt32, strong: Double, weak: Double)] = [:]
var running = false
var automaticDiscovery = false
var discoveryConfigurationCount: UInt32 = 0
var stopCompletion: (@Sendable () -> Void)?
}
let state = Mutex(State())
Expand All @@ -29,6 +31,13 @@ private final class SDLTestSource: ControllerSource {
}
}
func discover(seconds: Double) {}
func setAutomaticDiscovery(_ enabled: Bool) {
// Record policy intent without simulating Bluetooth or changing ready sessions.
state.withLock {
$0.automaticDiscovery = enabled
$0.discoveryConfigurationCount += 1
}
}
func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool) {}
func rumble(id: Switch2ControllerID, connection: UUID, strong: Double, weak: Double, duration: Double?, feedback: Bool) {
state.withLock { value in
Expand Down
Loading
Loading