Compare commits

..

16 commits

Author SHA1 Message Date
66cf65f803 fix: trim auth and post-write delays from 600ms down to 100ms total
- Auth trigger settle: 100ms → 50ms
- Auth password settle: 500ms → 50ms
- Post-write reboot settle: 200ms → 50ms

Beacon handles 50ms inter-command just fine, no reason for the
beginning and end to be slower.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:18:18 +00:00
38600193b7 fix: auto-write config immediately after connect — no manual tap needed
Removes the two-phase flow where the user had to confirm beacon flashing
and tap "Write Config". Now goes straight from connect → write → register
in one shot. The dxsmartConnectedView UI is removed.
2026-03-23 04:16:04 +00:00
3c41ecb49d fix: add disconnect detection + drop inter-command delay to 50ms
Two changes:
1. DXSmartProvisioner now registers for BLE disconnect callbacks.
   Previously if the beacon dropped the link mid-write, the provisioner
   would sit waiting for ACK timeouts (5s × 2 retries = 10s of dead air).
   Now it fails immediately with a clear error.

2. Inter-command delay reduced from 150ms → 50ms since beacon handles
   fast writes fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:08:40 +00:00
a08d3db893 fix: explicitly disconnect after writeConfig so beacon can reboot
After SaveConfig is sent, the DXSmartProvisioner.writeConfig() returns
but never disconnects. The beacon MCU needs the BLE link dropped to
finalize its save-and-reboot cycle. Without disconnect, the beacon stays
connected and keeps flashing indefinitely.

Added 200ms delay + explicit provisioner.disconnect() after writeConfig
completes in the success path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:04:45 +00:00
640bc32f92 fix: use .withoutResponse for SaveConfig to prevent silent write drop
The beacon reboots instantly on SaveConfig (0x60). Using .withResponse
meant CoreBluetooth expected a GATT ACK that never arrived, potentially
causing the write to be silently dropped — leaving the config unsaved
and the beacon LED still flashing after provisioning.

Switching to .withoutResponse fires the bytes directly into the BLE
radio buffer without requiring a round-trip ACK. The beacon firmware
processes the save command from its buffer before rebooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:57:56 +00:00
157ab6d008 fix: send HardwareId to register_beacon_hardware API
The API requires HardwareId as a mandatory field, but the iOS app was
sending MacAddress (wrong key) and always passing nil. This caused
"HardwareId is required" errors after provisioning.

Since CoreBluetooth doesn't expose raw MAC addresses, we use the
CBPeripheral.identifier UUID as the hardware ID — same concept as
Android's device.address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:55:13 +00:00
ce81a1a3d8 Merge branch 'schwifty/faster-provisioning' into main 2026-03-23 03:50:41 +00:00
fcf427ee57 fix: reduce inter-command delay to 150ms
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:48:28 +00:00
4bf4435feb fix: resolve false disconnect error at end of provisioning
succeed() was calling disconnectPeripheral() before completion(), so the
disconnect delegate fired while writesCompleted was still false — causing
the "Unexpected disconnect" error log even on successful provisions.

Swapped order: signal completion first, then disconnect. Also removed the
redundant provisioner.disconnect() in ScanView since succeed() already
handles it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:47:27 +00:00
f082eeadad fix: skip ACK wait on SaveConfig — beacon reboots, never ACKs
SaveConfig (0x60) causes the beacon MCU to reboot and save to flash.
It never sends an ACK, so writeToCharAndWaitACK would wait for the
5s timeout, during which the beacon disconnects. The disconnect
handler fires while writesCompleted is still false, causing a false
"Unexpected disconnect: beacon timed out" error.

Fix: fire-and-forget the SaveConfig write and return immediately.
The BLE-level write (.withResponse) confirms delivery. writeConfig()
returns before the disconnect callback runs, so writesCompleted gets
set to true in time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:44:36 +00:00
3720f496bd perf: reduce inter-command delay from 500ms to 300ms (conservative)
Shaves ~4.6s off the 23-command provisioning sequence while keeping
a safe margin for the beacon's BLE stack to process each write.

Next step: if stable, we can go more aggressive (200ms or 150ms).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:41:44 +00:00
a82a3697da fix: treat post-SaveConfig disconnect as expected, not an error
After all 23 commands write successfully, the DX-Smart beacon reboots
to apply config — dropping the BLE connection. ScanView's disconnect
handler was racing the async writeConfig return and logging this as
"Unexpected disconnect: beacon timed out" even though provisioning
succeeded.

Added writesCompleted flag so the disconnect handler knows writes
finished and logs it as expected behavior instead of an error.
2026-03-23 03:32:21 +00:00
9ce7b9571a fix: allow minor = 0 in allocateMinor response validation 2026-03-23 03:22:23 +00:00
734a18356f fix: show all BLE devices in scan, no filtering
Remove the guard that dropped non-CP-28 devices. All discovered
BLE peripherals now appear in the scan list (defaulting to .dxsmart
type). detectBeaconType still classifies known CP-28 patterns but
unknown devices are no longer hidden.
2026-03-23 03:18:11 +00:00
f0fdb04e0e fix: restore FFF0 fallback and add 'payfrit' name detection in BLE scan
The CP-28-only refactor accidentally over-filtered the BLE scan:

1. FFF0 service detection was gated on name patterns — CP-28 beacons
   advertising FFF0 with non-matching names (e.g. already provisioned
   as "Payfrit") were silently filtered out. Restored unconditional
   FFF0 → dxsmart mapping (matching old behavior).

2. Already-provisioned beacons broadcast with name "Payfrit" (set by
   old SDK cmd 0x43), but that name wasn't in the detection patterns.
   Added "payfrit" to the name check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:11:46 +00:00
12048e5c88 Merge pull request 'refactor: CP-28 only — strip all non-DX-Smart beacon code' (#41) from schwifty/cp28-only into main 2026-03-23 03:04:14 +00:00
5 changed files with 107 additions and 118 deletions

View file

@ -365,8 +365,10 @@ class BeaconProvisioner: NSObject, ObservableObject {
isTerminating = true
DebugLog.shared.log("BLE: Provisioning success!")
state = .success
disconnectPeripheral()
// Signal completion BEFORE disconnecting the disconnect delegate fires
// synchronously and ScanView needs writesCompleted=true before it sees it
completion?(.success(macAddress: nil))
disconnectPeripheral()
cleanup()
}

View file

@ -41,6 +41,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
private(set) var isConnected = false
private(set) var isFlashing = false // Beacon LED flashing after trigger
private var useNewSDK = true // Prefer new SDK, fallback to old
private var disconnected = false // Set true when BLE link drops unexpectedly
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
@ -76,6 +77,14 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
try await authenticate()
await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")")
// Register for unexpected disconnects so we fail fast instead of
// waiting for per-command ACK timeouts (5s × 2 = 10s of dead air).
bleManager?.onPeripheralDisconnected = { [weak self] disconnectedPeripheral, error in
guard disconnectedPeripheral.identifier == self?.peripheral.identifier else { return }
self?.handleUnexpectedDisconnect(error: error)
}
isConnected = true
isFlashing = true
return
@ -125,6 +134,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
}
func disconnect() {
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
bleManager?.onPeripheralDisconnected = nil
if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral)
}
@ -171,8 +182,26 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
]
for (index, (name, packet)) in commands.enumerated() {
// Bail immediately if BLE link dropped between commands
if disconnected {
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true)
throw ProvisionError.writeFailed("BLE disconnected during write sequence")
}
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
// SaveConfig (last command) causes beacon MCU to reboot it never sends an ACK.
// Use .withoutResponse so CoreBluetooth fires the bytes immediately into the
// BLE radio buffer without waiting for a GATT round-trip. With .withResponse,
// the beacon reboots before the ACK arrives, and CoreBluetooth may silently
// drop the write leaving the config unsaved and the beacon still flashing.
if name == "SaveConfig" {
peripheral.writeValue(packet, for: writeChar, type: .withoutResponse)
await diagnosticLog?.log("write", "✅ [\(index + 1)/\(commands.count)] SaveConfig sent — beacon will reboot")
await diagnosticLog?.log("write", "✅ All commands written successfully")
return
}
// Retry each command up to 2 times beacon BLE stack can be flaky
var lastError: Error?
for writeAttempt in 1...2 {
@ -193,8 +222,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
throw lastError
}
// 500ms between commands beacon needs time to process
try await Task.sleep(nanoseconds: 500_000_000)
// 50ms between commands beacon handles fast writes fine (was 150ms, 300ms, 500ms)
try await Task.sleep(nanoseconds: 50_000_000)
}
}
@ -270,6 +299,32 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
return packet
}
// MARK: - Disconnect Detection
/// Called when BLE link drops unexpectedly during provisioning.
/// Immediately resolves any pending continuations so we fail fast
/// instead of waiting for the 5s operationTimeout.
private func handleUnexpectedDisconnect(error: Error?) {
disconnected = true
isConnected = false
let disconnectError = ProvisionError.writeFailed("BLE disconnected unexpectedly: \(error?.localizedDescription ?? "unknown")")
Task { await diagnosticLog?.log("ble", "⚠️ Unexpected disconnect during provisioning", isError: true) }
// Cancel any pending write/response continuation immediately
if let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: disconnectError)
}
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(throwing: disconnectError)
}
if let cont = connectionContinuation {
connectionContinuation = nil
cont.resume(throwing: disconnectError)
}
}
// MARK: - Private Helpers
private func connectOnce() async throws {
@ -329,15 +384,13 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// Step 1: Trigger fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE)
if let triggerData = Self.triggerPassword.data(using: .utf8) {
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
}
// Step 2: Auth password fire and forget
if let authData = Self.defaultPassword.data(using: .utf8) {
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
// 500ms settle after auth beacon needs time to enter config mode,
// especially if BLE stack was stressed by prior provisioner attempts
try await Task.sleep(nanoseconds: 500_000_000)
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
}
}

View file

@ -201,8 +201,8 @@ actor APIClient {
guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor")
}
guard let minor = resp.BeaconMinor, minor > 0 else {
throw APIError.serverError("API returned invalid minor value: \(resp.BeaconMinor ?? 0). Service point may not be configured correctly.")
guard let minor = resp.BeaconMinor, minor >= 0 else {
throw APIError.serverError("API returned invalid minor value: \(resp.BeaconMinor.map(String.init) ?? "nil"). Service point may not be configured correctly.")
}
return minor
}
@ -220,7 +220,7 @@ actor APIClient {
uuid: String,
major: Int,
minor: Int,
macAddress: String?,
hardwareId: String,
beaconType: String,
token: String
) async throws {
@ -230,9 +230,9 @@ actor APIClient {
"UUID": uuid,
"Major": major,
"Minor": minor,
"HardwareId": hardwareId,
"BeaconType": beaconType
]
if let mac = macAddress { body["MacAddress"] = mac }
let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {
@ -241,12 +241,13 @@ actor APIClient {
}
func verifyBeaconBroadcast(
hardwareId: String,
uuid: String,
major: Int,
minor: Int,
token: String
) async throws {
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
let body: [String: Any] = ["HardwareId": hardwareId, "UUID": uuid, "Major": major, "Minor": minor]
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else {

View file

@ -144,11 +144,8 @@ final class BLEManager: NSObject, ObservableObject {
// CP-28 also advertises FFF0 on some firmware
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") ||
deviceName.isEmpty {
return .dxsmart
}
// Any FFF0 device is likely CP-28 don't filter by name
return .dxsmart
}
}
@ -163,10 +160,11 @@ final class BLEManager: NSObject, ObservableObject {
}
}
// 3. Device name patterns for CP-28
// 3. Device name patterns for CP-28 (includes "payfrit" our own provisioned name)
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx-cp") || deviceName.contains("dx-smart") ||
deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") {
deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") ||
deviceName.contains("payfrit") {
return .dxsmart
}
@ -229,10 +227,8 @@ extension BLEManager: CBCentralManagerDelegate {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Only show CP-28 beacons everything else is filtered out
guard let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) else {
return
}
// Detect beacon type default to .dxsmart so ALL devices show up in scan
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) ?? .dxsmart
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
self.discoveredBeacons[idx].rssi = rssiValue

View file

@ -23,6 +23,7 @@ struct ScanView: View {
// Provisioning flow
@State private var selectedBeacon: DiscoveredBeacon?
@State private var provisioningState: ProvisioningState = .idle
@State private var writesCompleted = false
@State private var statusMessage = ""
@State private var errorMessage: String?
@State private var showQRScanner = false
@ -208,8 +209,8 @@ struct ScanView: View {
progressView(title: "Connecting…", message: statusMessage)
case .connected:
// DXSmart: beacon is flashing, show write button
dxsmartConnectedView
// Legacy auto-write skips this state now
progressView(title: "Connected…", message: statusMessage)
case .writing:
progressView(title: "Writing Config…", message: statusMessage)
@ -318,51 +319,7 @@ struct ScanView: View {
// MARK: - DXSmart Connected View
private var dxsmartConnectedView: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "light.beacon.max")
.font(.system(size: 64))
.foregroundStyle(Color.payfritGreen)
.modifier(PulseEffectModifier())
Text("Connected — Beacon is Flashing")
.font(.title2.bold())
Text("Confirm the beacon LED is flashing, then tap Write Config to program it.\n\nThe beacon will timeout if you wait too long.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button {
Task { await writeConfigToConnectedBeacon() }
} label: {
HStack {
Image(systemName: "arrow.down.doc")
Text("Write Config")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(Color.payfritGreen)
.controlSize(.large)
.padding(.horizontal, 32)
Button("Cancel") {
cancelProvisioning()
}
.foregroundStyle(.secondary)
// Show diagnostic log
if !provisionLog.entries.isEmpty {
diagnosticLogView
}
Spacer()
}
}
// dxsmartConnectedView removed auto-write skips the manual confirmation step
// MARK: - Progress / Success / Failed Views
@ -613,6 +570,8 @@ struct ScanView: View {
// Create appropriate provisioner
let provisioner = makeProvisioner(for: beacon)
pendingProvisioner = provisioner
pendingConfig = config
// Wire up real-time status updates from provisioner
if let dxProvisioner = provisioner as? DXSmartProvisioner {
@ -628,17 +587,19 @@ struct ScanView: View {
bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in
if peripheral.identifier == beacon.peripheral.identifier {
DispatchQueue.main.async { [weak self] in
let reason = error?.localizedDescription ?? "beacon timed out"
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
guard let self = self else { return }
// CP-28: disconnect during .connected is expected beacon keeps
// flashing after BLE drops. We'll reconnect when user taps Write Config.
if self.provisioningState == .connected {
provisionLog?.log("disconnect", "CP-28 idle disconnect — beacon still flashing, ignoring")
let reason = error?.localizedDescription ?? "beacon timed out"
// Writes already finished beacon rebooted after SaveConfig, this is expected
if self.writesCompleted {
provisionLog?.log("disconnect", "Post-write disconnect (expected — beacon rebooted after save)")
return
}
// For all other active states, treat disconnect as failure
if self.provisioningState == .connecting || self.provisioningState == .connected ||
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
// For all active states, treat disconnect as failure
if self.provisioningState == .connecting ||
self.provisioningState == .writing || self.provisioningState == .verifying {
self.provisioningState = .failed
self.errorMessage = "Beacon disconnected: \(reason)"
@ -650,43 +611,17 @@ struct ScanView: View {
try await provisioner.connect()
provisionLog.log("connect", "Connected and authenticated successfully")
// CP-28: stop at connected state, wait for user to confirm flashing
provisioningState = .connected
pendingConfig = config
pendingProvisioner = provisioner
} catch {
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
provisioningState = .failed
errorMessage = error.localizedDescription
}
}
// Store for DXSmart two-phase flow
@State private var pendingConfig: BeaconConfig?
@State private var pendingProvisioner: (any BeaconProvisioner)?
private func writeConfigToConnectedBeacon() async {
guard let config = pendingConfig,
let provisioner = pendingProvisioner,
let sp = selectedServicePoint,
let ns = namespace,
let token = appState.token else { return }
provisioningState = .writing
statusMessage = "Writing config to DX-Smart…"
do {
// Reconnect if the beacon dropped BLE during the "confirm flashing" wait
if !provisioner.isConnected {
provisionLog.log("write", "Beacon disconnected while waiting — reconnecting…")
statusMessage = "Reconnecting to beacon…"
try await provisioner.connect()
provisionLog.log("write", "Reconnected — writing config…")
statusMessage = "Writing config to DX-Smart…"
}
// Auto-fire write immediately no pause needed
provisioningState = .writing
writesCompleted = false
statusMessage = "Writing config to DX-Smart…"
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
try await provisioner.writeConfig(config)
writesCompleted = true
// Brief settle after SaveConfig before dropping the BLE link.
try? await Task.sleep(nanoseconds: 50_000_000)
provisioner.disconnect()
try await APIClient.shared.registerBeaconHardware(
@ -695,7 +630,7 @@ struct ScanView: View {
uuid: ns.uuid,
major: ns.major,
minor: Int(config.minor),
macAddress: nil,
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
beaconType: BeaconType.dxsmart.rawValue,
token: token
)
@ -704,14 +639,16 @@ struct ScanView: View {
statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)"
} catch {
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
provisioningState = .failed
errorMessage = error.localizedDescription
}
pendingConfig = nil
pendingProvisioner = nil
}
// Kept for cancel/reset and registerAnywayAfterFailure fallback
@State private var pendingConfig: BeaconConfig?
@State private var pendingProvisioner: (any BeaconProvisioner)?
private func registerAnywayAfterFailure() async {
guard let sp = selectedServicePoint,
let ns = namespace,
@ -728,7 +665,7 @@ struct ScanView: View {
uuid: ns.uuid,
major: ns.major,
minor: Int(config.minor),
macAddress: nil,
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
beaconType: selectedBeacon?.type.rawValue ?? "Unknown",
token: token
)