完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。
引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -2,39 +2,6 @@ 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)?
|
||||
@ -62,8 +29,27 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
private var isDrainingShotQueue = false
|
||||
private var isEvaluatingShotBuffer = false
|
||||
private var propertyCheckCoalesceScheduled = false
|
||||
private var isExternalAttach = false
|
||||
private var sessionSetupTask: Task<Void, Never>?
|
||||
private var sessionCloseContinuation: CheckedContinuation<Void, Never>?
|
||||
private var sessionCloseTimeoutTask: Task<Void, Never>?
|
||||
private var closingDeviceUUID: String?
|
||||
private var shotDownloadDrainTask: Task<Void, Never>?
|
||||
private var coalescedCheckTask: Task<Void, Never>?
|
||||
private var isDisconnecting = false
|
||||
|
||||
var isBrowsing: Bool { deviceBrowser != nil }
|
||||
var isBrowsing: Bool { deviceBrowser != nil || isExternalAttach }
|
||||
|
||||
/// 跳过 Browser,直接连接 AutoDetect 已发现并授权的设备。
|
||||
func attach(to camera: ICCameraDevice) {
|
||||
guard cameraDevice == nil else {
|
||||
CameraTetheringLogger.log("Sony attach: 已有连接中的相机,忽略")
|
||||
return
|
||||
}
|
||||
isExternalAttach = true
|
||||
CameraTetheringLogger.log("Sony attach: \(camera.name ?? "Unknown")")
|
||||
openSession(for: camera)
|
||||
}
|
||||
|
||||
func startBrowsing() {
|
||||
guard deviceBrowser == nil else {
|
||||
@ -90,7 +76,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
browser.start()
|
||||
}
|
||||
|
||||
func stopBrowsing() {
|
||||
func stopBrowsing() async {
|
||||
CameraTetheringLogger.log("停止搜索并断开")
|
||||
propertyCheckTask?.cancel()
|
||||
shotDownloadQueue.removeAll()
|
||||
@ -100,28 +86,94 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
propertyCheckTask = nil
|
||||
stopShotBufferPolling()
|
||||
stopPolling()
|
||||
disconnect()
|
||||
deviceBrowser?.stop()
|
||||
await disconnect()
|
||||
if !isExternalAttach {
|
||||
deviceBrowser?.stop()
|
||||
}
|
||||
deviceBrowser = nil
|
||||
isExternalAttach = false
|
||||
isCatalogReady = false
|
||||
baselineNotified = false
|
||||
updateState(.disconnected)
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
func disconnect() async {
|
||||
guard !isDisconnecting else { return }
|
||||
isDisconnecting = true
|
||||
defer { isDisconnecting = false }
|
||||
|
||||
sessionSetupTask?.cancel()
|
||||
sessionSetupTask = nil
|
||||
shotDownloadDrainTask?.cancel()
|
||||
shotDownloadDrainTask = nil
|
||||
coalescedCheckTask?.cancel()
|
||||
coalescedCheckTask = nil
|
||||
propertyCheckTask?.cancel()
|
||||
propertyCheckTask = nil
|
||||
|
||||
let shouldClosePTP = isRemoteSessionReady
|
||||
isRemoteSessionReady = false
|
||||
isDownloadingPTPObject = false
|
||||
|
||||
shotDownloadQueue.removeAll()
|
||||
isDrainingShotQueue = false
|
||||
isEvaluatingShotBuffer = false
|
||||
propertyCheckCoalesceScheduled = false
|
||||
propertyCheckTask = nil
|
||||
stopShotBufferPolling()
|
||||
stopPolling()
|
||||
if let camera = cameraDevice {
|
||||
camera.ptpEventHandler = { _ in }
|
||||
camera.requestCloseSession()
|
||||
camera.delegate = nil
|
||||
cancelPendingDownloads()
|
||||
|
||||
guard let camera = cameraDevice else {
|
||||
resetDisconnectLocalState()
|
||||
return
|
||||
}
|
||||
|
||||
let deviceUUID = camera.uuidString ?? ""
|
||||
camera.ptpEventHandler = { _ in }
|
||||
|
||||
if shouldClosePTP {
|
||||
var localTransactionID = transactionID
|
||||
await SonyPTPHelper.teardownRemoteSession(on: camera, transactionID: &localTransactionID)
|
||||
}
|
||||
|
||||
await waitForSessionClose(on: camera, deviceUUID: deviceUUID)
|
||||
camera.delegate = nil
|
||||
resetDisconnectLocalState()
|
||||
}
|
||||
|
||||
private func cancelPendingDownloads() {
|
||||
let disconnected = CameraServiceError.notConnected
|
||||
for (_, continuation) in pendingDownloads {
|
||||
continuation.resume(throwing: disconnected)
|
||||
}
|
||||
pendingDownloads.removeAll()
|
||||
}
|
||||
|
||||
private func waitForSessionClose(on camera: ICCameraDevice, deviceUUID: String) async {
|
||||
sessionCloseTimeoutTask?.cancel()
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
sessionCloseContinuation = continuation
|
||||
closingDeviceUUID = deviceUUID
|
||||
camera.requestCloseSession()
|
||||
|
||||
sessionCloseTimeoutTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
guard let self, self.sessionCloseContinuation != nil else { return }
|
||||
CameraTetheringLogger.log("会话关闭超时,继续 teardown")
|
||||
self.finishSessionClose()
|
||||
}
|
||||
}
|
||||
sessionCloseTimeoutTask?.cancel()
|
||||
sessionCloseTimeoutTask = nil
|
||||
}
|
||||
|
||||
private func finishSessionClose() {
|
||||
sessionCloseContinuation?.resume()
|
||||
sessionCloseContinuation = nil
|
||||
closingDeviceUUID = nil
|
||||
}
|
||||
|
||||
private func resetDisconnectLocalState() {
|
||||
cameraDevice = nil
|
||||
transactionID = 1
|
||||
isCatalogReady = false
|
||||
@ -134,6 +186,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
isDrainingShotQueue = false
|
||||
isEvaluatingShotBuffer = false
|
||||
propertyCheckCoalesceScheduled = false
|
||||
isExternalAttach = false
|
||||
}
|
||||
|
||||
func localURL(forAssetID assetID: String) -> URL? {
|
||||
@ -286,11 +339,13 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
self?.handlePTPEvent(eventData)
|
||||
}
|
||||
|
||||
Task {
|
||||
sessionSetupTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let updatedID = await SonyPTPHelper.initializeRemoteSession(
|
||||
on: camera,
|
||||
startingTransactionID: transactionID
|
||||
)
|
||||
guard !Task.isCancelled, !self.isDisconnecting else { return }
|
||||
transactionID = updatedID
|
||||
isRemoteSessionReady = true
|
||||
startShotBufferPolling()
|
||||
@ -299,6 +354,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
}
|
||||
|
||||
private func handlePTPEvent(_ eventData: Data) {
|
||||
guard !isDisconnecting, isRemoteSessionReady else { return }
|
||||
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
CameraTetheringLogger.log("收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp)
|
||||
|
||||
@ -331,6 +387,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
}
|
||||
|
||||
private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval = 0.12) {
|
||||
guard !isDisconnecting else { return }
|
||||
if isEvaluatingShotBuffer {
|
||||
propertyCheckCoalesceScheduled = true
|
||||
return
|
||||
@ -344,6 +401,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
}
|
||||
|
||||
private func runPropertyBasedShotCheck(reason: String) async {
|
||||
guard !isDisconnecting else { return }
|
||||
if isEvaluatingShotBuffer {
|
||||
propertyCheckCoalesceScheduled = true
|
||||
return
|
||||
@ -351,19 +409,23 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
isEvaluatingShotBuffer = true
|
||||
defer {
|
||||
isEvaluatingShotBuffer = false
|
||||
if propertyCheckCoalesceScheduled {
|
||||
if propertyCheckCoalesceScheduled, !isDisconnecting {
|
||||
propertyCheckCoalesceScheduled = false
|
||||
Task { [weak self] in
|
||||
coalescedCheckTask?.cancel()
|
||||
coalescedCheckTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.runPropertyBasedShotCheck(reason: "Coalesced")
|
||||
}
|
||||
} else {
|
||||
propertyCheckCoalesceScheduled = false
|
||||
}
|
||||
}
|
||||
await evaluateShotBufferFromProperties(reason: reason)
|
||||
}
|
||||
|
||||
private func evaluateShotBufferFromProperties(reason: String) async {
|
||||
guard isRemoteSessionReady, let camera = cameraDevice else { return }
|
||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
var properties = await SonyPTPHelper.fetchExtDeviceProperties(
|
||||
@ -423,7 +485,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
/// Probe 缓冲区内对象;若 filename+size 与上次不同则入队下载。
|
||||
@discardableResult
|
||||
private func attemptFetchPendingShot(reason: String) async -> Bool {
|
||||
guard isRemoteSessionReady, let camera = cameraDevice else { return false }
|
||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return false }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
guard let probe = await SonyPTPHelper.probeShotObject(
|
||||
@ -452,11 +514,11 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
}
|
||||
|
||||
private func refreshShotBufferPollInterval() {
|
||||
guard isRemoteSessionReady else { return }
|
||||
guard !isDisconnecting, 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 }
|
||||
guard let self, !self.isDisconnecting, self.isRemoteSessionReady else { return }
|
||||
Task { await self.evaluateShotBufferFromProperties(reason: "D215Poll") }
|
||||
}
|
||||
}
|
||||
@ -468,6 +530,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
|
||||
/// 连拍时多个 PTP 事件会连续到达;入队串行处理,避免「下载进行中直接丢弃」。
|
||||
private func enqueueShotDownload(handle: UInt32, reason: String) {
|
||||
guard !isDisconnecting else { return }
|
||||
shotDownloadQueue.append((handle: handle, reason: reason))
|
||||
CameraTetheringLogger.log(
|
||||
"\(reason): 入队 PTP 下载 (队列=\(shotDownloadQueue.count))",
|
||||
@ -478,11 +541,13 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
}
|
||||
|
||||
private func startShotDownloadDrainIfNeeded() {
|
||||
guard !isDisconnecting else { return }
|
||||
guard !isDrainingShotQueue else { return }
|
||||
guard !shotDownloadQueue.isEmpty else { return }
|
||||
|
||||
isDrainingShotQueue = true
|
||||
Task { [weak self] in
|
||||
shotDownloadDrainTask?.cancel()
|
||||
shotDownloadDrainTask = Task { [weak self] in
|
||||
await self?.drainShotDownloadQueue()
|
||||
}
|
||||
}
|
||||
@ -490,13 +555,16 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
private func drainShotDownloadQueue() async {
|
||||
defer {
|
||||
isDrainingShotQueue = false
|
||||
refreshShotBufferPollInterval()
|
||||
if !shotDownloadQueue.isEmpty {
|
||||
startShotDownloadDrainIfNeeded()
|
||||
shotDownloadDrainTask = nil
|
||||
if !isDisconnecting {
|
||||
refreshShotBufferPollInterval()
|
||||
if !shotDownloadQueue.isEmpty {
|
||||
startShotDownloadDrainIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while !shotDownloadQueue.isEmpty {
|
||||
while !shotDownloadQueue.isEmpty, !isDisconnecting {
|
||||
let item = shotDownloadQueue.removeFirst()
|
||||
_ = await downloadPTPObject(
|
||||
handle: item.handle,
|
||||
@ -507,13 +575,14 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
try? await Task.sleep(nanoseconds: 120_000_000)
|
||||
}
|
||||
|
||||
guard !isDisconnecting else { return }
|
||||
await drainRemainingBuffer(maxExtraDownloads: 12)
|
||||
}
|
||||
|
||||
/// 连拍时部分事件可能在队列排空期间才写入缓冲;按 D215/Probe 继续拉取直到无新片。
|
||||
private func drainRemainingBuffer(maxExtraDownloads: Int) async {
|
||||
for round in 1...maxExtraDownloads {
|
||||
guard isRemoteSessionReady, let camera = cameraDevice else { break }
|
||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { break }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
guard let probe = await SonyPTPHelper.probeShotObject(
|
||||
@ -548,7 +617,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
maxAttempts: Int = 3,
|
||||
tryDirectGetObject: Bool = false
|
||||
) async -> Bool {
|
||||
guard isRemoteSessionReady, let camera = cameraDevice else {
|
||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else {
|
||||
CameraTetheringLogger.log("\(reason): Sony 远程会话尚未就绪", logger: CameraTetheringLogger.ptp)
|
||||
return false
|
||||
}
|
||||
@ -564,6 +633,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
var localTransactionID = transactionID
|
||||
|
||||
for attempt in 1...maxAttempts {
|
||||
guard !isDisconnecting else { return false }
|
||||
if attempt > 1 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000)
|
||||
}
|
||||
@ -676,6 +746,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
}
|
||||
|
||||
extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate {
|
||||
@objc(deviceBrowser:didAddDevice:moreComing:)
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
||||
CameraTetheringLogger.log("发现设备: \(device.name ?? "?"), type=\(device.type), transport=\(device.transportType ?? "?")")
|
||||
|
||||
@ -704,10 +775,12 @@ extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@objc(deviceBrowser:didRemoveDevice:moreGoing:)
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
||||
guard !isDisconnecting else { return }
|
||||
CameraTetheringLogger.log("设备移除: \(device.name ?? "?")")
|
||||
if device.uuidString == cameraDevice?.uuidString {
|
||||
disconnect()
|
||||
Task { await disconnect() }
|
||||
updateState(.disconnected)
|
||||
}
|
||||
}
|
||||
@ -715,9 +788,10 @@ extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate {
|
||||
|
||||
extension ImageCaptureDeviceManager: ICDeviceDelegate {
|
||||
func didRemove(_ device: ICDevice) {
|
||||
guard !isDisconnecting else { return }
|
||||
CameraTetheringLogger.log("didRemove: \(device.name ?? "?")")
|
||||
if device.uuidString == cameraDevice?.uuidString {
|
||||
disconnect()
|
||||
Task { await disconnect() }
|
||||
updateState(.disconnected)
|
||||
}
|
||||
}
|
||||
@ -742,11 +816,17 @@ extension ImageCaptureDeviceManager: ICDeviceDelegate {
|
||||
if let error {
|
||||
CameraTetheringLogger.log("会话关闭: \(error.localizedDescription)")
|
||||
}
|
||||
if device.uuidString == closingDeviceUUID {
|
||||
finishSessionClose()
|
||||
}
|
||||
guard !isDisconnecting else { return }
|
||||
if device.uuidString == cameraDevice?.uuidString {
|
||||
cameraDevice = nil
|
||||
isCatalogReady = false
|
||||
baselineNotified = false
|
||||
isRemoteSessionReady = false
|
||||
stopPolling()
|
||||
stopShotBufferPolling()
|
||||
updateState(.disconnected)
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ final class SonyCameraService: CameraServiceProtocol {
|
||||
private let deviceManager = ImageCaptureDeviceManager()
|
||||
private var knownAssetIDs: Set<String> = []
|
||||
private var baselineEstablished = false
|
||||
private var pendingAssetsWhileDetached: [CameraAsset] = []
|
||||
|
||||
init() {
|
||||
deviceManager.onConnectionStateChange = { [weak self] state in
|
||||
@ -64,11 +65,39 @@ final class SonyCameraService: CameraServiceProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
CameraTetheringLogger.log("SonyCameraService.disconnect()")
|
||||
deviceManager.stopBrowsing()
|
||||
/// 由 AutoDetect 在已完成授权后直接挂载设备。
|
||||
func attach(to camera: ICCameraDevice) async {
|
||||
CameraTetheringLogger.log("SonyCameraService.attach()")
|
||||
knownAssetIDs.removeAll()
|
||||
baselineEstablished = false
|
||||
pendingAssetsWhileDetached.removeAll()
|
||||
deviceManager.attach(to: camera)
|
||||
}
|
||||
|
||||
/// 页面离开:仅清空 UI 回调,底层 PTP 会话与轮询继续。
|
||||
func detachFromUI() {
|
||||
CameraTetheringLogger.log("SonyCameraService.detachFromUI()")
|
||||
onConnectionStateChange = nil
|
||||
onNewAsset = nil
|
||||
}
|
||||
|
||||
/// 再次进入页面:补发 detach 期间缓冲的新照片。
|
||||
func reattachToUI() {
|
||||
guard !pendingAssetsWhileDetached.isEmpty else { return }
|
||||
let pending = pendingAssetsWhileDetached
|
||||
pendingAssetsWhileDetached.removeAll()
|
||||
CameraTetheringLogger.log("SonyCameraService.reattachToUI: 补发 \(pending.count) 张离线新片")
|
||||
for asset in pending {
|
||||
onNewAsset?(asset)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
CameraTetheringLogger.log("SonyCameraService.shutdown()")
|
||||
await deviceManager.stopBrowsing()
|
||||
knownAssetIDs.removeAll()
|
||||
baselineEstablished = false
|
||||
pendingAssetsWhileDetached.removeAll()
|
||||
connectionState = .disconnected
|
||||
onConnectionStateChange?(.disconnected)
|
||||
}
|
||||
@ -100,13 +129,7 @@ final class SonyCameraService: CameraServiceProtocol {
|
||||
}
|
||||
|
||||
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)
|
||||
deliverNewAsset(asset, source: "PTP")
|
||||
}
|
||||
|
||||
/// 连接后首次目录就绪:已有照片记入基线;拍摄于枚举期间的新照片立即传输。
|
||||
@ -153,10 +176,20 @@ final class SonyCameraService: CameraServiceProtocol {
|
||||
|
||||
private func emitAssetIfNew(file: ICCameraFile, source: String) {
|
||||
let asset = deviceManager.makeAsset(from: file)
|
||||
guard !knownAssetIDs.contains(asset.id) else { return }
|
||||
deliverNewAsset(asset, source: source)
|
||||
}
|
||||
|
||||
private func deliverNewAsset(_ asset: CameraAsset, source: String) {
|
||||
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)
|
||||
CameraTetheringLogger.log(
|
||||
"[\(source)] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)",
|
||||
logger: CameraTetheringLogger.transfer
|
||||
)
|
||||
if let onNewAsset {
|
||||
onNewAsset(asset)
|
||||
} else {
|
||||
pendingAssetsWhileDetached.append(asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,26 +1,6 @@
|
||||
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
|
||||
@ -42,7 +22,7 @@ enum SonyPTPCommand {
|
||||
static let stillImageTransSize: UInt16 = 0xD268
|
||||
static let shootingFileInfo: UInt16 = 0xD215
|
||||
static let shotObjectHandle: UInt32 = 0xFFFF_C001
|
||||
static let ptpResponseOk: UInt16 = 0x2001
|
||||
static let ptpResponseOk: UInt16 = PTPResponseCode.ok.rawValue
|
||||
static let saveDestinationHostPC: UInt16 = 1
|
||||
static let saveDestinationHostAndCamera: UInt16 = 2
|
||||
static let shotBufferReadyThreshold: Int64 = 0x8000
|
||||
@ -66,90 +46,35 @@ enum SonyPTPHelper {
|
||||
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
|
||||
PTPHelper.buildCommand(code: code, transactionID: transactionID, parameters: parameters)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
try PTPHelper.parseContainer(data)
|
||||
}
|
||||
|
||||
static func parseEventCode(from eventData: Data) -> UInt16? {
|
||||
guard let container = try? parseContainer(eventData) else { return nil }
|
||||
return container.code
|
||||
PTPHelper.parseEventCode(from: eventData)
|
||||
}
|
||||
|
||||
static func parseObjectCompressedSize(_ data: Data) -> UInt32? {
|
||||
guard data.count >= 12 else { return nil }
|
||||
return readUInt32LE(data, offset: 8)
|
||||
PTPHelper.parseObjectCompressedSize(data)
|
||||
}
|
||||
|
||||
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)
|
||||
PTPHelper.parseObjectInfoFilename(data)
|
||||
}
|
||||
|
||||
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 }
|
||||
PTPHelper.parsePTPString(data, offset: start)
|
||||
}
|
||||
|
||||
var codeUnits: [UInt16] = []
|
||||
codeUnits.reserveCapacity(charCount)
|
||||
for index in 0..<charCount {
|
||||
codeUnits.append(readUInt16LE(data, offset: offset + index * 2))
|
||||
}
|
||||
offset += charCount * 2
|
||||
static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 {
|
||||
PTPHelper.readUInt16LE(data, offset: offset)
|
||||
}
|
||||
|
||||
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
||||
return (string, offset)
|
||||
static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 {
|
||||
PTPHelper.readUInt32LE(data, offset: offset)
|
||||
}
|
||||
|
||||
/// ImageCaptureCore 已建立 PTP 会话,此处执行 Sony SDIO 握手并进入 PC Remote 模式。
|
||||
@ -212,6 +137,30 @@ enum SonyPTPHelper {
|
||||
return transactionID
|
||||
}
|
||||
|
||||
/// 关闭 Sony PC Remote 并释放 PTP 会话。
|
||||
static func teardownRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
|
||||
_ = await setDeviceProperty(
|
||||
on: camera,
|
||||
propertyCode: SonyPTPCommand.positionKeySetting,
|
||||
value: 0,
|
||||
transactionID: &transactionID,
|
||||
label: "Position_Key_Setting=Local"
|
||||
)
|
||||
await closeRemoteSession(on: camera, transactionID: &transactionID)
|
||||
}
|
||||
|
||||
/// 关闭 PTP 远程会话,释放相机 PC Remote 占用。
|
||||
static func closeRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: SonyPTPCommand.closeSession,
|
||||
transactionID: &transactionID,
|
||||
parameters: [],
|
||||
outData: nil,
|
||||
label: "CloseSession"
|
||||
)
|
||||
}
|
||||
|
||||
static func probeShotObject(
|
||||
on camera: ICCameraDevice,
|
||||
handle: UInt32 = SonyPTPCommand.shotObjectHandle,
|
||||
@ -266,22 +215,17 @@ enum SonyPTPHelper {
|
||||
outData: nil,
|
||||
label: "GetObject(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
guard objectResult.success, isLikelyImageData(objectResult.inData) else { return nil }
|
||||
guard objectResult.success, PTPHelper.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
|
||||
PTPHelper.isLikelyImageData(data)
|
||||
}
|
||||
|
||||
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
|
||||
PTPHelper.isResponseOK(response)
|
||||
}
|
||||
|
||||
static func fetchExtDeviceProperties(
|
||||
@ -448,7 +392,7 @@ enum SonyPTPHelper {
|
||||
label: String
|
||||
) async -> Bool {
|
||||
var payload = Data()
|
||||
appendUInt32LE(value, to: &payload)
|
||||
PTPHelper.appendUInt32LE(value, to: &payload)
|
||||
|
||||
return await sendCommand(
|
||||
on: camera,
|
||||
@ -468,7 +412,7 @@ enum SonyPTPHelper {
|
||||
label: String
|
||||
) async -> Bool {
|
||||
var payload = Data()
|
||||
appendUInt16LE(value, to: &payload)
|
||||
PTPHelper.appendUInt16LE(value, to: &payload)
|
||||
|
||||
return await sendCommand(
|
||||
on: camera,
|
||||
@ -480,12 +424,6 @@ enum SonyPTPHelper {
|
||||
).success
|
||||
}
|
||||
|
||||
private struct PTPCommandResult {
|
||||
let success: Bool
|
||||
let response: Data
|
||||
let inData: Data
|
||||
}
|
||||
|
||||
private static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
@ -494,78 +432,13 @@ enum SonyPTPHelper {
|
||||
outData: Data?,
|
||||
label: String
|
||||
) async -> PTPCommandResult {
|
||||
let currentID = transactionID
|
||||
transactionID += 1
|
||||
|
||||
let commandData = buildCommand(
|
||||
await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: code,
|
||||
transactionID: currentID,
|
||||
parameters: parameters
|
||||
transactionID: &transactionID,
|
||||
parameters: parameters,
|
||||
outData: outData,
|
||||
label: label
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user