342 lines
12 KiB
Swift
342 lines
12 KiB
Swift
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<UInt16>()
|
|
}
|
|
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..<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
|
|
|
|
public func start(port: Int) async throws {
|
|
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
|
self.group = group
|
|
let sim = self
|
|
let bootstrap = ServerBootstrap(group: group)
|
|
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
|
.childChannelInitializer { channel in
|
|
channel.pipeline.addHandler(CAPServerHandler(sim: sim))
|
|
}
|
|
let channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get()
|
|
self.channel = channel
|
|
boundPort = channel.localAddress?.port
|
|
}
|
|
|
|
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
|
|
group = nil
|
|
boundPort = nil
|
|
}
|
|
}
|
|
|
|
/// Byte stream -> 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) }
|
|
}
|
|
}
|
|
}
|