Files
Ai/AudioVUMeter/AudioVUMeter/HardwareView.swift
T
Claude f5e266b22b 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
2025-12-14 10:41:27 +00:00

525 lines
19 KiB
Swift

//
// HardwareView.swift
// AudioVUMeter
//
// Hardware configuration and monitoring view for physical VU meters
// Includes auto-probe functionality to detect connected hardware
//
import SwiftUI
// MARK: - Hardware Panel in Main View
struct HardwarePanelView: View {
@EnvironmentObject var serialManager: SerialManager
var body: some View {
VStack(spacing: 15) {
// Header
HStack {
Text("HARDWARE OUTPUT")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
Spacer()
// Connection status
HStack(spacing: 6) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
Text(statusText)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.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
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
)
}
}
}
// 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: {
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))")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Text(serialManager.selectedPortPath.components(separatedBy: "/").last ?? "")
.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()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.3))
.overlay(
RoundedRectangle(cornerRadius: 12)
.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"
case .audioRight: return "R"
case .audioPeak: return "PK"
case .audioMono: return "M"
case .cpu: return "CPU"
case .ram: return "RAM"
case .disk: return "DSK"
case .network: return "NET"
}
}
private func formatBytes(_ bytes: UInt64) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
// MARK: - Single Dial Indicator
struct DialIndicatorView: View {
let dialNumber: Int
let value: Int
let channelName: String
let isConnected: Bool
var body: some View {
VStack(spacing: 4) {
// Dial number
Text("D\(dialNumber)")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
// Value arc
ZStack {
// Background arc
Circle()
.trim(from: 0.25, to: 0.75)
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(180))
// Value arc
Circle()
.trim(from: 0.25, to: 0.25 + (Double(value) / 255.0) * 0.5)
.stroke(
isConnected ? dialColor(for: value) : Color.gray,
style: StrokeStyle(lineWidth: 4, lineCap: .round)
)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(180))
// Value text
VStack(spacing: 0) {
Text("\(value)")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(isConnected ? .white : .gray)
}
}
// Channel name
Text(channelName)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.foregroundColor(channelColor(channelName))
}
}
private func dialColor(for value: Int) -> Color {
let ratio = Double(value) / 255.0
if ratio > 0.9 { return .red }
if ratio > 0.75 { return .orange }
if ratio > 0.5 { return .yellow }
return .green
}
private func channelColor(_ name: String) -> Color {
switch name {
case "L", "R", "PK", "M": return .green
case "CPU": return .blue
case "RAM": return .purple
case "DSK": return .teal
case "NET": return .indigo
default: return .gray
}
}
}
// MARK: - Hardware Settings View
struct HardwareSettingsView: View {
@EnvironmentObject var serialManager: SerialManager
var body: some View {
Form {
// Auto-Probe Section
Section("Auto-Detect Hardware") {
HStack {
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() }) {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderless)
Spacer()
Text("\(serialManager.availablePorts.count) ports found")
.font(.caption)
.foregroundColor(.secondary)
}
// Baud rate
Picker("Baud Rate", selection: $serialManager.baudRate) {
ForEach(SerialManager.availableBaudRates, id: \.self) { rate in
Text("\(rate)").tag(rate)
}
}
// Protocol
Picker("Protocol", selection: $serialManager.selectedProtocol) {
ForEach(SerialProtocol.allCases) { proto in
Text(proto.rawValue).tag(proto)
}
}
// Connect button
Button(action: { serialManager.toggleConnection() }) {
HStack {
Image(systemName: serialManager.isConnected ? "bolt.slash.fill" : "bolt.fill")
Text(serialManager.isConnected ? "Disconnect" : "Connect")
}
}
.foregroundColor(serialManager.isConnected ? .red : .green)
.disabled(serialManager.isProbing)
}
// Dial Configuration Section
Section("Dial Assignments") {
ForEach(0..<4) { index in
DialConfigRow(
dialNumber: index + 1,
config: $serialManager.dialConfigs[index]
)
}
}
// Advanced Settings
Section("Advanced") {
ForEach(0..<4) { index in
DisclosureGroup("Dial \(index + 1) Settings") {
HStack {
Text("Min Value")
Spacer()
TextField("0", value: $serialManager.dialConfigs[index].minValue, format: .number)
.frame(width: 60)
.textFieldStyle(.roundedBorder)
}
HStack {
Text("Max Value")
Spacer()
TextField("255", value: $serialManager.dialConfigs[index].maxValue, format: .number)
.frame(width: 60)
.textFieldStyle(.roundedBorder)
}
Toggle("Invert", isOn: $serialManager.dialConfigs[index].inverted)
HStack {
Text("Smoothing")
Slider(value: $serialManager.dialConfigs[index].smoothing, in: 0...0.9)
Text("\(Int(serialManager.dialConfigs[index].smoothing * 100))%")
.frame(width: 40)
}
}
}
}
// 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) {
protocolInfo
}
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.formStyle(.grouped)
}
@ViewBuilder
private var protocolInfo: some View {
switch serialManager.selectedProtocol {
case .rawBytes:
Text("Format: [0xAA] [D1] [D2] [D3] [D4] [0x55]")
Text("Values: 0-255 per dial")
case .textCommand:
Text("Format: CH1:val;CH2:val;CH3:val;CH4:val\\n")
Text("Values: 0-255 per channel")
case .json:
Text("Format: {\"dials\":[d1,d2,d3,d4]}\\n")
Text("Values: 0-255 array")
case .vuServer:
Text("Format: #0:val\\n#1:val\\n#2:val\\n#3:val\\n")
Text("Values: 0-100 percentage per dial")
}
}
}
// MARK: - Dial Config Row
struct DialConfigRow: View {
let dialNumber: Int
@Binding var config: DialConfig
var body: some View {
HStack {
Text("Dial \(dialNumber)")
.font(.system(.body, design: .monospaced))
.frame(width: 60, alignment: .leading)
Picker("", selection: $config.dialChannel) {
ForEach(DialChannel.allCases) { channel in
Text(channel.rawValue).tag(channel)
}
}
.labelsHidden()
}
}
}
// MARK: - Button Styles
struct HardwareButtonStyle: ButtonStyle {
let isConnected: 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(isConnected ? Color.red.opacity(0.7) : Color.green.opacity(0.7))
.opacity(configuration.isPressed ? 0.6 : 1.0)
)
}
}
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: 500, height: 700)
}