driver-arri: rewrite to real ARRI CAP — binary TCP :5055, frame codec, Welcome/challenge/MD5 password/ClientName handshake, variable subscription (EVENT-driven state), CDL via SetVariable 0x0050 10xF32 blob, BCD timecode, keep-alive; CAP-faithful sim; e2e updated — 200 tests, 3x soak

This commit is contained in:
Forge Dev 2026-07-11 14:02:56 +00:00
parent 2b6e8de4b3
commit 8fc0e95cdc
8 changed files with 1145 additions and 383 deletions

View file

@ -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",

View file

@ -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<Void, Never>?
private var socketFD: Int32 = -1
private var readTask: Task<Void, Never>?
private var keepAliveTask: Task<Void, Never>?
private var nextMsgId: UInt16 = 1
private var readBuffer = Data()
private var pendingReplies: [UInt16: CheckedContinuation<CAP.Frame, Error>] = [:]
// 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<CameraState>.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<CameraState> {
@ -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<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.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<sockaddr_in>.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..<n]))
} else if n == 0 {
await self.handleDisconnect()
return
} else {
// EAGAIN on timeout: keep looping unless cancelled.
if errno != EAGAIN && errno != EWOULDBLOCK {
await self.handleDisconnect()
return
}
}
}
}
}
private func ingest(_ data: Data) {
readBuffer.append(data)
while let frame = try? CAP.decodeFirst(&readBuffer) {
route(frame)
}
}
private func route(_ frame: CAP.Frame) {
switch frame.msgType {
case CAP.MsgType.reply.rawValue:
if let cont = pendingReplies.removeValue(forKey: frame.msgId) {
cont.resume(returning: frame)
} else if frame.cmdCode == CAP.Command.welcome.rawValue || frame.msgId == 0 {
// Unsolicited Welcome on connect stored implicitly; nothing pending.
}
case CAP.MsgType.event.rawValue:
// Variable update: payload = U16 id + value.
if let id = CAP.readU16(frame.payload, at: 0) {
varCache[id] = frame.payload.subdata(in: frame.payload.startIndex + 2..<frame.payload.endIndex)
rebuildState()
}
default:
break
}
}
private func handleDisconnect() {
closeSocket()
for (_, cont) in pendingReplies {
cont.resume(throwing: CameraError.connectionFailed("connection closed"))
}
pendingReplies.removeAll()
if lastState.connection == .connected {
emit(CameraState(connection: .disconnected))
}
}
// MARK: State assembly
private func rebuildState() {
func u16(_ v: CAP.Variable) -> 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")
}
}
}

View file

@ -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..<start + Int(len)), encoding: .utf8) ?? ""
return (s, 2 + Int(len))
}
// MARK: Frame codec
public static func encode(_ frame: Frame) -> 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..<base + Int(length))
buffer.removeFirst(Int(length))
return Frame(msgType: msgType, msgId: msgId, cmdCode: cmdCode, payload: payload)
}
// MARK: Timecode
/// U32 BCD HHMMSSFF -> "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) }
}
}

View file

@ -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<UInt16>()
}
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..<frame.payload.endIndex)
guard let blob = CAP.decodeCDLBlob(blobData) else {
reply(to: frame, result: .wrongType, on: ch)
break
}
lastCDL = blob
reply(to: frame, result: .ok, on: ch)
} else {
reply(to: frame, result: .notAllowed, on: ch)
}
case .requestVariables:
guard requireAuth() else { break }
// Payload: sequence of U16 variable IDs.
var offset = 0
while let id = CAP.readU16(frame.payload, at: offset) {
session.subscriptions.insert(id)
offset += 2
}
reply(to: frame, result: .ok, on: ch)
// Push current values immediately (initial state).
sessions[key] = session
for id in session.subscriptions {
if let payload = encodeVariable(id) {
Self.write(CAP.Frame(
msgType: CAP.MsgType.event.rawValue,
msgId: 0,
cmdCode: CAP.Command.requestVariables.rawValue,
payload: payload), to: ch)
}
}
case .set3DLutData:
guard requireAuth() else { break }
lutUploads.append(frame.payload)
reply(to: frame, result: .ok, on: ch)
case .recordStart:
guard requireAuth() else { break }
setRecording(true)
reply(to: frame, result: .ok, on: ch)
case .recordStop:
guard requireAuth() else { break }
setRecording(false)
reply(to: frame, result: .ok, on: ch)
default:
reply(to: frame, result: .noSuchCommand, on: ch)
}
sessions[key] = session
}
private func reply(to frame: CAP.Frame, result: CAP.Result, on ch: Channel) {
Self.write(CAP.Frame(
msgType: CAP.MsgType.reply.rawValue,
msgId: frame.msgId,
cmdCode: result.rawValue), to: ch)
}
private func replyPayload(to frame: CAP.Frame, result: CAP.Result, payload: Data, on ch: Channel) {
Self.write(CAP.Frame(
msgType: CAP.MsgType.reply.rawValue,
msgId: frame.msgId,
cmdCode: result.rawValue,
payload: payload), to: ch)
}
private static func write(_ frame: CAP.Frame, to ch: Channel) {
let data = CAP.encode(frame)
var buf = ch.allocator.buffer(capacity: data.count)
buf.writeBytes(data)
ch.writeAndFlush(NIOAny(buf), promise: nil)
}
// MARK: Lifecycle
@ -98,9 +285,7 @@ public actor ArriSimulator {
let bootstrap = ServerBootstrap(group: group)
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.childChannelInitializer { channel in
channel.pipeline.configureHTTPServerPipeline().flatMap {
channel.pipeline.addHandler(HTTPSimHandler(sim: sim))
}
channel.pipeline.addHandler(CAPServerHandler(sim: sim))
}
let channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get()
self.channel = channel
@ -108,6 +293,11 @@ public actor ArriSimulator {
}
public func stop() async throws {
for ch in channels.values {
_ = try? await ch.close()
}
channels.removeAll()
sessions.removeAll()
try await channel?.close()
try await group?.shutdownGracefully()
channel = nil
@ -116,57 +306,37 @@ public actor ArriSimulator {
}
}
/// Minimal HTTP1 request handler bridging into the actor.
final class HTTPSimHandler: ChannelInboundHandler, @unchecked Sendable {
typealias InboundIn = HTTPServerRequestPart
typealias OutboundOut = HTTPServerResponsePart
/// Byte stream -> 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) }
}
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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<timeval>.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<sockaddr_in>.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..<n])
} else if n == 0 {
return nil
}
}
return try? CAP.decodeFirst(&buffer) ?? nil
}
/// Read frames until predicate matches (skips events/other replies).
func readUntil(_ predicate: (CAP.Frame) -> 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)
}
}

View file

@ -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