mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
6176c0a79d
* feat(macos): add native camera PTZ controls Add physical UVC pan, tilt, and zoom through the signed Mac app, with camera.ptz.control kept behind dangerous-command approval. Verified against real Insta360 Link 2 Pro hardware. * refactor(agents): split message tool display config * fix(mac): harden camera PTZ contracts
267 lines
9.6 KiB
Swift
267 lines
9.6 KiB
Swift
import Darwin
|
|
import Foundation
|
|
import OpenClawCameraPTZNative
|
|
|
|
struct CameraUSBIdentity: Equatable, Sendable {
|
|
let locationId: UInt32
|
|
let vendorId: UInt16
|
|
let productId: UInt16
|
|
|
|
static func parse(deviceId: String) throws -> CameraUSBIdentity {
|
|
let trimmed = deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let hexadecimal = trimmed.hasPrefix("0x") ? String(trimmed.dropFirst(2)) : trimmed
|
|
guard !hexadecimal.isEmpty,
|
|
hexadecimal.count <= 16,
|
|
let value = UInt64(hexadecimal, radix: 16)
|
|
else {
|
|
throw CameraPTZError.unsupported("deviceId is not a USB camera identifier")
|
|
}
|
|
let locationId = UInt32(truncatingIfNeeded: value >> 32)
|
|
guard locationId != 0 else {
|
|
throw CameraPTZError.unsupported("deviceId does not contain a USB location")
|
|
}
|
|
return CameraUSBIdentity(
|
|
locationId: locationId,
|
|
vendorId: UInt16(truncatingIfNeeded: value >> 16),
|
|
productId: UInt16(truncatingIfNeeded: value))
|
|
}
|
|
}
|
|
|
|
enum CameraUVCDescriptorParser {
|
|
static func parse(_ descriptors: [UInt8]) -> (terminalId: UInt8, controls: UInt32)? {
|
|
var terminalId: UInt8 = 1
|
|
var controls: UInt32 = 0
|
|
let parsed = descriptors.withUnsafeBytes { bytes in
|
|
openclaw_uvc_parse_camera_terminal(
|
|
bytes.bindMemory(to: UInt8.self).baseAddress,
|
|
bytes.count,
|
|
&terminalId,
|
|
&controls)
|
|
}
|
|
return parsed == 1 ? (terminalId, controls) : nil
|
|
}
|
|
}
|
|
|
|
enum CameraUVCControlInfo {
|
|
private static let setCapability: UInt8 = 0x02
|
|
|
|
static func canSet(_ info: UInt8?) -> Bool {
|
|
info.map { $0 & self.setCapability != 0 } ?? false
|
|
}
|
|
}
|
|
|
|
struct NativeCameraPTZBackend: CameraPTZBackend {
|
|
func open(deviceId: String) throws -> any CameraPTZControlling {
|
|
try NativeCameraPTZController(deviceId: deviceId)
|
|
}
|
|
}
|
|
|
|
private final class NativeCameraPTZController: CameraPTZControlling {
|
|
private enum Control {
|
|
static let zoomAbsolute: UInt8 = 0x0B
|
|
static let panTiltAbsolute: UInt8 = 0x0D
|
|
}
|
|
|
|
private enum Request {
|
|
static let setCurrent: UInt8 = 0x01
|
|
static let getCurrent: UInt8 = 0x81
|
|
static let getMin: UInt8 = 0x82
|
|
static let getMax: UInt8 = 0x83
|
|
static let getResolution: UInt8 = 0x84
|
|
static let getInfo: UInt8 = 0x86
|
|
static let getDefault: UInt8 = 0x87
|
|
}
|
|
|
|
private enum Capability {
|
|
static let zoomAbsolute = UInt32(1 << 9)
|
|
static let panTiltAbsolute = UInt32(1 << 11)
|
|
}
|
|
|
|
private var handle: OpaquePointer?
|
|
private let controls: UInt32
|
|
private var panTiltCanSet = false
|
|
private var zoomCanSet = false
|
|
|
|
init(deviceId: String) throws {
|
|
let identity = try CameraUSBIdentity.parse(deviceId: deviceId)
|
|
var handle: OpaquePointer?
|
|
var controls: UInt32 = 0
|
|
var error: UnsafeMutablePointer<CChar>?
|
|
guard openclaw_uvc_open(
|
|
identity.locationId,
|
|
identity.vendorId,
|
|
identity.productId,
|
|
&handle,
|
|
&controls,
|
|
&error) == 1,
|
|
let handle
|
|
else {
|
|
throw Self.nativeError(error, fallback: "open USB camera")
|
|
}
|
|
self.handle = handle
|
|
self.controls = controls
|
|
guard self.advertisesPanTilt || self.advertisesZoom else {
|
|
self.close()
|
|
throw CameraPTZError.unsupported("camera advertises no absolute PTZ axes")
|
|
}
|
|
self.panTiltCanSet = self.advertisesPanTilt &&
|
|
CameraUVCControlInfo.canSet(try? self.readInfo(selector: Control.panTiltAbsolute))
|
|
self.zoomCanSet = self.advertisesZoom &&
|
|
CameraUVCControlInfo.canSet(try? self.readInfo(selector: Control.zoomAbsolute))
|
|
}
|
|
|
|
deinit {
|
|
self.close()
|
|
}
|
|
|
|
func status() throws -> CameraPTZRawStatus {
|
|
var pan: CameraPTZRawAxisStatus?
|
|
var tilt: CameraPTZRawAxisStatus?
|
|
var zoom: CameraPTZRawAxisStatus?
|
|
if self.advertisesPanTilt {
|
|
let ranges = try self.panTiltRanges()
|
|
let current = try self.readPanTilt(request: Request.getCurrent)
|
|
pan = CameraPTZRawAxisStatus(
|
|
current: current.pan,
|
|
range: ranges.pan,
|
|
canSet: self.panTiltCanSet)
|
|
tilt = CameraPTZRawAxisStatus(
|
|
current: current.tilt,
|
|
range: ranges.tilt,
|
|
canSet: self.panTiltCanSet)
|
|
}
|
|
if self.advertisesZoom {
|
|
let range = try self.zoomRange()
|
|
zoom = try CameraPTZRawAxisStatus(
|
|
current: self.readZoom(request: Request.getCurrent),
|
|
range: range,
|
|
canSet: self.zoomCanSet)
|
|
}
|
|
return CameraPTZRawStatus(pan: pan, tilt: tilt, zoom: zoom)
|
|
}
|
|
|
|
func setPanTilt(pan: Int32, tilt: Int32) throws {
|
|
guard self.panTiltCanSet else { throw CameraPTZError.axisUnsupported("pan/tilt") }
|
|
var bytes = Self.encodePanTilt(pan: pan, tilt: tilt)
|
|
try self.control(selector: Control.panTiltAbsolute, request: Request.setCurrent, bytes: &bytes)
|
|
}
|
|
|
|
func setZoom(_ zoom: Int32) throws {
|
|
guard self.zoomCanSet else { throw CameraPTZError.axisUnsupported("zoom") }
|
|
var bytes = Self.encodeZoom(zoom)
|
|
try self.control(selector: Control.zoomAbsolute, request: Request.setCurrent, bytes: &bytes)
|
|
}
|
|
|
|
func close() {
|
|
guard let handle = self.handle else { return }
|
|
openclaw_uvc_close(handle)
|
|
self.handle = nil
|
|
}
|
|
|
|
private var advertisesPanTilt: Bool {
|
|
self.controls & Capability.panTiltAbsolute != 0
|
|
}
|
|
|
|
private var advertisesZoom: Bool {
|
|
self.controls & Capability.zoomAbsolute != 0
|
|
}
|
|
|
|
private func panTiltRanges() throws -> (pan: CameraPTZRawRange, tilt: CameraPTZRawRange) {
|
|
let minimum = try self.readPanTilt(request: Request.getMin)
|
|
let maximum = try self.readPanTilt(request: Request.getMax)
|
|
let resolution = try self.readPanTilt(request: Request.getResolution)
|
|
let defaults = try? self.readPanTilt(request: Request.getDefault)
|
|
let pan = CameraPTZRawRange(
|
|
min: minimum.pan,
|
|
max: maximum.pan,
|
|
step: resolution.pan,
|
|
default: nil)
|
|
let tilt = CameraPTZRawRange(
|
|
min: minimum.tilt,
|
|
max: maximum.tilt,
|
|
step: resolution.tilt,
|
|
default: nil)
|
|
return (pan.withDefault(defaults?.pan), tilt.withDefault(defaults?.tilt))
|
|
}
|
|
|
|
private func zoomRange() throws -> CameraPTZRawRange {
|
|
let minimum = try self.readZoom(request: Request.getMin)
|
|
let maximum = try self.readZoom(request: Request.getMax)
|
|
let resolution = try self.readZoom(request: Request.getResolution)
|
|
let rawDefault = try? self.readZoom(request: Request.getDefault)
|
|
return CameraPTZRawRange(
|
|
min: minimum,
|
|
max: maximum,
|
|
step: resolution,
|
|
default: nil).withDefault(rawDefault)
|
|
}
|
|
|
|
private func readInfo(selector: UInt8) throws -> UInt8 {
|
|
var bytes = [UInt8](repeating: 0, count: 1)
|
|
try self.control(selector: selector, request: Request.getInfo, bytes: &bytes)
|
|
return bytes[0]
|
|
}
|
|
|
|
private func readPanTilt(request: UInt8) throws -> (pan: Int32, tilt: Int32) {
|
|
var bytes = [UInt8](repeating: 0, count: 8)
|
|
try self.control(selector: Control.panTiltAbsolute, request: request, bytes: &bytes)
|
|
return (Self.decodeInt32(bytes, offset: 0), Self.decodeInt32(bytes, offset: 4))
|
|
}
|
|
|
|
private func readZoom(request: UInt8) throws -> Int32 {
|
|
var bytes = [UInt8](repeating: 0, count: 2)
|
|
try self.control(selector: Control.zoomAbsolute, request: request, bytes: &bytes)
|
|
return Int32(UInt16(bytes[0]) | UInt16(bytes[1]) << 8)
|
|
}
|
|
|
|
private func control(selector: UInt8, request: UInt8, bytes: inout [UInt8]) throws {
|
|
guard let handle = self.handle else {
|
|
throw CameraPTZError.unsupported("controller is closed")
|
|
}
|
|
var error: UnsafeMutablePointer<CChar>?
|
|
let ok = bytes.withUnsafeMutableBytes { buffer in
|
|
openclaw_uvc_control(
|
|
handle,
|
|
selector,
|
|
request,
|
|
buffer.baseAddress,
|
|
UInt16(buffer.count),
|
|
&error)
|
|
}
|
|
guard ok == 1 else {
|
|
throw Self.nativeError(error, fallback: "perform camera control request")
|
|
}
|
|
}
|
|
|
|
private static func encodePanTilt(pan: Int32, tilt: Int32) -> [UInt8] {
|
|
self.encodeInt32(pan) + self.encodeInt32(tilt)
|
|
}
|
|
|
|
private static func encodeZoom(_ zoom: Int32) -> [UInt8] {
|
|
let value = UInt16(truncatingIfNeeded: zoom)
|
|
return [UInt8(truncatingIfNeeded: value), UInt8(truncatingIfNeeded: value >> 8)]
|
|
}
|
|
|
|
private static func encodeInt32(_ value: Int32) -> [UInt8] {
|
|
let bits = UInt32(bitPattern: value)
|
|
return (0..<4).map { UInt8(truncatingIfNeeded: bits >> UInt32($0 * 8)) }
|
|
}
|
|
|
|
private static func decodeInt32(_ bytes: [UInt8], offset: Int) -> Int32 {
|
|
var value: UInt32 = 0
|
|
for index in 0..<4 {
|
|
value |= UInt32(bytes[offset + index]) << UInt32(index * 8)
|
|
}
|
|
return Int32(bitPattern: value)
|
|
}
|
|
|
|
private static func nativeError(
|
|
_ error: UnsafeMutablePointer<CChar>?,
|
|
fallback: String) -> CameraPTZError
|
|
{
|
|
guard let error else { return .unsupported(fallback) }
|
|
defer { free(error) }
|
|
return .unsupported(String(cString: error))
|
|
}
|
|
}
|