payfrit-beacon-ios/PayfritBeacon/BeaconProvisioner.swift
John Pinkyfloyd 8c2320da44 Add ios-marketing idiom, iPad orientations, launch screen
- Fixed App Store icon display with ios-marketing idiom
- Added iPad orientation support for multitasking
- Added UILaunchScreen for iPad requirements
- Removed unused BLE permissions and files from build

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 19:38:11 -08:00

398 lines
13 KiB
Swift

import Foundation
import CoreBluetooth
/// Result of a provisioning operation
enum ProvisioningResult {
case success
case failure(String)
}
/// Configuration to write to a beacon
struct BeaconConfig {
let uuid: String // 32-char hex, no dashes
let major: UInt16
let minor: UInt16
let txPower: Int8 // Typically -59
let interval: UInt16 // Advertising interval in ms, typically 350
}
/// Handles GATT connection and provisioning of beacons
class BeaconProvisioner: NSObject, ObservableObject {
// MARK: - BlueCharm GATT Characteristics
private static let BLUECHARM_SERVICE = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
private static let BLUECHARM_PASSWORD_CHAR = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB")
private static let BLUECHARM_UUID_CHAR = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB")
private static let BLUECHARM_MAJOR_CHAR = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB")
private static let BLUECHARM_MINOR_CHAR = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB")
private static let BLUECHARM_TXPOWER_CHAR = CBUUID(string: "0000FFF5-0000-1000-8000-00805F9B34FB")
// MARK: - KBeacon GATT (basic - for full support use their SDK)
private static let KBEACON_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
// BlueCharm default passwords to try
private static let BLUECHARM_PASSWORDS = ["000000", "FFFF", "123456"]
// KBeacon default passwords
private static let KBEACON_PASSWORDS = [
"0000000000000000", // 16 zeros
"31323334353637383930313233343536" // ASCII "1234567890123456"
]
@Published var state: ProvisioningState = .idle
@Published var progress: String = ""
enum ProvisioningState: Equatable {
case idle
case connecting
case discoveringServices
case authenticating
case writing
case verifying
case success
case failed(String)
}
private var centralManager: CBCentralManager!
private var peripheral: CBPeripheral?
private var beaconType: BeaconType = .unknown
private var config: BeaconConfig?
private var completion: ((ProvisioningResult) -> Void)?
private var configService: CBService?
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var passwordIndex = 0
private var writeQueue: [(CBCharacteristic, Data)] = []
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: .main)
}
/// Provision a beacon with the given configuration
func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) {
guard centralManager.state == .poweredOn else {
completion(.failure("Bluetooth not available"))
return
}
self.peripheral = beacon.peripheral
self.beaconType = beacon.type
self.config = config
self.completion = completion
self.passwordIndex = 0
self.characteristics.removeAll()
self.writeQueue.removeAll()
state = .connecting
progress = "Connecting to \(beacon.displayName)..."
centralManager.connect(beacon.peripheral, options: nil)
// Timeout after 30 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
if self?.state != .success && self?.state != .idle {
self?.fail("Connection timeout")
}
}
}
/// Cancel current provisioning
func cancel() {
if let peripheral = peripheral {
centralManager.cancelPeripheralConnection(peripheral)
}
cleanup()
}
private func cleanup() {
peripheral = nil
config = nil
completion = nil
configService = nil
characteristics.removeAll()
writeQueue.removeAll()
state = .idle
progress = ""
}
private func fail(_ message: String) {
NSLog("BeaconProvisioner: Failed - \(message)")
state = .failed(message)
if let peripheral = peripheral {
centralManager.cancelPeripheralConnection(peripheral)
}
completion?(.failure(message))
cleanup()
}
private func succeed() {
NSLog("BeaconProvisioner: Success!")
state = .success
if let peripheral = peripheral {
centralManager.cancelPeripheralConnection(peripheral)
}
completion?(.success)
cleanup()
}
// MARK: - BlueCharm Provisioning
private func provisionBlueCharm() {
guard let service = configService else {
fail("Config service not found")
return
}
// Discover characteristics
peripheral?.discoverCharacteristics([
BeaconProvisioner.BLUECHARM_PASSWORD_CHAR,
BeaconProvisioner.BLUECHARM_UUID_CHAR,
BeaconProvisioner.BLUECHARM_MAJOR_CHAR,
BeaconProvisioner.BLUECHARM_MINOR_CHAR,
BeaconProvisioner.BLUECHARM_TXPOWER_CHAR
], for: service)
}
private func authenticateBlueCharm() {
guard let passwordChar = characteristics[BeaconProvisioner.BLUECHARM_PASSWORD_CHAR] else {
fail("Password characteristic not found")
return
}
let passwords = BeaconProvisioner.BLUECHARM_PASSWORDS
guard passwordIndex < passwords.count else {
fail("Authentication failed - tried all passwords")
return
}
state = .authenticating
progress = "Authenticating..."
let password = passwords[passwordIndex]
if let data = password.data(using: .utf8) {
NSLog("BeaconProvisioner: Trying BlueCharm password \(passwordIndex + 1)/\(passwords.count)")
peripheral?.writeValue(data, for: passwordChar, type: .withResponse)
}
}
private func writeBlueCharmConfig() {
guard let config = config else {
fail("No config provided")
return
}
state = .writing
progress = "Writing configuration..."
// Build write queue
writeQueue.removeAll()
// UUID - 16 bytes, no dashes
if let uuidChar = characteristics[BeaconProvisioner.BLUECHARM_UUID_CHAR] {
if let uuidData = hexStringToData(config.uuid) {
writeQueue.append((uuidChar, uuidData))
}
}
// Major - 2 bytes big-endian
if let majorChar = characteristics[BeaconProvisioner.BLUECHARM_MAJOR_CHAR] {
var major = config.major.bigEndian
let majorData = Data(bytes: &major, count: 2)
writeQueue.append((majorChar, majorData))
}
// Minor - 2 bytes big-endian
if let minorChar = characteristics[BeaconProvisioner.BLUECHARM_MINOR_CHAR] {
var minor = config.minor.bigEndian
let minorData = Data(bytes: &minor, count: 2)
writeQueue.append((minorChar, minorData))
}
// TxPower - 1 byte signed
if let txChar = characteristics[BeaconProvisioner.BLUECHARM_TXPOWER_CHAR] {
var txPower = config.txPower
let txData = Data(bytes: &txPower, count: 1)
writeQueue.append((txChar, txData))
}
// Start writing
processWriteQueue()
}
private func processWriteQueue() {
guard !writeQueue.isEmpty else {
// All writes complete
progress = "Configuration complete!"
succeed()
return
}
let (characteristic, data) = writeQueue.removeFirst()
NSLog("BeaconProvisioner: Writing \(data.count) bytes to \(characteristic.uuid)")
peripheral?.writeValue(data, for: characteristic, type: .withResponse)
}
// MARK: - KBeacon Provisioning
private func provisionKBeacon() {
// KBeacon uses a more complex protocol
// For now, we'll just try basic GATT writes
// Full support would require their SDK
state = .writing
progress = "KBeacon requires their SDK for full support.\nUse clipboard to copy config."
// For now, just succeed and let user use clipboard
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.fail("KBeacon provisioning requires their SDK. Please use the KBeacon app with the copied config.")
}
}
// MARK: - Helpers
private func hexStringToData(_ hex: String) -> Data? {
let clean = hex.replacingOccurrences(of: "-", with: "").uppercased()
guard clean.count == 32 else { return nil }
var data = Data()
var index = clean.startIndex
while index < clean.endIndex {
let nextIndex = clean.index(index, offsetBy: 2)
let byteString = String(clean[index..<nextIndex])
if let byte = UInt8(byteString, radix: 16) {
data.append(byte)
} else {
return nil
}
index = nextIndex
}
return data
}
}
// MARK: - CBCentralManagerDelegate
extension BeaconProvisioner: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
NSLog("BeaconProvisioner: Central state = \(central.state.rawValue)")
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
NSLog("BeaconProvisioner: Connected to \(peripheral.name ?? "unknown")")
peripheral.delegate = self
state = .discoveringServices
progress = "Discovering services..."
// Discover the config service based on beacon type
switch beaconType {
case .bluecharm:
peripheral.discoverServices([BeaconProvisioner.BLUECHARM_SERVICE])
case .kbeacon:
peripheral.discoverServices([BeaconProvisioner.KBEACON_SERVICE])
case .unknown:
peripheral.discoverServices(nil) // Discover all
}
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
fail("Failed to connect: \(error?.localizedDescription ?? "unknown error")")
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
NSLog("BeaconProvisioner: Disconnected from \(peripheral.name ?? "unknown")")
if state != .success && state != .idle {
// Unexpected disconnect
if case .failed = state {
// Already failed, don't report again
} else {
fail("Unexpected disconnect")
}
}
}
}
// MARK: - CBPeripheralDelegate
extension BeaconProvisioner: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error {
fail("Service discovery failed: \(error.localizedDescription)")
return
}
guard let services = peripheral.services else {
fail("No services found")
return
}
NSLog("BeaconProvisioner: Discovered \(services.count) services")
for service in services {
NSLog(" Service: \(service.uuid)")
if service.uuid == BeaconProvisioner.BLUECHARM_SERVICE {
configService = service
provisionBlueCharm()
return
} else if service.uuid == BeaconProvisioner.KBEACON_SERVICE {
configService = service
provisionKBeacon()
return
}
}
fail("Config service not found on device")
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error = error {
fail("Characteristic discovery failed: \(error.localizedDescription)")
return
}
guard let chars = service.characteristics else {
fail("No characteristics found")
return
}
NSLog("BeaconProvisioner: Discovered \(chars.count) characteristics")
for char in chars {
NSLog(" Char: \(char.uuid)")
characteristics[char.uuid] = char
}
// Start authentication for BlueCharm
if beaconType == .bluecharm {
authenticateBlueCharm()
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error {
NSLog("BeaconProvisioner: Write failed for \(characteristic.uuid): \(error.localizedDescription)")
// If this was a password attempt, try next password
if characteristic.uuid == BeaconProvisioner.BLUECHARM_PASSWORD_CHAR {
passwordIndex += 1
authenticateBlueCharm()
return
}
fail("Write failed: \(error.localizedDescription)")
return
}
NSLog("BeaconProvisioner: Write succeeded for \(characteristic.uuid)")
// If password write succeeded, proceed to config
if characteristic.uuid == BeaconProvisioner.BLUECHARM_PASSWORD_CHAR {
writeBlueCharmConfig()
return
}
// Process next write in queue
processWriteQueue()
}
}