Initial commit — RoadCode import
This commit is contained in:
322
ios-app/BlackRoadWatch/BlackRoadBLE.swift
Normal file
322
ios-app/BlackRoadWatch/BlackRoadBLE.swift
Normal file
@@ -0,0 +1,322 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
276
ios-app/BlackRoadWatch/BlackRoadUDP.swift
Normal file
276
ios-app/BlackRoadWatch/BlackRoadUDP.swift
Normal file
@@ -0,0 +1,276 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import WatchConnectivity
|
||||
import Combine
|
||||
|
||||
// MARK: - UDP Listener for M1s Firmware Broadcasts
|
||||
|
||||
class BlackRoadUDPManager: NSObject, ObservableObject {
|
||||
static let shared = BlackRoadUDPManager()
|
||||
|
||||
@Published var isListening = false
|
||||
@Published var isConnected = false
|
||||
@Published var watchReachable = false
|
||||
@Published var sensorData: SensorData?
|
||||
@Published var aiStatus: AIStatus?
|
||||
@Published var systemHealth: SystemHealth?
|
||||
@Published var lastUpdate: Date?
|
||||
@Published var packetsReceived: UInt64 = 0
|
||||
@Published var sourceAddress: String = ""
|
||||
|
||||
private var listener: NWListener?
|
||||
private var wcSession: WCSession?
|
||||
private let udpPort: UInt16 = 8420
|
||||
private let queue = DispatchQueue(label: "io.blackroad.udp", qos: .userInteractive)
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setupWatchConnectivity()
|
||||
}
|
||||
|
||||
// MARK: - UDP Listener
|
||||
|
||||
func startListening() {
|
||||
guard listener == nil else { return }
|
||||
|
||||
do {
|
||||
let params = NWParameters.udp
|
||||
params.allowLocalEndpointReuse = true
|
||||
params.requiredInterfaceType = .wifi
|
||||
|
||||
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: udpPort)!)
|
||||
|
||||
listener?.stateUpdateHandler = { [weak self] state in
|
||||
DispatchQueue.main.async {
|
||||
switch state {
|
||||
case .ready:
|
||||
self?.isListening = true
|
||||
print("[BR] UDP listener ready on port \(self?.udpPort ?? 0)")
|
||||
case .failed(let error):
|
||||
self?.isListening = false
|
||||
print("[BR] UDP listener failed: \(error)")
|
||||
// Retry after delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
self?.stopListening()
|
||||
self?.startListening()
|
||||
}
|
||||
case .cancelled:
|
||||
self?.isListening = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.newConnectionHandler = { [weak self] connection in
|
||||
self?.handleConnection(connection)
|
||||
}
|
||||
|
||||
listener?.start(queue: queue)
|
||||
} catch {
|
||||
print("[BR] Failed to create UDP listener: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stopListening() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
DispatchQueue.main.async {
|
||||
self.isListening = false
|
||||
self.isConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
receiveData(on: connection)
|
||||
}
|
||||
|
||||
private func receiveData(on connection: NWConnection) {
|
||||
connection.receiveMessage { [weak self] data, context, isComplete, error in
|
||||
if let data = data, !data.isEmpty {
|
||||
self?.processPacket(data, from: connection)
|
||||
}
|
||||
|
||||
if error == nil {
|
||||
self?.receiveData(on: connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON Parsing
|
||||
|
||||
private func processPacket(_ data: Data, from connection: NWConnection) {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = json["type"] as? String, type == "br_watch" else {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract source IP
|
||||
if let endpoint = connection.currentPath?.remoteEndpoint,
|
||||
case let .hostPort(host, _) = endpoint {
|
||||
let addr = "\(host)"
|
||||
DispatchQueue.main.async { self.sourceAddress = addr }
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.packetsReceived += 1
|
||||
self.isConnected = true
|
||||
self.lastUpdate = Date()
|
||||
|
||||
// Parse sensor data
|
||||
if let sensorDict = json["sensor"] as? [String: Any] {
|
||||
self.sensorData = SensorData(
|
||||
temperature: sensorDict["temp"] as? Double ?? 0,
|
||||
humidity: sensorDict["hum"] as? Double ?? 0,
|
||||
light: 0,
|
||||
accelX: 0, accelY: 0, accelZ: 0,
|
||||
batteryMV: sensorDict["bat"] as? Int ?? 0,
|
||||
uptimeSec: UInt32(sensorDict["up"] as? Int ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
// Parse AI status
|
||||
if let aiDict = json["ai"] as? [String: Any] {
|
||||
self.aiStatus = AIStatus(
|
||||
modelID: 1,
|
||||
confidence: aiDict["conf"] as? Int ?? 0,
|
||||
inferenceMS: 0,
|
||||
totalInferences: UInt32(aiDict["infers"] as? Int ?? 0),
|
||||
npuLoad: aiDict["load"] as? Int ?? 0,
|
||||
npuTemp: aiDict["temp"] as? Int ?? 0,
|
||||
classID: 0
|
||||
)
|
||||
}
|
||||
|
||||
// Parse fleet/health data
|
||||
if let fleetDict = json["fleet"] as? [String: Any] {
|
||||
self.systemHealth = SystemHealth(
|
||||
fleetOnline: fleetDict["on"] as? Int ?? 0,
|
||||
fleetTotal: fleetDict["total"] as? Int ?? 0,
|
||||
agentsActive: fleetDict["agents"] as? Int ?? 0,
|
||||
trafficGreen: fleetDict["green"] as? Int ?? 0,
|
||||
trafficYellow: 0,
|
||||
trafficRed: 0,
|
||||
tasksPending: 0,
|
||||
tasksDone: fleetDict["tasks"] as? Int ?? 0,
|
||||
memoryEntries: UInt32(fleetDict["mem"] as? Int ?? 0),
|
||||
reposCount: fleetDict["repos"] as? Int ?? 0,
|
||||
cfProjects: fleetDict["cf"] as? Int ?? 0,
|
||||
cpuLoad: 0
|
||||
)
|
||||
}
|
||||
|
||||
// Forward to Apple Watch
|
||||
self.sendToWatch()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.activationState == .activated else { return }
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
var context: [String: Any] = [:]
|
||||
|
||||
if let sensor = sensorData,
|
||||
let sensorJSON = try? encoder.encode(sensor),
|
||||
let sensorDict = try? JSONSerialization.jsonObject(with: sensorJSON) {
|
||||
context["sensor"] = sensorDict
|
||||
}
|
||||
if let ai = aiStatus,
|
||||
let aiJSON = try? encoder.encode(ai),
|
||||
let aiDict = try? JSONSerialization.jsonObject(with: aiJSON) {
|
||||
context["ai"] = aiDict
|
||||
}
|
||||
if let health = systemHealth,
|
||||
let healthJSON = try? encoder.encode(health),
|
||||
let healthDict = try? JSONSerialization.jsonObject(with: healthJSON) {
|
||||
context["health"] = healthDict
|
||||
}
|
||||
context["timestamp"] = Date().timeIntervalSince1970
|
||||
|
||||
// Update application context (persists, latest state survives app close)
|
||||
try? session.updateApplicationContext(context)
|
||||
|
||||
// Also send live message if watch is reachable
|
||||
if session.isReachable {
|
||||
session.sendMessage(context, replyHandler: nil)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.watchReachable = session.isReachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension BlackRoadUDPManager: WCSessionDelegate {
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
print("[BR] Watch session activated: \(activationState.rawValue)")
|
||||
DispatchQueue.main.async {
|
||||
self.watchReachable = session.isReachable
|
||||
}
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.watchReachable = session.isReachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Data Models
|
||||
|
||||
struct SensorData: Codable {
|
||||
var temperature: Double
|
||||
var humidity: Double
|
||||
var light: Int
|
||||
var accelX: Double
|
||||
var accelY: Double
|
||||
var accelZ: Double
|
||||
var batteryMV: Int
|
||||
var uptimeSec: UInt32
|
||||
}
|
||||
|
||||
struct AIStatus: Codable {
|
||||
var modelID: Int
|
||||
var confidence: Int
|
||||
var inferenceMS: Int
|
||||
var totalInferences: UInt32
|
||||
var npuLoad: Int
|
||||
var npuTemp: Int
|
||||
var classID: Int
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
16
ios-app/BlackRoadWatch/BlackRoadWatchiOSApp.swift
Normal file
16
ios-app/BlackRoadWatch/BlackRoadWatchiOSApp.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct BlackRoadWatchiOSApp: App {
|
||||
@StateObject private var udpManager = BlackRoadUDPManager.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
iOSContentView()
|
||||
.environmentObject(udpManager)
|
||||
.onAppear {
|
||||
udpManager.startListening()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
218
ios-app/BlackRoadWatch/iOSContentView.swift
Normal file
218
ios-app/BlackRoadWatch/iOSContentView.swift
Normal file
@@ -0,0 +1,218 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - BlackRoad Brand Colors (iOS)
|
||||
|
||||
extension Color {
|
||||
static let brHotPink = Color(red: 1.0, green: 0.114, blue: 0.424)
|
||||
static let brAmber = Color(red: 0.961, green: 0.651, blue: 0.137)
|
||||
static let brElectricBlue = Color(red: 0.161, green: 0.475, blue: 1.0)
|
||||
static let brViolet = Color(red: 0.612, green: 0.153, blue: 0.690)
|
||||
}
|
||||
|
||||
// MARK: - iOS Bridge View
|
||||
|
||||
struct iOSContentView: View {
|
||||
@EnvironmentObject var udp: BlackRoadUDPManager
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Connection Status Card
|
||||
connectionCard
|
||||
|
||||
// Data Preview
|
||||
if udp.isConnected {
|
||||
sensorCard
|
||||
aiCard
|
||||
fleetCard
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color.black)
|
||||
.navigationTitle("BlackRoad Watch")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbarBackground(Color.black, for: .navigationBar)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
// MARK: - Connection Card
|
||||
|
||||
private var connectionCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("BLACKROAD")
|
||||
.font(.system(size: 13, weight: .black, design: .monospaced))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.brAmber, .brHotPink],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
Spacer()
|
||||
Text("BRIDGE")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
Divider().background(Color.gray.opacity(0.3))
|
||||
|
||||
// M1s Connection
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(udp.isConnected ? Color.green : Color.red)
|
||||
.frame(width: 10, height: 10)
|
||||
Text("M1s Dock")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
if udp.isConnected {
|
||||
Text(udp.sourceAddress)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
} else {
|
||||
Text(udp.isListening ? "Listening on :8420" : "Starting...")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Watch Connection
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(udp.watchReachable ? Color.green : Color.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
Text("Apple Watch")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(udp.watchReachable ? "Connected" : "Waiting")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
// Stats
|
||||
HStack {
|
||||
Label("\(udp.packetsReceived)", systemImage: "arrow.down.circle")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.brElectricBlue)
|
||||
Spacer()
|
||||
if let last = udp.lastUpdate {
|
||||
Text(last, style: .relative)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [.brAmber.opacity(0.5), .brHotPink.opacity(0.3)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Sensor Card
|
||||
|
||||
private var sensorCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("SENSORS", systemImage: "thermometer")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.brAmber)
|
||||
|
||||
if let s = udp.sensorData {
|
||||
dataRow("Temperature", String(format: "%.1f\u{00B0}C", s.temperature), .brAmber)
|
||||
dataRow("Humidity", String(format: "%.1f%%", s.humidity), .brElectricBlue)
|
||||
dataRow("Battery", "\(s.batteryMV)mV", s.batteryMV > 3500 ? .green : .red)
|
||||
dataRow("Uptime", formatUptime(s.uptimeSec), .gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
|
||||
}
|
||||
|
||||
// MARK: - AI Card
|
||||
|
||||
private var aiCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("NPU", systemImage: "brain")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.brViolet)
|
||||
|
||||
if let a = udp.aiStatus {
|
||||
dataRow("Load", "\(a.npuLoad)%", .brElectricBlue)
|
||||
dataRow("Temp", "\(a.npuTemp)\u{00B0}C", a.npuTemp > 70 ? .red : .brAmber)
|
||||
dataRow("Confidence", "\(a.confidence)%", .brHotPink)
|
||||
dataRow("Inferences", "\(a.totalInferences)", .brAmber)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
|
||||
}
|
||||
|
||||
// MARK: - Fleet Card
|
||||
|
||||
private var fleetCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("FLEET", systemImage: "server.rack")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.brHotPink)
|
||||
|
||||
if let h = udp.systemHealth {
|
||||
dataRow("Devices", "\(h.fleetOnline)/\(h.fleetTotal)", .brElectricBlue)
|
||||
dataRow("Agents", "\(h.agentsActive)", .brViolet)
|
||||
dataRow("Green", "\(h.trafficGreen)", .green)
|
||||
dataRow("Tasks Done", "\(h.tasksDone)", .green)
|
||||
dataRow("Repos", "\(h.reposCount)", .brAmber)
|
||||
dataRow("CF Projects", "\(h.cfProjects)", .brElectricBlue)
|
||||
dataRow("Memory", "\(h.memoryEntries)", .brViolet)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func dataRow(_ label: String, _ value: String, _ color: Color) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(color)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatUptime(_ seconds: UInt32) -> String {
|
||||
let h = seconds / 3600
|
||||
let m = (seconds % 3600) / 60
|
||||
let s = seconds % 60
|
||||
return String(format: "%02d:%02d:%02d", h, m, s)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
iOSContentView()
|
||||
.environmentObject(BlackRoadUDPManager.shared)
|
||||
}
|
||||
Reference in New Issue
Block a user