#!/bin/python3 from xml.dom import minidom from urllib.parse import urlparse, unquote import os, sys import requests import argparse import time try: from gi.repository import GLib PICTURES_DIR = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES) except Exception: print('Note: no GLib module available to get user\'s picture folder path. Using English folder name "Pictures".') from pathlib import Path PICTURES_DIR = str(Path.home()) + '/Pictures' try: import gi; gi.require_version('Notify', '0.7') from gi.repository import Notify Notify.init('ImagingEdge4Linux') except Exception: print('Note: no support for desktop notifications available. Continuing without notifications.') Notify = None __author__ = 'Georg Sieber' __copyright__ = '(c) 2024-2025' class GetContentException(Exception): pass class ImagingEdge: VERSION = '0.2' ROOT_DIR_PUSH = 'PushRoot' ROOT_DIR_PULL = 'PhotoRoot' DEFAULT_IP = '192.168.122.1' DEFAULT_PORT = '64321' def __init__(self, address, port, output_dir, debug, notify=False): self.address = address self.port = port self.output_dir = output_dir self.debug = debug self.notify = notify self.transferStarted = False def startTransferNotification(self): if(self.transferStarted): return else: self.transferStarted = True # display "sync started" desktop notification if notification module is available self.notification = None if(self.notify and Notify): self.notification = Notify.Notification.new('ImagingEdge4Linux', 'Sync running...') self.notification.show() def endTransferNotification(self): if(not self.transferStarted): return else: self.transferStarted = False # update desktop notification to "sync finished" if(self.notify and self.notification): self.notification.update('ImagingEdge4Linux', 'Sync finished.') self.notification.show() self.notification = None def getServiceInfo(self): response = requests.get('http://'+self.address+':'+self.port+'/DmsDescPush.xml') return response.text def startTransfer(self): # display "Transferring" on the camera display (only works in push mode) response = requests.post( 'http://'+self.address+':'+self.port+'/upnp/control/XPushList', headers = { 'SOAPACTION': '"urn:schemas-sony-com:service:XPushList:1#X_TransferStart"', 'Content-Type': 'text/xml; charset="utf-8"', }, data = ('' +'' +'' +'' +'' +'') ) if(self.debug): print('Transfer start response:', response.status_code, response.text) def endTransfer(self): # exit the "Send to smartphone" mode (only works in push mode) response = requests.post( 'http://'+self.address+':'+self.port+'/upnp/control/XPushList', headers = { 'SOAPACTION': '"urn:schemas-sony-com:service:XPushList:1#X_TransferEnd"', 'Content-Type': 'text/xml; charset="utf-8"', }, data = ('' +'' +'' +'' +'0' +'' +'' +'') ) if(self.debug): print('Transfer end response:', response.status_code, response.text) # get dir contents def getDirectoryContent(self, dir, dirname, downloadSize=None, startingIndex=0): response = requests.post( 'http://'+self.address+':'+self.port+'/upnp/control/ContentDirectory', headers = { 'SOAPACTION': '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"', 'Content-Type': 'text/xml; charset="utf-8"', }, data = ('' +'' +'' +'' +''+dir+'' +'BrowseDirectChildren' +'*' +''+str(startingIndex)+'' +'9999' +'' +'' +'' +'') ) if(self.debug): print('Dir content response:', response.status_code, response.text) if(response.status_code != 200): raise GetContentException('Failed to get dir content:', dir) dom = minidom.parseString(response.text) for element in dom.getElementsByTagName('Result'): # yes, no joke: there is a XML string encoded inside the , so we need to parse a second time if(self.debug): print('Dir content inner response:', element.firstChild.nodeValue) dom2 = minidom.parseString(element.firstChild.nodeValue) # yay, a container (sub directory) for element2 in dom2.getElementsByTagName('container'): dirname = element2.getElementsByTagName('dc:title')[0].firstChild.nodeValue print('Entering subdir:', element2.attributes['id'].value, '-', dirname) self.getDirectoryContent(element2.attributes['id'].value, dirname, downloadSize) # yay, an image (item) for element2 in dom2.getElementsByTagName('item'): # get name and download path filename = element2.getElementsByTagName('dc:title')[0].firstChild.nodeValue filepath = self.output_dir+'/'+dirname+'/'+filename # find the best resolution of this item url = None elements3 = element2.getElementsByTagName('res') if(not downloadSize): lastSize = 0; lastResolution = 0 for element3 in elements3: size = 0; resolution = 0 if('size' in element3.attributes): size = int(element3.attributes['size'].value) if('resolution' in element3.attributes): resolution = element3.attributes['resolution'].value if(size > lastSize): url = element3.firstChild.nodeValue lastSize = size lastResolution = resolution # fallback 1: no item found with size property set, probably a RAW file, search for largest thumbnail if(not url): searchSize = '_LRG' if(downloadSize): searchSize = downloadSize for element3 in elements3: if('protocolInfo' in element3.attributes and searchSize in element3.attributes['protocolInfo'].value): url = element3.firstChild.nodeValue break # fallback 2: use last as this is most likely the best quality if(not url): if(len(elements3) > 0 and elements3[-1].firstChild.nodeValue): url = elements3[-1].firstChild.nodeValue # download the best resolution if(url): self.downloadFile(url, filepath) else: print('Unable to find a download candidate:', filename) numberReturned = int(dom.getElementsByTagName('NumberReturned')[0].firstChild.nodeValue) totalMatches = int(dom.getElementsByTagName('TotalMatches')[0].firstChild.nodeValue) if(startingIndex+numberReturned < totalMatches): self.getDirectoryContent(dir, dirname, downloadSize, startingIndex+numberReturned) def downloadFile(self, url, filepath=None): # fallback file name if(not filepath): filepath = self.output_dir+'/'+unquote(urlparse(url).path) # make dirs if(not os.path.isdir(os.path.dirname(filepath))): os.makedirs(os.path.dirname(filepath), exist_ok=True) # do the download with requests.get(url, stream=True) as r: if(r.status_code != 200): print('Got unexpected status code:', r.status_code, url) return # get download size length = 0; written = 0 if('content-length' in r.headers): length = int(r.headers['content-length']) # skip if exists and fully downloaded if(os.path.isfile(filepath) and length != 0 and length == os.stat(filepath).st_size): print('Skip existing file:', filepath) return # write chunks to file self.startTransferNotification() print('Downloading:', url, ' -> ', filepath) with open(filepath, 'wb') as f: for chunk in r.iter_content(chunk_size=16384): f.write(chunk) written += len(chunk) # check if everything received or download aborted if(length != written): # this can happen if the download was aborted # or if the file was deleted on SD card but the image is still in the camera internal database print('!! Bytes received do not match advertised content-length:', written, '<>', length) def main(): defaultImgDir = PICTURES_DIR + '/ImagingEdge4Linux' parser = argparse.ArgumentParser(epilog=__copyright__+' '+__author__+' - https://georg-sieber.de') parser.add_argument('-a', '--address', default=ImagingEdge.DEFAULT_IP, help='IP address of your camera') parser.add_argument('-p', '--port', default=ImagingEdge.DEFAULT_PORT, help='Port of your camera') parser.add_argument('-o', '--output-dir', default=defaultImgDir, help='Directory where to save the downloaded files') parser.add_argument('-s', '--download-size', default=None, help='Download a specific thumbnail size (LRG|SM|TN) - none for best/original quality') parser.add_argument('-d', '--daemon', action='store_true', help='Run in background and automatically copy images if camera is available') parser.add_argument('--debug', default=False, action='store_true', help='Show debug output') parser.add_argument('--version', action='store_true', help='Print version and exit') args = parser.parse_args() if(args.version): print(ImagingEdge.VERSION) sys.exit() ie = ImagingEdge(args.address, args.port, args.output_dir, args.debug, args.daemon) if(args.debug): print(ie.getServiceInfo()) while True: try: ie.startTransfer() try: # user selected "Choose images on camera" ie.getDirectoryContent(ie.ROOT_DIR_PUSH, ie.ROOT_DIR_PUSH, args.download_size) except GetContentException as e: # user selected "Choose images on computer" (= access to all images) ie.getDirectoryContent(ie.ROOT_DIR_PULL, ie.ROOT_DIR_PULL, args.download_size) ie.endTransfer() ie.endTransferNotification() except requests.exceptions.ConnectionError as e: # ignore connection errors so that deamon mode keeps running print(e) if(args.daemon): print('Running in daemon mode, waiting for next request...') time.sleep(10) else: break if(__name__ == '__main__'): main()