rainbow-dragon/Sources/ForgeSim/ArriSimulator.swift

167 lines
6 KiB
Swift

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)
}
}
}
}
}