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:
parent
2b6e8de4b3
commit
8fc0e95cdc
8 changed files with 1145 additions and 383 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
guard rc == 0 else {
|
||||
close(fd)
|
||||
throw CameraError.connectionFailed("connect \(host):\(port) failed")
|
||||
}
|
||||
socketFD = fd
|
||||
}
|
||||
|
||||
/// 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)
|
||||
private func closeSocket() {
|
||||
if socketFD >= 0 {
|
||||
close(socketFD)
|
||||
socketFD = -1
|
||||
}
|
||||
}
|
||||
|
||||
private func sendFrame(_ frame: CAP.Frame) throws {
|
||||
guard socketFD >= 0 else { throw CameraError.connectionFailed("not connected") }
|
||||
let data = CAP.encode(frame)
|
||||
var sent = 0
|
||||
try data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) in
|
||||
while sent < buf.count {
|
||||
let n = write(socketFD, buf.baseAddress!.advanced(by: sent), buf.count - sent)
|
||||
guard n > 0 else { throw CameraError.connectionFailed("write failed") }
|
||||
sent += n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send command, await matching reply (msgId correlation).
|
||||
private func request(_ cmd: CAP.Command, payload: Data = Data(), timeout: Duration = .seconds(3)) async throws -> CAP.Frame {
|
||||
let msgId = nextMsgId
|
||||
nextMsgId &+= 1
|
||||
if nextMsgId == 0 { nextMsgId = 1 }
|
||||
|
||||
let frame = CAP.Frame(
|
||||
msgType: CAP.MsgType.command.rawValue,
|
||||
msgId: msgId,
|
||||
cmdCode: cmd.rawValue,
|
||||
payload: payload)
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
pendingReplies[msgId] = cont
|
||||
do {
|
||||
try sendFrame(frame)
|
||||
} catch {
|
||||
pendingReplies.removeValue(forKey: msgId)
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let http = resp as? HTTPURLResponse else {
|
||||
cont.resume(throwing: CameraError.connectionFailed("no HTTP response"))
|
||||
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
|
||||
}
|
||||
cont.resume(returning: (data ?? Data(), http))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ingest(_ data: Data) {
|
||||
readBuffer.append(data)
|
||||
while let frame = try? CAP.decodeFirst(&readBuffer) {
|
||||
route(frame)
|
||||
}
|
||||
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)")
|
||||
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))
|
||||
}
|
||||
|
||||
/// CAP requires client traffic every 1 s.
|
||||
private func startKeepAlive() {
|
||||
keepAliveTask?.cancel()
|
||||
keepAliveTask = Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(900))
|
||||
_ = try? await self.request(.live, timeout: .seconds(2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func push(look: FlattenedLook) async throws {
|
||||
if let lut = look.lut, !capabilities.lut3dSizes.contains(lut.size) {
|
||||
if let cdl = look.cdl {
|
||||
var payload = Data()
|
||||
CAP.putU16(CAP.Variable.cdlValues.rawValue, into: &payload)
|
||||
payload.append(CAP.encodeCDLBlob(
|
||||
slope: (cdl.slope.x, cdl.slope.y, cdl.slope.z),
|
||||
offset: (cdl.offset.x, cdl.offset.y, cdl.offset.z),
|
||||
power: (cdl.power.x, cdl.power.y, cdl.power.z),
|
||||
saturation: cdl.saturation))
|
||||
let reply = try await request(.setVariable, payload: payload)
|
||||
guard reply.cmdCode == CAP.Result.ok.rawValue else {
|
||||
throw CameraError.pushFailed("SetVariable CDL -> result \(reply.cmdCode)")
|
||||
}
|
||||
}
|
||||
if let lut = look.lut, !lut.isApproximatelyIdentity() {
|
||||
guard capabilities.lut3dSizes.contains(lut.size) else {
|
||||
throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)")
|
||||
}
|
||||
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,
|
||||
]
|
||||
// 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)
|
||||
}
|
||||
if let lut = look.lut {
|
||||
payload["lut3dSize"] = lut.size
|
||||
payload["lut3dTable"] = lut.table
|
||||
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)")
|
||||
}
|
||||
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 {
|
||||
while !Task.isCancelled {
|
||||
await pollOnce()
|
||||
try? await Task.sleep(for: pollInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
if lastState.connection == .connected {
|
||||
emit(CameraState(connection: .disconnected))
|
||||
}
|
||||
if look.cdl == nil && (look.lut == nil || look.lut!.isApproximatelyIdentity()) {
|
||||
throw CameraError.unsupportedLook("nothing to push")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
208
Sources/ForgeCameraARRI/CAPProtocol.swift
Normal file
208
Sources/ForgeCameraARRI/CAPProtocol.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
self.timecodeBCD = CAP.encodeTimecodeBCD(timecode) ?? 0
|
||||
for v in [CAP.Variable.exposureIndex, .colorTemperature, .tint, .sensorFPS, .timecode] {
|
||||
notifySubscribers(variable: v)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Request handling (called from channel handler)
|
||||
// MARK: Variable encoding
|
||||
|
||||
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"]))
|
||||
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 (.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"]))
|
||||
return out
|
||||
}
|
||||
uploadedLooks.append(body)
|
||||
return (.ok, json(["status": "accepted"]))
|
||||
|
||||
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:
|
||||
return (.notFound, json(["error": "unknown path"]))
|
||||
}
|
||||
reply(to: frame, result: .noSuchCommand, on: ch)
|
||||
}
|
||||
|
||||
private func json(_ obj: [String: Any]) -> Data {
|
||||
(try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
Tests/ForgeCameraARRITests/CAPProtocolTests.swift
Normal file
107
Tests/ForgeCameraARRITests/CAPProtocolTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue