1#!/usr/bin/env python3
2
3# Copyright (C) 2015-2020 Damon Lynch <damonlynch@gmail.com>
4# Copyright (C) 2012-2015 Jim Easterbrook <jim@jim-easterbrook.me.uk>
5
6# This file is part of Rapid Photo Downloader.
7#
8# Rapid Photo Downloader is free software: you can redistribute it and/or
9# modify it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# Rapid Photo Downloader is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with Rapid Photo Downloader.  If not,
20# see <http://www.gnu.org/licenses/>.
21
22__author__ = 'Damon Lynch'
23__copyright__ = "Copyright 2015-2020, Damon Lynch. Copyright 2012-2015 Jim Easterbrook."
24
25import logging
26import os
27import io
28from collections import namedtuple
29import re
30from typing import Optional, List, Tuple, Union
31
32import gphoto2 as gp
33from raphodo.storage import StorageSpace
34from raphodo.constants import CameraErrorCode
35from raphodo.utilities import format_size_for_user
36
37
38def python_gphoto2_version():
39    return  gp.__version__
40
41
42def gphoto2_version():
43    return gp.gp_library_version(0)[0]
44
45
46def gphoto2_python_logging():
47    """
48    Version 2.0.0 of gphoto2 introduces a COMPATIBILITY CHANGE:
49    gp_log_add_func and use_python_logging now return a
50    Python object which must be stored until logging is no longer needed.
51    Could just go with the None returned by default from a function that
52    returns nothing, but want to make this explicit.
53
54    :return: either True or a Python object that must be stored until logging
55     is no longer needed
56    """
57
58    return gp.use_python_logging() or True
59
60
61def autodetect_cameras(context: gp.Context,
62                       suppress_errors: bool=True) -> Union[gp.CameraList, List]:
63    """
64    Do camera auto detection for multiple versions of gphoto2-python
65
66    Version 2.2.0 of gphoto2 introduces a COMPATIBILITY CHANGE:
67    Removed Context.camera_autodetect method.
68    Was quickly reintroduced in 2.2.1, but is due for removal.
69
70    :return: CameraList of model and port
71    """
72
73    try:
74        return gp.check_result(gp.gp_camera_autodetect(context))
75    except Exception:
76        if not suppress_errors:
77            raise
78        return []
79
80
81# convert error codes to error names
82gphoto2_error_codes = {
83    code: name for code, name in (
84        ((getattr(gp, attr), attr) for attr in dir(gp) if attr.startswith('GP_ERROR'))
85    )
86}
87
88
89def gphoto2_named_error(code: int) -> str:
90    return gphoto2_error_codes.get(code, 'Unknown gphoto2 error')
91
92
93class CameraError(Exception):
94    def __init__(self, code: CameraErrorCode) -> None:
95        self.code = code
96
97    def __repr__(self) -> str:
98        if self.code == CameraErrorCode.inaccessible:
99            return "inaccessible"
100        else:
101            return "locked"
102
103    def __str__(self) -> str:
104        if self.code == CameraErrorCode.inaccessible:
105            return "The camera is inaccessible"
106        else:
107            return "The camera is locked"
108
109
110class CameraProblemEx(CameraError):
111    def __init__(self, code: CameraErrorCode,
112                 gp_exception: Optional[gp.GPhoto2Error]=None,
113                 py_exception: Optional[Exception]=None) -> None:
114        super().__init__(code)
115        if gp_exception is not None:
116            self.gp_code = gp_exception.code
117        else:
118            self.gp_code = None
119        self.py_exception = py_exception
120
121    def __repr__(self) -> str:
122        if self.code == CameraErrorCode.read:
123            return "read error"
124        elif self.code == CameraErrorCode.write:
125            return 'write error'
126        else:
127            return repr(super())
128
129    def __str__(self) -> str:
130        if self.code == CameraErrorCode.read:
131            return "Could not read file from camera"
132        elif self.code == CameraErrorCode.write:
133            return 'Could not write file from camera'
134        else:
135            return str(super())
136
137
138def generate_devname(camera_port: str) -> Optional[str]:
139    """
140     Generate udev DEVNAME.
141
142     >>> generate_devname('usb:001,003')
143     '/dev/bus/usb/001/003'
144
145     >>> generate_devname('usb::001,003')
146
147    :param camera_port:
148    :return: devname if it could be generated, else None
149    """
150
151    match = re.match('usb:([0-9]+),([0-9]+)', camera_port)
152    if match is not None:
153        p1, p2 = match.groups()
154        return '/dev/bus/usb/{}/{}'.format(p1, p2)
155    return None
156
157
158class Camera:
159
160    """Access a camera via libgphoto2."""
161
162    def __init__(self, model: str,
163                 port:str,
164                 get_folders: bool=True,
165                 raise_errors: bool=False,
166                 context: gp.Context=None,
167                 specific_folders: Optional[List[str]]=None) -> None:
168        """
169        Initialize a camera via libgphoto2.
170
171        :param model: camera model, as returned by camera_autodetect() or
172         gp_camera_autodetect()
173        :param port: camera port, as returned by camera_autodetect()
174        :param get_folders: whether to detect the DCIM folders on the
175         camera
176        :param raise_errors: if True, if necessary free camera,
177         and raise error that occurs during initialization
178        :param specific_folders: folders such as DCIM,  PRIVATE,
179         and MP_ROOT that are searched for if get_folders is True.
180         If None, the root level folders are returned -- one for each
181         storage slot.
182        """
183
184        self.model = model
185        self.port = port
186        # class method _concise_model_name discusses why a display name is
187        # needed
188        self.display_name = model
189        self.camera_config = None
190
191        if context is None:
192            self.context = gp.Context()
193        else:
194            self.context = context
195
196        self._select_camera(model, port)
197
198        self.specific_folders = None  # type: Optional[List[str]]
199        self.specific_folder_located = False
200        self._dual_slots_active = False
201
202        self.storage_info = []
203
204        self.camera_initialized = False
205        try:
206            self.camera.init(self.context)
207            self.camera_initialized = True
208        except gp.GPhoto2Error as e:
209            if e.code == gp.GP_ERROR_IO_USB_CLAIM:
210                error_code = CameraErrorCode.inaccessible
211                logging.error("{} is already mounted".format(model))
212            elif e.code == gp.GP_ERROR:
213                logging.error("An error occurred initializing the camera using libgphoto2")
214                error_code = CameraErrorCode.inaccessible
215            else:
216                logging.error("Unable to access camera: %s", gphoto2_named_error(e.code))
217                error_code = CameraErrorCode.locked
218            if raise_errors:
219                raise CameraProblemEx(error_code, gp_exception=e)
220            return
221
222        concise_model_name = self._concise_model_name()
223        if concise_model_name:
224            self.display_name = concise_model_name
225
226        if get_folders:
227            try:
228                self.specific_folders = self._locate_specific_folders(
229                    path='/', specific_folders=specific_folders
230                )
231                self.specific_folder_located = len(self.specific_folders) > 0
232
233                logging.debug(
234                    "Folders located on %s: %s", self.display_name,
235                    ', '.join(', '.join(map(str, sl)) for sl in self.specific_folders)
236                )
237            except gp.GPhoto2Error as e:
238                logging.error(
239                    "Unable to access camera %s: %s. Is it locked?",
240                    self.display_name, gphoto2_named_error(e.code)
241                )
242                if raise_errors:
243                    self.free_camera()
244                    raise CameraProblemEx(CameraErrorCode.locked, gp_exception=e)
245
246        self.folders_and_files = []
247        self.audio_files = {}
248        self.video_thumbnails = []
249        abilities = self.camera.get_abilities()
250        self.can_fetch_thumbnails = abilities.file_operations & gp.GP_FILE_OPERATION_PREVIEW != 0
251
252    def camera_has_folders_to_scan(self) -> bool:
253        """
254        Check whether the camera has been initialized and if a DCIM or other specific folder
255        has been located
256
257        :return: True if the camera is initialized and a DCIM or other specific folder has
258                 been located
259        """
260        return self.camera_initialized and self.specific_folder_located
261
262    def _locate_specific_folders(self,
263                     path: str,
264                     specific_folders: Optional[List[str]]) -> List[Optional[List[str]]]:
265        """
266        Scan camera looking for folders such as DCIM,  PRIVATE, and MP_ROOT.
267
268        Looks in either the root of the path passed, or in one of the root
269        folders subfolders (it does not scan subfolders of those subfolders).
270
271        Returns all instances of the specific folders, which is helpful for
272        cameras that have more than one storage (memory card / internal memory)
273        slot.
274
275        No error checking: exceptions must be caught by the caller
276
277        :param path: the root folder to start scanning in
278        :param specific_folders: the subfolders to look for. If None, return the
279         root of each storage device
280        :return: the paths including the specific folders (if found), or empty list
281        """
282
283        # turn list of two items into a dictionary, for easier access
284        # no error checking as exceptions are caught by the caller
285        folders = dict(self.camera.folder_list_folders(path, self.context))
286
287        if specific_folders is None:
288            found_folders = [[path + folder] for folder in folders]
289        else:
290            found_folders = []
291
292            # look for the folders one level down from the root folder
293            # it is at this level that specific folders like DCIM will be found
294            for subfolder in folders:
295                subpath = os.path.join(path, subfolder)
296                subfolders = dict(self.camera.folder_list_folders(subpath, self.context))
297                ff = [
298                    os.path.join(subpath, folder) for folder in specific_folders
299                    if folder in subfolders
300                ]
301                if ff:
302                    found_folders.append(ff)
303
304        self._dual_slots_active = len(found_folders) > 1
305
306        return found_folders
307
308    def get_file_info(self, folder, file_name) -> Tuple[int, int]:
309        """
310        Returns modification time and file size
311
312        :type folder: str
313        :type file_name: str
314        :param folder: full path where file is located
315        :param file_name:
316        :return: tuple of modification time and file size
317        """
318        info = self.camera.file_get_info(folder, file_name, self.context)
319        modification_time = info.file.mtime
320        size = info.file.size
321        return modification_time, size
322
323    def get_exif_extract(self, folder: str,
324                         file_name: str,
325                         size_in_bytes: int=200) -> bytearray:
326        """"
327        Attempt to read only the exif portion of the file.
328
329        Assumes exif is located at the beginning of the file.
330        Use the result like this:
331        metadata = GExiv2.Metadata()
332        metadata.open_buf(buf)
333
334        :param folder: directory on the camera the file is stored
335        :param file_name: the photo's file name
336        :param size_in_bytes: how much of the photo to read, starting
337         from the front of the file
338        """
339
340        buffer = bytearray(size_in_bytes)
341        try:
342            self.camera.file_read(
343                folder, file_name, gp.GP_FILE_TYPE_NORMAL, 0, buffer, self.context
344            )
345        except gp.GPhoto2Error as e:
346            logging.error(
347                "Unable to extract portion of file from camera %s: %s",
348                self.display_name, gphoto2_named_error(e.code)
349            )
350            raise CameraProblemEx(code=CameraErrorCode.read, gp_exception=e)
351        else:
352            return buffer
353
354    def get_exif_extract_from_jpeg(self, folder: str, file_name: str) -> bytearray:
355        """
356        Extract strictly the app1 (exif) section of a jpeg.
357
358        Uses libgphoto2 to extract the exif header.
359
360        Assumes jpeg on camera is straight from the camera, i.e. not
361        modified by an exif altering program off the camera.
362
363        :param folder: directory on the camera where the jpeg is stored
364        :param file_name: name of the jpeg
365        :return: first section of jpeg such that it can be read by
366         exiv2 or similar
367
368        """
369
370        camera_file = self._get_file(folder, file_name, None, gp.GP_FILE_TYPE_EXIF)
371
372        try:
373            exif_data = gp.check_result(gp.gp_file_get_data_and_size(camera_file))
374        except gp.GPhoto2Error as ex:
375            logging.error(
376                'Error getting exif info for %s from camera %s: %s',
377                os.path.join(folder, file_name), self.display_name, gphoto2_named_error(ex.code)
378            )
379            raise CameraProblemEx(code=CameraErrorCode.read, gp_exception=ex)
380        return bytearray(exif_data)
381
382    def get_exif_extract_from_jpeg_manual_parse(self, folder: str,
383                                            file_name: str) -> Optional[bytes]:
384        """
385        Extract exif section of a jpeg.
386
387        I wrote this before I understood that libpghoto2 provides the
388        same functionality!
389
390        Reads first few bytes of jpeg on camera to determine the
391        location and length of the exif header, then reads in the
392        header.
393
394        Assumes jpeg on camera is straight from the camera, i.e. not
395        modified by an exif altering program off the camera.
396
397        :param folder: directory on the camera where the jpeg is stored
398        :param file_name: name of the jpeg
399        :return: first section of jpeg such that it can be read by
400         exiv2 or similar
401
402        """
403
404        # Step 1: determine the location of APP1 in the jpeg file
405        # See http://dev.exiv2.org/projects/exiv2/wiki/The_Metadata_in_JPEG_files
406
407        soi_marker_length = 2
408        marker_length = 2
409        exif_header_length = 8
410        read0_size = soi_marker_length + marker_length + exif_header_length
411
412        view = memoryview(bytearray(read0_size))
413        try:
414            bytes_read = gp.check_result(self.camera.file_read(
415                folder, file_name, gp.GP_FILE_TYPE_NORMAL,
416                0, view, self.context))
417        except gp.GPhoto2Error as ex:
418            logging.error(
419                'Error reading %s from camera: %s',
420                os.path.join(folder, file_name), gphoto2_named_error(ex.code)
421            )
422            return None
423
424        jpeg_header = view.tobytes()
425        view.release()
426
427        if jpeg_header[0:2] != b'\xff\xd8':
428            logging.error("%s not a jpeg image: no SOI marker", file_name)
429            return None
430
431        app_marker = jpeg_header[2:4]
432
433        # Step 2: handle presence of APP0 - it's optional
434        if app_marker == b'\xff\xe0':
435            # There is an APP0 before the probable APP1
436            # Don't neeed the content of the APP0
437            app0_data_length = jpeg_header[4] * 256 + jpeg_header[5]
438            # We've already read twelve bytes total, going into the APP1 data.
439            # Now we want to download the rest of the APP1, along with the app0 marker
440            # and the app0 exif header
441            read1_size = app0_data_length + 2
442            app0_view = memoryview(bytearray(read1_size))
443            try:
444                bytes_read = gp.check_result(self.camera.file_read(
445                    folder, file_name, gp.GP_FILE_TYPE_NORMAL,
446                    read0_size, app0_view, self.context))
447            except gp.GPhoto2Error as ex:
448                logging.error(
449                    'Error reading %s from camera: %s',
450                    os.path.join(folder, file_name), gphoto2_named_error(ex.code)
451                )
452            app0 = app0_view.tobytes()
453            app0_view.release()
454            app_marker = app0[(exif_header_length + 2) * -1:exif_header_length * -1]
455            exif_header = app0[exif_header_length * -1:]
456            jpeg_header = jpeg_header + app0
457            offset = read0_size + read1_size
458        else:
459            exif_header = jpeg_header[exif_header_length * -1:]
460            offset = read0_size
461
462        # Step 3: process exif header
463        if app_marker != b'\xff\xe1':
464            logging.error("Could not locate APP1 marker in %s", file_name)
465            return None
466        if exif_header[2:6] != b'Exif' or exif_header[6:8] != b'\x00\x00':
467            logging.error("APP1 is malformed in %s", file_name)
468            return None
469        app1_data_length = exif_header[0] * 256 + exif_header[1]
470
471        # Step 4: read APP1
472        view = memoryview(bytearray(app1_data_length))
473        try:
474            bytes_read = gp.check_result(
475                self.camera.file_read(
476                    folder, file_name, gp.GP_FILE_TYPE_NORMAL, offset, view, self.context
477                )
478            )
479        except gp.GPhoto2Error as ex:
480            logging.error(
481                'Error reading %s from camera: %s',
482                os.path.join(folder, file_name), gphoto2_named_error(ex.code)
483            )
484            return None
485        return jpeg_header + view.tobytes()
486
487    def _get_file(self, dir_name: str,
488                  file_name: str,
489                  dest_full_filename: Optional[str]=None,
490                  file_type: int=gp.GP_FILE_TYPE_NORMAL) -> gp.CameraFile:
491
492        try:
493            camera_file = gp.check_result(
494                gp.gp_camera_file_get(self.camera, dir_name, file_name, file_type, self.context)
495            )
496        except gp.GPhoto2Error as ex:
497            logging.error(
498                'Error reading %s from camera %s: %s',
499                os.path.join(dir_name, file_name), self.display_name, gphoto2_named_error(ex.code)
500            )
501            raise CameraProblemEx(code=CameraErrorCode.read, gp_exception=ex)
502
503        if dest_full_filename is not None:
504            try:
505                gp.check_result(gp.gp_file_save(camera_file, dest_full_filename))
506            except gp.GPhoto2Error as ex:
507                logging.error(
508                    'Error saving %s from camera %s: %s',
509                    os.path.join(dir_name, file_name), self.display_name,
510                    gphoto2_named_error(ex.code)
511                )
512                raise CameraProblemEx(code=CameraErrorCode.write, gp_exception=ex)
513
514        return camera_file
515
516    def save_file(self, dir_name: str,
517                  file_name: str,
518                  dest_full_filename: str) -> None:
519        """
520        Save the file from the camera to a local destination.
521
522        :param dir_name: directory on the camera
523        :param file_name: the photo or video
524        :param dest_full_filename: full path including filename where
525        the file will be saved.
526        """
527
528        self._get_file(dir_name, file_name, dest_full_filename)
529
530    def save_file_chunk(self, dir_name: str,
531                        file_name: str,
532                        chunk_size_in_bytes: int,
533                        dest_full_filename: str,
534                        mtime: int=None) -> None:
535        """
536        Save the file from the camera to a local destination.
537
538        :param dir_name: directory on the camera
539        :param file_name: the photo or video
540        :param chunk_size_in_bytes: how much of the file to read, starting
541         from the front of the file
542        :param dest_full_filename: full path including filename where
543        the file will be saved.
544        :param mtime: if specified, set the file modification time to this value
545        """
546
547        # get_exif_extract() can raise CameraProblemEx(code=CameraErrorCode.read):
548        buffer = self.get_exif_extract(dir_name, file_name, chunk_size_in_bytes)
549
550        view = memoryview(buffer)
551        dest_file = None
552        try:
553            dest_file = io.open(dest_full_filename, 'wb')
554            src_bytes = view.tobytes()
555            dest_file.write(src_bytes)
556            dest_file.close()
557            if mtime is not None:
558                os.utime(dest_full_filename, times=(mtime, mtime))
559        except (OSError, PermissionError) as ex:
560            logging.error(
561                'Error saving file %s from camera %s: %s',
562                os.path.join(dir_name, file_name), self.display_name, gphoto2_named_error(ex.errno)
563            )
564            if dest_file is not None:
565                dest_file.close()
566            raise CameraProblemEx(code=CameraErrorCode.write, py_exception=ex)
567
568    def save_file_by_chunks(self, dir_name: str,
569                            file_name: str,
570                            size: int,
571                            dest_full_filename: str,
572                            progress_callback,
573                            check_for_command,
574                            return_file_bytes = False,
575                            chunk_size=1048576) -> Optional[bytes]:
576        """
577        :param dir_name: directory on the camera
578        :param file_name: the photo or video
579        :param size: the size of the file in bytes
580        :param dest_full_filename: full path including filename where
581         the file will be saved
582        :param progress_callback: a function with which to update
583         copy progress
584        :param check_for_command: a function with which to check to see
585         if the execution should pause, resume or stop
586        :param return_file_bytes: if True, return a copy of the file's
587         bytes, else make that part of the return value None
588        :param chunk_size: the size of the chunks to copy. The default
589         is 1MB.
590        :return: True if the file was successfully saved, else False,
591         and the bytes that were copied
592        """
593
594        src_bytes = None
595        view = memoryview(bytearray(size))
596        amount_downloaded = 0
597        for offset in range(0, size, chunk_size):
598            check_for_command()
599            stop = min(offset + chunk_size, size)
600            try:
601                bytes_read = gp.check_result(
602                    self.camera.file_read(
603                        dir_name, file_name, gp.GP_FILE_TYPE_NORMAL, offset, view[offset:stop],
604                        self.context
605                    )
606                )
607                amount_downloaded += bytes_read
608                if progress_callback is not None:
609                    progress_callback(amount_downloaded, size)
610            except gp.GPhoto2Error as ex:
611                logging.error(
612                    'Error copying file %s from camera %s: %s',
613                    os.path.join(dir_name, file_name), self.display_name,
614                    gphoto2_named_error(ex.code)
615                )
616                if progress_callback is not None:
617                    progress_callback(size, size)
618                raise CameraProblemEx(code=CameraErrorCode.read, gp_exception=ex)
619
620        dest_file = None
621        try:
622            dest_file = io.open(dest_full_filename, 'wb')
623            src_bytes = view.tobytes()
624            dest_file.write(src_bytes)
625            dest_file.close()
626        except (OSError, PermissionError) as ex:
627            logging.error(
628                'Error saving file %s from camera %s. Error %s: %s',
629                os.path.join(dir_name, file_name), self.display_name, ex.errno, ex.strerror
630            )
631            if dest_file is not None:
632                dest_file.close()
633            raise CameraProblemEx(code=CameraErrorCode.write, py_exception=ex)
634
635        if return_file_bytes:
636            return src_bytes
637
638    def get_thumbnail(self, dir_name: str,
639                      file_name: str,
640                      ignore_embedded_thumbnail=False,
641                      cache_full_filename: Optional[str]=None) -> Optional[bytes]:
642        """
643        :param dir_name: directory on the camera
644        :param file_name: the photo or video
645        :param ignore_embedded_thumbnail: if True, do not retrieve the
646        embedded thumbnail
647        :param cache_full_filename: full path including filename where the
648        thumbnail will be saved. If none, will not save it.
649        :return: thumbnail in bytes format, which will be full
650        resolution if the embedded thumbnail is not selected
651        """
652
653        if self.can_fetch_thumbnails and not ignore_embedded_thumbnail:
654            get_file_type = gp.GP_FILE_TYPE_PREVIEW
655        else:
656            get_file_type = gp.GP_FILE_TYPE_NORMAL
657
658        camera_file = self._get_file(
659            dir_name, file_name, cache_full_filename, get_file_type
660        )
661
662        try:
663            thumbnail_data = gp.check_result(gp.gp_file_get_data_and_size(camera_file))
664        except gp.GPhoto2Error as ex:
665            logging.error(
666                'Error getting image %s from camera %s: %s',
667                os.path.join(dir_name, file_name), self.display_name,
668                gphoto2_named_error(ex.code)
669            )
670            raise CameraProblemEx(code=CameraErrorCode.read, gp_exception=ex)
671
672        if thumbnail_data:
673            data = memoryview(thumbnail_data)
674            return data.tobytes()
675
676    def get_THM_file(self, full_THM_name: str) -> Optional[bytes]:
677        """
678        Get THM thumbnail from camera
679
680        :param full_THM_name: path and file name of the THM file
681        :return: THM in raw bytes
682        """
683        dir_name, file_name = os.path.split(full_THM_name)
684        camera_file = self._get_file(dir_name, file_name)
685        try:
686            thumbnail_data = gp.check_result(gp.gp_file_get_data_and_size(camera_file))
687        except gp.GPhoto2Error as ex:
688            logging.error(
689                'Error getting THM file %s from camera %s: %s',
690                 os.path.join(dir_name, file_name), self.display_name, gphoto2_named_error(ex.code)
691            )
692            raise CameraProblemEx(code=CameraErrorCode.read, gp_exception=ex)
693
694        if thumbnail_data:
695            data = memoryview(thumbnail_data)
696            return data.tobytes()
697
698    def _select_camera(self, model, port_name)  -> None:
699        # Code from Jim Easterbrook's Photoini
700        # initialise camera
701        self.camera = gp.Camera()
702        # search abilities for camera model
703        abilities_list = gp.CameraAbilitiesList()
704        abilities_list.load(self.context)
705        idx = abilities_list.lookup_model(str(model))
706        self.camera.set_abilities(abilities_list[idx])
707        # search ports for camera port name
708        port_info_list = gp.PortInfoList()
709        port_info_list.load()
710        idx = port_info_list.lookup_path(str(port_name))
711        self.camera.set_port_info(port_info_list[idx])
712
713    def free_camera(self) -> None:
714        """
715        Disconnects the camera in gphoto2.
716        """
717        if self.camera_initialized:
718            self.camera.exit(self.context)
719            self.camera_initialized = False
720
721    def _concise_model_name(self) -> str:
722        """
723        Workaround the fact that the standard model name generated by
724        gphoto2 can be extremely verbose, e.g.
725        "Google Inc (for LG Electronics/Samsung) Nexus 4/5/7/10 (MTP)",
726        which is what is generated for a Nexus 4!!
727        :return: the model name as detected by gphoto2's camera
728         information, e.g. in the case above, a Nexus 4. Empty string
729         if not found.
730        """
731        if self.camera_config is None:
732            try:
733                self.camera_config = self.camera.get_config(self.context)
734            except gp.GPhoto2Error as e:
735                if e.code == gp.GP_ERROR_NOT_SUPPORTED:
736                    logging.error(
737                        "Getting camera configuration not supported for %s",
738                        self.display_name
739                    )
740                else:
741                    logging.error(
742                        "Unknown error getting camera configuration for %s",
743                        self.display_name
744                    )
745                return ''
746
747        # Here we really see the difference between C and python!
748        child_count = self.camera_config.count_children()
749        for i in range(child_count):
750            child1 = self.camera_config.get_child(i)
751            child_type = child1.get_type()
752            if child1.get_name() == 'status' and child_type == gp.GP_WIDGET_SECTION:
753                child1_count = child1.count_children()
754                for j in range(child1_count):
755                    child2 = child1.get_child(j)
756                    if child2.get_name() == 'cameramodel':
757                        return child2.get_value()
758        return ''
759
760    def get_storage_media_capacity(self, refresh: bool=False) -> List[StorageSpace]:
761        """
762        Determine the bytes free and bytes total (media capacity)
763        :param refresh: if True, get updated instead of cached values
764        :return: list of StorageSpace tuple. If could not be
765        determined due to an error, return value is None.
766        """
767
768        self._get_storage_info(refresh)
769        storage_capacity = []
770        for media_index in range(len(self.storage_info)):
771            info = self.storage_info[media_index]
772            if not (info.fields & gp.GP_STORAGEINFO_MAXCAPACITY and
773                    info.fields & gp.GP_STORAGEINFO_FREESPACEKBYTES):
774                logging.error('Could not locate storage on %s', self.display_name)
775            else:
776                storage_capacity.append(
777                    StorageSpace(
778                        bytes_free=info.freekbytes * 1024,
779                        bytes_total=info.capacitykbytes * 1024,
780                        path=info.basedir
781                    )
782                )
783        return storage_capacity
784
785    def get_storage_descriptions(self, refresh: bool=False) -> List[str]:
786        """
787        Storage description is used in MTP path names by gvfs and KDE.
788
789        :param refresh: if True, get updated instead of cached values
790        :return: the storage description
791        """
792        self._get_storage_info(refresh)
793        descriptions = []
794        for media_index in range(len(self.storage_info)):
795            info = self.storage_info[media_index]
796            if info.fields & gp.GP_STORAGEINFO_DESCRIPTION:
797                descriptions.append(info.description)
798        return descriptions
799
800    def no_storage_media(self, refresh: bool=False) -> int:
801        """
802        Return the number of storage media (e.g. memory cards) the
803        camera has
804        :param refresh: if True, refresh the storage information
805        :return: the number of media
806        """
807        self._get_storage_info(refresh)
808        return len(self.storage_info)
809
810    def _get_storage_info(self, refresh: bool):
811        """
812        Load the gphoto2 storage information
813        :param refresh: if True, refresh the storage information, i.e.
814         load it
815        """
816        if not self.storage_info or refresh:
817            try:
818                self.storage_info = self.camera.get_storageinfo(self.context)
819            except gp.GPhoto2Error as e:
820                logging.error(
821                    "Unable to determine storage info for camera %s: %s",
822                    self.display_name, gphoto2_named_error(e.code)
823                )
824                self.storage_info = []
825
826    @property
827    def dual_slots_active(self) -> bool:
828        """
829        :return: True if the camera has dual storage slots and both have specific
830        folders (e.g. DCIM etc.)
831        """
832
833        if self.specific_folders is None:
834            logging.warning(
835                "dual_slots_active() called before camera's folders scanned for %s",
836                self.display_name
837            )
838            return False
839        if not self.specific_folder_located:
840            logging.warning(
841                "dual_slots_active() called when no specific folders found for %s",
842                self.display_name
843            )
844            return False
845        return self.no_storage_media() > 1 and self._dual_slots_active
846
847    def unlocked(self) -> bool:
848        """
849        Smart phones can be in a locked state, such that their
850        contents cannot be accessed by gphoto2. Determine if
851        the device is unlocked by attempting to locate its
852        folders.
853        :return: True if unlocked, else False
854        """
855        try:
856            self.camera.folder_list_folders('/', self.context)
857        except gp.GPhoto2Error as e:
858            logging.error(
859                "Unable to access camera %s: %s. Is it locked?",
860                self.display_name, gphoto2_named_error(e.code)
861            )
862            return False
863        else:
864            return True
865
866
867def dump_camera_details() -> None:
868    import itertools
869    context = gp.Context()
870    cameras = autodetect_cameras(context)
871    for model, port in cameras:
872        c = Camera(model=model, port=port, context=context)
873        if not c.camera_initialized:
874            logging.error("Camera %s could not be initialized", model)
875        else:
876            print()
877            print(c.display_name)
878            print('=' * len(c.display_name))
879            print()
880            if not c.specific_folder_located:
881                print("Speicifc folder was not located")
882            else:
883                print(
884                    "Specific folders:", ', '.join(
885                        itertools.chain.from_iterable(c.specific_folders)
886                    )
887                )
888                print("Can fetch thumbnails:", c.can_fetch_thumbnails)
889
890                sc = c.get_storage_media_capacity()
891                if not sc:
892                    print("Unable to determine storage media capacity")
893                else:
894                    title = 'Storage capacity'
895                    print('\n{}\n{}'.format(title, '-' * len(title)))
896                    for ss in sc:
897                        print(
898                            '\nPath: {}\nCapacity: {}\nFree {}'.format(
899                                ss.path,
900                                format_size_for_user(ss.bytes_total),
901                                format_size_for_user(ss.bytes_free)
902                            )
903                        )
904                sd = c.get_storage_descriptions()
905                if not sd:
906                    print("Unable to determine storage descriptions")
907                else:
908                    title = 'Storage description(s)'
909                    print('\n{}\n{}'.format(title, '-' * len(title)))
910                    for ss in sd:
911                        print('\n{}'.format(ss))
912
913        c.free_camera()
914
915
916if __name__ == "__main__":
917    print("gphoto2 python: ", python_gphoto2_version())
918    # logging = gphoto2_python_logging()
919
920    if True:
921        dump_camera_details()
922
923    if True:
924
925        #Test stub
926        gp_context = gp.Context()
927        # Assume gphoto2 version 2.5 or greater
928        cameras = autodetect_cameras(gp_context)
929        for name, value in cameras:
930            camera = name
931            port = value
932            # print(port)
933            c = Camera(model=camera, port=port, specific_folders=['DCIM', 'MISC'])
934            # c = Camera(model=camera, port=port)
935            print(c.no_storage_media(), c.dual_slots_active, c.specific_folders)
936
937            for name, value in c.camera.folder_list_files('/', c.context):
938                print(name, value)
939
940            c.free_camera()
941
942
943
944
945