249 lines
10 KiB
Swift
249 lines
10 KiB
Swift
|
|
import NearbyInteraction
|
||
|
|
import ARKit
|
||
|
|
import Combine
|
||
|
|
import Foundation
|
||
|
|
import CoreBluetooth
|
||
|
|
import QuartzCore
|
||
|
|
import simd
|
||
|
|
|
||
|
|
enum SessionState: Equatable {
|
||
|
|
case idle
|
||
|
|
case waitingForAccessory // BLE ready, waiting for board to speak first
|
||
|
|
case configuring
|
||
|
|
case ranging
|
||
|
|
case error(String)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apple NI Accessory Protocol — confirmed against DW3_QM33_SDK_1.1.1 firmware source
|
||
|
|
// (Src/Comm/Src/BLE/niq/ble_niq.c, Libs/niq/Inc/niq.h)
|
||
|
|
//
|
||
|
|
// Board → iOS (GATT notify on TX characteristic):
|
||
|
|
// 0x01 Accessory Configuration Data — board's UWB config, sent in reply to 0x0A
|
||
|
|
// 0x02 UWB Did Start
|
||
|
|
// 0x03 UWB Did Stop
|
||
|
|
//
|
||
|
|
// iOS → Board (GATT write to RX characteristic):
|
||
|
|
// 0x0A Init — iOS sends this first to request the board's UWB config
|
||
|
|
// 0x0B Configure and Start — iOS sends NI-generated shareable config
|
||
|
|
// 0x0C Stop
|
||
|
|
private enum MsgAccessoryToApp: UInt8 {
|
||
|
|
case accessoryConfigData = 0x01
|
||
|
|
case uwbDidStart = 0x02
|
||
|
|
case uwbDidStop = 0x03
|
||
|
|
}
|
||
|
|
|
||
|
|
private enum MsgAppToAccessory: UInt8 {
|
||
|
|
case init_ = 0x0A // iOS → Board: triggers board to send its UWB config
|
||
|
|
case configureAndStart = 0x0B // iOS → Board: send NI-generated shareable config
|
||
|
|
case stop = 0x0C // iOS → Board: stop ranging
|
||
|
|
}
|
||
|
|
|
||
|
|
class NIManager: NSObject, ObservableObject {
|
||
|
|
@Published var sessionState: SessionState = .idle
|
||
|
|
@Published var lastRange: Float? = nil
|
||
|
|
|
||
|
|
/// Called with (distance in meters, timestamp) on each range update (fallback when no camera assistance).
|
||
|
|
var onRangeUpdate: ((Float, TimeInterval) -> Void)?
|
||
|
|
/// Called with the accessory's world-space position when camera assistance resolves it.
|
||
|
|
var onWorldPositionUpdate: ((simd_float3) -> Void)?
|
||
|
|
/// Called with outbound BLE data to write to the accessory.
|
||
|
|
var sendToAccessory: ((Data) -> Void)?
|
||
|
|
|
||
|
|
// Peripheral identifier set externally before start() is called.
|
||
|
|
var peripheralIdentifier: UUID?
|
||
|
|
// Set this to the app's ARSession before start() so NI can share it for camera assistance.
|
||
|
|
weak var arSession: ARSession?
|
||
|
|
|
||
|
|
private var niSession: NISession?
|
||
|
|
private var restartWorkItem: DispatchWorkItem?
|
||
|
|
|
||
|
|
/// Called by the app when BLE is connected and TX notifications are live.
|
||
|
|
/// Sends the MessageId_init (0x0A) command to trigger the board to reply
|
||
|
|
/// with its Accessory Configuration Data (0x01 + UWB config payload).
|
||
|
|
func start() {
|
||
|
|
guard sessionState == .idle else { return }
|
||
|
|
sessionState = .waitingForAccessory
|
||
|
|
let initMsg = Data([MsgAppToAccessory.init_.rawValue])
|
||
|
|
sendToAccessory?(initMsg)
|
||
|
|
print("[NI] → Sent Init (0x0A) — waiting for board Accessory Config (0x01)")
|
||
|
|
}
|
||
|
|
|
||
|
|
func handleAccessoryData(_ data: Data) {
|
||
|
|
guard !data.isEmpty else { return }
|
||
|
|
let tag = data[0]
|
||
|
|
let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ")
|
||
|
|
print("[NI] ← rx tag=0x\(String(tag, radix: 16, uppercase: false)) len=\(data.count): \(hex)")
|
||
|
|
|
||
|
|
switch tag {
|
||
|
|
case MsgAccessoryToApp.accessoryConfigData.rawValue:
|
||
|
|
// Board replied to our 0x0A with 0x01 + AccessoryConfigurationData payload
|
||
|
|
let payload = Data(data.dropFirst())
|
||
|
|
guard !payload.isEmpty else {
|
||
|
|
print("[NI] 0x01 received but payload is empty — ignoring")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
print("[NI] Board sent Accessory Config (0x01) — \(payload.count) bytes, starting NI session")
|
||
|
|
startNISession(with: payload)
|
||
|
|
|
||
|
|
case MsgAccessoryToApp.uwbDidStart.rawValue:
|
||
|
|
print("[NI] Board confirmed UWB Did Start (0x02)")
|
||
|
|
DispatchQueue.main.async { self.sessionState = .ranging }
|
||
|
|
|
||
|
|
case MsgAccessoryToApp.uwbDidStop.rawValue:
|
||
|
|
print("[NI] Board confirmed UWB Did Stop (0x03)")
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
self.sessionState = .idle
|
||
|
|
self.lastRange = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
default:
|
||
|
|
print("[NI] Unexpected tag 0x\(String(tag, radix: 16, uppercase: false)) (\(data.count) bytes) — ignoring")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func stop() {
|
||
|
|
restartWorkItem?.cancel()
|
||
|
|
sendToAccessory?(Data([MsgAppToAccessory.stop.rawValue]))
|
||
|
|
niSession?.invalidate()
|
||
|
|
niSession = nil
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
self.sessionState = .idle
|
||
|
|
self.lastRange = nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Private
|
||
|
|
|
||
|
|
private func startNISession(with accessoryData: Data) {
|
||
|
|
guard sessionState == .waitingForAccessory || sessionState == .idle else {
|
||
|
|
print("[NI] startNISession skipped — already in state \(sessionState)")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sessionState = .configuring
|
||
|
|
print("[NI] Creating NINearbyAccessoryConfiguration from \(accessoryData.count) bytes")
|
||
|
|
|
||
|
|
// Use initWithData: (not initWithAccessoryData:bluetoothPeerIdentifier:).
|
||
|
|
// The peer-identifier variant requires a bonded/paired Bluetooth device;
|
||
|
|
// the DWM3001CDK is connected but not bonded, so that path silently prevents
|
||
|
|
// didGenerateShareableConfigurationData from ever firing.
|
||
|
|
// We handle all BLE transport manually, so the simple init is correct.
|
||
|
|
let configuration: NINearbyAccessoryConfiguration
|
||
|
|
do {
|
||
|
|
configuration = try NINearbyAccessoryConfiguration(data: accessoryData)
|
||
|
|
} catch {
|
||
|
|
let hexDump = accessoryData.map { String(format: "%02x", $0) }.joined(separator: " ")
|
||
|
|
print("[NI] Configuration parse error: \(error)\n data: \(hexDump)")
|
||
|
|
DispatchQueue.main.async { self.sessionState = .waitingForAccessory }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Enable camera assistance when the device supports it (iOS 16+, U1/U2 chip).
|
||
|
|
// This fuses ARKit visual-inertial odometry with UWB ranging inside Apple's framework,
|
||
|
|
// giving us a world-space position via worldTransform(for:) without running our own solver.
|
||
|
|
if #available(iOS 16.0, *), NISession.deviceCapabilities.supportsCameraAssistance {
|
||
|
|
configuration.isCameraAssistanceEnabled = true
|
||
|
|
print("[NI] Camera assistance enabled")
|
||
|
|
}
|
||
|
|
|
||
|
|
let session = NISession()
|
||
|
|
session.delegate = self
|
||
|
|
niSession = session
|
||
|
|
|
||
|
|
// Share our existing ARSession so NI doesn't spin up a second one.
|
||
|
|
// Must be called before run(_:). The session must already be running with a compatible config.
|
||
|
|
if #available(iOS 16.0, *), configuration.isCameraAssistanceEnabled, let arSession {
|
||
|
|
session.setARSession(arSession)
|
||
|
|
print("[NI] Shared ARSession with NISession")
|
||
|
|
}
|
||
|
|
|
||
|
|
session.run(configuration)
|
||
|
|
print("[NI] NISession running")
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Send the Configure-and-Start message with both the canonical (0x0B) and
|
||
|
|
/// alternate (0x05) bytes so the board accepts it regardless of firmware version.
|
||
|
|
private func sendConfigureAndStart(_ shareableData: Data) {
|
||
|
|
// Try 0x0B first (Apple spec canonical byte)
|
||
|
|
var payload = Data([MsgAppToAccessory.configureAndStart.rawValue])
|
||
|
|
payload.append(shareableData)
|
||
|
|
sendToAccessory?(payload)
|
||
|
|
print("[NI] → Sent Configure and Start (0x0B) \(payload.count) bytes")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - NISessionDelegate
|
||
|
|
extension NIManager: NISessionDelegate {
|
||
|
|
func session(_ session: NISession, didUpdate nearbyObjects: [NINearbyObject]) {
|
||
|
|
guard let object = nearbyObjects.first, let distance = object.distance else { return }
|
||
|
|
let timestamp = CACurrentMediaTime()
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
self.lastRange = distance
|
||
|
|
self.sessionState = .ranging
|
||
|
|
}
|
||
|
|
|
||
|
|
// Prefer camera-assisted world position: Apple's framework fuses UWB + ARKit VIO internally.
|
||
|
|
// worldTransform(for:) returns nil until camera assistance has converged.
|
||
|
|
if #available(iOS 16.0, *),
|
||
|
|
let worldTransform = session.worldTransform(for: object) {
|
||
|
|
let worldPos = simd_float3(worldTransform.columns.3.x,
|
||
|
|
worldTransform.columns.3.y,
|
||
|
|
worldTransform.columns.3.z)
|
||
|
|
onWorldPositionUpdate?(worldPos)
|
||
|
|
} else {
|
||
|
|
// Fall back to manual range+pose fusion via AnchorEstimator
|
||
|
|
onRangeUpdate?(distance, timestamp)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func session(_ session: NISession, didRemove nearbyObjects: [NINearbyObject], reason: NINearbyObject.RemovalReason) {
|
||
|
|
print("[NI] Objects removed reason=\(reason.rawValue) — restarting NI handshake in 1 s")
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
self.sessionState = .idle
|
||
|
|
self.lastRange = nil
|
||
|
|
}
|
||
|
|
// BLE is still connected — re-send 0x0A to restart the NI handshake
|
||
|
|
let work = DispatchWorkItem { [weak self] in
|
||
|
|
guard let self else { return }
|
||
|
|
DispatchQueue.main.async { self.sessionState = .idle }
|
||
|
|
self.start()
|
||
|
|
}
|
||
|
|
restartWorkItem = work
|
||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: work)
|
||
|
|
}
|
||
|
|
|
||
|
|
func session(_ session: NISession, didUpdateAlgorithmConvergence convergence: NIAlgorithmConvergence, for object: NINearbyObject?) {
|
||
|
|
print("[NI] Algorithm convergence: \(convergence.status)")
|
||
|
|
}
|
||
|
|
|
||
|
|
func session(_ session: NISession, didGenerateShareableConfigurationData shareableConfigurationData: Data, for object: NINearbyObject) {
|
||
|
|
print("[NI] didGenerateShareableConfigurationData — \(shareableConfigurationData.count) bytes")
|
||
|
|
sendConfigureAndStart(shareableConfigurationData)
|
||
|
|
}
|
||
|
|
|
||
|
|
func sessionWasSuspended(_ session: NISession) {
|
||
|
|
print("[NI] Session suspended")
|
||
|
|
DispatchQueue.main.async { self.sessionState = .idle }
|
||
|
|
}
|
||
|
|
|
||
|
|
func sessionSuspensionEnded(_ session: NISession) {
|
||
|
|
print("[NI] Suspension ended — rerunning configuration")
|
||
|
|
guard let config = session.configuration else { return }
|
||
|
|
session.run(config)
|
||
|
|
}
|
||
|
|
|
||
|
|
func session(_ session: NISession, didInvalidateWith error: Error) {
|
||
|
|
print("[NI] Session invalidated: \(error)")
|
||
|
|
DispatchQueue.main.async {
|
||
|
|
self.sessionState = .error(error.localizedDescription)
|
||
|
|
self.niSession = nil
|
||
|
|
}
|
||
|
|
let work = DispatchWorkItem { [weak self] in
|
||
|
|
guard let self else { return }
|
||
|
|
DispatchQueue.main.async { self.sessionState = .idle }
|
||
|
|
self.start()
|
||
|
|
}
|
||
|
|
restartWorkItem = work
|
||
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: work)
|
||
|
|
}
|
||
|
|
}
|