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>
929 lines
38 KiB
Swift
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]))"
|
|
}
|
|
}
|