forked from shezi/airmtp
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathairmtp.py
3742 lines (3268 loc) · 178 KB
/
airmtp.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
#
#############################################################################
#
# airnefcmd.py - Wireless file transfer for PTP/MTP-equipped cameras (command-line app)
# Copyright (C) 2015, testcams.com
#
# This module is licensed under GPL v3: http://www.gnu.org/licenses/gpl-3.0.html
#
#############################################################################
#
from __future__ import print_function
from __future__ import division
import six
from six.moves import xrange
from six.moves import cPickle as pickle
import argparse
import mtpwifi
from mtpdef import *
import strutil
import struct
import time
import datetime
import sys
import os
import errno
import math
import traceback
import platform
import socket
import hashlib
from dlinkedlist import *
from collections import namedtuple
from applog import *
import rename
import ssdp
import subprocess
#
# constants
#
AIRMTPCMD_APP_VERSION = "1.1"
DEFAULT_MAX_KB_PER_GET_OBJECT_REQUEST = 1024 # 1MB - empirically tweaked to get max download performance from Nikon bodies (too large an xfer and Nikon bodies start intermittently dropping connections)
DEFAULT_MAX_KB_TO_BUFFER_FOR_GET_OBJECT_REQUESTS = 32768 # 32MB - max bytes we buffer before flushing what we have to disk
# values for g.fileTransferOrder
FILE_TRANSFER_ORDER_USER_CONFIGURED = 0
FILE_TRANSFER_ORDER_OLDEST_FIRST = 1
FILE_TRANSFER_ORDER_NEWEST_FIRST = 2
# values for g.cameraMake
CAMERA_MAKE_UNDETERMINED = 0
CAMERA_MAKE_NIKON = 1
CAMERA_MAKE_CANON = 2
CAMERA_MAKE_SONY = 3
# values for g.realtimeDownloadMethod
REALTIME_DOWNLOAD_METHOD_NIKON_EVENTS = 0
REALTIME_DOWNLOAD_METHOD_MTPOBJ_POLLING = 1
REALTIME_DOWNLOAD_METHOD_SONY_EXIT = 2
REALTIME_DOWNLOAD_METHOD_MAX = REALTIME_DOWNLOAD_METHOD_SONY_EXIT
# values for g.args['sonyuniquecmdsenable'] (bitmask)
SONY_UNQIUECMD_ENABLE_SENDING_MSG = 0x00000001
SONY_UNQIUECMD_ENABLE_UNKNOWN_CMD_1 = 0x00000002
SONY_UNQIUECMD_ENABLE_SAVING_PROCESS_CANCELLED_MSG = 0x00000004
#
# custom errno from app
#
ERRNO_CAMERA_COMMUNICATION_FAILURE = 4000 # general communication error with camera
ERRNO_CAMERA_UNEXPECTED_RESP_CODE = 4001 # an MTP request was successfully delivered but the camera responsed with response code that indicated failure
ERRNO_CAMERA_PROTOCOL_ERROR = 4002 # unexpected protocol event during PTP-IP exchange
ERRNO_SONY_REALTIME_ENTER_RETRY_LOOP = 4003 # not an error or exit - for Sony realtime operaiton we simply return to the session retry loop after a transfer operation
ERRNO_COULDNT_CONNECT_TO_CAMERA = 4004 # initial connection to camera was unsuccessful
# place all errors for which app should bypass retry invocations here, between ERRNO_FIRST_CUSTOM_DONT_RETRY and ERRNO_LAST_CUSTOM_DONT_RETRY
ERRNO_FIRST_CUSTOM_DONT_RETRY = 5000 # first custom errno that we trigger for which app should bypass any retry invocations
ERRNO_BAD_CMD_LINE_ARG = 5000 # bad command line argument specified
ERRNO_FILE_EXISTS_USER_SPECIFIED_EXIT = 5001 # a file exists that we were to write to and user configured app to exit when this occurs
ERRNO_DIFFERENT_CAMREA_DURING_RETRY = 5002 # during a retry invocation a different camera was discvered vs the original camera we found
ERRNO_NO_CAMERA_TRANSFER_LIST = 5003 # no camera transfer list available and user configured app to exit if the list is not available
ERRNO_NO_CARD_MEDIA_AVIALABLE = 5004 # second slot specified but camera only has 1 slot
ERRNO_MTP_OBJ_CACHE_VALIDATE_FAILED = 5005 # we're performing a (debug) validation of object cache and found a mismatch
ERRNO_DOWNLOAD_FILE_OP_FAILED = 5006 # create/append/write/close operation failed on file being downloaded
ERRNO_RENAME_ENGINE_PARSING_ERROR = 5007 # error parsing --dirnamespec or --filenamespec
ERRNO_REAL_TIME_CAPTURE_NOT_SUPPORTED = 5008 # realtime capture not supported on the camera we're connected to
ERRNO_TRANSFER_LIST_NOT_SUPPORTED = 5009 # camera doesn't support MTP transfer lists, which is used to download user-selected images in the camera
ERRNO_FILENAMESPEC_RESULT_EMPTY_STR = 5010 # the result of the user-specified --filenamespec resulted in an empty string
ERRNO_FILENAMESPEC_HAS_PATH_CHARACTERS = 5011 # the result of the user-specified --filenamespec had a path or path characters in it
ERRNO_DOWNLOADEXEC_LAUNCH_ERROR = 5012 # unable to launch app/script specified by --downloadexec
ERRNO_DOWNLOADEXEC_NON_ZERO_EXIT_CODE = 5013 # a launched '--downloadexec' app returned a non-zero result and user config was to exit on this case
ERRNO_LAST_CUSTOM_DONT_RETRY = 5099 # last custom errno that we trigger for which app should bypass any retry invocations
#
# structures
#
class DownloadStatsStruct:
def __init__(self):
self.countFilesSkippedDueToDownloadHistory = 0
self.countFilesSkippedDueToFileExistingLocally = 0
self.countFilesDownloaded = 0
self.totalBytesDownloaded = 0
self.totalDownloadTimeSecs = 0
def reportDownloadStats(self, fDontPrintStatsIfNoFilesDownloaded=False):
if g.dlstats.totalDownloadTimeSecs > 0: # avoid divide-by-zero in case no files downloaded
averageDownloadRateMbSec = g.dlstats.totalBytesDownloaded / g.dlstats.totalDownloadTimeSecs / 1048576
else:
averageDownloadRateMbSec = 0
if fDontPrintStatsIfNoFilesDownloaded and g.dlstats.countFilesDownloaded==0:
return
applog_i("\n{:d} files downloaded in {:.2f} seconds (Average Rate = {:.2f} MB/s)".format(g.dlstats.countFilesDownloaded, g.dlstats.totalDownloadTimeSecs, averageDownloadRateMbSec))
if g.dlstats.countFilesSkippedDueToDownloadHistory:
applog_i("{:d} previously-downloaded files skipped".format(g.dlstats.countFilesSkippedDueToDownloadHistory))
if g.dlstats.countFilesSkippedDueToFileExistingLocally:
applog_i("{:d} files skipped because they already existed in output directory".format(g.dlstats.countFilesSkippedDueToFileExistingLocally))
#
# stats structure used by the createMtpObjectXX methods
#
class CreateMtpObjectStatsStruct:
def __init__(self):
self.recursionNesting = 0
self.countObjectsProcessed = 0
self.countMtpObjectsAlreadyExisting = 0
self.countCacheHits = 0
class GlobalVarsStruct:
def __init__(self):
self.isWin32 = None # True if we're running on a Windows platform
self.isOSX = None # True if we're runnong on an OSX platform
self.isFrozen = None # True if we're running in a pyintaller frozen environment (ie, built as an executable)
self.args = None # dictionary of command-line arguments (generated by argparse)
self.appDir = None # directory where script is located. this path is used to store all metadata files, in case script is run in different working directory
self.appDataDir = None # directory where we keep app metadata
self.appStartTimeEpoch = None # time that application started
self.openSessionTimeEpoch = None # time when session started with camera
self.sessionId = None # MTP session ID
self.objfilter_dateStartEpoch = None # user-specified starting date filter. any file earlier than this will be filtered.
self.objfilter_dateEndEpoch = None # user-specified ending date filter. any file later than this will be filtered.
self.maxGetObjTransferSize = None # max size of MTP_OP_GetPartialObject requests
self.maxGetObjBufferSize = None # max amount of download file data we buffer before flushing
self.fileTransferOrder = None # FILE_TRANSFER_ORDER_* constant
self.socketPrimary = None
self.socketEvents = None
self.cameraMake = CAMERA_MAKE_UNDETERMINED
self.realtimeDownloadMethod = None
self.countCardsUsed = None # number of media cards that airmtp will be using/accessing this session
self.storageId = None
self.mtpStorageIds = None # list of storage IDs returned from MTP_OP_GetStorageIDs / parseMptStorageIds()
self.mtpDeviceInfo = None
self.mtpStorageInfoList = None
self.cameraLocalMetadataPathAndRootName = None # path+root name for all metadata files we associate with a specific model+serial number
self.lastFullMtpHandleListProcessedByBuildMtpObjects = None
self.fAllObjsAreFromCameraTransferList = False # True if buildMtpObjects() found and retrieved a transfer list from the camera (ie, user picked photos to download on camera)
self.fRetrievedMtpObjects = False # True if buildMtpObjects() has successfully completed this session
self.fRealTimeDownloadPhaseStarted = False # True if we've completed a "normal" mode transfer (or bypassed it by user config) and have started realtime image download
self.downloadHistoryDict = None # download history
self.downloadMtpFileObjects_LastMtpObjectDownload = None # MTP object last downloaded (either last completed or last we were working on)
self.countFilesDownloadedPersistentAcrossStatsReset = 0 # count of files downloaded this session, survives reset of DownloadStatsStruct
# download stats
self.dlstats = DownloadStatsStruct()
# exit cleanup tracking vars
self.filesToDeleteOnAppExit = []
#
# global vars
#
g = GlobalVarsStruct()
#
# global constant data
#
CmdLineActionToMtpTransferOpDict = {
'getfiles' : MTP_OP_GetObject, \
'getsmallthumbs' : MTP_OP_GetThumb, \
'getlargethumbs' : MTP_OP_GetLargeThumb \
}
#
# used to maintain information about current file being downloaded
# across any retry/resumption attempts
#
class PartialDownloadData():
def __init__(self):
self.bytesWritten = 0
self.downloadTimeSecs = 0
self.localFilenameWithoutPath = None
def getBytesWritten(self):
return self.bytesWritten
def addBytesWritten(self, moreBytesWritten):
self.bytesWritten += moreBytesWritten
def getDownloadTimeSecs(self):
return self.downloadTimeSecs
def addDownloadTimeSecs(self, moreTimeSecs):
self.downloadTimeSecs += moreTimeSecs
def getLocalFilenameWithoutPath(self):
return self.localFilenameWithoutPath
def setLocalFilenameWithoutPath(self, localFilenameWithoutPath):
self.localFilenameWithoutPath = localFilenameWithoutPath
#
# Main class to manage both individual MTP objects, including files, directories, etc.., plus collections of these objects
#
class MtpObject(LinkedListObj):
#
# class variables
#
__MtpObjects_LL_CaptureDateSorted = LinkedList() # link list of all MtpObject instances, sorted by capture date ([0] = oldest, [n-1]=newest)
__MtpObjects_ObjectHandleDict = {} # dictionary of all objects, keyed by object handle
_CountMtpObjectDirectories = 0 # number MtpObjects that represent directories
#
# instance variables (documentation only since variables don't need to be declared in Python)
# self.mtpObjectHandle: Handle by which camera references this object
# self.mtpObjectInfo: Structure containing information from MTP_OP_GetObjectInfo
# self.captureDateEpoch: self.mtpObjectInfo.captureDateStr converted to epoch time
# self.bInTransferList: TRUE if user selected this image for transfer in the camera
# self.bDownloadedThisSession TRUE if object has been downloaded successfully this session [for possible future retry logic, if implemented]
#
def __init__(self, mtpObjectHandle, mtpObjectInfo):
# init some instance vars
self.bInCameraTransferList = False
self.bDownloadedThisSession = False
self.partialDownloadData = None
# save handle and object info to instance vars
self.mtpObjectHandle = mtpObjectHandle
self.mtpObjectInfo = mtpObjectInfo
if isDebugLog():
applog_d("Creating MtpObject with the following mtpObjectInfo:\n" + str(self))
# calculate instance vars that are based on mtpObjectInfo data
self.captureDateEpoch = 0
if self.mtpObjectInfo.captureDateStr: # there is a non-empty capture date string
self.captureDateEpoch = mtpTimeStrToEpoch(self.mtpObjectInfo.captureDateStr)
else:
#
# Sony uses a date stamp for the filename of folders. Extract that as the date if this is a folder object. this is
# important because we rely on folder timestamps for the MTP object cache logic
#
if self.mtpObjectInfo.associationType == MTP_OBJASSOC_GenericFolder:
if len(self.mtpObjectInfo.filename)==10 and self.mtpObjectInfo.filename[4]=='-' and self.mtpObjectInfo.filename[7]=='-':
# capture date is in in YYYY-MM-DD (Sony uses this for folders)
self.captureDateEpoch = time.mktime( time.strptime(self.mtpObjectInfo.filename, "%Y-%m-%d"))
# make sure this object hasn't already been inserted
if MtpObject.objInList(self):
raise AssertionError("MtpObject: Attempting to insert mtpObjectHandle that's already in dictionary. newObj:\n{:s}, existingObj:\n{:s}".format(
str(self), str(MtpObject.__MtpObjects_ObjectHandleDict[self.mtpObjectHandle])))
# insert into capture-date sorted linked list
LinkedListObj.__init__(self, self.captureDateEpoch, MtpObject.__MtpObjects_LL_CaptureDateSorted)
# insert into object handle dictionary, which is used for quick lookups by object handle
MtpObject.__MtpObjects_ObjectHandleDict[self.mtpObjectHandle] = self
# update counts based on this object type
if self.mtpObjectInfo.associationType == MTP_OBJASSOC_GenericFolder:
MtpObject._CountMtpObjectDirectories += 1
def setAsDownloadedThisSession(self):
self.bDownloadedThisSession = True
def wasDownloadedThisSession(self):
return self.bDownloadedThisSession
def isPartialDownload(self):
return self.partialDownloadData != None
def partialDownloadObj(self):
if self.partialDownloadData:
return self.partialDownloadData
self.partialDownloadData = PartialDownloadData()
return self.partialDownloadData
def releasePartialDownloadObj(self):
if self.partialDownloadData:
self.partialDownloadData = None
def getImmediateDirectory(self): # gets immediate camera directory that this object is in. ex: "100NC1J4"
objHandleDirectory = self.mtpObjectInfo.parentObject
if not objHandleDirectory:
# no parent to this object
return ""
if objHandleDirectory not in MtpObject.__MtpObjects_ObjectHandleDict:
applog_d("getImmediateDirectory(): Unable to locate parent object for {:s}, parent=0x{:08x}".format(self.mtpObjectInfo.filename, objHandleDirectory))
return ""
dirObject = MtpObject.__MtpObjects_ObjectHandleDict[objHandleDirectory]
return dirObject.mtpObjectInfo.filename
def genFullPathStr(self): # builds full path string to this object on camera, including filename itself. Ex: "DCIM\100NC1J4\DSC_2266.NEF"
# full path built by walking up the parent object tree for this object, prepending the directory of each parent we find
pathStr = self.mtpObjectInfo.filename
objHandleAncestorDirectory = self.mtpObjectInfo.parentObject
loopIterationCounter_EndlessLoopProtectionFromCorruptList = 0
while (objHandleAncestorDirectory != 0):
if objHandleAncestorDirectory not in MtpObject.__MtpObjects_ObjectHandleDict:
# couldn't find next folder up. this shouldn't happen since we always pull down full directory tree for all objects
applog_d("genFullPathStr(): Unable to locate parent object for {:s}, parent=0x{:08x}".format(self.mtpObjectInfo.filename, objHandleDirectory))
return pathStr
dirObject = MtpObject.__MtpObjects_ObjectHandleDict[objHandleAncestorDirectory]
pathStr = dirObject.mtpObjectInfo.filename + "\\" + pathStr
objHandleAncestorDirectory = dirObject.mtpObjectInfo.parentObject
loopIterationCounter_EndlessLoopProtectionFromCorruptList += 1
if loopIterationCounter_EndlessLoopProtectionFromCorruptList >= 512:
# 512 is arbitrary. wouldn't expect cameras to have more than one or two directory levels
raise AssertionError("Endless loop detected while building directory chain for {:s}. Local list is corrupt".format(pathStr))
return pathStr
@classmethod
def getCount(cls): # returns count of objects
return MtpObject.__MtpObjects_LL_CaptureDateSorted.count()
@classmethod
def getOldest(cls): # returns oldest object in age collection
if MtpObject.__MtpObjects_LL_CaptureDateSorted.count(): # if list is not empty
return MtpObject.__MtpObjects_LL_CaptureDateSorted.head()
else:
return None
@classmethod
def getNewest(cls): # returns newest object in age collection
if MtpObject.__MtpObjects_LL_CaptureDateSorted.count(): # if list is not empty
return MtpObject.__MtpObjects_LL_CaptureDateSorted.tail()
else:
return None
def getNewer(self): # returns next newer object
return self.llNext()
def getOlder(self): # returns next older object
return self.llPrev()
@classmethod
def getByMtpObjectHandle(cls, mtpObjectHandle):
if mtpObjectHandle in MtpObject.__MtpObjects_ObjectHandleDict:
return MtpObject.__MtpObjects_ObjectHandleDict[mtpObjectHandle]
else:
return None
@classmethod
def objInList(cls, mtpObj):
return mtpObj.mtpObjectHandle in MtpObject.__MtpObjects_ObjectHandleDict
def __str__(self): # generates string description of object
s = "MtpObject instance = 0x{:08x}\n".format(id(self))
s += " mtpObjectHandle = 0x{:08x}\n".format(self.mtpObjectHandle)
s += " --- mptObjectInfo ---\n"
s += " storageId = " + getMtpStorageIdDesc(self.mtpObjectInfo.storageId) + "\n"
s += " objectFormat = " + getMtpObjFormatDesc(self.mtpObjectInfo.objectFormat) + "\n"
s += " protectionStatus = " + strutil.hexShort(self.mtpObjectInfo.protectionStatus) + "\n"
s += " compressedSize = " + strutil.hexWord(self.mtpObjectInfo.objectCompressedSize) + "\n"
s += " thumbFormat = " + getMtpObjFormatDesc(self.mtpObjectInfo.thumbFormat) + "\n"
s += " thumbCompressedSize= " + strutil.hexWord(self.mtpObjectInfo.thumbCompressedSize) + "\n"
s += " thumbPixDimensions = " + str(self.mtpObjectInfo.thumbPixWidth) + "x" + str(self.mtpObjectInfo.thumbPixHeight) + "\n"
s += " imagePixDimensions = " + str(self.mtpObjectInfo.imagePixWidth) + "x" + str(self.mtpObjectInfo.imagePixHeight) + "\n"
s += " imageBitDepth = " + str(self.mtpObjectInfo.imageBitDepth) + "\n"
s += " parentObject = " + strutil.hexWord(self.mtpObjectInfo.parentObject) + "\n"
s += " associationType = " + getObjAssocDesc(self.mtpObjectInfo.associationType) + "\n"
s += " associationDesc = " + strutil.hexWord(self.mtpObjectInfo.associationDesc) + "\n"
s += " sequenceNumber = " + strutil.hexWord(self.mtpObjectInfo.sequenceNumber) + "\n"
s += " filename = " + self.genFullPathStr() + "\n"
s += " captureDateSt = " + self.mtpObjectInfo.captureDateStr + "\n"
s += " modificationDateStr= " + self.mtpObjectInfo.modificationDateStr
return s
#
# resets all download statistics
#
def resetDownloadStats():
g.dlstats = DownloadStatsStruct()
#
# verifies user is running version a modern-enough version of python for this app
#
def verifyPythonVersion():
if sys.version_info.major == 2:
if sys.version_info.minor < 7:
applog_i("Warning: You are running a Python 2.x version older than app was tested with.")
applog_i("Version running is {:d}.{:d}.{:d}, app was tested on 2.7.x".format(sys.version_info.major, sys.version_info.minor, sys.version_info.micro))
elif sys.version_info.major == 3:
if sys.version_info.minor < 4:
applog_i("Warning: You are running a Python 3.x version older than app was tested with.")
applog_i("Version running is {:d}.{:d}.{:d}, app was tested on 3.4.x".format(sys.version_info.major, sys.version_info.minor, sys.version_info.micro))
#
# returns the number of seconds that have elapsed since
# the specified anchor time. if the anchor time is None
# then this routine returns the current time, which
# the caller can use for a subsequent call to get elapsed
# time. time values are floats
#
def secondsElapsed(timeAnchor):
timeCurrent = time.time()
if timeAnchor == None:
return timeCurrent
return timeCurrent - timeAnchor
#
# sets app-level globals related to the platform we're running under and
# creates path to app directories, creating them if necessary
#
def establishAppEnvironment():
g.isWin32 = (platform.system() == 'Windows')
g.isOSX = (platform.system() == 'Darwin')
g.isFrozen = (getattr(sys, 'frozen', False)) # note for OSX isFrozen is always false because py2app only marks airnef.pyw as frozen when we're a py2app
#
# determine the directory our script resides in, in case the
# user is executing from a different working directory.
#
g.appDir = os.path.dirname(os.path.realpath(sys.argv[0]))
#
# determine directory for our APPDATA, which contains log
# and configuration files. For Win32 if we're frozen this
# goes in the dedicated OS area for application data files
#
g.appDataDir = None
if g.isFrozen and g.isWin32:
if os.getenv('LOCALAPPDATA'):
g.appDataDir = os.path.join(os.getenv('LOCALAPPDATA'), "airmtp\\appdata") # typically C:\Users\<username>\AppData\Local\airnef\appdata
elif g.isOSX: # for OSX we always try to store our app data under Application Support
userHomeDir = os.getenv('HOME')
if userHomeDir:
applicationSupportDir = os.path.join(userHomeDir, 'Library/Application Support')
if os.path.exists(applicationSupportDir): # probably not necessary to check existence since every system should have this directory
g.appDataDir = os.path.join(applicationSupportDir, 'airmtp/appdata')
if not g.appDataDir:
# none of runtime-specific cases above selected an app data directory - use directory based off our app directory
g.appDataDir = os.path.join(g.appDir, "appdata")
# create our app-specific subdirectories if necessary
if not os.path.exists(g.appDataDir):
os.makedirs(g.appDataDir)
#
# transltes a date or date+time string from the user into an
# epoch time (ie, time in seconds).
#
def translateDateCmdLineArgToEpoch(cmdArgDesc, isInclusiveEndDate=False):
userDateTimeStr = g.args[cmdArgDesc]
if userDateTimeStr == None:
# user did not specify arg
return None
if userDateTimeStr.find(":") != -1:
# user specified date and time
strptimeTranslationStr = "%m/%d/%y %H:%M:%S"
bOnlyDateSpecified = False
else:
# user only specified time
strptimeTranslationStr = "%m/%d/%y"
bOnlyDateSpecified = True
try:
strptimeResult = time.strptime(userDateTimeStr, strptimeTranslationStr)
except ValueError as e:
applog_e("Date specified for \"--{:s}\" is \"{:s}\", which is formatted incorrectly or has an invalid date/time. It must be formatted as mm/dd/yy or mm/dd/yy hh:mm:ss (including leading zeros) and be a valid date/time.".\
format(cmdArgDesc, userDateTimeStr))
sys.exit(ERRNO_BAD_CMD_LINE_ARG)
timeEpoch = time.mktime(strptimeResult)
if bOnlyDateSpecified and isInclusiveEndDate:
timeEpoch += (23*60*60) + (59*60) + 59 # make end date inclusive by adding 23 hours, 59 minutes, 59 seconds to epoch time
return timeEpoch
#
# sets/changes the capture date filter to our app's start time. this is
# done to limit further downloads to those captured after the app started,
# such as for realtime capture
#
def changeCaptureDateFilterToAppStartTime():
#
# I've found a bug in the D7200 and D750 - it may exist on other cameras but I
# don't see it on a D7100 w/WU-1a nor a J5. The firmware will sometimes misreport
# the seconds field capture time in the MTP_OP_GetObjectInfo. It's a curious bug -
# the camera reports a seconds value that is half of the actual value. Here is an
# actual example taken from a D7200:
#
# Data returned from MTP_OP_GetObjectInfo:
#
# filename = DSC_0093.NEF
# captureDateSt = 20150927T185813
# modificationDateStr = 20150927T185813
#
# However when the file is viewed on the camera in playback mode the correctly
# displayed timestamp is 18:58:26, so the MTP seconds timestamp is half what it
# should be. When the issue does occur it appears to be a capture-time bug - the
# camera will always misreport the time on files that had the issue even
# if it's currently in a state where it doesn't have the issue (ie, where
# new files deliver the correct timestamp over MTP).
#
# Since we use capture-date filtering for our realtime capture having the time
# skew from this bug will cause us to think the image is older than it is, at least
# within the first minute of our session where the skew is enough to put the image
# before our appStartTimeEpoch. To work around this I adjust the capture-date filter
# back by 35 seconds from realtime (a few extra seconds padding just in case) - since
# the Nikon bug produces max skew of 30 seconds (for the case of the real timestamp
# being 59 seconds but the camera reports it as 29, since it rounds down). This runs
# the risk of us downloading images taken up to 35 seconds before the user launched
# airmtpcmd for realtime transfer (ie, images the user didn't want if he's running
# airmtpcmd in realtime-only mode)
#
g.objfilter_dateStartEpoch = g.appStartTimeEpoch - 35
g.objfilter_dateEndEpoch = None
#
# clears the capture date filters
#
def clearCaptureDateFilter():
g.objfilter_dateStartEpoch = None
g.objfilter_dateEndEpoch = None
#
# verifies that a command line integer value is within a defined range
#
def verifyIntegerArgRange(argNameStr, minAllowedValue, maxAllowedValue) :
if g.args[argNameStr] != None:
if g.args[argNameStr] < minAllowedValue or g.args[argNameStr] > maxAllowedValue:
applog_e("Invalid value for --{:s}: valid range is [{:d}-{:d}]".format(argNameStr, minAllowedValue, maxAllowedValue))
exitAfterCmdLineError(ERRNO_BAD_CMD_LINE_ARG)
#
# verifies that a command line argument is either an allowable
# string constant or a valid integer value. If validation fails
# then sys.exit() is called with ERRNO_BAD_CMD_LINE_ARG
#
def verifyIntegerArgStrOptions(argNameStr, validStrArgValues=None):
argValueStr = g.args[argNameStr]
if validStrArgValues and argValueStr in validStrArgValues:
# arg is one of the valid string values
return
try:
int(argValueStr)
except:
if validStrArgValues == None:
applog_e("Invalid integer value specified for --" + argNameStr)
else:
applog_e("Unknown value name or integer value specified for --" + argNameStr)
applog_e("Valid values are: {:s}".format(str(validStrArgValues)))
sys.exit(ERRNO_BAD_CMD_LINE_ARG)
#
# converts the values of a multiple-value argument ('nargs=+') to uppercase set
#
def convertMultipleArgToUppercaseSet(argNameStr):
if g.args[argNameStr]:
g.args[argNameStr] = set([ x.upper() for x in g.args[argNameStr] ])
#
# converts the values of a multiple-value argument ('nargs=+') to lowercase set
#
def convertMultipleArgToLowercaseSet(argNameStr):
if g.args[argNameStr]:
g.args[argNameStr] = set([ x.lower() for x in g.args[argNameStr] ])
#
# processCmdLine - Processes command line arguments
#
class ArgumentParserError(Exception): pass # from http://stackoverflow.com/questions/14728376/i-want-python-argparse-to-throw-an-exception-rather-than-usage
class ArgumentParserWithException(argparse.ArgumentParser):
def error(self, message):
raise ArgumentParserError(message)
#
# called when a command-line error is detected. shuts down the applog module
# and then invokves sys.exit() with specified error code
#
def exitAfterCmdLineError(errno):
shutdownApplog()
sys.exit(errno)
#
# used for a parser.add_argument "type=conver_int_auto_radix" instead of
# of "type=int". this version converts using a radix determined from the string rather
# than assumed base-10 for "type=int". this allows hex entries
#
def conver_int_auto_radix(argStr):
return int(argStr, 0)
def processCmdLine():
#
# note: if you add additional filter options/logic, go to realTimeCapture() to see if those options
# need to be overriden when we enter the realtime capture phase
#
parser = ArgumentParserWithException(fromfile_prefix_chars='!',\
formatter_class=argparse.RawDescriptionHelpFormatter,
description='Wifi image transfer utility for Nikon cameras (airnef@hotmail.com)',\
epilog="Options can also be specified from a file. Use !<filename>. Each word in the\nfile must be on its own line.\n\nYou "\
"can abbreviate any argument name provided you use enough characters to\nuniquely distinguish it from other argument names.\n"\
"\n\n"\
"Command-Line Examples:\n"\
" %(prog)s --extlist NEF MOV (download only raw images and MOV files)\n"\
" %(prog)s --downloadhistory ignore (dont skip files previously downloaded)")
parser.add_argument('--ipaddress', type=str.lower, help='IP address of camera. Default is "%(default)s"', default='192.168.1.1', metavar="addr", required=False)
parser.add_argument('--action', type=str.lower, choices=['getfiles', 'getsmallthumbs', 'getlargethumbs', 'listfiles'], help='Program action. Default is "%(default)s"', default='getfiles', required=False)
parser.add_argument('--realtimedownload', type=str.lower, choices=['disabled', 'afternormal', 'only'], help='Download images from camera in realtime as they\'re taken. \'afternormal\' means realtime capture starts after regular image download. \'only\' skips normal download and only captures realtime images. Default is "%(default)s"', default='disabled', required=False)
parser.add_argument('--extlist', help='Type of image/file(s) to download. Ex: \"--extlist NEF\". Multiple extensions can be specified. Use \"<NOEXT>\" to include files that don\'t have extensions. Default is to download all file types', default=None, nargs='+', metavar='extension', required=False)
parser.add_argument('--startdate', help='Only include image/file(s) captured on or later than date. Date-only Ex: --startdate 12/05/14. Date+Time Example: --startdate \"12/05/14 15:30:00\"', metavar="date", required=False)
parser.add_argument('--enddate', help='Only include image/file(s) captured on or earlier than date or date+time. Date without a specified time is inclusive, so for example --enddate 06/12/14 is interpreted as 06/12/14 23:59:59', metavar="date", required=False)
parser.add_argument('--outputdir', type=str, help='Directory to store image/file(s) to. Default is current directory. No ending backslash is necessary. If path contains any spaces enclose it in double quotes. Example: --outputdir \"c:\My Documents\"', default=None, metavar="path", required=False)
parser.add_argument('--ifexists', type=str.lower, choices=['uniquename', 'skip', 'overwrite', 'prompt', 'exit'], help='Action to take if file with same name already exists. Default is "%(default)s"', default='uniquename', required=False)
parser.add_argument('--downloadhistory', type=str.lower, choices=['skipfiles', 'ignore', 'clear' ], help='\'skipfiles\' means that files in history (ie, previously downloaded) will be skipped and not downloaded. Default is "%(default)s"', default='skipfiles', required=False)
parser.add_argument('--onlyfolders', help='Only include image/file(s) existing in specified camera folders.. Ex: \"--onlyfolders 100D7200 101D7200\". Default is to include all folders', default=None, nargs='+', metavar="camera_folder", required=False)
parser.add_argument('--excludefolders', help='Exclude image/file(s) existing in specified camera folders.. Ex: \"--excludefolders 103D7200\". Default is no exclusions.', default=None, nargs='+', metavar="camera_folder", required=False)
parser.add_argument('--filenamespec', type=str, help='Optionally rename files using dynamic renaming engine. See online help for documentation on \'spec\'', default=None, metavar="spec", required=False)
parser.add_argument('--dirnamespec', type=str, help='Optionally name directories using dynamic renaming engine. See online help for documentation on \'spec\'', default=None, metavar="spec", required=False)
parser.add_argument('--transferorder', type=str.lower, choices=['oldestfirst', 'newestfirst'], help='Transfer oldest or newest files first. Default is "%(default)s"', default='oldestfirst', required=False)
parser.add_argument('--slot', type=str.lower, help='Card slot on camera to read from. Default is "%(default)s", which means first populated slot', choices=['firstfound', 'first', 'second', 'both'], default='firstfound', required=False)
parser.add_argument('--cameratransferlist', type=str.lower, choices=['useifavail', 'exitifnotavail', 'ignore'], help='Decide how to handle images selected on camera. Default is "%(default)s"', default='useifavail', required=False)
parser.add_argument('--downloadexec', help='Launch application for each file downloaded', default=None, nargs='+', metavar=('executable', 'arguments'), required=False)
parser.add_argument('--downloadexec_extlist', help='Type of files(s) by extension on wich to perform --downloadexec on. Default is all file types', default=None, nargs='+', metavar='extension', required=False)
parser.add_argument('--downloadexec_options', help='Options for launcing application. For example \'wait\' waits for launched app to exit before proceeding to next download. See online help for more options', default=[], nargs='+', metavar='option', required=False)
parser.add_argument('--realtimepollsecs', type=int, help='How often camera is polled for new images in realtime mode, in seconds. Default is every %(default)s seconds', default=3, metavar="seconds", required=False)
parser.add_argument('--logginglevel', type=str.lower, choices=['normal', 'verbose', 'debug' ], help='Sets how much information is saved to the result log. Default is "%(default)s"', default='normal', required=False)
# hidden args (because they wont be used often and will complicate users learning the command line - they are documented online)
parser.add_argument('--connecttimeout', help=argparse.SUPPRESS, type=int, default=10, required=False)
parser.add_argument('--socketreadwritetimeout', help=argparse.SUPPRESS, type=int, default=5, required=False)
parser.add_argument('--retrycount', help=argparse.SUPPRESS, type=int, default=sys.maxsize, required=False)
parser.add_argument('--retrydelaysecs', help=argparse.SUPPRESS, type=int, default=5, required=False)
parser.add_argument('--printstackframes', help=argparse.SUPPRESS, type=str.lower, choices=['no', 'yes'], default='no', required=False)
parser.add_argument('--mtpobjcache', type=str.lower, choices=['enabled', 'writeonly', 'readonly', 'verify', 'disabled'], help=argparse.SUPPRESS, default='enabled', required=False)
parser.add_argument('--mtpobjcache_maxagemins', help=argparse.SUPPRESS, type=int, default=0, required=False) # default is 0=indefinite (never invalidate based on age)
parser.add_argument('--maxgetobjtransfersizekb', help=argparse.SUPPRESS, type=int, default=DEFAULT_MAX_KB_PER_GET_OBJECT_REQUEST, required=False)
parser.add_argument('--maxgetobjbuffersizekb', help=argparse.SUPPRESS, type=int, default=DEFAULT_MAX_KB_TO_BUFFER_FOR_GET_OBJECT_REQUESTS, required=False)
parser.add_argument('--initcmdreq_guid', help=argparse.SUPPRESS, type=str.lower, default='0x7766554433221100-0x0000000000009988', required=False) # GUID order in string is high-low
parser.add_argument('--initcmdreq_hostname', help=argparse.SUPPRESS, type=str, default='airmtp', required=False)
parser.add_argument('--initcmdreq_hostver', help=argparse.SUPPRESS, type=conver_int_auto_radix, default=0x00010000, required=False)
parser.add_argument('--opensessionid', help=argparse.SUPPRESS, type=conver_int_auto_radix, default=None, required=False)
parser.add_argument('--maxclockdeltabeforesync', help=argparse.SUPPRESS, type=str.lower, default='5', required=False)
parser.add_argument('--camerasleepwhendone', help=argparse.SUPPRESS, type=str.lower, choices=['no', 'yes'], default='yes', required=False)
parser.add_argument('--sonyuniquecmdsenable', help=argparse.SUPPRESS, type=conver_int_auto_radix, default=SONY_UNQIUECMD_ENABLE_SENDING_MSG, required=False)
parser.add_argument('--suppressdupconnecterrmsgs', help=argparse.SUPPRESS, type=str.lower, choices=['no', 'yes'], default='yes', required=False)
parser.add_argument('--rtd_pollingmethod', help=argparse.SUPPRESS, type=int, default=None, required=False)
parser.add_argument('--rtd_mtppollingmethod_newobjdetection', help=argparse.SUPPRESS, type=str.lower, choices=['objlist', 'numobjs'], default='objlist', required=False)
parser.add_argument('--rtd_maxsecsbeforeforceinitialobjlistget', help=argparse.SUPPRESS, type=int, default=5, required=False)
parser.add_argument('--ssdp_discoveryattempts', help=argparse.SUPPRESS, type=int, default=3, required=False)
parser.add_argument('--ssdp_discoverytimeoutsecsperattempt', help=argparse.SUPPRESS, type=int, default=2, required=False)
parser.add_argument('--ssdp_discoveryflags', help=argparse.SUPPRESS, type=conver_int_auto_radix, default=None, required=False)
parser.add_argument('--ssdp_addservice', help=argparse.SUPPRESS, nargs='+', required=False, default=None)
parser.add_argument('--ssdp_addmulticastif', help=argparse.SUPPRESS, nargs='+', required=False, default=None)
#
# if there is a default arguments file present, add it to the argument list so that parse_args() will process it
#
defaultArgFilename = os.path.join(g.appDir, "airmtpcmd-defaultopts")
if os.path.exists(defaultArgFilename):
sys.argv.insert(1, "!" + defaultArgFilename) # insert as first arg (past script name), so that the options in the file can still be overriden by user-entered cmd line options
# perform the argparse
try:
args = vars(parser.parse_args())
except ArgumentParserError as e:
applog_e("Command line error: " + str(e))
exitAfterCmdLineError(ERRNO_BAD_CMD_LINE_ARG)
# set our global var to the processed argument list and log them
g.args = args
#
# process any args that need verification/translation/conversion
#
# convert all arguments that have multiple values to uppercase/lowercase sets
convertMultipleArgToUppercaseSet('extlist')
convertMultipleArgToUppercaseSet('onlyfolders')
convertMultipleArgToUppercaseSet('excludefolders')
convertMultipleArgToLowercaseSet('downloadexec_options')
convertMultipleArgToUppercaseSet('downloadexec_extlist')
if not g.args['outputdir']:
if not g.args['dirnamespec']:
# neither 'outputdir' nor 'dirnamespec' specified - use current directory
g.args['outputdir'] = '.\\' if g.isWin32 else './'
else:
# 'dirnamespec' specified - use empty string for base output dir
g.args['outputdir'] = ""
# verify syntax of --filenamespec and --dirnamespec
if g.args['filenamespec']:
try:
rename.verifyRenameFormatStringSyntax(g.args['filenamespec'])
except rename.GenerateReplacementNameException as e:
applog_e("Error parsing filenamespec: " + str(e))
exitAfterCmdLineError(ERRNO_RENAME_ENGINE_PARSING_ERROR)
if g.args['dirnamespec']:
try:
rename.verifyRenameFormatStringSyntax(g.args['dirnamespec'])
except rename.GenerateReplacementNameException as e:
applog_e("Error parsing dirnamespec: " + str(e))
exitAfterCmdLineError(ERRNO_RENAME_ENGINE_PARSING_ERROR)
# verify syntax of --downloadexec
if g.args['downloadexec']:
try:
for argNumber, arg in enumerate(g.args['downloadexec']):
rename.verifyRenameFormatStringSyntax(arg)
except rename.GenerateReplacementNameException as e:
applog_e("Error parsing downloadexec arg #{:d} \"{:s}\": {:s}".format(argNumber+1, arg, str(e)))
exitAfterCmdLineError(ERRNO_RENAME_ENGINE_PARSING_ERROR)
# verify --downloadexec_options
validDownloadExecOptions = [ 'ignorelauncherror', 'wait', 'exitonfailcode', 'delay', 'notildereplacement' ]
for arg in g.args['downloadexec_options']:
if arg not in validDownloadExecOptions:
applog_e("Unrecognized option \"{:s}\" for --downloadexec_options".format(arg))
exitAfterCmdLineError(ERRNO_BAD_CMD_LINE_ARG)
if g.args['action'] not in CmdLineActionToMtpTransferOpDict:
#
# action is not a download operation. change/disable other command
# line arguments that aren't valid for non-download actions
#
g.args['realtimedownload'] = 'disabled'
if g.args['realtimedownload'] != 'only':
# regular image download enabled. process any start/end date filters that user specified
g.objfilter_dateStartEpoch = translateDateCmdLineArgToEpoch('startdate')
g.objfilter_dateEndEpoch = translateDateCmdLineArgToEpoch('enddate', isInclusiveEndDate=True)
# else we'll set the capture date filters later for realtime operation
g.fileTransferOrder = FILE_TRANSFER_ORDER_OLDEST_FIRST if g.args['transferorder']=='oldestfirst' else FILE_TRANSFER_ORDER_NEWEST_FIRST
g.maxGetObjTransferSize = g.args['maxgetobjtransfersizekb'] * 1024
g.maxGetObjBufferSize = g.args['maxgetobjbuffersizekb'] * 1024
verifyIntegerArgStrOptions('maxclockdeltabeforesync', ['disablesync', 'alwayssync'])
verifyIntegerArgRange('rtd_pollingmethod', 0, REALTIME_DOWNLOAD_METHOD_MAX)
# process any args that require action now
if g.args['logginglevel'] == 'normal':
# we've already set to this default
pass
elif g.args['logginglevel'] == 'verbose':
applog_set_loggingFlags(APPLOGF_LEVEL_INFORMATIONAL | APPLOGF_LEVEL_ERROR | APPLOGF_LEVEL_WARNING | APPLOGF_LEVEL_VERBOSE)
elif g.args['logginglevel'] == 'debug':
applog_set_loggingFlags(APPLOGF_LEVEL_INFORMATIONAL | APPLOGF_LEVEL_ERROR | APPLOGF_LEVEL_WARNING | APPLOGF_LEVEL_VERBOSE | APPLOGF_LEVEL_DEBUG)
# log the cmd line arguments
applog_d("Orig cmd line: {:s}".format(str(sys.argv)))
applog_d("Processed cmd line: {:s}".format(str(g.args)))
#
# Converts counted utf-16 MTP string to unicode string
#
def mtpCountedUtf16ToPythonUnicodeStr(data):
# format of string: first byte has character length of string inlcuding NULL (# bytes / 2)
if not data:
return "", 0
(utf16CharLenIncludingNull,) = struct.unpack('<B', data[0:1])
if utf16CharLenIncludingNull == 0:
# count byte of zero indicates no string.
return "", 1
utf16ByteLenIncludingNull = utf16CharLenIncludingNull*2
unicodeStr = six.text_type(data[1:1+utf16ByteLenIncludingNull-2], 'utf-16')
# some Nikon strings have trailing NULLs for padding - remove them
for charPos in reversed(xrange(len(unicodeStr))):
if unicodeStr[charPos] != '\x00':
break;
unicodeStr = unicodeStr[0:charPos+1] # ok if original string was null-only string
return unicodeStr, 1+utf16ByteLenIncludingNull # 1+ for first byte containing character length
#
# Removes specified leading characters from string
#
def removeLeadingCharsFromStr(str, charsToRemoveSet):
for charPos in xrange(len(str)):
if str[charPos] not in charsToRemoveSet:
break;
return str[charPos:]
#
# Converts a raw MTP-obtained counted array of values into a list format
# of string: first word has count of entries, followed by array of entries,
# each of which is 'elementSizeInBytes' in size. Returns (list, bytesConsumedFromData),
# where 'list' is the list of entries and 'bytesConsumedFromData' is the number of
# bytes used from 'data' to generate the list
#
def parseMtpCountedList(data, elementSizeInBytes):
elementSizeToUnpackStr = { 1 : 'B', 2 : 'H', 4 : 'I' }
theList = list()
(countEntries,) = struct.unpack('<I', data[0:4])
offset = 4
for entryIndex in xrange(countEntries):
(entry,) = struct.unpack('<' + elementSizeToUnpackStr[elementSizeInBytes], data[offset:offset+elementSizeInBytes])
offset += elementSizeInBytes
theList.append(entry)
return theList, countEntries*elementSizeInBytes + 4 # +4 to include count field itself
def parseMtpCountedWordList(data):
return parseMtpCountedList(data, 4)
def parseMtpCountedHalfwordList(data):
return parseMtpCountedList(data, 2)
#
# parses the raw data from MTP_OP_GetStorageIDs into a MptStorageIds tuple
#
def parseMptStorageIds(data):
(storageIdsList,bytesConsumed) = parseMtpCountedWordList(data)
return MptStorageIdsTuple(storageIdsList)
#
# parses the raw data from MTP_OP_GetStorageInfo into a MtpStorageInfo tuple
#
def parseMtpStorageInfo(data):
(storageType,fileSystemType,accessCapability,maxCapacityBytes,freeSpaceBytes,freeSpaceInImages,storageDescription) = struct.unpack('<HHHQQIB', data[0:27])
(volumeLabel,byteLen) = mtpCountedUtf16ToPythonUnicodeStr(data[27:])
return MtpStorageInfoTuple(storageType, fileSystemType, accessCapability, maxCapacityBytes,
freeSpaceBytes, freeSpaceInImages, storageDescription, volumeLabel)
#
# parses the raw data from MTP_OP_GetDeviceInfo into a MtpDeviceInfo tuple
#
def parseMtpDeviceInfo(data):
(standardVersion, vendorExtensionID, vendorExtensionVersion) = struct.unpack('<HIH', data[0:8])
offset = 8
(vendorExtensionDescStr,bytesConsumed) = mtpCountedUtf16ToPythonUnicodeStr(data[offset:])
offset += bytesConsumed
offset += 2 # skip 'FunctionalMode' field
(operationsSupportedList,bytesConsumed) = parseMtpCountedHalfwordList(data[offset:])
offset += bytesConsumed
(eventsSupportedList,bytesConsumed) = parseMtpCountedHalfwordList(data[offset:])
offset += bytesConsumed
(devicePropertiesSupportedList,bytesConsumed) = parseMtpCountedHalfwordList(data[offset:])
offset += bytesConsumed
(captureFormatsSupportedList,bytesConsumed) = parseMtpCountedHalfwordList(data[offset:])
offset += bytesConsumed
(imageFormatsSupportedList,bytesConsumed) = parseMtpCountedHalfwordList(data[offset:])
offset += bytesConsumed
(manufacturerStr,byteLen) = mtpCountedUtf16ToPythonUnicodeStr(data[offset:])
offset += byteLen
(modelStr,byteLen) = mtpCountedUtf16ToPythonUnicodeStr(data[offset:])
offset += byteLen
(deviceVersionStr,byteLen) = mtpCountedUtf16ToPythonUnicodeStr(data[offset:])
offset += byteLen
(serialNumberStr,byteLen) = mtpCountedUtf16ToPythonUnicodeStr(data[offset:])
serialNumberStr = removeLeadingCharsFromStr(serialNumberStr, {' ', '0'}) # remove leading spaces/zeros from serial number
return MtpDeviceInfoTuple( standardVersion, vendorExtensionID, vendorExtensionVersion, vendorExtensionDescStr,\
set(operationsSupportedList), set(eventsSupportedList), set(devicePropertiesSupportedList), \
set(captureFormatsSupportedList), set(imageFormatsSupportedList), manufacturerStr, \
modelStr, deviceVersionStr, serialNumberStr)
#
# called within application wait loops, this method makes
# sure we keep the MTP session alive by periodically sending
# an MTP request. without such requests many cameras will
# drop the MTP session, including Nikon cameras. the first
# call to this function should be with None - the function
# will return an opaque value that is actually a timestamp
# of when we were last called. the caller should
# periodically call this function thereafter with whatever
# return value we last give it - when enough time has elapsed
# we'll send an MTP command to keep the session alive
#
def mtpSessionKeepAlive(timeProbeLastSent):
timeCurrent = time.time()
if timeProbeLastSent == None:
return timeCurrent
if timeCurrent - timeProbeLastSent < 5:
return timeProbeLastSent
#
# send an MTP command to keep session alive. I wanted to use
# sendProbeRequest() since it's lightweight and seemingly made
# for that purpose but Sony cameras will still time'out
# the session if we just send those
#
mtpwifi.execMtpOp(g.socketPrimary, MTP_OP_GetDeviceInfo)
return timeCurrent
#
# parses the raw data from MTP_OP_GetObjectInfo into a MtpObjectInfo tuple
#
def parseMtpObjectInfo(data):
(storageId, objectFormat, protectionStatus) = struct.unpack('<IHH', data[0:8])
(objectCompressedSize, thumbFormat, thumbCompressedSize) = struct.unpack('<IHI', data[8:18])
(thumbPixWidth, thumbPixHeight, imagePixWidth, imagePixHeight) = struct.unpack('<IIII', data[18:34])
(imageBitDepth, parentObject, associationType) = struct.unpack('<IIH', data[34:44])
(associationDesc, sequenceNumber) = struct.unpack('<II', data[44:52])
offset = 52
(filename, bytesConsumed) = mtpCountedUtf16ToPythonUnicodeStr(data[offset:])
offset = offset + bytesConsumed
(captureDateStr, bytesConsumed) = mtpCountedUtf16ToPythonUnicodeStr(data[offset:])
captureDateStr = captureDateStr[:15] # Canon adds a ".0"... to the capture date/time - trim that off
offset = offset + bytesConsumed
(modificationDateStr, bytesConsumed) = mtpCountedUtf16ToPythonUnicodeStr(data[offset:])
modificationDateStr = modificationDateStr[:15] # Canon adds a ".0"... to the modification date/time - trim that off
return MtpObjectInfoTuple( storageId, objectFormat, protectionStatus, \
objectCompressedSize, thumbFormat, thumbCompressedSize, \
thumbPixWidth, thumbPixHeight, imagePixWidth, imagePixHeight, \
imageBitDepth, parentObject, associationType, \
associationDesc, sequenceNumber, filename, \
captureDateStr, modificationDateStr)
#
# closes TCP/IP connection sockets to camera's MTP interface
#
def closeSockets():
if g.socketPrimary:
g.socketPrimary.close()
g.socketPrimary = None
if g.socketEvents:
g.socketEvents.close()
g.socketEvents = None
#
# converts a GUID string to a pair of 64-bit values (high/low).
# the following string formats are support:
# xxxxxxxx presumed hex characters specifying lower 64-bit of GUID
# xxxxxxxx-yyyyyyyy presumed hex characters specifying both high (xx) and low (yy) parts of 128-bit GUID
# xx:xx:xx:xx:xx:xx presumed MAC address consisting of hex characters (for Sony)
#
def convertGuidStrToLongs(guidHexStr):
if guidHexStr.find(':') != -1:
#
# GUID specified as a MAC address (for Sony). Sony cameras can either operate
# in a host-selective mode where they'll only accept connections from a specific MAC
# address or in a mode where they'll accept any GUID as long as the lower 6-bytes
# of the GUID corresponding to area Sony uses to identify the MAC address are zero.
# the logic here is when the user wants to run in the host-selective mode - it's
# pretty much only for debugging.
#
# string are implicitly big endian. build as little endian and then convert
# to big endian if necessary when done if we're running on a big-endian platform
#
macAddressFieldList = guidHexStr.split(':')
guidLow = 0x0000000000000000
for nthField in xrange(len(macAddressFieldList)):
guidLow |= int(macAddressFieldList[nthField], 16) << nthField*8
if len(macAddressFieldList) == 6:
# Sony preprends 0xFFFF to MAC addr to form the full 64-bit lower GUID
guidLow = (guidLow<<16) | 0xFFFF
if sys.byteorder == 'big':
guidLow = strutil.invertEndian(guidLow)
guidHigh = 0x0000000000000000
else:
posColon = guidHexStr.find('-')