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" } 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] = [] public init() {} // MARK: Scripting hooks 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 func setMetadata(ei: Int, wb: Int, tint: Int, fps: Double, timecode: String) { self.ei = ei self.wb = wb self.tint = tint self.fps = fps self.timecode = timecode } // MARK: Request handling (called from channel handler) func handle(method: HTTPMethod, uri: String, body: Data?) -> (status: HTTPResponseStatus, body: Data) { switch (method, uri) { case (.GET, ArriSimPaths.systemInfo): return (.ok, json([ "model": "ALEXA 35", "serialNumber": "SIM-35-0001", "firmwareVersion": "SUP 2.0-sim", ])) case (.GET, ArriSimPaths.recordingStatus): var obj: [String: Any] = ["recording": recording] if let c = clipName { obj["clipName"] = c } return (.ok, json(obj)) case (.GET, ArriSimPaths.metadata): return (.ok, json([ "exposureIndex": ei, "whiteBalance": wb, "tint": tint, "fps": fps, "timecode": timecode, ])) case (.GET, ArriSimPaths.lookCurrent): guard let last = uploadedLooks.last else { return (.notFound, json(["error": "no look set"])) } return (.ok, last) case (.PUT, ArriSimPaths.lookCurrent): guard let body, (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) != nil else { return (.badRequest, json(["error": "invalid JSON"])) } uploadedLooks.append(body) return (.ok, json(["status": "accepted"])) default: return (.notFound, json(["error": "unknown path"])) } } private func json(_ obj: [String: Any]) -> Data { (try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data() } // 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.configureHTTPServerPipeline().flatMap { channel.pipeline.addHandler(HTTPSimHandler(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 { try await channel?.close() try await group?.shutdownGracefully() channel = nil group = nil boundPort = nil } } /// Minimal HTTP1 request handler bridging into the actor. final class HTTPSimHandler: ChannelInboundHandler, @unchecked Sendable { typealias InboundIn = HTTPServerRequestPart typealias OutboundOut = HTTPServerResponsePart private let sim: ArriSimulator private var method: HTTPMethod = .GET private var uri: String = "/" private var bodyBuffer: ByteBuffer? init(sim: ArriSimulator) { self.sim = sim } 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)") 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), promise: nil) } } } } }