将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
318
suixinkan/Core/CameraOTG/PTP/PTPUtilities.swift
Normal file
318
suixinkan/Core/CameraOTG/PTP/PTPUtilities.swift
Normal file
@ -0,0 +1,318 @@
|
||||
//
|
||||
// PTPUtilities.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/3.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// PTP Container 类型字段(ISO 15740)。
|
||||
enum PTPContainerType: UInt16 {
|
||||
/// 主机发出的操作命令包
|
||||
case command = 0x0001
|
||||
/// 伴随命令的数据阶段(out/in Data)
|
||||
case data = 0x0002
|
||||
/// 设备对命令的响应包
|
||||
case response = 0x0003
|
||||
/// 设备主动上报的事件包(如 Sony SDIE)
|
||||
case event = 0x0004
|
||||
}
|
||||
|
||||
/// 解析后的 PTP 容器:命令、数据、响应或事件包。
|
||||
struct PTPContainer {
|
||||
let length: UInt32
|
||||
let type: UInt16
|
||||
let code: UInt16
|
||||
let transactionID: UInt32
|
||||
let params: [UInt32]
|
||||
}
|
||||
|
||||
/// PTP Container 解析失败原因。
|
||||
enum PTPParseError: Error {
|
||||
/// 数据长度不足 12 字节(Container 最小头)
|
||||
case tooShort
|
||||
/// length 字段与 buffer 实际长度不一致
|
||||
case invalidLength
|
||||
}
|
||||
|
||||
/// 标准 PTP 操作码与响应码(ISO 15740)。
|
||||
enum PTPStandardCommand {
|
||||
/// GetDeviceInfo (0x1001),用于 PTP 管线 Probe
|
||||
static let getDeviceInfo: UInt16 = 0x1001
|
||||
/// GetObjectInfo (0x1008),Probe 虚拟对象元数据
|
||||
static let getObjectInfo: UInt16 = 0x1008
|
||||
/// GetObject (0x1009),下载对象二进制(Step 6)
|
||||
static let getObject: UInt16 = 0x1009
|
||||
/// ObjectAdded 事件 (0x4002)
|
||||
static let objectAdded: UInt16 = 0x4002
|
||||
/// RequestObjectTransfer 事件 (0x4009)
|
||||
static let requestObjectTransfer: UInt16 = 0x4009
|
||||
/// Response OK (0x2001),命令成功响应码
|
||||
static let responseOK: UInt16 = 0x2001
|
||||
}
|
||||
|
||||
/// PTP 包构建、解析与经 ImageCaptureCore 发送的通用工具。
|
||||
enum PTPHelper {
|
||||
|
||||
// MARK: - Build
|
||||
|
||||
/// 构建 PTP Command Container(小端序,length = 12 + 4×参数个数)。
|
||||
static func buildCommand(
|
||||
code: UInt16,
|
||||
transactionID: UInt32,
|
||||
parameters: [UInt32] = []
|
||||
) -> Data {
|
||||
let length = UInt32(12 + parameters.count * 4)
|
||||
var data = Data()
|
||||
appendUInt32LE(length, to: &data)
|
||||
appendUInt16LE(PTPContainerType.command.rawValue, to: &data)
|
||||
appendUInt16LE(code, to: &data)
|
||||
appendUInt32LE(transactionID, to: &data)
|
||||
for parameter in parameters {
|
||||
appendUInt32LE(parameter, to: &data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Parse
|
||||
|
||||
/// 从二进制数据解析 PTP Container;参数区按 4 字节对齐读取。
|
||||
static func parseContainer(_ data: Data) throws -> PTPContainer {
|
||||
guard data.count >= 12 else { throw PTPParseError.tooShort }
|
||||
|
||||
let length = readUInt32LE(data, offset: 0)
|
||||
guard length >= 12, data.count >= Int(length) else {
|
||||
throw PTPParseError.invalidLength
|
||||
}
|
||||
|
||||
let type = readUInt16LE(data, offset: 4)
|
||||
let code = readUInt16LE(data, offset: 6)
|
||||
let transactionID = readUInt32LE(data, offset: 8)
|
||||
|
||||
var params: [UInt32] = []
|
||||
var offset = 12
|
||||
while offset + 4 <= Int(length) {
|
||||
params.append(readUInt32LE(data, offset: offset))
|
||||
offset += 4
|
||||
}
|
||||
|
||||
return PTPContainer(
|
||||
length: length,
|
||||
type: type,
|
||||
code: code,
|
||||
transactionID: transactionID,
|
||||
params: params
|
||||
)
|
||||
}
|
||||
|
||||
/// 从 PTP Event Container 提取事件码(如 Sony 0xC203)。
|
||||
static func parseEventCode(from eventData: Data) -> UInt16? {
|
||||
try? parseContainer(eventData).code
|
||||
}
|
||||
|
||||
static func isResponseOK(_ response: Data) -> Bool {
|
||||
guard !response.isEmpty else { return true }
|
||||
guard let parsed = try? parseContainer(response) else { return false }
|
||||
return parsed.code == PTPStandardCommand.responseOK
|
||||
}
|
||||
|
||||
static func hexString(_ data: Data) -> String {
|
||||
data.map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// 同一文件名+大小视为同一张待传照片,用于边拍边传去重。
|
||||
static func objectSignature(filename: String, size: UInt32) -> String {
|
||||
"\(filename)_\(size)"
|
||||
}
|
||||
|
||||
/// ObjectInfo 固定布局:offset 8 为 CompressedSize(UInt32 LE)。
|
||||
static func parseObjectCompressedSize(_ data: Data) -> UInt32? {
|
||||
guard data.count >= 12 else { return nil }
|
||||
return readUInt32LE(data, offset: 8)
|
||||
}
|
||||
|
||||
/// 从 ObjectInfo 解析文件名;优先 PTP 字符串格式,失败则扫描 UTF-16LE。
|
||||
static func parseObjectInfoFilename(_ data: Data) -> String? {
|
||||
guard data.count >= 53 else { return nil }
|
||||
|
||||
if let filename = parsePTPString(data, offset: 52)?.string, !filename.isEmpty {
|
||||
return sanitizeFilename(filename)
|
||||
}
|
||||
|
||||
var offset = 52
|
||||
var codeUnits: [UInt16] = []
|
||||
while offset + 1 < data.count {
|
||||
let unit = readUInt16LE(data, offset: offset)
|
||||
if unit == 0 { break }
|
||||
codeUnits.append(unit)
|
||||
offset += 2
|
||||
}
|
||||
|
||||
guard !codeUnits.isEmpty else { return nil }
|
||||
let raw = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
||||
return sanitizeFilename(raw)
|
||||
}
|
||||
|
||||
/// PTP 字符串:1 字节字符数 + UTF-16LE 字符序列(无 NUL 终止)。
|
||||
static func parsePTPString(_ data: Data, offset start: Int) -> (string: String, nextOffset: Int)? {
|
||||
guard start < data.count else { return nil }
|
||||
let charCount = Int(data[start])
|
||||
var offset = start + 1
|
||||
guard charCount > 0, offset + charCount * 2 <= data.count else { return nil }
|
||||
|
||||
var codeUnits: [UInt16] = []
|
||||
codeUnits.reserveCapacity(charCount)
|
||||
for index in 0..<charCount {
|
||||
let unit = readUInt16LE(data, offset: offset + index * 2)
|
||||
// 部分机身(如 ZV-E10)在 charCount 内含 NUL,需跳过
|
||||
if unit != 0 {
|
||||
codeUnits.append(unit)
|
||||
}
|
||||
}
|
||||
offset += charCount * 2
|
||||
|
||||
guard !codeUnits.isEmpty else { return nil }
|
||||
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
||||
return (string, offset)
|
||||
}
|
||||
|
||||
/// 去除 PTP 字符串残留的控制字符与非法路径字符。
|
||||
static func sanitizeFilename(_ name: String) -> String {
|
||||
let withoutNulls = name.replacingOccurrences(of: "\0", with: "")
|
||||
let trimmed = withoutNulls.trimmingCharacters(in: .whitespacesAndNewlines.union(.controlCharacters))
|
||||
let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>")
|
||||
let cleaned = trimmed.components(separatedBy: invalid).joined(separator: "_")
|
||||
return cleaned.isEmpty ? "photo.jpg" : cleaned
|
||||
}
|
||||
|
||||
/// 粗略判断 inData 是否为 JPEG/PNG 或足够大的二进制块。
|
||||
static func isLikelyImageData(_ data: Data) -> Bool {
|
||||
guard data.count >= 3 else { return false }
|
||||
if data[0] == 0xFF, data[1] == 0xD8, data[2] == 0xFF { return true }
|
||||
if data.count >= 4, data[0] == 0x89, data[1] == 0x50, data[2] == 0x4E, data[3] == 0x47 { return true }
|
||||
return data.count > 1024
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
struct CommandResult {
|
||||
let success: Bool
|
||||
let response: Data
|
||||
let inData: Data
|
||||
}
|
||||
|
||||
/// 经 `requestSendPTPCommand` 发送 PTP 命令;调用方传入并递增 `transactionID`。
|
||||
/// 必须在主线程发起(ImageCaptureCore 要求),completion 内 resume continuation。
|
||||
static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
parameters: [UInt32] = [],
|
||||
outData: Data? = nil,
|
||||
label: String
|
||||
) async -> CommandResult {
|
||||
let currentID = transactionID
|
||||
transactionID += 1
|
||||
|
||||
let commandData = buildCommand(
|
||||
code: code,
|
||||
transactionID: currentID,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
OTGLog.info(.ptp, "PTP command sending: \(label), bytes=\(commandData.count)")
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
let finish: (CommandResult) -> Void = { result in
|
||||
// continuation 必须在主线程 resume,避免 UI/IC 线程问题
|
||||
if Thread.isMainThread {
|
||||
continuation.resume(returning: result)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sendPTP = {
|
||||
camera.requestSendPTPCommand(
|
||||
commandData,
|
||||
outData: outData,
|
||||
completion: { inData, response, error in
|
||||
OTGLog.info(
|
||||
.ptp,
|
||||
"PTP callback received: \(label), inData=\(inData.count), response=\(response.count)"
|
||||
)
|
||||
|
||||
if let error {
|
||||
OTGLog.error(.ptp, "PTP \(label) failed: \(error.localizedDescription)")
|
||||
finish(CommandResult(success: false, response: response, inData: inData))
|
||||
return
|
||||
}
|
||||
|
||||
var ok = true
|
||||
if !response.isEmpty {
|
||||
if let parsed = try? parseContainer(response) {
|
||||
// 非 0x2001 ResponseOK 视为失败
|
||||
if parsed.code != PTPStandardCommand.responseOK {
|
||||
ok = false
|
||||
OTGLog.warning(
|
||||
.ptp,
|
||||
"PTP \(label) response code=0x\(String(format: "%04X", parsed.code))"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ok = false
|
||||
OTGLog.warning(.ptp, "PTP \(label) response parse failed")
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
OTGLog.info(.ptp, "PTP \(label) OK, inData=\(inData.count) bytes")
|
||||
}
|
||||
|
||||
finish(CommandResult(success: ok, response: response, inData: inData))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ImageCaptureCore PTP API 要求从主线程调用
|
||||
if Thread.isMainThread {
|
||||
sendPTP()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: sendPTP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Little Endian
|
||||
|
||||
static func appendUInt16LE(_ value: UInt16, to data: inout Data) {
|
||||
var littleEndian = value.littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { raw in
|
||||
data.append(contentsOf: raw)
|
||||
}
|
||||
}
|
||||
|
||||
static func appendUInt32LE(_ value: UInt32, to data: inout Data) {
|
||||
var littleEndian = value.littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { raw in
|
||||
data.append(contentsOf: raw)
|
||||
}
|
||||
}
|
||||
|
||||
static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 {
|
||||
UInt16(data[offset])
|
||||
| (UInt16(data[offset + 1]) << 8)
|
||||
}
|
||||
|
||||
static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 {
|
||||
UInt32(data[offset])
|
||||
| (UInt32(data[offset + 1]) << 8)
|
||||
| (UInt32(data[offset + 2]) << 16)
|
||||
| (UInt32(data[offset + 3]) << 24)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user