From 9b89eb8964592951d4ce754c80e4e848dace1039 Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 20:02:11 +0000 Subject: [PATCH] =?UTF-8?q?driver-sony:=20Venice=202=20CNA-style=20REST=20?= =?UTF-8?q?driver=20=E2=80=94=20LUT-only=20push,=20no-native-CDL=20capabil?= =?UTF-8?q?ity=20gate,=20status=20polling;=20Sony=20sim=20=E2=80=94=205=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeCameraSony/ForgeCameraSony.swift | 1 - Sources/ForgeCameraSony/SonyDriver.swift | 177 ++++++++++++++++++ Sources/ForgeSim/SonySimulator.swift | 148 +++++++++++++++ .../SonyDriverTests.swift | 99 ++++++++++ 4 files changed, 424 insertions(+), 1 deletion(-) delete mode 100644 Sources/ForgeCameraSony/ForgeCameraSony.swift create mode 100644 Sources/ForgeCameraSony/SonyDriver.swift create mode 100644 Sources/ForgeSim/SonySimulator.swift create mode 100644 Tests/ForgeCameraSonyTests/SonyDriverTests.swift diff --git a/Sources/ForgeCameraSony/ForgeCameraSony.swift b/Sources/ForgeCameraSony/ForgeCameraSony.swift deleted file mode 100644 index 056f1ba..0000000 --- a/Sources/ForgeCameraSony/ForgeCameraSony.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeCameraSony diff --git a/Sources/ForgeCameraSony/SonyDriver.swift b/Sources/ForgeCameraSony/SonyDriver.swift new file mode 100644 index 0000000..f17756f --- /dev/null +++ b/Sources/ForgeCameraSony/SonyDriver.swift @@ -0,0 +1,177 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import ForgeCamera +import ForgeGrade +import ForgeColor + +/// Sony CNA endpoint paths — single source of truth. Correct against Sony +/// protocol docs in hardware phase; sim mirrors these. +enum SonyPaths { + static let cameraInfo = "/cna/v1/camera/info" + static let status = "/cna/v1/camera/status" + static let userLut = "/cna/v1/monitoring/lut" +} + +/// Venice 2 driver: REST over IP. Capability-narrow — user 3D LUT push only, +/// no native CDL (grade pipeline must full-bake for this camera). +public actor SonyDriver: CameraDriver { + public nonisolated let capabilities = CameraCapabilities( + vendor: .sony, + supportsNativeCDL: false, + lut3dSizes: [17, 33], + metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode]) + + private let host: String + private let port: Int + private let pollInterval: Duration + private let session: URLSession + + private var pollTask: Task? + private var lastState = CameraState(connection: .disconnected) + // Async registration OK here — poll loop re-emits on change (see ArriDriver note). + private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + + public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) { + self.host = host + self.port = port + self.pollInterval = pollInterval + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 3 + session = URLSession(configuration: config) + } + + public nonisolated var state: AsyncStream { + AsyncStream { continuation in + let id = UUID() + Task { await self.register(id: id, continuation: continuation) } + continuation.onTermination = { _ in + Task { await self.unregister(id: id) } + } + } + } + + private func register(id: UUID, continuation: AsyncStream.Continuation) { + stateContinuations[id] = continuation + } + + private func unregister(id: UUID) { + stateContinuations.removeValue(forKey: id) + } + + private func emit(_ s: CameraState) { + lastState = s + for c in stateContinuations.values { + c.yield(s) + } + } + + // MARK: HTTP (completion-handler path — async URLSession hangs on Linux) + + private func url(_ path: String) -> URL { + URL(string: "http://\(host):\(port)\(path)")! + } + + private func perform(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) { + try await withCheckedThrowingContinuation { cont in + let task = session.dataTask(with: req) { data, resp, err in + if let err { + cont.resume(throwing: err) + return + } + guard let http = resp as? HTTPURLResponse else { + cont.resume(throwing: CameraError.connectionFailed("no HTTP response")) + return + } + cont.resume(returning: (data ?? Data(), http)) + } + task.resume() + } + } + + private func getJSON(_ path: String) async throws -> [String: Any] { + let (data, http) = try await perform(URLRequest(url: url(path))) + guard http.statusCode == 200, + let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CameraError.connectionFailed("GET \(path) failed") + } + return obj + } + + // MARK: CameraDriver + + public func connect() async throws { + do { + _ = try await getJSON(SonyPaths.cameraInfo) + } catch { + throw CameraError.connectionFailed("camera info unreachable: \(error)") + } + emit(CameraState(connection: .connected)) + startPolling() + } + + public func disconnect() async { + pollTask?.cancel() + pollTask = nil + emit(CameraState(connection: .disconnected)) + } + + public func push(look: FlattenedLook) async throws { + guard look.cdl == nil else { + throw CameraError.unsupportedLook("Venice has no native CDL — re-flatten with splitLeadingCDL=false") + } + guard let lut = look.lut else { + throw CameraError.unsupportedLook("nothing to push") + } + guard capabilities.lut3dSizes.contains(lut.size) else { + throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)") + } + let payload = try JSONSerialization.data( + withJSONObject: ["size": lut.size, "table": lut.table], + options: [.sortedKeys]) + var req = URLRequest(url: url(SonyPaths.userLut)) + req.httpMethod = "PUT" + req.httpBody = payload + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (_, http) = try await perform(req) + guard http.statusCode == 200 else { + throw CameraError.pushFailed("LUT upload -> \(http.statusCode)") + } + } + + // MARK: Polling + + private func startPolling() { + pollTask?.cancel() + pollTask = Task { + while !Task.isCancelled { + await pollOnce() + try? await Task.sleep(for: pollInterval) + } + } + } + + private func pollOnce() async { + do { + let s = try await getJSON(SonyPaths.status) + let newState = CameraState( + connection: .connected, + isRecording: s["recording"] as? Bool ?? false, + clipName: s["clipName"] as? String, + metadata: CameraMetadata( + exposureIndex: s["exposureIndex"] as? Int, + whiteBalance: s["whiteBalance"] as? Int, + tint: s["tint"] as? Int, + fps: s["fps"] as? Double, + timecode: s["timecode"] as? String)) + if newState != lastState { + emit(newState) + } + } catch { + if lastState.connection == .connected { + emit(CameraState(connection: .disconnected)) + } + } + } +} diff --git a/Sources/ForgeSim/SonySimulator.swift b/Sources/ForgeSim/SonySimulator.swift new file mode 100644 index 0000000..273c296 --- /dev/null +++ b/Sources/ForgeSim/SonySimulator.swift @@ -0,0 +1,148 @@ +import Foundation +import NIO +import NIOHTTP1 + +/// CNA-style HTTP simulator for Venice 2. Sony's Camera Network API family +/// is REST-shaped; paths in one place, correct against protocol docs later. +public enum SonySimPaths { + public static let cameraInfo = "/cna/v1/camera/info" + public static let status = "/cna/v1/camera/status" + public static let userLut = "/cna/v1/monitoring/lut" +} + +public actor SonySimulator { + private var group: MultiThreadedEventLoopGroup? + private var channel: Channel? + public private(set) var boundPort: Int? + + private var recording = false + private var clipName: String? + private var ei = 800 + private var wb = 5500 + private var tint = 0 + private var fps = 24.0 + private var timecode = "00:00:00:00" + public private(set) var uploadedLutCount = 0 + + public init() {} + + public func setState(recording: Bool, clipName: String?, ei: Int, wb: Int, tint: Int, fps: Double, timecode: String) { + self.recording = recording + if let c = clipName { self.clipName = c } + self.ei = ei + self.wb = wb + self.tint = tint + self.fps = fps + self.timecode = timecode + } + + func handle(method: HTTPMethod, uri: String, body: Data?) -> (status: HTTPResponseStatus, body: Data) { + switch (method, uri) { + case (.GET, SonySimPaths.cameraInfo): + return (.ok, json(["model": "VENICE 2 SIM", "serial": "SNY-0001"])) + case (.GET, SonySimPaths.status): + var obj: [String: Any] = [ + "recording": recording, + "exposureIndex": ei, + "whiteBalance": wb, + "tint": tint, + "fps": fps, + "timecode": timecode, + ] + if let c = clipName { obj["clipName"] = c } + return (.ok, json(obj)) + case (.PUT, SonySimPaths.userLut): + guard let body, + let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let size = obj["size"] as? Int, + let table = obj["table"] as? [Any], + table.count == size * size * size * 3 else { + return (.badRequest, json(["error": "bad LUT"])) + } + uploadedLutCount += 1 + 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() + } + + 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(SonyHTTPHandler(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 + } +} + +final class SonyHTTPHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + private let sim: SonySimulator + private var method: HTTPMethod = .GET + private var uri: String = "/" + private var bodyBuffer: ByteBuffer? + + init(sim: SonySimulator) { + 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)") + headers.add(name: "Connection", value: "close") + 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)).whenComplete { _ in + channel.close(promise: nil) + } + } + } + } + } +} diff --git a/Tests/ForgeCameraSonyTests/SonyDriverTests.swift b/Tests/ForgeCameraSonyTests/SonyDriverTests.swift new file mode 100644 index 0000000..4dc395e --- /dev/null +++ b/Tests/ForgeCameraSonyTests/SonyDriverTests.swift @@ -0,0 +1,99 @@ +import XCTest +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import ForgeCamera +import ForgeGrade +import ForgeColor +import ForgeSim +@testable import ForgeCameraSony + +final class SonyDriverTests: XCTestCase { + + var sim: SonySimulator! + + override func setUp() async throws { + sim = SonySimulator() + try await sim.start(port: 0) + } + + override func tearDown() async throws { + try? await sim.stop() + sim = nil + } + + func makeDriver() async -> SonyDriver { + let port = await sim.boundPort! + return SonyDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(20)) + } + + // Venice 2: NO native CDL — capability-narrow. + func testCapabilities() async { + let driver = await makeDriver() + XCTAssertEqual(driver.capabilities.vendor, .sony) + XCTAssertFalse(driver.capabilities.supportsNativeCDL) + XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33)) + } + + func testConnectEmitsConnected() async throws { + let driver = await makeDriver() + var iterator = driver.state.makeAsyncIterator() + try await driver.connect() + var connected = false + for _ in 0..<5 { + if let s = await iterator.next(), s.connection == .connected { + connected = true + break + } + } + XCTAssertTrue(connected) + await driver.disconnect() + } + + func testRecStateAndMetadata() async throws { + let driver = await makeDriver() + var iterator = driver.state.makeAsyncIterator() + try await driver.connect() + + await sim.setState(recording: true, clipName: "C0001", ei: 500, wb: 5500, tint: 1, fps: 24, timecode: "09:15:00:00") + var got = false + for _ in 0..<30 { + if let s = await iterator.next(), s.isRecording { + XCTAssertEqual(s.clipName, "C0001") + XCTAssertEqual(s.metadata?.exposureIndex, 500) + got = true + break + } + } + XCTAssertTrue(got) + await driver.disconnect() + } + + // LUT-only push accepted. + func testPushLutOnly() async throws { + let driver = await makeDriver() + try await driver.connect() + let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33) + try await driver.push(look: look) + let count = await sim.uploadedLutCount + XCTAssertEqual(count, 1) + await driver.disconnect() + } + + // Push with split CDL to a no-native-CDL camera must throw unsupportedLook — + // caller (gang layer) is responsible for re-flattening full-bake. + func testPushSplitCDLRejected() async throws { + let driver = await makeDriver() + try await driver.connect() + let cdl = CDL(slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1) + let look = FlattenedLook(cdl: cdl, lut: Lut3D.identity(size: 33), latticeSize: 33) + do { + try await driver.push(look: look) + XCTFail("expected unsupportedLook") + } catch let e as CameraError { + guard case .unsupportedLook = e else { return XCTFail("wrong error") } + } + await driver.disconnect() + } +}