- 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>
398 lines
13 KiB
Swift
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()
|
|
}
|
|
}
|