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