rainbow-dragon/Sources/ForgeSim/ArriSimulator.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) }
}
}
}