payfrit-beacon-ios/PayfritBeacon/ScanView.swift
John Pinkyfloyd 5283d2d265 Fix DX-Smart provisioning protocol and add debug logging
Fix critical packet format bugs matching SDK: frame select/type/trigger/disable
commands now send empty data, RSSI@1m corrected to -59 dBm. Add DebugLog,
read-config mode, service point list, and dev scheme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:01:12 -08:00

929 lines
38 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()
@State private var namespace: BusinessNamespace?
@State private var servicePoints: [ServicePoint] = []
@State private var nextTableNumber: Int = 1
@State private var provisionedCount: Int = 0
// 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 list
if bleScanner.discoveredBeacons.isEmpty {
Spacer()
if bleScanner.isScanning {
VStack(spacing: 12) {
ProgressView()
Text("Scanning for beacons...")
.foregroundColor(.secondary)
}
} else {
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)
}
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(bleScanner.discoveredBeacons) { beacon in
beaconRow(beacon)
.onTapGesture {
selectedBeacon = beacon
showBeaconActionSheet = true
}
}
}
.padding(.horizontal)
.padding(.top, 8)
}
}
// 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: - 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 uuid = data.uuid {
configRow("UUID", uuid)
}
if let major = data.major {
configRow("Major", "\(major)")
}
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 {
do {
// Load namespace (UUID + Major) and service points in parallel
async let nsTask = Api.shared.allocateBusinessNamespace(businessId: businessId)
async let spTask = Api.shared.listServicePoints(businessId: businessId)
namespace = try await nsTask
servicePoints = try await spTask
DebugLog.shared.log("[ScanView] Loaded namespace: uuid=\(namespace?.uuid ?? "nil") major=\(namespace?.major ?? 0)")
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] loadServicePoints error: \(error)")
}
// Auto-start scan
startScan()
}
}
private func startScan() {
guard bleScanner.isBluetoothReady else {
showSnack("Bluetooth not available")
return
}
bleScanner.startScanning()
}
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 }
guard let ns = namespace else {
failProvisioning("Namespace not loaded — go back and try again")
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) minor=\(String(describing: sp.beaconMinor)) beacon=\(beacon.displayName)")
Task {
do {
// If SP has no minor, re-fetch to get it
var minor = sp.beaconMinor
if minor == nil {
DebugLog.shared.log("[ScanView] reprovisionBeacon: SP has no minor, re-fetching...")
let refreshed = try await Api.shared.listServicePoints(businessId: businessId)
minor = refreshed.first(where: { $0.servicePointId == sp.servicePointId })?.beaconMinor
}
guard let beaconMinor = minor else {
failProvisioning("Service point has no beacon minor assigned")
return
}
// Build config from namespace + service point (uuidClean for BLE)
let deviceName = "PF-\(sp.name)"
let beaconConfig = BeaconConfig(
uuid: ns.uuidClean,
major: ns.major,
minor: beaconMinor,
txPower: -59,
interval: 350,
deviceName: deviceName
)
DebugLog.shared.log("[ScanView] reprovisionBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(beaconMinor)")
provisioningProgress = "Provisioning beacon..."
let hardwareId = beacon.id.uuidString // BLE peripheral identifier
provisioner.provision(beacon: beacon, config: beaconConfig) { result in
Task { @MainActor in
switch result {
case .success:
do {
try await Api.shared.registerBeaconHardware(
businessId: businessId,
servicePointId: sp.servicePointId,
uuid: ns.uuid,
major: ns.major,
minor: beaconMinor,
hardwareId: hardwareId
)
finishProvisioning(name: sp.name)
} catch {
failProvisioning(error.localizedDescription)
}
case .failure(let error):
failProvisioning(error)
}
}
}
} catch {
failProvisioning(error.localizedDescription)
}
}
}
private func saveBeacon() {
guard let beacon = selectedBeacon else { return }
let name = assignName.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
guard let ns = namespace else {
failProvisioning("Namespace not loaded — go back and try again")
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) minor=\(String(describing: existing.beaconMinor))")
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) minor=\(String(describing: servicePoint.beaconMinor))")
}
// If SP has no minor yet, re-fetch service points to get the allocated minor
if servicePoint.beaconMinor == nil {
DebugLog.shared.log("[ScanView] saveBeacon: SP has no minor, re-fetching service points...")
let refreshed = try await Api.shared.listServicePoints(businessId: businessId)
if let updated = refreshed.first(where: { $0.servicePointId == servicePoint.servicePointId }) {
servicePoint = updated
DebugLog.shared.log("[ScanView] saveBeacon: refreshed SP minor=\(String(describing: servicePoint.beaconMinor))")
}
}
guard let minor = servicePoint.beaconMinor else {
failProvisioning("Service point has no beacon minor assigned")
return
}
// 2. Build config from namespace + service point (uuidClean = no dashes, for BLE)
let deviceName = "PF-\(name)"
let beaconConfig = BeaconConfig(
uuid: ns.uuidClean,
major: ns.major,
minor: minor,
txPower: -59,
interval: 350,
deviceName: deviceName
)
DebugLog.shared.log("[ScanView] saveBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(minor)")
provisioningProgress = "Provisioning beacon..."
// 3. Provision the beacon via GATT
let hardwareId = beacon.id.uuidString // BLE peripheral identifier
provisioner.provision(beacon: beacon, config: beaconConfig) { result in
Task { @MainActor in
switch result {
case .success:
// Register in backend (use original UUID from API, not cleaned)
do {
try await Api.shared.registerBeaconHardware(
businessId: businessId,
servicePointId: servicePoint.servicePointId,
uuid: ns.uuid,
major: ns.major,
minor: minor,
hardwareId: hardwareId
)
finishProvisioning(name: name)
} catch {
failProvisioning(error.localizedDescription)
}
case .failure(let error):
failProvisioning(error)
}
}
}
} 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
}
private func formatUuidWithDashes(_ raw: String) -> String {
let clean = raw.replacingOccurrences(of: "-", with: "").uppercased()
guard clean.count == 32 else { return raw }
let chars = Array(clean)
return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
}
}