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
This commit is contained in:
Claude
2025-12-14 16:35:31 +00:00
parent 7a34c719e8
commit 5e13ff069d
2 changed files with 325 additions and 19 deletions
+33 -9
View File
@@ -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")
}
}
+292 -10
View File
@@ -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: #<dial_id>:<value>\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