payfrit-beacon-ios/PayfritBeacon/ScanView.swift
Schwifty ee366870ea feat: multi-password auth, structured error codes, missing API endpoints
- DX-Smart auth now tries multiple passwords in sequence (555555, dx1234, 000000)
  instead of hardcoding a single password. Matches Android behavior for better
  compatibility across firmware versions.

- Added ProvisioningError enum with structured error codes (CONNECTION_FAILED,
  AUTH_FAILED, SERVICE_NOT_FOUND, WRITE_FAILED, etc.) matching Android's
  BeaconConfig error codes. All fail() calls now tagged with codes for better
  debugging and error reporting.

- Added ProvisioningResult.failureWithCode case and handling in ScanView.

- Added missing API endpoints that Android has:
  - getBusiness() - single business fetch
  - getBusinessName() - cached business name lookup
  - allocateServicePointMinor() - minor value allocation

- Fixed stray print() in Api.swift to use DebugLog.shared.log() for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:31:39 +00:00

1051 lines
44 KiB
Swift

import SwiftUI
// MARK: - ScanView (Beacon Provisioning)
struct ScanView: View {
let businessId: Int
let businessName: String
var reprovisionServicePoint: ServicePoint? = nil // If set, we're re-provisioning an existing SP
var onBack: () -> Void
@StateObject private var bleScanner = BLEBeaconScanner()
@StateObject private var provisioner = BeaconProvisioner()
@StateObject private var iBeaconScanner = BeaconScanner()
@State private var namespace: BusinessNamespace?
@State private var servicePoints: [ServicePoint] = []
@State private var nextTableNumber: Int = 1
@State private var provisionedCount: Int = 0
// iBeacon ownership tracking
// Key: "UUID|Major" (businessId, businessName)
@State private var detectedIBeacons: [DetectedBeacon] = []
@State private var beaconOwnership: [String: (businessId: Int, businessName: String)] = [:]
// UI State
@State private var snackMessage: String?
@State private var showAssignSheet = false
@State private var selectedBeacon: DiscoveredBeacon?
@State private var assignName = ""
@State private var isProvisioning = false
@State private var provisioningProgress = ""
@State private var provisioningError: String?
// Action sheet + check config
@State private var showBeaconActionSheet = false
@State private var showCheckConfigSheet = false
@State private var checkConfigData: BeaconCheckResult?
@State private var isCheckingConfig = false
@State private var checkConfigError: String?
// Debug log
@State private var showDebugLog = false
@ObservedObject private var debugLog = DebugLog.shared
var body: some View {
VStack(spacing: 0) {
// Toolbar
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.title3)
}
Text("Beacon Setup")
.font(.headline)
Spacer()
if provisionedCount > 0 {
Text("\(provisionedCount) done")
.font(.caption)
.foregroundColor(.payfritGreen)
}
Button {
showDebugLog = true
} label: {
Image(systemName: "ladybug")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
// Info bar
HStack {
Text(businessName)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
if let sp = reprovisionServicePoint {
Text("Re-provision: \(sp.name)")
.font(.subheadline.bold())
.foregroundColor(.orange)
} else {
Text("Next: Table \(nextTableNumber)")
.font(.subheadline.bold())
.foregroundColor(.payfritGreen)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
Divider()
// Bluetooth status
if bleScanner.bluetoothState != .poweredOn {
bluetoothWarning
}
// Beacon lists
ScrollView {
LazyVStack(spacing: 8) {
// BLE devices section (for provisioning) - shown first
if !bleScanner.discoveredBeacons.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Configurable Devices")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.padding(.horizontal)
ForEach(bleScanner.discoveredBeacons) { beacon in
beaconRow(beacon)
.onTapGesture {
selectedBeacon = beacon
showBeaconActionSheet = true
}
}
}
.padding(.top, 8)
if !detectedIBeacons.isEmpty {
Divider()
.padding(.vertical, 8)
}
} else if bleScanner.isScanning {
VStack(spacing: 12) {
ProgressView()
Text("Scanning for beacons...")
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else if detectedIBeacons.isEmpty {
VStack(spacing: 12) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No beacons found")
.foregroundColor(.secondary)
Text("Make sure beacons are powered on\nand in configuration mode")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
// Detected iBeacons section (shows ownership status)
if !detectedIBeacons.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Detected Beacons")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.padding(.horizontal)
ForEach(detectedIBeacons, id: \.minor) { ibeacon in
iBeaconRow(ibeacon)
}
}
}
}
.padding(.horizontal)
}
// Bottom action bar
VStack(spacing: 8) {
Button(action: startScan) {
HStack {
if bleScanner.isScanning {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(bleScanner.isScanning ? "Scanning..." : "Scan for Beacons")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(bleScanner.isScanning ? Color.gray : Color.payfritGreen)
.foregroundColor(.white)
.cornerRadius(8)
}
.disabled(bleScanner.isScanning || bleScanner.bluetoothState != .poweredOn)
Button(action: onBack) {
Text("Done")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.payfritGreen, lineWidth: 1)
)
.foregroundColor(.payfritGreen)
}
}
.padding()
}
.modifier(DevBanner())
.overlay(snackOverlay, alignment: .bottom)
.sheet(isPresented: $showAssignSheet) { assignSheet }
.sheet(isPresented: $showCheckConfigSheet) { checkConfigSheet }
.sheet(isPresented: $showDebugLog) { debugLogSheet }
.confirmationDialog(
selectedBeacon?.displayName ?? "Beacon",
isPresented: $showBeaconActionSheet,
titleVisibility: .visible
) {
if let sp = reprovisionServicePoint {
Button("Provision for \(sp.name)") {
guard selectedBeacon != nil else { return }
reprovisionBeacon()
}
} else {
Button("Configure (Assign & Provision)") {
guard selectedBeacon != nil else { return }
assignName = "Table \(nextTableNumber)"
showAssignSheet = true
}
}
Button("Check Current Config") {
guard let beacon = selectedBeacon else { return }
checkConfig(beacon)
}
Button("Cancel", role: .cancel) {
selectedBeacon = nil
}
}
.onAppear { loadServicePoints() }
}
// MARK: - Bluetooth Warning
private var bluetoothWarning: some View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.warningOrange)
Text(bluetoothMessage)
.font(.caption)
Spacer()
}
.padding()
.background(Color.warningOrange.opacity(0.1))
}
private var bluetoothMessage: String {
switch bleScanner.bluetoothState {
case .poweredOff:
return "Bluetooth is turned off"
case .unauthorized:
return "Bluetooth permission denied"
case .unsupported:
return "Bluetooth not supported"
default:
return "Bluetooth not ready"
}
}
// MARK: - Beacon Row
private func beaconRow(_ beacon: DiscoveredBeacon) -> some View {
HStack(spacing: 12) {
// Signal strength indicator
Rectangle()
.fill(signalColor(beacon.rssi))
.frame(width: 4)
.cornerRadius(2)
VStack(alignment: .leading, spacing: 4) {
Text(beacon.displayName)
.font(.system(.body, design: .default))
.lineLimit(1)
HStack(spacing: 8) {
if beacon.type != .unknown {
Text(beacon.type.rawValue)
.font(.caption2.weight(.medium))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple)
.cornerRadius(4)
}
Text("\(beacon.rssi) dBm")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(8)
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
}
private func signalColor(_ rssi: Int) -> Color {
if rssi >= -60 { return .signalStrong }
if rssi >= -75 { return .signalMedium }
return .signalWeak
}
// MARK: - iBeacon Row (shows ownership status)
private func iBeaconRow(_ beacon: DetectedBeacon) -> some View {
let ownership = getOwnershipStatus(for: beacon)
return HStack(spacing: 12) {
// Signal strength indicator
Rectangle()
.fill(signalColor(beacon.rssi))
.frame(width: 4)
.cornerRadius(2)
VStack(alignment: .leading, spacing: 4) {
// Ownership status - all green (own business shows name, others show "Unconfigured")
Text(ownership.displayText)
.font(.system(.body, design: .default).weight(.medium))
.foregroundColor(.payfritGreen)
.lineLimit(1)
HStack(spacing: 8) {
Text("Major: \(beacon.major)")
.font(.caption)
.foregroundColor(.secondary)
Text("Minor: \(beacon.minor)")
.font(.caption)
.foregroundColor(.secondary)
Text("\(beacon.rssi) dBm")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(8)
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
}
// MARK: - Assignment Sheet
private var assignSheet: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
if let beacon = selectedBeacon {
// Beacon info
VStack(alignment: .leading, spacing: 8) {
Text("Beacon")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Text(beacon.displayName)
.font(.headline)
Spacer()
Text(beacon.type.rawValue)
.font(.caption2.weight(.medium))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple)
.cornerRadius(4)
}
Text("Signal: \(beacon.rssi) dBm")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
// Service point name
VStack(alignment: .leading, spacing: 8) {
Text("Service Point Name")
.font(.caption)
.foregroundColor(.secondary)
TextField("e.g., Table 1", text: $assignName)
.textFieldStyle(.roundedBorder)
.font(.title3)
}
// Provisioning progress
if isProvisioning {
HStack {
ProgressView()
Text(provisioningProgress)
.font(.callout)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(.systemGray6))
.cornerRadius(8)
}
// Provisioning error
if let error = provisioningError {
HStack(alignment: .top) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(error)
.font(.callout)
.foregroundColor(.red)
}
.padding()
.background(Color.red.opacity(0.1))
.cornerRadius(8)
}
Spacer()
}
}
.padding()
.navigationTitle("Assign Beacon")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
showAssignSheet = false
selectedBeacon = nil
}
.disabled(isProvisioning)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save Beacon") {
saveBeacon()
}
.disabled(assignName.trimmingCharacters(in: .whitespaces).isEmpty || isProvisioning)
}
}
}
.presentationDetents([.medium])
.interactiveDismissDisabled(isProvisioning)
}
// MARK: - Check Config Sheet
private var checkConfigSheet: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let beacon = selectedBeacon {
// Beacon identity
VStack(alignment: .leading, spacing: 8) {
Text("Beacon")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Text(beacon.displayName)
.font(.headline)
Spacer()
if beacon.type != .unknown {
Text(beacon.type.rawValue)
.font(.caption2.weight(.medium))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple)
.cornerRadius(4)
}
}
Text("Signal: \(beacon.rssi) dBm")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
// Loading state
if isCheckingConfig {
HStack {
ProgressView()
Text(provisioner.progress.isEmpty ? "Connecting..." : provisioner.progress)
.font(.callout)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemGray6))
.cornerRadius(8)
}
// Error state
if let error = checkConfigError {
HStack(alignment: .top) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(error)
.font(.callout)
}
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
}
// Parsed config data
if let data = checkConfigData {
// iBeacon configuration
if data.hasConfig {
VStack(alignment: .leading, spacing: 10) {
Text("iBeacon Configuration")
.font(.subheadline.weight(.semibold))
if let major = data.major {
configRow("Major", "\(major)")
}
if let ns = namespace {
configRow("Shard", "\(ns.shardId)")
}
if let minor = data.minor {
configRow("Minor", "\(minor)")
}
if let name = data.deviceName {
configRow("Name", name)
}
if let rssi = data.rssiAt1m {
configRow("RSSI@1m", "\(rssi) dBm")
}
if let interval = data.advInterval {
configRow("Interval", "\(interval)00 ms")
}
if let tx = data.txPower {
configRow("TX Power", "\(tx)")
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
// Device info
if data.battery != nil || data.macAddress != nil {
VStack(alignment: .leading, spacing: 10) {
Text("Device Info")
.font(.subheadline.weight(.semibold))
if let battery = data.battery {
configRow("Battery", "\(battery)%")
}
if let mac = data.macAddress {
configRow("MAC", mac)
}
if let slots = data.frameSlots {
let slotStr = slots.enumerated().map { i, s in
"Slot\(i): 0x\(String(format: "%02X", s))"
}.joined(separator: " ")
configRow("Frames", slotStr)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
// No config found
if !data.hasConfig && data.battery == nil && data.macAddress == nil && data.rawResponses.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
Text("No DX-Smart config data received")
.font(.callout)
.foregroundColor(.secondary)
}
if !data.servicesFound.isEmpty {
Text("Services: \(data.servicesFound.joined(separator: ", "))")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
// Raw responses (debug section)
if !data.rawResponses.isEmpty {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Raw Responses")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Button {
let raw = data.rawResponses.joined(separator: "\n")
UIPasteboard.general.string = raw
showSnack("Copied to clipboard")
} label: {
Image(systemName: "doc.on.doc")
.font(.caption)
}
}
Text(data.rawResponses.joined(separator: "\n"))
.font(.system(.caption2, design: .monospaced))
.textSelection(.enabled)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
// Services/chars discovery info
if !data.characteristicsFound.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("BLE Discovery")
.font(.caption)
.foregroundColor(.secondary)
Text(data.characteristicsFound.joined(separator: "\n"))
.font(.system(.caption2, design: .monospaced))
.textSelection(.enabled)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
} else {
Text("No beacon selected")
.foregroundColor(.secondary)
}
}
.padding()
}
.navigationTitle("Check Config")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
showCheckConfigSheet = false
selectedBeacon = nil
checkConfigData = nil
checkConfigError = nil
}
.disabled(isCheckingConfig)
}
}
}
.presentationDetents([.medium, .large])
.interactiveDismissDisabled(isCheckingConfig)
}
private func configRow(_ label: String, _ value: String) -> some View {
HStack(alignment: .top) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 80, alignment: .leading)
Text(value)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
// MARK: - Debug Log Sheet
private var debugLogSheet: some View {
NavigationStack {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(Array(debugLog.entries.enumerated()), id: \.offset) { idx, entry in
Text(entry)
.font(.system(.caption2, design: .monospaced))
.textSelection(.enabled)
.id(idx)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
.onAppear {
if let last = debugLog.entries.indices.last {
proxy.scrollTo(last, anchor: .bottom)
}
}
}
.navigationTitle("Debug Log")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { showDebugLog = false }
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
UIPasteboard.general.string = debugLog.allText
} label: {
Image(systemName: "doc.on.doc")
}
Button {
debugLog.clear()
} label: {
Image(systemName: "trash")
}
}
}
}
}
// MARK: - Snack Overlay
@ViewBuilder
private var snackOverlay: some View {
if let message = snackMessage {
Text(message)
.font(.callout)
.foregroundColor(.white)
.padding()
.background(Color(.darkGray))
.cornerRadius(8)
.padding(.bottom, 100)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation { snackMessage = nil }
}
}
}
}
private func showSnack(_ message: String) {
withAnimation { snackMessage = message }
}
// MARK: - Actions
private func loadServicePoints() {
Task {
// Load namespace (for display/debug) and service points
// Note: Namespace is no longer required for provisioning - we use get_beacon_config instead
do {
namespace = try await Api.shared.allocateBusinessNamespace(businessId: businessId)
DebugLog.shared.log("[ScanView] Loaded namespace: uuid=\(namespace?.uuid ?? "nil") major=\(namespace?.major ?? 0)")
} catch {
DebugLog.shared.log("[ScanView] allocateBusinessNamespace error (non-critical): \(error)")
// Non-critical - provisioning will use get_beacon_config endpoint
}
do {
servicePoints = try await Api.shared.listServicePoints(businessId: businessId)
DebugLog.shared.log("[ScanView] Loaded \(servicePoints.count) service points")
// Find next table number
let maxNumber = servicePoints.compactMap { sp -> Int? in
guard let match = sp.name.range(of: #"Table\s+(\d+)"#,
options: [.regularExpression, .caseInsensitive]) else {
return nil
}
let numberStr = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
return Int(numberStr)
}.max() ?? 0
nextTableNumber = maxNumber + 1
} catch {
DebugLog.shared.log("[ScanView] listServicePoints error: \(error)")
}
// Auto-start scan
startScan()
}
}
private func startScan() {
guard bleScanner.isBluetoothReady else {
showSnack("Bluetooth not available")
return
}
// Start BLE scan for DX-Smart devices
bleScanner.startScanning()
// Also start iBeacon ranging to detect configured beacons and their ownership
startIBeaconScan()
}
private func startIBeaconScan() {
guard iBeaconScanner.hasPermissions() else {
// Request permission if needed
iBeaconScanner.requestPermission()
return
}
// Start ranging for all Payfrit shard UUIDs
iBeaconScanner.startRanging(uuids: BeaconShardPool.uuids)
// Collect results after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [self] in
let detected = iBeaconScanner.stopAndCollect()
detectedIBeacons = detected
DebugLog.shared.log("[ScanView] Detected \(detected.count) iBeacons")
// Look up ownership for each detected iBeacon
Task {
await resolveBeaconOwnership(detected)
}
}
}
private func resolveBeaconOwnership(_ beacons: [DetectedBeacon]) async {
for beacon in beacons {
let key = "\(beacon.uuid)|\(beacon.major)"
// Skip if we already have ownership info for this beacon
guard beaconOwnership[key] == nil else { continue }
do {
// Format UUID with dashes for API call
let uuidWithDashes = beacon.uuid.uuidWithDashes
let result = try await Api.shared.resolveBusiness(uuid: uuidWithDashes, major: beacon.major)
await MainActor.run {
beaconOwnership[key] = (businessId: result.businessId, businessName: result.businessName)
DebugLog.shared.log("[ScanView] Resolved beacon \(beacon.major): \(result.businessName)")
}
} catch {
DebugLog.shared.log("[ScanView] Failed to resolve beacon \(beacon.major): \(error.localizedDescription)")
}
}
}
/// Get ownership status for a detected iBeacon
private func getOwnershipStatus(for beacon: DetectedBeacon) -> (isOwned: Bool, displayText: String) {
let key = "\(beacon.uuid)|\(beacon.major)"
if let ownership = beaconOwnership[key] {
if ownership.businessId == businessId {
return (true, ownership.businessName)
}
}
return (false, "Unconfigured")
}
private func checkConfig(_ beacon: DiscoveredBeacon) {
isCheckingConfig = true
checkConfigError = nil
checkConfigData = nil
showCheckConfigSheet = true
// Stop scanning to avoid BLE interference
bleScanner.stopScanning()
provisioner.readConfig(beacon: beacon) { data, error in
Task { @MainActor in
isCheckingConfig = false
if let data = data {
checkConfigData = data
}
if let error = error {
checkConfigError = error
}
}
}
}
private func reprovisionBeacon() {
guard let beacon = selectedBeacon, let sp = reprovisionServicePoint else { return }
// Stop scanning
bleScanner.stopScanning()
isProvisioning = true
provisioningProgress = "Preparing..."
provisioningError = nil
showAssignSheet = true // Reuse assign sheet to show progress
assignName = sp.name
DebugLog.shared.log("[ScanView] reprovisionBeacon: sp=\(sp.name) spId=\(sp.servicePointId) beacon=\(beacon.displayName)")
Task {
do {
// Use the new unified get_beacon_config endpoint
provisioningProgress = "Getting beacon config..."
let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: sp.servicePointId)
DebugLog.shared.log("[ScanView] reprovisionBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)")
// Build config using server-provided values (NOT hardcoded)
let deviceName = config.servicePointName.isEmpty ? sp.name : config.servicePointName
let beaconConfig = BeaconConfig(
uuid: config.uuid,
major: config.major,
minor: config.minor,
measuredPower: config.measuredPower,
advInterval: config.advInterval,
txPower: config.txPower,
deviceName: deviceName
)
provisioningProgress = "Provisioning beacon..."
provisioner.provision(beacon: beacon, config: beaconConfig) { result in
Task { @MainActor in
switch result {
case .success(let macAddress):
do {
// Use MAC address as hardware ID, fallback to iBeacon UUID if unavailable
let uuidWithDashes = config.uuid.uuidWithDashes
let hardwareId = macAddress ?? uuidWithDashes
DebugLog.shared.log("[ScanView] Registering beacon - MAC: \(macAddress ?? "nil"), hardwareId: \(hardwareId), uuid: \(uuidWithDashes)")
try await Api.shared.registerBeaconHardware(
businessId: businessId,
servicePointId: sp.servicePointId,
uuid: uuidWithDashes,
major: config.major,
minor: config.minor,
hardwareId: hardwareId,
macAddress: macAddress
)
finishProvisioning(name: sp.name)
} catch {
failProvisioning(error.localizedDescription)
}
case .failure(let error):
failProvisioning(error)
case .failureWithCode(let code, let detail):
let msg = detail ?? code.errorDescription ?? code.rawValue
DebugLog.shared.log("[ScanView] Provisioning failed [\(code.rawValue)]: \(msg)")
failProvisioning(msg)
}
}
}
} catch {
failProvisioning(error.localizedDescription)
}
}
}
private func saveBeacon() {
guard let beacon = selectedBeacon else { return }
let name = assignName.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
isProvisioning = true
provisioningProgress = "Preparing..."
provisioningError = nil
// Stop scanning to avoid BLE interference
bleScanner.stopScanning()
DebugLog.shared.log("[ScanView] saveBeacon: name=\(name) beacon=\(beacon.displayName) businessId=\(businessId)")
Task {
do {
// 1. Reuse existing service point if name matches, otherwise create new
var servicePoint: ServicePoint
if let existing = servicePoints.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) {
DebugLog.shared.log("[ScanView] saveBeacon: found existing SP id=\(existing.servicePointId)")
servicePoint = existing
} else {
provisioningProgress = "Creating service point..."
DebugLog.shared.log("[ScanView] saveBeacon: creating new SP...")
servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name)
DebugLog.shared.log("[ScanView] saveBeacon: created SP id=\(servicePoint.servicePointId)")
}
// 2. Use the new unified get_beacon_config endpoint (replaces allocate_business_namespace + allocate_servicepoint_minor)
provisioningProgress = "Getting beacon config..."
let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId)
DebugLog.shared.log("[ScanView] saveBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)")
// 3. Build config using server-provided values (NOT hardcoded)
let deviceName = config.servicePointName.isEmpty ? name : config.servicePointName
let beaconConfig = BeaconConfig(
uuid: config.uuid,
major: config.major,
minor: config.minor,
measuredPower: config.measuredPower,
advInterval: config.advInterval,
txPower: config.txPower,
deviceName: deviceName
)
provisioningProgress = "Provisioning beacon..."
// 4. Provision the beacon via GATT
provisioner.provision(beacon: beacon, config: beaconConfig) { result in
Task { @MainActor in
switch result {
case .success(let macAddress):
// Register in backend (use UUID with dashes for API)
do {
// Use MAC address as hardware ID, fallback to iBeacon UUID if unavailable
let uuidWithDashes = config.uuid.uuidWithDashes
let hardwareId = macAddress ?? uuidWithDashes
DebugLog.shared.log("[ScanView] Registering beacon - MAC: \(macAddress ?? "nil"), hardwareId: \(hardwareId), uuid: \(uuidWithDashes)")
try await Api.shared.registerBeaconHardware(
businessId: businessId,
servicePointId: servicePoint.servicePointId,
uuid: uuidWithDashes,
major: config.major,
minor: config.minor,
hardwareId: hardwareId,
macAddress: macAddress
)
finishProvisioning(name: name)
} catch {
failProvisioning(error.localizedDescription)
}
case .failure(let error):
failProvisioning(error)
case .failureWithCode(let code, let detail):
let msg = detail ?? code.errorDescription ?? code.rawValue
DebugLog.shared.log("[ScanView] Provisioning failed [\(code.rawValue)]: \(msg)")
failProvisioning(msg)
}
}
}
} catch {
failProvisioning(error.localizedDescription)
}
}
}
private func finishProvisioning(name: String) {
isProvisioning = false
provisioningProgress = ""
showAssignSheet = false
selectedBeacon = nil
// Update table number
if let match = name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) {
let numberStr = name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
if let savedNumber = Int(numberStr), savedNumber >= nextTableNumber {
nextTableNumber = savedNumber + 1
} else {
nextTableNumber += 1
}
} else {
nextTableNumber += 1
}
provisionedCount += 1
showSnack("Saved \"\(name)\"")
// Remove beacon from list
if let beacon = selectedBeacon {
bleScanner.discoveredBeacons.removeAll { $0.id == beacon.id }
}
}
private func failProvisioning(_ error: String) {
DebugLog.shared.log("[ScanView] Provisioning failed: \(error)")
isProvisioning = false
provisioningProgress = ""
provisioningError = error
}
// UUID formatting now handled by String.uuidWithDashes (UUIDFormatting.swift)
}