diff --git a/PayfritBeacon/Services/BLEManager.swift b/PayfritBeacon/Services/BLEManager.swift index b633bfb..7b34496 100644 --- a/PayfritBeacon/Services/BLEManager.swift +++ b/PayfritBeacon/Services/BLEManager.swift @@ -22,6 +22,10 @@ final class BLEManager: NSObject, ObservableObject { // GATT Service UUIDs static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") + static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB") + + // DX-Smart factory default iBeacon UUID + static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0" // MARK: - Private @@ -88,10 +92,34 @@ final class BLEManager: NSObject, ObservableObject { ) } + // MARK: - iBeacon Manufacturer Data Parsing + + /// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00) + /// Returns (uuid, major, minor) if valid iBeacon advertisement found + private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? { + // iBeacon format in manufacturer data: + // [0x4C 0x00] (Apple company ID) [0x02 0x15] (iBeacon type+length) + // [UUID 16 bytes] [Major 2 bytes] [Minor 2 bytes] [TX Power 1 byte] + guard mfgData.count >= 25 else { return nil } + guard mfgData[0] == 0x4C && mfgData[1] == 0x00 && + mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil } + + // Extract UUID (bytes 4-19) + let uuidBytes = mfgData.subdata(in: 4..<20) + let hex = uuidBytes.map { String(format: "%02X", $0) }.joined() + let uuid = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-\(hex.dropFirst(12).prefix(4))-\(hex.dropFirst(16).prefix(4))-\(hex.dropFirst(20))" + + // Extract Major (bytes 20-21) and Minor (bytes 22-23) + let major = UInt16(mfgData[20]) << 8 | UInt16(mfgData[21]) + let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]) + + return (uuid: uuid, major: major, minor: minor) + } + // MARK: - Beacon Type Detection (matches Android BeaconScanner.kt) // NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D. - // CoreBluetooth does not expose raw MAC addresses, so this detection - // path is unavailable on iOS. We rely on service UUID + device name instead. + // CoreBluetooth does not expose raw MAC addresses, so we compensate + // with broader iBeacon UUID detection and more permissive inclusion. func detectBeaconType( name: String?, @@ -100,12 +128,19 @@ final class BLEManager: NSObject, ObservableObject { ) -> BeaconType { let deviceName = (name ?? "").lowercased() - // 1. Service UUID matching + // Parse iBeacon data if available (needed for UUID-based detection) + let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)? + if let mfgData = manufacturerData { + iBeaconData = parseIBeaconData(mfgData) + } else { + iBeaconData = nil + } + + // 1. Service UUID matching (matches Android lines 122-126) if let services = serviceUUIDs { let serviceStrings = services.map { $0.uuidString.uppercased() } - if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { - return .bluecharm - } + + // Android: KBeacon uses FFE0 as primary service if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) { // Could be KBeacon or DXSmart — check name to differentiate if deviceName.contains("cp28") || deviceName.contains("cp-28") || @@ -114,9 +149,45 @@ final class BLEManager: NSObject, ObservableObject { } return .kbeacon } + + // Android: DXSmart also uses FFF0 (line 125) + // FIXED: Was incorrectly mapping FFF0 → BlueCharm only. + // Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 → DXSMART + if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { + // Check name patterns to decide: DXSmart or BlueCharm + if deviceName.contains("cp28") || deviceName.contains("cp-28") || + deviceName.contains("dx") || deviceName.contains("pddaxlque") || + deviceName.isEmpty { + // DX beacons often have no name or DX-prefixed names + return .dxsmart + } + if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") || + deviceName.hasPrefix("table-") { + return .bluecharm + } + // Default FFF0 to DXSmart (matching Android behavior) + return .dxsmart + } + + // Android: BlueCharm uses FEA0 (line 124) + if serviceStrings.contains(where: { $0.hasPrefix("0000FEA0") }) { + return .bluecharm + } } - // 2. Device name patterns + // 2. Detect DX-Smart by factory default iBeacon UUID (Android line 130) + // This is critical — catches DX beacons that don't advertise service UUIDs + if let ibeacon = iBeaconData { + if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame { + return .dxsmart + } + // Check if broadcasting a Payfrit shard UUID (already provisioned DX beacon) + if BeaconShardPool.isPayfrit(ibeacon.uuid) { + return .dxsmart + } + } + + // 3. Device name patterns (Android lines 131-147) if deviceName.contains("kbeacon") || deviceName.contains("kbpro") || deviceName.hasPrefix("kb") { return .kbeacon @@ -126,29 +197,25 @@ final class BLEManager: NSObject, ObservableObject { return .bluecharm } if deviceName.contains("cp28") || deviceName.contains("cp-28") || - deviceName.contains("dx-smart") || deviceName.contains("dxsmart") || - deviceName.contains("pddaxlque") { + deviceName.contains("dx-cp") || deviceName.contains("dx-smart") || + deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") { return .dxsmart } - // 3. Generic beacon patterns + // 4. Detect by iBeacon minor in high range (Android line 143) + if let ibeacon = iBeaconData, ibeacon.minor > 10000 { + return .dxsmart + } + + // 5. Generic beacon patterns (Android lines 145-147) if deviceName.contains("ibeacon") || deviceName.contains("beacon") || deviceName.hasPrefix("ble") { return .dxsmart // Default to DXSmart like Android } - // 4. Check manufacturer data for iBeacon advertisement - if let mfgData = manufacturerData, mfgData.count >= 23 { - // Apple iBeacon prefix: 0x4C00 0215 - if mfgData.count >= 4 && mfgData[0] == 0x4C && mfgData[1] == 0x00 && - mfgData[2] == 0x02 && mfgData[3] == 0x15 { - // Extract minor (bytes 22-23) — high minors suggest DXSmart factory defaults - if mfgData.count >= 24 { - let minorVal = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]) - if minorVal > 10000 { return .dxsmart } - } - return .kbeacon - } + // 6. Any remaining iBeacon advertisement — still a beacon we should show + if iBeaconData != nil { + return .dxsmart } return .unknown @@ -175,11 +242,21 @@ extension BLEManager: CBCentralManagerDelegate { let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) - // Only show recognized beacons - guard type != .unknown else { return } + // Match Android behavior (lines 164-169): + // Include devices that have a recognized type, OR + // are broadcasting iBeacon data, OR + // are connectable with a name (potential configurable beacon) + if type == .unknown { + let hasName = !name.isEmpty + let hasIBeaconData = mfgData.flatMap { parseIBeaconData($0) } != nil + if !hasIBeaconData && !(isConnectable && hasName) { + return + } + } if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { // Update existing