Add USB auto-probing to detect VU meter hardware
Features: - Auto-probe scans all USB serial ports to find VU meter - Tests multiple baud rates (115200, 9600, 57600, 38400, 19200) - Tests all protocols (Raw, Text, JSON, VU-Server) - Detects response from hardware to confirm connection - Known USB device detection (CH340, CP210x, FTDI, Arduino, etc.) - USB Vendor/Product ID display in port selection - Quick Connect button for instant auto-connection - Progress bar and status during probing - Probe results display for debugging USB detection: - Reads USB idVendor/idProduct from IOKit registry - Marks known VU meter devices with star icon - Auto-selects detected VU meter port
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
// AudioVUMeter
|
// AudioVUMeter
|
||||||
//
|
//
|
||||||
// Hardware configuration and monitoring view for physical VU meters
|
// Hardware configuration and monitoring view for physical VU meters
|
||||||
|
// Includes auto-probe functionality to detect connected hardware
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -24,15 +25,26 @@ struct HardwarePanelView: View {
|
|||||||
// Connection status
|
// Connection status
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(serialManager.isConnected ? Color.green : Color.red)
|
.fill(statusColor)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
|
|
||||||
Text(serialManager.isConnected ? "CONNECTED" : "DISCONNECTED")
|
Text(statusText)
|
||||||
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(serialManager.isConnected ? .green : .red)
|
.foregroundColor(statusColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Probing progress
|
||||||
|
if serialManager.isProbing {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView(value: serialManager.probeProgress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
|
||||||
|
Text(serialManager.probeStatus)
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// 4 Physical Dial Indicators
|
// 4 Physical Dial Indicators
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
ForEach(0..<4) { index in
|
ForEach(0..<4) { index in
|
||||||
@@ -44,8 +56,28 @@ struct HardwarePanelView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Quick connect button
|
// Buttons
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
// Auto-probe button
|
||||||
|
Button(action: {
|
||||||
|
if serialManager.isProbing {
|
||||||
|
serialManager.stopAutoProbe()
|
||||||
|
} else {
|
||||||
|
serialManager.startAutoProbe()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: serialManager.isProbing ? "stop.fill" : "magnifyingglass")
|
||||||
|
Text(serialManager.isProbing ? "Stop" : "Auto-Find")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(ProbeButtonStyle(isProbing: serialManager.isProbing))
|
||||||
|
.disabled(serialManager.isConnected)
|
||||||
|
|
||||||
|
// Connect button
|
||||||
Button(action: {
|
Button(action: {
|
||||||
serialManager.toggleConnection()
|
serialManager.toggleConnection()
|
||||||
}) {
|
}) {
|
||||||
@@ -56,8 +88,10 @@ struct HardwarePanelView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(HardwareButtonStyle(isConnected: serialManager.isConnected))
|
.buttonStyle(HardwareButtonStyle(isConnected: serialManager.isConnected))
|
||||||
|
.disabled(serialManager.isProbing)
|
||||||
|
}
|
||||||
|
|
||||||
// Stats
|
// Stats / Device info
|
||||||
if serialManager.isConnected {
|
if serialManager.isConnected {
|
||||||
HStack {
|
HStack {
|
||||||
Text("TX: \(formatBytes(serialManager.bytesSent))")
|
Text("TX: \(formatBytes(serialManager.bytesSent))")
|
||||||
@@ -70,6 +104,24 @@ struct HardwarePanelView: View {
|
|||||||
.font(.system(size: 9, design: .monospaced))
|
.font(.system(size: 9, design: .monospaced))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
|
} else if let detected = serialManager.detectedDevice {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
|
||||||
|
Text("Found: \(detected.name)")
|
||||||
|
.font(.system(size: 9, design: .monospaced))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let vid = detected.vendorID, let pid = detected.productID {
|
||||||
|
Text(String(format: "%04X:%04X", vid, pid))
|
||||||
|
.font(.system(size: 8, design: .monospaced))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -78,12 +130,30 @@ struct HardwarePanelView: View {
|
|||||||
.fill(Color.black.opacity(0.3))
|
.fill(Color.black.opacity(0.3))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(serialManager.isConnected ? Color.green.opacity(0.3) : Color.clear, lineWidth: 1)
|
.stroke(borderColor, lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
if serialManager.isProbing { return .orange }
|
||||||
|
if serialManager.isConnected { return .green }
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusText: String {
|
||||||
|
if serialManager.isProbing { return "PROBING" }
|
||||||
|
if serialManager.isConnected { return "CONNECTED" }
|
||||||
|
return "DISCONNECTED"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var borderColor: Color {
|
||||||
|
if serialManager.isProbing { return .orange.opacity(0.3) }
|
||||||
|
if serialManager.isConnected { return .green.opacity(0.3) }
|
||||||
|
return .clear
|
||||||
|
}
|
||||||
|
|
||||||
private func shortChannelName(_ channel: DialChannel) -> String {
|
private func shortChannelName(_ channel: DialChannel) -> String {
|
||||||
switch channel {
|
switch channel {
|
||||||
case .audioLeft: return "L"
|
case .audioLeft: return "L"
|
||||||
@@ -178,21 +248,90 @@ struct HardwareSettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
// Connection Section
|
// Auto-Probe Section
|
||||||
Section("Serial Connection") {
|
Section("Auto-Detect Hardware") {
|
||||||
// Port selection
|
|
||||||
HStack {
|
HStack {
|
||||||
Picker("Port", selection: $serialManager.selectedPortPath) {
|
Button(action: {
|
||||||
Text("Select Port...").tag("")
|
if serialManager.isProbing {
|
||||||
ForEach(serialManager.availablePorts) { port in
|
serialManager.stopAutoProbe()
|
||||||
Text(port.name).tag(port.path)
|
} else {
|
||||||
|
serialManager.startAutoProbe()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: serialManager.isProbing ? "stop.fill" : "magnifyingglass.circle.fill")
|
||||||
|
Text(serialManager.isProbing ? "Stop Probing" : "Auto-Detect VU Meter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(serialManager.isConnected)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Quick Connect") {
|
||||||
|
serialManager.autoConnect()
|
||||||
|
}
|
||||||
|
.disabled(serialManager.isConnected || serialManager.isProbing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if serialManager.isProbing {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ProgressView(value: serialManager.probeProgress) {
|
||||||
|
Text(serialManager.probeStatus)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let detected = serialManager.detectedDevice {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Detected: \(detected.name)")
|
||||||
|
.font(.headline)
|
||||||
|
if let vid = detected.vendorID, let pid = detected.productID {
|
||||||
|
Text(String(format: "USB ID: %04X:%04X", vid, pid))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection Section
|
||||||
|
Section("Serial Connection") {
|
||||||
|
// Port selection with USB info
|
||||||
|
Picker("Port", selection: $serialManager.selectedPortPath) {
|
||||||
|
Text("Select Port...").tag("")
|
||||||
|
ForEach(serialManager.availablePorts) { port in
|
||||||
|
HStack {
|
||||||
|
if port.isVUMeter {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
}
|
||||||
|
Text(port.name)
|
||||||
|
if let vid = port.vendorID, let pid = port.productID {
|
||||||
|
Text(String(format: "(%04X:%04X)", vid, pid))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tag(port.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
Button(action: { serialManager.refreshPorts() }) {
|
Button(action: { serialManager.refreshPorts() }) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(serialManager.availablePorts.count) ports found")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baud rate
|
// Baud rate
|
||||||
@@ -217,6 +356,7 @@ struct HardwareSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(serialManager.isConnected ? .red : .green)
|
.foregroundColor(serialManager.isConnected ? .red : .green)
|
||||||
|
.disabled(serialManager.isProbing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dial Configuration Section
|
// Dial Configuration Section
|
||||||
@@ -261,6 +401,36 @@ struct HardwareSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Probe Results (for debugging)
|
||||||
|
if !serialManager.probeResults.isEmpty {
|
||||||
|
Section("Probe Results") {
|
||||||
|
ForEach(serialManager.probeResults.indices, id: \.self) { index in
|
||||||
|
let result = serialManager.probeResults[index]
|
||||||
|
HStack {
|
||||||
|
Image(systemName: result.success ? "checkmark.circle" : "xmark.circle")
|
||||||
|
.foregroundColor(result.success ? .green : .red)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(result.port.name)
|
||||||
|
.font(.caption)
|
||||||
|
Text("\(result.baudRate) baud - \(result.protocol_.rawValue)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let response = result.response {
|
||||||
|
Text(response.prefix(20) + "...")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Clear Results") {
|
||||||
|
serialManager.probeResults.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Protocol Info
|
// Protocol Info
|
||||||
Section("Protocol Reference") {
|
Section("Protocol Reference") {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -313,7 +483,7 @@ struct DialConfigRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hardware Button Style
|
// MARK: - Button Styles
|
||||||
struct HardwareButtonStyle: ButtonStyle {
|
struct HardwareButtonStyle: ButtonStyle {
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
|
|
||||||
@@ -330,9 +500,25 @@ struct HardwareButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ProbeButtonStyle: ButtonStyle {
|
||||||
|
let isProbing: Bool
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(isProbing ? Color.orange.opacity(0.7) : Color.blue.opacity(0.7))
|
||||||
|
.opacity(configuration.isPressed ? 0.6 : 1.0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
HardwareSettingsView()
|
HardwareSettingsView()
|
||||||
.environmentObject(SerialManager())
|
.environmentObject(SerialManager())
|
||||||
.frame(width: 450, height: 600)
|
.frame(width: 500, height: 700)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
//
|
//
|
||||||
// Serial communication manager for physical VU meter hardware
|
// Serial communication manager for physical VU meter hardware
|
||||||
// Supports multiple protocols: Raw bytes, Text commands, JSON, VU-Server compatible
|
// Supports multiple protocols: Raw bytes, Text commands, JSON, VU-Server compatible
|
||||||
|
// Includes auto-probing to find connected VU meter hardware
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import IOKit
|
import IOKit
|
||||||
import IOKit.serial
|
import IOKit.serial
|
||||||
|
import IOKit.usb
|
||||||
|
|
||||||
/// Protocol format for serial communication
|
/// Protocol format for serial communication
|
||||||
enum SerialProtocol: String, CaseIterable, Identifiable {
|
enum SerialProtocol: String, CaseIterable, Identifiable {
|
||||||
@@ -18,13 +20,50 @@ enum SerialProtocol: String, CaseIterable, Identifiable {
|
|||||||
case vuServer = "VU-Server Compatible"
|
case vuServer = "VU-Server Compatible"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Probe command for this protocol
|
||||||
|
var probeCommand: Data {
|
||||||
|
switch self {
|
||||||
|
case .rawBytes:
|
||||||
|
// Send test pattern
|
||||||
|
return Data([0xAA, 0x00, 0x00, 0x00, 0x00, 0x55])
|
||||||
|
case .textCommand:
|
||||||
|
return "PING\n".data(using: .utf8)!
|
||||||
|
case .json:
|
||||||
|
return "{\"cmd\":\"ping\"}\n".data(using: .utf8)!
|
||||||
|
case .vuServer:
|
||||||
|
return "?\n".data(using: .utf8)! // Query command
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a serial port device
|
/// Represents a serial port device with extended info
|
||||||
struct SerialPort: Identifiable, Hashable {
|
struct SerialPort: Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let path: String
|
let path: String
|
||||||
let name: String
|
let name: String
|
||||||
|
let vendorID: Int?
|
||||||
|
let productID: Int?
|
||||||
|
let isVUMeter: Bool // Detected as VU meter
|
||||||
|
|
||||||
|
init(path: String, name: String, vendorID: Int? = nil, productID: Int? = nil, isVUMeter: Bool = false) {
|
||||||
|
self.id = path
|
||||||
|
self.path = path
|
||||||
|
self.name = name
|
||||||
|
self.vendorID = vendorID
|
||||||
|
self.productID = productID
|
||||||
|
self.isVUMeter = isVUMeter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe result for a serial port
|
||||||
|
struct ProbeResult {
|
||||||
|
let port: SerialPort
|
||||||
|
let protocol_: SerialProtocol
|
||||||
|
let baudRate: Int
|
||||||
|
let success: Bool
|
||||||
|
let response: String?
|
||||||
|
let responseTime: TimeInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel assignment for physical VU meters
|
/// Channel assignment for physical VU meters
|
||||||
@@ -60,7 +99,7 @@ struct DialConfig: Identifiable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serial communication manager
|
/// Serial communication manager with auto-probing
|
||||||
class SerialManager: ObservableObject {
|
class SerialManager: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@@ -73,6 +112,13 @@ class SerialManager: ObservableObject {
|
|||||||
@Published var lastError: String?
|
@Published var lastError: String?
|
||||||
@Published var bytesSent: UInt64 = 0
|
@Published var bytesSent: UInt64 = 0
|
||||||
|
|
||||||
|
// Auto-probe state
|
||||||
|
@Published var isProbing = false
|
||||||
|
@Published var probeProgress: Double = 0
|
||||||
|
@Published var probeStatus: String = ""
|
||||||
|
@Published var detectedDevice: SerialPort?
|
||||||
|
@Published var probeResults: [ProbeResult] = []
|
||||||
|
|
||||||
// Current dial values (0-255)
|
// Current dial values (0-255)
|
||||||
@Published var dialValues: [Int] = [0, 0, 0, 0]
|
@Published var dialValues: [Int] = [0, 0, 0, 0]
|
||||||
|
|
||||||
@@ -80,12 +126,25 @@ class SerialManager: ObservableObject {
|
|||||||
|
|
||||||
private var fileDescriptor: Int32 = -1
|
private var fileDescriptor: Int32 = -1
|
||||||
private var writeQueue = DispatchQueue(label: "serial.write", qos: .userInteractive)
|
private var writeQueue = DispatchQueue(label: "serial.write", qos: .userInteractive)
|
||||||
|
private var probeQueue = DispatchQueue(label: "serial.probe", qos: .userInitiated)
|
||||||
private var updateTimer: Timer?
|
private var updateTimer: Timer?
|
||||||
private let updateInterval: TimeInterval = 1.0 / 30.0 // 30 Hz update rate
|
private let updateInterval: TimeInterval = 1.0 / 30.0 // 30 Hz update rate
|
||||||
|
|
||||||
// Smoothed values for each dial
|
// Smoothed values for each dial
|
||||||
private var smoothedValues: [Double] = [0, 0, 0, 0]
|
private var smoothedValues: [Double] = [0, 0, 0, 0]
|
||||||
|
|
||||||
|
// Known VU meter USB identifiers
|
||||||
|
private let knownVUMeterDevices: [(vendorID: Int, productID: Int, name: String)] = [
|
||||||
|
(0x1A86, 0x7523, "CH340 Serial"), // Common CH340 USB-Serial
|
||||||
|
(0x10C4, 0xEA60, "CP210x Serial"), // Silicon Labs CP210x
|
||||||
|
(0x0403, 0x6001, "FTDI Serial"), // FTDI FT232
|
||||||
|
(0x0403, 0x6015, "FTDI FT231X"), // FTDI FT231X
|
||||||
|
(0x2341, 0x0043, "Arduino Uno"), // Arduino Uno
|
||||||
|
(0x2341, 0x0001, "Arduino Mega"), // Arduino Mega
|
||||||
|
(0x1B4F, 0x9206, "SparkFun Pro Micro"), // SparkFun
|
||||||
|
(0x239A, 0x8014, "Adafruit Feather"), // Adafruit
|
||||||
|
]
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -106,18 +165,20 @@ class SerialManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Port Management
|
// MARK: - Port Management
|
||||||
|
|
||||||
/// Refresh list of available serial ports
|
/// Refresh list of available serial ports with USB info
|
||||||
func refreshPorts() {
|
func refreshPorts() {
|
||||||
availablePorts = getSerialPorts()
|
availablePorts = getSerialPortsWithUSBInfo()
|
||||||
|
|
||||||
// Auto-select first port if none selected
|
// Auto-select VU meter if found
|
||||||
if selectedPortPath.isEmpty, let firstPort = availablePorts.first {
|
if let vuMeter = availablePorts.first(where: { $0.isVUMeter }) {
|
||||||
|
selectedPortPath = vuMeter.path
|
||||||
|
} else if selectedPortPath.isEmpty, let firstPort = availablePorts.first {
|
||||||
selectedPortPath = firstPort.path
|
selectedPortPath = firstPort.path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all available serial ports
|
/// Get all available serial ports with USB vendor/product info
|
||||||
private func getSerialPorts() -> [SerialPort] {
|
private func getSerialPortsWithUSBInfo() -> [SerialPort] {
|
||||||
var ports: [SerialPort] = []
|
var ports: [SerialPort] = []
|
||||||
|
|
||||||
var iterator: io_iterator_t = 0
|
var iterator: io_iterator_t = 0
|
||||||
@@ -141,12 +202,53 @@ class SerialManager: ObservableObject {
|
|||||||
0
|
0
|
||||||
)?.takeRetainedValue() as? String else { continue }
|
)?.takeRetainedValue() as? String else { continue }
|
||||||
|
|
||||||
|
// Filter for cu.* devices (not tty.*)
|
||||||
|
guard pathKey.contains("cu.") else { continue }
|
||||||
|
|
||||||
// Get device name
|
// Get device name
|
||||||
var name = pathKey.components(separatedBy: "/").last ?? "Unknown"
|
var name = pathKey.components(separatedBy: "/").last ?? "Unknown"
|
||||||
|
|
||||||
// Try to get a better name from USB info
|
// Try to get USB info by traversing the registry
|
||||||
|
var vendorID: Int?
|
||||||
|
var productID: Int?
|
||||||
|
var isVUMeter = false
|
||||||
|
|
||||||
|
// Walk up the registry to find USB device info
|
||||||
|
var parent: io_object_t = 0
|
||||||
|
var current = service
|
||||||
|
IOObjectRetain(current)
|
||||||
|
|
||||||
|
for _ in 0..<10 { // Max depth
|
||||||
|
if IORegistryEntryGetParentEntry(current, kIOServicePlane, &parent) != KERN_SUCCESS {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
IOObjectRelease(current)
|
||||||
|
current = parent
|
||||||
|
|
||||||
|
// Try to get vendor ID
|
||||||
|
if let vid = IORegistryEntryCreateCFProperty(
|
||||||
|
current,
|
||||||
|
"idVendor" as CFString,
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
0
|
||||||
|
)?.takeRetainedValue() as? Int {
|
||||||
|
vendorID = vid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get product ID
|
||||||
|
if let pid = IORegistryEntryCreateCFProperty(
|
||||||
|
current,
|
||||||
|
"idProduct" as CFString,
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
0
|
||||||
|
)?.takeRetainedValue() as? Int {
|
||||||
|
productID = pid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get USB product name
|
||||||
if let usbName = IORegistryEntryCreateCFProperty(
|
if let usbName = IORegistryEntryCreateCFProperty(
|
||||||
service,
|
current,
|
||||||
"USB Product Name" as CFString,
|
"USB Product Name" as CFString,
|
||||||
kCFAllocatorDefault,
|
kCFAllocatorDefault,
|
||||||
0
|
0
|
||||||
@@ -154,18 +256,226 @@ class SerialManager: ObservableObject {
|
|||||||
name = usbName
|
name = usbName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter for common serial devices
|
if vendorID != nil && productID != nil {
|
||||||
if pathKey.contains("cu.") {
|
break
|
||||||
ports.append(SerialPort(
|
}
|
||||||
id: pathKey,
|
}
|
||||||
path: pathKey,
|
IOObjectRelease(current)
|
||||||
name: name
|
|
||||||
))
|
// Check if this is a known VU meter device
|
||||||
|
if let vid = vendorID, let pid = productID {
|
||||||
|
isVUMeter = knownVUMeterDevices.contains { $0.vendorID == vid && $0.productID == pid }
|
||||||
|
|
||||||
|
// Also check name for VU-related keywords
|
||||||
|
let lowerName = name.lowercased()
|
||||||
|
if lowerName.contains("vu") || lowerName.contains("dial") || lowerName.contains("meter") {
|
||||||
|
isVUMeter = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ports.append(SerialPort(
|
||||||
|
path: pathKey,
|
||||||
|
name: name,
|
||||||
|
vendorID: vendorID,
|
||||||
|
productID: productID,
|
||||||
|
isVUMeter: isVUMeter
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
IOObjectRelease(iterator)
|
IOObjectRelease(iterator)
|
||||||
return ports
|
|
||||||
|
// Sort: VU meters first, then by name
|
||||||
|
return ports.sorted { ($0.isVUMeter ? 0 : 1, $0.name) < ($1.isVUMeter ? 0 : 1, $1.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auto-Probing
|
||||||
|
|
||||||
|
/// Auto-probe all ports to find VU meter hardware
|
||||||
|
func startAutoProbe() {
|
||||||
|
guard !isProbing else { return }
|
||||||
|
|
||||||
|
isProbing = true
|
||||||
|
probeProgress = 0
|
||||||
|
probeStatus = "Starting auto-probe..."
|
||||||
|
probeResults = []
|
||||||
|
detectedDevice = nil
|
||||||
|
|
||||||
|
probeQueue.async { [weak self] in
|
||||||
|
self?.performAutoProbe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop auto-probing
|
||||||
|
func stopAutoProbe() {
|
||||||
|
isProbing = false
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.probeStatus = "Probe cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the actual auto-probe
|
||||||
|
private func performAutoProbe() {
|
||||||
|
let ports = availablePorts
|
||||||
|
let baudRates = [115200, 9600, 57600, 38400, 19200] // Most common first
|
||||||
|
let protocols = SerialProtocol.allCases
|
||||||
|
|
||||||
|
let totalSteps = Double(ports.count * baudRates.count * protocols.count)
|
||||||
|
var currentStep = 0
|
||||||
|
|
||||||
|
var bestResult: ProbeResult?
|
||||||
|
|
||||||
|
for port in ports {
|
||||||
|
guard isProbing else { break }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.probeStatus = "Probing: \(port.name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
for baud in baudRates {
|
||||||
|
guard isProbing else { break }
|
||||||
|
|
||||||
|
for proto in protocols {
|
||||||
|
guard isProbing else { break }
|
||||||
|
|
||||||
|
currentStep += 1
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.probeProgress = Double(currentStep) / totalSteps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to probe this combination
|
||||||
|
if let result = probePort(port: port, baudRate: baud, protocol_: proto) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.probeResults.append(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.success {
|
||||||
|
// Found a working device!
|
||||||
|
if bestResult == nil || result.responseTime < bestResult!.responseTime {
|
||||||
|
bestResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got a response, this is very likely the device
|
||||||
|
if result.response != nil {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.detectedDevice = port
|
||||||
|
self.selectedPortPath = port.path
|
||||||
|
self.selectedProtocol = proto
|
||||||
|
self.baudRate = baud
|
||||||
|
self.probeStatus = "Found VU Meter: \(port.name)"
|
||||||
|
self.isProbing = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probing complete
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isProbing = false
|
||||||
|
self.probeProgress = 1.0
|
||||||
|
|
||||||
|
if let best = bestResult {
|
||||||
|
self.detectedDevice = best.port
|
||||||
|
self.selectedPortPath = best.port.path
|
||||||
|
self.selectedProtocol = best.protocol_
|
||||||
|
self.baudRate = best.baudRate
|
||||||
|
self.probeStatus = "Found: \(best.port.name) (\(best.protocol_.rawValue))"
|
||||||
|
} else {
|
||||||
|
self.probeStatus = "No VU meter found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe a single port with specific settings
|
||||||
|
private func probePort(port: SerialPort, baudRate: Int, protocol_: SerialProtocol) -> ProbeResult? {
|
||||||
|
let fd = open(port.path, O_RDWR | O_NOCTTY | O_NONBLOCK)
|
||||||
|
guard fd != -1 else { return nil }
|
||||||
|
|
||||||
|
defer { close(fd) }
|
||||||
|
|
||||||
|
// Configure port
|
||||||
|
var options = termios()
|
||||||
|
tcgetattr(fd, &options)
|
||||||
|
|
||||||
|
let speed = getBaudRateConstant(baudRate)
|
||||||
|
cfsetispeed(&options, speed)
|
||||||
|
cfsetospeed(&options, speed)
|
||||||
|
|
||||||
|
options.c_cflag &= ~UInt(PARENB | CSTOPB | CSIZE)
|
||||||
|
options.c_cflag |= UInt(CS8 | CREAD | CLOCAL)
|
||||||
|
options.c_lflag &= ~UInt(ICANON | ECHO | ECHOE | ISIG)
|
||||||
|
options.c_oflag &= ~UInt(OPOST)
|
||||||
|
|
||||||
|
// Set read timeout
|
||||||
|
options.c_cc.16 = 0 // VMIN
|
||||||
|
options.c_cc.17 = 5 // VTIME (0.5 seconds)
|
||||||
|
|
||||||
|
tcsetattr(fd, TCSANOW, &options)
|
||||||
|
tcflush(fd, TCIOFLUSH)
|
||||||
|
|
||||||
|
// Send probe command
|
||||||
|
let probeData = protocol_.probeCommand
|
||||||
|
let startTime = Date()
|
||||||
|
|
||||||
|
let written = probeData.withUnsafeBytes { buffer -> Int in
|
||||||
|
guard let baseAddress = buffer.baseAddress else { return -1 }
|
||||||
|
return write(fd, baseAddress, probeData.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard written > 0 else {
|
||||||
|
return ProbeResult(port: port, protocol_: protocol_, baudRate: baudRate,
|
||||||
|
success: false, response: nil, responseTime: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
usleep(100_000) // 100ms
|
||||||
|
|
||||||
|
// Try to read response
|
||||||
|
var readBuffer = [UInt8](repeating: 0, count: 256)
|
||||||
|
let bytesRead = read(fd, &readBuffer, readBuffer.count)
|
||||||
|
|
||||||
|
let responseTime = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
if bytesRead > 0 {
|
||||||
|
let response = String(bytes: readBuffer.prefix(bytesRead), encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
return ProbeResult(port: port, protocol_: protocol_, baudRate: baudRate,
|
||||||
|
success: true, response: response, responseTime: responseTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No response, but connection succeeded - might still be valid
|
||||||
|
// (some devices don't respond to probes but accept data)
|
||||||
|
return ProbeResult(port: port, protocol_: protocol_, baudRate: baudRate,
|
||||||
|
success: written > 0, response: nil, responseTime: responseTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quick probe - just check if port opens and accepts data
|
||||||
|
func quickProbe(port: SerialPort) -> Bool {
|
||||||
|
let fd = open(port.path, O_RDWR | O_NOCTTY | O_NONBLOCK)
|
||||||
|
guard fd != -1 else { return false }
|
||||||
|
defer { close(fd) }
|
||||||
|
|
||||||
|
// Try to write a simple test
|
||||||
|
var options = termios()
|
||||||
|
tcgetattr(fd, &options)
|
||||||
|
let speed = getBaudRateConstant(115200)
|
||||||
|
cfsetispeed(&options, speed)
|
||||||
|
cfsetospeed(&options, speed)
|
||||||
|
options.c_cflag &= ~UInt(PARENB | CSTOPB | CSIZE)
|
||||||
|
options.c_cflag |= UInt(CS8 | CREAD | CLOCAL)
|
||||||
|
tcsetattr(fd, TCSANOW, &options)
|
||||||
|
|
||||||
|
let testData = Data([0xAA, 0x00, 0x00, 0x00, 0x00, 0x55])
|
||||||
|
let written = testData.withUnsafeBytes { buffer -> Int in
|
||||||
|
guard let baseAddress = buffer.baseAddress else { return -1 }
|
||||||
|
return write(fd, baseAddress, testData.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return written > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Connection Management
|
// MARK: - Connection Management
|
||||||
@@ -224,6 +534,32 @@ class SerialManager: ObservableObject {
|
|||||||
startUpdateTimer()
|
startUpdateTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-connect: probe and connect to first found device
|
||||||
|
func autoConnect() {
|
||||||
|
refreshPorts()
|
||||||
|
|
||||||
|
// First, try already marked VU meters
|
||||||
|
if let vuMeter = availablePorts.first(where: { $0.isVUMeter }) {
|
||||||
|
selectedPortPath = vuMeter.path
|
||||||
|
connect()
|
||||||
|
if isConnected { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick probe all ports
|
||||||
|
for port in availablePorts {
|
||||||
|
if quickProbe(port: port) {
|
||||||
|
selectedPortPath = port.path
|
||||||
|
connect()
|
||||||
|
if isConnected {
|
||||||
|
print("Auto-connected to \(port.name)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = "No VU meter found"
|
||||||
|
}
|
||||||
|
|
||||||
/// Disconnect from serial port
|
/// Disconnect from serial port
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
stopUpdateTimer()
|
stopUpdateTimer()
|
||||||
|
|||||||
Reference in New Issue
Block a user