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