- Modified BeaconProvisioner to read device info (0x30) before writing config - Extract MAC address from beacon and return in ProvisioningResult - Use MAC address as hardware_id field (snake_case for backend) - Reorder scan view: Configurable Devices section now appears first - Add debug logging for beacon registration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1048 lines
44 KiB
Swift
1048 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 = formatUuidWithDashes(beacon.uuid)
|
|
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 = formatUuidWithDashes(config.uuid)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
} 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 = formatUuidWithDashes(config.uuid)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
} 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]))"
|
|
}
|
|
}
|