Skip to content

Commit f6a1978

Browse files
authored
Merge pull request #89 from kmatzen/fix/sleep-visibility
Make sleep tracking work, and suppress only auto-reconnect rather than discovery
2 parents b232573 + 2b4da7f commit f6a1978

4 files changed

Lines changed: 162 additions & 18 deletions

File tree

Facett/BLEConnectionHandler.swift

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class BLEConnectionHandler {
3232
// Clear retry status on successful connection on main thread
3333
DispatchQueue.main.async {
3434
bleManager.connectionRetryStatus.removeValue(forKey: uuid)
35+
// A camera we just connected to is awake, whatever we last told it.
36+
bleManager.setDeviceSleeping(uuid, isSleeping: false)
3537
}
3638

3739
// UI updates must happen on main thread
@@ -71,20 +73,16 @@ class BLEConnectionHandler {
7173
let isSleeping = bleManager.isDeviceSleeping(uuid)
7274
let wasConnected = bleManager.connectedGoPros[uuid] != nil
7375

76+
// A sleeping camera stays on the discovered list so the user can still
77+
// see and tap it. Withholding it here made it unreachable, because
78+
// connectToGoPro requires the camera to be in discoveredGoPros. The
79+
// sleep flag suppresses automatic reconnection instead, below.
7480
if let gopro = bleManager.connectedGoPros[uuid] {
7581
bleManager.connectedGoPros.removeValue(forKey: uuid)
76-
if !isSleeping {
77-
bleManager.discoveredGoPros[uuid] = gopro
78-
} else {
79-
ErrorHandler.debug("\(cameraName) is sleeping - not moving to discovered list")
80-
}
82+
bleManager.discoveredGoPros[uuid] = gopro
8183
} else if let gopro = bleManager.connectingGoPros[uuid] {
8284
bleManager.connectingGoPros.removeValue(forKey: uuid)
83-
if !isSleeping {
84-
bleManager.discoveredGoPros[uuid] = gopro
85-
} else {
86-
ErrorHandler.debug("\(cameraName) is sleeping - not moving to discovered list")
87-
}
85+
bleManager.discoveredGoPros[uuid] = gopro
8886
}
8987

9088
if bleManager.connectedGoPros.isEmpty {

Facett/BLEDeviceStateManager.swift

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ class BLEDeviceStateManager: ObservableObject {
3636
@Published var connectingDevices: [UUID: DeviceState] = [:]
3737
@Published var connectionRetryStatus: [UUID: ConnectionRetryStatus] = [:]
3838

39+
/// Devices deliberately put to sleep, and when the sleep command was sent.
40+
/// Kept independent of the device dictionaries above, which are never populated.
41+
@Published private(set) var sleepingDevices: [UUID: Date] = [:]
42+
@Published private(set) var poweringDownDevices: Set<UUID> = []
43+
3944
private var connectionRetryCount: [UUID: Int] = [:]
4045
private var connectionRetryTimers: [UUID: Timer] = [:]
4146
private var connectionAttemptTimers: [UUID: Timer] = [:]
@@ -171,17 +176,47 @@ class BLEDeviceStateManager: ObservableObject {
171176
}
172177

173178
/// Set device sleeping state
179+
///
180+
/// Sleep state is held in `sleepingDevices` rather than on `DeviceState`.
181+
/// The device dictionaries are only ever populated by `addDiscoveredDevice`,
182+
/// which nothing calls, so both are permanently empty — writing sleep state
183+
/// through an optional chain into them made every setter a silent no-op and
184+
/// made `isDeviceSleeping` always return false.
174185
func setDeviceSleeping(_ uuid: UUID, isSleeping: Bool) {
186+
if isSleeping {
187+
sleepingDevices[uuid] = Date()
188+
} else {
189+
sleepingDevices.removeValue(forKey: uuid)
190+
}
175191
discoveredDevices[uuid]?.isSleeping = isSleeping
176192
connectedDevices[uuid]?.isSleeping = isSleeping
177193
}
178194

179195
/// Set device powering down state
180196
func setDevicePoweringDown(_ uuid: UUID, isPoweringDown: Bool) {
197+
if isPoweringDown {
198+
poweringDownDevices.insert(uuid)
199+
} else {
200+
poweringDownDevices.remove(uuid)
201+
}
181202
discoveredDevices[uuid]?.isPoweringDown = isPoweringDown
182203
connectedDevices[uuid]?.isPoweringDown = isPoweringDown
183204
}
184205

206+
/// Whether automatic reconnection to this device is suppressed.
207+
///
208+
/// This is the only thing the sleep flag gates. An earlier design also
209+
/// suppressed *discovery* of a sleeping camera, which cannot be made correct:
210+
/// a camera still shutting down and a camera that just woke up emit identical
211+
/// advertisements, so any rule based on advertisements plus elapsed time
212+
/// either undoes the user's sleep command (if it stops suppressing too early)
213+
/// or hides the camera forever (if it never stops). Suppressing only automatic
214+
/// reconnection avoids the ambiguity entirely: the camera stays visible and
215+
/// manually connectable, and the app simply never reconnects on its own.
216+
func isAutoReconnectSuppressed(for uuid: UUID) -> Bool {
217+
return isDeviceSleeping(uuid)
218+
}
219+
185220
/// Get device state
186221
func getDeviceState(for uuid: UUID) -> DeviceState? {
187222
return discoveredDevices[uuid]
@@ -204,19 +239,21 @@ class BLEDeviceStateManager: ObservableObject {
204239

205240
/// Check if device is sleeping
206241
func isDeviceSleeping(_ uuid: UUID) -> Bool {
207-
return discoveredDevices[uuid]?.isSleeping ?? false
242+
return sleepingDevices[uuid] != nil
208243
}
209244

210245
/// Check if device is powering down
211246
func isDevicePoweringDown(_ uuid: UUID) -> Bool {
212-
return discoveredDevices[uuid]?.isPoweringDown ?? false
247+
return poweringDownDevices.contains(uuid)
213248
}
214249

215250
/// Remove device from all collections
216251
func removeDevice(_ uuid: UUID) {
217252
discoveredDevices.removeValue(forKey: uuid)
218253
connectedDevices.removeValue(forKey: uuid)
219254
connectingDevices.removeValue(forKey: uuid)
255+
sleepingDevices.removeValue(forKey: uuid)
256+
poweringDownDevices.remove(uuid)
220257
connectionRetryStatus.removeValue(forKey: uuid)
221258
connectionRetryCount.removeValue(forKey: uuid)
222259

@@ -241,6 +278,8 @@ class BLEDeviceStateManager: ObservableObject {
241278
discoveredDevices.removeAll()
242279
connectedDevices.removeAll()
243280
connectingDevices.removeAll()
281+
sleepingDevices.removeAll()
282+
poweringDownDevices.removeAll()
244283
connectionRetryStatus.removeAll()
245284
connectionRetryCount.removeAll()
246285
connectionRetryTimers.removeAll()

Facett/BLEManager.swift

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -745,11 +745,16 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
745745
let gopro = GoPro(peripheral: peripheral)
746746
guard connectedGoPros[peripheral.identifier] == nil else { return }
747747

748-
// Don't add sleeping devices to discovered list
749-
guard !deviceStateManager.isDeviceSleeping(peripheral.identifier) else {
750-
ErrorHandler.debug("Ignoring sleeping device: \(peripheral.name ?? peripheral.identifier.uuidString)")
751-
return
752-
}
748+
// Advertisements are always honoured, including from a camera we believe
749+
// is asleep. A camera still shutting down and a camera that just woke up
750+
// emit identical advertisements, so no rule based on advertisements and
751+
// elapsed time can tell them apart — suppressing discovery either undoes
752+
// the user's sleep command or hides the camera permanently, since
753+
// connectToGoPro requires it to be in discoveredGoPros.
754+
//
755+
// The sleep flag instead suppresses only AUTOMATIC reconnection, which is
756+
// the behaviour that actually needed guarding. The camera stays visible
757+
// and the user can always tap to connect.
753758

754759
let peripheralId = peripheral.identifier
755760
let peripheralName = peripheral.name
@@ -1899,6 +1904,11 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
18991904
return deviceStateManager.isDeviceSleeping(uuid)
19001905
}
19011906

1907+
/// Record whether a device is sleeping
1908+
func setDeviceSleeping(_ uuid: UUID, isSleeping: Bool) {
1909+
deviceStateManager.setDeviceSleeping(uuid, isSleeping: isSleeping)
1910+
}
1911+
19021912
// MARK: - Query Timer Management
19031913

19041914
func startDeviceQueryTimer() {
@@ -2220,9 +2230,12 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
22202230
// Find cameras that should be connected but aren't
22212231
let stragglers = targetConnectedCameras.filter { cameraId in
22222232
// Camera should be connected if it's discovered but not connected and not currently connecting
2233+
// A camera the user asked to sleep is excluded: it stays visible and
2234+
// manually connectable, but must not be reconnected automatically.
22232235
return discoveredGoPros[cameraId] != nil &&
22242236
connectedGoPros[cameraId] == nil &&
2225-
connectingGoPros[cameraId] == nil
2237+
connectingGoPros[cameraId] == nil &&
2238+
!deviceStateManager.isDeviceSleeping(cameraId)
22262239
}
22272240

22282241
if !stragglers.isEmpty {
@@ -2265,6 +2278,11 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph
22652278
/// Schedule auto-reconnect for a camera that dropped unexpectedly
22662279
func scheduleReconnectIfNeeded(for uuid: UUID) {
22672280
guard targetConnectedCameras.contains(uuid) else { return }
2281+
// Never auto-reconnect a camera the user asked to sleep.
2282+
guard !deviceStateManager.isDeviceSleeping(uuid) else {
2283+
ErrorHandler.debug("Not scheduling reconnect - camera was put to sleep")
2284+
return
2285+
}
22682286

22692287
let cameraName = CameraIdentityManager.shared.getDisplayName(for: uuid)
22702288
ErrorHandler.info("Camera \(cameraName) dropped - scheduling reconnect")

FacettTests/StateMachineTests.swift

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,92 @@ class StateMachineTests: XCTestCase {
358358
return BLEManager()
359359
}
360360
}
361+
362+
// MARK: - Device Sleep State
363+
364+
final class DeviceSleepStateTests: XCTestCase {
365+
366+
var stateManager: BLEDeviceStateManager!
367+
368+
override func setUp() {
369+
super.setUp()
370+
stateManager = BLEDeviceStateManager()
371+
}
372+
373+
override func tearDown() {
374+
stateManager = nil
375+
super.tearDown()
376+
}
377+
378+
func testSleepStateIsRecorded() {
379+
let cam = UUID()
380+
XCTAssertFalse(stateManager.isDeviceSleeping(cam))
381+
382+
stateManager.setDeviceSleeping(cam, isSleeping: true)
383+
384+
// This previously wrote through an optional chain into a dictionary that
385+
// nothing ever populates, so the setter was a silent no-op and the getter
386+
// always returned false.
387+
XCTAssertTrue(stateManager.isDeviceSleeping(cam))
388+
}
389+
390+
func testAutoReconnectSuppressedWhileSleeping() {
391+
let cam = UUID()
392+
XCTAssertFalse(stateManager.isAutoReconnectSuppressed(for: cam))
393+
394+
stateManager.setDeviceSleeping(cam, isSleeping: true)
395+
396+
// The sleep flag gates automatic reconnection and nothing else. It must
397+
// not gate discovery: a camera still shutting down and one that just woke
398+
// emit identical advertisements, so suppressing discovery either undoes
399+
// the sleep command or hides the camera permanently.
400+
XCTAssertTrue(stateManager.isAutoReconnectSuppressed(for: cam))
401+
}
402+
403+
func testAutoReconnectResumesAfterWake() {
404+
let cam = UUID()
405+
stateManager.setDeviceSleeping(cam, isSleeping: true)
406+
stateManager.setDeviceSleeping(cam, isSleeping: false)
407+
408+
XCTAssertFalse(stateManager.isAutoReconnectSuppressed(for: cam))
409+
}
410+
411+
func testExplicitWakeClearsSleepState() {
412+
let cam = UUID()
413+
stateManager.setDeviceSleeping(cam, isSleeping: true)
414+
stateManager.setDeviceSleeping(cam, isSleeping: false)
415+
XCTAssertFalse(stateManager.isDeviceSleeping(cam))
416+
}
417+
418+
func testSleepStateIsPerDevice() {
419+
let sleeping = UUID()
420+
let awake = UUID()
421+
stateManager.setDeviceSleeping(sleeping, isSleeping: true)
422+
423+
XCTAssertFalse(stateManager.isDeviceSleeping(awake))
424+
XCTAssertFalse(stateManager.isAutoReconnectSuppressed(for: awake))
425+
}
426+
427+
func testPowerDownStateIsRecorded() {
428+
let cam = UUID()
429+
XCTAssertFalse(stateManager.isDevicePoweringDown(cam))
430+
431+
stateManager.setDevicePoweringDown(cam, isPoweringDown: true)
432+
XCTAssertTrue(stateManager.isDevicePoweringDown(cam))
433+
434+
stateManager.setDevicePoweringDown(cam, isPoweringDown: false)
435+
XCTAssertFalse(stateManager.isDevicePoweringDown(cam))
436+
}
437+
438+
func testCleanupClearsSleepState() {
439+
let cam = UUID()
440+
441+
stateManager.setDeviceSleeping(cam, isSleeping: true)
442+
stateManager.removeDevice(cam)
443+
XCTAssertFalse(stateManager.isDeviceSleeping(cam))
444+
445+
stateManager.setDeviceSleeping(cam, isSleeping: true)
446+
stateManager.clearAllDevices()
447+
XCTAssertFalse(stateManager.isDeviceSleeping(cam))
448+
}
449+
}

0 commit comments

Comments
 (0)