完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。
引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
39
suixinkan/Core/CameraTethering/Canon/Canon.md
Normal file
39
suixinkan/Core/CameraTethering/Canon/Canon.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Canon CameraTethering
|
||||
|
||||
## 职责
|
||||
|
||||
提供 Canon EOS 相机 USB OTG 有线连接与边拍边传(PC Remote)能力。
|
||||
|
||||
## 核心组件
|
||||
|
||||
| 组件 | 说明 |
|
||||
| --- | --- |
|
||||
| `CanonCameraService` | 实现 `CameraServiceProtocol`,管理基线与去重 |
|
||||
| `CanonImageCaptureDeviceManager` | ImageCaptureCore 会话、GetEvent 轮询、PTP/Catalog 下载 |
|
||||
| `CanonPTPCommands` | Canon EOS PTP 厂商扩展命令 |
|
||||
| `CanonEosEventParser` | GetEvent blob 解析(ObjectAddedEx / ObjectAddedEx2) |
|
||||
|
||||
## PTP 会话时序
|
||||
|
||||
1. ImageCaptureCore 打开 ICC 会话(不发 PTP OpenSession)。
|
||||
2. `SetRemoteMode` (0x9114) — 进入 PC 遥控模式。
|
||||
3. `SetEventMode` (0x9115) — 订阅扩展事件。
|
||||
4. `KeepDeviceOn` (0x911D) — 重置自动关机(部分机身不支持,非致命)。
|
||||
5. 每 500ms 轮询 `GetEvent` (0x9116)。
|
||||
6. 收到 `ObjectAddedEx` / `ObjectAddedEx2` 后通过 `EOS_GetObject` (0x9104) 下载。
|
||||
7. 发送 `TransferComplete` (0x9117) 释放相机缓冲。
|
||||
|
||||
## Catalog 回退
|
||||
|
||||
若 PTP 实时路径不可用,仍可通过 `deviceDidBecomeReady` + `didAddItems` + 1.5s 轮询检测 catalog 新文件,并使用 `requestDownloadFile` 下载。
|
||||
|
||||
## 相机端设置
|
||||
|
||||
- USB 模式设为 **电脑遥控 / PC Remote / 照片传输(PTP)**
|
||||
- 关闭「仅充电」模式
|
||||
- 边拍边传时勿调用 `SetUILock` (0x911B)
|
||||
|
||||
## 依赖
|
||||
|
||||
- ImageCaptureCore(系统框架)
|
||||
- 共享 `PTPHelper` / `CameraDownloadStorage`
|
||||
181
suixinkan/Core/CameraTethering/Canon/CanonCameraService.swift
Normal file
181
suixinkan/Core/CameraTethering/Canon/CanonCameraService.swift
Normal file
@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// Canon EOS 相机有线连接服务,实现边拍边传与目录回退下载。
|
||||
@MainActor
|
||||
final class CanonCameraService: CameraServiceProtocol {
|
||||
let brand: CameraBrand = .canon
|
||||
|
||||
private(set) var connectionState: CameraConnectionState = .disconnected
|
||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
||||
var onNewAsset: ((CameraAsset) -> Void)?
|
||||
|
||||
private let deviceManager = CanonImageCaptureDeviceManager()
|
||||
private var knownAssetIDs: Set<String> = []
|
||||
private var baselineEstablished = false
|
||||
private var pendingAssetsWhileDetached: [CameraAsset] = []
|
||||
|
||||
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("Canon 相机错误: \(message)")
|
||||
self?.connectionState = .error(message)
|
||||
self?.onConnectionStateChange?(.error(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connect() async {
|
||||
CameraTetheringLogger.log("CanonCameraService.connect()")
|
||||
let shouldResetBaseline = !deviceManager.isBrowsing
|
||||
deviceManager.startBrowsing()
|
||||
if shouldResetBaseline {
|
||||
knownAssetIDs.removeAll()
|
||||
baselineEstablished = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 由 AutoDetect 在已完成授权后直接挂载设备。
|
||||
func attach(to camera: ICCameraDevice) async {
|
||||
CameraTetheringLogger.log("CanonCameraService.attach()")
|
||||
knownAssetIDs.removeAll()
|
||||
baselineEstablished = false
|
||||
pendingAssetsWhileDetached.removeAll()
|
||||
deviceManager.attach(to: camera)
|
||||
}
|
||||
|
||||
/// 页面离开:仅清空 UI 回调,底层 PTP 会话与轮询继续。
|
||||
func detachFromUI() {
|
||||
CameraTetheringLogger.log("CanonCameraService.detachFromUI()")
|
||||
onConnectionStateChange = nil
|
||||
onNewAsset = nil
|
||||
}
|
||||
|
||||
/// 再次进入页面:补发 detach 期间缓冲的新照片。
|
||||
func reattachToUI() {
|
||||
guard !pendingAssetsWhileDetached.isEmpty else { return }
|
||||
let pending = pendingAssetsWhileDetached
|
||||
pendingAssetsWhileDetached.removeAll()
|
||||
CameraTetheringLogger.log("CanonCameraService.reattachToUI: 补发 \(pending.count) 张离线新片")
|
||||
for asset in pending {
|
||||
onNewAsset?(asset)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
CameraTetheringLogger.log("CanonCameraService.shutdown()")
|
||||
await deviceManager.stopBrowsing()
|
||||
knownAssetIDs.removeAll()
|
||||
baselineEstablished = false
|
||||
pendingAssetsWhileDetached.removeAll()
|
||||
connectionState = .disconnected
|
||||
onConnectionStateChange?(.disconnected)
|
||||
}
|
||||
|
||||
func listAssets() async throws -> [CameraAsset] {
|
||||
guard connectionState.isConnected else {
|
||||
throw CameraServiceError.notConnected
|
||||
}
|
||||
return deviceManager.allFiles().map { deviceManager.makeAsset(from: $0) }
|
||||
}
|
||||
|
||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
||||
guard connectionState.isConnected else {
|
||||
throw CameraServiceError.notConnected
|
||||
}
|
||||
if let cachedURL = deviceManager.localURL(forAssetID: asset.id) {
|
||||
return cachedURL
|
||||
}
|
||||
guard let file = deviceManager.file(forAssetID: asset.id) else {
|
||||
throw CameraServiceError.assetNotFound
|
||||
}
|
||||
let directory = CameraDownloadStorage.temporaryDownloadsDirectory
|
||||
return try await deviceManager.download(file: file, to: directory)
|
||||
}
|
||||
|
||||
private func handlePTPAsset(_ asset: CameraAsset) {
|
||||
deliverNewAsset(asset, source: "Canon PTP")
|
||||
}
|
||||
|
||||
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("Canon 基线已建立: 跳过已有 \(skipped) 张, 立即传输新拍 \(newCount) 张")
|
||||
}
|
||||
|
||||
private func handleNewItems(_ items: [ICCameraItem], source: String) {
|
||||
guard baselineEstablished else { 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 }
|
||||
for file in deviceManager.allFiles() {
|
||||
emitAssetIfNew(file: file, source: source)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitAssetIfNew(file: ICCameraFile, source: String) {
|
||||
let asset = deviceManager.makeAsset(from: file)
|
||||
deliverNewAsset(asset, source: source)
|
||||
}
|
||||
|
||||
private func deliverNewAsset(_ asset: CameraAsset, source: String) {
|
||||
guard !knownAssetIDs.contains(asset.id) else { return }
|
||||
knownAssetIDs.insert(asset.id)
|
||||
CameraTetheringLogger.log("[Canon \(source)] 发现新照片: \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
||||
if let onNewAsset {
|
||||
onNewAsset(asset)
|
||||
} else {
|
||||
pendingAssetsWhileDetached.append(asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
188
suixinkan/Core/CameraTethering/Canon/CanonEosEventParser.swift
Normal file
188
suixinkan/Core/CameraTethering/Canon/CanonEosEventParser.swift
Normal file
@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
|
||||
/// Canon EOS GetEvent 返回的事件 blob 解析器。
|
||||
enum CanonEosEventParser {
|
||||
private static let recordHeaderSize = 8
|
||||
private static let objectAddedExMinPayload = 33
|
||||
private static let objectAddedEx2MinPayload = 41
|
||||
|
||||
/// 解析后的 Canon 事件。
|
||||
enum ParsedEvent {
|
||||
case objectAdded(ObjectAdded)
|
||||
case requestObjectTransfer(handle: UInt32)
|
||||
case cameraStatus(status: Int32)
|
||||
}
|
||||
|
||||
/// ObjectAddedEx / ObjectAddedEx2 事件载荷。
|
||||
struct ObjectAdded {
|
||||
let objectHandle: UInt32
|
||||
let storageId: UInt32
|
||||
let formatCode: UInt16
|
||||
let parentHandle: UInt32
|
||||
let size: Int64
|
||||
let filename: String
|
||||
}
|
||||
|
||||
/// 解析 GetEvent 返回的二进制 blob。
|
||||
static func parse(_ blob: Data) -> [ParsedEvent] {
|
||||
guard !blob.isEmpty else { return [] }
|
||||
|
||||
var events: [ParsedEvent] = []
|
||||
var offset = 0
|
||||
|
||||
while offset + recordHeaderSize <= blob.count {
|
||||
let recordStart = offset
|
||||
let recordSizeRaw = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
|
||||
offset += 4
|
||||
if recordSizeRaw == 0 { break }
|
||||
if recordSizeRaw < Int64(recordHeaderSize) { break }
|
||||
|
||||
let recordSize = Int(recordSizeRaw)
|
||||
let type = Int(PTPHelper.readUInt32LE(blob, offset: offset) & 0xFFFF)
|
||||
offset += 4
|
||||
let payloadSize = recordSize - recordHeaderSize
|
||||
|
||||
if type == 0, payloadSize == 0 { break }
|
||||
if offset + payloadSize > blob.count { break }
|
||||
|
||||
let payloadEnd = offset + payloadSize
|
||||
|
||||
switch UInt16(type) {
|
||||
case CanonPTPCommand.eosObjectAddedEx:
|
||||
if let added = parseObjectAdded(
|
||||
blob,
|
||||
offset: offset,
|
||||
payloadSize: payloadSize,
|
||||
filenameOffset: 32,
|
||||
minPayload: objectAddedExMinPayload
|
||||
) {
|
||||
events.append(.objectAdded(added))
|
||||
}
|
||||
case CanonPTPCommand.eosObjectAddedEx2:
|
||||
if let added = parseObjectAdded(
|
||||
blob,
|
||||
offset: offset,
|
||||
payloadSize: payloadSize,
|
||||
filenameOffset: 40,
|
||||
minPayload: objectAddedEx2MinPayload
|
||||
) {
|
||||
events.append(.objectAdded(added))
|
||||
}
|
||||
case CanonPTPCommand.eosRequestObjectTransfer:
|
||||
let handle: UInt32 = payloadSize >= 4 ? PTPHelper.readUInt32LE(blob, offset: offset) : 0
|
||||
events.append(.requestObjectTransfer(handle: handle))
|
||||
case CanonPTPCommand.eosCameraStatusChanged:
|
||||
let status: Int32 = payloadSize >= 4
|
||||
? Int32(bitPattern: PTPHelper.readUInt32LE(blob, offset: offset))
|
||||
: 0
|
||||
events.append(.cameraStatus(status: status))
|
||||
case CanonPTPCommand.eosObjectInfoChangedEx,
|
||||
CanonPTPCommand.eosStorageInfoChanged:
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
offset = payloadEnd
|
||||
if offset == recordStart { break }
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/// 将 ObjectAdded 事件转为 CameraAsset。
|
||||
static func makeAsset(from event: ObjectAdded) -> CameraAsset {
|
||||
let filename = event.filename.isEmpty
|
||||
? "IMG_\(event.objectHandle).JPG"
|
||||
: CameraDownloadStorage.sanitizeFilename(event.filename)
|
||||
let isMovie = isVideoFormat(event.formatCode) || isVideoExtension(filename)
|
||||
return CameraAsset(
|
||||
id: CanonPTPHelper.assetID(handle: event.objectHandle, filename: filename, size: event.size),
|
||||
filename: filename,
|
||||
fileSize: event.size,
|
||||
creationDate: Date(),
|
||||
isMovie: isMovie
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseObjectAdded(
|
||||
_ blob: Data,
|
||||
offset payloadStart: Int,
|
||||
payloadSize: Int,
|
||||
filenameOffset: Int,
|
||||
minPayload: Int
|
||||
) -> ObjectAdded? {
|
||||
guard payloadSize >= minPayload else { return nil }
|
||||
|
||||
var offset = payloadStart
|
||||
let objectHandle = PTPHelper.readUInt32LE(blob, offset: offset)
|
||||
offset += 4
|
||||
let storageId = PTPHelper.readUInt32LE(blob, offset: offset)
|
||||
offset += 4
|
||||
let ofc = UInt16(PTPHelper.readUInt32LE(blob, offset: offset) & 0xFFFF)
|
||||
offset += 4
|
||||
|
||||
let size: Int64
|
||||
let parentHandle: UInt32
|
||||
|
||||
if filenameOffset == 32 {
|
||||
offset += 4 // flags
|
||||
parentHandle = PTPHelper.readUInt32LE(blob, offset: offset)
|
||||
offset += 4
|
||||
offset += 4 // reserved
|
||||
size = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
|
||||
offset += 4
|
||||
offset += 4 // reserved
|
||||
} else {
|
||||
offset += 4 // reserved
|
||||
parentHandle = PTPHelper.readUInt32LE(blob, offset: offset)
|
||||
offset += 4
|
||||
let sizeLow = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
|
||||
offset += 4
|
||||
let sizeHigh = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
|
||||
offset += 4
|
||||
size = sizeLow | (sizeHigh << 32)
|
||||
offset += 4 // reserved
|
||||
offset += 4 // relatedHandle
|
||||
}
|
||||
|
||||
let expected = payloadStart + filenameOffset
|
||||
if offset != expected { offset = expected }
|
||||
|
||||
let filenameBytesAvailable = payloadSize - filenameOffset
|
||||
guard filenameBytesAvailable > 0, offset + filenameBytesAvailable <= blob.count else { return nil }
|
||||
|
||||
let filenameData = blob.subdata(in: offset..<(offset + filenameBytesAvailable))
|
||||
let filename = extractCString(filenameData)
|
||||
|
||||
return ObjectAdded(
|
||||
objectHandle: objectHandle,
|
||||
storageId: storageId,
|
||||
formatCode: ofc,
|
||||
parentHandle: parentHandle,
|
||||
size: size,
|
||||
filename: filename
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractCString(_ bytes: Data) -> String {
|
||||
if let nulIndex = bytes.firstIndex(of: 0) {
|
||||
return String(bytes: bytes.prefix(upTo: nulIndex), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
}
|
||||
return String(bytes: bytes, encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
}
|
||||
|
||||
private static func isVideoFormat(_ ofc: UInt16) -> Bool {
|
||||
switch ofc {
|
||||
case 0x300D, 0x3000, 0xB982, 0xB983, 0x300F:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func isVideoExtension(_ filename: String) -> Bool {
|
||||
let lower = filename.lowercased()
|
||||
return [".mov", ".mp4", ".m4v", ".avi", ".mpg", ".mpeg"].contains { lower.hasSuffix($0) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,597 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// Canon 相机 ImageCaptureCore 设备管理:会话、GetEvent 轮询与 PTP/Catalog 双路径下载。
|
||||
final class CanonImageCaptureDeviceManager: 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 isExternalAttach = false
|
||||
private var eventPollTask: Task<Void, Never>?
|
||||
private var keepDeviceOnSupported = false
|
||||
private var eventPollTick = 0
|
||||
private var seenHandles: Set<UInt32> = []
|
||||
private var isDownloadingPTPObject = 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 isDisconnecting = false
|
||||
|
||||
var isBrowsing: Bool { deviceBrowser != nil || isExternalAttach }
|
||||
|
||||
/// 跳过 Browser,直接连接 AutoDetect 已发现并授权的设备。
|
||||
func attach(to camera: ICCameraDevice) {
|
||||
guard cameraDevice == nil else {
|
||||
CameraTetheringLogger.log("Canon attach: 已有连接中的相机,忽略")
|
||||
return
|
||||
}
|
||||
isExternalAttach = true
|
||||
CameraTetheringLogger.log("Canon attach: \(camera.name ?? "Unknown")")
|
||||
openSession(for: camera)
|
||||
}
|
||||
|
||||
func startBrowsing() {
|
||||
guard deviceBrowser == nil else {
|
||||
CameraTetheringLogger.log("Canon: 已在监听 USB 相机")
|
||||
if cameraDevice == nil {
|
||||
updateState(.searching)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
CameraTetheringLogger.log("Canon: 开始搜索 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
|
||||
}
|
||||
deviceBrowser = browser
|
||||
browser.start()
|
||||
}
|
||||
|
||||
func stopBrowsing() async {
|
||||
CameraTetheringLogger.log("Canon: 停止搜索并断开")
|
||||
stopEventPolling()
|
||||
stopPolling()
|
||||
await disconnect()
|
||||
if !isExternalAttach {
|
||||
deviceBrowser?.stop()
|
||||
}
|
||||
deviceBrowser = nil
|
||||
isExternalAttach = false
|
||||
isCatalogReady = false
|
||||
baselineNotified = false
|
||||
updateState(.disconnected)
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
guard !isDisconnecting else { return }
|
||||
isDisconnecting = true
|
||||
defer { isDisconnecting = false }
|
||||
|
||||
sessionSetupTask?.cancel()
|
||||
sessionSetupTask = nil
|
||||
|
||||
let shouldClosePTP = isRemoteSessionReady
|
||||
isRemoteSessionReady = false
|
||||
isDownloadingPTPObject = false
|
||||
|
||||
stopEventPolling()
|
||||
stopPolling()
|
||||
cancelPendingDownloads()
|
||||
|
||||
guard let camera = cameraDevice else {
|
||||
resetDisconnectLocalState()
|
||||
return
|
||||
}
|
||||
|
||||
let deviceUUID = camera.uuidString ?? ""
|
||||
camera.ptpEventHandler = { _ in }
|
||||
|
||||
if shouldClosePTP {
|
||||
var localTransactionID = transactionID
|
||||
await CanonPTPHelper.closeRemoteSession(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("Canon 会话关闭超时,继续 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
|
||||
baselineNotified = false
|
||||
isRemoteSessionReady = false
|
||||
keepDeviceOnSupported = false
|
||||
eventPollTick = 0
|
||||
seenHandles.removeAll()
|
||||
ptpAssetURLs.removeAll()
|
||||
isExternalAttach = 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("Canon 开始下载 \(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("Canon 连接状态: \(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
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
browser.requestControlAuthorization { status in
|
||||
controlGranted = status == .authorized
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
completion(contentsGranted, controlGranted)
|
||||
}
|
||||
}
|
||||
|
||||
private func openSession(for camera: ICCameraDevice) {
|
||||
updateState(.connecting)
|
||||
camera.delegate = self
|
||||
cameraDevice = camera
|
||||
CameraTetheringLogger.log("Canon 打开相机会务: \(camera.name ?? "Unknown")")
|
||||
camera.requestOpenSession(options: nil) { [weak self] error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
CameraTetheringLogger.log("Canon 会话打开失败: \(error.localizedDescription)")
|
||||
self.onError?(error.localizedDescription)
|
||||
self.updateState(.error(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
let name = camera.name ?? "Canon Camera"
|
||||
CameraTetheringLogger.log("Canon 会话已打开: \(name)")
|
||||
self.updateState(.connected(deviceName: name))
|
||||
self.configureCameraAfterSession(camera)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureCameraAfterSession(_ camera: ICCameraDevice) {
|
||||
let capabilities = camera.capabilities.joined(separator: ", ")
|
||||
CameraTetheringLogger.log("Canon 相机能力: [\(capabilities)]")
|
||||
|
||||
camera.ptpEventHandler = { [weak self] eventData in
|
||||
self?.handlePTPEvent(eventData)
|
||||
}
|
||||
|
||||
sessionSetupTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let session = await CanonPTPHelper.initializeRemoteSession(
|
||||
on: camera,
|
||||
startingTransactionID: transactionID
|
||||
)
|
||||
guard !Task.isCancelled, !self.isDisconnecting else { return }
|
||||
transactionID = session.transactionID
|
||||
keepDeviceOnSupported = session.keepDeviceOnSupported
|
||||
isRemoteSessionReady = true
|
||||
startEventPolling()
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePTPEvent(_ eventData: Data) {
|
||||
guard !isDisconnecting else { return }
|
||||
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
CameraTetheringLogger.log("Canon 收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp)
|
||||
}
|
||||
|
||||
private func startEventPolling() {
|
||||
stopEventPolling()
|
||||
CameraTetheringLogger.log("Canon: 启动 GetEvent 轮询 (每 \(CanonPTPCommand.getEventInterval)s)", logger: CameraTetheringLogger.ptp)
|
||||
|
||||
eventPollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self, !self.isDisconnecting else { return }
|
||||
await self.pollEventsOnce()
|
||||
try? await Task.sleep(nanoseconds: UInt64(CanonPTPCommand.getEventInterval * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopEventPolling() {
|
||||
eventPollTask?.cancel()
|
||||
eventPollTask = nil
|
||||
}
|
||||
|
||||
private func pollEventsOnce() async {
|
||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return }
|
||||
|
||||
eventPollTick += 1
|
||||
if keepDeviceOnSupported, eventPollTick % CanonPTPCommand.keepOnIntervalTicks == 0 {
|
||||
var localID = transactionID
|
||||
_ = await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosKeepDeviceOn,
|
||||
transactionID: &localID,
|
||||
parameters: [],
|
||||
outData: nil,
|
||||
label: "KeepDeviceOn(poll)"
|
||||
)
|
||||
transactionID = localID
|
||||
}
|
||||
|
||||
var localID = transactionID
|
||||
let blob = await CanonPTPHelper.pollEvents(on: camera, transactionID: &localID)
|
||||
transactionID = localID
|
||||
|
||||
guard !blob.isEmpty else { return }
|
||||
|
||||
let parsed = CanonEosEventParser.parse(blob)
|
||||
let addedCount = parsed.filter {
|
||||
if case .objectAdded = $0 { return true }
|
||||
return false
|
||||
}.count
|
||||
|
||||
if addedCount > 0 {
|
||||
CameraTetheringLogger.log("Canon GetEvent: \(parsed.count) 事件, 新增 \(addedCount)", logger: CameraTetheringLogger.ptp)
|
||||
}
|
||||
|
||||
for event in parsed {
|
||||
switch event {
|
||||
case .objectAdded(let added):
|
||||
await handleObjectAdded(added)
|
||||
case .requestObjectTransfer(let handle):
|
||||
CameraTetheringLogger.log("Canon RequestObjectTransfer handle=\(handle)", logger: CameraTetheringLogger.ptp)
|
||||
case .cameraStatus:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleObjectAdded(_ added: CanonEosEventParser.ObjectAdded) async {
|
||||
guard !isDisconnecting else { return }
|
||||
guard seenHandles.insert(added.objectHandle).inserted else { return }
|
||||
|
||||
let asset = CanonEosEventParser.makeAsset(from: added)
|
||||
guard CanonPTPHelper.isSupportedFilename(asset.filename) else {
|
||||
CameraTetheringLogger.log("Canon: 跳过不支持的文件 \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
||||
return
|
||||
}
|
||||
|
||||
guard !isDownloadingPTPObject else {
|
||||
CameraTetheringLogger.log("Canon: 下载进行中,稍后重试 \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
||||
seenHandles.remove(added.objectHandle)
|
||||
return
|
||||
}
|
||||
|
||||
isDownloadingPTPObject = true
|
||||
defer { isDownloadingPTPObject = false }
|
||||
|
||||
guard let camera = cameraDevice else { return }
|
||||
|
||||
var localID = transactionID
|
||||
guard let data = await CanonPTPHelper.downloadObject(
|
||||
handle: added.objectHandle,
|
||||
on: camera,
|
||||
transactionID: &localID
|
||||
) else {
|
||||
seenHandles.remove(added.objectHandle)
|
||||
CameraTetheringLogger.log("Canon: PTP 下载失败 \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
||||
return
|
||||
}
|
||||
transactionID = localID
|
||||
|
||||
await CanonPTPHelper.transferComplete(
|
||||
handle: added.objectHandle,
|
||||
on: camera,
|
||||
transactionID: &localID
|
||||
)
|
||||
transactionID = localID
|
||||
|
||||
let destination = CameraDownloadStorage.uniqueTemporaryURL(for: asset.filename)
|
||||
do {
|
||||
try data.write(to: destination, options: .atomic)
|
||||
} catch {
|
||||
seenHandles.remove(added.objectHandle)
|
||||
CameraTetheringLogger.log("Canon: 文件写入失败 \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
|
||||
return
|
||||
}
|
||||
|
||||
ptpAssetURLs[asset.id] = destination
|
||||
CameraTetheringLogger.log(
|
||||
"Canon PTP 下载完成: \(asset.filename) -> \(destination.path)",
|
||||
logger: CameraTetheringLogger.transfer
|
||||
)
|
||||
onPTPAssetReady?(asset, destination)
|
||||
}
|
||||
|
||||
private func notifyBaselineIfNeeded() {
|
||||
guard !baselineNotified else { return }
|
||||
baselineNotified = true
|
||||
isCatalogReady = true
|
||||
|
||||
let fileCount = allFiles().count
|
||||
CameraTetheringLogger.log("Canon 目录就绪,当前 \(fileCount) 个文件,建立基线")
|
||||
onBaselineCatalogReady?()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
stopPolling()
|
||||
CameraTetheringLogger.log("Canon: 启动轮询检测新照片 (每 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 CanonImageCaptureDeviceManager: ICDeviceBrowserDelegate {
|
||||
@objc(deviceBrowser:didAddDevice:moreComing:)
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
||||
CameraTetheringLogger.log("Canon 发现设备: \(device.name ?? "?")")
|
||||
|
||||
guard let camera = device as? ICCameraDevice else { return }
|
||||
guard cameraDevice == nil else { return }
|
||||
|
||||
requestAuthorizations { [weak self] contentsGranted, controlGranted in
|
||||
guard let self else { return }
|
||||
guard contentsGranted else {
|
||||
let message = "未获得相机内容访问权限,请在系统设置中允许"
|
||||
self.onError?(message)
|
||||
self.updateState(.error("权限被拒绝"))
|
||||
return
|
||||
}
|
||||
if !controlGranted {
|
||||
CameraTetheringLogger.log("Canon: 控制授权未通过,仍尝试连接")
|
||||
}
|
||||
self.openSession(for: camera)
|
||||
}
|
||||
}
|
||||
|
||||
@objc(deviceBrowser:didRemoveDevice:moreGoing:)
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
||||
guard !isDisconnecting else { return }
|
||||
if device.uuidString == cameraDevice?.uuidString {
|
||||
Task { await disconnect() }
|
||||
updateState(.disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CanonImageCaptureDeviceManager: ICDeviceDelegate {
|
||||
func didRemove(_ device: ICDevice) {
|
||||
guard !isDisconnecting else { return }
|
||||
if device.uuidString == cameraDevice?.uuidString {
|
||||
Task { await disconnect() }
|
||||
updateState(.disconnected)
|
||||
}
|
||||
}
|
||||
|
||||
func device(_ device: ICDevice, didOpenSessionWithError error: Error?) {
|
||||
if let error {
|
||||
CameraTetheringLogger.log("Canon delegate didOpenSession 失败: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func device(_ device: ICDevice, didCloseSessionWithError error: Error?) {
|
||||
if device.uuidString == closingDeviceUUID {
|
||||
finishSessionClose()
|
||||
}
|
||||
guard !isDisconnecting else { return }
|
||||
if device.uuidString == cameraDevice?.uuidString {
|
||||
cameraDevice = nil
|
||||
isCatalogReady = false
|
||||
baselineNotified = false
|
||||
isRemoteSessionReady = false
|
||||
stopPolling()
|
||||
stopEventPolling()
|
||||
updateState(.disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CanonImageCaptureDeviceManager: ICCameraDeviceDelegate {
|
||||
@objc(cameraDevice:didAddItems:)
|
||||
func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) {
|
||||
let names = items.compactMap { ($0 as? ICCameraFile)?.name ?? $0.name }
|
||||
CameraTetheringLogger.log("Canon didAddItems: \(names.joined(separator: ", "))")
|
||||
onNewItems?(items)
|
||||
}
|
||||
|
||||
@objc(cameraDevice:didRemoveItems:)
|
||||
func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) {
|
||||
}
|
||||
|
||||
@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("Canon deviceDidBecomeReady, 文件数=\(allFiles().count)")
|
||||
notifyBaselineIfNeeded()
|
||||
}
|
||||
|
||||
func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) {
|
||||
}
|
||||
|
||||
func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) {
|
||||
}
|
||||
|
||||
func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) {
|
||||
onError?("设备已锁定,解锁后才能读取媒体文件")
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent eventData: Data) {
|
||||
handlePTPEvent(eventData)
|
||||
}
|
||||
}
|
||||
177
suixinkan/Core/CameraTethering/Canon/CanonPTPCommands.swift
Normal file
177
suixinkan/Core/CameraTethering/Canon/CanonPTPCommands.swift
Normal file
@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// Canon EOS PTP 厂商扩展命令码。
|
||||
enum CanonPTPCommand {
|
||||
static let eosGetObject: UInt16 = 0x9104
|
||||
static let eosGetStorageIDs: UInt16 = 0x9101
|
||||
static let eosGetObjectInfoEx: UInt16 = 0x9109
|
||||
static let eosGetPartialObject: UInt16 = 0x9107
|
||||
static let eosGetThumbEx: UInt16 = 0x910A
|
||||
static let eosSetRemoteMode: UInt16 = 0x9114
|
||||
static let eosSetEventMode: UInt16 = 0x9115
|
||||
static let eosGetEvent: UInt16 = 0x9116
|
||||
static let eosTransferComplete: UInt16 = 0x9117
|
||||
static let eosCancelTransfer: UInt16 = 0x9118
|
||||
static let eosKeepDeviceOn: UInt16 = 0x911D
|
||||
|
||||
static let eosObjectAddedEx: UInt16 = 0xC181
|
||||
static let eosObjectRemoved: UInt16 = 0xC182
|
||||
static let eosRequestGetObjectInfoEx: UInt16 = 0xC183
|
||||
static let eosStorageStatusChanged: UInt16 = 0xC184
|
||||
static let eosStorageInfoChanged: UInt16 = 0xC185
|
||||
static let eosObjectInfoChangedEx: UInt16 = 0xC189
|
||||
static let eosCameraStatusChanged: UInt16 = 0xC18A
|
||||
static let eosRequestObjectTransfer: UInt16 = 0xC18B
|
||||
static let eosObjectAddedEx2: UInt16 = 0xC1A7
|
||||
|
||||
static let getObjectInfo: UInt16 = 0x1008
|
||||
static let getObject: UInt16 = 0x1009
|
||||
static let getThumb: UInt16 = 0x100A
|
||||
static let closeSession: UInt16 = 0x1003
|
||||
|
||||
static let getEventInterval: TimeInterval = 0.5
|
||||
static let keepOnIntervalTicks: Int = 60
|
||||
|
||||
static let supportedExtensions: Set<String> = [
|
||||
"jpg", "jpeg", "png", "cr2", "cr3", "mp4", "mov"
|
||||
]
|
||||
}
|
||||
|
||||
/// Canon EOS PTP 命令封装与会话初始化。
|
||||
enum CanonPTPHelper {
|
||||
/// ImageCaptureCore 已打开会话后,进入 Canon PC Remote 模式并启用事件推送。
|
||||
static func initializeRemoteSession(on camera: ICCameraDevice, startingTransactionID: UInt32) async -> (transactionID: UInt32, keepDeviceOnSupported: Bool) {
|
||||
var transactionID = startingTransactionID
|
||||
|
||||
guard camera.capabilities.contains("ICCameraDeviceCanAcceptPTPCommands") else {
|
||||
CameraTetheringLogger.log("Canon: 相机不支持 PTP 指令", logger: CameraTetheringLogger.ptp)
|
||||
return (transactionID, false)
|
||||
}
|
||||
|
||||
CameraTetheringLogger.log("开始 Canon EOS 远程会话初始化", logger: CameraTetheringLogger.ptp)
|
||||
|
||||
let remoteMode = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosSetRemoteMode,
|
||||
transactionID: &transactionID,
|
||||
parameters: [1],
|
||||
outData: nil,
|
||||
label: "SetRemoteMode"
|
||||
)
|
||||
CameraTetheringLogger.log("SetRemoteMode: \(remoteMode.success ? "OK" : "failed")", logger: CameraTetheringLogger.ptp)
|
||||
|
||||
let eventMode = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosSetEventMode,
|
||||
transactionID: &transactionID,
|
||||
parameters: [1],
|
||||
outData: nil,
|
||||
label: "SetEventMode"
|
||||
)
|
||||
CameraTetheringLogger.log("SetEventMode: \(eventMode.success ? "OK" : "failed")", logger: CameraTetheringLogger.ptp)
|
||||
|
||||
let keepOn = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosKeepDeviceOn,
|
||||
transactionID: &transactionID,
|
||||
parameters: [],
|
||||
outData: nil,
|
||||
label: "KeepDeviceOn"
|
||||
)
|
||||
let keepOnSupported = keepOn.success
|
||||
CameraTetheringLogger.log("KeepDeviceOn: \(keepOnSupported ? "OK" : "skipped")", logger: CameraTetheringLogger.ptp)
|
||||
|
||||
CameraTetheringLogger.log("Canon EOS 远程会话初始化完成", logger: CameraTetheringLogger.ptp)
|
||||
return (transactionID, keepOnSupported)
|
||||
}
|
||||
|
||||
/// 关闭 PTP 远程会话,释放相机 PC Remote 占用。
|
||||
static func closeRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.closeSession,
|
||||
transactionID: &transactionID,
|
||||
parameters: [],
|
||||
outData: nil,
|
||||
label: "CloseSession"
|
||||
)
|
||||
}
|
||||
|
||||
/// 轮询 Canon 事件队列。
|
||||
static func pollEvents(
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> Data {
|
||||
let result = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosGetEvent,
|
||||
transactionID: &transactionID,
|
||||
parameters: [],
|
||||
outData: nil,
|
||||
label: "GetEvent"
|
||||
)
|
||||
return result.success ? result.inData : Data()
|
||||
}
|
||||
|
||||
/// 通过 Canon 扩展 GetObject 下载文件。
|
||||
static func downloadObject(
|
||||
handle: UInt32,
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> Data? {
|
||||
let result = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosGetObject,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
outData: nil,
|
||||
label: "EOS_GetObject(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
guard result.success, !result.inData.isEmpty else { return nil }
|
||||
return result.inData
|
||||
}
|
||||
|
||||
/// 通知相机传输完成,释放缓冲。
|
||||
static func transferComplete(
|
||||
handle: UInt32,
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async {
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosTransferComplete,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
outData: nil,
|
||||
label: "TransferComplete"
|
||||
)
|
||||
}
|
||||
|
||||
static func isSupportedFilename(_ filename: String) -> Bool {
|
||||
let ext = (filename as NSString).pathExtension.lowercased()
|
||||
return CanonPTPCommand.supportedExtensions.contains(ext)
|
||||
}
|
||||
|
||||
static func assetID(handle: UInt32, filename: String, size: Int64) -> String {
|
||||
"canon_\(handle)_\(filename)_\(size)"
|
||||
}
|
||||
|
||||
private static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
parameters: [UInt32],
|
||||
outData: Data?,
|
||||
label: String
|
||||
) async -> PTPCommandResult {
|
||||
await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: code,
|
||||
transactionID: &transactionID,
|
||||
parameters: parameters,
|
||||
outData: outData,
|
||||
label: label
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user