import Foundation import ForgeCamera import ForgeGrade import ForgeColor #if canImport(Glibc) import Glibc #endif /// RED parameter names — single source of truth, correct against RED SDK /// (RCP2) when hardware/SDK agreement lands. Sim mirrors these. enum RedParams { static let recordState = "RECORD_STATE" static let clipName = "CLIP_NAME" static let iso = "ISO" static let colorTemp = "COLOR_TEMP" static let tint = "TINT" static let sensorFPS = "SENSOR_FPS" static let timecode = "TIMECODE" static let ipp2CDL = "IPP2_CDL" } /// DSMC3 driver: RCP2-style length-prefixed JSON over TCP. /// Request/response serialized through the actor — one in-flight request. public actor RedDriver: CameraDriver { public nonisolated let capabilities = CameraCapabilities( vendor: .red, supportsNativeCDL: true, lut3dSizes: [17, 33], metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode]) private let host: String private let port: Int private let pollInterval: Duration private var socketFD: Int32 = -1 private var pollTask: Task? private var lastState = CameraState(connection: .disconnected) private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) { self.host = host self.port = port self.pollInterval = pollInterval } public nonisolated var state: AsyncStream { AsyncStream { continuation in let id = UUID() Task { await self.register(id: id, continuation: continuation) } continuation.onTermination = { _ in Task { await self.unregister(id: id) } } } } private func register(id: UUID, continuation: AsyncStream.Continuation) { stateContinuations[id] = continuation } private func unregister(id: UUID) { stateContinuations.removeValue(forKey: id) } private func emit(_ s: CameraState) { lastState = s for c in stateContinuations.values { c.yield(s) } } // MARK: Socket transport (blocking, actor-serialized, short timeouts) private func openSocket() throws { let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) guard fd >= 0 else { throw CameraError.connectionFailed("socket() failed") } var tv = timeval(tv_sec: 3, tv_usec: 0) setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) var addr = sockaddr_in() addr.sin_family = sa_family_t(AF_INET) addr.sin_port = in_port_t(UInt16(port).bigEndian) guard inet_pton(AF_INET, host, &addr.sin_addr) == 1 else { close(fd) throw CameraError.connectionFailed("bad host \(host)") } let rc = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in Glibc.connect(fd, sa, socklen_t(MemoryLayout.size)) } } guard rc == 0 else { close(fd) throw CameraError.connectionFailed("connect to \(host):\(port) failed") } socketFD = fd } private func closeSocket() { if socketFD >= 0 { close(socketFD) socketFD = -1 } } private func sendAll(_ data: Data) throws { var sent = 0 try data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) in while sent < buf.count { let n = write(socketFD, buf.baseAddress!.advanced(by: sent), buf.count - sent) guard n > 0 else { throw CameraError.connectionFailed("write failed") } sent += n } } } private func recvExact(_ count: Int) throws -> Data { var out = Data(capacity: count) var remaining = count var chunk = [UInt8](repeating: 0, count: 64 * 1024) while remaining > 0 { let n = read(socketFD, &chunk, min(remaining, chunk.count)) guard n > 0 else { throw CameraError.connectionFailed("read failed/timeout") } out.append(contentsOf: chunk[0.. [String: Any] { guard socketFD >= 0 else { throw CameraError.connectionFailed("not connected") } let payload = try JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]) var frame = Data() var lenBE = UInt32(payload.count).bigEndian withUnsafeBytes(of: &lenBE) { frame.append(contentsOf: $0) } frame.append(payload) try sendAll(frame) let header = try recvExact(4) // Byte-wise assembly — Data slices may be misaligned for a u32 load. let length = header.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } let body = try recvExact(Int(length)) guard let reply = try JSONSerialization.jsonObject(with: body) as? [String: Any] else { throw CameraError.connectionFailed("bad reply JSON") } return reply } private func getParam(_ name: String) throws -> String { let reply = try request(["type": "get", "param": name]) guard reply["ok"] as? Bool == true, let value = reply["value"] as? String else { throw CameraError.connectionFailed("get \(name) failed") } return value } private func setParam(_ name: String, _ value: String) throws { let reply = try request(["type": "set", "param": name, "value": value]) guard reply["ok"] as? Bool == true else { throw CameraError.pushFailed("set \(name) failed") } } // MARK: CameraDriver public func connect() async throws { closeSocket() try openSocket() let reply = try request(["type": "handshake"]) guard reply["ok"] as? Bool == true else { closeSocket() throw CameraError.connectionFailed("handshake rejected") } emit(CameraState(connection: .connected)) startPolling() } public func disconnect() async { pollTask?.cancel() pollTask = nil closeSocket() emit(CameraState(connection: .disconnected)) } public func push(look: FlattenedLook) async throws { if let lut = look.lut, !capabilities.lut3dSizes.contains(lut.size) { throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)") } if let cdl = look.cdl { let obj: [String: Any] = [ "slope": [cdl.slope.x, cdl.slope.y, cdl.slope.z], "offset": [cdl.offset.x, cdl.offset.y, cdl.offset.z], "power": [cdl.power.x, cdl.power.y, cdl.power.z], "saturation": cdl.saturation, ] let json = String(data: try JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), encoding: .utf8)! try setParam(RedParams.ipp2CDL, json) } if let lut = look.lut { let reply = try request(["type": "lut", "size": lut.size, "table": lut.table]) guard reply["ok"] as? Bool == true else { throw CameraError.pushFailed("LUT upload rejected") } } } // MARK: Polling private func startPolling() { pollTask?.cancel() pollTask = Task { while !Task.isCancelled { pollOnce() try? await Task.sleep(for: pollInterval) } } } private func pollOnce() { do { let rec = try getParam(RedParams.recordState) == "1" let clip = try getParam(RedParams.clipName) let newState = CameraState( connection: .connected, isRecording: rec, clipName: clip.isEmpty ? nil : clip, metadata: CameraMetadata( exposureIndex: Int(try getParam(RedParams.iso)), whiteBalance: Int(try getParam(RedParams.colorTemp)), tint: Int(try getParam(RedParams.tint)), fps: Double(try getParam(RedParams.sensorFPS)), timecode: try getParam(RedParams.timecode))) if newState != lastState { emit(newState) } } catch { if lastState.connection == .connected { emit(CameraState(connection: .disconnected)) } } } }