sim: ARRI-style REST simulator (NIO HTTP) — scriptable rec state/metadata/look upload — 6 tests
This commit is contained in:
parent
388ad50dd3
commit
cf980f70db
3 changed files with 273 additions and 1 deletions
167
Sources/ForgeSim/ArriSimulator.swift
Normal file
167
Sources/ForgeSim/ArriSimulator.swift
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// ForgeSim
|
|
||||||
106
Tests/ForgeCameraTests/ArriSimTests.swift
Normal file
106
Tests/ForgeCameraTests/ArriSimTests.swift
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
@testable import ForgeSim
|
||||||
|
|
||||||
|
final class ArriSimTests: XCTestCase {
|
||||||
|
|
||||||
|
var sim: ArriSimulator!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
sim = ArriSimulator()
|
||||||
|
try await sim.start(port: 0) // ephemeral
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
try await sim.stop()
|
||||||
|
sim = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(_ path: String) async throws -> (Int, Data) {
|
||||||
|
let port = await sim.boundPort!
|
||||||
|
let url = URL(string: "http://127.0.0.1:\(port)\(path)")!
|
||||||
|
let (data, resp) = try await URLSession.shared.data(from: url)
|
||||||
|
return ((resp as! HTTPURLResponse).statusCode, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func put(_ path: String, body: Data) async throws -> (Int, Data) {
|
||||||
|
let port = await sim.boundPort!
|
||||||
|
var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)\(path)")!)
|
||||||
|
req.httpMethod = "PUT"
|
||||||
|
req.httpBody = body
|
||||||
|
let (data, resp) = try await URLSession.shared.data(for: req)
|
||||||
|
return ((resp as! HTTPURLResponse).statusCode, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System info endpoint serves model + serial JSON.
|
||||||
|
func testSystemInfo() async throws {
|
||||||
|
let (status, data) = try await get("/api/v1/system/info")
|
||||||
|
XCTAssertEqual(status, 200)
|
||||||
|
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["model"] as? String, "ALEXA 35")
|
||||||
|
XCTAssertNotNil(obj?["serialNumber"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rec state reflects scripted changes.
|
||||||
|
func testRecStateScriptable() async throws {
|
||||||
|
var (_, data) = try await get("/api/v1/recording/status")
|
||||||
|
var obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["recording"] as? Bool, false)
|
||||||
|
|
||||||
|
await sim.setRecording(true, clipName: "A001C003_250710_R1CD")
|
||||||
|
(_, data) = try await get("/api/v1/recording/status")
|
||||||
|
obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["recording"] as? Bool, true)
|
||||||
|
XCTAssertEqual(obj?["clipName"] as? String, "A001C003_250710_R1CD")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata endpoint.
|
||||||
|
func testMetadata() async throws {
|
||||||
|
await sim.setMetadata(ei: 1280, wb: 3200, tint: -2, fps: 25, timecode: "10:20:30:12")
|
||||||
|
let (status, data) = try await get("/api/v1/camera/metadata")
|
||||||
|
XCTAssertEqual(status, 200)
|
||||||
|
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["exposureIndex"] as? Int, 1280)
|
||||||
|
XCTAssertEqual(obj?["whiteBalance"] as? Int, 3200)
|
||||||
|
XCTAssertEqual(obj?["tint"] as? Int, -2)
|
||||||
|
XCTAssertEqual(obj?["timecode"] as? String, "10:20:30:12")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look upload stored and retrievable.
|
||||||
|
func testLookUploadStored() async throws {
|
||||||
|
let payload = try JSONSerialization.data(withJSONObject: [
|
||||||
|
"name": "shot42",
|
||||||
|
"cdl": ["slope": [1.1, 1.0, 0.9], "offset": [0, 0, 0], "power": [1, 1, 1], "saturation": 1.2],
|
||||||
|
"lut3dSize": 2,
|
||||||
|
"lut3dTable": Array(repeating: 0.5, count: 24),
|
||||||
|
])
|
||||||
|
let (status, _) = try await put("/api/v1/look/current", body: payload)
|
||||||
|
XCTAssertEqual(status, 200)
|
||||||
|
|
||||||
|
let stored = await sim.uploadedLooks
|
||||||
|
XCTAssertEqual(stored.count, 1)
|
||||||
|
let storedObj = try JSONSerialization.jsonObject(with: stored[0]) as? [String: Any]
|
||||||
|
XCTAssertEqual(storedObj?["name"] as? String, "shot42")
|
||||||
|
|
||||||
|
// Retrievable over HTTP too.
|
||||||
|
let (gs, gd) = try await get("/api/v1/look/current")
|
||||||
|
XCTAssertEqual(gs, 200)
|
||||||
|
let obj = try JSONSerialization.jsonObject(with: gd) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["name"] as? String, "shot42")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed look rejected 400.
|
||||||
|
func testMalformedLookRejected() async throws {
|
||||||
|
let (status, _) = try await put("/api/v1/look/current", body: Data("not json".utf8))
|
||||||
|
XCTAssertEqual(status, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown path 404.
|
||||||
|
func testUnknownPath404() async throws {
|
||||||
|
let (status, _) = try await get("/api/v1/warp/drive")
|
||||||
|
XCTAssertEqual(status, 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue