323 lines
11 KiB
Swift
323 lines
11 KiB
Swift
import Foundation
|
|
import CoreBluetooth
|
|
import WatchConnectivity
|
|
import Combine
|
|
|
|
// MARK: - BLE Service/Characteristic UUIDs (must match firmware)
|
|
|
|
enum BlackRoadBLEConstants {
|
|
static let serviceUUID = CBUUID(string: "00000001-0000-0000-0000-005242BB0000")
|
|
static let sensorUUID = CBUUID(string: "00000001-0000-0000-0000-005242BB0100")
|
|
static let aiStatusUUID = CBUUID(string: "00000001-0000-0000-0000-005242BB0200")
|
|
static let sysHealthUUID = CBUUID(string: "00000001-0000-0000-0000-005242BB0300")
|
|
static let notifUUID = CBUUID(string: "00000001-0000-0000-0000-005242BB0400")
|
|
}
|
|
|
|
// MARK: - Data Models
|
|
|
|
struct SensorData: Codable {
|
|
var temperature: Double // Celsius
|
|
var humidity: Double // %
|
|
var light: Int
|
|
var accelX: Double // g
|
|
var accelY: Double
|
|
var accelZ: Double
|
|
var batteryMV: Int
|
|
var uptimeSec: UInt32
|
|
|
|
static func decode(from data: Data) -> SensorData? {
|
|
guard data.count >= 20 else { return nil }
|
|
return SensorData(
|
|
temperature: Double(data.uint16(at: 0)) / 100.0,
|
|
humidity: Double(data.uint16(at: 2)) / 100.0,
|
|
light: Int(data.uint16(at: 4)),
|
|
accelX: Double(data.int16(at: 6)) / 1000.0,
|
|
accelY: Double(data.int16(at: 8)) / 1000.0,
|
|
accelZ: Double(data.int16(at: 10)) / 1000.0,
|
|
batteryMV: Int(data.uint16(at: 12)),
|
|
uptimeSec: data.uint32(at: 14)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct AIStatus: Codable {
|
|
var modelID: Int
|
|
var confidence: Int
|
|
var inferenceMS: Int
|
|
var totalInferences: UInt32
|
|
var npuLoad: Int
|
|
var npuTemp: Int
|
|
var classID: Int
|
|
|
|
static func decode(from data: Data) -> AIStatus? {
|
|
guard data.count >= 11 else { return nil }
|
|
return AIStatus(
|
|
modelID: Int(data[0]),
|
|
confidence: Int(data[1]),
|
|
inferenceMS: Int(data.uint16(at: 2)),
|
|
totalInferences: data.uint32(at: 4),
|
|
npuLoad: Int(data[8]),
|
|
npuTemp: Int(data[9]),
|
|
classID: Int(data[10])
|
|
)
|
|
}
|
|
}
|
|
|
|
struct SystemHealth: Codable {
|
|
var fleetOnline: Int
|
|
var fleetTotal: Int
|
|
var agentsActive: Int
|
|
var trafficGreen: Int
|
|
var trafficYellow: Int
|
|
var trafficRed: Int
|
|
var tasksPending: Int
|
|
var tasksDone: Int
|
|
var memoryEntries: UInt32
|
|
var reposCount: Int
|
|
var cfProjects: Int
|
|
var cpuLoad: Double
|
|
|
|
static func decode(from data: Data) -> SystemHealth? {
|
|
guard data.count >= 20 else { return nil }
|
|
return SystemHealth(
|
|
fleetOnline: Int(data[0]),
|
|
fleetTotal: Int(data[1]),
|
|
agentsActive: Int(data[2]),
|
|
trafficGreen: Int(data[3]),
|
|
trafficYellow: Int(data[4]),
|
|
trafficRed: Int(data[5]),
|
|
tasksPending: Int(data.uint16(at: 6)),
|
|
tasksDone: Int(data.uint16(at: 8)),
|
|
memoryEntries: data.uint32(at: 10),
|
|
reposCount: Int(data.uint16(at: 14)),
|
|
cfProjects: Int(data.uint16(at: 16)),
|
|
cpuLoad: Double(data.uint16(at: 18)) / 100.0
|
|
)
|
|
}
|
|
}
|
|
|
|
struct BRNotification: Codable {
|
|
enum NotificationType: Int, Codable {
|
|
case info = 0, warning = 1, critical = 2, deploy = 3
|
|
}
|
|
var type: NotificationType
|
|
var source: Int
|
|
var eventID: Int
|
|
var message: String
|
|
|
|
static func decode(from data: Data) -> BRNotification? {
|
|
guard data.count >= 4 else { return nil }
|
|
let msgData = data.subdata(in: 4..<min(20, data.count))
|
|
let message = String(data: msgData, encoding: .utf8)?
|
|
.trimmingCharacters(in: .controlCharacters) ?? ""
|
|
return BRNotification(
|
|
type: NotificationType(rawValue: Int(data[0])) ?? .info,
|
|
source: Int(data[1]),
|
|
eventID: Int(data.uint16(at: 2)),
|
|
message: message
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - BLE Manager
|
|
|
|
class BlackRoadBLEManager: NSObject, ObservableObject {
|
|
static let shared = BlackRoadBLEManager()
|
|
|
|
@Published var isConnected = false
|
|
@Published var isScanning = false
|
|
@Published var sensorData = SensorData(temperature: 0, humidity: 0, light: 0,
|
|
accelX: 0, accelY: 0, accelZ: 0,
|
|
batteryMV: 0, uptimeSec: 0)
|
|
@Published var aiStatus = AIStatus(modelID: 0, confidence: 0, inferenceMS: 0,
|
|
totalInferences: 0, npuLoad: 0, npuTemp: 0, classID: 0)
|
|
@Published var systemHealth = SystemHealth(fleetOnline: 0, fleetTotal: 0, agentsActive: 0,
|
|
trafficGreen: 0, trafficYellow: 0, trafficRed: 0,
|
|
tasksPending: 0, tasksDone: 0, memoryEntries: 0,
|
|
reposCount: 0, cfProjects: 0, cpuLoad: 0)
|
|
@Published var lastNotification: BRNotification?
|
|
@Published var deviceName: String = "Scanning..."
|
|
|
|
private var centralManager: CBCentralManager!
|
|
private var peripheral: CBPeripheral?
|
|
private var wcSession: WCSession?
|
|
|
|
override init() {
|
|
super.init()
|
|
centralManager = CBCentralManager(delegate: self, queue: .main)
|
|
setupWatchConnectivity()
|
|
}
|
|
|
|
func startScanning() {
|
|
guard centralManager.state == .poweredOn else { return }
|
|
isScanning = true
|
|
centralManager.scanForPeripherals(
|
|
withServices: [BlackRoadBLEConstants.serviceUUID],
|
|
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
|
)
|
|
}
|
|
|
|
func stopScanning() {
|
|
centralManager.stopScan()
|
|
isScanning = false
|
|
}
|
|
|
|
func disconnect() {
|
|
guard let peripheral = peripheral else { return }
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
|
|
// MARK: - Watch Connectivity
|
|
|
|
private func setupWatchConnectivity() {
|
|
guard WCSession.isSupported() else { return }
|
|
wcSession = WCSession.default
|
|
wcSession?.delegate = self
|
|
wcSession?.activate()
|
|
}
|
|
|
|
private func sendToWatch() {
|
|
guard let session = wcSession, session.isReachable else { return }
|
|
|
|
let encoder = JSONEncoder()
|
|
var context: [String: Any] = [:]
|
|
|
|
if let sensorJSON = try? encoder.encode(sensorData),
|
|
let sensorDict = try? JSONSerialization.jsonObject(with: sensorJSON) {
|
|
context["sensor"] = sensorDict
|
|
}
|
|
if let aiJSON = try? encoder.encode(aiStatus),
|
|
let aiDict = try? JSONSerialization.jsonObject(with: aiJSON) {
|
|
context["ai"] = aiDict
|
|
}
|
|
if let healthJSON = try? encoder.encode(systemHealth),
|
|
let healthDict = try? JSONSerialization.jsonObject(with: healthJSON) {
|
|
context["health"] = healthDict
|
|
}
|
|
context["timestamp"] = Date().timeIntervalSince1970
|
|
|
|
// Use application context for latest state (survives app close)
|
|
try? session.updateApplicationContext(context)
|
|
|
|
// Also send immediate message if watch is reachable
|
|
session.sendMessage(context, replyHandler: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - CBCentralManagerDelegate
|
|
|
|
extension BlackRoadBLEManager: CBCentralManagerDelegate {
|
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
if central.state == .poweredOn {
|
|
startScanning()
|
|
}
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
|
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
|
self.peripheral = peripheral
|
|
self.deviceName = peripheral.name ?? "BlackRoad-M1s"
|
|
stopScanning()
|
|
central.connect(peripheral, options: nil)
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
isConnected = true
|
|
peripheral.delegate = self
|
|
peripheral.discoverServices([BlackRoadBLEConstants.serviceUUID])
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
isConnected = false
|
|
// Auto-reconnect after 2 seconds
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
|
self?.startScanning()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CBPeripheralDelegate
|
|
|
|
extension BlackRoadBLEManager: CBPeripheralDelegate {
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
guard let services = peripheral.services else { return }
|
|
for service in services {
|
|
peripheral.discoverCharacteristics(nil, for: service)
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
guard let characteristics = service.characteristics else { return }
|
|
for char in characteristics {
|
|
// Subscribe to notifications on all characteristics
|
|
if char.properties.contains(.notify) {
|
|
peripheral.setNotifyValue(true, for: char)
|
|
}
|
|
// Also do initial read
|
|
if char.properties.contains(.read) {
|
|
peripheral.readValue(for: char)
|
|
}
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
guard let data = characteristic.value else { return }
|
|
|
|
switch characteristic.uuid {
|
|
case BlackRoadBLEConstants.sensorUUID:
|
|
if let sensor = SensorData.decode(from: data) {
|
|
sensorData = sensor
|
|
}
|
|
case BlackRoadBLEConstants.aiStatusUUID:
|
|
if let ai = AIStatus.decode(from: data) {
|
|
aiStatus = ai
|
|
}
|
|
case BlackRoadBLEConstants.sysHealthUUID:
|
|
if let health = SystemHealth.decode(from: data) {
|
|
systemHealth = health
|
|
}
|
|
case BlackRoadBLEConstants.notifUUID:
|
|
if let notif = BRNotification.decode(from: data) {
|
|
lastNotification = notif
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
// Forward to Apple Watch
|
|
sendToWatch()
|
|
}
|
|
}
|
|
|
|
// MARK: - WCSessionDelegate
|
|
|
|
extension BlackRoadBLEManager: WCSessionDelegate {
|
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
print("[BR] Watch session activated: \(activationState.rawValue)")
|
|
}
|
|
|
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
|
func sessionDidDeactivate(_ session: WCSession) {
|
|
session.activate()
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Extensions
|
|
|
|
extension Data {
|
|
func uint16(at offset: Int) -> UInt16 {
|
|
guard offset + 2 <= count else { return 0 }
|
|
return subdata(in: offset..<offset+2).withUnsafeBytes { $0.load(as: UInt16.self) }
|
|
}
|
|
|
|
func int16(at offset: Int) -> Int16 {
|
|
guard offset + 2 <= count else { return 0 }
|
|
return subdata(in: offset..<offset+2).withUnsafeBytes { $0.load(as: Int16.self) }
|
|
}
|
|
|
|
func uint32(at offset: Int) -> UInt32 {
|
|
guard offset + 4 <= count else { return 0 }
|
|
return subdata(in: offset..<offset+4).withUnsafeBytes { $0.load(as: UInt32.self) }
|
|
}
|
|
}
|