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

22 KiB
Raw Blame History

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

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

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

// 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

// 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

// 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

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

// 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:

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.