From cf980f70dbd8d5705f385ca8f5bfbd9bea8a0712 Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 19:01:40 +0000 Subject: [PATCH] =?UTF-8?q?sim:=20ARRI-style=20REST=20simulator=20(NIO=20H?= =?UTF-8?q?TTP)=20=E2=80=94=20scriptable=20rec=20state/metadata/look=20upl?= =?UTF-8?q?oad=20=E2=80=94=206=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeSim/ArriSimulator.swift | 167 ++++++++++++++++++++++ Sources/ForgeSim/ForgeSim.swift | 1 - Tests/ForgeCameraTests/ArriSimTests.swift | 106 ++++++++++++++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 Sources/ForgeSim/ArriSimulator.swift delete mode 100644 Sources/ForgeSim/ForgeSim.swift create mode 100644 Tests/ForgeCameraTests/ArriSimTests.swift diff --git a/Sources/ForgeSim/ArriSimulator.swift b/Sources/ForgeSim/ArriSimulator.swift new file mode 100644 index 0000000..fe3a560 --- /dev/null +++ b/Sources/ForgeSim/ArriSimulator.swift @@ -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) + } + } + } + } +} diff --git a/Sources/ForgeSim/ForgeSim.swift b/Sources/ForgeSim/ForgeSim.swift deleted file mode 100644 index 72544f5..0000000 --- a/Sources/ForgeSim/ForgeSim.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeSim diff --git a/Tests/ForgeCameraTests/ArriSimTests.swift b/Tests/ForgeCameraTests/ArriSimTests.swift new file mode 100644 index 0000000..c7396dd --- /dev/null +++ b/Tests/ForgeCameraTests/ArriSimTests.swift @@ -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) + } +}