308 lines
11 KiB
Swift
308 lines
11 KiB
Swift
//
|
||
// PTPUtilities.swift
|
||
// otg_swift
|
||
//
|
||
// Created by hanqiu on 2026/7/3.
|
||
//
|
||
|
||
import Foundation
|
||
@preconcurrency 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。
|
||
@MainActor
|
||
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(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 要求从主线程调用,方法本身由 MainActor 隔离。
|
||
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)
|
||
}
|
||
}
|