diff --git a/Package.swift b/Package.swift index dcd7bc7..9e05d54 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,8 @@ let package = Package( // Simulated cameras .target(name: "ForgeSim", dependencies: [ "ForgeCamera", + "ForgeCameraARRI", // CAP frame codec shared by ARRI sim + "ForgeOffload", // MD5 for CAP challenge verification .product(name: "NIO", package: "swift-nio"), .product(name: "NIOHTTP1", package: "swift-nio"), .product(name: "NIOWebSocket", package: "swift-nio"), @@ -25,8 +27,8 @@ let package = Package( // Vendor drivers .target(name: "ForgeCameraARRI", dependencies: [ "ForgeCamera", + "ForgeOffload", // MD5 for CAP password challenge .product(name: "NIO", package: "swift-nio"), - .product(name: "NIOHTTP1", package: "swift-nio"), ]), .target(name: "ForgeCameraRED", dependencies: [ "ForgeCamera", diff --git a/Sources/ForgeCameraARRI/ArriDriver.swift b/Sources/ForgeCameraARRI/ArriDriver.swift index 9e30403..ce0664c 100644 --- a/Sources/ForgeCameraARRI/ArriDriver.swift +++ b/Sources/ForgeCameraARRI/ArriDriver.swift @@ -1,22 +1,18 @@ import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif import ForgeCamera import ForgeGrade import ForgeColor +import ForgeOffload -/// ARRI REST endpoint paths — single source of truth for this driver. -/// NOTE: shapes modeled on ARRI's documented Camera Companion/REST API family; -/// verify + correct against hardware/API docs in macOS phase. Sim mirrors these. -enum ArriPaths { - static let systemInfo = "/api/v1/system/info" - static let recordingStatus = "/api/v1/recording/status" - static let metadata = "/api/v1/camera/metadata" - static let lookCurrent = "/api/v1/look/current" -} +#if canImport(Glibc) +import Glibc +#endif -/// ALEXA 35 driver: REST over IP. Polls rec state + metadata, pushes CDL+LUT looks. +/// 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, @@ -26,25 +22,27 @@ public actor ArriDriver: CameraDriver { private let host: String private let port: Int - private let pollInterval: Duration - private let session: URLSession + private let password: String + private let clientName: String - private var pollTask: Task? + 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) - - // NOTE: registration is async (actor hop). Safe here: the poll loop - // re-emits on every state *change* vs lastState, so a subscriber attached - // moments late still receives the next change. (A lock-based - // nonisolated-let variant crashed swiftc 6.0.3 codegen on Linux.) private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] - public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) { + public init(host: String, port: Int = 5055, password: String = "arri", clientName: String = "Forge") { self.host = host self.port = port - self.pollInterval = pollInterval - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = 3 - session = URLSession(configuration: config) + self.password = password + self.clientName = clientName } public nonisolated var state: AsyncStream { @@ -72,127 +70,289 @@ public actor ArriDriver: CameraDriver { } } - // MARK: HTTP + // MARK: Socket - private func url(_ path: String) -> URL { - URL(string: "http://\(host):\(port)\(path)")! - } + 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)) - /// Completion-handler dataTask wrapped in a continuation. The async - /// URLSession API hangs intermittently on Linux (dead-connection reuse); - /// the completion path always fires — success, error, or timeout. - private func perform(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) { - try await withCheckedThrowingContinuation { cont in - let task = session.dataTask(with: req) { data, resp, err in - if let err { - cont.resume(throwing: err) - return - } - guard let http = resp as? HTTPURLResponse else { - cont.resume(throwing: CameraError.connectionFailed("no HTTP response")) - return - } - cont.resume(returning: (data ?? Data(), http)) + 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)) } - task.resume() + } + 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 getJSON(_ path: String) async throws -> [String: Any] { - let (data, http) = try await perform(URLRequest(url: url(path))) - guard http.statusCode == 200 else { - throw CameraError.connectionFailed("GET \(path) -> \(http.statusCode)") + 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 + } } - guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw CameraError.connectionFailed("GET \(path): bad JSON") - } - return obj } - private func putJSON(_ path: String, body: Data) async throws { - var req = URLRequest(url: url(path)) - req.httpMethod = "PUT" - req.httpBody = body - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - let (_, http) = try await perform(req) - guard http.statusCode == 200 else { - throw CameraError.pushFailed("PUT \(path) -> \(http.statusCode)") + /// 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 { - // Verify camera reachable via system info. - do { - _ = try await getJSON(ArriPaths.systemInfo) - } catch { - throw CameraError.connectionFailed("system info unreachable: \(error)") + 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)) - startPolling() + startKeepAlive() + } + + private func teardownAfterFailure() async { + readTask?.cancel() + readTask = nil + closeSocket() } public func disconnect() async { - pollTask?.cancel() - pollTask = nil + keepAliveTask?.cancel() + keepAliveTask = nil + readTask?.cancel() + readTask = 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)") - } - var payload: [String: Any] = [:] - if let cdl = look.cdl { - payload["cdl"] = [ - "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, - ] - } - if let lut = look.lut { - payload["lut3dSize"] = lut.size - payload["lut3dTable"] = lut.table - } - let body = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) - try await putJSON(ArriPaths.lookCurrent, body: body) - } - - // MARK: Polling - - private func startPolling() { - pollTask?.cancel() - pollTask = Task { + /// CAP requires client traffic every 1 s. + private func startKeepAlive() { + keepAliveTask?.cancel() + keepAliveTask = Task { while !Task.isCancelled { - await pollOnce() - try? await Task.sleep(for: pollInterval) + try? await Task.sleep(for: .milliseconds(900)) + _ = try? await self.request(.live, timeout: .seconds(2)) } } } - private func pollOnce() async { - do { - let rec = try await getJSON(ArriPaths.recordingStatus) - let md = try await getJSON(ArriPaths.metadata) - let newState = CameraState( - connection: .connected, - isRecording: rec["recording"] as? Bool ?? false, - clipName: rec["clipName"] as? String, - metadata: CameraMetadata( - exposureIndex: md["exposureIndex"] as? Int, - whiteBalance: md["whiteBalance"] as? Int, - tint: md["tint"] as? Int, - fps: md["fps"] as? Double, - timecode: md["timecode"] as? String)) - if newState != lastState { - emit(newState) + 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)") } - } catch { - if lastState.connection == .connected { - emit(CameraState(connection: .disconnected)) + } + 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") } } } diff --git a/Sources/ForgeCameraARRI/CAPProtocol.swift b/Sources/ForgeCameraARRI/CAPProtocol.swift new file mode 100644 index 0000000..e778a64 --- /dev/null +++ b/Sources/ForgeCameraARRI/CAPProtocol.swift @@ -0,0 +1,208 @@ +import Foundation + +/// ARRI Camera Access Protocol (CAP) — binary over TCP :5055, big-endian. +/// Frame: [length U16][msgType U8][msgId U16][cmdCode U16][payload]. +/// length includes the 7-byte header. Verified: docs/research/arri-cap-protocol.md. +public enum CAP { + + public enum MsgType: UInt8, Sendable { + case command = 0x01 + case reply = 0x02 + case event = 0x03 + } + + /// Command codes (verified subset). + public enum Command: UInt16, Sendable { + case live = 0x0080 + case requestPwdChallenge = 0x0081 + case password = 0x0082 + case clientName = 0x0083 + case requestVariables = 0x0084 + case unRequestVariables = 0x0085 + case setVariable = 0x0086 + case welcome = 0x0087 + case getFrameGrab = 0x0088 + case set3DLutData = 0x008c + case saveLookFile = 0x008d + case getVariable = 0x0090 + case recordStart = 0x00a0 + case recordStop = 0x00a1 + case getClipList = 0x00a4 + } + + /// Result codes in reply cmdCode field. + public enum Result: UInt16, Sendable { + case ok = 0x0000 + case noSuchCommand = 0x0001 + case notAllowed = 0x0002 + case noSuchVariables = 0x0003 + case wrongPassword = 0x0004 + case wrongType = 0x0006 + case notAuthorized = 0x0007 + case tooManyClients = 0x0008 + case protocolError = 0x0009 + } + + /// Variable IDs (verified subset). + public enum Variable: UInt16, Sendable, CaseIterable { + case cameraState = 0x0040 + case lookFilename = 0x0041 + case cdlValues = 0x0050 + case colorTemperature = 0x0051 + case tint = 0x0052 + case exposureIndex = 0x0053 + case currentReel = 0x005C + case clipNumber = 0x005D + case sensorFPS = 0x0061 + case timecode = 0x0078 + } + + /// Camera state bitfield (variable 0x0040). + public struct CameraStateBits: OptionSet, Sendable { + public let rawValue: UInt16 + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + public static let recording = CameraStateBits(rawValue: 0x0001) + public static let playback = CameraStateBits(rawValue: 0x0002) + public static let standbyReady = CameraStateBits(rawValue: 0x0004) + public static let idle = CameraStateBits(rawValue: 0x0080) + } + + public struct Frame: Equatable, Sendable { + public var msgType: UInt8 + public var msgId: UInt16 + public var cmdCode: UInt16 + public var payload: Data + + public init(msgType: UInt8, msgId: UInt16, cmdCode: UInt16, payload: Data = Data()) { + self.msgType = msgType + self.msgId = msgId + self.cmdCode = cmdCode + self.payload = payload + } + } + + public enum CodecError: Error, Equatable { + case frameTooShort + case lengthMismatch + } + + // MARK: Encoding primitives (big-endian) + + public static func putU16(_ v: UInt16, into data: inout Data) { + data.append(UInt8(v >> 8)) + data.append(UInt8(v & 0xFF)) + } + + public static func putU32(_ v: UInt32, into data: inout Data) { + data.append(UInt8((v >> 24) & 0xFF)) + data.append(UInt8((v >> 16) & 0xFF)) + data.append(UInt8((v >> 8) & 0xFF)) + data.append(UInt8(v & 0xFF)) + } + + public static func putF32(_ v: Float, into data: inout Data) { + putU32(v.bitPattern, into: &data) + } + + /// CAP string: U16 length + UTF-8. + public static func putString(_ s: String, into data: inout Data) { + let bytes = Data(s.utf8) + putU16(UInt16(bytes.count), into: &data) + data.append(bytes) + } + + public static func readU16(_ data: Data, at offset: Int) -> UInt16? { + guard data.count >= offset + 2 else { return nil } + let i = data.startIndex + offset + return UInt16(data[i]) << 8 | UInt16(data[i + 1]) + } + + public static func readU32(_ data: Data, at offset: Int) -> UInt32? { + guard data.count >= offset + 4 else { return nil } + let i = data.startIndex + offset + return UInt32(data[i]) << 24 | UInt32(data[i + 1]) << 16 + | UInt32(data[i + 2]) << 8 | UInt32(data[i + 3]) + } + + public static func readF32(_ data: Data, at offset: Int) -> Float? { + readU32(data, at: offset).map { Float(bitPattern: $0) } + } + + public static func readString(_ data: Data, at offset: Int) -> (value: String, consumed: Int)? { + guard let len = readU16(data, at: offset), + data.count >= offset + 2 + Int(len) else { return nil } + let start = data.startIndex + offset + 2 + let s = String(data: data.subdata(in: start.. Data { + var out = Data() + let total = UInt16(7 + frame.payload.count) + putU16(total, into: &out) + out.append(frame.msgType) + putU16(frame.msgId, into: &out) + putU16(frame.cmdCode, into: &out) + out.append(frame.payload) + return out + } + + /// Decode first complete frame from buffer, consuming it. nil if incomplete. + public static func decodeFirst(_ buffer: inout Data) throws -> Frame? { + guard buffer.count >= 2 else { return nil } + guard let length = readU16(buffer, at: 0) else { return nil } + guard length >= 7 else { throw CodecError.frameTooShort } + guard buffer.count >= Int(length) else { return nil } + + let base = buffer.startIndex + let msgType = buffer[base + 2] + guard let msgId = readU16(buffer, at: 3), + let cmdCode = readU16(buffer, at: 5) else { return nil } + let payload = buffer.subdata(in: base + 7.. "HH:MM:SS:FF". + public static func decodeTimecodeBCD(_ v: UInt32) -> String { + func bcd(_ byte: UInt32) -> UInt32 { + (byte >> 4) * 10 + (byte & 0xF) + } + let h = bcd((v >> 24) & 0xFF) + let m = bcd((v >> 16) & 0xFF) + let s = bcd((v >> 8) & 0xFF) + let f = bcd(v & 0xFF) + return String(format: "%02d:%02d:%02d:%02d", h, m, s, f) + } + + public static func encodeTimecodeBCD(_ tc: String) -> UInt32? { + let parts = tc.split(separator: ":").compactMap { UInt32($0) } + guard parts.count == 4 else { return nil } + func toBCD(_ v: UInt32) -> UInt32 { + (v / 10) << 4 | (v % 10) + } + return toBCD(parts[0]) << 24 | toBCD(parts[1]) << 16 | toBCD(parts[2]) << 8 | toBCD(parts[3]) + } + + /// CDL BLOB: 10×F32 slope(3) offset(3) power(3) sat — order per ASC convention (INFERRED). + public static func encodeCDLBlob(slope: (Float, Float, Float), offset: (Float, Float, Float), + power: (Float, Float, Float), saturation: Float) -> Data { + var out = Data() + for v in [slope.0, slope.1, slope.2, offset.0, offset.1, offset.2, + power.0, power.1, power.2, saturation] { + putF32(v, into: &out) + } + return out + } + + public static func decodeCDLBlob(_ data: Data) -> [Float]? { + guard data.count == 40 else { return nil } + return (0..<10).compactMap { readF32(data, at: $0 * 4) } + } +} diff --git a/Sources/ForgeSim/ArriSimulator.swift b/Sources/ForgeSim/ArriSimulator.swift index ce92611..13159c5 100644 --- a/Sources/ForgeSim/ArriSimulator.swift +++ b/Sources/ForgeSim/ArriSimulator.swift @@ -1,92 +1,279 @@ import Foundation import NIO -import NIOHTTP1 - -/// In-process HTTP server mimicking ARRI ALEXA 35 REST API shape. -/// Endpoint paths kept in one place (`ArriSimPaths`) — mirror of the driver's -/// constants file; correct both together when verified against hardware. -public enum ArriSimPaths { - public static let systemInfo = "/api/v1/system/info" - public static let recordingStatus = "/api/v1/recording/status" - public static let metadata = "/api/v1/camera/metadata" - public static let lookCurrent = "/api/v1/look/current" -} +import ForgeCameraARRI +import ForgeOffload +/// CAP-faithful ARRI ALEXA 35 simulator. +/// Binary TCP per docs/research/arri-cap-protocol.md: +/// Welcome on connect -> RequestPwdChallenge -> Password(MD5(challenge+password)) +/// -> ClientName -> Get/Set/RequestVariables. EVENT frames on subscribed changes. public actor ArriSimulator { private var group: MultiThreadedEventLoopGroup? private var channel: Channel? public private(set) var boundPort: Int? - // Scriptable state. - private var recording = false - private var clipName: String? - private var ei = 800 - private var wb = 5600 - private var tint = 0 - private var fps = 24.0 - private var timecode = "00:00:00:00" - /// Raw JSON bodies of accepted look uploads (Sendable; decode in consumers). - public private(set) var uploadedLooks: [Data] = [] + /// Camera password (camera GUI setting; ARRI default "arri"). + public let password: String + private let challenge = "SIMCHAL01" - public init() {} + // Scriptable camera state. + private var stateBits: CAP.CameraStateBits = [.standbyReady] + private var reel: UInt16 = 1 + private var clipNumber: UInt16 = 0 + private var ei: UInt32 = 800 + private var colorTemp: UInt32 = 5600 + private var tint: Float = 0 + private var fps: Float = 24 + private var timecodeBCD: UInt32 = 0 + /// Last CDL blob written via SetVariable 0x0050 (10 F32). + public private(set) var lastCDL: [Float]? + /// Raw 3D LUT payloads received via Set3DLutData. + public private(set) var lutUploads: [Data] = [] + public private(set) var clientNames: [String] = [] - // MARK: Scripting hooks + private struct Session { + var authenticated = false + var challengeSent = false + var subscriptions = Set() + } + private var sessions: [ObjectIdentifier: Session] = [:] + private var channels: [ObjectIdentifier: Channel] = [:] - public func setRecording(_ on: Bool, clipName: String? = nil) { - recording = on - if let c = clipName { self.clipName = c } - if !on { /* keep clip name for last-clip queries */ } + public init(password: String = "arri") { + self.password = password } - public func setMetadata(ei: Int, wb: Int, tint: Int, fps: Double, timecode: String) { + // MARK: Scripting + + public func setRecording(_ on: Bool, clipNumber: UInt16? = nil) { + if on { + stateBits.insert(.recording) + if let c = clipNumber { self.clipNumber = c } else { self.clipNumber += 1 } + } else { + stateBits.remove(.recording) + } + // Clip number first so rec-start state already carries the new clip. + notifySubscribers(variable: .clipNumber) + notifySubscribers(variable: .cameraState) + } + + public func setMetadata(ei: UInt32, colorTemp: UInt32, tint: Float, fps: Float, timecode: String) { self.ei = ei - self.wb = wb + self.colorTemp = colorTemp self.tint = tint self.fps = fps - self.timecode = timecode - } - - // MARK: Request handling (called from channel handler) - - func handle(method: HTTPMethod, uri: String, body: Data?) -> (status: HTTPResponseStatus, body: Data) { - switch (method, uri) { - case (.GET, ArriSimPaths.systemInfo): - return (.ok, json([ - "model": "ALEXA 35", - "serialNumber": "SIM-35-0001", - "firmwareVersion": "SUP 2.0-sim", - ])) - case (.GET, ArriSimPaths.recordingStatus): - var obj: [String: Any] = ["recording": recording] - if let c = clipName { obj["clipName"] = c } - return (.ok, json(obj)) - case (.GET, ArriSimPaths.metadata): - return (.ok, json([ - "exposureIndex": ei, - "whiteBalance": wb, - "tint": tint, - "fps": fps, - "timecode": timecode, - ])) - case (.GET, ArriSimPaths.lookCurrent): - guard let last = uploadedLooks.last else { - return (.notFound, json(["error": "no look set"])) - } - return (.ok, last) - case (.PUT, ArriSimPaths.lookCurrent): - guard let body, - (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) != nil else { - return (.badRequest, json(["error": "invalid JSON"])) - } - uploadedLooks.append(body) - return (.ok, json(["status": "accepted"])) - default: - return (.notFound, json(["error": "unknown path"])) + self.timecodeBCD = CAP.encodeTimecodeBCD(timecode) ?? 0 + for v in [CAP.Variable.exposureIndex, .colorTemperature, .tint, .sensorFPS, .timecode] { + notifySubscribers(variable: v) } } - private func json(_ obj: [String: Any]) -> Data { - (try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data() + // MARK: Variable encoding + + private func encodeVariable(_ id: UInt16) -> Data? { + guard let variable = CAP.Variable(rawValue: id) else { return nil } + var out = Data() + CAP.putU16(id, into: &out) + switch variable { + case .cameraState: + CAP.putU16(stateBits.rawValue, into: &out) + case .lookFilename: + CAP.putString("forge-sim-look", into: &out) + case .cdlValues: + let blob = lastCDL ?? [1, 1, 1, 0, 0, 0, 1, 1, 1, 1] + for v in blob { CAP.putF32(v, into: &out) } + case .colorTemperature: + CAP.putU32(colorTemp, into: &out) + case .tint: + CAP.putF32(tint, into: &out) + case .exposureIndex: + CAP.putU32(ei, into: &out) + case .currentReel: + CAP.putU16(reel, into: &out) + case .clipNumber: + CAP.putU16(clipNumber, into: &out) + case .sensorFPS: + CAP.putF32(fps, into: &out) + case .timecode: + CAP.putU32(timecodeBCD, into: &out) + } + return out + } + + private func notifySubscribers(variable: CAP.Variable) { + guard let payload = encodeVariable(variable.rawValue) else { return } + for (key, session) in sessions where session.authenticated && session.subscriptions.contains(variable.rawValue) { + guard let ch = channels[key] else { continue } + let frame = CAP.Frame( + msgType: CAP.MsgType.event.rawValue, + msgId: 0, + cmdCode: CAP.Command.requestVariables.rawValue, + payload: payload) + Self.write(frame, to: ch) + } + } + + // MARK: Connection lifecycle + + func clientConnected(_ ch: Channel) { + let key = ObjectIdentifier(ch) + sessions[key] = Session() + channels[key] = ch + // Welcome: protocol version string + result OK. + var payload = Data() + CAP.putString("CAP 1.12 sim", into: &payload) + CAP.putU16(CAP.Result.ok.rawValue, into: &payload) + Self.write(CAP.Frame( + msgType: CAP.MsgType.reply.rawValue, + msgId: 0, + cmdCode: CAP.Command.welcome.rawValue, + payload: payload), to: ch) + } + + func clientDisconnected(_ ch: Channel) { + let key = ObjectIdentifier(ch) + sessions.removeValue(forKey: key) + channels.removeValue(forKey: key) + } + + func handleFrame(_ frame: CAP.Frame, from ch: Channel) { + let key = ObjectIdentifier(ch) + guard var session = sessions[key] else { return } + guard let cmd = CAP.Command(rawValue: frame.cmdCode) else { + reply(to: frame, result: .noSuchCommand, on: ch) + return + } + + func requireAuth() -> Bool { + if !session.authenticated { + reply(to: frame, result: .notAuthorized, on: ch) + return false + } + return true + } + + switch cmd { + case .live: + reply(to: frame, result: .ok, on: ch) + + case .requestPwdChallenge: + session.challengeSent = true + var payload = Data() + CAP.putString(challenge, into: &payload) + replyPayload(to: frame, result: .ok, payload: payload, on: ch) + + case .password: + guard session.challengeSent, + let (hash, _) = CAP.readString(frame.payload, at: 0) else { + reply(to: frame, result: .protocolError, on: ch) + break + } + // Spec: MD5(challenge concat password). + let expected = MD5.hexString(Data((challenge + password).utf8)) + if hash.lowercased() == expected { + session.authenticated = true + reply(to: frame, result: .ok, on: ch) + } else { + reply(to: frame, result: .wrongPassword, on: ch) + } + + case .clientName: + guard requireAuth() else { break } + if let (name, _) = CAP.readString(frame.payload, at: 0) { + clientNames.append(name) + } + reply(to: frame, result: .ok, on: ch) + + case .getVariable: + guard requireAuth() else { break } + guard let id = CAP.readU16(frame.payload, at: 0), + let payload = encodeVariable(id) else { + reply(to: frame, result: .noSuchVariables, on: ch) + break + } + replyPayload(to: frame, result: .ok, payload: payload, on: ch) + + case .setVariable: + guard requireAuth() else { break } + guard let id = CAP.readU16(frame.payload, at: 0) else { + reply(to: frame, result: .protocolError, on: ch) + break + } + if id == CAP.Variable.cdlValues.rawValue { + let blobData = frame.payload.subdata(in: frame.payload.startIndex + 2.. CAP frames -> sim actor. +final class CAPServerHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer private let sim: ArriSimulator - private var method: HTTPMethod = .GET - private var uri: String = "/" - private var bodyBuffer: ByteBuffer? + private var buffer = Data() init(sim: ArriSimulator) { self.sim = sim } + func channelActive(context: ChannelHandlerContext) { + let ch = context.channel + Task { await sim.clientConnected(ch) } + context.fireChannelActive() + } + + func channelInactive(context: ChannelHandlerContext) { + let ch = context.channel + Task { await sim.clientDisconnected(ch) } + context.fireChannelInactive() + } + func channelRead(context: ChannelHandlerContext, data: NIOAny) { - switch unwrapInboundIn(data) { - case .head(let head): - method = head.method - uri = head.uri - bodyBuffer = nil - case .body(var buf): - if bodyBuffer == nil { - bodyBuffer = buf - } else { - bodyBuffer?.writeBuffer(&buf) - } - case .end: - let body = bodyBuffer.map { Data($0.readableBytesView) } - let m = method - let u = uri - let channel = context.channel - let loop = context.eventLoop - Task { - let (status, respBody) = await self.sim.handle(method: m, uri: u, body: body) - loop.execute { - var headers = HTTPHeaders() - headers.add(name: "Content-Type", value: "application/json") - headers.add(name: "Content-Length", value: "\(respBody.count)") - // Close after response: idle keep-alive channels block - // shutdownGracefully() and hang test teardown. - headers.add(name: "Connection", value: "close") - let head = HTTPResponseHead(version: .http1_1, status: status, headers: headers) - channel.write(HTTPServerResponsePart.head(head), promise: nil) - var buf = channel.allocator.buffer(capacity: respBody.count) - buf.writeBytes(respBody) - channel.write(HTTPServerResponsePart.body(.byteBuffer(buf)), promise: nil) - channel.writeAndFlush(HTTPServerResponsePart.end(nil)).whenComplete { _ in - channel.close(promise: nil) - } - } - } + var incoming = unwrapInboundIn(data) + if let bytes = incoming.readBytes(length: incoming.readableBytes) { + buffer.append(contentsOf: bytes) + } + let ch = context.channel + while let frame = try? CAP.decodeFirst(&buffer) { + Task { await sim.handleFrame(frame, from: ch) } } } } diff --git a/Tests/ForgeCameraARRITests/ArriDriverTests.swift b/Tests/ForgeCameraARRITests/ArriDriverTests.swift index 8ec309f..9630bfd 100644 --- a/Tests/ForgeCameraARRITests/ArriDriverTests.swift +++ b/Tests/ForgeCameraARRITests/ArriDriverTests.swift @@ -1,16 +1,19 @@ import XCTest +import Foundation import ForgeCamera import ForgeGrade import ForgeColor import ForgeSim @testable import ForgeCameraARRI +/// Tests against CAP-faithful simulator (binary TCP, MD5 challenge auth, +/// variable subscription — docs/research/arri-cap-protocol.md). final class ArriDriverTests: XCTestCase { var sim: ArriSimulator! override func setUp() async throws { - sim = ArriSimulator() + sim = ArriSimulator(password: "arri") try await sim.start(port: 0) } @@ -19,77 +22,69 @@ final class ArriDriverTests: XCTestCase { sim = nil } - func makeDriver(pollInterval: Duration = .milliseconds(20)) async -> ArriDriver { + func makeDriver(password: String = "arri") async -> ArriDriver { let port = await sim.boundPort! - return ArriDriver(host: "127.0.0.1", port: port, pollInterval: pollInterval) + return ArriDriver(host: "127.0.0.1", port: port, password: password) } - // Capabilities: ARRI = native CDL + 33/65 LUTs. func testCapabilities() async { let driver = await makeDriver() XCTAssertEqual(driver.capabilities.vendor, .arri) XCTAssertTrue(driver.capabilities.supportsNativeCDL) XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33)) - XCTAssertTrue(driver.capabilities.metadataFields.contains(.clipName)) } - // Connect verifies system info and emits connected state. - func testConnectEmitsConnected() async throws { + // Full handshake: challenge -> MD5 password -> client name -> subscribed + connected. + func testConnectHandshake() async throws { let driver = await makeDriver() var iterator = driver.state.makeAsyncIterator() try await driver.connect() var connected = false - for _ in 0..<5 { + for _ in 0..<10 { if let s = await iterator.next(), s.connection == .connected { connected = true break } } XCTAssertTrue(connected) + let names = await sim.clientNames + XCTAssertEqual(names, ["Forge"]) await driver.disconnect() } - // Connect to dead port throws. + // Wrong password -> connectionFailed, never authenticated. + func testWrongPasswordRejected() async throws { + let driver = await makeDriver(password: "wrong") + do { + try await driver.connect() + XCTFail("expected throw") + } catch let e as CameraError { + guard case .connectionFailed(let msg) = e else { return XCTFail("wrong error") } + XCTAssertTrue(msg.contains("password")) + } + let names = await sim.clientNames + XCTAssertTrue(names.isEmpty) + } + func testConnectFailsOnDeadPort() async { - let driver = ArriDriver(host: "127.0.0.1", port: 1, pollInterval: .milliseconds(20)) + let driver = ArriDriver(host: "127.0.0.1", port: 1) do { try await driver.connect() XCTFail("expected throw") } catch {} } - // Poll loop surfaces metadata. - func testMetadataPolling() async throws { - await sim.setMetadata(ei: 1280, wb: 3200, tint: -2, fps: 25, timecode: "10:20:30:12") - let driver = await makeDriver() - var iterator = driver.state.makeAsyncIterator() - try await driver.connect() - - var got = false - for _ in 0..<20 { - if let s = await iterator.next(), - let md = s.metadata, - md.exposureIndex == 1280, md.whiteBalance == 3200 { - XCTAssertEqual(md.timecode, "10:20:30:12") - got = true - break - } - } - XCTAssertTrue(got) - await driver.disconnect() - } - - // Rec start/stop events with clip name. + // Subscription events: rec start/stop with composed clip name RxxxCxxx. func testRecStateEvents() async throws { let driver = await makeDriver() var iterator = driver.state.makeAsyncIterator() try await driver.connect() - await sim.setRecording(true, clipName: "A001C007_250710_R1CD") + await sim.setRecording(true, clipNumber: 7) + // Events arrive per-variable; wait for a state with BOTH rec + clip. var sawRec = false - for _ in 0..<30 { - if let s = await iterator.next(), s.isRecording { - XCTAssertEqual(s.clipName, "A001C007_250710_R1CD") + for _ in 0..<40 { + if let s = await iterator.next(), s.isRecording, s.clipName == "R001C007" { sawRec = true break } @@ -108,61 +103,100 @@ final class ArriDriverTests: XCTestCase { await driver.disconnect() } - // push(look:) uploads CDL + LUT payload sim can parse. - func testPushLook() async throws { + // Metadata via subscribed variables incl. BCD timecode decode. + func testMetadataEvents() async throws { let driver = await makeDriver() + var iterator = driver.state.makeAsyncIterator() try await driver.connect() - let cdl = CDL(slope: SIMD3(1.1, 1.0, 0.9), offset: .zero, power: .one, saturation: 1.2) - let lut = Lut3D.identity(size: 33) - let look = FlattenedLook(cdl: cdl, lut: lut, latticeSize: 33) - try await driver.push(look: look) - - let uploads = await sim.uploadedLooks - XCTAssertEqual(uploads.count, 1) - let obj = try JSONSerialization.jsonObject(with: uploads[0]) as? [String: Any] - let cdlObj = obj?["cdl"] as? [String: Any] - let slope = cdlObj?["slope"] as? [Double] - XCTAssertEqual(slope?[0] ?? 0, 1.1, accuracy: 1e-5) - XCTAssertEqual(cdlObj?["saturation"] as? Double ?? 0, 1.2, accuracy: 1e-5) - XCTAssertEqual(obj?["lut3dSize"] as? Int, 33) - let table = obj?["lut3dTable"] as? [Double] - XCTAssertEqual(table?.count, 33 * 33 * 33 * 3) + await sim.setMetadata(ei: 1280, colorTemp: 3200, tint: -2, fps: 25, timecode: "10:20:30:12") + var got = false + for _ in 0..<40 { + if let s = await iterator.next(), let md = s.metadata, + md.exposureIndex == 1280, md.whiteBalance == 3200, + md.tint == -2, md.timecode == "10:20:30:12", + let fps = md.fps, abs(fps - 25) < 0.01 { + got = true + break + } + } + XCTAssertTrue(got) await driver.disconnect() } - // Unsupported LUT size rejected before hitting the wire. + // CDL push -> SetVariable 0x0050 blob lands in sim with correct 10 floats. + func testPushCDL() async throws { + let driver = await makeDriver() + try await driver.connect() + + let cdl = CDL( + slope: SIMD3(1.1, 1.0, 0.9), + offset: SIMD3(0.02, 0.0, -0.01), + power: SIMD3(0.95, 1.0, 1.05), + saturation: 1.3) + try await driver.push(look: FlattenedLook(cdl: cdl, lut: nil, latticeSize: 33)) + + let blob = await sim.lastCDL + XCTAssertNotNil(blob) + XCTAssertEqual(blob![0], 1.1, accuracy: 1e-5) // slope R + XCTAssertEqual(blob![2], 0.9, accuracy: 1e-5) // slope B + XCTAssertEqual(blob![3], 0.02, accuracy: 1e-5) // offset R + XCTAssertEqual(blob![5], -0.01, accuracy: 1e-5) + XCTAssertEqual(blob![6], 0.95, accuracy: 1e-5) // power R + XCTAssertEqual(blob![9], 1.3, accuracy: 1e-5) // sat + await driver.disconnect() + } + + // CDL + LUT push: both land (LUT path experimental but sim accepts). + func testPushCDLAndLut() async throws { + let driver = await makeDriver() + try await driver.connect() + + let look = FlattenedLook( + cdl: CDL(slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1), + lut: Lut3D.build(size: 17) { $0 * 0.9 }, + latticeSize: 17) + try await driver.push(look: look) + + let cdl = await sim.lastCDL + XCTAssertEqual(cdl?[0] ?? 0, 1.2, accuracy: 1e-5) + let luts = await sim.lutUploads + XCTAssertEqual(luts.count, 1) + // U16 size prefix + 17³×3 floats. + XCTAssertEqual(luts[0].count, 2 + 17 * 17 * 17 * 3 * 4) + await driver.disconnect() + } + + // Unsupported LUT size rejected before wire. func testUnsupportedLutSizeRejected() async throws { let driver = await makeDriver() try await driver.connect() - let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 5), latticeSize: 5) + let look = FlattenedLook(cdl: nil, lut: Lut3D.build(size: 5) { $0 * 0.5 }, latticeSize: 5) do { try await driver.push(look: look) XCTFail("expected unsupportedLook") - } catch let error as CameraError { - guard case .unsupportedLook = error else { return XCTFail("wrong error") } + } catch let e as CameraError { + guard case .unsupportedLook = e else { return XCTFail("wrong error") } } - let uploads = await sim.uploadedLooks - XCTAssertTrue(uploads.isEmpty) await driver.disconnect() } - // Reconnect after sim restart. + // Reconnect after sim restart on same port. func testReconnectAfterSimRestart() async throws { let port = await sim.boundPort! - let driver = ArriDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(20)) + let driver = ArriDriver(host: "127.0.0.1", port: port) try await driver.connect() await driver.disconnect() try await sim.stop() - sim = ArriSimulator() + sim = ArriSimulator(password: "arri") try await sim.start(port: port) try await driver.connect() - let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33) - try await driver.push(look: look) - let uploads = await sim.uploadedLooks - XCTAssertEqual(uploads.count, 1) + try await driver.push(look: FlattenedLook( + cdl: .identity, lut: nil, latticeSize: 33)) + let blob = await sim.lastCDL + XCTAssertNotNil(blob) await driver.disconnect() } } diff --git a/Tests/ForgeCameraARRITests/CAPProtocolTests.swift b/Tests/ForgeCameraARRITests/CAPProtocolTests.swift new file mode 100644 index 0000000..1126fe9 --- /dev/null +++ b/Tests/ForgeCameraARRITests/CAPProtocolTests.swift @@ -0,0 +1,107 @@ +import XCTest +import Foundation +@testable import ForgeCameraARRI + +final class CAPProtocolTests: XCTestCase { + + // Frame encode: length includes 7-byte header, big-endian fields. + func testEncodeFrame() { + let frame = CAP.Frame( + msgType: CAP.MsgType.command.rawValue, + msgId: 0x1234, + cmdCode: CAP.Command.live.rawValue, + payload: Data([0xAA, 0xBB])) + let data = CAP.encode(frame) + XCTAssertEqual([UInt8](data), [ + 0x00, 0x09, // length 9 = 7 + 2 + 0x01, // COMMAND + 0x12, 0x34, // msgId + 0x00, 0x80, // Live + 0xAA, 0xBB, + ]) + } + + // Round trip. + func testDecodeRoundTrip() throws { + let frame = CAP.Frame(msgType: 0x02, msgId: 7, cmdCode: 0x0000, payload: Data([1, 2, 3])) + var buf = CAP.encode(frame) + let decoded = try XCTUnwrap(CAP.decodeFirst(&buf)) + XCTAssertEqual(decoded, frame) + XCTAssertTrue(buf.isEmpty) + } + + // Partial frame buffers. + func testPartialFrame() throws { + let full = CAP.encode(CAP.Frame(msgType: 0x01, msgId: 1, cmdCode: 0x0080)) + var buf = Data(full.prefix(4)) + XCTAssertNil(try CAP.decodeFirst(&buf)) + buf.append(full.suffix(from: 4)) + XCTAssertNotNil(try CAP.decodeFirst(&buf)) + } + + // Two frames in one buffer. + func testTwoFrames() throws { + var buf = CAP.encode(CAP.Frame(msgType: 0x01, msgId: 1, cmdCode: 0x0080)) + buf.append(CAP.encode(CAP.Frame(msgType: 0x01, msgId: 2, cmdCode: 0x0090))) + let f1 = try XCTUnwrap(CAP.decodeFirst(&buf)) + let f2 = try XCTUnwrap(CAP.decodeFirst(&buf)) + XCTAssertEqual(f1.msgId, 1) + XCTAssertEqual(f2.msgId, 2) + } + + // Bogus length rejected. + func testShortLengthRejected() { + var buf = Data([0x00, 0x03, 0x01]) + XCTAssertThrowsError(try CAP.decodeFirst(&buf)) + } + + // CAP string encoding. + func testStringCodec() { + var data = Data() + CAP.putString("Forge", into: &data) + XCTAssertEqual([UInt8](data.prefix(2)), [0x00, 0x05]) + let read = CAP.readString(data, at: 0) + XCTAssertEqual(read?.value, "Forge") + XCTAssertEqual(read?.consumed, 7) + } + + // BCD timecode. + func testTimecodeBCD() { + // 12:34:56:23 -> BCD 0x12345623 + XCTAssertEqual(CAP.decodeTimecodeBCD(0x12345623), "12:34:56:23") + XCTAssertEqual(CAP.encodeTimecodeBCD("12:34:56:23"), 0x12345623) + XCTAssertEqual(CAP.decodeTimecodeBCD(CAP.encodeTimecodeBCD("09:59:00:01")!), "09:59:00:01") + } + + // CDL blob: 10 F32 big-endian, 40 bytes. + func testCDLBlob() { + let blob = CAP.encodeCDLBlob( + slope: (1.1, 1.0, 0.9), + offset: (0.01, 0.0, -0.01), + power: (0.95, 1.0, 1.05), + saturation: 1.2) + XCTAssertEqual(blob.count, 40) + let values = CAP.decodeCDLBlob(blob)! + XCTAssertEqual(values[0], 1.1, accuracy: 1e-6) + XCTAssertEqual(values[2], 0.9, accuracy: 1e-6) + XCTAssertEqual(values[5], -0.01, accuracy: 1e-6) + XCTAssertEqual(values[9], 1.2, accuracy: 1e-6) + } + + // Camera state bitfield. + func testCameraStateBits() { + let bits = CAP.CameraStateBits(rawValue: 0x0005) + XCTAssertTrue(bits.contains(.recording)) + XCTAssertTrue(bits.contains(.standbyReady)) + XCTAssertFalse(bits.contains(.playback)) + } + + // U32/F32 round trip. + func testNumericCodecs() { + var data = Data() + CAP.putU32(0xDEADBEEF, into: &data) + CAP.putF32(3.14159, into: &data) + XCTAssertEqual(CAP.readU32(data, at: 0), 0xDEADBEEF) + XCTAssertEqual(CAP.readF32(data, at: 4) ?? 0, 3.14159, accuracy: 1e-5) + } +} diff --git a/Tests/ForgeCameraTests/ArriSimTests.swift b/Tests/ForgeCameraTests/ArriSimTests.swift index c7396dd..4aa4925 100644 --- a/Tests/ForgeCameraTests/ArriSimTests.swift +++ b/Tests/ForgeCameraTests/ArriSimTests.swift @@ -1,106 +1,187 @@ import XCTest import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif +import ForgeCameraARRI +import ForgeOffload @testable import ForgeSim +/// CAP-level sim tests: raw TCP client speaking binary CAP frames. final class ArriSimTests: XCTestCase { var sim: ArriSimulator! override func setUp() async throws { - sim = ArriSimulator() - try await sim.start(port: 0) // ephemeral + sim = ArriSimulator(password: "arri") + try await sim.start(port: 0) } override func tearDown() async throws { - try await sim.stop() + try? await sim.stop() sim = nil } - func get(_ path: String) async throws -> (Int, Data) { + /// Minimal blocking CAP test client. + final class TestClient { + let fd: Int32 + var buffer = Data() + var msgId: UInt16 = 100 + + init?(port: Int) { + fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) + guard fd >= 0 else { return nil } + var tv = timeval(tv_sec: 2, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &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) + _ = inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) + let rc = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + connect(fd, sa, socklen_t(MemoryLayout.size)) + } + } + guard rc == 0 else { + close(fd) + return nil + } + } + + deinit { + close(fd) + } + + func send(_ cmd: CAP.Command, payload: Data = Data()) -> UInt16 { + msgId &+= 1 + let frame = CAP.Frame( + msgType: CAP.MsgType.command.rawValue, + msgId: msgId, + cmdCode: cmd.rawValue, + payload: payload) + let data = CAP.encode(frame) + _ = data.withUnsafeBytes { write(fd, $0.baseAddress, $0.count) } + return msgId + } + + func readFrame() -> CAP.Frame? { + var chunk = [UInt8](repeating: 0, count: 8192) + for _ in 0..<40 { + if let f = try? CAP.decodeFirst(&buffer), f != nil { + return f + } + let n = read(fd, &chunk, chunk.count) + if n > 0 { + buffer.append(contentsOf: chunk[0.. Bool) -> CAP.Frame? { + for _ in 0..<50 { + guard let f = readFrame() else { return nil } + if predicate(f) { return f } + } + return nil + } + + func authenticate(password: String) -> Bool { + // Welcome arrives unsolicited. + guard readUntil({ $0.cmdCode == CAP.Command.welcome.rawValue }) != nil else { return false } + let chalId = send(.requestPwdChallenge) + guard let chalReply = readUntil({ $0.msgId == chalId }), + let (challenge, _) = CAP.readString(chalReply.payload, at: 0) else { return false } + var pw = Data() + CAP.putString(MD5.hexString(Data((challenge + password).utf8)), into: &pw) + let pwId = send(.password, payload: pw) + guard let pwReply = readUntil({ $0.msgId == pwId }), + pwReply.cmdCode == CAP.Result.ok.rawValue else { return false } + return true + } + } + + func testWelcomeOnConnect() async throws { let port = await sim.boundPort! - let url = URL(string: "http://127.0.0.1:\(port)\(path)")! - let (data, resp) = try await URLSession.shared.data(from: url) - return ((resp as! HTTPURLResponse).statusCode, data) + let client = try XCTUnwrap(TestClient(port: port)) + let welcome = client.readUntil { $0.cmdCode == CAP.Command.welcome.rawValue } + XCTAssertNotNil(welcome) + let (version, _) = CAP.readString(welcome!.payload, at: 0) ?? ("", 0) + XCTAssertTrue(version.contains("CAP")) } - func put(_ path: String, body: Data) async throws -> (Int, Data) { + func testAuthFlow() async throws { let port = await sim.boundPort! - var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)\(path)")!) - req.httpMethod = "PUT" - req.httpBody = body - let (data, resp) = try await URLSession.shared.data(for: req) - return ((resp as! HTTPURLResponse).statusCode, data) + let client = try XCTUnwrap(TestClient(port: port)) + XCTAssertTrue(client.authenticate(password: "arri")) } - // System info endpoint serves model + serial JSON. - func testSystemInfo() async throws { - let (status, data) = try await get("/api/v1/system/info") - XCTAssertEqual(status, 200) - let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] - XCTAssertEqual(obj?["model"] as? String, "ALEXA 35") - XCTAssertNotNil(obj?["serialNumber"]) + func testWrongPassword() async throws { + let port = await sim.boundPort! + let client = try XCTUnwrap(TestClient(port: port)) + XCTAssertFalse(client.authenticate(password: "nope")) } - // Rec state reflects scripted changes. - func testRecStateScriptable() async throws { - var (_, data) = try await get("/api/v1/recording/status") - var obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] - XCTAssertEqual(obj?["recording"] as? Bool, false) - - await sim.setRecording(true, clipName: "A001C003_250710_R1CD") - (_, data) = try await get("/api/v1/recording/status") - obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] - XCTAssertEqual(obj?["recording"] as? Bool, true) - XCTAssertEqual(obj?["clipName"] as? String, "A001C003_250710_R1CD") + func testUnauthenticatedGetRejected() async throws { + let port = await sim.boundPort! + let client = try XCTUnwrap(TestClient(port: port)) + _ = client.readUntil { $0.cmdCode == CAP.Command.welcome.rawValue } + var payload = Data() + CAP.putU16(CAP.Variable.exposureIndex.rawValue, into: &payload) + let id = client.send(.getVariable, payload: payload) + let reply = client.readUntil { $0.msgId == id } + XCTAssertEqual(reply?.cmdCode, CAP.Result.notAuthorized.rawValue) } - // Metadata endpoint. - func testMetadata() async throws { - await sim.setMetadata(ei: 1280, wb: 3200, tint: -2, fps: 25, timecode: "10:20:30:12") - let (status, data) = try await get("/api/v1/camera/metadata") - XCTAssertEqual(status, 200) - let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] - XCTAssertEqual(obj?["exposureIndex"] as? Int, 1280) - XCTAssertEqual(obj?["whiteBalance"] as? Int, 3200) - XCTAssertEqual(obj?["tint"] as? Int, -2) - XCTAssertEqual(obj?["timecode"] as? String, "10:20:30:12") + func testGetVariable() async throws { + await sim.setMetadata(ei: 1280, colorTemp: 3200, tint: -2, fps: 25, timecode: "01:02:03:04") + let port = await sim.boundPort! + let client = try XCTUnwrap(TestClient(port: port)) + XCTAssertTrue(client.authenticate(password: "arri")) + + var payload = Data() + CAP.putU16(CAP.Variable.exposureIndex.rawValue, into: &payload) + let id = client.send(.getVariable, payload: payload) + let reply = client.readUntil { $0.msgId == id } + XCTAssertEqual(reply?.cmdCode, CAP.Result.ok.rawValue) + XCTAssertEqual(CAP.readU16(reply!.payload, at: 0), CAP.Variable.exposureIndex.rawValue) + XCTAssertEqual(CAP.readU32(reply!.payload, at: 2), 1280) } - // Look upload stored and retrievable. - func testLookUploadStored() async throws { - let payload = try JSONSerialization.data(withJSONObject: [ - "name": "shot42", - "cdl": ["slope": [1.1, 1.0, 0.9], "offset": [0, 0, 0], "power": [1, 1, 1], "saturation": 1.2], - "lut3dSize": 2, - "lut3dTable": Array(repeating: 0.5, count: 24), - ]) - let (status, _) = try await put("/api/v1/look/current", body: payload) - XCTAssertEqual(status, 200) + func testSetCDLStored() async throws { + let port = await sim.boundPort! + let client = try XCTUnwrap(TestClient(port: port)) + XCTAssertTrue(client.authenticate(password: "arri")) - let stored = await sim.uploadedLooks - XCTAssertEqual(stored.count, 1) - let storedObj = try JSONSerialization.jsonObject(with: stored[0]) as? [String: Any] - XCTAssertEqual(storedObj?["name"] as? String, "shot42") + var payload = Data() + CAP.putU16(CAP.Variable.cdlValues.rawValue, into: &payload) + payload.append(CAP.encodeCDLBlob( + slope: (1.5, 1, 1), offset: (0, 0, 0), power: (1, 1, 1), saturation: 0.8)) + let id = client.send(.setVariable, payload: payload) + let reply = client.readUntil { $0.msgId == id } + XCTAssertEqual(reply?.cmdCode, CAP.Result.ok.rawValue) - // Retrievable over HTTP too. - let (gs, gd) = try await get("/api/v1/look/current") - XCTAssertEqual(gs, 200) - let obj = try JSONSerialization.jsonObject(with: gd) as? [String: Any] - XCTAssertEqual(obj?["name"] as? String, "shot42") + let stored = await sim.lastCDL + XCTAssertEqual(stored?[0] ?? 0, 1.5, accuracy: 1e-5) + XCTAssertEqual(stored?[9] ?? 0, 0.8, accuracy: 1e-5) } - // Malformed look rejected 400. - func testMalformedLookRejected() async throws { - let (status, _) = try await put("/api/v1/look/current", body: Data("not json".utf8)) - XCTAssertEqual(status, 400) - } + func testSubscriptionPushesEvents() async throws { + let port = await sim.boundPort! + let client = try XCTUnwrap(TestClient(port: port)) + XCTAssertTrue(client.authenticate(password: "arri")) - // Unknown path 404. - func testUnknownPath404() async throws { - let (status, _) = try await get("/api/v1/warp/drive") - XCTAssertEqual(status, 404) + var payload = Data() + CAP.putU16(CAP.Variable.cameraState.rawValue, into: &payload) + let id = client.send(.requestVariables, payload: payload) + _ = client.readUntil { $0.msgId == id } + + await sim.setRecording(true) + let event = client.readUntil { frame in + frame.msgType == CAP.MsgType.event.rawValue + && CAP.readU16(frame.payload, at: 0) == CAP.Variable.cameraState.rawValue + && CAP.CameraStateBits(rawValue: CAP.readU16(frame.payload, at: 2) ?? 0).contains(.recording) + } + XCTAssertNotNil(event) } } diff --git a/Tests/ForgeLoggerTests/EndToEndExportTests.swift b/Tests/ForgeLoggerTests/EndToEndExportTests.swift index b6690f2..2267903 100644 --- a/Tests/ForgeLoggerTests/EndToEndExportTests.swift +++ b/Tests/ForgeLoggerTests/EndToEndExportTests.swift @@ -25,7 +25,7 @@ final class EndToEndExportTests: XCTestCase { let project = try store.createProject(name: "E2E") let day = try store.createDay(projectID: project.id, label: "Day 01", date: "2026-07-10") - let driver = ArriDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(15)) + let driver = ArriDriver(host: "127.0.0.1", port: port) let logger = ClipLogger(store: store, dayID: day.id) let grade = GradeStack(nodes: [GradeNode(kind: .cdl(CDL( @@ -36,18 +36,18 @@ final class EndToEndExportTests: XCTestCase { await logger.attach(slotName: "A-Cam", driver: driver) { snapshotForProvider } try await driver.connect() - // Three takes. - let takes: [(clip: String, tcIn: String, tcOut: String)] = [ - ("A001C001_260710", "10:00:00:00", "10:00:30:00"), - ("A001C002_260710", "10:05:00:00", "10:05:45:00"), - ("A001C003_260710", "10:12:00:00", "10:13:00:00"), + // Three takes. CAP sim composes clip names as RxxxCxxx from reel+clip vars. + let takes: [(clipNo: UInt16, clip: String, tcIn: String, tcOut: String)] = [ + (1, "R001C001", "10:00:00:00", "10:00:30:00"), + (2, "R001C002", "10:05:00:00", "10:05:45:00"), + (3, "R001C003", "10:12:00:00", "10:13:00:00"), ] for take in takes { - await sim.setMetadata(ei: 800, wb: 5600, tint: 0, fps: 24, timecode: take.tcIn) + await sim.setMetadata(ei: 800, colorTemp: 5600, tint: 0, fps: 24, timecode: take.tcIn) try await Task.sleep(for: .milliseconds(60)) - await sim.setRecording(true, clipName: take.clip) + await sim.setRecording(true, clipNumber: take.clipNo) try await Task.sleep(for: .milliseconds(80)) - await sim.setMetadata(ei: 800, wb: 5600, tint: 0, fps: 24, timecode: take.tcOut) + await sim.setMetadata(ei: 800, colorTemp: 5600, tint: 0, fps: 24, timecode: take.tcOut) try await Task.sleep(for: .milliseconds(60)) await sim.setRecording(false) try await Task.sleep(for: .milliseconds(80)) @@ -68,13 +68,13 @@ final class EndToEndExportTests: XCTestCase { // All export formats produce sane output. let ale = PostExport.writeALE(shots: shots, fps: 24) - XCTAssertEqual(ale.components(separatedBy: "A001C").count - 1, 3) + XCTAssertEqual(ale.components(separatedBy: "R001C").count - 1, 3) let csv = PostExport.writeCSV(shots: shots) XCTAssertEqual(csv.split(separator: "\n").count, 4) // header + 3 let edl = PostExport.writeEDL(shots: shots, title: "DAY01", fps: 24) - XCTAssertTrue(edl.contains("003 A001C003_260710")) + XCTAssertTrue(edl.contains("003 R001C003")) XCTAssertEqual(edl.components(separatedBy: "* ASC_SOP").count - 1, 3) let ccc = CDLExport.writeCCC(shots.compactMap { s in