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:
@ -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)
|
||||
}
|
||||
}
|
||||
162
suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift
Normal file
162
suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
571
suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift
Normal file
571
suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user