5e13ff069d
- Add VUServerProtocol struct with proper binary frame format - Header: '>' + cmd + reserved + data_type + reserved + len_h + len_l + reserved + len_l - Support commands: setDialPercentSingle (0x03), setDialPercentAll (0x04) - Support backlight control (RGB/RGBW) - Add device info queries (firmware/hardware version, UID) - Add response parsing for '<' responses from hardware - Show firmware/hardware version in UI when connected - Update protocol info display to reflect binary protocol
583 lines
22 KiB
Swift
583 lines
22 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 / Errors
|
|
if serialManager.isConnected {
|
|
VStack(spacing: 4) {
|
|
HStack {
|
|
Text("TX: \(formatBytes(serialManager.bytesSent))")
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(.gray)
|
|
|
|
Text("RX: \(formatBytes(serialManager.bytesReceived))")
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(.gray)
|
|
|
|
Spacer()
|
|
|
|
Text(serialManager.selectedPortPath.components(separatedBy: "/").last ?? "")
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(.green)
|
|
}
|
|
|
|
// VU-Server hardware info
|
|
if serialManager.selectedProtocol == .vuServer {
|
|
HStack {
|
|
if let fw = serialManager.firmwareVersion {
|
|
Text("FW: \(fw)")
|
|
.font(.system(size: 8, design: .monospaced))
|
|
.foregroundColor(.cyan)
|
|
}
|
|
if let hw = serialManager.hardwareVersion {
|
|
Text("HW: \(hw)")
|
|
.font(.system(size: 8, design: .monospaced))
|
|
.foregroundColor(.cyan)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
} else if let error = serialManager.lastError {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.red)
|
|
.font(.system(size: 10))
|
|
|
|
Text(error)
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(.red)
|
|
.lineLimit(2)
|
|
|
|
Spacer()
|
|
}
|
|
} 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)
|
|
}
|
|
}
|
|
} else if serialManager.availablePorts.isEmpty {
|
|
HStack {
|
|
Image(systemName: "usb")
|
|
.foregroundColor(.orange)
|
|
.font(.system(size: 10))
|
|
|
|
Text("No USB serial devices detected")
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(.orange)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.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 with connection indicator
|
|
HStack(spacing: 2) {
|
|
Circle()
|
|
.fill(isConnected ? Color.green : Color.red)
|
|
.frame(width: 5, height: 5)
|
|
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.3), lineWidth: 4)
|
|
.frame(width: 50, height: 50)
|
|
.rotationEffect(.degrees(180))
|
|
|
|
// Value arc - always show with minimum visibility
|
|
Circle()
|
|
.trim(from: 0.25, to: 0.25 + max(0.02, (Double(value) / 255.0) * 0.5))
|
|
.stroke(
|
|
dialColor(for: value, connected: isConnected),
|
|
style: StrokeStyle(lineWidth: 4, lineCap: .round)
|
|
)
|
|
.frame(width: 50, height: 50)
|
|
.rotationEffect(.degrees(180))
|
|
.animation(.easeOut(duration: 0.1), value: value)
|
|
|
|
// 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, connected: Bool) -> Color {
|
|
if !connected {
|
|
return Color.gray.opacity(0.5)
|
|
}
|
|
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("Binary Protocol: '>' + 9-byte header + payload")
|
|
Text("Commands: 0x03 (single %), 0x04 (all %)")
|
|
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)
|
|
}
|