diff --git a/AudioVUMeter/AudioVUMeter/HardwareView.swift b/AudioVUMeter/AudioVUMeter/HardwareView.swift index 6986d6d..51f954a 100644 --- a/AudioVUMeter/AudioVUMeter/HardwareView.swift +++ b/AudioVUMeter/AudioVUMeter/HardwareView.swift @@ -3,6 +3,7 @@ // AudioVUMeter // // Hardware configuration and monitoring view for physical VU meters +// Includes auto-probe functionality to detect connected hardware // import SwiftUI @@ -24,40 +25,73 @@ struct HardwarePanelView: View { // Connection status HStack(spacing: 6) { Circle() - .fill(serialManager.isConnected ? Color.green : Color.red) + .fill(statusColor) .frame(width: 8, height: 8) - Text(serialManager.isConnected ? "CONNECTED" : "DISCONNECTED") + Text(statusText) .font(.system(size: 9, weight: .semibold, design: .monospaced)) - .foregroundColor(serialManager.isConnected ? .green : .red) + .foregroundColor(statusColor) } } - // 4 Physical Dial Indicators - HStack(spacing: 15) { - ForEach(0..<4) { index in - DialIndicatorView( - dialNumber: index + 1, - value: serialManager.dialValues[index], - channelName: shortChannelName(serialManager.dialConfigs[index].dialChannel), - isConnected: serialManager.isConnected - ) + // 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 + HStack(spacing: 15) { + ForEach(0..<4) { index in + DialIndicatorView( + dialNumber: index + 1, + value: serialManager.dialValues[index], + channelName: shortChannelName(serialManager.dialConfigs[index].dialChannel), + isConnected: serialManager.isConnected + ) + } } } - // Quick connect button - Button(action: { - serialManager.toggleConnection() - }) { - HStack { - Image(systemName: serialManager.isConnected ? "antenna.radiowaves.left.and.right.slash" : "antenna.radiowaves.left.and.right") - Text(serialManager.isConnected ? "Disconnect" : "Connect") + // 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) } - .frame(maxWidth: .infinity) - } - .buttonStyle(HardwareButtonStyle(isConnected: serialManager.isConnected)) + .buttonStyle(ProbeButtonStyle(isProbing: serialManager.isProbing)) + .disabled(serialManager.isConnected) - // Stats + // Connect button + Button(action: { + serialManager.toggleConnection() + }) { + HStack { + Image(systemName: serialManager.isConnected ? "antenna.radiowaves.left.and.right.slash" : "antenna.radiowaves.left.and.right") + Text(serialManager.isConnected ? "Disconnect" : "Connect") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(HardwareButtonStyle(isConnected: serialManager.isConnected)) + .disabled(serialManager.isProbing) + } + + // Stats / Device info if serialManager.isConnected { HStack { Text("TX: \(formatBytes(serialManager.bytesSent))") @@ -70,6 +104,24 @@ struct HardwarePanelView: View { .font(.system(size: 9, design: .monospaced)) .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() @@ -78,12 +130,30 @@ struct HardwarePanelView: View { .fill(Color.black.opacity(0.3)) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(serialManager.isConnected ? Color.green.opacity(0.3) : Color.clear, lineWidth: 1) + .stroke(borderColor, lineWidth: 1) ) ) .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 { switch channel { case .audioLeft: return "L" @@ -178,21 +248,90 @@ struct HardwareSettingsView: View { var body: some View { Form { - // Connection Section - Section("Serial Connection") { - // Port selection + // Auto-Probe Section + Section("Auto-Detect Hardware") { HStack { - Picker("Port", selection: $serialManager.selectedPortPath) { - Text("Select Port...").tag("") - ForEach(serialManager.availablePorts) { port in - Text(port.name).tag(port.path) + Button(action: { + if serialManager.isProbing { + serialManager.stopAutoProbe() + } 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() }) { - Image(systemName: "arrow.clockwise") + Label("Refresh", systemImage: "arrow.clockwise") } .buttonStyle(.borderless) + + Spacer() + + Text("\(serialManager.availablePorts.count) ports found") + .font(.caption) + .foregroundColor(.secondary) } // Baud rate @@ -217,6 +356,7 @@ struct HardwareSettingsView: View { } } .foregroundColor(serialManager.isConnected ? .red : .green) + .disabled(serialManager.isProbing) } // 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 Section("Protocol Reference") { VStack(alignment: .leading, spacing: 8) { @@ -313,7 +483,7 @@ struct DialConfigRow: View { } } -// MARK: - Hardware Button Style +// MARK: - Button Styles struct HardwareButtonStyle: ButtonStyle { 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 #Preview { HardwareSettingsView() .environmentObject(SerialManager()) - .frame(width: 450, height: 600) + .frame(width: 500, height: 700) } diff --git a/AudioVUMeter/AudioVUMeter/SerialManager.swift b/AudioVUMeter/AudioVUMeter/SerialManager.swift index 7305f65..24f0e56 100644 --- a/AudioVUMeter/AudioVUMeter/SerialManager.swift +++ b/AudioVUMeter/AudioVUMeter/SerialManager.swift @@ -4,11 +4,13 @@ // // Serial communication manager for physical VU meter hardware // Supports multiple protocols: Raw bytes, Text commands, JSON, VU-Server compatible +// Includes auto-probing to find connected VU meter hardware // import Foundation import IOKit import IOKit.serial +import IOKit.usb /// Protocol format for serial communication enum SerialProtocol: String, CaseIterable, Identifiable { @@ -18,13 +20,50 @@ enum SerialProtocol: String, CaseIterable, Identifiable { case vuServer = "VU-Server Compatible" 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 { let id: String let path: 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 @@ -60,7 +99,7 @@ struct DialConfig: Identifiable, Codable { } } -/// Serial communication manager +/// Serial communication manager with auto-probing class SerialManager: ObservableObject { // MARK: - Published Properties @@ -73,6 +112,13 @@ class SerialManager: ObservableObject { @Published var lastError: String? @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) @Published var dialValues: [Int] = [0, 0, 0, 0] @@ -80,12 +126,25 @@ class SerialManager: ObservableObject { private var fileDescriptor: Int32 = -1 private var writeQueue = DispatchQueue(label: "serial.write", qos: .userInteractive) + private var probeQueue = DispatchQueue(label: "serial.probe", qos: .userInitiated) private var updateTimer: Timer? private let updateInterval: TimeInterval = 1.0 / 30.0 // 30 Hz update rate // Smoothed values for each dial 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 init() { @@ -106,18 +165,20 @@ class SerialManager: ObservableObject { // MARK: - Port Management - /// Refresh list of available serial ports + /// Refresh list of available serial ports with USB info func refreshPorts() { - availablePorts = getSerialPorts() + availablePorts = getSerialPortsWithUSBInfo() - // Auto-select first port if none selected - if selectedPortPath.isEmpty, let firstPort = availablePorts.first { + // Auto-select VU meter if found + if let vuMeter = availablePorts.first(where: { $0.isVUMeter }) { + selectedPortPath = vuMeter.path + } else if selectedPortPath.isEmpty, let firstPort = availablePorts.first { selectedPortPath = firstPort.path } } - /// Get all available serial ports - private func getSerialPorts() -> [SerialPort] { + /// Get all available serial ports with USB vendor/product info + private func getSerialPortsWithUSBInfo() -> [SerialPort] { var ports: [SerialPort] = [] var iterator: io_iterator_t = 0 @@ -141,31 +202,280 @@ class SerialManager: ObservableObject { 0 )?.takeRetainedValue() as? String else { continue } + // Filter for cu.* devices (not tty.*) + guard pathKey.contains("cu.") else { continue } + // Get device name var name = pathKey.components(separatedBy: "/").last ?? "Unknown" - // Try to get a better name from USB info - if let usbName = IORegistryEntryCreateCFProperty( - service, - "USB Product Name" as CFString, - kCFAllocatorDefault, - 0 - )?.takeRetainedValue() as? String { - name = usbName + // 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( + current, + "USB Product Name" as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? String { + name = usbName + } + + if vendorID != nil && productID != nil { + break + } + } + IOObjectRelease(current) + + // 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 + } } - // Filter for common serial devices - if pathKey.contains("cu.") { - ports.append(SerialPort( - id: pathKey, - path: pathKey, - name: name - )) - } + ports.append(SerialPort( + path: pathKey, + name: name, + vendorID: vendorID, + productID: productID, + isVUMeter: isVUMeter + )) } 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 @@ -224,6 +534,32 @@ class SerialManager: ObservableObject { 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 func disconnect() { stopUpdateTimer()