- Add beacon-sharding API endpoints for scalable iBeacon addressing (64 shard UUIDs × 65k businesses = ~4.2M capacity) - Fix callServer.cfm to save UserID when creating Call Server tasks - Fix getDetails.cfm to return customer info from Task.UserID when Order.UserID is null (for tasks without orders) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
621 lines
22 KiB
Markdown
621 lines
22 KiB
Markdown
# 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<String> = loadBundledShards()
|
||
private var remoteShards: List<String> = emptyList()
|
||
private var lastRemoteVersion: Int = 0
|
||
|
||
val allShards: List<String>
|
||
get() = (bundledShards + remoteShards).distinct()
|
||
|
||
private fun loadBundledShards(): List<String> {
|
||
// 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<IBeacon>) -> 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<Int, EwmaValue>()
|
||
|
||
// 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<IBeacon>, 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<IBeacon>): 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<IBeacon>, 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.
|