Add TravelAlbum, ProfileSpace, and wired camera transfer modules.

Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 09:41:05 +08:00
parent 5692134efc
commit d2fe5d71e4
48 changed files with 6477 additions and 11 deletions

View File

@ -0,0 +1,806 @@
import CoreGraphics
import Foundation
import ImageCaptureCore
final class CameraDownloadDelegate: NSObject, ICCameraDeviceDownloadDelegate {
var onComplete: ((ICCameraFile, URL?, Error?) -> Void)?
var onProgress: ((String, Double) -> Void)?
@objc func didDownloadFile(_ file: ICCameraFile, error: Error?, options: [String: Any], contextInfo: UnsafeMutableRawPointer?) {
if let error {
CameraTetheringLogger.log("下载失败 \(file.name ?? "?"): \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
onComplete?(file, nil, error)
return
}
let downloadOptions = options as? [ICDownloadOption: Any] ?? [:]
if let directory = downloadOptions[.downloadsDirectoryURL] as? URL,
let filename = downloadOptions[.savedFilename] as? String {
let url = directory.appendingPathComponent(filename)
CameraTetheringLogger.log("下载完成 \(filename) -> \(url.path)", logger: CameraTetheringLogger.transfer)
onComplete?(file, url, nil)
} else if let urlString = file.value(forKey: "url") as? String {
let url = URL(fileURLWithPath: urlString)
CameraTetheringLogger.log("下载完成 \(file.name ?? "?") -> \(url.path)", logger: CameraTetheringLogger.transfer)
onComplete?(file, url, nil)
} else {
CameraTetheringLogger.log("下载完成但无法定位路径: \(file.name ?? "?")", logger: CameraTetheringLogger.transfer)
onComplete?(file, nil, CameraServiceError.downloadFailed("无法定位下载文件路径"))
}
}
@objc func didReceiveDownloadProgress(for file: ICCameraFile, downloadedBytes: off_t, maxBytes: off_t) {
guard maxBytes > 0, let name = file.name else { return }
onProgress?(name, Double(downloadedBytes) / Double(maxBytes))
}
}
final class ImageCaptureDeviceManager: NSObject {
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewItems: (([ICCameraItem]) -> Void)?
var onBaselineCatalogReady: (() -> Void)?
var onPollTick: (() -> Void)?
var onPTPAssetReady: ((CameraAsset, URL) -> Void)?
var onError: ((String) -> Void)?
private(set) var cameraDevice: ICCameraDevice?
private(set) var isCatalogReady = false
private var deviceBrowser: ICDeviceBrowser?
private let downloadDelegate = CameraDownloadDelegate()
private var transactionID: UInt32 = 1
private var pendingDownloads: [String: CheckedContinuation<URL, Error>] = [:]
private var ptpAssetURLs: [String: URL] = [:]
private var pollTimer: Timer?
private var baselineNotified = false
private var isRemoteSessionReady = false
private var isDownloadingPTPObject = false
private var propertyCheckTask: Task<Void, Never>?
private var shotBufferPollTimer: Timer?
private var lastDownloadedObjectSignature: String?
private var lastShootingFileInfoValue: Int64?
private var shotDownloadQueue: [(handle: UInt32, reason: String)] = []
private var isDrainingShotQueue = false
private var isEvaluatingShotBuffer = false
private var propertyCheckCoalesceScheduled = false
var isBrowsing: Bool { deviceBrowser != nil }
func startBrowsing() {
guard deviceBrowser == nil else {
CameraTetheringLogger.log("已在监听 USB 相机")
if cameraDevice == nil {
updateState(.searching)
}
return
}
CameraTetheringLogger.log("开始搜索 USB 相机…")
updateState(.searching)
let browser = ICDeviceBrowser()
browser.delegate = self
if #available(iOS 15.2, *) {
let mask = ICDeviceTypeMask(
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
) ?? .camera
browser.browsedDeviceTypeMask = mask
CameraTetheringLogger.log("已设置 browsedDeviceTypeMask = Camera | Local USB")
}
deviceBrowser = browser
browser.start()
}
func stopBrowsing() {
CameraTetheringLogger.log("停止搜索并断开")
propertyCheckTask?.cancel()
shotDownloadQueue.removeAll()
isDrainingShotQueue = false
isEvaluatingShotBuffer = false
propertyCheckCoalesceScheduled = false
propertyCheckTask = nil
stopShotBufferPolling()
stopPolling()
disconnect()
deviceBrowser?.stop()
deviceBrowser = nil
isCatalogReady = false
baselineNotified = false
updateState(.disconnected)
}
func disconnect() {
propertyCheckTask?.cancel()
shotDownloadQueue.removeAll()
isDrainingShotQueue = false
isEvaluatingShotBuffer = false
propertyCheckCoalesceScheduled = false
propertyCheckTask = nil
stopShotBufferPolling()
stopPolling()
if let camera = cameraDevice {
camera.ptpEventHandler = { _ in }
camera.requestCloseSession()
camera.delegate = nil
}
cameraDevice = nil
transactionID = 1
isCatalogReady = false
baselineNotified = false
isRemoteSessionReady = false
ptpAssetURLs.removeAll()
lastDownloadedObjectSignature = nil
lastShootingFileInfoValue = nil
shotDownloadQueue.removeAll()
isDrainingShotQueue = false
isEvaluatingShotBuffer = false
propertyCheckCoalesceScheduled = false
}
func localURL(forAssetID assetID: String) -> URL? {
ptpAssetURLs[assetID]
}
func allFiles() -> [ICCameraFile] {
guard let camera = cameraDevice else { return [] }
if let mediaFiles = camera.mediaFiles, !mediaFiles.isEmpty {
return mediaFiles.compactMap { $0 as? ICCameraFile }
}
return collectFiles(from: camera.contents ?? [])
}
func file(forAssetID assetID: String) -> ICCameraFile? {
allFiles().first { assetIdentifier(for: $0) == assetID }
}
func download(file: ICCameraFile, to directory: URL) async throws -> URL {
guard let camera = cameraDevice else {
throw CameraServiceError.notConnected
}
let filename = file.name ?? "photo_\(UUID().uuidString).jpg"
let assetID = assetIdentifier(for: file)
CameraTetheringLogger.log("开始下载 \(filename), handle=\(file.ptpObjectHandle)", logger: CameraTetheringLogger.transfer)
return try await withCheckedThrowingContinuation { continuation in
pendingDownloads[assetID] = continuation
downloadDelegate.onComplete = { [weak self] completedFile, url, error in
guard let self else { return }
let completedID = self.assetIdentifier(for: completedFile)
guard let pending = self.pendingDownloads.removeValue(forKey: completedID) else { return }
if let error {
pending.resume(throwing: error)
} else if let url {
pending.resume(returning: url)
} else {
pending.resume(throwing: CameraServiceError.downloadFailed("未知错误"))
}
}
let options: [ICDownloadOption: Any] = [
.downloadsDirectoryURL: directory,
.savedFilename: filename,
.overwrite: true
]
camera.requestDownloadFile(
file,
options: options,
downloadDelegate: downloadDelegate,
didDownloadSelector: #selector(CameraDownloadDelegate.didDownloadFile(_:error:options:contextInfo:)),
contextInfo: nil
)
}
}
func assetIdentifier(for file: ICCameraFile) -> String {
if file.ptpObjectHandle != 0 {
return "ptp_\(file.ptpObjectHandle)"
}
if let persistentID = file.value(forKey: "persistentID") as? String, !persistentID.isEmpty {
return persistentID
}
let name = file.name ?? "unknown"
let size = file.fileSize
let date = file.creationDate?.timeIntervalSince1970 ?? 0
return "\(name)_\(size)_\(Int(date))"
}
func makeAsset(from file: ICCameraFile) -> CameraAsset {
let filename = file.name ?? "unknown"
let isMovie = file.duration > 0 || Self.movieExtensions.contains(where: { filename.lowercased().hasSuffix($0) })
return CameraAsset(
id: assetIdentifier(for: file),
filename: filename,
fileSize: file.fileSize,
creationDate: file.creationDate,
isMovie: isMovie
)
}
private static let movieExtensions = [".mp4", ".mov", ".m4v", ".avi"]
private func collectFiles(from items: [ICCameraItem]) -> [ICCameraFile] {
var files: [ICCameraFile] = []
for item in items {
if let file = item as? ICCameraFile {
files.append(file)
} else if let folder = item as? ICCameraFolder {
files.append(contentsOf: collectFiles(from: folder.contents ?? []))
}
}
return files
}
private func updateState(_ state: CameraConnectionState) {
CameraTetheringLogger.log("连接状态: \(state.displayText)")
onConnectionStateChange?(state)
}
private func requestAuthorizations(completion: @escaping (Bool, Bool) -> Void) {
guard let browser = deviceBrowser else {
completion(false, false)
return
}
var contentsGranted = false
var controlGranted = false
let group = DispatchGroup()
group.enter()
browser.requestContentsAuthorization { status in
contentsGranted = status == .authorized
CameraTetheringLogger.log("内容授权: \(String(describing: status))")
group.leave()
}
group.enter()
browser.requestControlAuthorization { status in
controlGranted = status == .authorized
CameraTetheringLogger.log("控制授权: \(String(describing: status))")
group.leave()
}
group.notify(queue: .main) {
completion(contentsGranted, controlGranted)
}
}
private func openSession(for camera: ICCameraDevice) {
updateState(.connecting)
camera.delegate = self
cameraDevice = camera
CameraTetheringLogger.log("打开相机会话: \(camera.name ?? "Unknown")")
camera.requestOpenSession()
}
private func configureCameraAfterSession(_ camera: ICCameraDevice) {
let capabilities = camera.capabilities.joined(separator: ", ")
CameraTetheringLogger.log("相机能力: [\(capabilities)]")
CameraTetheringLogger.log("tetheredCaptureEnabled=\(camera.tetheredCaptureEnabled)")
camera.ptpEventHandler = { [weak self] eventData in
self?.handlePTPEvent(eventData)
}
Task {
let updatedID = await SonyPTPHelper.initializeRemoteSession(
on: camera,
startingTransactionID: transactionID
)
transactionID = updatedID
isRemoteSessionReady = true
startShotBufferPolling()
schedulePropertyBasedShotCheck(reason: "PostHandshake", delay: 0.2)
}
}
private func handlePTPEvent(_ eventData: Data) {
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
CameraTetheringLogger.log("收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp)
guard let container = try? SonyPTPHelper.parseContainer(eventData) else {
CameraTetheringLogger.log("PTP 事件解析失败", logger: CameraTetheringLogger.ptp)
return
}
CameraTetheringLogger.log(
"PTP 事件码: 0x\(String(format: "%04X", container.code)), params=\(container.params)",
logger: CameraTetheringLogger.ptp
)
switch container.code {
case SonyPTPCommand.objectAdded, SonyPTPCommand.sdieObjectAdded:
guard isRemoteSessionReady else { break }
let handle = container.params.first ?? SonyPTPCommand.shotObjectHandle
enqueueShotDownload(handle: handle, reason: "ObjectAdded")
case SonyPTPCommand.sdieCapturedEvent:
guard isRemoteSessionReady else { break }
enqueueShotDownload(handle: SonyPTPCommand.shotObjectHandle, reason: "CapturedEvent")
case SonyPTPCommand.sdieObjectRemoved:
lastShootingFileInfoValue = nil
case SonyPTPCommand.sdieDevicePropChanged:
guard isRemoteSessionReady else { break }
schedulePropertyBasedShotCheck(reason: "DevicePropChanged")
default:
break
}
}
private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval = 0.12) {
if isEvaluatingShotBuffer {
propertyCheckCoalesceScheduled = true
return
}
propertyCheckTask?.cancel()
propertyCheckTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return }
await self?.runPropertyBasedShotCheck(reason: reason)
}
}
private func runPropertyBasedShotCheck(reason: String) async {
if isEvaluatingShotBuffer {
propertyCheckCoalesceScheduled = true
return
}
isEvaluatingShotBuffer = true
defer {
isEvaluatingShotBuffer = false
if propertyCheckCoalesceScheduled {
propertyCheckCoalesceScheduled = false
Task { [weak self] in
try? await Task.sleep(nanoseconds: 80_000_000)
await self?.runPropertyBasedShotCheck(reason: "Coalesced")
}
}
}
await evaluateShotBufferFromProperties(reason: reason)
}
private func evaluateShotBufferFromProperties(reason: String) async {
guard isRemoteSessionReady, let camera = cameraDevice else { return }
var localTransactionID = transactionID
var properties = await SonyPTPHelper.fetchExtDeviceProperties(
on: camera,
transactionID: &localTransactionID,
onlyDiff: true
)
transactionID = localTransactionID
var shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo]
// diff D215 fallback Probe
if shootingFileInfo == nil, shouldFallbackFullPropertyFetch(for: reason) {
properties = await SonyPTPHelper.fetchExtDeviceProperties(
on: camera,
transactionID: &localTransactionID,
onlyDiff: false
)
transactionID = localTransactionID
shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo]
CameraTetheringLogger.log("\(reason): diff 无 D215已读取全量属性", logger: CameraTetheringLogger.ptp)
}
if let shootingFileInfo {
CameraTetheringLogger.log(
"\(reason): D215(Shooting_File_Info)=0x\(String(format: "%04X", shootingFileInfo))",
logger: CameraTetheringLogger.ptp
)
lastShootingFileInfoValue = shootingFileInfo
} else {
CameraTetheringLogger.log("\(reason): 属性包无 D215改用 Probe 检测缓冲", logger: CameraTetheringLogger.ptp)
}
if SonyPTPHelper.isShotBufferReady(shootingFileInfo) {
_ = await attemptFetchPendingShot(reason: "\(reason)-D215")
return
}
if shouldProbeWithoutD215Ready(for: reason) {
_ = await attemptFetchPendingShot(reason: "\(reason)-Probe")
}
}
private func shouldFallbackFullPropertyFetch(for reason: String) -> Bool {
reason.contains("DevicePropChanged")
|| reason.hasPrefix("D215Poll")
|| reason.hasPrefix("Coalesced")
}
private func shouldProbeWithoutD215Ready(for reason: String) -> Bool {
reason.contains("DevicePropChanged")
|| reason.hasPrefix("D215Poll")
|| reason.hasPrefix("Coalesced")
|| reason.hasPrefix("BufferDrain")
}
/// Probe filename+size
@discardableResult
private func attemptFetchPendingShot(reason: String) async -> Bool {
guard isRemoteSessionReady, let camera = cameraDevice else { return false }
var localTransactionID = transactionID
guard let probe = await SonyPTPHelper.probeShotObject(
on: camera,
handle: SonyPTPCommand.shotObjectHandle,
transactionID: &localTransactionID
) else {
return false
}
transactionID = localTransactionID
let signature = SonyPTPHelper.shotObjectSignature(filename: probe.filename, size: probe.size)
if signature == lastDownloadedObjectSignature {
CameraTetheringLogger.log("\(reason): 缓冲无新片 signature=\(signature)", logger: CameraTetheringLogger.ptp)
return false
}
enqueueShotDownload(handle: SonyPTPCommand.shotObjectHandle, reason: reason)
return true
}
private func startShotBufferPolling() {
stopShotBufferPolling()
CameraTetheringLogger.log("启动 D215 缓冲轮询 (连拍时 0.25s / 空闲 1.0s)", logger: CameraTetheringLogger.ptp)
refreshShotBufferPollInterval()
}
private func refreshShotBufferPollInterval() {
guard isRemoteSessionReady else { return }
let interval = (isDrainingShotQueue || !shotDownloadQueue.isEmpty) ? 0.25 : 1.0
stopShotBufferPolling()
shotBufferPollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
guard let self, self.isRemoteSessionReady else { return }
Task { await self.evaluateShotBufferFromProperties(reason: "D215Poll") }
}
}
private func stopShotBufferPolling() {
shotBufferPollTimer?.invalidate()
shotBufferPollTimer = nil
}
/// PTP
private func enqueueShotDownload(handle: UInt32, reason: String) {
shotDownloadQueue.append((handle: handle, reason: reason))
CameraTetheringLogger.log(
"\(reason): 入队 PTP 下载 (队列=\(shotDownloadQueue.count))",
logger: CameraTetheringLogger.ptp
)
refreshShotBufferPollInterval()
startShotDownloadDrainIfNeeded()
}
private func startShotDownloadDrainIfNeeded() {
guard !isDrainingShotQueue else { return }
guard !shotDownloadQueue.isEmpty else { return }
isDrainingShotQueue = true
Task { [weak self] in
await self?.drainShotDownloadQueue()
}
}
private func drainShotDownloadQueue() async {
defer {
isDrainingShotQueue = false
refreshShotBufferPollInterval()
if !shotDownloadQueue.isEmpty {
startShotDownloadDrainIfNeeded()
}
}
while !shotDownloadQueue.isEmpty {
let item = shotDownloadQueue.removeFirst()
_ = await downloadPTPObject(
handle: item.handle,
reason: item.reason,
maxAttempts: 8,
tryDirectGetObject: true
)
try? await Task.sleep(nanoseconds: 120_000_000)
}
await drainRemainingBuffer(maxExtraDownloads: 12)
}
/// D215/Probe
private func drainRemainingBuffer(maxExtraDownloads: Int) async {
for round in 1...maxExtraDownloads {
guard isRemoteSessionReady, let camera = cameraDevice else { break }
var localTransactionID = transactionID
guard let probe = await SonyPTPHelper.probeShotObject(
on: camera,
handle: SonyPTPCommand.shotObjectHandle,
transactionID: &localTransactionID
) else {
break
}
transactionID = localTransactionID
let signature = SonyPTPHelper.shotObjectSignature(filename: probe.filename, size: probe.size)
if signature == lastDownloadedObjectSignature {
break
}
let downloaded = await downloadPTPObject(
handle: SonyPTPCommand.shotObjectHandle,
reason: "BufferDrain-\(round)",
maxAttempts: 6,
tryDirectGetObject: true
)
guard downloaded else { break }
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
@discardableResult
private func downloadPTPObject(
handle: UInt32,
reason: String,
maxAttempts: Int = 3,
tryDirectGetObject: Bool = false
) async -> Bool {
guard isRemoteSessionReady, let camera = cameraDevice else {
CameraTetheringLogger.log("\(reason): Sony 远程会话尚未就绪", logger: CameraTetheringLogger.ptp)
return false
}
guard !isDownloadingPTPObject else {
CameraTetheringLogger.log("\(reason): 下载重入,重新入队", logger: CameraTetheringLogger.ptp)
shotDownloadQueue.insert((handle: handle, reason: reason), at: 0)
return false
}
isDownloadingPTPObject = true
defer { isDownloadingPTPObject = false }
var localTransactionID = transactionID
for attempt in 1...maxAttempts {
if attempt > 1 {
try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000)
}
var probeFilename = "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
var probeSize: UInt32 = 0
if let probe = await SonyPTPHelper.probeShotObject(
on: camera,
handle: handle,
transactionID: &localTransactionID
) {
probeFilename = probe.filename
probeSize = probe.size
} else if !tryDirectGetObject {
if attempt == maxAttempts {
CameraTetheringLogger.log("\(reason): 相机缓冲区内暂无可用照片", logger: CameraTetheringLogger.ptp)
}
continue
}
let signature: String
if probeSize > 0 {
signature = SonyPTPHelper.shotObjectSignature(filename: probeFilename, size: probeSize)
if signature == lastDownloadedObjectSignature {
CameraTetheringLogger.log("\(reason): 照片已下载过 \(probeFilename),跳过", logger: CameraTetheringLogger.ptp)
return false
}
} else {
signature = "\(probeFilename)_attempt\(attempt)_\(Int(Date().timeIntervalSince1970))"
}
guard let result = await SonyPTPHelper.downloadObject(
handle: handle,
on: camera,
transactionID: &localTransactionID,
skipObjectInfo: tryDirectGetObject && probeSize == 0
) else {
if attempt == maxAttempts {
CameraTetheringLogger.log("\(reason): PTP 下载失败 handle=0x\(String(format: "%08X", handle))", logger: CameraTetheringLogger.ptp)
}
continue
}
transactionID = localTransactionID
lastDownloadedObjectSignature = probeSize > 0 ? signature : "\(result.filename)_\(result.data.count)"
let assetID = "ptp_\(handle)_\(result.filename)_\(result.data.count)"
let destination = CameraDownloadStorage.uniqueLocalURL(for: result.filename)
do {
try result.data.write(to: destination, options: .atomic)
} catch {
CameraTetheringLogger.log("PTP 文件写入失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
return false
}
let asset = CameraAsset(
id: assetID,
filename: result.filename,
fileSize: Int64(result.data.count),
creationDate: Date(),
isMovie: false
)
ptpAssetURLs[assetID] = destination
CameraTetheringLogger.log(
"[\(reason)] PTP 下载完成: \(result.filename) -> \(destination.path)",
logger: CameraTetheringLogger.transfer
)
onPTPAssetReady?(asset, destination)
return true
}
return false
}
private func notifyBaselineOrPoll(reason: String) {
guard isCatalogReady else {
CameraTetheringLogger.log("\(reason): 目录尚未就绪,等待 catalog ready")
return
}
onPollTick?()
}
private func notifyBaselineIfNeeded() {
guard !baselineNotified else { return }
baselineNotified = true
isCatalogReady = true
let fileCount = allFiles().count
CameraTetheringLogger.log("目录就绪,当前 \(fileCount) 个文件,建立基线")
onBaselineCatalogReady?()
startPolling()
}
private func startPolling() {
stopPolling()
CameraTetheringLogger.log("启动轮询检测新照片 (每 1.5s)")
pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
guard let self, self.isCatalogReady else { return }
self.onPollTick?()
}
}
private func stopPolling() {
pollTimer?.invalidate()
pollTimer = nil
}
}
extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate {
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
CameraTetheringLogger.log("发现设备: \(device.name ?? "?"), type=\(device.type), transport=\(device.transportType ?? "?")")
guard let camera = device as? ICCameraDevice else {
CameraTetheringLogger.log("非相机设备,忽略")
return
}
guard cameraDevice == nil else {
CameraTetheringLogger.log("已有连接中的相机,忽略")
return
}
requestAuthorizations { [weak self] contentsGranted, controlGranted in
guard let self else { return }
guard contentsGranted else {
let message = "未获得相机内容访问权限,请在系统设置中允许"
CameraTetheringLogger.log(message)
self.onError?(message)
self.updateState(.error("权限被拒绝"))
return
}
if !controlGranted {
CameraTetheringLogger.log("警告: 控制授权未通过,仍尝试连接(下载通常只需内容授权)")
}
self.openSession(for: camera)
}
}
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
CameraTetheringLogger.log("设备移除: \(device.name ?? "?")")
if device.uuidString == cameraDevice?.uuidString {
disconnect()
updateState(.disconnected)
}
}
}
extension ImageCaptureDeviceManager: ICDeviceDelegate {
func didRemove(_ device: ICDevice) {
CameraTetheringLogger.log("didRemove: \(device.name ?? "?")")
if device.uuidString == cameraDevice?.uuidString {
disconnect()
updateState(.disconnected)
}
}
func device(_ device: ICDevice, didOpenSessionWithError error: Error?) {
if let error {
CameraTetheringLogger.log("会话打开失败: \(error.localizedDescription)")
onError?(error.localizedDescription)
updateState(.error(error.localizedDescription))
return
}
guard let camera = device as? ICCameraDevice else { return }
let name = camera.name ?? "Camera"
CameraTetheringLogger.log("会话已打开: \(name)")
updateState(.connected(deviceName: name))
configureCameraAfterSession(camera)
}
func device(_ device: ICDevice, didCloseSessionWithError error: Error?) {
if let error {
CameraTetheringLogger.log("会话关闭: \(error.localizedDescription)")
}
if device.uuidString == cameraDevice?.uuidString {
cameraDevice = nil
isCatalogReady = false
baselineNotified = false
stopPolling()
updateState(.disconnected)
}
}
}
extension ImageCaptureDeviceManager: ICCameraDeviceDelegate {
@objc(cameraDevice:didAddItems:)
func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) {
let names = items.compactMap { ($0 as? ICCameraFile)?.name ?? $0.name }
CameraTetheringLogger.log("didAddItems: \(names.joined(separator: ", "))")
for item in items {
if let file = item as? ICCameraFile {
CameraTetheringLogger.log(" + \(file.name ?? "?") addedAfterCatalog=\(file.wasAddedAfterContentCatalogCompleted)")
}
}
onNewItems?(items)
}
@objc(cameraDevice:didRemoveItems:)
func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) {
let names = items.compactMap { $0.name }
CameraTetheringLogger.log("didRemoveItems: \(names.joined(separator: ", "))")
}
@objc(cameraDevice:didRenameItems:)
func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) {
}
func cameraDevice(_ camera: ICCameraDevice, didReceiveThumbnail thumbnail: CGImage?, for item: ICCameraItem, error: Error?) {
}
func cameraDevice(_ camera: ICCameraDevice, didReceiveMetadata metadata: [AnyHashable: Any]?, for item: ICCameraItem, error: Error?) {
}
func deviceDidBecomeReady(withCompleteContentCatalog device: ICCameraDevice) {
CameraTetheringLogger.log("deviceDidBecomeReadyWithCompleteContentCatalog, 文件数=\(allFiles().count)")
notifyBaselineIfNeeded()
}
func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) {
CameraTetheringLogger.log("cameraDeviceDidChangeCapability")
}
func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) {
CameraTetheringLogger.log("cameraDeviceDidRemoveAccessRestriction")
}
func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) {
CameraTetheringLogger.log("cameraDeviceDidEnableAccessRestriction")
}
func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent eventData: Data) {
handlePTPEvent(eventData)
}
}

View File

@ -0,0 +1,162 @@
import Foundation
import ImageCaptureCore
@MainActor
final class SonyCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
private(set) var connectionState: CameraConnectionState = .disconnected
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
private let deviceManager = ImageCaptureDeviceManager()
private var knownAssetIDs: Set<String> = []
private var baselineEstablished = false
init() {
deviceManager.onConnectionStateChange = { [weak self] state in
Task { @MainActor in
self?.connectionState = state
self?.onConnectionStateChange?(state)
}
}
deviceManager.onNewItems = { [weak self] items in
Task { @MainActor in
self?.handleNewItems(items, source: "didAddItems")
}
}
deviceManager.onBaselineCatalogReady = { [weak self] in
Task { @MainActor in
self?.establishBaseline()
}
}
deviceManager.onPollTick = { [weak self] in
Task { @MainActor in
self?.scanForNewAssets(source: "poll")
}
}
deviceManager.onPTPAssetReady = { [weak self] asset, _ in
Task { @MainActor in
self?.handlePTPAsset(asset)
}
}
deviceManager.onError = { [weak self] message in
Task { @MainActor in
CameraTetheringLogger.log("相机错误: \(message)")
self?.connectionState = .error(message)
self?.onConnectionStateChange?(.error(message))
}
}
}
func connect() async {
CameraTetheringLogger.log("SonyCameraService.connect()")
let shouldResetBaseline = !deviceManager.isBrowsing
deviceManager.startBrowsing()
if shouldResetBaseline {
knownAssetIDs.removeAll()
baselineEstablished = false
}
}
func disconnect() async {
CameraTetheringLogger.log("SonyCameraService.disconnect()")
deviceManager.stopBrowsing()
knownAssetIDs.removeAll()
baselineEstablished = false
connectionState = .disconnected
onConnectionStateChange?(.disconnected)
}
func listAssets() async throws -> [CameraAsset] {
guard connectionState.isConnected else {
throw CameraServiceError.notConnected
}
let assets = deviceManager.allFiles().map { deviceManager.makeAsset(from: $0) }
CameraTetheringLogger.log("listAssets: \(assets.count) 个文件")
return assets
}
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
guard connectionState.isConnected else {
throw CameraServiceError.notConnected
}
if let cachedURL = deviceManager.localURL(forAssetID: asset.id) {
CameraTetheringLogger.log("downloadAsset: 返回 PTP 缓存 \(asset.filename)", logger: CameraTetheringLogger.transfer)
return cachedURL
}
guard let file = deviceManager.file(forAssetID: asset.id) else {
CameraTetheringLogger.log("downloadAsset: 找不到文件 \(asset.filename) id=\(asset.id)", logger: CameraTetheringLogger.transfer)
throw CameraServiceError.assetNotFound
}
let directory = CameraDownloadStorage.downloadsDirectory
return try await deviceManager.download(file: file, to: directory)
}
private func handlePTPAsset(_ asset: CameraAsset) {
guard !knownAssetIDs.contains(asset.id) else { return }
knownAssetIDs.insert(asset.id)
CameraTetheringLogger.log(
"[PTP] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)",
logger: CameraTetheringLogger.transfer
)
onNewAsset?(asset)
}
/// 线
private func establishBaseline() {
guard !baselineEstablished else { return }
baselineEstablished = true
let files = deviceManager.allFiles()
var skipped = 0
var newCount = 0
for file in files {
if file.wasAddedAfterContentCatalogCompleted {
emitAssetIfNew(file: file, source: "baseline-new")
newCount += 1
} else {
knownAssetIDs.insert(deviceManager.assetIdentifier(for: file))
skipped += 1
}
}
CameraTetheringLogger.log("基线已建立: 跳过已有 \(skipped) 张, 立即传输新拍 \(newCount)")
}
private func handleNewItems(_ items: [ICCameraItem], source: String) {
guard baselineEstablished else {
CameraTetheringLogger.log("\(source): 基线未建立,暂存处理")
return
}
for item in items {
guard let file = item as? ICCameraFile else { continue }
emitAssetIfNew(file: file, source: source)
}
}
private func scanForNewAssets(source: String) {
guard baselineEstablished else { return }
let files = deviceManager.allFiles()
for file in files {
emitAssetIfNew(file: file, source: source)
}
}
private func emitAssetIfNew(file: ICCameraFile, source: String) {
let asset = deviceManager.makeAsset(from: file)
guard !knownAssetIDs.contains(asset.id) else { return }
knownAssetIDs.insert(asset.id)
CameraTetheringLogger.log("[\(source)] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)", logger: CameraTetheringLogger.transfer)
onNewAsset?(asset)
}
}

View File

@ -0,0 +1,571 @@
import Foundation
import ImageCaptureCore
enum PTPContainerType: UInt16 {
case command = 0x0001
case data = 0x0002
case response = 0x0003
case event = 0x0004
}
struct PTPContainer {
let length: UInt32
let type: UInt16
let code: UInt16
let transactionID: UInt32
let params: [UInt32]
}
enum PTPParseError: Error {
case tooShort
case invalidLength
}
enum SonyPTPCommand {
static let getDeviceInfo: UInt16 = 0x1001
static let openSession: UInt16 = 0x1002
static let closeSession: UInt16 = 0x1003
static let getObjectHandles: UInt16 = 0x1007
static let getObjectInfo: UInt16 = 0x1008
static let getObject: UInt16 = 0x1009
static let objectAdded: UInt16 = 0x4002
static let sdieObjectAdded: UInt16 = 0xC201
static let sdieObjectRemoved: UInt16 = 0xC202
static let sdieDevicePropChanged: UInt16 = 0xC203
static let sdieCapturedEvent: UInt16 = 0xC206
static let sdioConnect: UInt16 = 0x9201
static let sdioGetExtDeviceInfo: UInt16 = 0x9202
static let sdioSetExtDevicePropValue: UInt16 = 0x9205
static let sdioGetAllExtDevicePropInfo: UInt16 = 0x9209
static let positionKeySetting: UInt16 = 0xD25A
static let stillImageSaveDestination: UInt16 = 0xD222
static let stillImageTransSize: UInt16 = 0xD268
static let shootingFileInfo: UInt16 = 0xD215
static let shotObjectHandle: UInt32 = 0xFFFF_C001
static let ptpResponseOk: UInt16 = 0x2001
static let saveDestinationHostPC: UInt16 = 1
static let saveDestinationHostAndCamera: UInt16 = 2
static let shotBufferReadyThreshold: Int64 = 0x8000
}
private enum PTPDataType: UInt16 {
case int8 = 0x0001
case uint8 = 0x0002
case int16 = 0x0003
case uint16 = 0x0004
case int32 = 0x0005
case uint32 = 0x0006
case int64 = 0x0007
case uint64 = 0x0008
case string = 0xFFFF
}
enum SonyPTPHelper {
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
}
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
)
}
static func parseEventCode(from eventData: Data) -> UInt16? {
guard let container = try? parseContainer(eventData) else { return nil }
return container.code
}
static func parseObjectCompressedSize(_ data: Data) -> UInt32? {
guard data.count >= 12 else { return nil }
return readUInt32LE(data, offset: 8)
}
static func parseObjectInfoFilename(_ data: Data) -> String? {
guard data.count >= 53 else { return nil }
if let filename = parsePTPString(data, offset: 52)?.string, !filename.isEmpty {
return CameraDownloadStorage.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 CameraDownloadStorage.sanitizeFilename(raw)
}
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 {
codeUnits.append(readUInt16LE(data, offset: offset + index * 2))
}
offset += charCount * 2
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
return (string, offset)
}
/// ImageCaptureCore PTP Sony SDIO PC Remote
static func initializeRemoteSession(on camera: ICCameraDevice, startingTransactionID: UInt32) async -> UInt32 {
var transactionID = startingTransactionID
CameraTetheringLogger.log("开始 Sony SDIO 握手", logger: CameraTetheringLogger.ptp)
let step1 = await sendCommand(
on: camera,
code: SonyPTPCommand.sdioConnect,
transactionID: &transactionID,
parameters: [1, 0, 0],
outData: nil,
label: "SDIO Connect(1)"
)
guard step1.success else { return transactionID }
let step2 = await sendCommand(
on: camera,
code: SonyPTPCommand.sdioConnect,
transactionID: &transactionID,
parameters: [2, 0, 0],
outData: nil,
label: "SDIO Connect(2)"
)
guard step2.success else { return transactionID }
let step3 = await sendCommand(
on: camera,
code: SonyPTPCommand.sdioGetExtDeviceInfo,
transactionID: &transactionID,
parameters: [0x012C, 1],
outData: nil,
label: "SDIO GetExtDeviceInfo"
)
guard step3.success else { return transactionID }
let step4 = await sendCommand(
on: camera,
code: SonyPTPCommand.sdioConnect,
transactionID: &transactionID,
parameters: [3, 0, 0],
outData: nil,
label: "SDIO Connect(3)"
)
guard step4.success else { return transactionID }
_ = await sendCommand(
on: camera,
code: SonyPTPCommand.sdioGetAllExtDevicePropInfo,
transactionID: &transactionID,
parameters: [1],
outData: nil,
label: "SDIO GetAllExtDevicePropInfo"
)
await configureRemoteCapture(on: camera, transactionID: &transactionID)
CameraTetheringLogger.log("Sony SDIO 握手完成", logger: CameraTetheringLogger.ptp)
return transactionID
}
static func probeShotObject(
on camera: ICCameraDevice,
handle: UInt32 = SonyPTPCommand.shotObjectHandle,
transactionID: inout UInt32
) async -> (filename: String, size: UInt32)? {
let infoResult = await sendCommand(
on: camera,
code: SonyPTPCommand.getObjectInfo,
transactionID: &transactionID,
parameters: [handle],
outData: nil,
label: "ProbeObjectInfo(0x\(String(format: "%08X", handle)))"
)
guard infoResult.success else { return nil }
guard let size = parseObjectCompressedSize(infoResult.inData), size > 0 else {
return nil
}
let filename = parseObjectInfoFilename(infoResult.inData)
?? CameraDownloadStorage.sanitizeFilename("DSC_\(Int(Date().timeIntervalSince1970)).JPG")
return (filename, size)
}
static func downloadObject(
handle: UInt32,
on camera: ICCameraDevice,
transactionID: inout UInt32,
skipObjectInfo: Bool = false
) async -> (filename: String, data: Data)? {
var filename = CameraDownloadStorage.sanitizeFilename("DSC_\(Int(Date().timeIntervalSince1970)).JPG")
if !skipObjectInfo {
let infoResult = await sendCommand(
on: camera,
code: SonyPTPCommand.getObjectInfo,
transactionID: &transactionID,
parameters: [handle],
outData: nil,
label: "GetObjectInfo(0x\(String(format: "%08X", handle)))"
)
if infoResult.success, let parsedName = parseObjectInfoFilename(infoResult.inData) {
filename = parsedName
}
}
let objectResult = await sendCommand(
on: camera,
code: SonyPTPCommand.getObject,
transactionID: &transactionID,
parameters: [handle],
outData: nil,
label: "GetObject(0x\(String(format: "%08X", handle)))"
)
guard objectResult.success, isLikelyImageData(objectResult.inData) else { return nil }
return (filename, objectResult.inData)
}
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
}
static func isResponseOK(_ response: Data) -> Bool {
guard !response.isEmpty else { return true }
guard let parsed = try? parseContainer(response) else { return false }
return parsed.code == SonyPTPCommand.ptpResponseOk
}
static func fetchExtDeviceProperties(
on camera: ICCameraDevice,
transactionID: inout UInt32,
onlyDiff: Bool = true
) async -> [UInt16: Int64] {
let result = await sendCommand(
on: camera,
code: SonyPTPCommand.sdioGetAllExtDevicePropInfo,
transactionID: &transactionID,
parameters: [onlyDiff ? 1 : 0],
outData: nil,
label: "GetAllExtDevicePropInfo(\(onlyDiff ? "diff" : "all"))"
)
guard result.success else { return [:] }
return parseExtDeviceProperties(result.inData)
}
static func parseExtDeviceProperties(_ data: Data) -> [UInt16: Int64] {
guard data.count >= 8 else { return [:] }
var properties: [UInt16: Int64] = [:]
var offset = 8
while offset + 6 <= data.count {
let propertyCode = readUInt16LE(data, offset: offset)
offset += 2
let dataType = readUInt16LE(data, offset: offset)
offset += 2
offset += 2 // getset, isenabled
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { break }
guard let currentValue = readVariableValue(dataType: dataType, data: data, offset: &offset) else { break }
properties[propertyCode] = currentValue
guard offset < data.count else { break }
let formFlag = data[offset]
offset += 1
guard skipFormPayload(formFlag: formFlag, dataType: dataType, data: data, offset: &offset) else { break }
}
return properties
}
static func isShotBufferReady(_ shootingFileInfo: Int64?) -> Bool {
guard let shootingFileInfo else { return false }
return shootingFileInfo > SonyPTPCommand.shotBufferReadyThreshold
}
static func shotObjectSignature(filename: String, size: UInt32) -> String {
"\(filename)_\(size)"
}
private static func readVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Int64? {
guard let type = PTPDataType(rawValue: dataType) else { return nil }
switch type {
case .int8:
guard offset < data.count else { return nil }
let value = Int64(Int8(bitPattern: data[offset]))
offset += 1
return value
case .uint8:
guard offset < data.count else { return nil }
let value = Int64(data[offset])
offset += 1
return value
case .int16:
guard offset + 1 < data.count else { return nil }
let value = Int64(Int16(bitPattern: readUInt16LE(data, offset: offset)))
offset += 2
return value
case .uint16:
guard offset + 1 < data.count else { return nil }
let value = Int64(readUInt16LE(data, offset: offset))
offset += 2
return value
case .int32:
guard offset + 3 < data.count else { return nil }
let value = Int64(Int32(bitPattern: readUInt32LE(data, offset: offset)))
offset += 4
return value
case .uint32:
guard offset + 3 < data.count else { return nil }
let value = Int64(readUInt32LE(data, offset: offset))
offset += 4
return value
case .int64, .uint64:
guard offset + 7 < data.count else { return nil }
offset += 8
return 0
case .string:
guard offset < data.count else { return nil }
let length = Int(data[offset])
offset += 1 + length * 2
return 0
}
}
private static func skipVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Bool {
readVariableValue(dataType: dataType, data: data, offset: &offset) != nil
}
private static func skipFormPayload(formFlag: UInt8, dataType: UInt16, data: Data, offset: inout Int) -> Bool {
switch formFlag {
case 0:
return true
case 1:
for _ in 0..<3 {
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
}
return true
case 2:
guard offset + 1 < data.count else { return false }
var enumCount = Int(readUInt16LE(data, offset: offset))
offset += 2
for _ in 0..<enumCount {
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
}
guard offset + 1 < data.count else { return false }
enumCount = Int(readUInt16LE(data, offset: offset))
offset += 2
for _ in 0..<enumCount {
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
}
return true
default:
return true
}
}
private static func configureRemoteCapture(on camera: ICCameraDevice, transactionID: inout UInt32) async {
_ = await setDeviceProperty(
on: camera,
propertyCode: SonyPTPCommand.positionKeySetting,
value: 1,
transactionID: &transactionID,
label: "Position_Key_Setting=PC Remote"
)
_ = await setDeviceProperty(
on: camera,
propertyCode: SonyPTPCommand.stillImageSaveDestination,
value: SonyPTPCommand.saveDestinationHostPC,
transactionID: &transactionID,
label: "Still_Image_Save_Destination=Host PC"
)
_ = await setDevicePropertyUInt32(
on: camera,
propertyCode: SonyPTPCommand.stillImageTransSize,
value: 0xFFFF_FFFF,
transactionID: &transactionID,
label: "Still_Image_Trans_Size=Full"
)
}
private static func setDevicePropertyUInt32(
on camera: ICCameraDevice,
propertyCode: UInt16,
value: UInt32,
transactionID: inout UInt32,
label: String
) async -> Bool {
var payload = Data()
appendUInt32LE(value, to: &payload)
return await sendCommand(
on: camera,
code: SonyPTPCommand.sdioSetExtDevicePropValue,
transactionID: &transactionID,
parameters: [UInt32(propertyCode), 1],
outData: payload,
label: label
).success
}
private static func setDeviceProperty(
on camera: ICCameraDevice,
propertyCode: UInt16,
value: UInt16,
transactionID: inout UInt32,
label: String
) async -> Bool {
var payload = Data()
appendUInt16LE(value, to: &payload)
return await sendCommand(
on: camera,
code: SonyPTPCommand.sdioSetExtDevicePropValue,
transactionID: &transactionID,
parameters: [UInt32(propertyCode), 1],
outData: payload,
label: label
).success
}
private struct PTPCommandResult {
let success: Bool
let response: Data
let inData: Data
}
private static func sendCommand(
on camera: ICCameraDevice,
code: UInt16,
transactionID: inout UInt32,
parameters: [UInt32],
outData: Data?,
label: String
) async -> PTPCommandResult {
let currentID = transactionID
transactionID += 1
let commandData = buildCommand(
code: code,
transactionID: currentID,
parameters: parameters
)
return await withCheckedContinuation { continuation in
camera.requestSendPTPCommand(
commandData,
outData: outData,
completion: { inData, response, error in
if let error {
CameraTetheringLogger.log("PTP \(label) 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.ptp)
continuation.resume(returning: PTPCommandResult(success: false, response: response, inData: inData))
return
}
var commandSuccess = true
if !response.isEmpty {
do {
let parsed = try parseContainer(response)
if parsed.code != SonyPTPCommand.ptpResponseOk {
CameraTetheringLogger.log(
"PTP \(label) 响应码: 0x\(String(format: "%04X", parsed.code)) (非 OK)",
logger: CameraTetheringLogger.ptp
)
commandSuccess = false
}
} catch {
CameraTetheringLogger.log("PTP \(label) 响应解析失败", logger: CameraTetheringLogger.ptp)
commandSuccess = false
}
}
if commandSuccess {
CameraTetheringLogger.log(
"PTP \(label) 成功, inData=\(inData.count) bytes",
logger: CameraTetheringLogger.ptp
)
}
continuation.resume(returning: PTPCommandResult(success: commandSuccess, response: response, inData: inData))
}
)
}
}
private static func appendUInt16LE(_ value: UInt16, to data: inout Data) {
var littleEndian = value.littleEndian
withUnsafeBytes(of: &littleEndian) { raw in
data.append(contentsOf: raw)
}
}
private 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)
}
}