From 174240c13e1e152250579fd42dd1b49f23be9b0a Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 22:55:35 +0000 Subject: [PATCH] fix: overhaul beacon discovery to match Android detection logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major detection gaps were causing iOS to miss 7-8 out of 8-9 nearby DX beacons: 1. FFF0 service UUID was incorrectly mapped exclusively to BlueCharm. Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 to DXSMART. Now checks device name to disambiguate FFF0 between DX and BlueCharm, defaulting to DXSmart (matching Android behavior). 2. Added DX factory default UUID detection (E2C56DB5-DFFB-48D2-B060-D0F5A71096E0). Android catches DX beacons by this UUID on line 130 of BeaconScanner.kt. iOS was missing this entirely. 3. Added Payfrit shard UUID detection — already-provisioned DX beacons broadcasting a shard UUID are now recognized. 4. Added iBeacon manufacturer data parsing with proper UUID extraction. Any device broadcasting valid iBeacon data is now included (not just those with minor > 10000). 5. Added permissive fallback matching Android lines 164-169: connectable devices with names are included even if type is unknown, so they're at least visible to the user. 6. Added FEA0 service UUID for BlueCharm (Android line 124). 7. Added "DX-CP" name pattern (Android line 138) that was missing. Root cause: Android uses MAC OUI prefix 48:87:2D to catch all DX beacons regardless of advertisement contents. iOS can't do this (CoreBluetooth doesn't expose MAC addresses), so we compensate with broader iBeacon UUID matching and more permissive device inclusion. Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/Services/BLEManager.swift | 125 +++++++++++++++++++----- 1 file changed, 101 insertions(+), 24 deletions(-) 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