Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/OTG/PTP/PTPUtilities.swift
T

308 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
}
}