driver-sony: Venice 2 CNA-style REST driver — LUT-only push, no-native-CDL capability gate, status polling; Sony sim — 5 tests
This commit is contained in:
parent
acce32c493
commit
9b89eb8964
4 changed files with 424 additions and 1 deletions
|
|
@ -1 +0,0 @@
|
|||
// ForgeCameraSony
|
||||
177
Sources/ForgeCameraSony/SonyDriver.swift
Normal file
177
Sources/ForgeCameraSony/SonyDriver.swift
Normal file
|
|
@ -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<Void, Never>?
|
||||
private var lastState = CameraState(connection: .disconnected)
|
||||
// Async registration OK here — poll loop re-emits on change (see ArriDriver note).
|
||||
private var stateContinuations: [UUID: AsyncStream<CameraState>.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<CameraState> {
|
||||
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<CameraState>.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
Sources/ForgeSim/SonySimulator.swift
Normal file
148
Sources/ForgeSim/SonySimulator.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
Tests/ForgeCameraSonyTests/SonyDriverTests.swift
Normal file
99
Tests/ForgeCameraSonyTests/SonyDriverTests.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue