From c193f0abd18579ab49cc315bdd711593d0b549a6 Mon Sep 17 00:00:00 2001 From: Koda Date: Sun, 22 Mar 2026 17:40:43 +0000 Subject: [PATCH] feat: full parity with Android beacon provisioner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix BeaconShardPool: replace all 64 UUIDs to match Android + migration.sql (iOS had a completely different set of UUIDs — would cause shard resolution failures) - Add frames 3-6 disable to write sequence (16 → 24 steps, matches Android's DXSmartProvisioner.writeBeaconConfig() exactly) - Add CLAUDE.md with full project docs, API endpoints, protocol reference Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 150 ++++++++++++++++++++++++++ PayfritBeacon/BeaconProvisioner.swift | 30 +++++- PayfritBeacon/BeaconShardPool.swift | 129 +++++++++++----------- 3 files changed, 240 insertions(+), 69 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7010933 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,150 @@ +# Payfrit Beacon iOS + +Native Swift iOS app for provisioning iBeacon hardware at restaurant tables. Repo: `payfrit-beacon-ios` on Forgejo. + +## Purpose + +Utility app for restaurant setup staff to: +1. Scan for nearby BLE iBeacons +2. Check UUID against known bulk manufacturer defaults (ban list) +3. Assign table names to beacons (smart-incremented) +4. Save beacon + auto-create service point records via API + +## Environment + +- **Server**: dev.payfrit.com (always dev — this is a setup tool) +- **API Base**: `https://dev.payfrit.com/api` +- **IS_DEV**: Driven by `DEV` compiler flag (orange DEV banner on all screens) +- **OTP**: Dev server uses magic OTP (no Twilio) + +## Database (dev schema — migrated Jan 2026) + +Connected via API, not direct SQL. **Dev DB uses clean, unprefixed names.** PKs are always `ID`. FKs reference their parent table. + +| Table | PK | Key Columns | +|-------|----|-------------| +| Beacons | `ID` | `UUID`, `Name`, `BusinessID`, `IsActive` | +| ServicePoints | `ID` | `BusinessID`, `Name`, `TypeID`, `Code`, `SortOrder`, `IsActive`, `BeaconID` | +| Businesses | `ID` | `Name`, `ParentBusinessID` | + +**Note**: `POST /api/beacons/save.php` auto-creates a ServicePoint when saving a beacon. + +## API Endpoints Used + +| Method | Endpoint | Auth | Purpose | +|--------|----------|------|---------| +| POST | /auth/loginOTP.php | No | Send OTP to phone | +| POST | /auth/verifyLoginOTP.php | No | Verify OTP, get token | +| POST | /businesses/list.php | Yes | List user's businesses | +| POST | /businesses/get.php | Yes | Get single business | +| POST | /servicepoints/list.php | Yes | List service points for business | +| POST | /servicepoints/save.php | Yes | Create/update service point | +| POST | /beacon-sharding/allocate_business_namespace.php | Yes | Allocate UUID+Major shard | +| POST | /beacon-sharding/get_beacon_config.php | Yes | Get complete beacon config | +| POST | /beacon-sharding/register_beacon_hardware.php | Yes | Register provisioned device | +| POST | /beacon-sharding/verify_beacon_broadcast.php | Yes | Verify beacon is broadcasting | +| POST | /beacon-sharding/resolve_business.php | Yes | Resolve business by UUID+Major | +| POST | /beacon-sharding/allocate_servicepoint_minor.php | Yes | Auto-assign minor value | +| GET | /beacons/list_all.php | No | All active beacons (UUID→ID map) | +| POST | /beacons/lookup.php | No | Lookup beacon assignments by UUID | +| POST | /beacons/lookupByMac.php | No | Lookup beacon by MAC address | +| POST | /beacons/list.php | Yes | List beacons for a business | +| POST | /beacons/save.php | Yes | Create/update beacon + auto-create service point | +| POST | /beacons/wipe.php | Yes | Wipe/deactivate a beacon | + +## Build & Deploy + +Build in Xcode targeting iOS 17+. No local testing — deploy to device for BLE testing. + +## Project Structure + +``` +PayfritBeacon/ +├── Api.swift REST client, all API calls, auth token mgmt +├── BeaconBanList.swift Known bad UUID prefixes (factory defaults) +├── BeaconProvisioner.swift DX-Smart CP28 GATT provisioner (24-step write sequence) +├── BeaconScanner.swift iBeacon CLLocationManager scanner +├── BeaconShardPool.swift 64 Payfrit shard UUIDs (matches Android + migration.sql) +├── BLEBeaconScanner.swift CoreBluetooth BLE scanner + BeaconType enum +├── BusinessListView.swift Business selection screen +├── DebugLog.swift Shared logging singleton +├── DevBanner.swift Orange "DEV" banner overlay +├── LoginView.swift OTP login screen +├── PayfritBeaconApp.swift App entry point +├── RootView.swift Root navigation +├── ScanView.swift Main provisioning hub +├── ServicePointListView.swift Service point list + beacon assignment +└── UUIDFormatting.swift UUID string formatting extensions +``` + +## DX-Smart CP28 Provisioning Protocol + +### BLE Service & Characteristics + +- **Service**: `0000FFE0-0000-1000-8000-00805F9B34FB` +- **FFE1** (Notify): Response notifications (RX) +- **FFE2** (Write): Command TX +- **FFE3** (Write): Password authentication + +### Packet Format + +`[4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM]` + +Header: `4E 4F` (fixed). Checksum: XOR of CMD ⊕ LEN ⊕ DATA bytes. + +### Command Codes + +| Code | Purpose | +|------|---------| +| 0x11-0x16 | Select frame 1-6 | +| 0x60 | Save config to flash | +| 0x61 | Set frame as device info | +| 0x62 | Set frame as iBeacon | +| 0x71 | Write device name (max 20 ASCII) | +| 0x74 | Write UUID (16 bytes BE) | +| 0x75 | Write Major (2 bytes BE) | +| 0x76 | Write Minor (2 bytes BE) | +| 0x77 | Write RSSI@1m (1 byte signed) | +| 0x78 | Write adv interval (1 byte, x100ms) | +| 0x79 | Write TX power (1 byte, 0-7 index) | +| 0xA0 | Trigger off | +| 0xFF | Disable frame (no data) | + +### 24-Step Write Sequence + +1. DeviceName (0x71) → Frame1 select (0x11) → Frame1 type (0x61) → Frame1 RSSI/AdvInt/TxPow +2. Frame2 select (0x12) → Frame2 iBeacon (0x62) → UUID/Major/Minor/RSSI/AdvInt/TxPow +3. TriggerOff (0xA0) +4. Disable frames 3-6 (select 0x13-0x16 + 0xFF for each) +5. SaveConfig (0x60) + +### Authentication + +Passwords tried in order: `555555`, `dx1234`, `000000`. Written to FFE3 with `.withResponse`. + +## Ban List UUIDs + +Hardcoded in `BeaconBanList.swift`. Known factory-default prefixes: +- `E2C56DB5` — Apple AirLocate sample +- `B9407F30` — Estimote default +- `FDA50693` — Generic Chinese bulk default +- `F7826DA6` — Kontakt.io default +- `74278BDA` — Generic bulk default +- `00000000` / `FFFFFFFF` — Unconfigured hardware + +## Dependencies + +- SwiftUI + Combine +- CoreBluetooth (BLE GATT operations) +- CoreLocation (CLBeaconRegion for iBeacon ranging) +- Security (Keychain for auth token storage) + +## App Flow + +1. **Login** → OTP (Keychain-saved token for re-auth) +2. **Select Business** → Auto-select if one, list if multiple +3. **Select Service Point** → Table assignment target +4. **Scan** → BLE scan → show results with status badges +5. **Provision** → Connect → Auth → Write 24-step config → Save +6. **Register** → API call to register hardware +7. **Repeat** or **Done** diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 7215149..84db06c 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -94,11 +94,11 @@ struct BeaconCheckResult { /// - Callback-driven state machine (CoreBluetooth delegates) /// - Response gating: wait for FFE1 notification after each FFE2 write (matches Android) /// - Disconnect recovery: reconnect + re-auth + resume from saved write index -/// - 16-step write sequence: DeviceName, Frame1 (device info), Frame2 (iBeacon), Save +/// - 24-step write sequence: DeviceName, Frame1 (device info), Frame2 (iBeacon), Frames 3-6 disable, Save /// /// Key learnings preserved from previous iterations: /// 1. Skip device info read (0x30) — causes disconnects, MAC is optional -/// 2. Skip extra frame disables (3-6) — fewer writes = fewer disconnects +/// 2. Disable frames 3-6 explicitly (matches Android parity) /// 3. Full reconnect on FFE2 miss (CoreBluetooth caches stale GATT) /// 4. SaveConfig write-error = success (beacon reboots immediately) /// 5. Response gating between writes prevents MCU overload @@ -458,9 +458,17 @@ class BeaconProvisioner: NSObject, ObservableObject { /// 13. AdvInterval 0x78 [advInterval] /// 14. TxPower 0x79 [txPower] /// 15. TriggerOff 0xA0 - /// 16. SaveConfig 0x60 — persist to flash + /// 16. Frame3_Select 0x13 — select frame 3 + /// 17. Frame3_Off 0xFF — disable frame 3 + /// 18. Frame4_Select 0x14 — select frame 4 + /// 19. Frame4_Off 0xFF — disable frame 4 + /// 20. Frame5_Select 0x15 — select frame 5 + /// 21. Frame5_Off 0xFF — disable frame 5 + /// 22. Frame6_Select 0x16 — select frame 6 + /// 23. Frame6_Off 0xFF — disable frame 6 + /// 24. SaveConfig 0x60 — persist to flash /// - /// Frames 3-6 intentionally left untouched — fewer writes = fewer disconnects. + /// Matches Android DXSmartProvisioner.writeBeaconConfig() exactly. private func buildAndStartWriting() { guard let config = config else { fail("No config provided", code: .noConfig) @@ -509,7 +517,19 @@ class BeaconProvisioner: NSObject, ObservableObject { commandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 13. AdvInt commandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // 14. TxPower commandQueue.append(buildDXPacket(cmd: .triggerOff, data: [])) // 15. TriggerOff - commandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) // 16. Save + + // --- Disable Frames 3-6 (matches Android parity) --- + commandQueue.append(buildDXPacket(cmd: .frameSelectSlot2, data: [])) // 16. Frame 3 select + commandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) // 17. Frame 3 disable + commandQueue.append(buildDXPacket(cmd: .frameSelectSlot3, data: [])) // 18. Frame 4 select + commandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) // 19. Frame 4 disable + commandQueue.append(buildDXPacket(cmd: .frameSelectSlot4, data: [])) // 20. Frame 5 select + commandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) // 21. Frame 5 disable + commandQueue.append(buildDXPacket(cmd: .frameSelectSlot5, data: [])) // 22. Frame 6 select + commandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) // 23. Frame 6 disable + + // --- Save --- + commandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) // 24. Save DebugLog.shared.log("BLE: Command queue built with \(commandQueue.count) commands") sendNextCommand() diff --git a/PayfritBeacon/BeaconShardPool.swift b/PayfritBeacon/BeaconShardPool.swift index dc0cf0a..b0a19eb 100644 --- a/PayfritBeacon/BeaconShardPool.swift +++ b/PayfritBeacon/BeaconShardPool.swift @@ -8,71 +8,72 @@ import CoreLocation enum BeaconShardPool { /// All Payfrit shard UUIDs as strings (from BeaconShards database table). + /// Must match Android BeaconShardPool.kt and migration.sql exactly. static let uuidStrings: [String] = [ - "34b8cd87-1905-47a9-a7b7-fad8a4c011a1", - "5ee62089-8599-46f7-a399-d40c2f398712", - "fd3790ac-33eb-4091-b1c7-1e0615e68a87", - "bfce1bc4-ad2a-462a-918b-df26752c378d", - "845b64c7-0c91-41cd-9c30-ac56b6ae5ca1", - "7de0b2fb-69a3-4dbb-9808-8f33e2566661", - "54c34c7e-5a9b-4738-a4b4-2e228337ae3c", - "70f9fc09-25e6-46ec-8395-72f487877a1a", - "f8cef0af-6bef-4ba6-8a5d-599d647b628c", - "41868a10-7fd6-41c6-9b14-b5ca33c11471", - "25e1044d-446b-4403-9abd-1e15f806dfe9", - "cdeefaf0-bf95-4dab-8bd5-f7b261f9935d", - "bf3b156a-a0fb-4bad-b3fd-0b408ffc9d6e", - "11b7c63e-a61d-4530-a447-2cb8e6a30a45", - "d0519f2d-97a1-4484-a2c2-57135affe427", - "d2d1caa9-aa89-4c9d-93b1-d6fe1527f622", - "6f65071f-e060-44e7-a6f9-5dae49cbf095", - "4492bbbb-8584-421a-8f26-cb20c7727ead", - "73bc2b23-9cf8-4a93-8bfc-5cf44f03a973", - "70129c14-78ed-447e-ab9e-638243f8bdae", - "6956f91b-e581-48a5-b364-181662cb2f9f", - "39fc9b45-d1b3-4d97-aa82-a52457bf808f", - "ef150f40-5e24-4267-a1d0-b5ea5ce66c99", - "ac504bbd-fbdb-46d0-83c0-cdf53e82cdf8", - "bbecefd2-7317-4fd4-93d9-2661df8c4762", - "b252557a-e998-4b28-9d7d-bc9a8c907441", - "527b504c-d363-438a-bb65-2db1db6cb487", - "ea5eef55-b7e9-4866-a4a1-8b4a1f6ea79d", - "40a5d0c8-727a-47db-8ffd-154bfc36e03d", - "4d90467e-5f68-41ef-b4ec-2b2c8ec1adce", - "1cc513ee-627a-4cfe-b162-7cea3cb1374e", - "2913ab6e-ab0d-4666-bff1-7fe3169c4f55", - "7371381a-f2aa-4a40-b497-b06e66d51a31", - "e890450f-0b8d-4a5a-973e-5af37233c13b", - "d190eef0-59ee-44bc-a459-e0d5b767b26f", - "76ebe90f-f4b2-45d4-9887-841b1ddd3ca9", - "7fbed5b0-a212-4497-9b54-9831e433491b", - "3d41e7b0-5d91-4178-81c1-de42ab6b3466", - "5befd90a-7967-4fe5-89ba-7a9de617d507", - "e033235c-f69d-4018-a197-78e7df59dfa3", - "71edc8b9-b120-415a-a1d4-77bdd8e30f14", - "521de568-a0e6-4ec9-bf1c-bdb7b9f9cae2", - "a28db91b-c18f-4b4b-804f-38664d3456cc", - "5738e431-25bc-4cc1-a4e2-da8c562075b3", - "f90b7c87-324b-4fd5-b2ff-acf6800f6bd0", - "bd4ea89c-f99d-4440-8e27-d295836fd09d", - "b5c2d016-1143-4bb2-992b-f08fb073ef2c", - "0bb16d1a-f970-4baf-b410-76e5f1ff7c9e", - "b4f22e62-4052-4c58-a18b-38e2a5c04b9a", - "b8150be6-0fbd-4bb9-9993-a8a2992d5003", - "50d2d4c6-1907-4789-afe2-3b28baa3c679", - "ee42778d-53c9-42c9-8dfa-87a509799990", - "6001ee07-fc35-45f7-8ef6-afc30371bd73", - "0761bede-deb6-4b08-bfbb-10675060164a", - "c03ac1de-a7ea-490a-b3a9-7cc5e8ab4dd1", - "57ecd21d-76b1-4016-8c86-7c5a861aae67", - "f119066c-a4e2-4b2e-aef3-6a0bf6b288bc", - "e2c2ccff-d651-488f-9d74-4ecf4a0487e0", - "7d5ba66c-d8f8-4d54-9900-4c52b5667682", - "1b0f57f9-0c02-43a5-9740-63acbc9574a0", - "314fdc08-fbfd-4bd8-aaae-e579d9ef567d", - "5835398b-95ac-44ba-af78-a5d3dc4fc0ad", - "3eb1baca-84bb-4d85-8860-42a9df3b820e", - "da73ba99-976c-4e81-894a-d799e05f9186" + "f7826da6-4fa2-4e98-8024-bc5b71e0893e", + "2f234454-cf6d-4a0f-adf2-f4911ba9ffa6", + "b9407f30-f5f8-466e-aff9-25556b57fe6d", + "e2c56db5-dffb-48d2-b060-d0f5a71096e0", + "d0d3fa86-ca76-45ec-9bd9-6af4fac1e268", + "a7ae2eb7-1f00-4168-b99b-a749bac36c92", + "8deefbb9-f738-4297-8040-96668bb44281", + "5a4bcfce-174e-4bac-a814-092978f50e04", + "74278bda-b644-4520-8f0c-720eaf059935", + "e7eb6d67-2b4f-4c1b-8b6e-8c3b3f5d8b9a", + "1f3c5d7e-9a2b-4c8d-ae6f-0b1c2d3e4f5a", + "a1b2c3d4-e5f6-4789-abcd-ef0123456789", + "98765432-10fe-4cba-9876-543210fedcba", + "deadbeef-cafe-4bab-dead-beefcafebabe", + "c0ffee00-dead-4bee-f000-ba5eba11fade", + "0a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d", + "12345678-90ab-4def-1234-567890abcdef", + "fedcba98-7654-4210-fedc-ba9876543210", + "abcd1234-ef56-4789-abcd-1234ef567890", + "11111111-2222-4333-4444-555566667777", + "88889999-aaaa-4bbb-cccc-ddddeeeeefff", + "01234567-89ab-4cde-f012-3456789abcde", + "a0a0a0a0-b1b1-4c2c-d3d3-e4e4e4e4f5f5", + "f0e0d0c0-b0a0-4908-0706-050403020100", + "13579bdf-2468-4ace-1357-9bdf2468ace0", + "fdb97531-eca8-4642-0fdb-97531eca8642", + "aabbccdd-eeff-4011-2233-445566778899", + "99887766-5544-4332-2110-ffeeddccbbaa", + "a1a2a3a4-b5b6-4c7c-8d9d-e0e1f2f3f4f5", + "5f4f3f2f-1f0f-4efe-dfcf-bfaf9f8f7f6f", + "00112233-4455-4667-7889-9aabbccddeef", + "feeddccb-baa9-4887-7665-5443322110ff", + "1a2b3c4d-5e6f-4a0b-1c2d-3e4f5a6b7c8d", + "d8c7b6a5-9483-4726-1504-f3e2d1c0b9a8", + "0f1e2d3c-4b5a-4697-8879-6a5b4c3d2e1f", + "f1e2d3c4-b5a6-4978-8697-a5b4c3d2e1f0", + "12ab34cd-56ef-4789-0abc-def123456789", + "987654fe-dcba-4098-7654-321fedcba098", + "abcdef01-2345-4678-9abc-def012345678", + "876543fe-dcba-4210-9876-543fedcba210", + "0a0b0c0d-0e0f-4101-1121-314151617181", + "91a1b1c1-d1e1-4f10-2030-405060708090", + "a0b1c2d3-e4f5-4a6b-7c8d-9e0f1a2b3c4d", + "d4c3b2a1-0f9e-48d7-c6b5-a49382716050", + "50607080-90a0-4b0c-0d0e-0f1011121314", + "14131211-100f-4e0d-0c0b-0a0908070605", + "a1b2c3d4-e5f6-4718-293a-4b5c6d7e8f90", + "09f8e7d6-c5b4-4a39-2817-0615f4e3d2c1", + "11223344-5566-4778-899a-abbccddeeff0", + "ffeeddc0-bbaa-4988-7766-554433221100", + "a1a1b2b2-c3c3-4d4d-e5e5-f6f6a7a7b8b8", + "b8b8a7a7-f6f6-4e5e-5d4d-4c3c3b2b2a1a", + "12341234-5678-4567-89ab-89abcdefcdef", + "fedcfedc-ba98-4ba9-8765-87654321d321", + "0a1a2a3a-4a5a-4a6a-7a8a-9aaabacadaea", + "eadacaba-aa9a-48a7-a6a5-a4a3a2a1a0af", + "01020304-0506-4708-090a-0b0c0d0e0f10", + "100f0e0d-0c0b-4a09-0807-060504030201", + "aabbccdd-1122-4334-4556-6778899aabbc", + "cbba9988-7766-4554-4332-2110ddccbbaa", + "f0f1f2f3-f4f5-4f6f-7f8f-9fafbfcfdfef", + "efdfcfbf-af9f-48f7-f6f5-f4f3f2f1f0ee", + "a0a1a2a3-a4a5-4a6a-7a8a-9a0b1b2b3b4b", + "4b3b2b1b-0a9a-48a7-a6a5-a4a3a2a1a0ff" ] /// All Payfrit shard UUIDs as UUID objects.