feat: full parity with Android beacon provisioner #23

Closed
koda wants to merge 1 commit from koda/full-parity-with-android into main
3 changed files with 240 additions and 69 deletions

150
CLAUDE.md Normal file
View file

@ -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**

View file

@ -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()

View file

@ -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.