This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/api/beacon-sharding/IMPLEMENTATION.md
John Mizerek 3089f84873 Add beacon UUID sharding system and fix task customer info
- 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>
2026-02-06 17:16:08 -08:00

621 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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