# Payfrit Beacon Sharding System ## Overview This system implements a scalable iBeacon addressing scheme that supports >65,536 businesses using UUID sharding. ### Addressing Scheme ``` iBeacon Frame: ┌─────────────────────────────────────┬────────────┬────────────┐ │ UUID (128-bit) │ Major (16) │ Minor (16) │ │ = Payfrit Shard UUID │ = Business │ = SvcPoint │ └─────────────────────────────────────┴────────────┴────────────┘ Capacity: 65,536 businesses/shard × 64 shards = ~4.2M businesses 65,536 service points per business ``` ## Database Schema ### New Tables ```sql -- BeaconShards: UUID pool with utilization tracking CREATE TABLE BeaconShards ( ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, UUID CHAR(36) NOT NULL UNIQUE, BusinessCount INT UNSIGNED DEFAULT 0, MaxBusinesses INT UNSIGNED DEFAULT 65535, IsActive TINYINT(1) DEFAULT 1 ); -- BeaconHardware: Physical device inventory CREATE TABLE BeaconHardware ( ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, HardwareId VARCHAR(64) NOT NULL UNIQUE, BusinessID INT UNSIGNED NULL, ServicePointID INT UNSIGNED NULL, ShardUUID CHAR(36) NULL, Major SMALLINT UNSIGNED NULL, Minor SMALLINT UNSIGNED NULL, Status ENUM('unassigned','assigned','verified','retired') ); ``` ### Modified Tables ```sql -- Businesses: Add beacon namespace ALTER TABLE Businesses ADD COLUMN BeaconShardID INT UNSIGNED NULL; ALTER TABLE Businesses ADD COLUMN BeaconMajor SMALLINT UNSIGNED NULL; -- ServicePoints: Add beacon minor ALTER TABLE ServicePoints ADD COLUMN BeaconMinor SMALLINT UNSIGNED NULL; ``` ## API Endpoints ### Allocation APIs | Endpoint | Method | Description | |----------|--------|-------------| | `/api/beacon-sharding/allocate_business_namespace.cfm` | POST | Allocate shard UUID + Major for a business | | `/api/beacon-sharding/allocate_servicepoint_minor.cfm` | POST | Allocate Minor for a service point | | `/api/beacon-sharding/register_beacon_hardware.cfm` | POST | Register physical beacon after provisioning | | `/api/beacon-sharding/verify_beacon_broadcast.cfm` | POST | Confirm beacon is broadcasting correctly | ### Resolution APIs | Endpoint | Method | Description | |----------|--------|-------------| | `/api/beacon-sharding/resolve_business.cfm` | POST | Resolve (UUID, Major) → Business | | `/api/beacon-sharding/resolve_servicepoint.cfm` | POST | Resolve (UUID, Major, Minor) → ServicePoint | | `/api/beacon-sharding/get_shard_pool.cfm` | GET | Get list of all shard UUIDs | --- ## Provisioning Workflow ### Step-by-Step Process ``` ┌─────────────────────────────────────────────────────────────────┐ │ PROVISIONING FLOW │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. SELECT BUSINESS │ │ ├── List businesses or search │ │ └── Select target business │ │ │ │ 2. ALLOCATE NAMESPACE (if needed) │ │ ├── POST /allocate_business_namespace │ │ │ { "BusinessID": 123 } │ │ └── Response: { BeaconShardUUID, BeaconMajor } │ │ │ │ 3. SELECT/CREATE SERVICE POINT │ │ ├── List existing service points │ │ ├── Create new service point if needed │ │ └── Select target service point │ │ │ │ 4. ALLOCATE MINOR (if needed) │ │ ├── POST /allocate_servicepoint_minor │ │ │ { "BusinessID": 123, "ServicePointID": 456 } │ │ └── Response: { BeaconMinor } │ │ │ │ 5. CONNECT TO BEACON (GATT) │ │ ├── Scan for beacons in config mode │ │ ├── Connect via GATT │ │ └── Authenticate if required by vendor │ │ │ │ 6. WRITE BEACON CONFIG │ │ ├── Write UUID = BeaconShardUUID │ │ ├── Write Major = BeaconMajor │ │ ├── Write Minor = BeaconMinor │ │ ├── (Optional) Set TxPower, AdvertisingInterval │ │ └── Disconnect GATT │ │ │ │ 7. REGISTER HARDWARE │ │ ├── POST /register_beacon_hardware │ │ │ { HardwareId, BusinessID, ServicePointID, │ │ │ UUID, Major, Minor, TxPower, ... } │ │ └── Status = "assigned" │ │ │ │ 8. VERIFY BROADCAST │ │ ├── Scan for iBeacon advertisements │ │ ├── Find beacon with matching UUID/Major/Minor │ │ ├── POST /verify_beacon_broadcast │ │ │ { HardwareId, UUID, Major, Minor, RSSI } │ │ └── Status = "verified" │ │ │ │ 9. DONE │ │ └── Beacon is now live and discoverable │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### Provisioning App Code (Pseudocode) ```kotlin // Android/iOS Provisioning Flow suspend fun provisionBeacon( businessId: Int, servicePointId: Int, beaconDevice: BluetoothDevice ) { // Step 1: Get/allocate business namespace val namespace = api.allocateBusinessNamespace(businessId) val shardUUID = namespace.beaconShardUUID val major = namespace.beaconMajor // Step 2: Get/allocate service point minor val spMinor = api.allocateServicePointMinor(businessId, servicePointId) val minor = spMinor.beaconMinor // Step 3: Connect to beacon via GATT val gatt = beaconDevice.connectGatt(context, false, gattCallback) waitForConnection() // Step 4: Write iBeacon config (vendor-specific GATT characteristics) writeBeaconConfig(gatt, BeaconConfig( uuid = shardUUID, major = major, minor = minor, txPower = -59, advertisingInterval = 350 )) // Step 5: Register hardware val hardwareId = beaconDevice.address // MAC address api.registerBeaconHardware(RegisterRequest( hardwareId = hardwareId, businessId = businessId, servicePointId = servicePointId, uuid = shardUUID, major = major, minor = minor )) // Step 6: Verify broadcast gatt.disconnect() delay(2000) // Wait for beacon to start advertising val scannedBeacon = scanForBeacon(shardUUID, major, minor, timeout = 10.seconds) if (scannedBeacon != null) { api.verifyBeaconBroadcast(VerifyRequest( hardwareId = hardwareId, uuid = shardUUID, major = major, minor = minor, rssi = scannedBeacon.rssi )) showSuccess("Beacon provisioned and verified!") } else { showError("Beacon not broadcasting expected values") } } ``` --- ## App Runtime Scanning ### UUID Pool Management ```kotlin // BeaconShardPool.kt object BeaconShardPool { private val bundledShards: List = loadBundledShards() private var remoteShards: List = emptyList() private var lastRemoteVersion: Int = 0 val allShards: List get() = (bundledShards + remoteShards).distinct() private fun loadBundledShards(): List { // Load from payfrit_beacon_shards.json bundled in app val json = resources.openRawResource(R.raw.payfrit_beacon_shards) return parseShards(json) } suspend fun refreshFromRemote() { try { val response = api.getShardPool(since = lastRemoteVersion) if (response.ok && response.shards.isNotEmpty()) { remoteShards = (remoteShards + response.shards.map { it.uuid }).distinct() lastRemoteVersion = response.version saveToLocalStorage() } } catch (e: Exception) { // Ignore - use cached/bundled shards } } } ``` ### Scanning Strategy ```kotlin // BeaconScanner.kt class BeaconScanner(private val context: Context) { // Foreground: Scan for all Payfrit shard UUIDs fun startForegroundScan(callback: (List) -> Unit) { val regions = BeaconShardPool.allShards.map { uuid -> Region("payfrit-$uuid", Identifier.parse(uuid), null, null) } beaconManager.apply { regions.forEach { addRangeNotifier(callback); startRangingBeacons(it) } } } // Background (iOS): Monitor only the selected business fun startBackgroundMonitoring(businessUUID: String, businessMajor: Int) { val region = Region( "selected-business", Identifier.parse(businessUUID), Identifier.fromInt(businessMajor), null ) beaconManager.startMonitoring(region) } } ``` --- ## Service Point Lock Algorithm ### Overview The lock algorithm prevents "beacon flapping" when a user is near multiple beacons. It uses hysteresis to ensure stable service point selection. ### Two-Stage Lock ``` Stage A: Business Lock (cold start only) ├── Aggregate dominance per (UUID, Major) over rolling window ├── Lock when same business dominant for N consecutive samples └── Once locked, skip to Stage B Stage B: Service Point Lock (always active) ├── Within locked business, track per-minor RSSI ├── Winner minor must beat current by Δ dB for T ms └── Lost timeout: clear lock if no beacons seen for timeout period ``` ### Implementation ```kotlin // BeaconLockManager.kt class BeaconLockManager { // Tunable constants companion object { const val WINDOW_MS = 1500L // Rolling window duration const val EWMA_ALPHA = 0.25 // EWMA smoothing factor const val SWITCH_THRESHOLD_DB = 8 // Must beat current by this much const val SWITCH_HOLD_MS = 1000L // Must maintain lead for this long const val LOST_TIMEOUT_MS = 4000L // Clear lock after no beacons } // Current lock state private var lockedBusiness: BusinessLock? = null private var lockedServicePoint: ServicePointLock? = null // RSSI tracking per minor (EWMA smoothed) private val minorRssiMap = mutableMapOf() // Candidate tracking for hysteresis private var switchCandidate: SwitchCandidate? = null data class BusinessLock( val uuid: String, val major: Int, val businessId: Int, val lockedAt: Long ) data class ServicePointLock( val minor: Int, val servicePointId: Int, val lockedAt: Long ) data class SwitchCandidate( val minor: Int, val startTime: Long, val rssiAdvantage: Int ) data class EwmaValue( var value: Double, var lastUpdate: Long ) /** * Process incoming beacon scan results. * Called by scanner with all detected beacons. */ fun processBeacons(beacons: List, now: Long = System.currentTimeMillis()) { // Filter to Payfrit shards only val payfritBeacons = beacons.filter { BeaconShardPool.allShards.contains(it.id1.toString()) } if (payfritBeacons.isEmpty()) { handleNoBeacons(now) return } // Stage A: Business lock (if not already locked) if (lockedBusiness == null) { val dominantBusiness = findDominantBusiness(payfritBeacons) if (dominantBusiness != null) { lockBusiness(dominantBusiness, now) } return // Don't proceed to Stage B until business locked } // Filter to locked business only val businessBeacons = payfritBeacons.filter { it.id1.toString() == lockedBusiness!!.uuid && it.id2.toInt() == lockedBusiness!!.major } if (businessBeacons.isEmpty()) { handleNoBeaconsFromLockedBusiness(now) return } // Reset lost timeout lastBeaconSeen = now // Stage B: Service point lock updateMinorRssi(businessBeacons, now) evaluateServicePointLock(now) } /** * Find the dominant business using EWMA of strongest minor per business. */ private fun findDominantBusiness(beacons: List): BusinessCandidate? { // Group by (UUID, Major) = business val byBusiness = beacons.groupBy { "${it.id1}:${it.id2}" } // For each business, get max RSSI (strongest beacon) val businessStrength = byBusiness.mapValues { (_, biz) -> biz.maxOf { it.rssi } } // Find strongest val strongest = businessStrength.maxByOrNull { it.value } ?: return null // Check dominance (must be significantly stronger than others) val others = businessStrength.filter { it.key != strongest.key } val secondStrongest = others.maxOfOrNull { it.value } ?: Int.MIN_VALUE if (strongest.value - secondStrongest >= SWITCH_THRESHOLD_DB) { val parts = strongest.key.split(":") return BusinessCandidate(parts[0], parts[1].toInt()) } return null } /** * Update EWMA values for each minor. */ private fun updateMinorRssi(beacons: List, now: Long) { for (beacon in beacons) { val minor = beacon.id3.toInt() val ewma = minorRssiMap.getOrPut(minor) { EwmaValue(beacon.rssi.toDouble(), now) } // EWMA update: new = alpha * current + (1-alpha) * old ewma.value = EWMA_ALPHA * beacon.rssi + (1 - EWMA_ALPHA) * ewma.value ewma.lastUpdate = now } // Decay old entries val cutoff = now - WINDOW_MS minorRssiMap.entries.removeIf { it.value.lastUpdate < cutoff } } /** * Evaluate whether to switch service point lock. */ private fun evaluateServicePointLock(now: Long) { if (minorRssiMap.isEmpty()) return // Find strongest minor val strongest = minorRssiMap.maxByOrNull { it.value.value }!! val strongestMinor = strongest.key val strongestRssi = strongest.value.value // If no current lock, lock immediately if (lockedServicePoint == null) { lockServicePoint(strongestMinor, now) return } // If current lock is the strongest, clear any switch candidate if (lockedServicePoint!!.minor == strongestMinor) { switchCandidate = null return } // Check if strongest beats current by threshold val currentRssi = minorRssiMap[lockedServicePoint!!.minor]?.value ?: Double.MIN_VALUE val advantage = strongestRssi - currentRssi if (advantage >= SWITCH_THRESHOLD_DB) { // Potential switch - check hysteresis if (switchCandidate?.minor == strongestMinor) { // Same candidate - check hold time if (now - switchCandidate!!.startTime >= SWITCH_HOLD_MS) { // Switch! lockServicePoint(strongestMinor, now) switchCandidate = null } } else { // New candidate switchCandidate = SwitchCandidate(strongestMinor, now, advantage.toInt()) } } else { // Advantage insufficient - clear candidate switchCandidate = null } } private fun handleNoBeacons(now: Long) { if (now - lastBeaconSeen > LOST_TIMEOUT_MS) { clearAllLocks() } } private fun handleNoBeaconsFromLockedBusiness(now: Long) { if (now - lastBeaconSeen > LOST_TIMEOUT_MS) { clearAllLocks() } } private fun lockBusiness(candidate: BusinessCandidate, now: Long) { // Resolve business via API CoroutineScope(Dispatchers.IO).launch { val resolved = api.resolveBusiness(candidate.uuid, candidate.major) if (resolved.ok) { lockedBusiness = BusinessLock( uuid = candidate.uuid, major = candidate.major, businessId = resolved.businessId, lockedAt = now ) notifyBusinessLocked(resolved) } } } private fun lockServicePoint(minor: Int, now: Long) { CoroutineScope(Dispatchers.IO).launch { val resolved = api.resolveServicePoint( lockedBusiness!!.uuid, lockedBusiness!!.major, minor ) if (resolved.ok) { lockedServicePoint = ServicePointLock( minor = minor, servicePointId = resolved.servicePointId, lockedAt = now ) notifyServicePointLocked(resolved) } } } private fun clearAllLocks() { lockedBusiness = null lockedServicePoint = null switchCandidate = null minorRssiMap.clear() notifyLocksCleared() } } ``` --- ## iOS Background Considerations ### Region Monitoring iOS cannot continuously scan in the background. Use region monitoring for the user's selected/last-used business only. ```swift // iOS: BeaconRegionManager.swift class BeaconRegionManager: NSObject, CLLocationManagerDelegate { private let locationManager = CLLocationManager() private var monitoredRegion: CLBeaconRegion? func monitorSelectedBusiness(uuid: UUID, major: CLBeaconMajorValue) { // Stop monitoring old region if let old = monitoredRegion { locationManager.stopMonitoring(for: old) } // Create new region with UUID + Major (no Minor = any service point) let region = CLBeaconRegion( uuid: uuid, major: major, identifier: "selected-business" ) region.notifyEntryStateOnDisplay = true region.notifyOnEntry = true region.notifyOnExit = true monitoredRegion = region locationManager.startMonitoring(for: region) } func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { // User entered business - prompt to open app sendLocalNotification("You're at \(businessName). Open to place an order?") } func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { // User left business clearServicePointLock() } } ``` ### Foreground Ranging When app is active, do full ranging to get precise service point: ```swift func locationManager(_ manager: CLLocationManager, didRangeBeacons beacons: [CLBeacon], in region: CLBeaconRegion) { // Convert to common format and feed to lock algorithm let ibeacons = beacons.map { beacon in IBeacon( uuid: beacon.uuid.uuidString, major: beacon.major.uint16Value, minor: beacon.minor.uint16Value, rssi: beacon.rssi ) } lockManager.processBeacons(ibeacons) } ``` --- ## Migration Notes 1. Run `migration.sql` on the database 2. Deploy all API endpoints 3. Bundle `payfrit_beacon_shards.json` in app assets 4. Implement BeaconShardPool in apps 5. Implement BeaconLockManager in apps 6. Update provisioning apps to use new workflow 7. Gradually provision beacons with new system ### Backward Compatibility The old Beacons table remains unchanged. The new sharding system runs in parallel. Old beacons will continue to work via the legacy lookup endpoint until migrated.