feat: add travel album OTG import flow
This commit is contained in:
@ -0,0 +1,59 @@
|
||||
//
|
||||
// CanonCameraDriver.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 佳能相机 Driver:ImageCaptureCore 枚举 SD 卡历史照片并下载。
|
||||
/// 边拍边传由 `CanonRemoteCaptureService` + EOS GetEvent 负责,与 catalog 扫描分离。
|
||||
final class CanonCameraDriver: CameraDriver {
|
||||
private let device: ICCameraDevice
|
||||
let platform: CameraPlatform = .canon
|
||||
let deviceInfo: CameraDeviceInfo
|
||||
|
||||
init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) {
|
||||
self.device = device
|
||||
self.deviceInfo = deviceInfo
|
||||
OTGLog.info(.canon, "initialized, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
OTGLog.debug(.canon, "connect called")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
OTGLog.info(.canon, "driver released, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func listObjects() async throws -> [CameraObject] {
|
||||
let mediaFilesCount = device.mediaFiles?.count ?? 0
|
||||
let contentsCount = device.contents?.count ?? 0
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: device)
|
||||
OTGLog.info(.canon, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)")
|
||||
|
||||
let results = await ICCameraItemScanner.cameraObjects(from: files)
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
|
||||
OTGLog.info(.canon, "listObjects: image count=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
|
||||
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
|
||||
OTGLog.error(.canon, "downloadObject file not found: \(object.filename)")
|
||||
throw CameraError.deviceNotFound
|
||||
}
|
||||
|
||||
let url = try await ICCameraFileDownloader.download(file, to: directory)
|
||||
OTGLog.info(.canon, "downloadObject saved: \(url.lastPathComponent)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
//
|
||||
// CanonPTPCommands.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 佳能 EOS 扩展 PTP 命令码与 GetEvent 传图事件码。
|
||||
enum CanonPTPCommand {
|
||||
/// 标准 GetObjectInfo
|
||||
static let getObjectInfo: UInt16 = PTPStandardCommand.getObjectInfo
|
||||
/// 标准 GetObject
|
||||
static let getObject: UInt16 = PTPStandardCommand.getObject
|
||||
/// EOS GetObject (0x9104)
|
||||
static let eosGetObject: UInt16 = 0x9104
|
||||
/// EOS GetObjectInfoEx (0x9109)
|
||||
static let eosGetObjectInfoEx: UInt16 = 0x9109
|
||||
/// EOS SetRemoteMode (0x9114)
|
||||
static let setRemoteMode: UInt16 = 0x9114
|
||||
/// EOS SetEventMode (0x9115)
|
||||
static let setEventMode: UInt16 = 0x9115
|
||||
/// EOS GetEvent (0x9116)
|
||||
static let getEvent: UInt16 = 0x9116
|
||||
/// EOS TransferComplete (0x9117)
|
||||
static let transferComplete: UInt16 = 0x9117
|
||||
/// EOS PCHDDCapacity (0x911A)
|
||||
static let pchddCapacity: UInt16 = 0x911A
|
||||
/// EOS KeepDeviceOn (0x911D)
|
||||
static let keepDeviceOn: UInt16 = 0x911D
|
||||
/// 标准 ObjectAdded 事件
|
||||
static let objectAdded: UInt16 = PTPStandardCommand.objectAdded
|
||||
/// 标准 RequestObjectTransfer 事件
|
||||
static let requestObjectTransfer: UInt16 = PTPStandardCommand.requestObjectTransfer
|
||||
/// EOS ObjectAddedEx (0xC181)
|
||||
static let eosObjectAddedEx: UInt16 = 0xC181
|
||||
/// EOS ObjectInfoChangedEx (0xC187)
|
||||
static let eosObjectInfoChangedEx: UInt16 = 0xC187
|
||||
/// EOS ObjectContentChanged (0xC188)
|
||||
static let eosObjectContentChanged: UInt16 = 0xC188
|
||||
/// EOS RequestObjectTransfer (0xC186)
|
||||
static let eosRequestObjectTransfer: UInt16 = 0xC186
|
||||
/// EOS RequestObjectTransferDT (0xC190)
|
||||
static let eosRequestObjectTransferDT: UInt16 = 0xC190
|
||||
/// EOS RequestObjectTransferTS (0xC1A2)
|
||||
static let eosRequestObjectTransferTS: UInt16 = 0xC1A2
|
||||
/// EOS RequestObjectTransferEx (0xC1A7),R8 等新机型快门后传图事件
|
||||
static let eosRequestObjectTransferEx: UInt16 = 0xC1A7
|
||||
/// EOS DevicePropChanged (0xC189)
|
||||
static let eosDevicePropChanged: UInt16 = 0xC189
|
||||
/// EOS PropValueDescChanged (0xC18A)
|
||||
static let eosPropValueDescChanged: UInt16 = 0xC18A
|
||||
/// SetRemoteMode 参数:启用
|
||||
static let remoteModeEnabled: UInt32 = 1
|
||||
/// SetEventMode 参数:启用
|
||||
static let eventModeEnabled: UInt32 = 1
|
||||
}
|
||||
|
||||
/// libgphoto2 `ptp-pack.c` 中 Canon EOS 事件记录字段偏移(相对单条 record 起点)。
|
||||
enum CanonEOSEventOffset {
|
||||
static let recordCode = 0x04
|
||||
static let objectHandle = 0x08
|
||||
static let transferObjectSize = 0x14
|
||||
static let transferFilename = 0x1C
|
||||
static let addedStorageID = 0x0C
|
||||
static let addedObjectSize = 0x1C
|
||||
static let addedFilename = 0x28
|
||||
}
|
||||
|
||||
/// GetEvent 解析出的单条佳能 EOS 事件。
|
||||
struct CanonEOSEvent {
|
||||
let code: UInt32
|
||||
let storageID: UInt32?
|
||||
let objectHandle: UInt32?
|
||||
let filename: String?
|
||||
let objectSize: UInt32?
|
||||
}
|
||||
|
||||
/// Canon 远程会话初始化结果。
|
||||
struct CanonRemoteSessionResult {
|
||||
let success: Bool
|
||||
let nextTransactionID: UInt32
|
||||
}
|
||||
@ -0,0 +1,369 @@
|
||||
//
|
||||
// CanonPTPHelper.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 佳能 EOS PTP:远程会话、GetEvent 解析、Probe 与 GetObject 下载。
|
||||
enum CanonPTPHelper {
|
||||
|
||||
/// 会话打开且 catalog 就绪后进入 Canon 远程拍摄模式。
|
||||
static func initializeRemoteSession(
|
||||
on camera: ICCameraDevice,
|
||||
startingTransactionID: UInt32
|
||||
) async -> CanonRemoteSessionResult {
|
||||
var transactionID = startingTransactionID
|
||||
|
||||
OTGLog.info(.canon, "Canon EOS remote session initializing")
|
||||
|
||||
let remoteMode = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.setRemoteMode,
|
||||
transactionID: &transactionID,
|
||||
parameters: [CanonPTPCommand.remoteModeEnabled],
|
||||
label: "EOS SetRemoteMode"
|
||||
)
|
||||
guard remoteMode.success else {
|
||||
OTGLog.error(.canon, "SetRemoteMode failed")
|
||||
return CanonRemoteSessionResult(success: false, nextTransactionID: transactionID)
|
||||
}
|
||||
|
||||
let eventMode = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.setEventMode,
|
||||
transactionID: &transactionID,
|
||||
parameters: [CanonPTPCommand.eventModeEnabled],
|
||||
label: "EOS SetEventMode"
|
||||
)
|
||||
guard eventMode.success else {
|
||||
OTGLog.error(.canon, "SetEventMode failed")
|
||||
return CanonRemoteSessionResult(success: false, nextTransactionID: transactionID)
|
||||
}
|
||||
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.pchddCapacity,
|
||||
transactionID: &transactionID,
|
||||
parameters: [0x0FFF_FFF8, 0x1000, 1],
|
||||
label: "EOS PCHDDCapacity"
|
||||
)
|
||||
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.keepDeviceOn,
|
||||
transactionID: &transactionID,
|
||||
parameters: [1],
|
||||
label: "EOS KeepDeviceOn"
|
||||
)
|
||||
|
||||
OTGLog.info(.canon, "Canon EOS remote session ready, transactionID=\(transactionID)")
|
||||
return CanonRemoteSessionResult(success: true, nextTransactionID: transactionID)
|
||||
}
|
||||
|
||||
/// 轮询 GetEvent 并解析 EOS 事件列表。
|
||||
static func pollEvents(
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> [CanonEOSEvent] {
|
||||
let result = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.getEvent,
|
||||
transactionID: &transactionID,
|
||||
parameters: [],
|
||||
label: "EOS GetEvent"
|
||||
)
|
||||
guard result.success, !result.inData.isEmpty else { return [] }
|
||||
|
||||
let events = parseEOSEvents(result.inData)
|
||||
if events.isEmpty, result.inData.count > 8 {
|
||||
let hex = result.inData.prefix(64).map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
OTGLog.debug(.canon, "GetEvent inData=\(result.inData.count)B, no events, raw=\(hex)")
|
||||
}
|
||||
for event in events where isTransferEvent(event) {
|
||||
OTGLog.info(
|
||||
.canon,
|
||||
"transfer event 0x\(String(format: "%04X", event.code)) handle=0x\(String(format: "%08X", event.objectHandle ?? 0)) file=\(event.filename ?? "?") size=\(event.objectSize ?? 0)"
|
||||
)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/// 解析 GetEvent 返回的 EOS 事件(布局参考 libgphoto2 `ptp_unpack_EOS_events`)。
|
||||
static func parseEOSEvents(_ data: Data) -> [CanonEOSEvent] {
|
||||
var events: [CanonEOSEvent] = []
|
||||
var offset = 0
|
||||
|
||||
while offset + 8 <= data.count {
|
||||
let recordLen = Int(PTPHelper.readUInt32LE(data, offset: offset))
|
||||
let code = PTPHelper.readUInt32LE(data, offset: offset + CanonEOSEventOffset.recordCode)
|
||||
|
||||
if recordLen == 8, code == 0 { break }
|
||||
guard recordLen >= 8, offset + recordLen <= data.count else { break }
|
||||
|
||||
events.append(
|
||||
parseSingleEOSEvent(
|
||||
data: data,
|
||||
recordOffset: offset,
|
||||
recordLen: recordLen,
|
||||
code: code
|
||||
)
|
||||
)
|
||||
|
||||
offset += recordLen
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/// 是否为应触发下载的传图事件。
|
||||
static func isTransferEvent(_ event: CanonEOSEvent) -> Bool {
|
||||
guard let handle = event.objectHandle, handle != 0 else { return false }
|
||||
switch event.code {
|
||||
case UInt32(CanonPTPCommand.eosRequestObjectTransfer),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferTS),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferDT),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferEx),
|
||||
UInt32(CanonPTPCommand.eosObjectAddedEx),
|
||||
UInt32(CanonPTPCommand.eosObjectInfoChangedEx),
|
||||
UInt32(CanonPTPCommand.eosObjectContentChanged),
|
||||
UInt32(CanonPTPCommand.objectAdded),
|
||||
UInt32(CanonPTPCommand.requestObjectTransfer):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取对象元数据(文件名、压缩大小)。
|
||||
static func probeObject(
|
||||
handle: UInt32,
|
||||
storageID: UInt32?,
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> (filename: String, size: UInt32)? {
|
||||
if let storageID, storageID != 0 {
|
||||
let infoResult = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosGetObjectInfoEx,
|
||||
transactionID: &transactionID,
|
||||
parameters: [storageID, handle, 0],
|
||||
label: "EOS GetObjectInfoEx(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
if infoResult.success,
|
||||
let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 {
|
||||
let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData) ?? defaultFilename()
|
||||
return (filename, size)
|
||||
}
|
||||
}
|
||||
|
||||
let infoResult = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.getObjectInfo,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
label: "GetObjectInfo(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
guard infoResult.success,
|
||||
let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData) ?? defaultFilename()
|
||||
return (filename, size)
|
||||
}
|
||||
|
||||
/// 下载对象二进制;先试标准 GetObject,再试 EOS GetObject。
|
||||
static func downloadObject(
|
||||
handle: UInt32,
|
||||
storageID: UInt32?,
|
||||
preferredFilename: String?,
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> (filename: String, data: Data)? {
|
||||
var filename = preferredFilename ?? defaultFilename()
|
||||
|
||||
if preferredFilename == nil,
|
||||
let probe = await probeObject(
|
||||
handle: handle,
|
||||
storageID: storageID,
|
||||
on: camera,
|
||||
transactionID: &transactionID
|
||||
) {
|
||||
filename = probe.filename
|
||||
}
|
||||
|
||||
let standardResult = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.getObject,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
label: "GetObject(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
if standardResult.success, PTPHelper.isLikelyImageData(standardResult.inData) {
|
||||
return (filename, standardResult.inData)
|
||||
}
|
||||
|
||||
if let storageID, storageID != 0 {
|
||||
let eosResult = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosGetObject,
|
||||
transactionID: &transactionID,
|
||||
parameters: [storageID, handle, 0],
|
||||
label: "EOS GetObject(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
if eosResult.success, PTPHelper.isLikelyImageData(eosResult.inData) {
|
||||
return (filename, eosResult.inData)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 传图完成后 ACK,避免后续事件卡住。
|
||||
static func acknowledgeTransfer(
|
||||
handle: UInt32,
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async {
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.transferComplete,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
label: "EOS TransferComplete(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
}
|
||||
|
||||
/// 从被动 PTP Event Container 提取 object handle(ObjectAdded 等)。
|
||||
static func parsePTPEventHandle(_ eventData: Data) -> UInt32? {
|
||||
guard let container = try? PTPHelper.parseContainer(eventData) else { return nil }
|
||||
switch container.code {
|
||||
case CanonPTPCommand.objectAdded, CanonPTPCommand.requestObjectTransfer:
|
||||
return container.params.first
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func objectSignature(filename: String, size: UInt32) -> String {
|
||||
PTPHelper.objectSignature(filename: filename, size: size)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func parseSingleEOSEvent(
|
||||
data: Data,
|
||||
recordOffset: Int,
|
||||
recordLen: Int,
|
||||
code: UInt32
|
||||
) -> CanonEOSEvent {
|
||||
func u32(at absoluteOffset: Int) -> UInt32? {
|
||||
guard absoluteOffset + 4 <= recordOffset + recordLen, absoluteOffset + 4 <= data.count else { return nil }
|
||||
return PTPHelper.readUInt32LE(data, offset: absoluteOffset)
|
||||
}
|
||||
|
||||
func string(at absoluteOffset: Int) -> String? {
|
||||
guard absoluteOffset < recordOffset + recordLen else { return nil }
|
||||
guard let parsed = PTPHelper.parsePTPString(data, offset: absoluteOffset) else { return nil }
|
||||
let sanitized = PTPHelper.sanitizeFilename(parsed.string)
|
||||
return sanitized.isEmpty ? nil : sanitized
|
||||
}
|
||||
|
||||
switch code {
|
||||
case UInt32(CanonPTPCommand.eosRequestObjectTransfer),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferTS),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferDT),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferEx):
|
||||
guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
return CanonEOSEvent(
|
||||
code: code,
|
||||
storageID: nil,
|
||||
objectHandle: handle,
|
||||
filename: string(at: recordOffset + CanonEOSEventOffset.transferFilename),
|
||||
objectSize: u32(at: recordOffset + CanonEOSEventOffset.transferObjectSize)
|
||||
)
|
||||
|
||||
case UInt32(CanonPTPCommand.eosObjectAddedEx),
|
||||
UInt32(CanonPTPCommand.eosObjectInfoChangedEx):
|
||||
guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
return CanonEOSEvent(
|
||||
code: code,
|
||||
storageID: u32(at: recordOffset + CanonEOSEventOffset.addedStorageID),
|
||||
objectHandle: handle,
|
||||
filename: string(at: recordOffset + CanonEOSEventOffset.addedFilename),
|
||||
objectSize: u32(at: recordOffset + CanonEOSEventOffset.addedObjectSize)
|
||||
)
|
||||
|
||||
case UInt32(CanonPTPCommand.eosObjectContentChanged):
|
||||
guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: handle, filename: nil, objectSize: nil)
|
||||
|
||||
case UInt32(CanonPTPCommand.objectAdded),
|
||||
UInt32(CanonPTPCommand.requestObjectTransfer):
|
||||
guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: handle, filename: nil, objectSize: nil)
|
||||
|
||||
default:
|
||||
if isNonTransferNoiseCode(code) {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle)
|
||||
if handle != nil {
|
||||
OTGLog.debug(
|
||||
.canon,
|
||||
"EOS event 0x\(String(format: "%04X", code)) len=\(recordLen) handle=0x\(String(format: "%08X", handle ?? 0))"
|
||||
)
|
||||
}
|
||||
return CanonEOSEvent(
|
||||
code: code,
|
||||
storageID: u32(at: recordOffset + CanonEOSEventOffset.addedStorageID),
|
||||
objectHandle: handle,
|
||||
filename: string(at: recordOffset + CanonEOSEventOffset.addedFilename)
|
||||
?? string(at: recordOffset + CanonEOSEventOffset.transferFilename),
|
||||
objectSize: u32(at: recordOffset + CanonEOSEventOffset.addedObjectSize)
|
||||
?? u32(at: recordOffset + CanonEOSEventOffset.transferObjectSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isNonTransferNoiseCode(_ code: UInt32) -> Bool {
|
||||
switch code {
|
||||
case UInt32(CanonPTPCommand.eosDevicePropChanged),
|
||||
UInt32(CanonPTPCommand.eosPropValueDescChanged),
|
||||
0xC18B, 0xC185, 0xC19D, 0xC1F6, 0xC1A4:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFilename() -> String {
|
||||
PTPHelper.sanitizeFilename("IMG_\(Int(Date().timeIntervalSince1970)).JPG")
|
||||
}
|
||||
|
||||
private static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
parameters: [UInt32],
|
||||
label: String
|
||||
) async -> PTPHelper.CommandResult {
|
||||
await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: code,
|
||||
transactionID: &transactionID,
|
||||
parameters: parameters,
|
||||
label: label
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,456 @@
|
||||
//
|
||||
// CanonRemoteCaptureService.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 佳能 EOS 边拍边传:GetEvent 轮询 + PTP 下载 + TransferComplete + 写入相册。
|
||||
final class CanonRemoteCaptureService {
|
||||
|
||||
private weak var camera: ICCameraDevice?
|
||||
private var transactionID: UInt32 = 1
|
||||
private(set) var isReady = false
|
||||
|
||||
private var albumID: Int?
|
||||
private let photoRepository: PhotoRepositoryProtocol
|
||||
/// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((String) -> Void)?
|
||||
|
||||
private var eventPollTimer: Timer?
|
||||
private var catalogPollTimer: Timer?
|
||||
private var isDownloading = false
|
||||
private var isDrainingQueue = false
|
||||
private var isPollingGetEvent = false
|
||||
|
||||
private var lastDownloadedSignature: String?
|
||||
private var knownDownloadedHandles: Set<UInt32> = []
|
||||
private var knownCatalogIDs: Set<String> = []
|
||||
private var catalogBaselineEstablished = false
|
||||
private var savedShotCount = 0
|
||||
|
||||
private var downloadQueue: [PendingDownload] = []
|
||||
|
||||
private struct PendingDownload {
|
||||
let handle: UInt32
|
||||
let storageID: UInt32?
|
||||
let filename: String?
|
||||
let objectSize: UInt32?
|
||||
let reason: String
|
||||
}
|
||||
|
||||
private let eventPollInterval: TimeInterval = 0.2
|
||||
private let catalogPollInterval: TimeInterval = 1.5
|
||||
private var pollTickCount = 0
|
||||
private var catalogScanCount = 0
|
||||
|
||||
init(photoRepository: PhotoRepositoryProtocol = TravelAlbumOTGPhotoRepository()) {
|
||||
self.photoRepository = photoRepository
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// catalog 就绪且远程会话初始化成功后启动。
|
||||
func start(
|
||||
camera: ICCameraDevice,
|
||||
startingTransactionID: UInt32,
|
||||
albumID: Int?
|
||||
) {
|
||||
stop()
|
||||
|
||||
self.camera = camera
|
||||
self.transactionID = startingTransactionID
|
||||
self.albumID = albumID
|
||||
self.isReady = true
|
||||
self.lastDownloadedSignature = nil
|
||||
self.knownDownloadedHandles = []
|
||||
self.knownCatalogIDs = []
|
||||
self.catalogBaselineEstablished = false
|
||||
self.savedShotCount = 0
|
||||
self.downloadQueue = []
|
||||
self.pollTickCount = 0
|
||||
self.isPollingGetEvent = false
|
||||
|
||||
establishCatalogBaseline(from: camera)
|
||||
|
||||
OTGLog.info(.canon, "Remote capture started, transactionID=\(startingTransactionID), album=\(albumID.map(String.init) ?? "nil")")
|
||||
startEventPolling()
|
||||
startCatalogPolling()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isReady = false
|
||||
if Thread.isMainThread {
|
||||
stopEventPolling()
|
||||
stopCatalogPolling()
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
stopEventPolling()
|
||||
stopCatalogPolling()
|
||||
}
|
||||
}
|
||||
isDownloading = false
|
||||
isDrainingQueue = false
|
||||
isPollingGetEvent = false
|
||||
downloadQueue = []
|
||||
knownCatalogIDs = []
|
||||
catalogBaselineEstablished = false
|
||||
albumID = nil
|
||||
camera = nil
|
||||
}
|
||||
|
||||
// MARK: - Catalog Fallback
|
||||
|
||||
/// ImageCaptureCore 在 catalog 就绪后新增的文件(仅保存到 SD 卡时 GetEvent 可能无传图事件)。
|
||||
func handleCatalogItemsAdded(_ items: [ICCameraItem]) {
|
||||
guard isReady, catalogBaselineEstablished else { return }
|
||||
|
||||
for item in items {
|
||||
guard let file = item as? ICCameraFile else { continue }
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "didAdd") }
|
||||
}
|
||||
}
|
||||
|
||||
private func establishCatalogBaseline(from camera: ICCameraDevice) {
|
||||
var known = Set<String>()
|
||||
var newDuringConnect = 0
|
||||
|
||||
for file in ICCameraItemScanner.collectImageFiles(from: camera) {
|
||||
if file.wasAddedAfterContentCatalogCompleted {
|
||||
newDuringConnect += 1
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "baseline-new") }
|
||||
} else {
|
||||
known.insert(ICCameraItemScanner.cameraObjectID(for: file))
|
||||
}
|
||||
}
|
||||
|
||||
knownCatalogIDs = known
|
||||
catalogBaselineEstablished = true
|
||||
OTGLog.info(.canon, "Catalog baseline: known=\(known.count), newDuringConnect=\(newDuringConnect)")
|
||||
}
|
||||
|
||||
private func scanCatalogForNewFiles() async {
|
||||
guard isReady, catalogBaselineEstablished, let camera else { return }
|
||||
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: camera)
|
||||
catalogScanCount += 1
|
||||
if catalogScanCount == 1 || catalogScanCount % 10 == 0 {
|
||||
OTGLog.debug(.canon, "Catalog scan #\(catalogScanCount): files=\(files.count), known=\(knownCatalogIDs.count)")
|
||||
}
|
||||
|
||||
for file in files {
|
||||
await downloadCatalogFileIfNew(file, reason: "catalog-poll")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadCatalogFileIfNew(_ file: ICCameraFile, reason: String) async {
|
||||
guard isReady else { return }
|
||||
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(for: file)
|
||||
guard !knownCatalogIDs.contains(objectID) else { return }
|
||||
knownCatalogIDs.insert(objectID)
|
||||
|
||||
if isDownloading {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
let filename = file.name ?? UUID().uuidString
|
||||
if objectID == lastDownloadedSignature {
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.canon, "\(reason): downloading \(filename) id=\(objectID)")
|
||||
|
||||
do {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("canon-live", isDirectory: true)
|
||||
let url = try await ICCameraFileDownloader.download(file, to: tempDir)
|
||||
let data = try Data(contentsOf: url)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
lastDownloadedSignature = objectID
|
||||
|
||||
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||
onShotSaved?(filename)
|
||||
} catch {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
OTGLog.error(.canon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PTP Events
|
||||
|
||||
/// 处理 ImageCaptureCore 被动 PTP 事件(ObjectAdded 等)。
|
||||
func handlePTPEvent(_ eventData: Data) {
|
||||
guard isReady else { return }
|
||||
|
||||
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
OTGLog.info(.canon, "PTP event received: \(hex) (\(eventData.count)B)")
|
||||
|
||||
if let handle = CanonPTPHelper.parsePTPEventHandle(eventData), handle != 0 {
|
||||
enqueueDownload(
|
||||
handle: handle,
|
||||
storageID: nil,
|
||||
filename: nil,
|
||||
objectSize: nil,
|
||||
reason: "PTPEvent"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GetEvent Polling
|
||||
|
||||
private func startEventPolling() {
|
||||
let schedule = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopEventPolling()
|
||||
OTGLog.info(.canon, "GetEvent polling started (interval=\(self.eventPollInterval)s)")
|
||||
|
||||
let timer = Timer(timeInterval: self.eventPollInterval, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isReady else { return }
|
||||
Task { await self.pollCanonEvents() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.eventPollTimer = timer
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
schedule()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: schedule)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopEventPolling() {
|
||||
eventPollTimer?.invalidate()
|
||||
eventPollTimer = nil
|
||||
}
|
||||
|
||||
private func startCatalogPolling() {
|
||||
let schedule = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopCatalogPolling()
|
||||
OTGLog.info(.canon, "Catalog polling started (interval=\(self.catalogPollInterval)s)")
|
||||
|
||||
let timer = Timer(timeInterval: self.catalogPollInterval, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isReady else { return }
|
||||
Task { await self.scanCatalogForNewFiles() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.catalogPollTimer = timer
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
schedule()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: schedule)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCatalogPolling() {
|
||||
catalogPollTimer?.invalidate()
|
||||
catalogPollTimer = nil
|
||||
}
|
||||
|
||||
private func pollCanonEvents() async {
|
||||
guard isReady, let camera else { return }
|
||||
guard !isDownloading else { return }
|
||||
guard !isPollingGetEvent else { return }
|
||||
|
||||
isPollingGetEvent = true
|
||||
defer { isPollingGetEvent = false }
|
||||
|
||||
pollTickCount += 1
|
||||
|
||||
var localTransactionID = transactionID
|
||||
let events = await CanonPTPHelper.pollEvents(on: camera, transactionID: &localTransactionID)
|
||||
transactionID = localTransactionID
|
||||
|
||||
if pollTickCount == 1 || pollTickCount % 25 == 0 {
|
||||
OTGLog.debug(.canon, "GetEvent poll #\(pollTickCount), parsedEvents=\(events.count)")
|
||||
}
|
||||
|
||||
for event in events where CanonPTPHelper.isTransferEvent(event) {
|
||||
guard let handle = event.objectHandle else { continue }
|
||||
enqueueDownload(
|
||||
handle: handle,
|
||||
storageID: event.storageID,
|
||||
filename: event.filename,
|
||||
objectSize: event.objectSize,
|
||||
reason: "GetEvent-0x\(String(format: "%04X", event.code))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download Queue
|
||||
|
||||
private func enqueueDownload(
|
||||
handle: UInt32,
|
||||
storageID: UInt32?,
|
||||
filename: String?,
|
||||
objectSize: UInt32?,
|
||||
reason: String
|
||||
) {
|
||||
if knownDownloadedHandles.contains(handle) { return }
|
||||
|
||||
downloadQueue.append(
|
||||
PendingDownload(
|
||||
handle: handle,
|
||||
storageID: storageID,
|
||||
filename: filename,
|
||||
objectSize: objectSize,
|
||||
reason: reason
|
||||
)
|
||||
)
|
||||
OTGLog.info(
|
||||
.canon,
|
||||
"\(reason): enqueued handle=0x\(String(format: "%08X", handle)) file=\(filename ?? "?") queue=\(downloadQueue.count)"
|
||||
)
|
||||
startDrainIfNeeded()
|
||||
}
|
||||
|
||||
private func startDrainIfNeeded() {
|
||||
guard !isDrainingQueue, !downloadQueue.isEmpty else { return }
|
||||
isDrainingQueue = true
|
||||
Task { await drainDownloadQueue() }
|
||||
}
|
||||
|
||||
private func drainDownloadQueue() async {
|
||||
defer {
|
||||
isDrainingQueue = false
|
||||
if !downloadQueue.isEmpty {
|
||||
startDrainIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
while !downloadQueue.isEmpty {
|
||||
let item = downloadQueue.removeFirst()
|
||||
_ = await downloadAndSave(item: item, maxAttempts: 6)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func downloadAndSave(item: PendingDownload, maxAttempts: Int) async -> Bool {
|
||||
guard isReady, let camera else { return false }
|
||||
|
||||
if knownDownloadedHandles.contains(item.handle) { return false }
|
||||
|
||||
if isDownloading {
|
||||
downloadQueue.insert(item, at: 0)
|
||||
return false
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
|
||||
for attempt in 1...maxAttempts {
|
||||
if attempt > 1 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(attempt) * 300_000_000)
|
||||
}
|
||||
|
||||
var filename = item.filename
|
||||
var resolvedSize = item.objectSize ?? 0
|
||||
|
||||
if filename == nil || resolvedSize == 0 {
|
||||
if let probe = await CanonPTPHelper.probeObject(
|
||||
handle: item.handle,
|
||||
storageID: item.storageID,
|
||||
on: camera,
|
||||
transactionID: &localTransactionID
|
||||
) {
|
||||
if filename == nil { filename = probe.filename }
|
||||
if resolvedSize == 0 { resolvedSize = probe.size }
|
||||
} else if filename == nil, resolvedSize == 0 {
|
||||
if attempt == maxAttempts {
|
||||
OTGLog.warning(.canon, "\(item.reason): object not readable handle=0x\(String(format: "%08X", item.handle))")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedFilename = filename ?? PTPHelper.sanitizeFilename("IMG_\(item.handle).JPG")
|
||||
let signature = resolvedSize > 0
|
||||
? CanonPTPHelper.objectSignature(filename: resolvedFilename, size: resolvedSize)
|
||||
: "\(resolvedFilename)_handle_\(item.handle)"
|
||||
|
||||
if signature == lastDownloadedSignature {
|
||||
knownDownloadedHandles.insert(item.handle)
|
||||
return false
|
||||
}
|
||||
|
||||
guard let result = await CanonPTPHelper.downloadObject(
|
||||
handle: item.handle,
|
||||
storageID: item.storageID,
|
||||
preferredFilename: filename,
|
||||
on: camera,
|
||||
transactionID: &localTransactionID
|
||||
) else {
|
||||
if attempt == maxAttempts {
|
||||
OTGLog.error(.canon, "\(item.reason): download failed handle=0x\(String(format: "%08X", item.handle))")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
transactionID = localTransactionID
|
||||
|
||||
await CanonPTPHelper.acknowledgeTransfer(
|
||||
handle: item.handle,
|
||||
on: camera,
|
||||
transactionID: &localTransactionID
|
||||
)
|
||||
transactionID = localTransactionID
|
||||
|
||||
lastDownloadedSignature = signature
|
||||
knownDownloadedHandles.insert(item.handle)
|
||||
|
||||
guard await saveToAlbum(data: result.data, filename: result.filename, reason: item.reason) else {
|
||||
return false
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(result.filename), bytes=\(result.data.count)")
|
||||
onShotSaved?(result.filename)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
|
||||
guard let albumID else {
|
||||
OTGLog.warning(.canon, "\(reason): live transfer album not configured, skip save")
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
let fileURL = try AlbumPhotoStorage.writeImage(
|
||||
data,
|
||||
filename: filename,
|
||||
albumID: albumID
|
||||
)
|
||||
try photoRepository.addPhoto(
|
||||
albumID: albumID,
|
||||
localPath: fileURL.path,
|
||||
createdAt: Date()
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
//
|
||||
// NikonCameraDriver.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 尼康相机 Driver:ImageCaptureCore 枚举 SD 卡历史照片并下载(含 NEF/JPEG 等)。
|
||||
final class NikonCameraDriver: CameraDriver {
|
||||
private let device: ICCameraDevice
|
||||
let platform: CameraPlatform = .nikon
|
||||
let deviceInfo: CameraDeviceInfo
|
||||
|
||||
init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) {
|
||||
self.device = device
|
||||
self.deviceInfo = deviceInfo
|
||||
OTGLog.info(.nikon, "initialized, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
OTGLog.debug(.nikon, "connect called")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
OTGLog.info(.nikon, "driver released, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func listObjects() async throws -> [CameraObject] {
|
||||
let mediaFilesCount = device.mediaFiles?.count ?? 0
|
||||
let contentsCount = device.contents?.count ?? 0
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: device)
|
||||
OTGLog.info(.nikon, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)")
|
||||
|
||||
let results = await ICCameraItemScanner.cameraObjects(from: files)
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
|
||||
OTGLog.info(.nikon, "listObjects: image count=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
|
||||
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
|
||||
OTGLog.error(.nikon, "downloadObject file not found: \(object.filename)")
|
||||
throw CameraError.deviceNotFound
|
||||
}
|
||||
|
||||
let url = try await ICCameraFileDownloader.download(file, to: directory)
|
||||
OTGLog.info(.nikon, "downloadObject saved: \(url.lastPathComponent)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,210 @@
|
||||
//
|
||||
// NikonCatalogLiveCaptureService.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 尼康边拍边传:监听 ImageCaptureCore catalog 新增文件并下载写入相册。
|
||||
/// Z6 系列等在快门后会触发 `didAdd`,无需 Nikon 专用 PTP 协议。
|
||||
final class NikonCatalogLiveCaptureService {
|
||||
|
||||
private weak var camera: ICCameraDevice?
|
||||
private(set) var isReady = false
|
||||
|
||||
private var albumID: Int?
|
||||
private let photoRepository: PhotoRepositoryProtocol
|
||||
var onShotSaved: ((String) -> Void)?
|
||||
|
||||
private var catalogPollTimer: Timer?
|
||||
private var isDownloading = false
|
||||
|
||||
private var lastDownloadedSignature: String?
|
||||
private var knownCatalogIDs: Set<String> = []
|
||||
private var catalogBaselineEstablished = false
|
||||
private var savedShotCount = 0
|
||||
|
||||
private let catalogPollInterval: TimeInterval = 1.5
|
||||
private var catalogScanCount = 0
|
||||
|
||||
init(photoRepository: PhotoRepositoryProtocol = TravelAlbumOTGPhotoRepository()) {
|
||||
self.photoRepository = photoRepository
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start(camera: ICCameraDevice, albumID: Int?) {
|
||||
stop()
|
||||
|
||||
self.camera = camera
|
||||
self.albumID = albumID
|
||||
self.isReady = true
|
||||
self.lastDownloadedSignature = nil
|
||||
self.knownCatalogIDs = []
|
||||
self.catalogBaselineEstablished = false
|
||||
self.savedShotCount = 0
|
||||
self.catalogScanCount = 0
|
||||
|
||||
establishCatalogBaseline(from: camera)
|
||||
|
||||
OTGLog.info(.nikon, "Catalog live capture started, album=\(albumID.map(String.init) ?? "nil")")
|
||||
startCatalogPolling()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isReady = false
|
||||
if Thread.isMainThread {
|
||||
stopCatalogPolling()
|
||||
} else {
|
||||
DispatchQueue.main.sync { stopCatalogPolling() }
|
||||
}
|
||||
isDownloading = false
|
||||
knownCatalogIDs = []
|
||||
catalogBaselineEstablished = false
|
||||
albumID = nil
|
||||
camera = nil
|
||||
}
|
||||
|
||||
// MARK: - Catalog
|
||||
|
||||
func handleCatalogItemsAdded(_ items: [ICCameraItem]) {
|
||||
guard isReady, catalogBaselineEstablished else { return }
|
||||
|
||||
for item in items {
|
||||
guard let file = item as? ICCameraFile else { continue }
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "didAdd") }
|
||||
}
|
||||
}
|
||||
|
||||
private func establishCatalogBaseline(from camera: ICCameraDevice) {
|
||||
var known = Set<String>()
|
||||
var newDuringConnect = 0
|
||||
|
||||
for file in ICCameraItemScanner.collectImageFiles(from: camera) {
|
||||
if file.wasAddedAfterContentCatalogCompleted {
|
||||
newDuringConnect += 1
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "baseline-new") }
|
||||
} else {
|
||||
known.insert(ICCameraItemScanner.cameraObjectID(for: file))
|
||||
}
|
||||
}
|
||||
|
||||
knownCatalogIDs = known
|
||||
catalogBaselineEstablished = true
|
||||
OTGLog.info(.nikon, "Catalog baseline: known=\(known.count), newDuringConnect=\(newDuringConnect)")
|
||||
}
|
||||
|
||||
private func scanCatalogForNewFiles() async {
|
||||
guard isReady, catalogBaselineEstablished, let camera else { return }
|
||||
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: camera)
|
||||
catalogScanCount += 1
|
||||
if catalogScanCount == 1 || catalogScanCount % 10 == 0 {
|
||||
OTGLog.debug(.nikon, "Catalog scan #\(catalogScanCount): files=\(files.count), known=\(knownCatalogIDs.count)")
|
||||
}
|
||||
|
||||
for file in files {
|
||||
await downloadCatalogFileIfNew(file, reason: "catalog-poll")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadCatalogFileIfNew(_ file: ICCameraFile, reason: String) async {
|
||||
guard isReady else { return }
|
||||
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(for: file)
|
||||
guard !knownCatalogIDs.contains(objectID) else { return }
|
||||
knownCatalogIDs.insert(objectID)
|
||||
|
||||
if isDownloading {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
let filename = file.name ?? UUID().uuidString
|
||||
if objectID == lastDownloadedSignature {
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.nikon, "\(reason): downloading \(filename) id=\(objectID)")
|
||||
|
||||
do {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("nikon-live", isDirectory: true)
|
||||
let url = try await ICCameraFileDownloader.download(file, to: tempDir)
|
||||
let data = try Data(contentsOf: url)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
lastDownloadedSignature = objectID
|
||||
|
||||
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.nikon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||
onShotSaved?(filename)
|
||||
} catch {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Polling
|
||||
|
||||
private func startCatalogPolling() {
|
||||
let schedule = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopCatalogPolling()
|
||||
OTGLog.info(.nikon, "Catalog polling started (interval=\(self.catalogPollInterval)s)")
|
||||
|
||||
let timer = Timer(timeInterval: self.catalogPollInterval, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isReady else { return }
|
||||
Task { await self.scanCatalogForNewFiles() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.catalogPollTimer = timer
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
schedule()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: schedule)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCatalogPolling() {
|
||||
catalogPollTimer?.invalidate()
|
||||
catalogPollTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
|
||||
guard let albumID else {
|
||||
OTGLog.warning(.nikon, "\(reason): live transfer album not configured, skip save")
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
let fileURL = try AlbumPhotoStorage.writeImage(
|
||||
data,
|
||||
filename: filename,
|
||||
albumID: albumID
|
||||
)
|
||||
try photoRepository.addPhoto(
|
||||
albumID: albumID,
|
||||
localPath: fileURL.path,
|
||||
createdAt: Date()
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
//
|
||||
// SonyCameraDriver.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 索尼相机 **MTP 历史导入** 路径的 Driver 实现。
|
||||
/// PC Remote 边拍边传由 `SonyRemoteCaptureService` + PTP 负责,与本 Driver 的 catalog 扫描分离。
|
||||
/// 索尼在 PC Remote 模式下 catalog 常为空,需提示用户切换 MTP(见 AGENTS.md)。
|
||||
final class SonyCameraDriver: CameraDriver {
|
||||
private let device: ICCameraDevice
|
||||
let platform: CameraPlatform = .sony
|
||||
let deviceInfo: CameraDeviceInfo
|
||||
|
||||
init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) {
|
||||
self.device = device
|
||||
self.deviceInfo = deviceInfo
|
||||
OTGLog.info(.sony, "initialized, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
OTGLog.debug(.sony, "connect called")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
OTGLog.info(.sony, "driver released, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
/// 扫描 `mediaFiles` / `contents` 树;PC Remote 模式下可能返回空列表。
|
||||
func listObjects() async throws -> [CameraObject] {
|
||||
let mediaFilesCount = device.mediaFiles?.count ?? 0
|
||||
let contentsCount = device.contents?.count ?? 0
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: device)
|
||||
OTGLog.info(.sony, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)")
|
||||
|
||||
let results = await ICCameraItemScanner.cameraObjects(from: files)
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
|
||||
OTGLog.info(.sony, "listObjects: image count=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
|
||||
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
|
||||
OTGLog.error(.sony, "downloadObject file not found: \(object.filename)")
|
||||
throw CameraError.deviceNotFound
|
||||
}
|
||||
|
||||
let url = try await ICCameraFileDownloader.download(file, to: directory)
|
||||
OTGLog.info(.sony, "downloadObject saved: \(url.lastPathComponent)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
//
|
||||
// SonyPTPCommands.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/3.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 索尼 SDIO / SDIE 扩展 PTP 命令码、属性码与事件码常量。
|
||||
enum SonyPTPCommand {
|
||||
// MARK: - SDIO 命令
|
||||
|
||||
/// SDIO Connect (0x9201),握手阶段多次调用,参数区分阶段
|
||||
static let sdioConnect: UInt16 = 0x9201
|
||||
/// GetExtDeviceInfo (0x9202),获取扩展设备能力
|
||||
static let sdioGetExtDeviceInfo: UInt16 = 0x9202
|
||||
/// SetExtDevicePropValue (0x9205),写入单个扩展属性
|
||||
static let sdioSetExtDevicePropValue: UInt16 = 0x9205
|
||||
/// GetAllExtDevicePropInfo (0x9209),读取全部或 diff 扩展属性
|
||||
static let sdioGetAllExtDevicePropInfo: UInt16 = 0x9209
|
||||
|
||||
// MARK: - 扩展设备属性
|
||||
|
||||
/// Position_Key_Setting (0xD25A),设为 PC Remote 模式
|
||||
static let positionKeySetting: UInt16 = 0xD25A
|
||||
/// Still_Image_Save_Destination (0xD222),保存目标含 Host PC
|
||||
static let stillImageSaveDestination: UInt16 = 0xD222
|
||||
/// Still_Image_Trans_Size (0xD268),传输尺寸(0xFFFFFFFF = 原图)
|
||||
static let stillImageTransSize: UInt16 = 0xD268
|
||||
/// Shooting_File_Info (0xD215);值 > 0x8000 表示 PC Remote 缓冲区可能有新片
|
||||
static let shootingFileInfo: UInt16 = 0xD215
|
||||
|
||||
/// PC Remote 虚拟对象句柄,GetObjectInfo/GetObject 用于 Probe 与下载
|
||||
static let shotObjectHandle: UInt32 = 0xFFFF_C001
|
||||
/// 静态影像保存到 Host PC 的属性枚举值
|
||||
static let saveDestinationHostPC: UInt16 = 1
|
||||
/// D215 超过此阈值视为 shot buffer 就绪
|
||||
static let shotBufferReadyThreshold: Int64 = 0x8000
|
||||
|
||||
// MARK: - PTP Events (Sony SDIE)
|
||||
|
||||
/// ObjectAdded (0xC201),虚拟对象已加入设备侧 catalog
|
||||
static let sdieObjectAdded: UInt16 = 0xC201
|
||||
/// ObjectRemoved (0xC202),对象从 catalog 移除
|
||||
static let sdieObjectRemoved: UInt16 = 0xC202
|
||||
/// DevicePropChanged (0xC203);ZV-E10 快门后常见,需配合 D215/Probe
|
||||
static let sdieDevicePropChanged: UInt16 = 0xC203
|
||||
/// CapturedEvent (0xC206),拍摄完成事件
|
||||
static let sdieCapturedEvent: UInt16 = 0xC206
|
||||
}
|
||||
@ -0,0 +1,422 @@
|
||||
//
|
||||
// SonyPTPHelper.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/3.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// 索尼 PC Remote(SDIO)握手、属性解析与边拍边传 Probe 的 PTP 封装。
|
||||
enum SonyPTPHelper {
|
||||
|
||||
/// SDIO 握手结果;`nextTransactionID` 供后续 PTP 命令递增使用。
|
||||
struct RemoteSessionResult {
|
||||
let success: Bool
|
||||
let nextTransactionID: UInt32
|
||||
}
|
||||
|
||||
private static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
parameters: [UInt32],
|
||||
outData: Data?,
|
||||
label: String
|
||||
) async -> PTPHelper.CommandResult {
|
||||
await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: code,
|
||||
transactionID: &transactionID,
|
||||
parameters: parameters,
|
||||
outData: outData,
|
||||
label: label
|
||||
)
|
||||
}
|
||||
|
||||
private static func setDeviceProperty(
|
||||
on camera: ICCameraDevice,
|
||||
propertyCode: UInt16,
|
||||
value: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
label: String
|
||||
) async -> Bool {
|
||||
var payload = Data()
|
||||
PTPHelper.appendUInt16LE(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 setDevicePropertyUInt32(
|
||||
on camera: ICCameraDevice,
|
||||
propertyCode: UInt16,
|
||||
value: UInt32,
|
||||
transactionID: inout UInt32,
|
||||
label: String
|
||||
) async -> Bool {
|
||||
var payload = Data()
|
||||
PTPHelper.appendUInt32LE(value, to: &payload)
|
||||
|
||||
return await sendCommand(
|
||||
on: camera,
|
||||
code: SonyPTPCommand.sdioSetExtDevicePropValue,
|
||||
transactionID: &transactionID,
|
||||
parameters: [UInt32(propertyCode), 1],
|
||||
outData: payload,
|
||||
label: label
|
||||
).success
|
||||
}
|
||||
|
||||
/// 配置 PC Remote 保存目标与传输尺寸(D25A / D222 / D268)。
|
||||
private static func configureRemoteCapture(
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> Bool {
|
||||
guard await setDeviceProperty(
|
||||
on: camera,
|
||||
propertyCode: SonyPTPCommand.positionKeySetting,
|
||||
value: 1,
|
||||
transactionID: &transactionID,
|
||||
label: "Position_Key_Setting=PC Remote"
|
||||
) else { return false }
|
||||
|
||||
guard await setDeviceProperty(
|
||||
on: camera,
|
||||
propertyCode: SonyPTPCommand.stillImageSaveDestination,
|
||||
value: SonyPTPCommand.saveDestinationHostPC,
|
||||
transactionID: &transactionID,
|
||||
label: "Still_Image_Save_Destination=Host PC"
|
||||
) else { return false }
|
||||
|
||||
return await setDevicePropertyUInt32(
|
||||
on: camera,
|
||||
propertyCode: SonyPTPCommand.stillImageTransSize,
|
||||
value: 0xFFFF_FFFF,
|
||||
transactionID: &transactionID,
|
||||
label: "Still_Image_Trans_Size=Full"
|
||||
)
|
||||
}
|
||||
|
||||
/// 完整 SDIO 握手:Connect×3 → GetExtDeviceInfo → GetAllExtDevicePropInfo → 远程拍摄属性。
|
||||
static func initializeRemoteSession(
|
||||
on camera: ICCameraDevice,
|
||||
startingTransactionID: UInt32
|
||||
) async -> RemoteSessionResult {
|
||||
var transactionID = startingTransactionID
|
||||
|
||||
OTGLog.info(.sony, "SDIO handshake starting (phase 1: steps 1-5)")
|
||||
|
||||
// Phase 1:标准 SDIO 连接序列(与 libgphoto2 / my_otg 一致)
|
||||
let steps: [(UInt16, [UInt32], String)] = [
|
||||
(SonyPTPCommand.sdioConnect, [1, 0, 0], "SDIO Connect(1)"),
|
||||
(SonyPTPCommand.sdioConnect, [2, 0, 0], "SDIO Connect(2)"),
|
||||
(SonyPTPCommand.sdioGetExtDeviceInfo, [0x012C, 1], "SDIO GetExtDeviceInfo"),
|
||||
(SonyPTPCommand.sdioConnect, [3, 0, 0], "SDIO Connect(3)"),
|
||||
(SonyPTPCommand.sdioGetAllExtDevicePropInfo, [1], "SDIO GetAllExtDevicePropInfo"),
|
||||
]
|
||||
|
||||
for (code, parameters, label) in steps {
|
||||
let result = await sendCommand(
|
||||
on: camera,
|
||||
code: code,
|
||||
transactionID: &transactionID,
|
||||
parameters: parameters,
|
||||
outData: nil,
|
||||
label: label
|
||||
)
|
||||
guard result.success else {
|
||||
OTGLog.error(.sony, "SDIO failed at: \(label)")
|
||||
return RemoteSessionResult(success: false, nextTransactionID: transactionID)
|
||||
}
|
||||
}
|
||||
|
||||
OTGLog.info(.sony, "SDIO phase 1 OK, nextTransactionID=\(transactionID)")
|
||||
|
||||
let configured = await configureRemoteCapture(on: camera, transactionID: &transactionID)
|
||||
if configured {
|
||||
OTGLog.info(.sony, "SDIO handshake completed, nextTransactionID=\(transactionID)")
|
||||
} else {
|
||||
OTGLog.error(.sony, "SDIO handshake failed at remote capture configuration")
|
||||
}
|
||||
|
||||
return RemoteSessionResult(success: configured, nextTransactionID: transactionID)
|
||||
}
|
||||
|
||||
// MARK: - PTP Events
|
||||
|
||||
static func describeEvent(_ eventData: Data) -> String {
|
||||
guard let code = PTPHelper.parseEventCode(from: eventData) else {
|
||||
return "unknown, hex=\(PTPHelper.hexString(eventData.prefix(16)))"
|
||||
}
|
||||
|
||||
let name: String
|
||||
switch code {
|
||||
case SonyPTPCommand.sdieObjectAdded:
|
||||
name = "ObjectAdded"
|
||||
case SonyPTPCommand.sdieObjectRemoved:
|
||||
name = "ObjectRemoved"
|
||||
case SonyPTPCommand.sdieDevicePropChanged:
|
||||
name = "DevicePropChanged"
|
||||
case SonyPTPCommand.sdieCapturedEvent:
|
||||
name = "CapturedEvent"
|
||||
default:
|
||||
name = "Other"
|
||||
}
|
||||
|
||||
if let container = try? PTPHelper.parseContainer(eventData), !container.params.isEmpty {
|
||||
let params = container.params
|
||||
.map { String(format: "0x%08X", $0) }
|
||||
.joined(separator: ", ")
|
||||
return "\(name) (0x\(String(format: "%04X", code))), params=[\(params)]"
|
||||
}
|
||||
|
||||
return "\(name) (0x\(String(format: "%04X", code)))"
|
||||
}
|
||||
|
||||
// MARK: - Shot detection
|
||||
|
||||
/// Probe 成功时返回的待下载片信息(尚未 GetObject)。
|
||||
struct PendingShot {
|
||||
let filename: String
|
||||
let size: UInt32
|
||||
let handle: UInt32
|
||||
}
|
||||
|
||||
/// 对虚拟句柄发 GetObjectInfo,解析文件名与 CompressedSize。
|
||||
static func probeShotObject(
|
||||
on camera: ICCameraDevice,
|
||||
handle: UInt32 = SonyPTPCommand.shotObjectHandle,
|
||||
transactionID: inout UInt32
|
||||
) async -> PendingShot? {
|
||||
let infoResult = await sendCommand(
|
||||
on: camera,
|
||||
code: PTPStandardCommand.getObjectInfo,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
outData: nil,
|
||||
label: "ProbeObjectInfo(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
guard infoResult.success else { return nil }
|
||||
|
||||
guard let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData)
|
||||
?? "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
|
||||
|
||||
return PendingShot(filename: filename, size: size, handle: handle)
|
||||
}
|
||||
|
||||
/// GetObject 下载结果。
|
||||
struct DownloadedShot {
|
||||
let filename: String
|
||||
let data: Data
|
||||
let handle: UInt32
|
||||
}
|
||||
|
||||
/// 对虚拟句柄发 GetObject,返回 JPEG/RAW 二进制;Probe 后可 `skipObjectInfo` 省略重复 GetObjectInfo。
|
||||
static func downloadShotObject(
|
||||
on camera: ICCameraDevice,
|
||||
handle: UInt32 = SonyPTPCommand.shotObjectHandle,
|
||||
transactionID: inout UInt32,
|
||||
knownFilename: String? = nil,
|
||||
skipObjectInfo: Bool = false
|
||||
) async -> DownloadedShot? {
|
||||
var filename = PTPHelper.sanitizeFilename(
|
||||
knownFilename ?? "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
|
||||
)
|
||||
|
||||
if !skipObjectInfo {
|
||||
let infoResult = await sendCommand(
|
||||
on: camera,
|
||||
code: PTPStandardCommand.getObjectInfo,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
outData: nil,
|
||||
label: "GetObjectInfo(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
if infoResult.success, let parsedName = PTPHelper.parseObjectInfoFilename(infoResult.inData) {
|
||||
filename = parsedName
|
||||
}
|
||||
}
|
||||
|
||||
let objectResult = await sendCommand(
|
||||
on: camera,
|
||||
code: PTPStandardCommand.getObject,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
outData: nil,
|
||||
label: "GetObject(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
guard objectResult.success, PTPHelper.isLikelyImageData(objectResult.inData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadedShot(filename: filename, data: objectResult.inData, handle: handle)
|
||||
}
|
||||
|
||||
/// 读取 SDIO 扩展属性;`onlyDiff=true` 仅返回自上次读取以来变化的属性。
|
||||
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)
|
||||
}
|
||||
|
||||
/// 解析 GetAllExtDevicePropInfo 返回的二进制属性列表。
|
||||
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 = PTPHelper.readUInt16LE(data, offset: offset)
|
||||
offset += 2
|
||||
let dataType = PTPHelper.readUInt16LE(data, offset: offset)
|
||||
offset += 2
|
||||
offset += 2 // GetSet 等标志位,当前不解析
|
||||
|
||||
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
|
||||
// formFlag: 0=无表单, 1=Range, 2=Enumeration
|
||||
guard skipFormPayload(formFlag: formFlag, dataType: dataType, data: data, offset: &offset) else { break }
|
||||
}
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
/// D215(Shooting_File_Info)大于阈值表示缓冲区可能有完整新片。
|
||||
static func isShotBufferReady(_ shootingFileInfo: Int64?) -> Bool {
|
||||
guard let shootingFileInfo else { return false }
|
||||
return shootingFileInfo > SonyPTPCommand.shotBufferReadyThreshold
|
||||
}
|
||||
|
||||
static func shotObjectSignature(filename: String, size: UInt32) -> String {
|
||||
PTPHelper.objectSignature(filename: filename, size: size)
|
||||
}
|
||||
|
||||
/// PTP 设备属性值的数据类型码(用于 parseExtDeviceProperties)。
|
||||
private enum PTPDataType: UInt16 {
|
||||
/// 有符号 8 位整数
|
||||
case int8 = 0x0001
|
||||
/// 无符号 8 位整数
|
||||
case uint8 = 0x0002
|
||||
/// 有符号 16 位整数
|
||||
case int16 = 0x0003
|
||||
/// 无符号 16 位整数
|
||||
case uint16 = 0x0004
|
||||
/// 有符号 32 位整数
|
||||
case int32 = 0x0005
|
||||
/// 无符号 32 位整数
|
||||
case uint32 = 0x0006
|
||||
/// 有符号 64 位整数(当前仅跳过 8 字节)
|
||||
case int64 = 0x0007
|
||||
/// 无符号 64 位整数(当前仅跳过 8 字节)
|
||||
case uint64 = 0x0008
|
||||
/// UTF-16LE 字符串;解析时跳过不参与数值 map
|
||||
case string = 0xFFFF
|
||||
}
|
||||
|
||||
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: PTPHelper.readUInt16LE(data, offset: offset)))
|
||||
offset += 2
|
||||
return value
|
||||
case .uint16:
|
||||
guard offset + 1 < data.count else { return nil }
|
||||
let value = Int64(PTPHelper.readUInt16LE(data, offset: offset))
|
||||
offset += 2
|
||||
return value
|
||||
case .int32:
|
||||
guard offset + 3 < data.count else { return nil }
|
||||
let value = Int64(Int32(bitPattern: PTPHelper.readUInt32LE(data, offset: offset)))
|
||||
offset += 4
|
||||
return value
|
||||
case .uint32:
|
||||
guard offset + 3 < data.count else { return nil }
|
||||
let value = Int64(PTPHelper.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(PTPHelper.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(PTPHelper.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,334 @@
|
||||
//
|
||||
// SonyRemoteCaptureService.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import ImageCaptureCore
|
||||
|
||||
/// Sony PC Remote 边拍边传:检测(Step 5)+ GetObject 下载入库(Step 6)。
|
||||
final class SonyRemoteCaptureService {
|
||||
|
||||
private weak var camera: ICCameraDevice?
|
||||
private var transactionID: UInt32 = 1
|
||||
private(set) var isReady = false
|
||||
|
||||
private var albumID: Int?
|
||||
private let photoRepository: PhotoRepositoryProtocol
|
||||
/// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((String) -> Void)?
|
||||
|
||||
private var pollTimer: Timer?
|
||||
private var propertyCheckTask: Task<Void, Never>?
|
||||
private var isEvaluatingShotBuffer = false
|
||||
private var propertyCheckCoalesceScheduled = false
|
||||
private var isDownloading = false
|
||||
|
||||
private var lastDownloadedShotSignature: String?
|
||||
private var detectedShotCount = 0
|
||||
private var savedShotCount = 0
|
||||
|
||||
private let idlePollInterval: TimeInterval = 1.0
|
||||
/// 检测到新片后加快 D215 轮询,提高连拍响应
|
||||
private let activePollInterval: TimeInterval = 0.25
|
||||
/// C203 等事件后略延迟再读属性,等待相机写入缓冲区
|
||||
private let propertyCheckDelay: TimeInterval = 0.12
|
||||
|
||||
init(photoRepository: PhotoRepositoryProtocol = TravelAlbumOTGPhotoRepository()) {
|
||||
self.photoRepository = photoRepository
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// 在 SDIO 握手成功后启动;`albumID` 为边拍边传目标相册,nil 时只检测不保存。
|
||||
func start(
|
||||
camera: ICCameraDevice,
|
||||
startingTransactionID: UInt32,
|
||||
albumID: Int?
|
||||
) {
|
||||
stop()
|
||||
|
||||
self.camera = camera
|
||||
self.transactionID = startingTransactionID
|
||||
self.albumID = albumID
|
||||
self.isReady = true
|
||||
self.lastDownloadedShotSignature = nil
|
||||
self.detectedShotCount = 0
|
||||
self.savedShotCount = 0
|
||||
|
||||
OTGLog.info(.sony, "Remote capture service started, transactionID=\(startingTransactionID), album=\(albumID.map(String.init) ?? "nil")")
|
||||
startD215Polling()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isReady = false
|
||||
stopD215Polling()
|
||||
propertyCheckTask?.cancel()
|
||||
propertyCheckTask = nil
|
||||
isEvaluatingShotBuffer = false
|
||||
propertyCheckCoalesceScheduled = false
|
||||
isDownloading = false
|
||||
albumID = nil
|
||||
camera = nil
|
||||
}
|
||||
|
||||
// MARK: - PTP Events
|
||||
|
||||
/// 处理 Sony SDIE 事件:C201/C206 强信号,C203 需配合 D215/Probe。
|
||||
func handlePTPEvent(_ eventData: Data) {
|
||||
guard isReady else { return }
|
||||
|
||||
guard let code = PTPHelper.parseEventCode(from: eventData) else { return }
|
||||
|
||||
switch code {
|
||||
case SonyPTPCommand.sdieObjectAdded:
|
||||
let handle = (try? PTPHelper.parseContainer(eventData))?.params.first
|
||||
?? SonyPTPCommand.shotObjectHandle
|
||||
OTGLog.info(.sony, "Shot signal: ObjectAdded handle=0x\(String(format: "%08X", handle))")
|
||||
schedulePropertyBasedShotCheck(reason: "ObjectAdded")
|
||||
|
||||
case SonyPTPCommand.sdieCapturedEvent:
|
||||
OTGLog.info(.sony, "Shot signal: CapturedEvent")
|
||||
schedulePropertyBasedShotCheck(reason: "CapturedEvent")
|
||||
|
||||
case SonyPTPCommand.sdieDevicePropChanged:
|
||||
OTGLog.debug(.sony, "PTP event: DevicePropChanged (0xC203)")
|
||||
schedulePropertyBasedShotCheck(reason: "DevicePropChanged")
|
||||
|
||||
case SonyPTPCommand.sdieObjectRemoved:
|
||||
OTGLog.debug(.sony, "PTP event: ObjectRemoved (0xC202)")
|
||||
|
||||
default:
|
||||
OTGLog.debug(.sony, "PTP event: \(SonyPTPHelper.describeEvent(eventData))")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - D215 Polling
|
||||
|
||||
private func startD215Polling() {
|
||||
stopD215Polling()
|
||||
OTGLog.info(.sony, "D215 polling started (idle=\(idlePollInterval)s)")
|
||||
schedulePollTimer(interval: idlePollInterval)
|
||||
}
|
||||
|
||||
private func stopD215Polling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
private func schedulePollTimer(interval: TimeInterval) {
|
||||
stopD215Polling()
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
self?.schedulePropertyBasedShotCheck(reason: "D215Poll", delay: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPollInterval() {
|
||||
guard isReady else { return }
|
||||
let interval = savedShotCount > 0 ? activePollInterval : idlePollInterval
|
||||
if pollTimer?.timeInterval != interval {
|
||||
schedulePollTimer(interval: interval)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Property check + Probe + Download
|
||||
|
||||
private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval? = nil) {
|
||||
// 正在评估时合并后续请求,避免并发 PTP 风暴
|
||||
if isEvaluatingShotBuffer {
|
||||
propertyCheckCoalesceScheduled = true
|
||||
return
|
||||
}
|
||||
|
||||
propertyCheckTask?.cancel()
|
||||
let wait = delay ?? propertyCheckDelay
|
||||
propertyCheckTask = Task { [weak self] in
|
||||
if wait > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(wait * 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
|
||||
schedulePropertyBasedShotCheck(reason: "Coalesced", delay: 0.08)
|
||||
}
|
||||
}
|
||||
|
||||
await evaluateShotBuffer(reason: reason)
|
||||
}
|
||||
|
||||
/// 读 D215 → 必要时 Probe → GetObject 下载入库。
|
||||
private func evaluateShotBuffer(reason: String) async {
|
||||
guard isReady, let camera else { return }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
var properties = await SonyPTPHelper.fetchExtDeviceProperties(
|
||||
on: camera,
|
||||
transactionID: &localTransactionID,
|
||||
onlyDiff: true
|
||||
)
|
||||
transactionID = localTransactionID
|
||||
|
||||
var shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo]
|
||||
|
||||
if shootingFileInfo == nil, shouldFallbackFullPropertyFetch(for: reason) {
|
||||
properties = await SonyPTPHelper.fetchExtDeviceProperties(
|
||||
on: camera,
|
||||
transactionID: &localTransactionID,
|
||||
onlyDiff: false
|
||||
)
|
||||
transactionID = localTransactionID
|
||||
shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo]
|
||||
OTGLog.debug(.sony, "\(reason): diff had no D215, fetched full property set")
|
||||
}
|
||||
|
||||
if let shootingFileInfo {
|
||||
OTGLog.debug(
|
||||
.sony,
|
||||
"\(reason): D215=0x\(String(format: "%04X", shootingFileInfo))"
|
||||
)
|
||||
}
|
||||
|
||||
if SonyPTPHelper.isShotBufferReady(shootingFileInfo) {
|
||||
_ = await attemptFetchPendingShot(reason: "\(reason)-D215")
|
||||
refreshPollInterval()
|
||||
return
|
||||
}
|
||||
|
||||
// ZV-E10 等机型快门可能只发 C203 且 D215 未及时更新,仍尝试 Probe
|
||||
if shouldProbeWithoutD215(for: reason) {
|
||||
_ = await attemptFetchPendingShot(reason: "\(reason)-Probe")
|
||||
}
|
||||
|
||||
refreshPollInterval()
|
||||
}
|
||||
|
||||
/// Probe → 去重 → GetObject → 写入相册目录与 Core Data。
|
||||
@discardableResult
|
||||
private func attemptFetchPendingShot(reason: String) async -> Bool {
|
||||
guard isReady, let camera else { return false }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
guard let probe = await SonyPTPHelper.probeShotObject(
|
||||
on: camera,
|
||||
transactionID: &localTransactionID
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
transactionID = localTransactionID
|
||||
|
||||
let signature = SonyPTPHelper.shotObjectSignature(
|
||||
filename: probe.filename,
|
||||
size: probe.size
|
||||
)
|
||||
|
||||
if signature == lastDownloadedShotSignature {
|
||||
OTGLog.debug(.sony, "\(reason): already downloaded signature=\(signature)")
|
||||
return false
|
||||
}
|
||||
|
||||
detectedShotCount += 1
|
||||
OTGLog.info(
|
||||
.sony,
|
||||
"New shot detected (#\(detectedShotCount)): \(probe.filename), size=\(probe.size), reason=\(reason)"
|
||||
)
|
||||
|
||||
guard await downloadAndSave(probe: probe, reason: reason) else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastDownloadedShotSignature = signature
|
||||
savedShotCount += 1
|
||||
return true
|
||||
}
|
||||
|
||||
/// 串行下载,失败时最多重试 3 次(连拍缓冲区可能尚未写完)。
|
||||
private func downloadAndSave(probe: SonyPTPHelper.PendingShot, reason: String) async -> Bool {
|
||||
guard isReady, let camera else { return false }
|
||||
|
||||
guard let albumID else {
|
||||
OTGLog.warning(.sony, "\(reason): live transfer album not configured, skip save")
|
||||
return false
|
||||
}
|
||||
|
||||
if isDownloading {
|
||||
OTGLog.debug(.sony, "\(reason): download already in progress, skip")
|
||||
return false
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
|
||||
for attempt in 1...3 {
|
||||
if attempt > 1 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000)
|
||||
}
|
||||
|
||||
guard let downloaded = await SonyPTPHelper.downloadShotObject(
|
||||
on: camera,
|
||||
handle: probe.handle,
|
||||
transactionID: &localTransactionID,
|
||||
knownFilename: probe.filename,
|
||||
skipObjectInfo: true
|
||||
) else {
|
||||
if attempt == 3 {
|
||||
OTGLog.error(.sony, "\(reason): GetObject failed after \(attempt) attempts")
|
||||
}
|
||||
continue
|
||||
}
|
||||
transactionID = localTransactionID
|
||||
|
||||
do {
|
||||
let fileURL = try AlbumPhotoStorage.writeImage(
|
||||
downloaded.data,
|
||||
filename: downloaded.filename,
|
||||
albumID: albumID
|
||||
)
|
||||
try photoRepository.addPhoto(
|
||||
albumID: albumID,
|
||||
localPath: fileURL.path,
|
||||
createdAt: Date()
|
||||
)
|
||||
OTGLog.info(
|
||||
.sony,
|
||||
"Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count), path=\(fileURL.lastPathComponent)"
|
||||
)
|
||||
onShotSaved?(downloaded.filename)
|
||||
return true
|
||||
} catch {
|
||||
OTGLog.error(.sony, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func shouldFallbackFullPropertyFetch(for reason: String) -> Bool {
|
||||
reason.contains("DevicePropChanged")
|
||||
|| reason.hasPrefix("D215Poll")
|
||||
|| reason.hasPrefix("Coalesced")
|
||||
}
|
||||
|
||||
private func shouldProbeWithoutD215(for reason: String) -> Bool {
|
||||
reason.contains("DevicePropChanged")
|
||||
|| reason.hasPrefix("D215Poll")
|
||||
|| reason.hasPrefix("Coalesced")
|
||||
|| reason.contains("ObjectAdded")
|
||||
|| reason.contains("CapturedEvent")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user