fix: slim provisioning — skip extra frame disables + full reconnect on FFE2 miss

Two key changes:

1. Remove frames 3-6 disable commands (was steps 16-23, 8 extra BLE writes).
   We only configure Frame 1 (device info) and Frame 2 (iBeacon), then save.
   Fewer writes = fewer chances for supervision timeout disconnects.

2. When FFE2 characteristic goes missing after a disconnect, do a full
   disconnect → reconnect → re-discover services → re-auth → resume cycle
   instead of trying to rediscover characteristics on the same (stale GATT)
   connection. CoreBluetooth returns cached results on the same connection,
   so FFE2 stays missing. Full reconnect forces a fresh GATT discovery.

Write sequence is now 16 steps (down from 24).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Schwifty 2026-03-22 03:54:29 +00:00
parent f0d2b2ae90
commit d123d2561a

View file

@ -470,8 +470,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
dxSmartWriteConfig()
}
/// Build the full command sequence and start writing
/// New 24-step write sequence for DX-Smart CP28:
/// Build the command sequence and start writing
/// Slim write sequence for DX-Smart CP28 (no extra frame overwrites):
/// 1. DeviceName 0x71 [name bytes] service point name (max 20 ASCII chars)
/// 2. Frame1_Select 0x11 select frame 1
/// 3. Frame1_Type 0x61 enable as device info (broadcasts name)
@ -487,8 +487,11 @@ class BeaconProvisioner: NSObject, ObservableObject {
/// 13. AdvInterval 0x78 [advInterval]
/// 14. TxPower 0x79 [txPower]
/// 15. TriggerOff 0xA0
/// 16-23. Frames 3-6 select + 0xFF (disable each)
/// 24. SaveConfig 0x60 persist to flash
/// 16. SaveConfig 0x60 persist to flash
///
/// NOTE: Frames 3-6 are intentionally left untouched. Disabling extra frames
/// caused additional BLE writes that increased disconnect risk. We only write
/// the two frames we care about (device info + iBeacon) and save.
private func dxSmartWriteConfig() {
guard let config = config else {
fail("No config provided", code: .noConfig)
@ -553,21 +556,9 @@ class BeaconProvisioner: NSObject, ObservableObject {
// 15. TriggerOff (0xA0)
dxSmartCommandQueue.append(buildDXPacket(cmd: .triggerOff, data: []))
// --- Frames 3-6: Disable each ---
// 16-17. Frame 3: select (0x13) + disable (0xFF)
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot2, data: []))
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
// 18-19. Frame 4: select (0x14) + disable (0xFF)
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot3, data: []))
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
// 20-21. Frame 5: select (0x15) + disable (0xFF)
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot4, data: []))
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
// 22-23. Frame 6: select (0x16) + disable (0xFF)
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot5, data: []))
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
// Frames 3-6 intentionally left untouched fewer writes = fewer disconnects
// 24. SaveConfig (0x60) persist to flash
// 16. SaveConfig (0x60) persist to flash
dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: []))
DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands")
@ -590,23 +581,43 @@ class BeaconProvisioner: NSObject, ObservableObject {
progress = "Writing config (\(current)/\(total))..."
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else {
// After disconnect+reconnect, characteristic discovery may return incomplete results.
// Re-discover characteristics instead of hard-failing.
if charRediscoveryCount < BeaconProvisioner.MAX_CHAR_REDISCOVERY, let service = configService {
// FFE2 missing the GATT cache is stale after a disconnect. Rediscovering
// characteristics on the same connection doesn't work because CoreBluetooth
// returns cached (stale) results. We need a full disconnect reconnect
// re-discover services re-discover characteristics cycle.
if charRediscoveryCount < BeaconProvisioner.MAX_CHAR_REDISCOVERY {
charRediscoveryCount += 1
DebugLog.shared.log("BLE: FFE2 missing — re-discovering characteristics (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))")
progress = "Re-discovering characteristics..."
state = .discoveringServices
scheduleCharRediscoveryTimeout()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.peripheral?.discoverCharacteristics([
BeaconProvisioner.DXSMART_NOTIFY_CHAR,
BeaconProvisioner.DXSMART_COMMAND_CHAR,
BeaconProvisioner.DXSMART_PASSWORD_CHAR
], for: service)
DebugLog.shared.log("BLE: FFE2 missing — triggering full disconnect/reconnect (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))")
progress = "FFE2 missing, reconnecting..."
// Use the existing disconnect retry path it handles reconnect + re-auth + resume
resumeWriteAfterDisconnect = true
dxSmartAuthenticated = false
dxSmartNotifySubscribed = false
passwordIndex = 0
characteristics.removeAll()
responseBuffer.removeAll()
awaitingCommandResponse = false
cancelResponseGateTimeout()
state = .connecting
// Disconnect didDisconnectPeripheral won't re-enter because we set state = .connecting
// and handle reconnect manually here
if let peripheral = peripheral, peripheral.state == .connected {
centralManager.cancelPeripheralConnection(peripheral)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
guard let self = self, let beacon = self.currentBeacon else { return }
guard self.state == .connecting else { return }
let resolvedPeripheral = self.resolvePeripheral(beacon)
self.peripheral = resolvedPeripheral
resolvedPeripheral.delegate = self
DebugLog.shared.log("BLE: Reconnecting after FFE2 miss...")
self.centralManager.connect(resolvedPeripheral, options: nil)
}
} else {
fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) rediscovery attempts", code: .serviceNotFound)
fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) reconnect attempts", code: .serviceNotFound)
}
return
}
@ -1227,6 +1238,12 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
if state == .success || state == .idle {
return
}
// Intentional disconnect for FFE2 reconnect we're already handling reconnect
if state == .connecting && resumeWriteAfterDisconnect {
DebugLog.shared.log("BLE: Intentional disconnect for FFE2 reconnect, ignoring")
return
}
if case .failed = state {
DebugLog.shared.log("BLE: Disconnect after failure, ignoring")
return