import Foundation import ForgeCamera import ForgeGrade import ForgeColor import ForgeOffload #if canImport(Glibc) import Glibc #endif /// ALEXA 35 / Mini LF driver — real ARRI CAP protocol: /// binary TCP :5055, big-endian frames, MD5 challenge auth, variable subscription. /// Verified against CAP v1.12 spec tables (docs/research/arri-cap-protocol.md). /// CDL pushed via SetVariable 0x0050 (10×F32 blob). 3D LUT via Set3DLutData — /// marked experimental until chunk framing verified on hardware. public actor ArriDriver: CameraDriver { public nonisolated let capabilities = CameraCapabilities( vendor: .arri, supportsNativeCDL: true, lut3dSizes: [17, 33, 65], metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode]) private let host: String private let port: Int private let password: String private let clientName: String private var socketFD: Int32 = -1 private var readTask: Task? private var keepAliveTask: Task? private var nextMsgId: UInt16 = 1 private var readBuffer = Data() private var pendingReplies: [UInt16: CheckedContinuation] = [:] // Variable cache -> CameraState. private var varCache: [UInt16: Data] = [:] private var lastState = CameraState(connection: .disconnected) private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] public init(host: String, port: Int = 5055, password: String = "arri", clientName: String = "Forge") { self.host = host self.port = port self.password = password self.clientName = clientName } 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 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 \(host):\(port) failed") } socketFD = fd } private func closeSocket() { if socketFD >= 0 { close(socketFD) socketFD = -1 } } private func sendFrame(_ frame: CAP.Frame) throws { guard socketFD >= 0 else { throw CameraError.connectionFailed("not connected") } let data = CAP.encode(frame) 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 } } } /// Send command, await matching reply (msgId correlation). private func request(_ cmd: CAP.Command, payload: Data = Data(), timeout: Duration = .seconds(3)) async throws -> CAP.Frame { let msgId = nextMsgId nextMsgId &+= 1 if nextMsgId == 0 { nextMsgId = 1 } let frame = CAP.Frame( msgType: CAP.MsgType.command.rawValue, msgId: msgId, cmdCode: cmd.rawValue, payload: payload) return try await withCheckedThrowingContinuation { cont in pendingReplies[msgId] = cont do { try sendFrame(frame) } catch { pendingReplies.removeValue(forKey: msgId) cont.resume(throwing: error) return } Task { try? await Task.sleep(for: timeout) self.timeoutReply(msgId: msgId) } } } private func timeoutReply(msgId: UInt16) { if let cont = pendingReplies.removeValue(forKey: msgId) { cont.resume(throwing: CameraError.connectionFailed("reply timeout msgId \(msgId)")) } } // MARK: Read loop private func startReadLoop() { readTask?.cancel() readTask = Task.detached { [weak self] in guard let self else { return } let fd = await self.socketFD var chunk = [UInt8](repeating: 0, count: 64 * 1024) while !Task.isCancelled { let n = read(fd, &chunk, chunk.count) if n > 0 { await self.ingest(Data(chunk[0.. UInt16? { varCache[v.rawValue].flatMap { CAP.readU16($0, at: 0) } } func u32(_ v: CAP.Variable) -> UInt32? { varCache[v.rawValue].flatMap { CAP.readU32($0, at: 0) } } func f32(_ v: CAP.Variable) -> Float? { varCache[v.rawValue].flatMap { CAP.readF32($0, at: 0) } } let bits = CAP.CameraStateBits(rawValue: u16(.cameraState) ?? 0) // Clip name composed from reel + clip number (full names need GetClipList). var clipName: String? if let reel = u16(.currentReel), let clip = u16(.clipNumber), clip > 0 { clipName = String(format: "R%03dC%03d", reel, clip) } let newState = CameraState( connection: .connected, isRecording: bits.contains(.recording), clipName: clipName, metadata: CameraMetadata( exposureIndex: u32(.exposureIndex).map(Int.init), whiteBalance: u32(.colorTemperature).map(Int.init), tint: f32(.tint).map { Int($0.rounded()) }, fps: f32(.sensorFPS).map(Double.init), timecode: u32(.timecode).map(CAP.decodeTimecodeBCD))) if newState != lastState { emit(newState) } } // MARK: CameraDriver public func connect() async throws { closeSocket() readBuffer.removeAll() varCache.removeAll() try openSocket() startReadLoop() // Camera sends Welcome unsolicited; brief settle for it to arrive. try? await Task.sleep(for: .milliseconds(50)) // Auth: challenge -> MD5(challenge + password) -> client name. let challengeReply = try await request(.requestPwdChallenge) guard challengeReply.cmdCode == CAP.Result.ok.rawValue, let (challenge, _) = CAP.readString(challengeReply.payload, at: 0) else { await teardownAfterFailure() throw CameraError.connectionFailed("challenge request failed") } let hash = MD5.hexString(Data((challenge + password).utf8)) var pwPayload = Data() CAP.putString(hash, into: &pwPayload) let pwReply = try await request(.password, payload: pwPayload) guard pwReply.cmdCode == CAP.Result.ok.rawValue else { await teardownAfterFailure() throw CameraError.connectionFailed("password rejected (result \(pwReply.cmdCode))") } var namePayload = Data() CAP.putString(clientName, into: &namePayload) _ = try await request(.clientName, payload: namePayload) // Subscribe to state + metadata variables — EVENT-driven from here. var subPayload = Data() for v in [CAP.Variable.cameraState, .currentReel, .clipNumber, .exposureIndex, .colorTemperature, .tint, .sensorFPS, .timecode] { CAP.putU16(v.rawValue, into: &subPayload) } _ = try await request(.requestVariables, payload: subPayload) emit(CameraState(connection: .connected)) startKeepAlive() } private func teardownAfterFailure() async { readTask?.cancel() readTask = nil closeSocket() } public func disconnect() async { keepAliveTask?.cancel() keepAliveTask = nil readTask?.cancel() readTask = nil closeSocket() emit(CameraState(connection: .disconnected)) } /// CAP requires client traffic every 1 s. private func startKeepAlive() { keepAliveTask?.cancel() keepAliveTask = Task { while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(900)) _ = try? await self.request(.live, timeout: .seconds(2)) } } } public func push(look: FlattenedLook) async throws { if let cdl = look.cdl { var payload = Data() CAP.putU16(CAP.Variable.cdlValues.rawValue, into: &payload) payload.append(CAP.encodeCDLBlob( 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 reply = try await request(.setVariable, payload: payload) guard reply.cmdCode == CAP.Result.ok.rawValue else { throw CameraError.pushFailed("SetVariable CDL -> result \(reply.cmdCode)") } } if let lut = look.lut, !lut.isApproximatelyIdentity() { guard capabilities.lut3dSizes.contains(lut.size) else { throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)") } // EXPERIMENTAL: Set3DLutData payload layout unverified on hardware // (chunk framing sections unavailable publicly). Sim mirrors this // simple layout: U16 lattice size + F32 table. var payload = Data() CAP.putU16(UInt16(lut.size), into: &payload) for v in lut.table { CAP.putF32(v, into: &payload) } let reply = try await request(.set3DLutData, payload: payload, timeout: .seconds(10)) guard reply.cmdCode == CAP.Result.ok.rawValue else { throw CameraError.pushFailed("Set3DLutData -> result \(reply.cmdCode)") } } if look.cdl == nil && (look.lut == nil || look.lut!.isApproximatelyIdentity()) { throw CameraError.unsupportedLook("nothing to push") } } }