cue-ios: strip CoreBluetooth, replace with UDP/WiFi transport
This commit is contained in:
parent
d13909c400
commit
3f91159596
|
|
@ -1,6 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import Combine
|
|
||||||
|
|
||||||
enum Tab: String, CaseIterable, Identifiable {
|
enum Tab: String, CaseIterable, Identifiable {
|
||||||
case eis = "EIS"
|
case eis = "EIS"
|
||||||
|
|
@ -19,12 +18,13 @@ enum Tab: String, CaseIterable, Identifiable {
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class AppState {
|
final class AppState {
|
||||||
let ble: BLEManager
|
let transport: UDPManager
|
||||||
var tab: Tab = .eis
|
var tab: Tab = .eis
|
||||||
var status: String = "Disconnected"
|
var status: String = "Disconnected"
|
||||||
var bleConnected: Bool = false
|
|
||||||
var tempC: Float = 25.0
|
var tempC: Float = 25.0
|
||||||
|
|
||||||
|
var connected: Bool { transport.state == .connected }
|
||||||
|
|
||||||
// EIS
|
// EIS
|
||||||
var eisPoints: [EisPoint] = []
|
var eisPoints: [EisPoint] = []
|
||||||
var sweepTotal: UInt16 = 0
|
var sweepTotal: UInt16 = 0
|
||||||
|
|
@ -98,40 +98,17 @@ final class AppState {
|
||||||
var cleanV: String = "1200"
|
var cleanV: String = "1200"
|
||||||
var cleanDur: String = "30"
|
var cleanDur: String = "30"
|
||||||
|
|
||||||
// Temperature polling
|
|
||||||
private var tempTimer: Timer?
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
ble = BLEManager()
|
transport = UDPManager()
|
||||||
ble.setMessageHandler { [weak self] msg in
|
transport.setMessageHandler { [weak self] msg in
|
||||||
self?.handleMessage(msg)
|
self?.handleMessage(msg)
|
||||||
}
|
}
|
||||||
startTempPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BLE connection tracking
|
|
||||||
|
|
||||||
func updateConnectionState() {
|
|
||||||
let connected = ble.state == .connected
|
|
||||||
if connected != bleConnected {
|
|
||||||
bleConnected = connected
|
|
||||||
status = ble.state.rawValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Temperature polling
|
|
||||||
|
|
||||||
private func startTempPolling() {
|
|
||||||
tempTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
|
||||||
guard let self, self.bleConnected else { return }
|
|
||||||
self.send(buildSysexGetTemp())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Send helper
|
// MARK: - Send helper
|
||||||
|
|
||||||
func send(_ sysex: [UInt8]) {
|
func send(_ sysex: [UInt8]) {
|
||||||
ble.sendCommand(sysex)
|
transport.send(sysex)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message handler
|
// MARK: - Message handler
|
||||||
|
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
/// CoreBluetooth BLE MIDI connection manager.
|
|
||||||
/// Scans for "EIS4", connects, subscribes to MIDI notifications,
|
|
||||||
/// parses incoming BLE MIDI packets into EisMessage values.
|
|
||||||
|
|
||||||
import CoreBluetooth
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@Observable
|
|
||||||
final class BLEManager: NSObject {
|
|
||||||
|
|
||||||
nonisolated(unsafe) static let midiServiceUUID = CBUUID(string: "03B80E5A-EDE8-4B33-A751-6CE34EC4C700")
|
|
||||||
nonisolated(unsafe) static let midiCharUUID = CBUUID(string: "7772E5DB-3868-4112-A1A9-F2669D106BF3")
|
|
||||||
|
|
||||||
enum ConnectionState: String {
|
|
||||||
case disconnected = "Disconnected"
|
|
||||||
case scanning = "Scanning..."
|
|
||||||
case connecting = "Connecting..."
|
|
||||||
case connected = "Connected"
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DiscoveredDevice: Identifiable {
|
|
||||||
let id: UUID
|
|
||||||
let peripheral: CBPeripheral
|
|
||||||
let name: String
|
|
||||||
let rssi: Int
|
|
||||||
var serviceUUIDs: [CBUUID]
|
|
||||||
}
|
|
||||||
|
|
||||||
var state: ConnectionState = .disconnected
|
|
||||||
var lastMessage: EisMessage?
|
|
||||||
var discoveredDevices: [DiscoveredDevice] = []
|
|
||||||
|
|
||||||
private var centralManager: CBCentralManager!
|
|
||||||
private(set) var peripheral: CBPeripheral?
|
|
||||||
private var midiCharacteristic: CBCharacteristic?
|
|
||||||
private var onMessage: ((EisMessage) -> Void)?
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
centralManager = CBCentralManager(delegate: self, queue: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMessageHandler(_ handler: @escaping (EisMessage) -> Void) {
|
|
||||||
onMessage = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func startScanning() {
|
|
||||||
guard centralManager.state == .poweredOn else {
|
|
||||||
print("[BLE] can't scan, state: \(centralManager.state.rawValue)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
print("[BLE] starting scan (no filter)")
|
|
||||||
state = .scanning
|
|
||||||
discoveredDevices.removeAll()
|
|
||||||
centralManager.scanForPeripherals(
|
|
||||||
withServices: nil,
|
|
||||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopScanning() {
|
|
||||||
centralManager.stopScan()
|
|
||||||
if state == .scanning { state = .disconnected }
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectTo(_ device: DiscoveredDevice) {
|
|
||||||
centralManager.stopScan()
|
|
||||||
peripheral = device.peripheral
|
|
||||||
state = .connecting
|
|
||||||
print("[BLE] connecting to \(device.name)")
|
|
||||||
centralManager.connect(device.peripheral, options: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect() {
|
|
||||||
if let p = peripheral {
|
|
||||||
centralManager.cancelPeripheralConnection(p)
|
|
||||||
}
|
|
||||||
peripheral = nil
|
|
||||||
midiCharacteristic = nil
|
|
||||||
state = .disconnected
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendCommand(_ sysex: [UInt8]) {
|
|
||||||
guard let char = midiCharacteristic, let p = peripheral else { return }
|
|
||||||
let packet = Self.wrapBLEMIDI(sysex)
|
|
||||||
p.writeValue(packet, for: char, type: .withoutResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BLE MIDI packet handling
|
|
||||||
|
|
||||||
/// Wrap raw SysEx into a BLE MIDI packet.
|
|
||||||
/// Input: [F0, 7D, CMD, ...payload, F7]
|
|
||||||
/// Output: [0x80, 0x80, F0, 7D, CMD, ...payload, 0x80, F7]
|
|
||||||
static func wrapBLEMIDI(_ sysex: [UInt8]) -> Data {
|
|
||||||
var pkt: [UInt8] = [0x80, 0x80]
|
|
||||||
pkt.append(contentsOf: sysex.dropLast())
|
|
||||||
pkt.append(0x80)
|
|
||||||
pkt.append(0xF7)
|
|
||||||
return Data(pkt)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract SysEx payload from BLE MIDI notification.
|
|
||||||
/// Returns bytes between F0 and F7 exclusive, stripping timestamps.
|
|
||||||
/// Result: [7D, RSP_ID, ...data]
|
|
||||||
static func extractSysEx(from data: Data) -> [UInt8]? {
|
|
||||||
let bytes = Array(data)
|
|
||||||
guard bytes.count >= 5 else { return nil }
|
|
||||||
|
|
||||||
var i = 1 // skip header
|
|
||||||
while i < bytes.count {
|
|
||||||
let b = bytes[i]
|
|
||||||
if b == 0xF0 { break }
|
|
||||||
if b & 0x80 != 0 && b != 0xF7 { i += 1; continue }
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
guard i < bytes.count, bytes[i] == 0xF0 else { return nil }
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
var payload: [UInt8] = []
|
|
||||||
while i < bytes.count && bytes[i] != 0xF7 {
|
|
||||||
if bytes[i] & 0x80 != 0 { i += 1; continue }
|
|
||||||
payload.append(bytes[i])
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
guard !payload.isEmpty else { return nil }
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CBCentralManagerDelegate
|
|
||||||
|
|
||||||
extension BLEManager: CBCentralManagerDelegate {
|
|
||||||
|
|
||||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
||||||
print("[BLE] centralManager state: \(central.state.rawValue)")
|
|
||||||
if central.state == .poweredOn {
|
|
||||||
startScanning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func centralManager(
|
|
||||||
_ central: CBCentralManager,
|
|
||||||
didDiscover peripheral: CBPeripheral,
|
|
||||||
advertisementData: [String: Any],
|
|
||||||
rssi RSSI: NSNumber
|
|
||||||
) {
|
|
||||||
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown"
|
|
||||||
let svcUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
|
||||||
|
|
||||||
print("[BLE] found: \(name) rssi:\(RSSI) services:\(svcUUIDs)")
|
|
||||||
|
|
||||||
if discoveredDevices.contains(where: { $0.id == peripheral.identifier }) { return }
|
|
||||||
|
|
||||||
let device = DiscoveredDevice(
|
|
||||||
id: peripheral.identifier,
|
|
||||||
peripheral: peripheral,
|
|
||||||
name: name,
|
|
||||||
rssi: RSSI.intValue,
|
|
||||||
serviceUUIDs: svcUUIDs
|
|
||||||
)
|
|
||||||
discoveredDevices.append(device)
|
|
||||||
}
|
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
||||||
peripheral.delegate = self
|
|
||||||
peripheral.discoverServices([Self.midiServiceUUID])
|
|
||||||
}
|
|
||||||
|
|
||||||
func centralManager(
|
|
||||||
_ central: CBCentralManager,
|
|
||||||
didDisconnectPeripheral peripheral: CBPeripheral,
|
|
||||||
error: Error?
|
|
||||||
) {
|
|
||||||
self.peripheral = nil
|
|
||||||
self.midiCharacteristic = nil
|
|
||||||
state = .disconnected
|
|
||||||
startScanning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CBPeripheralDelegate
|
|
||||||
|
|
||||||
extension BLEManager: CBPeripheralDelegate {
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
||||||
guard let service = peripheral.services?.first(where: {
|
|
||||||
$0.uuid == Self.midiServiceUUID
|
|
||||||
}) else { return }
|
|
||||||
peripheral.discoverCharacteristics([Self.midiCharUUID], for: service)
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(
|
|
||||||
_ peripheral: CBPeripheral,
|
|
||||||
didDiscoverCharacteristicsFor service: CBService,
|
|
||||||
error: Error?
|
|
||||||
) {
|
|
||||||
guard let char = service.characteristics?.first(where: {
|
|
||||||
$0.uuid == Self.midiCharUUID
|
|
||||||
}) else { return }
|
|
||||||
midiCharacteristic = char
|
|
||||||
peripheral.setNotifyValue(true, for: char)
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(
|
|
||||||
_ peripheral: CBPeripheral,
|
|
||||||
didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
|
||||||
error: Error?
|
|
||||||
) {
|
|
||||||
if characteristic.isNotifying {
|
|
||||||
state = .connected
|
|
||||||
sendCommand(buildSysexGetConfig())
|
|
||||||
sendCommand(buildSysexGetCellK())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(
|
|
||||||
_ peripheral: CBPeripheral,
|
|
||||||
didUpdateValueFor characteristic: CBCharacteristic,
|
|
||||||
error: Error?
|
|
||||||
) {
|
|
||||||
guard let data = characteristic.value,
|
|
||||||
let sysex = Self.extractSysEx(from: data),
|
|
||||||
let msg = parseSysex(sysex) else { return }
|
|
||||||
lastMessage = msg
|
|
||||||
onMessage?(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,9 +7,6 @@ struct CueIOSApp: App {
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView(state: state)
|
ContentView(state: state)
|
||||||
.onReceive(Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()) { _ in
|
|
||||||
state.updateConnectionState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,8 @@
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>EIS4 uses Bluetooth to communicate with the impedance analyzer</string>
|
<string>Cue connects to the EIS4 impedance analyzer over WiFi</string>
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>bluetooth-central</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
/// UDP transport for EIS4 firmware communication.
|
||||||
|
/// Matches the desktop Cue protocol: raw SysEx frames over UDP port 5941.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class UDPManager: @unchecked Sendable {
|
||||||
|
|
||||||
|
enum ConnectionState: String {
|
||||||
|
case disconnected = "Disconnected"
|
||||||
|
case connecting = "Connecting..."
|
||||||
|
case connected = "Connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let defaultAddr = "192.168.4.1"
|
||||||
|
private static let defaultPort: UInt16 = 5941
|
||||||
|
private static let keepaliveInterval: TimeInterval = 5
|
||||||
|
private static let timeout: TimeInterval = 10
|
||||||
|
private static let addrKey = "eis4_udp_addr"
|
||||||
|
|
||||||
|
var state: ConnectionState = .disconnected
|
||||||
|
var address: String
|
||||||
|
var port: UInt16
|
||||||
|
|
||||||
|
private var connection: NWConnection?
|
||||||
|
private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated)
|
||||||
|
private var onMessage: ((EisMessage) -> Void)?
|
||||||
|
private var keepaliveTimer: Timer?
|
||||||
|
private var timeoutTimer: Timer?
|
||||||
|
private var lastReceived: Date = .distantPast
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let saved = UserDefaults.standard.string(forKey: Self.addrKey) ?? ""
|
||||||
|
if saved.isEmpty {
|
||||||
|
address = Self.defaultAddr
|
||||||
|
port = Self.defaultPort
|
||||||
|
} else {
|
||||||
|
let parts = saved.split(separator: ":")
|
||||||
|
address = String(parts[0])
|
||||||
|
port = parts.count > 1 ? UInt16(parts[1]) ?? Self.defaultPort : Self.defaultPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMessageHandler(_ handler: @escaping (EisMessage) -> Void) {
|
||||||
|
onMessage = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection
|
||||||
|
|
||||||
|
func connect() {
|
||||||
|
disconnect()
|
||||||
|
state = .connecting
|
||||||
|
|
||||||
|
let addrStr = "\(address):\(port)"
|
||||||
|
UserDefaults.standard.set(addrStr, forKey: Self.addrKey)
|
||||||
|
|
||||||
|
let host = NWEndpoint.Host(address)
|
||||||
|
let nwPort = NWEndpoint.Port(rawValue: port) ?? NWEndpoint.Port(rawValue: Self.defaultPort)!
|
||||||
|
let params = NWParameters.udp
|
||||||
|
params.allowLocalEndpointReuse = true
|
||||||
|
|
||||||
|
let conn = NWConnection(host: host, port: nwPort, using: params)
|
||||||
|
connection = conn
|
||||||
|
|
||||||
|
conn.stateUpdateHandler = { [weak self] newState in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.handleStateChange(newState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
stopTimers()
|
||||||
|
connection?.cancel()
|
||||||
|
connection = nil
|
||||||
|
state = .disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ sysex: [UInt8]) {
|
||||||
|
guard let conn = connection else { return }
|
||||||
|
let data = Data(sysex)
|
||||||
|
conn.send(content: data, completion: .contentProcessed({ _ in }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State handling
|
||||||
|
|
||||||
|
private func handleStateChange(_ newState: NWConnection.State) {
|
||||||
|
switch newState {
|
||||||
|
case .ready:
|
||||||
|
state = .connected
|
||||||
|
lastReceived = Date()
|
||||||
|
send(buildSysexGetTemp())
|
||||||
|
send(buildSysexGetConfig())
|
||||||
|
send(buildSysexGetCellK())
|
||||||
|
startTimers()
|
||||||
|
receiveLoop()
|
||||||
|
|
||||||
|
case .failed, .cancelled:
|
||||||
|
state = .disconnected
|
||||||
|
stopTimers()
|
||||||
|
|
||||||
|
case .waiting:
|
||||||
|
state = .connecting
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Receive
|
||||||
|
|
||||||
|
private func receiveLoop() {
|
||||||
|
guard let conn = connection else { return }
|
||||||
|
conn.receiveMessage { [weak self] content, _, _, error in
|
||||||
|
guard let self else { return }
|
||||||
|
if error != nil {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if self.state == .connected {
|
||||||
|
self.state = .disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = content, !data.isEmpty else {
|
||||||
|
self.receiveLoop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.lastReceived = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
let frames = Self.extractSysExFrames(data)
|
||||||
|
for frame in frames {
|
||||||
|
if let msg = parseSysex(frame) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onMessage?(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.receiveLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract SysEx payloads from a UDP datagram.
|
||||||
|
/// Scans for F0..F7 boundaries. Returns inner bytes (between F0 and F7, exclusive).
|
||||||
|
static func extractSysExFrames(_ data: Data) -> [[UInt8]] {
|
||||||
|
let bytes = Array(data)
|
||||||
|
var frames: [[UInt8]] = []
|
||||||
|
var i = 0
|
||||||
|
while i < bytes.count {
|
||||||
|
if bytes[i] == 0xF0 {
|
||||||
|
if let end = bytes[i...].firstIndex(of: 0xF7) {
|
||||||
|
let payload = Array(bytes[(i + 1)..<end])
|
||||||
|
if !payload.isEmpty {
|
||||||
|
frames.append(payload)
|
||||||
|
}
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timers
|
||||||
|
|
||||||
|
private func startTimers() {
|
||||||
|
keepaliveTimer = Timer.scheduledTimer(withTimeInterval: Self.keepaliveInterval, repeats: true) { [weak self] _ in
|
||||||
|
self?.send(buildSysexGetTemp())
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
||||||
|
guard let self, self.state == .connected else { return }
|
||||||
|
if Date().timeIntervalSince(self.lastReceived) > Self.timeout {
|
||||||
|
self.state = .disconnected
|
||||||
|
self.stopTimers()
|
||||||
|
self.connection?.cancel()
|
||||||
|
self.connection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimers() {
|
||||||
|
keepaliveTimer?.invalidate()
|
||||||
|
keepaliveTimer = nil
|
||||||
|
timeoutTimer?.invalidate()
|
||||||
|
timeoutTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ struct StatusBar: View {
|
||||||
|
|
||||||
private var connectionIndicator: some View {
|
private var connectionIndicator: some View {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(state.bleConnected ? Color.green : Color.red)
|
.fill(state.connected ? Color.green : Color.red)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,38 @@ import SwiftUI
|
||||||
|
|
||||||
struct ConnectionView: View {
|
struct ConnectionView: View {
|
||||||
var state: AppState
|
var state: AppState
|
||||||
|
@State private var addressField: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
Divider()
|
Divider()
|
||||||
deviceList
|
connectionForm
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.navigationTitle("Connection")
|
.navigationTitle("Connection")
|
||||||
|
.onAppear { addressField = state.transport.address }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(statusColor)
|
.fill(statusColor)
|
||||||
.frame(width: 10, height: 10)
|
.frame(width: 10, height: 10)
|
||||||
Text(state.ble.state.rawValue)
|
Text(state.transport.state.rawValue)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Spacer()
|
Spacer()
|
||||||
if state.ble.state == .connected {
|
if state.transport.state == .connected {
|
||||||
Button("Disconnect") { state.ble.disconnect() }
|
Button("Disconnect") { state.transport.disconnect() }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
} else if state.ble.state == .scanning {
|
} else if state.transport.state == .connecting {
|
||||||
Button("Stop") { state.ble.stopScanning() }
|
ProgressView()
|
||||||
.buttonStyle(.bordered)
|
.controlSize(.small)
|
||||||
} else {
|
} else {
|
||||||
Button("Scan") { state.ble.startScanning() }
|
Button("Connect") { connectToDevice() }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -36,52 +41,49 @@ struct ConnectionView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusColor: Color {
|
private var statusColor: Color {
|
||||||
switch state.ble.state {
|
switch state.transport.state {
|
||||||
case .connected: .green
|
case .connected: .green
|
||||||
case .scanning: .orange
|
|
||||||
case .connecting: .yellow
|
case .connecting: .yellow
|
||||||
case .disconnected: .red
|
case .disconnected: .red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var deviceList: some View {
|
// MARK: - Connection form
|
||||||
List {
|
|
||||||
if state.ble.discoveredDevices.isEmpty && state.ble.state == .scanning {
|
private var connectionForm: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Firmware Address")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
TextField("192.168.4.1", text: $addressField)
|
||||||
Text("Scanning for devices...")
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.keyboardType(.numbersAndPunctuation)
|
||||||
|
Text(":\(state.transport.port)")
|
||||||
|
.font(.subheadline.monospacedDigit())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(state.ble.discoveredDevices) { device in
|
Button("Connect") { connectToDevice() }
|
||||||
Button {
|
.buttonStyle(.borderedProminent)
|
||||||
state.ble.connectTo(device)
|
.disabled(state.transport.state == .connecting)
|
||||||
} label: {
|
|
||||||
HStack {
|
Text("Connect to the EIS4 WiFi network, then tap Connect.")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(device.name)
|
|
||||||
.font(.body.weight(.medium))
|
|
||||||
if !device.serviceUUIDs.isEmpty {
|
|
||||||
Text(device.serviceUUIDs.map(\.uuidString).joined(separator: ", "))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Text("\(device.rssi) dBm")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
if device.name == "EIS4" {
|
|
||||||
Image(systemName: "star.fill")
|
|
||||||
.foregroundStyle(.yellow)
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func connectToDevice() {
|
||||||
|
let addr = addressField.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !addr.isEmpty {
|
||||||
|
state.transport.address = addr
|
||||||
}
|
}
|
||||||
.disabled(state.ble.state == .connecting)
|
state.transport.connect()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue