From 5e13ff069daf806f7ce7e933f7df948cfc824325 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Dec 2025 16:35:31 +0000 Subject: [PATCH] Implement VU-Server binary protocol for real hardware - 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 --- AudioVUMeter/AudioVUMeter/HardwareView.swift | 42 ++- AudioVUMeter/AudioVUMeter/SerialManager.swift | 302 +++++++++++++++++- 2 files changed, 325 insertions(+), 19 deletions(-) diff --git a/AudioVUMeter/AudioVUMeter/HardwareView.swift b/AudioVUMeter/AudioVUMeter/HardwareView.swift index 62fb4fb..fa01f2a 100644 --- a/AudioVUMeter/AudioVUMeter/HardwareView.swift +++ b/AudioVUMeter/AudioVUMeter/HardwareView.swift @@ -93,16 +93,39 @@ struct HardwarePanelView: View { // Stats / Device info / Errors if serialManager.isConnected { - HStack { - Text("TX: \(formatBytes(serialManager.bytesSent))") - .font(.system(size: 9, design: .monospaced)) - .foregroundColor(.gray) + VStack(spacing: 4) { + HStack { + Text("TX: \(formatBytes(serialManager.bytesSent))") + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) - Spacer() + Text("RX: \(formatBytes(serialManager.bytesReceived))") + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) - Text(serialManager.selectedPortPath.components(separatedBy: "/").last ?? "") - .font(.system(size: 9, design: .monospaced)) - .foregroundColor(.green) + 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 { @@ -490,7 +513,8 @@ struct HardwareSettingsView: View { 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("Binary Protocol: '>' + 9-byte header + payload") + Text("Commands: 0x03 (single %), 0x04 (all %)") Text("Values: 0-100 percentage per dial") } } diff --git a/AudioVUMeter/AudioVUMeter/SerialManager.swift b/AudioVUMeter/AudioVUMeter/SerialManager.swift index 45ece3c..f6e66cb 100644 --- a/AudioVUMeter/AudioVUMeter/SerialManager.swift +++ b/AudioVUMeter/AudioVUMeter/SerialManager.swift @@ -17,7 +17,7 @@ enum SerialProtocol: String, CaseIterable, Identifiable { case rawBytes = "Raw Bytes (0-255)" case textCommand = "Text Commands" case json = "JSON Format" - case vuServer = "VU-Server Compatible" + case vuServer = "VU-Server Binary" var id: String { rawValue } @@ -32,11 +32,148 @@ enum SerialProtocol: String, CaseIterable, Identifiable { case .json: return "{\"cmd\":\"ping\"}\n".data(using: .utf8)! case .vuServer: - return "?\n".data(using: .utf8)! // Query command + // Binary probe: get firmware version command + return VUServerProtocol.buildCommand(.getFirmwareVersion, payload: []) } } } +// MARK: - VU-Server Binary Protocol + +/// VU-Server hardware binary protocol implementation +/// Based on https://github.com/SasaKaranovic/VU-Server +struct VUServerProtocol { + + // Protocol constants + static let startByte: UInt8 = 0x3E // '>' + static let responseStartByte: UInt8 = 0x3C // '<' + static let headerSize = 9 + static let maxPayloadSize = 1000 + + // Command codes (from Comms_Hub_Gauge.py) + enum Command: UInt8 { + // Dial control commands + case setDialRawSingle = 0x01 // Set single dial raw value (0-65535) + case setDialRawAll = 0x02 // Set all dials raw + case setDialPercentSingle = 0x03 // Set single dial percentage (0-100) + case setDialPercentAll = 0x04 // Set all dials percentage + case setDialDAC = 0x05 // Set DAC directly + + // Backlight commands + case setBacklightRGB = 0x12 // Set RGB backlight + case setBacklightRGBW = 0x13 // Set RGBW backlight + + // Calibration + case calibrateMin = 0x20 // Calibrate minimum + case calibrateMax = 0x21 // Calibrate maximum + case calibrateMid = 0x22 // Calibrate midpoint + + // Info commands + case getUID = 0x30 // Get device UID + case getFirmwareVersion = 0x31 // Get firmware version + case getHardwareVersion = 0x32 // Get hardware version + case getProtocolVersion = 0x33 // Get protocol version + + // Easing commands + case setEasingDialStep = 0x40 + case setEasingDialPeriod = 0x41 + case setEasingBacklightStep = 0x42 + case setEasingBacklightPeriod = 0x43 + case getEasingConfig = 0x44 + + // Display commands + case clearDisplay = 0x50 + case updateDisplay = 0x51 + + // Power + case setPower = 0x60 + } + + // Data type identifiers + enum DataType: UInt8 { + case none = 0x00 + case uint8 = 0x01 + case uint16 = 0x02 + case uint32 = 0x03 + case string = 0x04 + case binary = 0x05 + } + + /// Build a command frame for VU-Server hardware + /// Frame format: [START] [CMD] [RESERVED] [DATA_TYPE] [RESERVED] [LEN_H] [LEN_L] [RESERVED] [LEN_L] [PAYLOAD...] + static func buildCommand(_ command: Command, payload: [UInt8], dataType: DataType = .uint8) -> Data { + var frame = [UInt8]() + + // Header (9 bytes) + frame.append(startByte) // Byte 0: Start '>' + frame.append(command.rawValue) // Byte 1: Command + frame.append(0x00) // Byte 2: Reserved + frame.append(dataType.rawValue) // Byte 3: Data type + frame.append(0x00) // Byte 4: Reserved + + let payloadLen = UInt16(payload.count) + frame.append(UInt8(payloadLen >> 8)) // Byte 5: Length high + frame.append(UInt8(payloadLen & 0xFF)) // Byte 6: Length low + frame.append(0x00) // Byte 7: Reserved + frame.append(UInt8(payloadLen & 0xFF)) // Byte 8: Length low (repeated) + + // Payload + frame.append(contentsOf: payload) + + return Data(frame) + } + + /// Build command to set a single dial to a percentage value + static func setDialPercent(dialIndex: UInt8, percent: UInt8) -> Data { + let clampedPercent = min(percent, 100) + return buildCommand(.setDialPercentSingle, payload: [dialIndex, clampedPercent]) + } + + /// Build command to set all dials at once (percentage values) + static func setAllDialsPercent(values: [UInt8]) -> Data { + let payload = values.map { min($0, 100) } + return buildCommand(.setDialPercentAll, payload: payload) + } + + /// Build command to set a single dial to a raw 16-bit value + static func setDialRaw(dialIndex: UInt8, value: UInt16) -> Data { + return buildCommand(.setDialRawSingle, payload: [ + dialIndex, + UInt8(value >> 8), // High byte + UInt8(value & 0xFF) // Low byte + ], dataType: .uint16) + } + + /// Build command to set backlight RGB color + static func setBacklightRGB(dialIndex: UInt8, red: UInt8, green: UInt8, blue: UInt8) -> Data { + return buildCommand(.setBacklightRGB, payload: [dialIndex, red, green, blue]) + } + + /// Build command to set backlight RGBW color + static func setBacklightRGBW(dialIndex: UInt8, red: UInt8, green: UInt8, blue: UInt8, white: UInt8) -> Data { + return buildCommand(.setBacklightRGBW, payload: [dialIndex, red, green, blue, white]) + } + + /// Parse response from hardware + static func parseResponse(_ data: Data) -> (success: Bool, command: UInt8, payload: Data)? { + guard data.count >= headerSize else { return nil } + + let bytes = [UInt8](data) + + // Check start byte + guard bytes[0] == responseStartByte else { return nil } + + let command = bytes[1] + let payloadLength = Int(bytes[5]) << 8 | Int(bytes[6]) + + guard data.count >= headerSize + payloadLength else { return nil } + + let payload = Data(bytes[headerSize..<(headerSize + payloadLength)]) + + return (success: true, command: command, payload: payload) + } +} + /// Represents a serial port device with extended info struct SerialPort: Identifiable, Hashable { let id: String @@ -111,6 +248,12 @@ class SerialManager: ObservableObject { @Published var dialConfigs: [DialConfig] = [] @Published var lastError: String? @Published var bytesSent: UInt64 = 0 + @Published var bytesReceived: UInt64 = 0 + + // Hardware info (VU-Server) + @Published var firmwareVersion: String? + @Published var hardwareVersion: String? + @Published var deviceUID: String? // Auto-probe state @Published var isProbing = false @@ -586,6 +729,39 @@ class SerialManager: ObservableObject { // Start update timer startUpdateTimer() + + // For VU-Server: start response reader and query device info + if selectedProtocol == .vuServer { + startResponseReader() + + // Query device info after short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.requestDeviceInfo() + } + } + } + + /// Request device information from VU-Server hardware + func requestDeviceInfo() { + guard isConnected, fileDescriptor != -1, selectedProtocol == .vuServer else { return } + + writeQueue.async { [weak self] in + guard let self = self else { return } + + // Query firmware version + let fwCmd = VUServerProtocol.buildCommand(.getFirmwareVersion, payload: []) + self.writeData(fwCmd) + usleep(50_000) // 50ms delay between commands + + // Query hardware version + let hwCmd = VUServerProtocol.buildCommand(.getHardwareVersion, payload: []) + self.writeData(hwCmd) + usleep(50_000) + + // Query UID + let uidCmd = VUServerProtocol.buildCommand(.getUID, payload: []) + self.writeData(uidCmd) + } } /// Auto-connect: probe and connect to first found device @@ -734,16 +910,122 @@ class SerialManager: ObservableObject { return Data() } - /// Format for VU-Server compatible hardware - /// Protocol: #:\n + /// Format for VU-Server hardware using binary protocol + /// Sends percentage values (0-100) for each dial private func formatVUServer() -> Data { - var message = "" - for (index, value) in dialValues.enumerated() { - // VU-Server uses percentage values 0-100 - let percentage = (value * 100) / 255 - message += "#\(index):\(percentage)\n" + // Convert 0-255 values to 0-100 percentage + let percentValues = dialValues.map { UInt8((($0) * 100) / 255) } + + // Use the optimized "set all dials" command + return VUServerProtocol.setAllDialsPercent(values: percentValues) + } + + /// Send individual dial value using VU-Server binary protocol + func sendDialValue(dialIndex: Int, value: Int) { + guard isConnected, fileDescriptor != -1, selectedProtocol == .vuServer else { return } + + writeQueue.async { [weak self] in + guard let self = self else { return } + + let percent = UInt8((value * 100) / 255) + let data = VUServerProtocol.setDialPercent(dialIndex: UInt8(dialIndex), percent: percent) + self.writeData(data) + } + } + + /// Set backlight color for a dial (VU-Server only) + func setBacklightColor(dialIndex: Int, red: UInt8, green: UInt8, blue: UInt8) { + guard isConnected, fileDescriptor != -1, selectedProtocol == .vuServer else { return } + + writeQueue.async { [weak self] in + guard let self = self else { return } + + let data = VUServerProtocol.setBacklightRGB( + dialIndex: UInt8(dialIndex), + red: red, + green: green, + blue: blue + ) + self.writeData(data) + } + } + + /// Request firmware version from hardware + func requestFirmwareVersion() { + guard isConnected, fileDescriptor != -1, selectedProtocol == .vuServer else { return } + + writeQueue.async { [weak self] in + guard let self = self else { return } + + let data = VUServerProtocol.buildCommand(.getFirmwareVersion, payload: []) + self.writeData(data) + + // Read response + self.readResponse() + } + } + + // MARK: - Response Handling + + /// Read and parse response from hardware + private func readResponse() { + var buffer = [UInt8](repeating: 0, count: 256) + let bytesRead = read(fileDescriptor, &buffer, buffer.count) + + if bytesRead > 0 { + DispatchQueue.main.async { + self.bytesReceived += UInt64(bytesRead) + } + + let responseData = Data(buffer.prefix(bytesRead)) + + // Parse VU-Server response + if let response = VUServerProtocol.parseResponse(responseData) { + handleVUServerResponse(command: response.command, payload: response.payload) + } + } + } + + /// Handle parsed VU-Server response + private func handleVUServerResponse(command: UInt8, payload: Data) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + switch command { + case VUServerProtocol.Command.getFirmwareVersion.rawValue: + if let version = String(data: payload, encoding: .utf8) { + self.firmwareVersion = version.trimmingCharacters(in: .controlCharacters) + print("VU-Server Firmware: \(self.firmwareVersion ?? "unknown")") + } + + case VUServerProtocol.Command.getHardwareVersion.rawValue: + if let version = String(data: payload, encoding: .utf8) { + self.hardwareVersion = version.trimmingCharacters(in: .controlCharacters) + print("VU-Server Hardware: \(self.hardwareVersion ?? "unknown")") + } + + case VUServerProtocol.Command.getUID.rawValue: + if let uid = String(data: payload, encoding: .utf8) { + self.deviceUID = uid.trimmingCharacters(in: .controlCharacters) + print("VU-Server UID: \(self.deviceUID ?? "unknown")") + } + + default: + print("VU-Server response: cmd=0x\(String(command, radix: 16)), payload=\(payload.count) bytes") + } + } + } + + /// Start background response reader + private func startResponseReader() { + guard selectedProtocol == .vuServer else { return } + + DispatchQueue.global(qos: .utility).async { [weak self] in + while let self = self, self.isConnected, self.fileDescriptor != -1 { + self.readResponse() + usleep(10_000) // 10ms + } } - return message.data(using: .utf8) ?? Data() } // MARK: - Low-level I/O