feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -0,0 +1,59 @@
//
// CanonCameraDriver.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
@preconcurrency import ImageCaptureCore
/// DriverImageCaptureCore 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
}
}

View File

@ -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
}

View File

@ -0,0 +1,369 @@
//
// CanonPTPHelper.swift
// otg_swift
//
import Foundation
@preconcurrency import ImageCaptureCore
/// EOS PTPGetEvent 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 handleObjectAdded
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
)
}
}

View File

@ -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
}
}
}

View File

@ -0,0 +1,58 @@
//
// NikonCameraDriver.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
@preconcurrency import ImageCaptureCore
/// DriverImageCaptureCore 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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -0,0 +1,422 @@
//
// SonyPTPHelper.swift
// otg_swift
//
// Created by hanqiu on 2026/7/3.
//
import Foundation
@preconcurrency import ImageCaptureCore
/// PC RemoteSDIO 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
}
/// D215Shooting_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
}
}
}

View File

@ -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")
}
}