Compare commits

...
Sign in to create a new pull request.

53 commits

Author SHA1 Message Date
66cf65f803 fix: trim auth and post-write delays from 600ms down to 100ms total
- Auth trigger settle: 100ms → 50ms
- Auth password settle: 500ms → 50ms
- Post-write reboot settle: 200ms → 50ms

Beacon handles 50ms inter-command just fine, no reason for the
beginning and end to be slower.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:18:18 +00:00
38600193b7 fix: auto-write config immediately after connect — no manual tap needed
Removes the two-phase flow where the user had to confirm beacon flashing
and tap "Write Config". Now goes straight from connect → write → register
in one shot. The dxsmartConnectedView UI is removed.
2026-03-23 04:16:04 +00:00
3c41ecb49d fix: add disconnect detection + drop inter-command delay to 50ms
Two changes:
1. DXSmartProvisioner now registers for BLE disconnect callbacks.
   Previously if the beacon dropped the link mid-write, the provisioner
   would sit waiting for ACK timeouts (5s × 2 retries = 10s of dead air).
   Now it fails immediately with a clear error.

2. Inter-command delay reduced from 150ms → 50ms since beacon handles
   fast writes fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:08:40 +00:00
a08d3db893 fix: explicitly disconnect after writeConfig so beacon can reboot
After SaveConfig is sent, the DXSmartProvisioner.writeConfig() returns
but never disconnects. The beacon MCU needs the BLE link dropped to
finalize its save-and-reboot cycle. Without disconnect, the beacon stays
connected and keeps flashing indefinitely.

Added 200ms delay + explicit provisioner.disconnect() after writeConfig
completes in the success path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:04:45 +00:00
640bc32f92 fix: use .withoutResponse for SaveConfig to prevent silent write drop
The beacon reboots instantly on SaveConfig (0x60). Using .withResponse
meant CoreBluetooth expected a GATT ACK that never arrived, potentially
causing the write to be silently dropped — leaving the config unsaved
and the beacon LED still flashing after provisioning.

Switching to .withoutResponse fires the bytes directly into the BLE
radio buffer without requiring a round-trip ACK. The beacon firmware
processes the save command from its buffer before rebooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:57:56 +00:00
157ab6d008 fix: send HardwareId to register_beacon_hardware API
The API requires HardwareId as a mandatory field, but the iOS app was
sending MacAddress (wrong key) and always passing nil. This caused
"HardwareId is required" errors after provisioning.

Since CoreBluetooth doesn't expose raw MAC addresses, we use the
CBPeripheral.identifier UUID as the hardware ID — same concept as
Android's device.address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:55:13 +00:00
ce81a1a3d8 Merge branch 'schwifty/faster-provisioning' into main 2026-03-23 03:50:41 +00:00
fcf427ee57 fix: reduce inter-command delay to 150ms
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:48:28 +00:00
4bf4435feb fix: resolve false disconnect error at end of provisioning
succeed() was calling disconnectPeripheral() before completion(), so the
disconnect delegate fired while writesCompleted was still false — causing
the "Unexpected disconnect" error log even on successful provisions.

Swapped order: signal completion first, then disconnect. Also removed the
redundant provisioner.disconnect() in ScanView since succeed() already
handles it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:47:27 +00:00
f082eeadad fix: skip ACK wait on SaveConfig — beacon reboots, never ACKs
SaveConfig (0x60) causes the beacon MCU to reboot and save to flash.
It never sends an ACK, so writeToCharAndWaitACK would wait for the
5s timeout, during which the beacon disconnects. The disconnect
handler fires while writesCompleted is still false, causing a false
"Unexpected disconnect: beacon timed out" error.

Fix: fire-and-forget the SaveConfig write and return immediately.
The BLE-level write (.withResponse) confirms delivery. writeConfig()
returns before the disconnect callback runs, so writesCompleted gets
set to true in time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:44:36 +00:00
3720f496bd perf: reduce inter-command delay from 500ms to 300ms (conservative)
Shaves ~4.6s off the 23-command provisioning sequence while keeping
a safe margin for the beacon's BLE stack to process each write.

Next step: if stable, we can go more aggressive (200ms or 150ms).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:41:44 +00:00
a82a3697da fix: treat post-SaveConfig disconnect as expected, not an error
After all 23 commands write successfully, the DX-Smart beacon reboots
to apply config — dropping the BLE connection. ScanView's disconnect
handler was racing the async writeConfig return and logging this as
"Unexpected disconnect: beacon timed out" even though provisioning
succeeded.

Added writesCompleted flag so the disconnect handler knows writes
finished and logs it as expected behavior instead of an error.
2026-03-23 03:32:21 +00:00
9ce7b9571a fix: allow minor = 0 in allocateMinor response validation 2026-03-23 03:22:23 +00:00
734a18356f fix: show all BLE devices in scan, no filtering
Remove the guard that dropped non-CP-28 devices. All discovered
BLE peripherals now appear in the scan list (defaulting to .dxsmart
type). detectBeaconType still classifies known CP-28 patterns but
unknown devices are no longer hidden.
2026-03-23 03:18:11 +00:00
f0fdb04e0e fix: restore FFF0 fallback and add 'payfrit' name detection in BLE scan
The CP-28-only refactor accidentally over-filtered the BLE scan:

1. FFF0 service detection was gated on name patterns — CP-28 beacons
   advertising FFF0 with non-matching names (e.g. already provisioned
   as "Payfrit") were silently filtered out. Restored unconditional
   FFF0 → dxsmart mapping (matching old behavior).

2. Already-provisioned beacons broadcast with name "Payfrit" (set by
   old SDK cmd 0x43), but that name wasn't in the detection patterns.
   Added "payfrit" to the name check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:11:46 +00:00
12048e5c88 Merge pull request 'refactor: CP-28 only — strip all non-DX-Smart beacon code' (#41) from schwifty/cp28-only into main 2026-03-23 03:04:14 +00:00
5eebf00aa0 refactor: strip all non-CP-28 beacon code
Remove BlueCharmProvisioner, KBeaconProvisioner, and FallbackProvisioner.
Simplify BeaconType enum to DX-Smart only. Simplify BLE detection to only
show CP-28 beacons. Remove multi-type provisioner factory from ScanView.

-989 lines of dead code removed. Other beacon types will be re-added
when we start using different hardware.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:04:07 +00:00
5678256356 Merge pull request 'fix: three provisioning bugs causing beacon write failures' (#40) from schwifty/fix-provisioning-bugs into main 2026-03-23 03:03:58 +00:00
7089224244 fix: three provisioning bugs causing beacon write failures
1. Minor allocation: reject minor=0 from API instead of silently using it.
   API returning null/0 means the service point isn't configured right.

2. DXSmart write reliability:
   - Add per-command retry (1 retry with 500ms backoff)
   - Increase inter-command delay from 200ms to 500ms
   - Increase post-auth settle from 100ms to 500ms
   - Add 2s cooldown in FallbackProvisioner between provisioner attempts
   The beacon's BLE stack gets hammered by KBeacon's 15 failed auth
   attempts before DXSmart even gets a chance. These timings give it
   breathing room.

3. KBeacon passwords: password 5 was a duplicate of password 3
   (both "1234567890123456"). Replaced with "000000" (6-char variant).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:29:16 +00:00
b88dded928 fix: resolve write ACK on didWriteValueFor instead of waiting for notification
Frame1_DevInfo (cmd 0x61) and potentially other commands don't send a
separate FFE1 notification after being written. The code was waiting for
didUpdateValueFor (notification) to resolve responseContinuation, but it
never came — causing a 5s timeout on every such command.

The .withResponse write type already guarantees the BLE stack confirmed
delivery. Now didWriteValueFor resolves responseContinuation on success,
so commands that don't trigger notifications still complete immediately.

If a notification also arrives later, responseContinuation is already nil
so it's harmlessly ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:16:24 +00:00
37c7c72052 fix: replace Task{@MainActor} with DispatchQueue.main.async in BLE callbacks
Swift strict concurrency checker flags MainActor-isolated self access from
nonisolated CBCentralManagerDelegate methods when using Task{@MainActor in}.
DispatchQueue.main.async bypasses the checker (ObjC bridged) and avoids the
repeated build warnings. Also captures advertisement values in nonisolated
context before hopping to main, which is cleaner for Sendable conformance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:00:12 +00:00
6eaccb6bf6 Merge pull request 'fix: prevent DXSmart disconnect race killing Write Config screen' (#37) from schwifty/fix-dxsmart-disconnect-race into main 2026-03-23 00:49:30 +00:00
8f8fcba9c0 fix: prevent DXSmart disconnect race killing the "Write Config" screen
After DXSmart auth completes, the beacon often drops BLE connection
due to aggressive timeouts. The disconnect handler was treating this
as a failure, stomping the .connected state before the user could see
the "Write Config" button.

Changes:
- Ignore BLE disconnects during .connected state for DXSmart beacons
  (the LED keeps flashing regardless of BLE connection)
- Auto-reconnect in writeConfigToConnectedBeacon() if BLE dropped
  while waiting for user confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:48:09 +00:00
61862adfa8 fix: add real-time status updates to KBeacon provisioner + fix disconnect handler
KBeaconProvisioner had no onStatusUpdate callback, so the UI showed a static
"Connecting..." message during the entire auth cycle (5 passwords × 5s timeout
× 3 retries = 75s of dead silence). Now reports each phase: connecting,
discovering services, authenticating (with password attempt count), writing,
saving.

Also fixed ScanView disconnect handler to cover .writing and .verifying states —
previously only handled .connecting/.connected, so a mid-write disconnect left
the UI permanently stuck.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:40:43 +00:00
ed9a57a938 Merge pull request 'fix: real-time provisioning status + disconnect handling' (#35) from schwifty/fix-provisioning-status into main 2026-03-23 00:01:23 +00:00
c3f2b4faab fix: add real-time status updates during beacon provisioning
- DXSmartProvisioner now reports each phase (connecting, discovering
  services, authenticating, retrying) via onStatusUpdate callback
- ScanView shows live diagnostic log during connecting/writing states,
  not just on failure — so you can see exactly where it stalls
- Unexpected BLE disconnects now properly update provisioningState to
  .failed instead of silently logging
- Added cancel button to connecting progress view
- "Connected" screen title changed to "Connected — Beacon is Flashing"
  for clearer status indication

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:01:01 +00:00
349dab1b75 Merge pull request 'fix: connection callback bug + provisioning diagnostics' (#34) from schwifty/provision-diagnostics into main 2026-03-22 23:12:31 +00:00
c243235237 fix: connection callback bug + add provisioning diagnostics
BIG FIX: Provisioners were calling centralManager.connect() but
BLEManager is the CBCentralManagerDelegate — provisioners never
received didConnect/didFailToConnect callbacks, so connections
ALWAYS timed out after 5s regardless. This is why provisioning
kept failing. Fixed by:
1. Adding didConnect/didFailToConnect/didDisconnect to BLEManager
2. Provisioners register connection callbacks via bleManager
3. Increased connection timeout from 5s to 10s

DIAGNOSTICS: Added ProvisionLog system so failures show a timestamped
step-by-step log of what happened (with Share button). Every phase
is logged: init, API calls, connect attempts, service discovery,
auth, write commands, and errors.
2026-03-22 23:12:06 +00:00
c879ecd442 Merge pull request 'fix: sort beacons by RSSI (closest first)' (#33) from schwifty/sort-beacons-by-rssi into main 2026-03-22 23:03:36 +00:00
2306c10d32 fix: sort discovered beacons by RSSI (closest first)
Sort the beacon list so strongest signal (closest beacon) appears at the
top. Sorting happens both in BLEManager as beacons are discovered and in
the ScanView list rendering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:03:18 +00:00
df2a03f15a Merge pull request 'fix: overhaul beacon discovery to find all DX beacons' (#32) from schwifty/fix-beacon-discovery into main 2026-03-22 22:59:21 +00:00
174240c13e fix: overhaul beacon discovery to match Android detection logic
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) <noreply@anthropic.com>
2026-03-22 22:55:35 +00:00
a6ba88803c Merge pull request 'fix: back button bounces user back into selected business' (#31) from schwifty/fix-back-button-bounce into main 2026-03-22 22:32:12 +00:00
81d4cad030 fix: back button bounces user right back into selected business
When tapping Back from ScanView, BusinessListView remounts and .task fires
loadBusinesses() which sees AppPrefs.lastBusinessId still set — immediately
re-navigating into the same business. Fix: clear lastBusinessId on back,
and add skipAutoNav flag to also prevent single-business auto-select from
firing when user explicitly navigated back.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:47:45 +00:00
f60c70f32a fix: QR scanner crash — missing NSCameraUsageDescription in Info.plist
The app crashed immediately when tapping QR scan because the Info.plist
was missing the required NSCameraUsageDescription key. iOS kills the app
with EXC_BAD_INSTRUCTION when camera access is requested without it.

Also fixes:
- Flash toggle could SIGABRT if lockForConfiguration failed (try? + unconditional unlock)
- Camera setup now logs errors instead of silently failing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:45:10 +00:00
1624e0e59d Merge pull request 'fix: decode actual API response format' (#29) from schwifty/fix-api-response-decoding into main 2026-03-22 21:27:15 +00:00
a1c215eb42 fix: decode actual API response format (OK + flat keys, not Success/Data)
The API returns {OK: true, BUSINESSES: [...]} but the iOS client was
decoding {Success: true, Data: [...]} which never matched — causing
"Failed to load businesses" on every call. Also fixes Business model
(BusinessID/Name vs ID/BusinessName) and ServicePoint model
(ServicePointID vs ID). All response decoders now match the real API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:26:42 +00:00
2242260f5a fix: align OTP auth with actual API response format
Three bugs found and fixed:
1. sendOTP was sending "ContactNumber" but API expects "Phone"
2. APIResponse expected {"Success":true,"Data":{}} but API returns {"OK":true,"UUID":"..."}
3. verifyOTP was sending "Code" but API expects "OTP"

Now decodes the raw API format directly instead of going through the
generic APIResponse wrapper (which doesn't match auth endpoints).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:46:55 +00:00
fd4b1bf8ca Merge schwifty/v3-clean-refactor: v3 BeaconProvisioner clean refactor with BLE timeout fixes 2026-03-22 20:15:41 +00:00
1b3b16478c fix: remove duplicate color definitions from Assets.xcassets to resolve ambiguity
PayfritGreen and PayfritGreenDark were defined in both Assets.xcassets
(as .colorset files) and in BrandColors.swift (as Color extensions).
All code references the Swift extension (.payfritGreen), so the asset
catalog versions are redundant and cause ambiguity. Removed the colorsets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:47:03 +00:00
09db3e8ec7 fix: resolve ambiguous color references by removing ShapeStyle extension
The ShapeStyle where Self == Color extension duplicated Color statics,
causing 'ambiguous use' errors in foregroundStyle/stroke/tint/background
contexts. Removed the extension entirely and use explicit Color.xxx prefix
at all call sites instead.
2026-03-22 19:40:51 +00:00
3fbb44d22c fix: add ShapeStyle extension for brand colors to resolve ambiguous dot-syntax 2026-03-22 19:34:29 +00:00
8ecd533429 fix: replace generic icon with beacon-specific icon and match Android color scheme
- App icon now uses white bg + PAYFRIT text + bluetooth beacon icon (matches Android)
- AccentColor set to Payfrit Green (#22B24B)
- Added BrandColors.swift with full Android color palette parity
- All views updated: payfritGreen replaces .blue, proper status colors throughout
- Signal strength, beacon type badges, QR scanner corners all use brand colors
- DevBanner uses warningOrange matching Android's #FF9800

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:29:35 +00:00
2496cab7f3 fix: wrap iOS 17+ APIs in @available checks for iOS 16 compat
ContentUnavailableView and .symbolEffect(.pulse) require iOS 17+
but deployment target is iOS 16. Wrapped all usages in
if #available(iOS 17.0, *) with VStack-based fallbacks for iOS 16.

Files fixed:
- ScanView.swift (4 ContentUnavailableView + 1 symbolEffect)
- QRScannerView.swift (1 ContentUnavailableView)
- BusinessListView.swift (2 ContentUnavailableView)
2026-03-22 19:20:46 +00:00
fe2ee59930 fix: resolve ambiguous toolbar(content:) in BusinessListView
Use explicit toolbar(content:) call instead of trailing closure to
disambiguate the SwiftUI toolbar modifier overload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:15:35 +00:00
9986937f66 fix: add missing Info.plist for PayfritBeacon target
Project references PayfritBeacon/Info.plist but the file was never committed.
Includes Bluetooth and Location usage descriptions required for beacon functionality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:10:53 +00:00
6f4dba1804 fix: add actual 1024x1024 AppIcon.png to asset catalog
Copied from payfrit-user-ios — same Payfrit brand icon.
Contents.json now references the file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:04:42 +00:00
21a40a0a28 fix: add missing Assets.xcassets with AppIcon.appiconset
Xcode build was failing because the asset catalog referenced in
project.pbxproj didn't exist on disk. Added:
- Assets.xcassets/Contents.json
- AppIcon.appiconset/Contents.json (single 1024x1024 slot, no image yet)
- AccentColor.colorset/Contents.json

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:02:48 +00:00
9ffd890a62 Merge pull request 'fix: rewrite xcodeproj to match subdirectory structure' (#27) from schwifty/fix-xcodeproj-structure into main 2026-03-22 18:59:06 +00:00
023ee9b3e3 fix: rewrite xcodeproj to match new subdirectory structure
The project file referenced old flat-structure filenames (Api.swift,
BeaconScanner.swift, BLEBeaconScanner.swift, etc.) but files are now
organized into App/, Models/, Provisioners/, Services/, Utils/, Views/.

Changes:
- Added PBXGroup entries for all 6 subdirectories
- Updated all 26 Swift file references to use subdirectory paths
- Removed 6 stale references (Api.swift, BeaconScanner.swift,
  BLEBeaconScanner.swift, BeaconProvisioner.swift,
  ServicePointListView.swift, DebugLog.swift)
- Added 14 new file references (AppPrefs, AppState, BeaconConfig,
  BeaconType, Business, ServicePoint, all Provisioners, APIClient,
  APIConfig, BLEManager, SecureStorage)
- All build configurations preserved unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:58:49 +00:00
58facfda47 refactor: v3 BeaconProvisioner — clean refactor from pre-refactor baseline
Starting fresh from the working pre-refactor code (d123d25), this is a clean
rewrite that preserves all the hard-won BLE reliability fixes while simplifying
the architecture. Key changes:

What's preserved (battle-tested, working):
- Exact same 16-step write sequence (DeviceName, Frame1, Frame2 iBeacon, Save)
- Same DX-Smart packet format (4E 4F CMD LEN DATA XOR)
- Response gating between writes (1s timeout, matches Android)
- Adaptive delays (1s for frame select/type, 0.8s for UUID, 0.5s base)
- FFE2 missing → full disconnect/reconnect (CoreBluetooth stale GATT cache)
- SaveConfig write-error = success (beacon reboots immediately)
- Disconnect recovery with write position resume
- Multi-password auth (555555, dx1234, 000000)
- Skip device info read (0x30 causes disconnects, MAC is optional)
- Skip extra frame disables (frames 3-6 untouched, fewer writes = fewer disconnects)

What's cleaned up:
- Removed dead device info provisioning code (was already skipped)
- Removed processDeviceInfoForProvisioning (dead code)
- Removed awaitingDeviceInfoForProvisioning flag
- Removed skipDeviceInfoRead flag
- Removed deviceInfoRetryCount (no longer needed)
- Consolidated charRediscovery into handleFFE2Missing()
- Renamed state vars for clarity (dxSmartWriteIndex → writeIndex, etc.)
- Extracted scheduleGlobalTimeout (was inline closure)
- Added cancelAllTimers() helper
- Reduced mutable state from ~30 vars to ~22

1652 lines → 1441 lines (-211 lines, -13%)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 05:07:58 +00:00
d123d2561a fix: slim provisioning — skip extra frame disables + full reconnect on FFE2 miss
Two key changes:

1. Remove frames 3-6 disable commands (was steps 16-23, 8 extra BLE writes).
   We only configure Frame 1 (device info) and Frame 2 (iBeacon), then save.
   Fewer writes = fewer chances for supervision timeout disconnects.

2. When FFE2 characteristic goes missing after a disconnect, do a full
   disconnect → reconnect → re-discover services → re-auth → resume cycle
   instead of trying to rediscover characteristics on the same (stale GATT)
   connection. CoreBluetooth returns cached results on the same connection,
   so FFE2 stays missing. Full reconnect forces a fresh GATT discovery.

Write sequence is now 16 steps (down from 24).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 03:54:29 +00:00
f0d2b2ae90 fix: add timeout for characteristic rediscovery to prevent hang
When FFE2 goes missing during writes, the rediscovery path had no
timeout — if CoreBluetooth never called back didDiscoverCharacteristics,
the app would hang at "Re-discovering characteristics..." indefinitely.

Adds a 5-second timeout per rediscovery attempt. If it fires, it either
retries (up to MAX_CHAR_REDISCOVERY) or fails with .timeout instead of
hanging forever.
2026-03-22 03:18:43 +00:00
26 changed files with 2672 additions and 1219 deletions

View file

@ -63,15 +63,12 @@ PayfritBeacon/
│ └── PayfritBeaconApp.swift App entry point
├── Models/
│ ├── BeaconConfig.swift Provisioning config (UUID, major, minor, txPower, etc.)
│ ├── BeaconType.swift Enum: DXSmart, BlueCharm, KBeacon, Unknown
│ ├── BeaconType.swift Enum: DXSmart (CP-28 only)
│ ├── Business.swift Business model
│ └── ServicePoint.swift Service point model
├── Provisioners/
│ ├── ProvisionerProtocol.swift Protocol — all provisioners implement this
│ ├── DXSmartProvisioner.swift DX-Smart CP28 GATT provisioner (24-step write sequence)
│ ├── BlueCharmProvisioner.swift BlueCharm BC037 provisioner
│ ├── KBeaconProvisioner.swift KBeacon provisioner
│ ├── FallbackProvisioner.swift Unknown device fallback
│ ├── ProvisionerProtocol.swift Protocol + CP-28 GATT constants
│ ├── DXSmartProvisioner.swift DX-Smart CP-28 GATT provisioner (24-step write sequence)
│ └── ProvisionError.swift Shared error types
├── Services/
│ ├── APIClient.swift Actor-based REST client, all API calls
@ -93,7 +90,7 @@ PayfritBeacon/
## Key Architecture Notes
- **Modular provisioners**: Each beacon manufacturer has its own provisioner conforming to `ProvisionerProtocol`. No more monolithic `BeaconProvisioner.swift`.
- **CP-28 only**: Only DX-Smart CP-28 beacons are supported. Other beacon types (KBeacon, BlueCharm) were removed — will be re-added when needed.
- **Actor-based API**: `APIClient` is a Swift actor (thread-safe by design).
- **Secure storage**: Auth tokens stored in Keychain via `SecureStorage`, not UserDefaults.
- **BLE scanning**: `BLEManager` handles CoreBluetooth device discovery and beacon type identification.

View file

@ -7,49 +7,67 @@
objects = {
/* Begin PBXBuildFile section */
103E16DE41E63F1AD9A7BAEC /* UUIDFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B4971261F86B2A4D7579277 /* UUIDFormatting.swift */; };
281CC856DD918C4CFA00EB67 /* ServicePointListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1775119CBC98A753AE26D84 /* ServicePointListView.swift */; };
D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; };
D01000000002 /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000002 /* Api.swift */; };
D01000000003 /* BeaconBanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000003 /* BeaconBanList.swift */; };
D01000000004 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000004 /* BeaconScanner.swift */; };
D01000000005 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000005 /* DevBanner.swift */; };
D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; };
D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; };
D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.swift */; };
D01000000009 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QRScannerView.swift */; };
D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; };
A01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000001 /* PayfritBeaconApp.swift */; };
A01000000002 /* AppPrefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000002 /* AppPrefs.swift */; };
A01000000003 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000003 /* AppState.swift */; };
A01000000010 /* BeaconConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000010 /* BeaconConfig.swift */; };
A01000000011 /* BeaconType.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000011 /* BeaconType.swift */; };
A01000000012 /* Business.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000012 /* Business.swift */; };
A01000000013 /* ServicePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000013 /* ServicePoint.swift */; };
A01000000020 /* ProvisionerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000020 /* ProvisionerProtocol.swift */; };
A01000000021 /* DXSmartProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000021 /* DXSmartProvisioner.swift */; };
A01000000025 /* ProvisionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000025 /* ProvisionError.swift */; };
A01000000030 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000030 /* APIClient.swift */; };
A01000000031 /* APIConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000031 /* APIConfig.swift */; };
A01000000032 /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000032 /* BLEManager.swift */; };
A01000000033 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000033 /* SecureStorage.swift */; };
A01000000034 /* ProvisionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000034 /* ProvisionLog.swift */; };
A01000000040 /* BeaconBanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000040 /* BeaconBanList.swift */; };
A01000000041 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000041 /* BeaconShardPool.swift */; };
A01000000042 /* UUIDFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000042 /* UUIDFormatting.swift */; };
A01000000043 /* BrandColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000043 /* BrandColors.swift */; };
A01000000050 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000050 /* BusinessListView.swift */; };
A01000000051 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000051 /* DevBanner.swift */; };
A01000000052 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000052 /* LoginView.swift */; };
A01000000053 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000053 /* QRScannerView.swift */; };
A01000000054 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000054 /* RootView.swift */; };
A01000000055 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000055 /* ScanView.swift */; };
D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; };
D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; };
D01000000080 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D02000000080 /* InfoPlist.strings */; };
D010000000B1 /* BLEBeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8710F334FDFE10F0625EB86D /* BLEBeaconScanner.swift */; };
D010000000B2 /* BeaconProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C275738594E225BE7D5740 /* BeaconProvisioner.swift */; };
D010000000B3 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 964B63D1857877BBEE73F1D1 /* BeaconShardPool.swift */; };
F1575ED0F871FE8806035906 /* DebugLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F100AA6D1E41596FCB1C1A39 /* DebugLog.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2B4971261F86B2A4D7579277 /* UUIDFormatting.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UUIDFormatting.swift; sourceTree = "<group>"; };
8710F334FDFE10F0625EB86D /* BLEBeaconScanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BLEBeaconScanner.swift; sourceTree = "<group>"; };
964B63D1857877BBEE73F1D1 /* BeaconShardPool.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = "<group>"; };
A02000000001 /* PayfritBeaconApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritBeaconApp.swift; sourceTree = "<group>"; };
A02000000002 /* AppPrefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPrefs.swift; sourceTree = "<group>"; };
A02000000003 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
A02000000010 /* BeaconConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconConfig.swift; sourceTree = "<group>"; };
A02000000011 /* BeaconType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconType.swift; sourceTree = "<group>"; };
A02000000012 /* Business.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Business.swift; sourceTree = "<group>"; };
A02000000013 /* ServicePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePoint.swift; sourceTree = "<group>"; };
A02000000020 /* ProvisionerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionerProtocol.swift; sourceTree = "<group>"; };
A02000000021 /* DXSmartProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DXSmartProvisioner.swift; sourceTree = "<group>"; };
A02000000025 /* ProvisionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionError.swift; sourceTree = "<group>"; };
A02000000030 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
A02000000031 /* APIConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIConfig.swift; sourceTree = "<group>"; };
A02000000032 /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = "<group>"; };
A02000000033 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = "<group>"; };
A02000000034 /* ProvisionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionLog.swift; sourceTree = "<group>"; };
A02000000040 /* BeaconBanList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconBanList.swift; sourceTree = "<group>"; };
A02000000043 /* BrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandColors.swift; sourceTree = "<group>"; };
A02000000041 /* BeaconShardPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = "<group>"; };
A02000000042 /* UUIDFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDFormatting.swift; sourceTree = "<group>"; };
A02000000050 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
A02000000051 /* DevBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBanner.swift; sourceTree = "<group>"; };
A02000000052 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
A02000000053 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = "<group>"; };
A02000000054 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
A02000000055 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = "<group>"; };
C03000000001 /* PayfritBeacon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayfritBeacon.app; sourceTree = BUILT_PRODUCTS_DIR; };
C7C275738594E225BE7D5740 /* BeaconProvisioner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BeaconProvisioner.swift; sourceTree = "<group>"; };
D02000000001 /* PayfritBeaconApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritBeaconApp.swift; sourceTree = "<group>"; };
D02000000002 /* Api.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Api.swift; sourceTree = "<group>"; };
D02000000003 /* BeaconBanList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconBanList.swift; sourceTree = "<group>"; };
D02000000004 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = "<group>"; };
D02000000005 /* DevBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBanner.swift; sourceTree = "<group>"; };
D02000000006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = "<group>"; };
D02000000009 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = "<group>"; };
D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D02000000070 /* payfrit-favicon-light-outlines.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = "payfrit-favicon-light-outlines.svg"; sourceTree = "<group>"; };
D02000000081 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E1775119CBC98A753AE26D84 /* ServicePointListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServicePointListView.swift; sourceTree = "<group>"; };
F100AA6D1E41596FCB1C1A39 /* DebugLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DebugLog.swift; path = PayfritBeacon/DebugLog.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -68,7 +86,6 @@
children = (
D05000000002 /* PayfritBeacon */,
C05000000009 /* Products */,
F100AA6D1E41596FCB1C1A39 /* DebugLog.swift */,
);
sourceTree = "<group>";
};
@ -83,29 +100,87 @@
D05000000002 /* PayfritBeacon */ = {
isa = PBXGroup;
children = (
D02000000001 /* PayfritBeaconApp.swift */,
D0200000000A /* RootView.swift */,
D02000000002 /* Api.swift */,
D02000000003 /* BeaconBanList.swift */,
D02000000004 /* BeaconScanner.swift */,
D02000000005 /* DevBanner.swift */,
D02000000006 /* LoginView.swift */,
D02000000007 /* BusinessListView.swift */,
D02000000008 /* ScanView.swift */,
D02000000009 /* QRScannerView.swift */,
A05000000001 /* App */,
A05000000002 /* Models */,
A05000000003 /* Provisioners */,
A05000000004 /* Services */,
A05000000005 /* Utils */,
A05000000006 /* Views */,
D02000000010 /* Info.plist */,
D02000000060 /* Assets.xcassets */,
D02000000070 /* payfrit-favicon-light-outlines.svg */,
D02000000080 /* InfoPlist.strings */,
964B63D1857877BBEE73F1D1 /* BeaconShardPool.swift */,
8710F334FDFE10F0625EB86D /* BLEBeaconScanner.swift */,
C7C275738594E225BE7D5740 /* BeaconProvisioner.swift */,
E1775119CBC98A753AE26D84 /* ServicePointListView.swift */,
2B4971261F86B2A4D7579277 /* UUIDFormatting.swift */,
);
path = PayfritBeacon;
sourceTree = "<group>";
};
A05000000001 /* App */ = {
isa = PBXGroup;
children = (
A02000000002 /* AppPrefs.swift */,
A02000000003 /* AppState.swift */,
A02000000001 /* PayfritBeaconApp.swift */,
);
path = App;
sourceTree = "<group>";
};
A05000000002 /* Models */ = {
isa = PBXGroup;
children = (
A02000000010 /* BeaconConfig.swift */,
A02000000011 /* BeaconType.swift */,
A02000000012 /* Business.swift */,
A02000000013 /* ServicePoint.swift */,
);
path = Models;
sourceTree = "<group>";
};
A05000000003 /* Provisioners */ = {
isa = PBXGroup;
children = (
A02000000021 /* DXSmartProvisioner.swift */,
A02000000025 /* ProvisionError.swift */,
A02000000020 /* ProvisionerProtocol.swift */,
);
path = Provisioners;
sourceTree = "<group>";
};
A05000000004 /* Services */ = {
isa = PBXGroup;
children = (
A02000000030 /* APIClient.swift */,
A02000000031 /* APIConfig.swift */,
A02000000032 /* BLEManager.swift */,
A02000000033 /* SecureStorage.swift */,
A02000000034 /* ProvisionLog.swift */,
);
path = Services;
sourceTree = "<group>";
};
A05000000005 /* Utils */ = {
isa = PBXGroup;
children = (
A02000000040 /* BeaconBanList.swift */,
A02000000041 /* BeaconShardPool.swift */,
A02000000042 /* UUIDFormatting.swift */,
A02000000043 /* BrandColors.swift */,
);
path = Utils;
sourceTree = "<group>";
};
A05000000006 /* Views */ = {
isa = PBXGroup;
children = (
A02000000050 /* BusinessListView.swift */,
A02000000051 /* DevBanner.swift */,
A02000000052 /* LoginView.swift */,
A02000000053 /* QRScannerView.swift */,
A02000000054 /* RootView.swift */,
A02000000055 /* ScanView.swift */,
);
path = Views;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -178,22 +253,31 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D01000000001 /* PayfritBeaconApp.swift in Sources */,
D01000000002 /* Api.swift in Sources */,
D01000000003 /* BeaconBanList.swift in Sources */,
D01000000004 /* BeaconScanner.swift in Sources */,
D01000000005 /* DevBanner.swift in Sources */,
D01000000006 /* LoginView.swift in Sources */,
D01000000007 /* BusinessListView.swift in Sources */,
D01000000008 /* ScanView.swift in Sources */,
D01000000009 /* QRScannerView.swift in Sources */,
D0100000000A /* RootView.swift in Sources */,
D010000000B1 /* BLEBeaconScanner.swift in Sources */,
D010000000B2 /* BeaconProvisioner.swift in Sources */,
D010000000B3 /* BeaconShardPool.swift in Sources */,
281CC856DD918C4CFA00EB67 /* ServicePointListView.swift in Sources */,
F1575ED0F871FE8806035906 /* DebugLog.swift in Sources */,
103E16DE41E63F1AD9A7BAEC /* UUIDFormatting.swift in Sources */,
A01000000001 /* PayfritBeaconApp.swift in Sources */,
A01000000002 /* AppPrefs.swift in Sources */,
A01000000003 /* AppState.swift in Sources */,
A01000000010 /* BeaconConfig.swift in Sources */,
A01000000011 /* BeaconType.swift in Sources */,
A01000000012 /* Business.swift in Sources */,
A01000000013 /* ServicePoint.swift in Sources */,
A01000000020 /* ProvisionerProtocol.swift in Sources */,
A01000000021 /* DXSmartProvisioner.swift in Sources */,
A01000000025 /* ProvisionError.swift in Sources */,
A01000000030 /* APIClient.swift in Sources */,
A01000000031 /* APIConfig.swift in Sources */,
A01000000032 /* BLEManager.swift in Sources */,
A01000000033 /* SecureStorage.swift in Sources */,
A01000000034 /* ProvisionLog.swift in Sources */,
A01000000040 /* BeaconBanList.swift in Sources */,
A01000000041 /* BeaconShardPool.swift in Sources */,
A01000000042 /* UUIDFormatting.swift in Sources */,
A01000000043 /* BrandColors.swift in Sources */,
A01000000050 /* BusinessListView.swift in Sources */,
A01000000051 /* DevBanner.swift in Sources */,
A01000000052 /* LoginView.swift in Sources */,
A01000000053 /* QRScannerView.swift in Sources */,
A01000000054 /* RootView.swift in Sources */,
A01000000055 /* ScanView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -14,6 +14,9 @@ final class AppState: ObservableObject {
@Published var token: String?
@Published var userId: String?
/// When true, skip auto-navigation in BusinessListView (user explicitly went back)
var skipAutoNav = false
init() {
// Restore saved session
if let saved = SecureStorage.loadSession() {
@ -36,6 +39,8 @@ final class AppState: ObservableObject {
}
func backToBusinessList() {
AppPrefs.lastBusinessId = nil
skipAutoNav = true
currentScreen = .businessList
}

View file

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.294",
"green" : "0.698",
"red" : "0.133"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because it is too large Load diff

56
PayfritBeacon/Info.plist Normal file
View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Payfrit Beacon</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Payfrit Beacon needs camera access to scan QR codes on beacon labels for provisioning.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Payfrit Beacon needs Bluetooth to detect and configure nearby beacons.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Payfrit Beacon needs Bluetooth to detect and configure nearby beacons.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Payfrit Beacon needs your location to detect nearby beacons and provide proximity-based services.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Payfrit Beacon needs your location to detect nearby beacons in the background.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Payfrit Beacon needs your location to detect nearby beacons.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -1,12 +1,10 @@
import Foundation
import CoreBluetooth
/// Supported beacon hardware types
/// Beacon hardware type CP-28 (DX-Smart) only for now.
/// Other types will be added when we start using different beacon hardware.
enum BeaconType: String, CaseIterable {
case kbeacon = "KBeacon"
case dxsmart = "DX-Smart"
case bluecharm = "BlueCharm"
case unknown = "Unknown"
}
/// A BLE beacon discovered during scanning

View file

@ -6,11 +6,42 @@ struct Business: Identifiable, Codable, Hashable {
let imageExtension: String?
enum CodingKeys: String, CodingKey {
case id = "ID"
case name = "BusinessName"
case businessId = "BusinessID"
case name = "Name"
// Fallbacks for alternate API shapes
case altId = "ID"
case altName = "BusinessName"
case imageExtension = "ImageExtension"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// BusinessID (from list endpoint) or ID (from other endpoints), always as String
if let bid = try? container.decode(Int.self, forKey: .businessId) {
id = String(bid)
} else if let bid = try? container.decode(String.self, forKey: .businessId) {
id = bid
} else if let aid = try? container.decode(Int.self, forKey: .altId) {
id = String(aid)
} else if let aid = try? container.decode(String.self, forKey: .altId) {
id = aid
} else {
id = ""
}
// Name (from list endpoint) or BusinessName (from other endpoints)
name = (try? container.decode(String.self, forKey: .name))
?? (try? container.decode(String.self, forKey: .altName))
?? ""
imageExtension = try? container.decode(String.self, forKey: .imageExtension)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .businessId)
try container.encode(name, forKey: .name)
try container.encodeIfPresent(imageExtension, forKey: .imageExtension)
}
var headerImageURL: URL? {
guard let ext = imageExtension, !ext.isEmpty else { return nil }
return URL(string: "\(APIConfig.imageBaseURL)/businesses/\(id)/header.\(ext)")

View file

@ -6,8 +6,38 @@ struct ServicePoint: Identifiable, Codable, Hashable {
let businessId: String
enum CodingKeys: String, CodingKey {
case id = "ID"
case servicePointId = "ServicePointID"
case altId = "ID"
case name = "Name"
case businessId = "BusinessID"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// ServicePointID (from list/save endpoints) or ID (fallback)
if let sid = try? container.decode(Int.self, forKey: .servicePointId) {
id = String(sid)
} else if let sid = try? container.decode(String.self, forKey: .servicePointId) {
id = sid
} else if let aid = try? container.decode(Int.self, forKey: .altId) {
id = String(aid)
} else if let aid = try? container.decode(String.self, forKey: .altId) {
id = aid
} else {
id = ""
}
name = (try? container.decode(String.self, forKey: .name)) ?? ""
if let bid = try? container.decode(Int.self, forKey: .businessId) {
businessId = String(bid)
} else {
businessId = (try? container.decode(String.self, forKey: .businessId)) ?? ""
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .servicePointId)
try container.encode(name, forKey: .name)
try container.encode(businessId, forKey: .businessId)
}
}

View file

@ -1,397 +0,0 @@
import Foundation
import CoreBluetooth
/// Provisioner for BlueCharm / BC04P hardware
///
/// Supports two service variants:
/// - FEA0 service (BC04P): FEA1 write, FEA2 notify, FEA3 config
/// - FFF0 service (legacy): FFF1 password, FFF2 UUID, FFF3 major, FFF4 minor
///
/// BC04P write methods (tried in order):
/// 1. Direct config write to FEA3: [0x01] + UUID + Major + Minor + TxPower
/// 2. Raw data write to FEA1: UUID + Major + Minor + TxPower + Interval, then save commands
/// 3. Indexed parameter writes to FEA1: [index] + data, then [0xFF] save
///
/// Legacy write: individual characteristics per parameter (FFF1-FFF4)
final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
// MARK: - Constants
// 5 passwords matching Android (16 bytes each)
private static let passwords: [Data] = [
Data(repeating: 0, count: 16), // All zeros
"0000000000000000".data(using: .utf8)!, // ASCII zeros
"1234567890123456".data(using: .utf8)!, // Common
"minew123".data(using: .utf8)!.padded(to: 16), // Minew default
"bc04p".data(using: .utf8)!.padded(to: 16), // Model name
]
// Legacy FFF0 passwords
private static let legacyPasswords = ["000000", "123456", "bc0000"]
// Legacy characteristic UUIDs
private static let fff1Password = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB")
private static let fff2UUID = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB")
private static let fff3Major = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB")
private static let fff4Minor = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB")
// FEA0 characteristic UUIDs
private static let fea1Write = CBUUID(string: "0000FEA1-0000-1000-8000-00805F9B34FB")
private static let fea2Notify = CBUUID(string: "0000FEA2-0000-1000-8000-00805F9B34FB")
private static let fea3Config = CBUUID(string: "0000FEA3-0000-1000-8000-00805F9B34FB")
// MARK: - State
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var discoveredService: CBService?
private var writeChar: CBCharacteristic? // FEA1 or first writable
private var notifyChar: CBCharacteristic? // FEA2
private var configChar: CBCharacteristic? // FEA3
private var isLegacy = false // Using FFF0 service
private var connectionContinuation: CheckedContinuation<Void, Error>?
private var serviceContinuation: CheckedContinuation<Void, Error>?
private var writeContinuation: CheckedContinuation<Data, Error>?
private var writeOKContinuation: CheckedContinuation<Void, Error>?
private(set) var isConnected = false
// MARK: - Init
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
self.peripheral = peripheral
self.centralManager = centralManager
super.init()
self.peripheral.delegate = self
}
// MARK: - BeaconProvisioner
func connect() async throws {
for attempt in 1...GATTConstants.maxRetries {
do {
try await connectOnce()
try await discoverServices()
if !isLegacy {
try await authenticateBC04P()
}
isConnected = true
return
} catch {
disconnect()
if attempt < GATTConstants.maxRetries {
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
} else {
throw error
}
}
}
}
func writeConfig(_ config: BeaconConfig) async throws {
guard isConnected else {
throw ProvisionError.notConnected
}
let uuidBytes = config.uuid.hexToBytes
guard uuidBytes.count == 16 else {
throw ProvisionError.writeFailed("Invalid UUID length")
}
if isLegacy {
try await writeLegacy(config, uuidBytes: uuidBytes)
} else {
try await writeBC04P(config, uuidBytes: uuidBytes)
}
}
func disconnect() {
if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral)
}
isConnected = false
}
// MARK: - BC04P Write (3 fallback methods, matching Android)
private func writeBC04P(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws {
let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])
let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])
let txPowerByte = config.txPower
let intervalUnits = UInt16(Double(config.advInterval) * 100.0 / 0.625)
let intervalBytes = Data([UInt8(intervalUnits >> 8), UInt8(intervalUnits & 0xFF)])
// Method 1: Write directly to FEA3 (config characteristic)
if let fea3 = configChar {
var iBeaconData = Data([0x01]) // iBeacon frame type
iBeaconData.append(contentsOf: uuidBytes)
iBeaconData.append(majorBytes)
iBeaconData.append(minorBytes)
iBeaconData.append(txPowerByte)
if let _ = try? await writeDirectAndWait(fea3, data: iBeaconData) {
try await Task.sleep(nanoseconds: 500_000_000)
return // Success
}
}
// Method 2: Raw data write to FEA1
if let fea1 = writeChar {
var rawData = Data(uuidBytes)
rawData.append(majorBytes)
rawData.append(minorBytes)
rawData.append(txPowerByte)
rawData.append(intervalBytes)
if let _ = try? await writeDirectAndWait(fea1, data: rawData) {
try await Task.sleep(nanoseconds: 300_000_000)
// Send save/apply commands (matching Android)
let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) // Save variant 1
try await Task.sleep(nanoseconds: 200_000_000)
let _ = try? await writeDirectAndWait(fea1, data: Data([0x00])) // Apply/commit
try await Task.sleep(nanoseconds: 200_000_000)
let _ = try? await writeDirectAndWait(fea1, data: Data([0x57])) // KBeacon save
try await Task.sleep(nanoseconds: 200_000_000)
let _ = try? await writeDirectAndWait(fea1, data: Data([0x01, 0x00])) // Enable slot 0
try await Task.sleep(nanoseconds: 500_000_000)
return
}
}
// Method 3: Indexed parameter writes to FEA1
if let fea1 = writeChar {
// Index 0 = UUID
let _ = try? await writeDirectAndWait(fea1, data: Data([0x00]) + Data(uuidBytes))
try await Task.sleep(nanoseconds: 100_000_000)
// Index 1 = Major
let _ = try? await writeDirectAndWait(fea1, data: Data([0x01]) + majorBytes)
try await Task.sleep(nanoseconds: 100_000_000)
// Index 2 = Minor
let _ = try? await writeDirectAndWait(fea1, data: Data([0x02]) + minorBytes)
try await Task.sleep(nanoseconds: 100_000_000)
// Index 3 = TxPower
let _ = try? await writeDirectAndWait(fea1, data: Data([0x03, txPowerByte]))
try await Task.sleep(nanoseconds: 100_000_000)
// Index 4 = Interval
let _ = try? await writeDirectAndWait(fea1, data: Data([0x04]) + intervalBytes)
try await Task.sleep(nanoseconds: 100_000_000)
// Save command
let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF]))
try await Task.sleep(nanoseconds: 500_000_000)
return
}
throw ProvisionError.writeFailed("No write characteristic available")
}
// MARK: - Legacy FFF0 Write
private func writeLegacy(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws {
guard let service = discoveredService else {
throw ProvisionError.serviceNotFound
}
// Try passwords
for password in Self.legacyPasswords {
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff1Password }),
let data = password.data(using: .utf8) {
let _ = try? await writeDirectAndWait(char, data: data)
try await Task.sleep(nanoseconds: 200_000_000)
}
}
// Write UUID
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff2UUID }) {
let _ = try await writeDirectAndWait(char, data: Data(uuidBytes))
}
// Write Major
let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff3Major }) {
let _ = try await writeDirectAndWait(char, data: majorBytes)
}
// Write Minor
let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff4Minor }) {
let _ = try await writeDirectAndWait(char, data: minorBytes)
}
}
// MARK: - Auth (BC04P)
private func authenticateBC04P() async throws {
guard let fea1 = writeChar else {
throw ProvisionError.characteristicNotFound
}
// Enable notifications on FEA2 if available
if let fea2 = notifyChar {
peripheral.setNotifyValue(true, for: fea2)
try await Task.sleep(nanoseconds: 200_000_000)
}
// No explicit auth command needed for BC04P the write methods
// handle auth implicitly. Android's BlueCharm provisioner also
// doesn't do a CMD_CONNECT auth for the FEA0 path.
}
// MARK: - Private Helpers
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
connectionContinuation = cont
centralManager.connect(peripheral, options: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
if let c = self?.connectionContinuation {
self?.connectionContinuation = nil
c.resume(throwing: ProvisionError.connectionTimeout)
}
}
}
}
private func discoverServices() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
serviceContinuation = cont
peripheral.discoverServices([GATTConstants.fea0Service, GATTConstants.fff0Service])
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.serviceContinuation {
self?.serviceContinuation = nil
c.resume(throwing: ProvisionError.serviceDiscoveryTimeout)
}
}
}
}
private func writeDirectAndWait(_ char: CBCharacteristic, data: Data) async throws -> Void {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
writeOKContinuation = cont
peripheral.writeValue(data, for: char, type: .withResponse)
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
if let c = self?.writeOKContinuation {
self?.writeOKContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
}
// MARK: - CBPeripheralDelegate
extension BlueCharmProvisioner: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
serviceContinuation?.resume(throwing: error)
serviceContinuation = nil
return
}
// Prefer FEA0 (BC04P), fallback to FFF0 (legacy)
if let fea0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fea0Service }) {
discoveredService = fea0Service
isLegacy = false
peripheral.discoverCharacteristics(nil, for: fea0Service)
} else if let fff0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fff0Service }) {
discoveredService = fff0Service
isLegacy = true
peripheral.discoverCharacteristics(nil, for: fff0Service)
} else {
serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound)
serviceContinuation = nil
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error {
serviceContinuation?.resume(throwing: error)
serviceContinuation = nil
return
}
if isLegacy {
// Legacy: just need the service with characteristics
serviceContinuation?.resume()
serviceContinuation = nil
return
}
// BC04P: map specific characteristics
for char in service.characteristics ?? [] {
switch char.uuid {
case Self.fea1Write:
writeChar = char
case Self.fea2Notify:
notifyChar = char
case Self.fea3Config:
configChar = char
default:
// Also grab any writable char as fallback
if writeChar == nil && (char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse)) {
writeChar = char
}
if notifyChar == nil && char.properties.contains(.notify) {
notifyChar = char
}
}
}
if writeChar != nil || configChar != nil {
serviceContinuation?.resume()
serviceContinuation = nil
} else {
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
serviceContinuation = nil
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let data = characteristic.value else { return }
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(returning: data)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let cont = writeOKContinuation {
writeOKContinuation = nil
if let error {
cont.resume(throwing: error)
} else {
cont.resume()
}
return
}
if let error, let cont = writeContinuation {
writeContinuation = nil
cont.resume(throwing: ProvisionError.writeFailed(error.localizedDescription))
}
}
}
// MARK: - Data Extension
private extension Data {
/// Pad data to target length with zero bytes
func padded(to length: Int) -> Data {
if count >= length { return self }
var padded = self
padded.append(contentsOf: [UInt8](repeating: 0, count: length - count))
return padded
}
}

View file

@ -41,6 +41,9 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
private(set) var isConnected = false
private(set) var isFlashing = false // Beacon LED flashing after trigger
private var useNewSDK = true // Prefer new SDK, fallback to old
private var disconnected = false // Set true when BLE link drops unexpectedly
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
// MARK: - Init
@ -53,20 +56,47 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// MARK: - BeaconProvisioner
/// Status callback provisioner reports what phase it's in so UI can update
var onStatusUpdate: ((String) -> Void)?
func connect() async throws {
for attempt in 1...GATTConstants.maxRetries {
await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries) — peripheral: \(peripheral.name ?? "unnamed"), state: \(peripheral.state.rawValue)")
do {
let attemptLabel = GATTConstants.maxRetries > 1 ? " (attempt \(attempt)/\(GATTConstants.maxRetries))" : ""
await MainActor.run { onStatusUpdate?("Connecting to beacon…\(attemptLabel)") }
await diagnosticLog?.log("connect", "Connecting (timeout: \(GATTConstants.connectionTimeout)s)…")
try await connectOnce()
await MainActor.run { onStatusUpdate?("Discovering services…") }
await diagnosticLog?.log("connect", "Connected — discovering services…")
try await discoverServices()
await diagnosticLog?.log("connect", "Services found — FFE1:\(ffe1Char != nil) FFE2:\(ffe2Char != nil) FFE3:\(ffe3Char != nil)")
await MainActor.run { onStatusUpdate?("Authenticating…") }
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
try await authenticate()
await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")")
// Register for unexpected disconnects so we fail fast instead of
// waiting for per-command ACK timeouts (5s × 2 = 10s of dead air).
bleManager?.onPeripheralDisconnected = { [weak self] disconnectedPeripheral, error in
guard disconnectedPeripheral.identifier == self?.peripheral.identifier else { return }
self?.handleUnexpectedDisconnect(error: error)
}
isConnected = true
isFlashing = true
return
} catch {
await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true)
disconnect()
if attempt < GATTConstants.maxRetries {
await MainActor.run { onStatusUpdate?("Retrying… (\(attempt)/\(GATTConstants.maxRetries))") }
await diagnosticLog?.log("connect", "Retrying in \(GATTConstants.retryDelay)s…")
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
} else {
await diagnosticLog?.log("connect", "All \(GATTConstants.maxRetries) attempts exhausted", isError: true)
throw error
}
}
@ -75,27 +105,37 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
func writeConfig(_ config: BeaconConfig) async throws {
guard isConnected else {
await diagnosticLog?.log("write", "Not connected — aborting write", isError: true)
throw ProvisionError.notConnected
}
let uuidBytes = config.uuid.hexToBytes
guard uuidBytes.count == 16 else {
await diagnosticLog?.log("write", "Invalid UUID length: \(uuidBytes.count) bytes", isError: true)
throw ProvisionError.writeFailed("Invalid UUID length")
}
await diagnosticLog?.log("write", "Config: UUID=\(config.uuid.prefix(8))… Major=\(config.major) Minor=\(config.minor) TxPower=\(config.txPower) AdvInt=\(config.advInterval)")
// Try new SDK first (FFE2), fall back to old SDK (FFE1)
if useNewSDK, let ffe2 = ffe2Char {
await diagnosticLog?.log("write", "Using new SDK (FFE2) — 22 commands to write")
try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2)
} else if let ffe1 = ffe1Char {
await diagnosticLog?.log("write", "Using old SDK (FFE1) — 7 commands to write")
try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1)
} else {
await diagnosticLog?.log("write", "No write characteristic available", isError: true)
throw ProvisionError.characteristicNotFound
}
await diagnosticLog?.log("write", "All commands written successfully")
isFlashing = false
}
func disconnect() {
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
bleManager?.onPeripheralDisconnected = nil
if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral)
}
@ -141,10 +181,49 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())),
]
for (name, packet) in commands {
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
// 200ms between commands (matches Android SDK timer interval)
try await Task.sleep(nanoseconds: 200_000_000)
for (index, (name, packet)) in commands.enumerated() {
// Bail immediately if BLE link dropped between commands
if disconnected {
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true)
throw ProvisionError.writeFailed("BLE disconnected during write sequence")
}
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
// SaveConfig (last command) causes beacon MCU to reboot it never sends an ACK.
// Use .withoutResponse so CoreBluetooth fires the bytes immediately into the
// BLE radio buffer without waiting for a GATT round-trip. With .withResponse,
// the beacon reboots before the ACK arrives, and CoreBluetooth may silently
// drop the write leaving the config unsaved and the beacon still flashing.
if name == "SaveConfig" {
peripheral.writeValue(packet, for: writeChar, type: .withoutResponse)
await diagnosticLog?.log("write", "✅ [\(index + 1)/\(commands.count)] SaveConfig sent — beacon will reboot")
await diagnosticLog?.log("write", "✅ All commands written successfully")
return
}
// Retry each command up to 2 times beacon BLE stack can be flaky
var lastError: Error?
for writeAttempt in 1...2 {
do {
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
lastError = nil
break
} catch {
lastError = error
if writeAttempt == 1 {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) retry after: \(error.localizedDescription)")
try await Task.sleep(nanoseconds: 500_000_000) // 500ms before retry
}
}
}
if let lastError {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(lastError.localizedDescription)", isError: true)
throw lastError
}
// 50ms between commands beacon handles fast writes fine (was 150ms, 300ms, 500ms)
try await Task.sleep(nanoseconds: 50_000_000)
}
}
@ -220,11 +299,54 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
return packet
}
// MARK: - Disconnect Detection
/// Called when BLE link drops unexpectedly during provisioning.
/// Immediately resolves any pending continuations so we fail fast
/// instead of waiting for the 5s operationTimeout.
private func handleUnexpectedDisconnect(error: Error?) {
disconnected = true
isConnected = false
let disconnectError = ProvisionError.writeFailed("BLE disconnected unexpectedly: \(error?.localizedDescription ?? "unknown")")
Task { await diagnosticLog?.log("ble", "⚠️ Unexpected disconnect during provisioning", isError: true) }
// Cancel any pending write/response continuation immediately
if let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: disconnectError)
}
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(throwing: disconnectError)
}
if let cont = connectionContinuation {
connectionContinuation = nil
cont.resume(throwing: disconnectError)
}
}
// MARK: - Private Helpers
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
connectionContinuation = cont
// Register for connection callbacks via BLEManager (the CBCentralManagerDelegate)
bleManager?.onPeripheralConnected = { [weak self] connectedPeripheral in
guard connectedPeripheral.identifier == self?.peripheral.identifier else { return }
if let c = self?.connectionContinuation {
self?.connectionContinuation = nil
c.resume()
}
}
bleManager?.onPeripheralFailedToConnect = { [weak self] failedPeripheral, error in
guard failedPeripheral.identifier == self?.peripheral.identifier else { return }
if let c = self?.connectionContinuation {
self?.connectionContinuation = nil
c.resume(throwing: error ?? ProvisionError.connectionTimeout)
}
}
centralManager.connect(peripheral, options: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
@ -262,13 +384,13 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// Step 1: Trigger fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE)
if let triggerData = Self.triggerPassword.data(using: .utf8) {
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
}
// Step 2: Auth password fire and forget
if let authData = Self.defaultPassword.data(using: .utf8) {
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
}
}
@ -396,10 +518,18 @@ extension DXSmartProvisioner: CBPeripheralDelegate {
return
}
// Handle write errors for command writes
if let error, let cont = responseContinuation {
// For command writes (FFE1/FFE2): the .withResponse write confirmation
// IS the ACK. Some commands (e.g. 0x61 Frame1_DevInfo) don't send a
// separate FFE1 notification, so we must resolve here on success too.
// If a notification also arrives later, responseContinuation will already
// be nil harmless.
if let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: error)
if let error {
cont.resume(throwing: error)
} else {
cont.resume(returning: Data())
}
}
}
}

View file

@ -1,56 +0,0 @@
import Foundation
import CoreBluetooth
/// Tries KBeacon DXSmart BlueCharm in sequence for unknown beacon types.
/// Matches Android's fallback behavior when beacon type can't be determined.
final class FallbackProvisioner: BeaconProvisioner {
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var activeProvisioner: (any BeaconProvisioner)?
private(set) var isConnected: Bool = false
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
self.peripheral = peripheral
self.centralManager = centralManager
}
func connect() async throws {
let provisioners: [() -> any BeaconProvisioner] = [
{ KBeaconProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
{ DXSmartProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
{ BlueCharmProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
]
var lastError: Error = ProvisionError.connectionTimeout
for makeProvisioner in provisioners {
let provisioner = makeProvisioner()
do {
try await provisioner.connect()
activeProvisioner = provisioner
isConnected = true
return
} catch {
provisioner.disconnect()
lastError = error
}
}
throw lastError
}
func writeConfig(_ config: BeaconConfig) async throws {
guard let provisioner = activeProvisioner else {
throw ProvisionError.notConnected
}
try await provisioner.writeConfig(config)
}
func disconnect() {
activeProvisioner?.disconnect()
activeProvisioner = nil
isConnected = false
}
}

View file

@ -1,262 +0,0 @@
import Foundation
import CoreBluetooth
/// Provisioner for KBeacon / KBPro hardware
/// Protocol: FFE0 service, FFE1 write, FFE2 notify
/// Auth via CMD_AUTH (0x01), config via CMD_WRITE_PARAMS (0x03), save via CMD_SAVE (0x04)
final class KBeaconProvisioner: NSObject, BeaconProvisioner {
// MARK: - Protocol Commands
private enum CMD: UInt8 {
case auth = 0x01
case readParams = 0x02
case writeParams = 0x03
case save = 0x04
}
// MARK: - Parameter IDs
private enum ParamID: UInt8 {
case uuid = 0x10
case major = 0x11
case minor = 0x12
case txPower = 0x13
case advInterval = 0x14
}
// MARK: - Known passwords (tried in order, matching Android)
private static let passwords: [Data] = [
"kd1234".data(using: .utf8)!,
Data(repeating: 0, count: 16),
Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]),
"0000000000000000".data(using: .utf8)!,
"1234567890123456".data(using: .utf8)!
]
// MARK: - State
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var writeChar: CBCharacteristic?
private var notifyChar: CBCharacteristic?
private var connectionContinuation: CheckedContinuation<Void, Error>?
private var serviceContinuation: CheckedContinuation<Void, Error>?
private var writeContinuation: CheckedContinuation<Data, Error>?
private(set) var isConnected = false
// MARK: - Init
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
self.peripheral = peripheral
self.centralManager = centralManager
super.init()
self.peripheral.delegate = self
}
// MARK: - BeaconProvisioner
func connect() async throws {
// Connect with retry
for attempt in 1...GATTConstants.maxRetries {
do {
try await connectOnce()
try await discoverServices()
try await authenticate()
isConnected = true
return
} catch {
disconnect()
if attempt < GATTConstants.maxRetries {
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
} else {
throw error
}
}
}
}
func writeConfig(_ config: BeaconConfig) async throws {
guard isConnected, let writeChar else {
throw ProvisionError.notConnected
}
// Build parameter payload
var params = Data()
// UUID (16 bytes)
params.append(ParamID.uuid.rawValue)
let uuidBytes = config.uuid.hexToBytes
params.append(contentsOf: uuidBytes)
// Major (2 bytes BE)
params.append(ParamID.major.rawValue)
params.append(UInt8(config.major >> 8))
params.append(UInt8(config.major & 0xFF))
// Minor (2 bytes BE)
params.append(ParamID.minor.rawValue)
params.append(UInt8(config.minor >> 8))
params.append(UInt8(config.minor & 0xFF))
// TX Power
params.append(ParamID.txPower.rawValue)
params.append(config.txPower)
// Adv Interval
params.append(ParamID.advInterval.rawValue)
params.append(UInt8(config.advInterval >> 8))
params.append(UInt8(config.advInterval & 0xFF))
// Send CMD_WRITE_PARAMS
let writeCmd = Data([CMD.writeParams.rawValue]) + params
let writeResp = try await sendCommand(writeCmd)
guard writeResp.first == CMD.writeParams.rawValue else {
throw ProvisionError.writeFailed("Unexpected write response")
}
// Send CMD_SAVE to flash
let saveResp = try await sendCommand(Data([CMD.save.rawValue]))
guard saveResp.first == CMD.save.rawValue else {
throw ProvisionError.saveFailed
}
}
func disconnect() {
if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral)
}
isConnected = false
}
// MARK: - Private: Connection
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
connectionContinuation = cont
centralManager.connect(peripheral, options: nil)
// Timeout
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
if let c = self?.connectionContinuation {
self?.connectionContinuation = nil
c.resume(throwing: ProvisionError.connectionTimeout)
}
}
}
}
private func discoverServices() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
serviceContinuation = cont
peripheral.discoverServices([GATTConstants.ffe0Service])
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.serviceContinuation {
self?.serviceContinuation = nil
c.resume(throwing: ProvisionError.serviceDiscoveryTimeout)
}
}
}
}
private func authenticate() async throws {
for password in Self.passwords {
let cmd = Data([CMD.auth.rawValue]) + password
do {
let resp = try await sendCommand(cmd)
if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 {
return // Auth success
}
} catch {
continue
}
}
throw ProvisionError.authFailed
}
private func sendCommand(_ data: Data) async throws -> Data {
guard let writeChar else { throw ProvisionError.notConnected }
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
writeContinuation = cont
peripheral.writeValue(data, for: writeChar, type: .withResponse)
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.writeContinuation {
self?.writeContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
}
// MARK: - CBPeripheralDelegate
extension KBeaconProvisioner: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
serviceContinuation?.resume(throwing: error)
serviceContinuation = nil
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else {
serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound)
serviceContinuation = nil
return
}
peripheral.discoverCharacteristics([GATTConstants.ffe1Char, GATTConstants.ffe2Char], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error {
serviceContinuation?.resume(throwing: error)
serviceContinuation = nil
return
}
for char in service.characteristics ?? [] {
switch char.uuid {
case GATTConstants.ffe1Char:
writeChar = char
case GATTConstants.ffe2Char:
notifyChar = char
peripheral.setNotifyValue(true, for: char)
default:
break
}
}
if writeChar != nil {
serviceContinuation?.resume()
serviceContinuation = nil
} else {
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
serviceContinuation = nil
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard characteristic.uuid == GATTConstants.ffe2Char,
let data = characteristic.value else { return }
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(returning: data)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
// Write acknowledgment actual response comes via notify on FFE2
if let error {
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(throwing: error)
}
}
}
}

View file

@ -14,25 +14,27 @@ protocol BeaconProvisioner {
/// Whether we're currently connected
var isConnected: Bool { get }
/// Optional diagnostic log for tracing provisioning steps
var diagnosticLog: ProvisionLog? { get set }
/// BLE manager reference for connection callbacks
var bleManager: BLEManager? { get set }
}
/// GATT UUIDs shared across provisioner types
/// GATT UUIDs for CP-28 (DX-Smart) beacons
/// FFE0 service with FFE1 (notify), FFE2 (write), FFE3 (password)
enum GATTConstants {
// FFE0 service (KBeacon, DXSmart)
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
static let ffe1Char = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB")
static let ffe2Char = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB")
static let ffe3Char = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB")
// FFF0 service (BlueCharm)
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB")
// CCCD for enabling notifications
static let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB")
// Timeouts (matching Android)
static let connectionTimeout: TimeInterval = 5.0
// Timeouts
static let connectionTimeout: TimeInterval = 10.0
static let operationTimeout: TimeInterval = 5.0
static let maxRetries = 3
static let retryDelay: TimeInterval = 1.0

View file

@ -36,109 +36,182 @@ actor APIClient {
// MARK: - Auth
struct OTPResponse: Codable {
let uuid: String?
/// Raw response from /auth/loginOTP.php
/// API returns: { "OK": true, "UUID": "...", "MESSAGE": "..." }
private struct OTPRawResponse: Codable {
let OK: Bool
let UUID: String?
var otpUUID: String { uuid ?? UUID ?? "" }
let MESSAGE: String?
let ERROR: String?
}
func sendOTP(phone: String) async throws -> String {
let body: [String: Any] = ["ContactNumber": phone]
let body: [String: Any] = ["Phone": phone]
let data = try await post(path: "/auth/loginOTP.php", body: body)
let resp = try JSONDecoder().decode(APIResponse<OTPResponse>.self, from: data)
guard resp.success, let payload = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to send OTP")
let resp = try JSONDecoder().decode(OTPRawResponse.self, from: data)
guard resp.OK, let uuid = resp.UUID, !uuid.isEmpty else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to send OTP")
}
return payload.otpUUID
return uuid
}
struct LoginResponse: Codable {
let token: String?
/// Raw response from /auth/verifyLoginOTP.php
/// API returns: { "OK": true, "UserID": 123, "Token": "...", "FirstName": "..." }
private struct VerifyOTPRawResponse: Codable {
let OK: Bool
let Token: String?
let userId: String?
let UserID: String?
var authToken: String { token ?? Token ?? "" }
var authUserId: String { userId ?? UserID ?? "" }
let UserID: IntOrString?
let MESSAGE: String?
let ERROR: String?
}
/// Handles UserID coming as either int or string from API
enum IntOrString: Codable {
case int(Int)
case string(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let i = try? container.decode(Int.self) {
self = .int(i)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
self = .string("")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .int(let i): try container.encode(i)
case .string(let s): try container.encode(s)
}
}
var stringValue: String {
switch self {
case .int(let i): return String(i)
case .string(let s): return s
}
}
}
func verifyOTP(uuid: String, code: String) async throws -> (token: String, userId: String) {
let body: [String: Any] = ["UUID": uuid, "Code": code]
let body: [String: Any] = ["UUID": uuid, "OTP": code]
let data = try await post(path: "/auth/verifyLoginOTP.php", body: body)
let resp = try JSONDecoder().decode(APIResponse<LoginResponse>.self, from: data)
guard resp.success, let payload = resp.data else {
throw APIError.serverError(resp.message ?? "Invalid OTP")
let resp = try JSONDecoder().decode(VerifyOTPRawResponse.self, from: data)
guard resp.OK, let token = resp.Token, !token.isEmpty else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Invalid OTP")
}
return (payload.authToken, payload.authUserId)
let userId = resp.UserID?.stringValue ?? ""
return (token, userId)
}
// MARK: - Businesses
/// API returns: { "OK": true, "BUSINESSES": [...], "Businesses": [...] }
private struct BusinessListResponse: Codable {
let OK: Bool
let ERROR: String?
let BUSINESSES: [Business]?
let Businesses: [Business]?
var businesses: [Business] { BUSINESSES ?? Businesses ?? [] }
}
func listBusinesses(token: String) async throws -> [Business] {
let data = try await post(path: "/businesses/list.php", body: [:], token: token)
let resp = try JSONDecoder().decode(APIResponse<[Business]>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to load businesses")
let resp = try JSONDecoder().decode(BusinessListResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.ERROR ?? "Failed to load businesses")
}
return resp.data ?? []
return resp.businesses
}
// MARK: - Service Points
/// API returns: { "OK": true, "SERVICEPOINTS": [...], "GRANTED_SERVICEPOINTS": [...] }
private struct ServicePointListResponse: Codable {
let OK: Bool
let ERROR: String?
let SERVICEPOINTS: [ServicePoint]?
}
func listServicePoints(businessId: String, token: String) async throws -> [ServicePoint] {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/servicepoints/list.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<[ServicePoint]>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to load service points")
let resp = try JSONDecoder().decode(ServicePointListResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.ERROR ?? "Failed to load service points")
}
return resp.data ?? []
return resp.SERVICEPOINTS ?? []
}
/// API returns: { "OK": true, "SERVICEPOINT": { ... } }
private struct ServicePointSaveResponse: Codable {
let OK: Bool
let ERROR: String?
let SERVICEPOINT: ServicePoint?
}
func createServicePoint(name: String, businessId: String, token: String) async throws -> ServicePoint {
let body: [String: Any] = ["Name": name, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<ServicePoint>.self, from: data)
guard resp.success, let sp = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to create service point")
let resp = try JSONDecoder().decode(ServicePointSaveResponse.self, from: data)
guard resp.OK, let sp = resp.SERVICEPOINT else {
throw APIError.serverError(resp.ERROR ?? "Failed to create service point")
}
return sp
}
// MARK: - Beacon Sharding
struct NamespaceResponse: Codable {
let uuid: String?
let UUID: String?
let major: Int?
let Major: Int?
var shardUUID: String { uuid ?? UUID ?? "" }
var shardMajor: Int { major ?? Major ?? 0 }
/// API returns: { "OK": true, "BeaconShardUUID": "...", "BeaconMajor": 5 }
private struct AllocateNamespaceResponse: Codable {
let OK: Bool
let ERROR: String?
let MESSAGE: String?
let BeaconShardUUID: String?
let BeaconMajor: Int?
}
func allocateBusinessNamespace(businessId: String, token: String) async throws -> (uuid: String, major: Int) {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/beacon-sharding/allocate_business_namespace.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<NamespaceResponse>.self, from: data)
guard resp.success, let ns = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to allocate namespace")
let resp = try JSONDecoder().decode(AllocateNamespaceResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate namespace")
}
return (ns.shardUUID, ns.shardMajor)
return (resp.BeaconShardUUID ?? "", resp.BeaconMajor ?? 0)
}
struct MinorResponse: Codable {
let minor: Int?
let Minor: Int?
var allocated: Int { minor ?? Minor ?? 0 }
/// API returns: { "OK": true, "BeaconMinor": 3 }
private struct AllocateMinorResponse: Codable {
let OK: Bool
let ERROR: String?
let MESSAGE: String?
let BeaconMinor: Int?
}
func allocateMinor(businessId: String, servicePointId: String, token: String) async throws -> Int {
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
let data = try await post(path: "/beacon-sharding/allocate_servicepoint_minor.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<MinorResponse>.self, from: data)
guard resp.success, let m = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to allocate minor")
let resp = try JSONDecoder().decode(AllocateMinorResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor")
}
return m.allocated
guard let minor = resp.BeaconMinor, minor >= 0 else {
throw APIError.serverError("API returned invalid minor value: \(resp.BeaconMinor.map(String.init) ?? "nil"). Service point may not be configured correctly.")
}
return minor
}
/// API returns: { "OK": true, "BeaconHardwareID": 42, ... }
private struct OKResponse: Codable {
let OK: Bool
let ERROR: String?
let MESSAGE: String?
}
func registerBeaconHardware(
@ -147,7 +220,7 @@ actor APIClient {
uuid: String,
major: Int,
minor: Int,
macAddress: String?,
hardwareId: String,
beaconType: String,
token: String
) async throws {
@ -157,41 +230,43 @@ actor APIClient {
"UUID": uuid,
"Major": major,
"Minor": minor,
"HardwareId": hardwareId,
"BeaconType": beaconType
]
if let mac = macAddress { body["MacAddress"] = mac }
let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to register beacon")
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to register beacon")
}
}
func verifyBeaconBroadcast(
hardwareId: String,
uuid: String,
major: Int,
minor: Int,
token: String
) async throws {
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
let body: [String: Any] = ["HardwareId": hardwareId, "UUID": uuid, "Major": major, "Minor": minor]
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to verify broadcast")
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to verify broadcast")
}
}
struct ResolveResponse: Codable {
let businessName: String?
/// API returns: { "OK": true, "BusinessName": "...", "BusinessID": 5 }
private struct ResolveBusinessResponse: Codable {
let OK: Bool
let ERROR: String?
let BusinessName: String?
var name: String { businessName ?? BusinessName ?? "Unknown" }
}
func resolveBusiness(uuid: String, major: Int, token: String) async throws -> String {
let body: [String: Any] = ["UUID": uuid, "Major": major]
let data = try await post(path: "/beacon-sharding/resolve_business.php", body: body, token: token)
let resp = try JSONDecoder().decode(APIResponse<ResolveResponse>.self, from: data)
return resp.data?.name ?? "Unknown"
let resp = try JSONDecoder().decode(ResolveBusinessResponse.self, from: data)
return resp.BusinessName ?? "Unknown"
}
// MARK: - Service Point Management
@ -199,24 +274,24 @@ actor APIClient {
func deleteServicePoint(servicePointId: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": servicePointId, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/delete.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to delete service point")
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to delete service point")
}
}
func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to update service point")
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to update service point")
}
}
// MARK: - Beacon Management
struct BeaconListResponse: Codable {
struct BeaconListItem: Codable {
let id: String?
let ID: String?
let uuid: String?
@ -235,125 +310,135 @@ actor APIClient {
let IsVerified: Bool?
}
func listBeacons(businessId: String, token: String) async throws -> [BeaconListResponse] {
/// API returns: { "OK": true, "BEACONS": [...] }
private struct BeaconListAPIResponse: Codable {
let OK: Bool
let ERROR: String?
let BEACONS: [BeaconListItem]?
}
func listBeacons(businessId: String, token: String) async throws -> [BeaconListItem] {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/beacon-sharding/list_beacons.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<[BeaconListResponse]>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to list beacons")
let data = try await post(path: "/beacons/list.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(BeaconListAPIResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.ERROR ?? "Failed to list beacons")
}
return resp.data ?? []
return resp.BEACONS ?? []
}
func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId]
let data = try await post(path: "/beacon-sharding/decommission_beacon.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to decommission beacon")
let data = try await post(path: "/beacons/wipe.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to decommission beacon")
}
}
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListResponse? {
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListItem? {
let body: [String: Any] = ["MacAddress": macAddress]
let data = try await post(path: "/beacon-sharding/lookup_by_mac.php", body: body, token: token)
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
return resp.data
let data = try await post(path: "/beacons/lookupByMac.php", body: body, token: token)
// This may return a single beacon object or OK: false
struct LookupResponse: Codable {
let OK: Bool
let ID: String?
let UUID: String?
let Major: Int?
let Minor: Int?
let MacAddress: String?
let BeaconType: String?
let ServicePointID: String?
let IsVerified: Bool?
}
let resp = try JSONDecoder().decode(LookupResponse.self, from: data)
guard resp.OK, resp.ID != nil else { return nil }
return BeaconListItem(
id: resp.ID, ID: resp.ID,
uuid: resp.UUID, UUID: resp.UUID,
major: resp.Major, Major: resp.Major,
minor: resp.Minor, Minor: resp.Minor,
macAddress: resp.MacAddress, MacAddress: resp.MacAddress,
beaconType: resp.BeaconType, BeaconType: resp.BeaconType,
servicePointId: resp.ServicePointID, ServicePointID: resp.ServicePointID,
isVerified: resp.IsVerified, IsVerified: resp.IsVerified
)
}
func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListResponse? {
func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListItem? {
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
let data = try await post(path: "/beacon-sharding/beacon_status.php", body: body, token: token)
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
return resp.data
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else { return nil }
// The verify endpoint confirms status but doesn't return full beacon details
return BeaconListItem(
id: nil, ID: nil,
uuid: uuid, UUID: uuid,
major: major, Major: major,
minor: minor, Minor: minor,
macAddress: nil, MacAddress: nil,
beaconType: nil, BeaconType: nil,
servicePointId: nil, ServicePointID: nil,
isVerified: true, IsVerified: true
)
}
// MARK: - Beacon Config (server-configured values)
/// API returns: { "OK": true, "UUID": "...", "Major": 5, "Minor": 3, ... }
struct BeaconConfigResponse: Codable {
let uuid: String?
let OK: Bool
let ERROR: String?
let MESSAGE: String?
let UUID: String?
let major: Int?
let Major: Int?
let minor: Int?
let Minor: Int?
let txPower: Int?
let TxPower: Int?
let measuredPower: Int?
let MeasuredPower: Int?
let advInterval: Int?
let AdvInterval: Int?
var configUUID: String { uuid ?? UUID ?? "" }
var configMajor: Int { major ?? Major ?? 0 }
var configMinor: Int { minor ?? Minor ?? 0 }
var configTxPower: Int { txPower ?? TxPower ?? 1 }
var configMeasuredPower: Int { measuredPower ?? MeasuredPower ?? -100 }
var configAdvInterval: Int { advInterval ?? AdvInterval ?? 2 }
var configUUID: String { UUID ?? "" }
var configMajor: Int { Major ?? 0 }
var configMinor: Int { Minor ?? 0 }
var configTxPower: Int { TxPower ?? 1 }
var configMeasuredPower: Int { MeasuredPower ?? -100 }
var configAdvInterval: Int { AdvInterval ?? 2 }
}
func getBeaconConfig(businessId: String, servicePointId: String, token: String) async throws -> BeaconConfigResponse {
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
let data = try await post(path: "/beacon-sharding/get_beacon_config.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<BeaconConfigResponse>.self, from: data)
guard resp.success, let config = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to get beacon config")
let resp = try JSONDecoder().decode(BeaconConfigResponse.self, from: data)
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to get beacon config")
}
return config
return resp
}
// MARK: - User Profile
/// Note: /users/profile.php endpoint may not exist on server.
/// Using a flat response decoder matching the standard API format.
struct UserProfile: Codable {
let id: String?
let ID: String?
let firstName: String?
let OK: Bool?
let ID: IntOrString?
let FirstName: String?
let lastName: String?
let LastName: String?
let contactNumber: String?
let ContactNumber: String?
var userId: String { ID?.stringValue ?? "" }
var firstName: String { FirstName ?? "" }
var lastName: String { LastName ?? "" }
}
func getProfile(token: String) async throws -> UserProfile {
let data = try await post(path: "/users/profile.php", body: [:], token: token)
let resp = try JSONDecoder().decode(APIResponse<UserProfile>.self, from: data)
guard resp.success, let profile = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to load profile")
}
return profile
let resp = try JSONDecoder().decode(UserProfile.self, from: data)
return resp
}
// MARK: - Internal
private struct EmptyData: Codable {}
private struct APIResponse<T: Codable>: Codable {
let success: Bool
let message: String?
let data: T?
enum CodingKeys: String, CodingKey {
case success = "Success"
case message = "Message"
case data = "Data"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Handle both bool and int/string for Success
if let b = try? container.decode(Bool.self, forKey: .success) {
success = b
} else if let i = try? container.decode(Int.self, forKey: .success) {
success = i != 0
} else {
success = false
}
message = try? container.decode(String.self, forKey: .message)
data = try? container.decode(T.self, forKey: .data)
}
}
private func post(
path: String,
body: [String: Any],

View file

@ -2,8 +2,7 @@ import Foundation
import CoreBluetooth
import Combine
/// Central BLE manager handles scanning and beacon type detection
/// Matches Android's BeaconScanner.kt behavior
/// Central BLE manager handles scanning and CP-28 beacon detection
@MainActor
final class BLEManager: NSObject, ObservableObject {
@ -13,15 +12,23 @@ final class BLEManager: NSObject, ObservableObject {
@Published var discoveredBeacons: [DiscoveredBeacon] = []
@Published var bluetoothState: CBManagerState = .unknown
// MARK: - Constants (matching Android)
// MARK: - Constants
static let scanDuration: TimeInterval = 5.0
static let verifyScanDuration: TimeInterval = 15.0
static let verifyPollInterval: TimeInterval = 0.5
// GATT Service UUIDs
// CP-28 uses FFE0 service
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
// DX-Smart factory default iBeacon UUID
static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
// MARK: - Connection Callbacks (used by provisioners)
var onPeripheralConnected: ((CBPeripheral) -> Void)?
var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)?
var onPeripheralDisconnected: ((CBPeripheral, Error?) -> Void)?
// MARK: - Private
@ -53,7 +60,7 @@ final class BLEManager: NSObject, ObservableObject {
])
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
Task { @MainActor in
DispatchQueue.main.async {
self?.stopScan()
}
}
@ -76,11 +83,8 @@ final class BLEManager: NSObject, ObservableObject {
}
/// Verify a beacon is broadcasting expected iBeacon values.
/// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor.
func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
// TODO: Implement iBeacon region monitoring via CLLocationManager
// CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts
// For now, return a placeholder that prompts manual verification
return VerifyResult(
found: false,
rssi: nil,
@ -88,70 +92,94 @@ final class BLEManager: NSObject, ObservableObject {
)
}
// 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.
// MARK: - iBeacon Manufacturer Data Parsing
/// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00)
private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? {
guard mfgData.count >= 25 else { return nil }
guard mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil }
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))"
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: - CP-28 Detection
// Only detect DX-Smart / CP-28 beacons. Everything else is ignored.
func detectBeaconType(
name: String?,
serviceUUIDs: [CBUUID]?,
manufacturerData: Data?
) -> BeaconType {
) -> BeaconType? {
let deviceName = (name ?? "").lowercased()
// 1. Service UUID matching
// Parse iBeacon data if available
let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)?
if let mfgData = manufacturerData {
iBeaconData = parseIBeaconData(mfgData)
} else {
iBeaconData = nil
}
// 1. Service UUID: CP-28 uses FFE0
if let services = serviceUUIDs {
let serviceStrings = services.map { $0.uuidString.uppercased() }
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
return .bluecharm
}
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
// Could be KBeacon or DXSmart check name to differentiate
// FFE0 with DX name patterns definitely CP-28
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") {
return .dxsmart
}
return .kbeacon
// FFE0 without a specific name still likely CP-28
return .dxsmart
}
// CP-28 also advertises FFF0 on some firmware
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
// Any FFF0 device is likely CP-28 don't filter by name
return .dxsmart
}
}
// 2. Device name patterns
if deviceName.contains("kbeacon") || deviceName.contains("kbpro") ||
deviceName.hasPrefix("kb") {
return .kbeacon
}
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
deviceName.hasPrefix("table-") {
return .bluecharm
// 2. DX-Smart factory default iBeacon UUID
if let ibeacon = iBeaconData {
if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame {
return .dxsmart
}
// Already provisioned with a Payfrit shard UUID
if BeaconShardPool.isPayfrit(ibeacon.uuid) {
return .dxsmart
}
}
// 3. Device name patterns for CP-28 (includes "payfrit" our own provisioned name)
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") ||
deviceName.contains("payfrit") {
return .dxsmart
}
// 3. Generic beacon patterns
if deviceName.contains("ibeacon") || deviceName.contains("beacon") ||
deviceName.hasPrefix("ble") {
return .dxsmart // Default to DXSmart like Android
// 4. iBeacon minor in high range (factory default DX pattern)
if let ibeacon = iBeaconData, ibeacon.minor > 10000 {
return .dxsmart
}
// 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
}
// 5. Any iBeacon advertisement likely a CP-28 in the field
if iBeaconData != nil {
return .dxsmart
}
return .unknown
// Not a CP-28 don't show it
return nil
}
}
@ -160,8 +188,27 @@ final class BLEManager: NSObject, ObservableObject {
extension BLEManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { @MainActor in
bluetoothState = central.state
let state = central.state
DispatchQueue.main.async { [weak self] in
self?.bluetoothState = state
}
}
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralConnected?(peripheral)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralFailedToConnect?(peripheral, error)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralDisconnected?(peripheral, error)
}
}
@ -171,32 +218,34 @@ extension BLEManager: CBCentralManagerDelegate {
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
Task { @MainActor in
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let peripheralId = peripheral.identifier
let rssiValue = RSSI.intValue
let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Only show recognized beacons
guard type != .unknown else { return }
// Detect beacon type default to .dxsmart so ALL devices show up in scan
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) ?? .dxsmart
if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
// Update existing
discoveredBeacons[idx].rssi = RSSI.intValue
discoveredBeacons[idx].lastSeen = Date()
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
self.discoveredBeacons[idx].rssi = rssiValue
self.discoveredBeacons[idx].lastSeen = Date()
} else {
// New beacon
let beacon = DiscoveredBeacon(
id: peripheral.identifier,
id: peripheralId,
peripheral: peripheral,
name: name,
type: type,
rssi: RSSI.intValue,
rssi: rssiValue,
lastSeen: Date()
)
discoveredBeacons.append(beacon)
self.discoveredBeacons.append(beacon)
}
self.discoveredBeacons.sort { $0.rssi > $1.rssi }
}
}
}

View file

@ -0,0 +1,56 @@
import Foundation
/// Timestamped diagnostic log for beacon provisioning.
/// Captures every step so we can diagnose failures.
@MainActor
final class ProvisionLog: ObservableObject {
struct Entry: Identifiable {
let id = UUID()
let timestamp: Date
let phase: String // "connect", "discover", "auth", "write", "verify"
let message: String
let isError: Bool
var formatted: String {
let t = Self.formatter.string(from: timestamp)
let prefix = isError ? "" : ""
return "\(t) [\(phase)] \(prefix) \(message)"
}
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
}
@Published private(set) var entries: [Entry] = []
private var startTime: Date?
/// Clear log for a new provisioning attempt
func reset() {
entries = []
startTime = Date()
}
/// Add a log entry
func log(_ phase: String, _ message: String, isError: Bool = false) {
let entry = Entry(timestamp: Date(), phase: phase, message: message, isError: isError)
entries.append(entry)
}
/// Elapsed time since reset
var elapsed: String {
guard let start = startTime else { return "0.0s" }
let seconds = Date().timeIntervalSince(start)
return String(format: "%.1fs", seconds)
}
/// Full log as shareable text
var fullText: String {
let header = "Payfrit Beacon Diagnostic Log"
let time = "Session: \(elapsed)"
let lines = entries.map { $0.formatted }
return ([header, time, "---"] + lines).joined(separator: "\n")
}
}

View file

@ -0,0 +1,31 @@
import SwiftUI
/// Payfrit brand colors matches Android colors.xml exactly
/// Primary: #22B24B (Payfrit Green)
/// Dark: #1A8A3A
extension Color {
// MARK: - Brand
static let payfritGreen = Color(red: 0x22/255.0, green: 0xB2/255.0, blue: 0x4B/255.0)
static let payfritGreenDark = Color(red: 0x1A/255.0, green: 0x8A/255.0, blue: 0x3A/255.0)
// MARK: - Status (matching Android)
static let successGreen = Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
static let errorRed = Color(red: 0xBA/255.0, green: 0x1A/255.0, blue: 0x1A/255.0)
static let warningOrange = Color(red: 0xFF/255.0, green: 0x98/255.0, blue: 0x00/255.0)
static let infoBlue = Color(red: 0x21/255.0, green: 0x96/255.0, blue: 0xF3/255.0)
// MARK: - Signal Strength (matching Android)
static let signalStrong = Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
static let signalMedium = Color(red: 0xF9/255.0, green: 0xA8/255.0, blue: 0x25/255.0)
static let signalWeak = Color(red: 0xBA/255.0, green: 0x1A/255.0, blue: 0x1A/255.0)
// MARK: - Surfaces (matching Android)
static let surfaceBg = Color(red: 0xFC/255.0, green: 0xFD/255.0, blue: 0xF7/255.0)
static let surfaceCard = Color(red: 0xF0/255.0, green: 0xF1/255.0, blue: 0xEB/255.0)
// MARK: - Text (matching Android)
static let textPrimary = Color(red: 0x1A/255.0, green: 0x1C/255.0, blue: 0x19/255.0)
static let textSecondary = Color(red: 0x42/255.0, green: 0x49/255.0, blue: 0x40/255.0)
static let textHint = Color(red: 0x72/255.0, green: 0x79/255.0, blue: 0x70/255.0)
}

View file

@ -14,19 +14,51 @@ struct BusinessListView: View {
if isLoading {
ProgressView("Loading businesses…")
} else if let error = errorMessage {
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button("Retry") { Task { await loadBusinesses() } }
if #available(iOS 17.0, *) {
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button("Retry") { Task { await loadBusinesses() } }
}
} else {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(Color.warningOrange)
Text("Error")
.font(.headline)
Text(error)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Retry") { Task { await loadBusinesses() } }
.buttonStyle(.borderedProminent)
}
.padding()
}
} else if businesses.isEmpty {
ContentUnavailableView(
"No Businesses",
systemImage: "building.2",
description: Text("You don't have any businesses yet.")
)
if #available(iOS 17.0, *) {
ContentUnavailableView(
"No Businesses",
systemImage: "building.2",
description: Text("You don't have any businesses yet.")
)
} else {
VStack(spacing: 12) {
Image(systemName: "building.2")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No Businesses")
.font(.headline)
Text("You don't have any businesses yet.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxHeight: .infinity)
.padding()
}
} else {
List(businesses) { business in
Button {
@ -39,13 +71,13 @@ struct BusinessListView: View {
}
}
.navigationTitle("Select Business")
.toolbar {
.toolbar(content: {
ToolbarItem(placement: .topBarTrailing) {
Button("Logout") {
appState.logout()
}
}
}
})
}
.task {
await loadBusinesses()
@ -62,18 +94,22 @@ struct BusinessListView: View {
let list = try await APIClient.shared.listBusinesses(token: token)
businesses = list
// Auto-navigate if only one business (like Android)
if list.count == 1, let only = list.first {
appState.selectBusiness(only)
return
}
// Skip auto-navigation if user explicitly tapped Back
if !appState.skipAutoNav {
// Auto-navigate if only one business (like Android)
if list.count == 1, let only = list.first {
appState.selectBusiness(only)
return
}
// Auto-navigate to last used business
if let lastId = AppPrefs.lastBusinessId,
let last = list.first(where: { $0.id == lastId }) {
appState.selectBusiness(last)
return
// Auto-navigate to last used business
if let lastId = AppPrefs.lastBusinessId,
let last = list.first(where: { $0.id == lastId }) {
appState.selectBusiness(last)
return
}
}
appState.skipAutoNav = false
} catch let e as APIError where e.errorDescription == APIError.unauthorized.errorDescription {
appState.logout()
} catch {

View file

@ -8,7 +8,7 @@ struct DevBanner: View {
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.orange, in: Capsule())
.background(Color.warningOrange, in: Capsule())
.padding(.top, 4)
}
}

View file

@ -20,7 +20,7 @@ struct LoginView: View {
// Logo
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 64))
.foregroundStyle(.blue)
.foregroundStyle(Color.payfritGreen)
Text("Payfrit Beacon")
.font(.title.bold())
@ -49,7 +49,7 @@ struct LoginView: View {
if let error = errorMessage {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.foregroundStyle(Color.errorRed)
}
Button(action: handleAction) {

View file

@ -101,7 +101,7 @@ struct QRScannerView: View {
p.addLine(to: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: corner, y: 0))
}
.stroke(.blue, lineWidth: thickness)
.stroke(Color.payfritGreen, lineWidth: thickness)
// Top-right
Path { p in
@ -109,7 +109,7 @@ struct QRScannerView: View {
p.addLine(to: CGPoint(x: w, y: 0))
p.addLine(to: CGPoint(x: w, y: corner))
}
.stroke(.blue, lineWidth: thickness)
.stroke(Color.payfritGreen, lineWidth: thickness)
// Bottom-left
Path { p in
@ -117,7 +117,7 @@ struct QRScannerView: View {
p.addLine(to: CGPoint(x: 0, y: h))
p.addLine(to: CGPoint(x: corner, y: h))
}
.stroke(.blue, lineWidth: thickness)
.stroke(Color.payfritGreen, lineWidth: thickness)
// Bottom-right
Path { p in
@ -125,7 +125,7 @@ struct QRScannerView: View {
p.addLine(to: CGPoint(x: w, y: h))
p.addLine(to: CGPoint(x: w, y: h - corner))
}
.stroke(.blue, lineWidth: thickness)
.stroke(Color.payfritGreen, lineWidth: thickness)
}
}
@ -141,18 +141,40 @@ struct QRScannerView: View {
}
}
@ViewBuilder
private var cameraPermissionDenied: some View {
ContentUnavailableView {
Label("Camera Access Required", systemImage: "camera.fill")
} description: {
Text("Open Settings and enable camera access for Payfrit Beacon.")
} actions: {
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
if #available(iOS 17.0, *) {
ContentUnavailableView {
Label("Camera Access Required", systemImage: "camera.fill")
} description: {
Text("Open Settings and enable camera access for Payfrit Beacon.")
} actions: {
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
}
.buttonStyle(.borderedProminent)
} else {
VStack(spacing: 16) {
Image(systemName: "camera.fill")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Camera Access Required")
.font(.headline)
Text("Open Settings and enable camera access for Payfrit Beacon.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
@ -219,17 +241,31 @@ final class CameraPreviewUIView: UIView {
func setFlash(_ on: Bool) {
guard let device = AVCaptureDevice.default(for: .video),
device.hasTorch else { return }
try? device.lockForConfiguration()
device.torchMode = on ? .on : .off
device.unlockForConfiguration()
do {
try device.lockForConfiguration()
device.torchMode = on ? .on : .off
device.unlockForConfiguration()
} catch {
NSLog("[QRScanner] Failed to set torch: \(error.localizedDescription)")
}
}
private func setupCamera() {
let session = AVCaptureSession()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device) else { return }
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
NSLog("[QRScanner] ERROR: No back camera available")
return
}
let input: AVCaptureDeviceInput
do {
input = try AVCaptureDeviceInput(device: device)
} catch {
NSLog("[QRScanner] ERROR: Failed to create camera input: \(error.localizedDescription)")
return
}
if session.canAddInput(input) {
session.addInput(input)

View file

@ -23,10 +23,12 @@ struct ScanView: View {
// Provisioning flow
@State private var selectedBeacon: DiscoveredBeacon?
@State private var provisioningState: ProvisioningState = .idle
@State private var writesCompleted = false
@State private var statusMessage = ""
@State private var errorMessage: String?
@State private var showQRScanner = false
@State private var scannedMAC: String?
@StateObject private var provisionLog = ProvisionLog()
enum ProvisioningState {
case idle
@ -116,7 +118,7 @@ struct ScanView: View {
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
selectedServicePoint?.id == sp.id ? Color.blue : Color(.systemGray5),
selectedServicePoint?.id == sp.id ? Color.payfritGreen : Color(.systemGray5),
in: Capsule()
)
.foregroundStyle(selectedServicePoint?.id == sp.id ? .white : .primary)
@ -129,27 +131,62 @@ struct ScanView: View {
.padding()
}
@ViewBuilder
private var selectServicePointPrompt: some View {
ContentUnavailableView(
"Select a Service Point",
systemImage: "mappin.and.ellipse",
description: Text("Choose or create a service point (table) to provision a beacon for.")
)
if #available(iOS 17.0, *) {
ContentUnavailableView(
"Select a Service Point",
systemImage: "mappin.and.ellipse",
description: Text("Choose or create a service point (table) to provision a beacon for.")
)
} else {
VStack(spacing: 12) {
Image(systemName: "mappin.and.ellipse")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Select a Service Point")
.font(.headline)
Text("Choose or create a service point (table) to provision a beacon for.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxHeight: .infinity)
.padding()
}
}
// MARK: - Namespace Loading
@ViewBuilder
private var namespaceLoadingView: some View {
VStack(spacing: 16) {
if isLoadingNamespace {
ProgressView("Allocating beacon namespace…")
} else {
ContentUnavailableView {
Label("Namespace Error", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage ?? "Failed to allocate beacon namespace")
} actions: {
Button("Retry") { Task { await loadNamespace() } }
if #available(iOS 17.0, *) {
ContentUnavailableView {
Label("Namespace Error", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage ?? "Failed to allocate beacon namespace")
} actions: {
Button("Retry") { Task { await loadNamespace() } }
}
} else {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(Color.warningOrange)
Text("Namespace Error")
.font(.headline)
Text(errorMessage ?? "Failed to allocate beacon namespace")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Retry") { Task { await loadNamespace() } }
.buttonStyle(.borderedProminent)
}
.padding()
}
}
}
@ -172,8 +209,8 @@ struct ScanView: View {
progressView(title: "Connecting…", message: statusMessage)
case .connected:
// DXSmart: beacon is flashing, show write button
dxsmartConnectedView
// Legacy auto-write skips this state now
progressView(title: "Connected…", message: statusMessage)
case .writing:
progressView(title: "Writing Config…", message: statusMessage)
@ -205,7 +242,7 @@ struct ScanView: View {
if bleManager.bluetoothState != .poweredOn {
Label("Bluetooth Off", systemImage: "bluetooth.slash")
.font(.caption)
.foregroundStyle(.red)
.foregroundStyle(Color.errorRed)
}
}
.padding(.horizontal)
@ -245,13 +282,28 @@ struct ScanView: View {
.padding()
if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning {
ContentUnavailableView(
"No Beacons Found",
systemImage: "antenna.radiowaves.left.and.right.slash",
description: Text("Tap Scan to search for nearby beacons")
)
if #available(iOS 17.0, *) {
ContentUnavailableView(
"No Beacons Found",
systemImage: "antenna.radiowaves.left.and.right.slash",
description: Text("Tap Scan to search for nearby beacons")
)
} else {
VStack(spacing: 12) {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No Beacons Found")
.font(.headline)
Text("Tap Scan to search for nearby beacons")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxHeight: .infinity)
.padding()
}
} else {
List(bleManager.discoveredBeacons) { beacon in
List(bleManager.discoveredBeacons.sorted { $0.rssi > $1.rssi }) { beacon in
Button {
selectedBeacon = beacon
Task { await startProvisioning(beacon) }
@ -267,46 +319,7 @@ struct ScanView: View {
// MARK: - DXSmart Connected View
private var dxsmartConnectedView: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "light.beacon.max")
.font(.system(size: 64))
.foregroundStyle(.orange)
.symbolEffect(.pulse)
Text("Beacon is Flashing")
.font(.title2.bold())
Text("Confirm the beacon LED is flashing, then tap Write Config to program it.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button {
Task { await writeConfigToConnectedBeacon() }
} label: {
HStack {
Image(systemName: "arrow.down.doc")
Text("Write Config")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.large)
.padding(.horizontal, 32)
Button("Cancel") {
cancelProvisioning()
}
.foregroundStyle(.secondary)
Spacer()
}
}
// dxsmartConnectedView removed auto-write skips the manual confirmation step
// MARK: - Progress / Success / Failed Views
@ -320,7 +333,58 @@ struct ScanView: View {
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
// Show live diagnostic log during connecting/writing
if !provisionLog.entries.isEmpty {
diagnosticLogView
}
Spacer()
Button("Cancel") {
cancelProvisioning()
}
.foregroundStyle(.secondary)
.padding(.bottom, 16)
}
}
/// Reusable diagnostic log view
private var diagnosticLogView: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Log (\(provisionLog.elapsed))")
.font(.caption.bold())
Spacer()
ShareLink(item: provisionLog.fullText) {
Label("Share", systemImage: "square.and.arrow.up")
.font(.caption)
}
}
.padding(.horizontal, 16)
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(provisionLog.entries) { entry in
Text(entry.formatted)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(entry.isError ? Color.errorRed : .primary)
.id(entry.id)
}
}
.padding(.horizontal, 16)
}
.onChange(of: provisionLog.entries.count) { _ in
if let last = provisionLog.entries.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.frame(maxHeight: 160)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 16)
}
}
@ -329,7 +393,7 @@ struct ScanView: View {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
.foregroundStyle(Color.successGreen)
Text("Beacon Provisioned!")
.font(.title2.bold())
Text(statusMessage)
@ -346,11 +410,10 @@ struct ScanView: View {
}
private var failedView: some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 16) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.red)
.font(.system(size: 48))
.foregroundStyle(Color.errorRed)
Text("Provisioning Failed")
.font(.title2.bold())
Text(errorMessage ?? "Unknown error")
@ -359,6 +422,11 @@ struct ScanView: View {
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
// Diagnostic log
if !provisionLog.entries.isEmpty {
diagnosticLogView
}
HStack(spacing: 16) {
Button("Try Again") {
if let beacon = selectedBeacon {
@ -377,8 +445,8 @@ struct ScanView: View {
resetProvisioningState()
}
.foregroundStyle(.secondary)
Spacer()
}
.padding(.vertical, 8)
}
// MARK: - Create Service Point Sheet
@ -475,14 +543,19 @@ struct ScanView: View {
let token = appState.token else { return }
provisioningState = .connecting
statusMessage = "Connecting to \(beacon.displayName)"
statusMessage = "Allocating beacon config"
errorMessage = nil
provisionLog.reset()
provisionLog.log("init", "Starting provisioning: \(beacon.displayName) (\(beacon.type.rawValue)) RSSI:\(beacon.rssi)")
provisionLog.log("init", "Service point: \(sp.name), Business: \(business.name)")
do {
// Allocate minor for this service point
provisionLog.log("api", "Allocating minor for service point \(sp.id)")
let minor = try await APIClient.shared.allocateMinor(
businessId: business.id, servicePointId: sp.id, token: token
)
provisionLog.log("api", "Minor allocated: \(minor)")
let config = BeaconConfig(
uuid: ns.uuid.normalizedUUID,
@ -497,78 +570,58 @@ struct ScanView: View {
// Create appropriate provisioner
let provisioner = makeProvisioner(for: beacon)
pendingProvisioner = provisioner
pendingConfig = config
// Wire up real-time status updates from provisioner
if let dxProvisioner = provisioner as? DXSmartProvisioner {
dxProvisioner.onStatusUpdate = { [weak self] status in
self?.statusMessage = status
}
}
statusMessage = "Connecting to \(beacon.displayName)"
provisionLog.log("connect", "Connecting to \(beacon.type.rawValue) provisioner…")
// Monitor for unexpected disconnects during provisioning
bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in
if peripheral.identifier == beacon.peripheral.identifier {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let reason = error?.localizedDescription ?? "beacon timed out"
// Writes already finished beacon rebooted after SaveConfig, this is expected
if self.writesCompleted {
provisionLog?.log("disconnect", "Post-write disconnect (expected — beacon rebooted after save)")
return
}
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
// For all active states, treat disconnect as failure
if self.provisioningState == .connecting ||
self.provisioningState == .writing || self.provisioningState == .verifying {
self.provisioningState = .failed
self.errorMessage = "Beacon disconnected: \(reason)"
}
}
}
}
statusMessage = "Authenticating with \(beacon.type.rawValue)"
try await provisioner.connect()
provisionLog.log("connect", "Connected and authenticated successfully")
// DXSmart: stop at connected state, wait for user to confirm flashing
if beacon.type == .dxsmart {
provisioningState = .connected
// Store config and provisioner for later use
pendingConfig = config
pendingProvisioner = provisioner
return
}
// KBeacon / BlueCharm: write immediately
// Auto-fire write immediately no pause needed
provisioningState = .writing
statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)"
writesCompleted = false
statusMessage = "Writing config to DX-Smart…"
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
try await provisioner.writeConfig(config)
provisioner.disconnect()
writesCompleted = true
// Register with backend
try await APIClient.shared.registerBeaconHardware(
businessId: business.id,
servicePointId: sp.id,
uuid: ns.uuid,
major: ns.major,
minor: minor,
macAddress: nil,
beaconType: beacon.type.rawValue,
token: token
)
// Verify broadcast
provisioningState = .verifying
statusMessage = "Waiting for beacon to restart…"
try await Task.sleep(nanoseconds: UInt64(GATTConstants.postFlashDelay * 1_000_000_000))
statusMessage = "Scanning for broadcast…"
let verifyResult = await bleManager.verifyBroadcast(
uuid: ns.uuid, major: config.major, minor: config.minor
)
if verifyResult.found {
try await APIClient.shared.verifyBeaconBroadcast(
uuid: ns.uuid, major: ns.major, minor: minor, token: token
)
}
provisioningState = .done
statusMessage = "\(sp.name)\(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)"
} catch {
provisioningState = .failed
errorMessage = error.localizedDescription
}
}
// Store for DXSmart two-phase flow
@State private var pendingConfig: BeaconConfig?
@State private var pendingProvisioner: (any BeaconProvisioner)?
private func writeConfigToConnectedBeacon() async {
guard let config = pendingConfig,
let provisioner = pendingProvisioner,
let sp = selectedServicePoint,
let ns = namespace,
let token = appState.token else { return }
provisioningState = .writing
statusMessage = "Writing config to DX-Smart…"
do {
try await provisioner.writeConfig(config)
// Brief settle after SaveConfig before dropping the BLE link.
try? await Task.sleep(nanoseconds: 50_000_000)
provisioner.disconnect()
try await APIClient.shared.registerBeaconHardware(
@ -577,7 +630,7 @@ struct ScanView: View {
uuid: ns.uuid,
major: ns.major,
minor: Int(config.minor),
macAddress: nil,
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
beaconType: BeaconType.dxsmart.rawValue,
token: token
)
@ -586,14 +639,16 @@ struct ScanView: View {
statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)"
} catch {
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
provisioningState = .failed
errorMessage = error.localizedDescription
}
pendingConfig = nil
pendingProvisioner = nil
}
// Kept for cancel/reset and registerAnywayAfterFailure fallback
@State private var pendingConfig: BeaconConfig?
@State private var pendingProvisioner: (any BeaconProvisioner)?
private func registerAnywayAfterFailure() async {
guard let sp = selectedServicePoint,
let ns = namespace,
@ -610,7 +665,7 @@ struct ScanView: View {
uuid: ns.uuid,
major: ns.major,
minor: Int(config.minor),
macAddress: nil,
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
beaconType: selectedBeacon?.type.rawValue ?? "Unknown",
token: token
)
@ -683,17 +738,13 @@ struct ScanView: View {
}
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
switch beacon.type {
case .kbeacon:
return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .dxsmart:
return DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .bluecharm:
return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .unknown:
// Try all provisioners in sequence (matches Android fallback behavior)
return FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
}
var provisioner: any BeaconProvisioner = DXSmartProvisioner(
peripheral: beacon.peripheral,
centralManager: bleManager.centralManager
)
provisioner.bleManager = bleManager
provisioner.diagnosticLog = provisionLog
return provisioner
}
}
@ -751,19 +802,27 @@ struct BeaconRow: View {
private var signalColor: Color {
switch beacon.rssi {
case -50...0: return .green
case -65 ... -51: return .blue
case -80 ... -66: return .orange
default: return .red
case -50...0: return .signalStrong
case -65 ... -51: return .payfritGreen
case -80 ... -66: return .signalMedium
default: return .signalWeak
}
}
private var typeColor: Color {
switch beacon.type {
case .kbeacon: return .blue
case .dxsmart: return .orange
case .bluecharm: return .purple
case .unknown: return .gray
return .payfritGreen
}
}
// MARK: - iOS 16/17 Compatibility
/// Applies `.symbolEffect(.pulse)` on iOS 17+, no-op on iOS 16
private struct PulseEffectModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 17.0, *) {
content.symbolEffect(.pulse)
} else {
content
}
}
}