import Foundation import NIO 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? /// Camera password (camera GUI setting; ARRI default "arri"). public let password: String private let challenge = "SIMCHAL01" // 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] = [] private struct Session { var authenticated = false var challengeSent = false var subscriptions = Set() } private var sessions: [ObjectIdentifier: Session] = [:] private var channels: [ObjectIdentifier: Channel] = [:] public init(password: String = "arri") { self.password = password } // 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.colorTemp = colorTemp self.tint = tint self.fps = fps self.timecodeBCD = CAP.encodeTimecodeBCD(timecode) ?? 0 for v in [CAP.Variable.exposureIndex, .colorTemperature, .tint, .sensorFPS, .timecode] { notifySubscribers(variable: v) } } // MARK: Variable encoding private func encodeVariable(_ id: UInt16) -> Data? { guard let variable = CAP.Variable(rawValue: id) else { return nil } var out = Data() CAP.putU16(id, into: &out) switch variable { case .cameraState: CAP.putU16(stateBits.rawValue, into: &out) case .lookFilename: CAP.putString("forge-sim-look", into: &out) case .cdlValues: let blob = lastCDL ?? [1, 1, 1, 0, 0, 0, 1, 1, 1, 1] for v in blob { CAP.putF32(v, into: &out) } case .colorTemperature: CAP.putU32(colorTemp, into: &out) case .tint: CAP.putF32(tint, into: &out) case .exposureIndex: CAP.putU32(ei, into: &out) case .currentReel: CAP.putU16(reel, into: &out) case .clipNumber: CAP.putU16(clipNumber, into: &out) case .sensorFPS: CAP.putF32(fps, into: &out) case .timecode: CAP.putU32(timecodeBCD, into: &out) } return out } private func notifySubscribers(variable: CAP.Variable) { guard let payload = encodeVariable(variable.rawValue) else { return } for (key, session) in sessions where session.authenticated && session.subscriptions.contains(variable.rawValue) { guard let ch = channels[key] else { continue } let frame = CAP.Frame( msgType: CAP.MsgType.event.rawValue, msgId: 0, cmdCode: CAP.Command.requestVariables.rawValue, payload: payload) Self.write(frame, to: ch) } } // MARK: Connection lifecycle func clientConnected(_ ch: Channel) { let key = ObjectIdentifier(ch) sessions[key] = Session() channels[key] = ch // Welcome: protocol version string + result OK. var payload = Data() CAP.putString("CAP 1.12 sim", into: &payload) CAP.putU16(CAP.Result.ok.rawValue, into: &payload) Self.write(CAP.Frame( msgType: CAP.MsgType.reply.rawValue, msgId: 0, cmdCode: CAP.Command.welcome.rawValue, payload: payload), to: ch) } func clientDisconnected(_ ch: Channel) { let key = ObjectIdentifier(ch) sessions.removeValue(forKey: key) channels.removeValue(forKey: key) } func handleFrame(_ frame: CAP.Frame, from ch: Channel) { let key = ObjectIdentifier(ch) guard var session = sessions[key] else { return } guard let cmd = CAP.Command(rawValue: frame.cmdCode) else { reply(to: frame, result: .noSuchCommand, on: ch) return } func requireAuth() -> Bool { if !session.authenticated { reply(to: frame, result: .notAuthorized, on: ch) return false } return true } switch cmd { case .live: reply(to: frame, result: .ok, on: ch) case .requestPwdChallenge: session.challengeSent = true var payload = Data() CAP.putString(challenge, into: &payload) replyPayload(to: frame, result: .ok, payload: payload, on: ch) case .password: guard session.challengeSent, let (hash, _) = CAP.readString(frame.payload, at: 0) else { reply(to: frame, result: .protocolError, on: ch) break } // Spec: MD5(challenge concat password). let expected = MD5.hexString(Data((challenge + password).utf8)) if hash.lowercased() == expected { session.authenticated = true reply(to: frame, result: .ok, on: ch) } else { reply(to: frame, result: .wrongPassword, on: ch) } case .clientName: guard requireAuth() else { break } if let (name, _) = CAP.readString(frame.payload, at: 0) { clientNames.append(name) } reply(to: frame, result: .ok, on: ch) case .getVariable: guard requireAuth() else { break } guard let id = CAP.readU16(frame.payload, at: 0), let payload = encodeVariable(id) else { reply(to: frame, result: .noSuchVariables, on: ch) break } replyPayload(to: frame, result: .ok, payload: payload, on: ch) case .setVariable: guard requireAuth() else { break } guard let id = CAP.readU16(frame.payload, at: 0) else { reply(to: frame, result: .protocolError, on: ch) break } if id == CAP.Variable.cdlValues.rawValue { let blobData = frame.payload.subdata(in: frame.payload.startIndex + 2.. CAP frames -> sim actor. final class CAPServerHandler: ChannelInboundHandler, @unchecked Sendable { typealias InboundIn = ByteBuffer private let sim: ArriSimulator private var 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) { 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) } } } }