diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eb931de --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +# Translation workflow +# 1. Extract strings from source +update-ts: + pyside6-lupdate QuickSync4LinuxGui/QuickSync4LinuxGui/gui.py -ts QuickSync4LinuxGui/QuickSync4LinuxGui/lang/de.ts +# 2. Compile .ts to .qm +compile-ts: + lrelease6 QuickSync4LinuxGui/QuickSync4LinuxGui/lang/de.ts +# Do both +i18n: update-ts compile-ts \ No newline at end of file diff --git a/QuickSync4Linux/__init__.py b/QuickSync4Linux/__init__.py index b32e420..ba77bc2 100644 --- a/QuickSync4Linux/__init__.py +++ b/QuickSync4Linux/__init__.py @@ -3,5 +3,4 @@ __license__ = 'GPL-3.0' __version__ = '1.0' __website__ = 'https://github.com/schorschii/QuickSync4Linux' - -__all__ = [__author__, __license__, __version__] +__all__ = [__author__, __license__, __version__] \ No newline at end of file diff --git a/QuickSync4Linux/__main__.py b/QuickSync4Linux/__main__.py index ed8e04c..08f5c65 100644 --- a/QuickSync4Linux/__main__.py +++ b/QuickSync4Linux/__main__.py @@ -1,4 +1,4 @@ from . import quicksync if __name__ == '__main__': - quicksync.main() + quicksync.main() \ No newline at end of file diff --git a/QuickSync4Linux/btserial.py b/QuickSync4Linux/btserial.py new file mode 100644 index 0000000..94c22e3 --- /dev/null +++ b/QuickSync4Linux/btserial.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +import socket +import select +import time +import fcntl +import struct +import termios + +from serial import SerialTimeoutException + + +class BluetoothSerial: + """Drop-in replacement for serial.Serial backed by an RFCOMM socket. + Implements only the subset of pyserial's API used by quicksync: + name, write, read, in_waiting.""" + + def __init__(self, address, channel=1, write_timeout=None): + self.name = f'bt:{address}@{channel}' + self._write_timeout = write_timeout + self._sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) + self._sock.connect((address, channel)) + self._sock.setblocking(False) + + @property + def in_waiting(self): + buf = fcntl.ioctl(self._sock.fileno(), termios.FIONREAD, b'\x00\x00\x00\x00') + return struct.unpack('I', buf)[0] + + def write(self, data): + total = 0 + deadline = time.monotonic() + self._write_timeout if self._write_timeout else None + while total < len(data): + remaining = (deadline - time.monotonic()) if deadline is not None else None + if remaining is not None and remaining <= 0: + raise SerialTimeoutException('Write timeout') + _, w, _ = select.select([], [self._sock], [], remaining) + if not w: + raise SerialTimeoutException('Write timeout') + try: + sent = self._sock.send(data[total:]) + except BlockingIOError: + continue + if sent == 0: + raise OSError('Bluetooth socket closed by peer') + total += sent + return total + + def read(self, n): + if n <= 0: + return b'' + try: + return self._sock.recv(n) + except BlockingIOError: + return b'' + + def close(self): + self._sock.close() + + +BT_ADDR_RE = __import__('re').compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}(@\d+)?$') + +def isBluetoothAddress(s): + return BT_ADDR_RE.match(s) is not None + +def parseBluetoothAddress(s): + """Returns (mac, channel) where channel defaults to 1.""" + if '@' in s: + mac, ch = s.rsplit('@', 1) + return mac, int(ch) + return s, 1 diff --git a/QuickSync4Linux/quicksync.py b/QuickSync4Linux/quicksync.py index 9e2a078..1543c67 100755 --- a/QuickSync4Linux/quicksync.py +++ b/QuickSync4Linux/quicksync.py @@ -2,456 +2,507 @@ from pathlib import Path import configparser - +import datetime +import struct import serial import time -import struct import argparse -import datetime import re import sys from . import at from . import obex +from . import btserial from .__init__ import __version__ -def main(): - # read config - config = {} - configParser = configparser.ConfigParser() - configParser.read(str(Path.home())+'/.config/quicksync4linux.ini') - if(configParser.has_section('general')): config = dict(configParser.items('general')) +# ─── Connection ─────────────────────────────────────────────────────────────── - # parse arguments - parser = argparse.ArgumentParser( - prog='QuickSync4Linux', - description='Communicate with Gigaset devices', - epilog=f'Version {__version__}, (c) Georg Sieber 2023-2024. If you like this program please consider making a donation using the sponsor button on GitHub (https://github.com/schorschii/QuickSync4Linux) to support the development. It depends on users like you if this software gets further updates.' - ) - parser.add_argument('action', help='one of: info, obexinfo, dial, getcontacts, createcontact, editcontact, deletecontact, listfiles, upload, download, delete') - parser.add_argument('options', nargs='?', help='e.g. a phone number for the "dial" action, a luid for contact operations or a file name on device for file actions') - parser.add_argument('-d', '--device', default=config.get('device', '/dev/ttyACM0'), help='serial port device') - parser.add_argument('-b', '--baud', default=config.get('baud', 9600)) - parser.add_argument('-f', '--file', default='-', help='file to read from or write into, stdout/stdin default') - parser.add_argument('-v', '--verbose', action='count', default=0, help='print complete AT/Obex serial communication') - args = parser.parse_args() +def open_connection(device: str, baud: int = 9600): + """Open a serial or Bluetooth connection to the device. + Returns a serial/BluetoothSerial object.""" + if btserial.isBluetoothAddress(device): + mac, channel = btserial.parseBluetoothAddress(device) + return btserial.BluetoothSerial(mac, channel, write_timeout=at.Delay.TimeoutWrite) + else: + return serial.Serial(device, baud, write_timeout=at.Delay.TimeoutWrite) - # open serial port - ser = serial.Serial( - args.device, - args.baud, - write_timeout=at.Delay.TimeoutWrite - ) - if(args.verbose): print('Connected to:', ser.name) - - def readVcfFile(path): - vcf = open(path, 'rb').read() - return vcf.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') # ensure CRLF line breaks - - def sendAndReadResponse(data, wait=None, isObex=False): - if(args.verbose): - print() - print('=== SEND ===') - if(args.verbose >= 2): print(data.hex()) - print(data.decode('ascii', errors='backslashreplace')) - ser.write(data) - - if(args.verbose): - print() - print('=== RECEIVE ===') - results = [] - buf = b'' - while True: - defaultDelay = at.Delay.AfterInvoke if(not isObex) else 0 - time.sleep(wait if(wait) else defaultDelay) - - if(ser.in_waiting == 0 and not wait): continue - - tmp = ser.read(ser.in_waiting) - buf += tmp - if(args.verbose): - if(args.verbose >= 2): print(tmp.hex()) - print(tmp.decode('ascii', errors='backslashreplace'), end='') - - if(isObex): # obex command result handling - try: - if(obex.evaluateResponse(buf, results, ser, isObex==obex.QuickSyncOperation.Upload)): - return b''.join(results) - else: - buf = b'' - except obex.InvalidObexLengthException: - # incomplete transmission, read more bytes from serial port - continue - - else: # AT command result handling - try: - return at.evaluateResponse(buf, data) - except at.IncompleteAtResponseException: - # incomplete transmission, read more bytes from serial port - continue - - - if(args.action == 'info'): - for title, command in { - 'Manufacturer': at.Command.GetManufacturer, - 'Type': at.Command.GetDeviceType, - 'Product': at.Command.GetProductName, - 'Serial (IPUI)': at.Command.GetSerialNumber, - 'Internal Name': at.Command.GetInternalName, - 'Battery State': at.Command.GetBatteryState, - 'Signal State': at.Command.GetSignalState, - 'Firmware': at.Command.GetFirmwareVersion, - 'Firmware URL': at.Command.GetFirmwareUrl, - 'Melodies': at.Command.ListMelodies, - 'Area Codes': at.Command.GetAreaCodes, - 'Hardware Connection State': at.Command.GetHardwareConnectionState, - 'Supported Features': at.Command.GetSupportedFeatures, - 'Supported Multimedia': at.Command.GetSupportedMultimedia, - 'Screen Size Clip': at.Command.GetScreenSizeClip, - 'Screen Size Full': at.Command.GetScreenSizeFull, - 'Extended Modes List': at.Command.GetExtendedModesList, - 'Current Extended Mode': at.Command.GetCurrentExtendedMode, - }.items(): - try: - response = sendAndReadResponse(at.formatCommand(command)).decode('ascii') - except Exception as e: - response = '['+'ERROR: '+str(e)+']' - print(title+':', response) +def close_connection(ser): + """Close the serial/Bluetooth connection.""" + try: + ser.close() + except Exception: + pass - elif(args.action == 'obexinfo'): - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) - print() - print('===', obex.FilePath.InfoLog) - print(sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileNameHeader( obex.FilePath.InfoLog ) - ), - isObex=True - ).decode('utf8')) +# ─── Low-level communication ────────────────────────────────────────────────── +def send_and_read(ser, data, wait=None, is_obex=False, verbose=0): + """Send data and read response from device.""" + if verbose: print() - print('===', obex.FilePath.DevInfo) - print(sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileNameHeader( obex.FilePath.DevInfo ) - ), - isObex=True - ).decode('utf8')) + print('=== SEND ===') + if verbose >= 2: print(data.hex()) + print(data.decode('ascii', errors='backslashreplace')) - print() - print('===', obex.FilePath.LuidCC) - print(sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileNameHeader( obex.FilePath.LuidCC ) - ), - isObex=True - ).decode('utf8')) + ser.write(data) + if verbose: print() - print('===', obex.FilePath.Luid0) - print(sendAndReadResponse( + print('=== RECEIVE ===') + + results = [] + buf = b'' + while True: + default_delay = at.Delay.AfterInvoke if not is_obex else 0 + time.sleep(wait if wait else default_delay) + + if ser.in_waiting == 0 and not wait: + continue + + tmp = ser.read(ser.in_waiting) + buf += tmp + if verbose: + if verbose >= 2: print(tmp.hex()) + print(tmp.decode('ascii', errors='backslashreplace'), end='') + + if is_obex: + try: + if obex.evaluateResponse(buf, results, ser, is_obex == obex.QuickSyncOperation.Upload): + return b''.join(results) + else: + buf = b'' + except obex.InvalidObexLengthException: + continue + else: + try: + return at.evaluateResponse(buf, data) + except at.IncompleteAtResponseException: + continue + + +def _exit_obex(ser): + """Exit OBEX mode and reset device.""" + time.sleep(at.Delay.ObexBoundary) + send_and_read(ser, at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) + time.sleep(at.Delay.AfterExitObex) + send_and_read(ser, at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) + + +def _enter_obex_dessync(ser): + """Enter OBEX mode and connect to DesSync service.""" + send_and_read(ser, at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) + send_and_read(ser, + obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), + is_obex=True + ) + + +# ─── Device info ────────────────────────────────────────────────────────────── + +def get_info(ser, verbose=0) -> str: + """Query device info and return as formatted string.""" + lines = [] + for title, command in { + 'Manufacturer': at.Command.GetManufacturer, + 'Type': at.Command.GetDeviceType, + 'Product': at.Command.GetProductName, + 'Serial (IPUI)': at.Command.GetSerialNumber, + 'Internal Name': at.Command.GetInternalName, + 'Battery State': at.Command.GetBatteryState, + 'Signal State': at.Command.GetSignalState, + 'Firmware': at.Command.GetFirmwareVersion, + 'Firmware URL': at.Command.GetFirmwareUrl, + 'Melodies': at.Command.ListMelodies, + 'Area Codes': at.Command.GetAreaCodes, + 'Hardware Connection State': at.Command.GetHardwareConnectionState, + 'Supported Features': at.Command.GetSupportedFeatures, + 'Supported Multimedia': at.Command.GetSupportedMultimedia, + 'Screen Size Clip': at.Command.GetScreenSizeClip, + 'Screen Size Full': at.Command.GetScreenSizeFull, + 'Extended Modes List': at.Command.GetExtendedModesList, + 'Current Extended Mode': at.Command.GetCurrentExtendedMode, + }.items(): + try: + response = send_and_read(ser, at.formatCommand(command), verbose=verbose).decode('ascii') + except Exception as e: + response = '[ERROR: ' + str(e) + ']' + lines.append(title + ': ' + response) + return '\n'.join(lines) + + +def get_obex_info(ser, verbose=0) -> str: + """Query OBEX device info and return as formatted string.""" + lines = [] + _enter_obex_dessync(ser) + + for path in [obex.FilePath.InfoLog, obex.FilePath.DevInfo, + obex.FilePath.LuidCC, obex.FilePath.Luid0]: + lines.append('') + lines.append('=== ' + path) + lines.append(send_and_read(ser, obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileNameHeader( obex.FilePath.Luid0 ) + obex.OpCode.Get + obex.Mask.Final, + obex.compileNameHeader(path) ), - isObex=True + is_obex=True, + verbose=verbose ).decode('utf8')) - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) + _exit_obex(ser) + return '\n'.join(lines) - elif(args.action == 'dial'): - if(not args.options): - raise Exception('Please tell me a number to call') +# ─── Contacts ───────────────────────────────────────────────────────────────── - sendAndReadResponse(at.formatCommand(at.Command.Dial, args.options), wait=0) +def get_contacts(ser, verbose=0) -> bytes: + """Download all contacts from device, return raw VCF bytes.""" + _enter_obex_dessync(ser) + vcf = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.PhoneBook) + ), + is_obex=True, + verbose=verbose + ) - elif(args.action == 'getcontacts'): - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) + _exit_obex(ser) + return vcf - vcf = sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileNameHeader( obex.FilePath.PhoneBook ) - ), - isObex=True - ).decode('utf8') - if(args.file == '-' or args.file == ''): - print(vcf) - else: - with open(args.file, 'w') as f: - f.write(vcf) - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) +def set_contacts(ser, vcf_data: bytes, verbose=0): + """Upload VCF data (bytes) to device, replacing all contacts.""" + if isinstance(vcf_data, str): + vcf_data = vcf_data.encode('utf-8') + # Ensure CRLF line endings + vcf_data = vcf_data.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') + _enter_obex_dessync(ser) - elif(args.action == 'createcontacts'): - if(args.file == ''): - raise Exception('Please give a .vcf file for import via --file parameter') - elif(args.file == '-'): - vcf = sys.stdin.read() - else: - vcf = readVcfFile(args.file).decode('utf8') + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.PhoneBook) + + obex.compileLengthHeader(len(vcf_data)) + + obex.compileMessage(obex.Header.EndOfBody, vcf_data) + ), + is_obex=True, + verbose=verbose + ) - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) + _exit_obex(ser) - counter = 1 - for vcard in re.findall(r"BEGIN\:VCARD[\S\s]*?END\:VCARD", vcf): - if(not args.verbose): print('Creating contact #{0}'.format(counter)) - vcardBytes = vcard.encode('ascii') - sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Put+obex.Mask.Final, - obex.compileNameHeader( obex.FilePath.NewVCardGQS ) - + obex.compileLengthHeader( len(vcardBytes) ) - + obex.compileMessage( obex.Header.EndOfBody, vcardBytes ) - ), - isObex=True - ) - counter += 1 - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) +def create_contact(ser, vcf_data: bytes, verbose=0): + """Create a new contact on device.""" + if isinstance(vcf_data, str): + vcf_data = vcf_data.encode('utf-8') + vcf_data = vcf_data.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') + _enter_obex_dessync(ser) - elif(args.action == 'editcontact'): - if(args.file == '-' or args.file == ''): - raise Exception('Please give a .vcf file for import via --file parameter') - if(not args.options): - raise Exception('Please give the luid of the contact which should be edited') - vcf = readVcfFile(args.file) + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.NewVCardGQS) + + obex.compileLengthHeader(len(vcf_data)) + + obex.compileMessage(obex.Header.EndOfBody, vcf_data) + ), + is_obex=True, + verbose=verbose + ) - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) + _exit_obex(ser) - sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Put+obex.Mask.Final, - obex.compileNameHeader( obex.FilePath.VCardLuid.format(args.options) ) - + obex.compileLengthHeader( len(vcf) ) - + obex.compileMessage( obex.Header.EndOfBody, vcf ) - ), - isObex=True - ) - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) +def edit_contact(ser, luid: str, vcf_data: bytes, verbose=0): + """Edit an existing contact on device by luid.""" + if isinstance(vcf_data, str): + vcf_data = vcf_data.encode('utf-8') + vcf_data = vcf_data.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') + _enter_obex_dessync(ser) - elif(args.action == 'deletecontact'): - if(not args.options): - raise Exception('Please give the luid of the contact which should be edited') + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.VCardLuid.format(luid)) + + obex.compileLengthHeader(len(vcf_data)) + + obex.compileMessage(obex.Header.EndOfBody, vcf_data) + ), + is_obex=True, + verbose=verbose + ) - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) + _exit_obex(ser) - sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Put+obex.Mask.Final, - obex.compileNameHeader( obex.FilePath.VCardLuid.format(args.options) ) - ), - isObex=True - ) - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) +def delete_contact(ser, luid: str, verbose=0): + """Delete a contact from device by luid.""" + _enter_obex_dessync(ser) + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.VCardLuid.format(luid)) + ), + is_obex=True, + verbose=verbose + ) - elif(args.action == 'listfiles'): - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) + _exit_obex(ser) + + +# ─── Files ──────────────────────────────────────────────────────────────────── - totalSpaceResponseBytes = sendAndReadResponse( +def list_files(ser, verbose=0) -> str: + """List all files on device, return formatted string.""" + lines = [] + _enter_obex_dessync(ser) + + total_bytes = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileMessage(obex.Header.AppParameters, obex.AppParametersCommand.MemoryStatusTotal) + ), + is_obex=True, + verbose=verbose + ) + free_bytes = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileMessage(obex.Header.AppParameters, obex.AppParametersCommand.MemoryStatusFree) + ), + is_obex=True, + verbose=verbose + ) + lines.append('Total Space: ' + str(obex.parseMemoryResponse(total_bytes) / 1024) + ' KiB') + lines.append('Free Space: ' + str(obex.parseMemoryResponse(free_bytes) / 1024) + ' KiB') + + for folder in [obex.FolderPath.ScreenSavers, obex.FolderPath.ClipPictures, obex.FolderPath.Ringtones]: + lines.append('') + lines.append('=== ' + folder) + send_and_read(ser, obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileMessage( obex.Header.AppParameters, obex.AppParametersCommand.MemoryStatusTotal ) + obex.OpCode.SetPath, + struct.pack('B', obex.SetPathFlags.DontCreate) + + struct.pack('B', obex.SetPathFlags.Constants) + + obex.compileNameHeader(folder) ), - isObex=True + is_obex=True, + verbose=verbose ) - freeSpaceResponseBytes = sendAndReadResponse( + file_list_xml = send_and_read(ser, obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileMessage( obex.Header.AppParameters, obex.AppParametersCommand.MemoryStatusFree ) + obex.OpCode.Get + obex.Mask.Final, + obex.compileMessage(obex.Header.Type, obex.ObjectMimeType.FolderListing) ), - isObex=True - ) - print('Total Space:', obex.parseMemoryResponse(totalSpaceResponseBytes)/1024, 'KiB') - print('Free Space:', obex.parseMemoryResponse(freeSpaceResponseBytes)/1024, 'KiB') - - for folder in [ - obex.FolderPath.ScreenSavers, - obex.FolderPath.ClipPictures, - obex.FolderPath.Ringtones, - ]: - print() - print('===', folder) - sendAndReadResponse( - obex.compileMessage( - obex.OpCode.SetPath, - struct.pack('B', obex.SetPathFlags.DontCreate) - + struct.pack('B', obex.SetPathFlags.Constants) - + obex.compileNameHeader( folder ) - ), - isObex=True + is_obex=True, + verbose=verbose + ).decode('utf8') + files, max_len = obex.parseFileListXml(''.join(file_list_xml)) + for f in files: + lines.append( + (f['fileid'] + ':').ljust(4) + ' ' + + f['name'].ljust(max_len) + ' ' + + datetime.datetime.strptime(f['modified'], '%Y%m%dT%H%M%S').strftime('%Y-%m-%d %H:%M') + ' ' + + f['user-perm'] + ' ' + + str(round(int(f['size']) / 1024, 1)) + ' KiB' ) - fileList = sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileMessage( obex.Header.Type, obex.ObjectMimeType.FolderListing ) - ), - isObex=True - ).decode('utf8') - files, maxLenName = obex.parseFileListXml(''.join(fileList)) - for file in files: - print( - (file['fileid']+':').ljust(4), - file['name'].ljust(maxLenName), - datetime.datetime.strptime(file['modified'], '%Y%m%dT%H%M%S').strftime('%Y-%m-%d %H:%M'), - file['user-perm'], - str(round(int(file['size'])/1024, 1)) + ' KiB' - ) - - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) - - - elif(args.action == 'download'): - if(not args.options): - raise Exception('Please give the file name of the file which should be downloaded') - if(args.file == '-' or args.file == ''): - raise Exception('Please specify the output file name via --file parameter') - - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) - fileContent = sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Get+obex.Mask.Final, - obex.compileNameHeader( args.options ) - ), - isObex=True - ) - with open(args.file, 'wb') as f: - f.write(fileContent) + _exit_obex(ser) + return '\n'.join(lines) - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) +def download_file(ser, remote_path: str, local_path: str, verbose=0): + """Download a file from device to local path.""" + _enter_obex_dessync(ser) - elif(args.action == 'upload'): - if(not args.options): - raise Exception('Please give the file name of the file which should be uploaded') - if(args.file == '-' or args.file == ''): - raise Exception('Please specify the input file via --file parameter') + content = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileNameHeader(remote_path) + ), + is_obex=True, + verbose=verbose + ) + with open(local_path, 'wb') as f: + f.write(content) - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) + _exit_obex(ser) - with open(args.file, 'rb') as f: - data = f.read() - chunkSize = 958 - counter = 0 - chunks = [data[i:i+chunkSize] for i in range(0, len(data), chunkSize)] - for chunk in chunks: - finalFlag = obex.Mask.Final if(counter == len(chunks)-1) else 0 - bodyHeader = obex.Header.EndOfBody if(counter == len(chunks)-1) else obex.Header.Body - nameHeader = obex.compileNameHeader(args.options) if(counter == 0) else b'' - lengthHeader = obex.compileLengthHeader(len(data)) if(counter == 0) else b'' - sendAndReadResponse( - obex.compileMessage( - obex.OpCode.Put+finalFlag, - nameHeader - + lengthHeader - + obex.compileMessage( bodyHeader, chunk ) - ), - isObex=obex.QuickSyncOperation.Upload - ) - counter += 1 - - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) - - - elif(args.action == 'delete'): - if(not args.options): - raise Exception('Please give the file name of the file which should be deleted') - - sendAndReadResponse(at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) - sendAndReadResponse( - obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), - isObex=True - ) - sendAndReadResponse( +def upload_file(ser, remote_name: str, local_path: str, verbose=0): + """Upload a local file to device.""" + with open(local_path, 'rb') as f: + data = f.read() + + _enter_obex_dessync(ser) + + chunk_size = 958 + counter = 0 + chunks = [data[i:i + chunk_size] for i in range(0, len(data), chunk_size)] + for chunk in chunks: + final_flag = obex.Mask.Final if counter == len(chunks) - 1 else 0 + body_header = obex.Header.EndOfBody if counter == len(chunks) - 1 else obex.Header.Body + name_header = obex.compileNameHeader(remote_name) if counter == 0 else b'' + length_header = obex.compileLengthHeader(len(data)) if counter == 0 else b'' + send_and_read(ser, obex.compileMessage( - obex.OpCode.Put+obex.Mask.Final, - obex.compileNameHeader( args.options ) + obex.OpCode.Put + final_flag, + name_header + length_header + obex.compileMessage(body_header, chunk) ), - isObex=True + is_obex=obex.QuickSyncOperation.Upload, + verbose=verbose ) + counter += 1 - time.sleep(at.Delay.ObexBoundary) - sendAndReadResponse(at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) - time.sleep(at.Delay.AfterExitObex) - sendAndReadResponse(at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) + _exit_obex(ser) - else: - print('Unknown action: {0}'.format(args.action)) - exit(1) +def delete_file(ser, remote_path: str, verbose=0): + """Delete a file from device.""" + _enter_obex_dessync(ser) + + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(remote_path) + ), + is_obex=True, + verbose=verbose + ) + + _exit_obex(ser) + + +def dial(ser, number: str, verbose=0): + """Dial a phone number.""" + send_and_read(ser, at.formatCommand(at.Command.Dial, number), wait=0, verbose=verbose) + + +# ─── CLI entry point ────────────────────────────────────────────────────────── + +def main(): + # read config + config = {} + config_parser = configparser.ConfigParser() + config_parser.read(str(Path.home()) + '/.config/quicksync4linux.ini') + if config_parser.has_section('general'): + config = dict(config_parser.items('general')) + + # parse arguments + parser = argparse.ArgumentParser( + prog='QuickSync4Linux', + description='Communicate with Gigaset devices', + epilog=f'Version {__version__}, (c) Georg Sieber 2023-2024. ' + f'If you like this program please consider making a donation using the sponsor button on GitHub ' + f'(https://github.com/schorschii/QuickSync4Linux) to support the development. ' + f'It depends on users like you if this software gets further updates.' + ) + parser.add_argument('action', help='one of: info, obexinfo, dial, getcontacts, setcontacts, ' + 'createcontact, editcontact, deletecontact, listfiles, upload, download, delete') + parser.add_argument('options', nargs='?', help='e.g. a phone number for "dial", a luid for contact ' + 'operations or a file name for file actions') + parser.add_argument('-d', '--device', default=config.get('device', '/dev/ttyACM0'), + help='serial port device or Bluetooth MAC (optionally with @channel, default channel 1)') + parser.add_argument('-b', '--baud', default=config.get('baud', 9600)) + parser.add_argument('-f', '--file', default='-', help='file to read from or write into, stdout/stdin default') + parser.add_argument('-v', '--verbose', action='count', default=0, + help='print complete AT/Obex serial communication') + args = parser.parse_args() + + # open connection + try: + ser = open_connection(args.device, args.baud) + except (OSError, serial.SerialException) as e: + print(f'✗ Connection to {args.device} failed: {e}', file=sys.stderr) + sys.exit(1) + if args.verbose: + print('Connected to:', ser.name) + + try: + if args.action == 'info': + print(get_info(ser, verbose=args.verbose)) + + elif args.action == 'obexinfo': + print(get_obex_info(ser, verbose=args.verbose)) + + elif args.action == 'dial': + if not args.options: + raise Exception('Please provide a number to call') + dial(ser, args.options, verbose=args.verbose) + + elif args.action == 'getcontacts': + if args.file == '-' or args.file == '': + sys.stdout.buffer.write(get_contacts(ser, verbose=args.verbose)) + else: + with open(args.file, 'wb') as f: + f.write(get_contacts(ser, verbose=args.verbose)) + + elif args.action in ('setcontacts', 'createcontacts'): + if args.file == '-' or args.file == '': + vcf = sys.stdin.buffer.read() + else: + with open(args.file, 'rb') as f: + vcf = f.read() + set_contacts(ser, vcf, verbose=args.verbose) + + elif args.action == 'createcontact': + if args.file == '-' or args.file == '': + vcf = sys.stdin.buffer.read() + else: + with open(args.file, 'rb') as f: + vcf = f.read() + create_contact(ser, vcf, verbose=args.verbose) + + elif args.action == 'editcontact': + if not args.options: + raise Exception('Please provide the luid of the contact to edit') + if args.file == '-' or args.file == '': + vcf = sys.stdin.buffer.read() + else: + with open(args.file, 'rb') as f: + vcf = f.read() + edit_contact(ser, args.options, vcf, verbose=args.verbose) + + elif args.action == 'deletecontact': + if not args.options: + raise Exception('Please provide the luid of the contact to delete') + delete_contact(ser, args.options, verbose=args.verbose) + + elif args.action == 'listfiles': + print(list_files(ser, verbose=args.verbose)) + + elif args.action == 'download': + if not args.options: + raise Exception('Please provide the remote file name') + if args.file == '-' or args.file == '': + raise Exception('Please specify the output file via --file parameter') + download_file(ser, args.options, args.file, verbose=args.verbose) + + elif args.action == 'upload': + if not args.options: + raise Exception('Please provide the remote file name') + if args.file == '-' or args.file == '': + raise Exception('Please specify the input file via --file parameter') + upload_file(ser, args.options, args.file, verbose=args.verbose) + + elif args.action == 'delete': + if not args.options: + raise Exception('Please provide the remote file name to delete') + delete_file(ser, args.options, verbose=args.verbose) + + else: + print('Unknown action:', args.action) + sys.exit(1) + + finally: + close_connection(ser) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/QuickSync4LinuxGui.desktop b/QuickSync4LinuxGui.desktop new file mode 100755 index 0000000..28afb1e --- /dev/null +++ b/QuickSync4LinuxGui.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=QuickSync4LinuxGui +Exec=python3 -m QuickSync4LinuxGui +Path=/home/matze/Projekte/QuickSync4Linux/QuickSync4LinuxGui +Terminal=false +Icon=phone +Categories=Application;Utility; +Comment=Graphical interface for Gigaset DECT devices + +# execute `update-desktop-database` after copying into /usr/share/applications diff --git a/QuickSync4LinuxGui/QuickSync4LinuxGui/__init__.py b/QuickSync4LinuxGui/QuickSync4LinuxGui/__init__.py new file mode 100644 index 0000000..6e1b70d --- /dev/null +++ b/QuickSync4LinuxGui/QuickSync4LinuxGui/__init__.py @@ -0,0 +1,6 @@ +__title__ = 'QuickSync4LinuxGui' +__author__ = 'Georg Sieber' +__license__ = 'GPL-3.0' +__version__ = '1.0' +__website__ = 'https://github.com/schorschii/QuickSync4Linux' +__all__ = [__author__, __license__, __version__] \ No newline at end of file diff --git a/QuickSync4LinuxGui/QuickSync4LinuxGui/__main__.py b/QuickSync4LinuxGui/QuickSync4LinuxGui/__main__.py new file mode 100644 index 0000000..52c256c --- /dev/null +++ b/QuickSync4LinuxGui/QuickSync4LinuxGui/__main__.py @@ -0,0 +1,14 @@ +import sys + +from QuickSync4Linux import quicksync + +if __name__ == '__main__': + if len(sys.argv) == 1 or (len(sys.argv) > 1 and sys.argv[1] in ('gui', '--gui', '-g')): + try: + from . import gui + gui.run() + except ImportError: + print('PySide6 is not installed. Install it with: pip install PySide6') + sys.exit(1) + else: + quicksync.main() \ No newline at end of file diff --git a/QuickSync4LinuxGui/QuickSync4LinuxGui/backend.py b/QuickSync4LinuxGui/QuickSync4LinuxGui/backend.py new file mode 100644 index 0000000..a37cf69 --- /dev/null +++ b/QuickSync4LinuxGui/QuickSync4LinuxGui/backend.py @@ -0,0 +1,175 @@ +""" +QuickSync4LinuxGui — Backend +Shared logic and CLI communication. +""" +import os +import re +import configparser +import subprocess +import logging +from logging.handlers import RotatingFileHandler + +# ─── Constants and defaults ─────────────────────────────────────────────────── +CHECK_TIMEOUT = 10 +DISCOVER_TIMEOUT = 10 +CLI_DEFAULT_TIMEOUT = 300 +DEFAULT_BAUD = '9600' + +BT_MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$') + +_CONFIG_DIR = os.path.expanduser('~/.config/QuickSync4LinuxGui') +DEFAULT_LOG_FILE = os.path.join(_CONFIG_DIR, 'QuickSync4LinuxGui.log') +# GUI-only settings file. The QuickSync4Linux CLI package keeps its own +# configuration independently, so the GUI can be installed separately. +DEFAULT_CONFIG_FILE = os.path.join(_CONFIG_DIR, 'settings.ini') + +# ─── Logger setup ───────────────────────────────────────────────────────────── +import datetime as _dt + +def _setup_rotating_logger(): + os.makedirs(_CONFIG_DIR, exist_ok=True) + handler = RotatingFileHandler( + DEFAULT_LOG_FILE, + maxBytes=1 * 1024 * 1024, # 1 MB + backupCount=3, + encoding='utf-8', + ) + handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s', + datefmt='%d.%m.%Y %H:%M:%S')) + logger = logging.getLogger('QuickSync4LinuxGui') + logger.setLevel(logging.DEBUG) + if not logger.handlers: + logger.addHandler(handler) + return logger + +logger = _setup_rotating_logger() + +# Separator + program start, logged only once via a sentinel file +_sentinel = os.path.join(_CONFIG_DIR, '.started') +if not os.path.exists(_sentinel): + # Create sentinel so subprocesses do not log their own start + open(_sentinel, 'w').close() + _start_time = _dt.datetime.now().strftime('%d.%m.%Y %H:%M:%S') + logger.info('─' * 60) + logger.info(f'Program start: {_start_time}') + + import atexit as _atexit + def _log_exit(): + _end_time = _dt.datetime.now().strftime('%d.%m.%Y %H:%M:%S') + logger.info(f'Program end: {_end_time}') + logger.info('─' * 60) + try: os.unlink(_sentinel) + except: pass + _atexit.register(_log_exit) + +# ─── Load/save GUI settings ─────────────────────────────────────────────────── +def load_settings(path=DEFAULT_CONFIG_FILE): + try: + config_parser = configparser.ConfigParser() + config_parser.read(path) + if not config_parser.has_section('general'): + return + data = dict(config_parser.items('general')) + global CHECK_TIMEOUT, DISCOVER_TIMEOUT, CLI_DEFAULT_TIMEOUT, DEFAULT_BAUD + if 'check_timeout' in data: CHECK_TIMEOUT = int(data['check_timeout']) + if 'discover_timeout' in data: DISCOVER_TIMEOUT = int(data['discover_timeout']) + if 'cli_default_timeout' in data: CLI_DEFAULT_TIMEOUT = int(data['cli_default_timeout']) + if 'baud' in data: DEFAULT_BAUD = str(data['baud']) + except Exception: + pass + +def save_settings(path=DEFAULT_CONFIG_FILE): + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + config_parser = configparser.ConfigParser() + config_parser.read(path) # Preserve existing keys, e.g. "device" + if not config_parser.has_section('general'): + config_parser.add_section('general') + config_parser.set('general', 'check_timeout', str(CHECK_TIMEOUT)) + config_parser.set('general', 'discover_timeout', str(DISCOVER_TIMEOUT)) + config_parser.set('general', 'cli_default_timeout', str(CLI_DEFAULT_TIMEOUT)) + config_parser.set('general', 'baud', str(DEFAULT_BAUD)) + with open(path, 'w', encoding='utf-8') as f: + config_parser.write(f) + except Exception: + pass + +# Load settings during import +load_settings() + +# ─── Bluetooth device discovery ─────────────────────────────────────────────── +def _get_bt_device_class(mac: str) -> str: + """Returns the device class of a Bluetooth device (e.g. 'phone', 'audio').""" + try: + out = subprocess.check_output( + ['bluetoothctl', 'info', mac], text=True, timeout=5 + ) + for line in out.splitlines(): + if 'Icon:' in line: + return line.split(':', 1)[1].strip().lower() + except Exception: + pass + return '' + +def discover_devices() -> list[tuple[str, str]]: + """Returns a list of (mac, label) for phone devices via bluetoothctl.""" + devices = [] + try: + out = subprocess.check_output( + ['bluetoothctl', 'devices'], text=True, timeout=DISCOVER_TIMEOUT + ) + for line in out.splitlines(): + m = re.match(r'Device\s+([0-9A-Fa-f:]{17})\s+(.*)', line) + if m: + mac, label = m.group(1), m.group(2).strip() + device_class = _get_bt_device_class(mac) + if 'phone' in device_class: + devices.append((mac, label)) + except Exception: + pass + return devices + +# ─── Bluetooth connection status ────────────────────────────────────────────── +def check_bt_connected(mac: str) -> bool | None: + """Checks if a device is actively connected via bluetoothctl. + Returns True if connected, False if not connected, None on error.""" + try: + out = subprocess.check_output( + ['bluetoothctl', 'info', mac], text=True, timeout=5 + ) + for line in out.splitlines(): + if 'Connected:' in line: + return 'yes' in line.lower() + return False + except Exception: + return None + +# ─── Connection error interpretation ────────────────────────────────────────── +def interpret_connection_error(text: str, mac: str = '') -> str | None: + """Returns a symbolic error key or None. The GUI layer translates keys via _translate_err(). + Uses errno numbers for matching — language-independent.""" + t = text.lower() + import re as _re + m = _re.search(r'\[errno\s+(\d+)\]', t) + errno_num = int(m.group(1)) if m else None + + if errno_num == 112 or 'host is down' in t: + if mac: + bt_connected = check_bt_connected(mac) + if bt_connected is False: + return 'ERR_NOT_CONNECTED' + elif bt_connected is True: + return 'ERR_NOT_REACHABLE_LOCKED' + return 'ERR_NOT_REACHABLE' + if errno_num == 111 or 'connection refused' in t: + return 'ERR_REFUSED' + if errno_num == 113 or 'no route to host' in t: + return 'ERR_NOT_FOUND' + if errno_num == 110 or 'timed out' in t or 'timeout' in t: + return 'ERR_TIMEOUT' + return None + +def log(text: str): + for line in text.splitlines(): + if line.strip(): + logger.info(line) diff --git a/QuickSync4LinuxGui/QuickSync4LinuxGui/gui.py b/QuickSync4LinuxGui/QuickSync4LinuxGui/gui.py new file mode 100644 index 0000000..adb2b60 --- /dev/null +++ b/QuickSync4LinuxGui/QuickSync4LinuxGui/gui.py @@ -0,0 +1,1402 @@ +#!/usr/bin/env python3 +"""QuickSync4Linux GUI – PySide6 version (drop-in replacement for the tkinter gui.py)""" + +import json +import os +import re +import subprocess +import tempfile +import threading + +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QComboBox, QLineEdit, QTextEdit, QFrame, + QDialog, QDialogButtonBox, QMessageBox, QFileDialog, + QFormLayout, QSizePolicy, QStatusBar, QAbstractItemView, QStyle, + QTableWidget, QTableWidgetItem, QHeaderView, QListWidget, + QListWidgetItem, QSplitter, QSpinBox, QRadioButton, QButtonGroup, +) +from PySide6.QtGui import QFont, QPixmap, QColor +from PySide6.QtCore import Qt, Signal, QObject, QTimer + +from . import vcard +from QuickSync4Linux import btserial +from QuickSync4Linux import quicksync + +from . import backend + +# Reuse constants from backend +DEFAULT_LOG_FILE = backend.DEFAULT_LOG_FILE +DEFAULT_CONFIG_FILE = backend.DEFAULT_CONFIG_FILE +BT_MAC_RE = backend.BT_MAC_RE + +def CHECK_TIMEOUT(): return backend.CHECK_TIMEOUT +def DISCOVER_TIMEOUT(): return backend.DISCOVER_TIMEOUT +def CLI_DEFAULT_TIMEOUT(): return backend.CLI_DEFAULT_TIMEOUT +def DEFAULT_BAUD(): return backend.DEFAULT_BAUD + +# Module-level aliases for compatibility +CHECK_TIMEOUT = backend.CHECK_TIMEOUT +DISCOVER_TIMEOUT = backend.DISCOVER_TIMEOUT +CLI_DEFAULT_TIMEOUT = backend.CLI_DEFAULT_TIMEOUT +DEFAULT_BAUD = backend.DEFAULT_BAUD + +# ─── Backend error-code translation ─────────────────────────────────────────── +_BT_ERROR_STRINGS: dict[str, str] = { + 'ERR_NOT_CONNECTED': '✗ Device not connected — Please enable Bluetooth on your phone.', + 'ERR_NOT_REACHABLE_LOCKED': '✗ Device not reachable — Please turn on the screen and unlock your phone.', + 'ERR_NOT_REACHABLE': '✗ Device not reachable — Please enable Bluetooth and turn on the screen of your phone.', + 'ERR_REFUSED': '✗ Connection refused — Please enable Bluetooth on your phone.', + 'ERR_NOT_FOUND': '✗ Device not found — Please enable Bluetooth on your phone.', + 'ERR_TIMEOUT': '✗ Timeout — Please unlock your phone and try again.', +} + +def _translate_err(widget, key: str) -> str: + """Translate a backend error key through Qt tr() into the active locale.""" + src = _BT_ERROR_STRINGS.get(key) + if src: + return widget.tr(src) + return key + +def _make_dialog_buttons(widget, flags=None): + """Creates a QDialogButtonBox with translatable button labels.""" + from PySide6.QtWidgets import QDialogButtonBox + if flags is None: + flags = QDialogButtonBox.Ok | QDialogButtonBox.Cancel + btns = QDialogButtonBox(flags) + ok_btn = btns.button(QDialogButtonBox.Ok) + cancel_btn = btns.button(QDialogButtonBox.Cancel) + close_btn = btns.button(QDialogButtonBox.Close) + if ok_btn: ok_btn.setText(widget.tr('OK')) + if cancel_btn: cancel_btn.setText(widget.tr('Cancel')) + if close_btn: close_btn.setText(widget.tr('Close')) + return btns + + +def simple_input(parent, title: str, label: str) -> str: + from PySide6.QtWidgets import QInputDialog + text, ok = QInputDialog.getText(parent, title, label) + return text.strip() if ok else '' + +class _WorkerSignals(QObject): + append_text = Signal(str) + status_update = Signal(str, str) + connection_state = Signal(bool) + action_finished = Signal(str, str) + show_info = Signal(str, str) # (title, text) + clear_output = Signal() + +class QuickSyncGUI(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle(self.tr('QuickSync4LinuxGui')) + self.resize(720, 560) + + self._device_map: dict[str, str] = {} + self._device_connected: bool | None = None + self._signals = _WorkerSignals() + self._signals.append_text.connect(self._append_output) + self._signals.status_update.connect(self._update_status_bar) + self._signals.connection_state.connect(self._set_connection_state) + self._signals.action_finished.connect(self._parse_and_fill_ui) + self._signals.show_info.connect(self._show_info_window) + + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(6) + + # QSplitter: Seitenleiste verschiebbar wie in Dolphin + splitter = QSplitter(Qt.Horizontal) + splitter.setChildrenCollapsible(False) + splitter.setHandleWidth(4) + + # Sidebar + sidebar = QWidget() + sidebar.setMinimumWidth(120) + sidebar.setMaximumWidth(300) + sidebar_layout = QVBoxLayout(sidebar) + sidebar_layout.setContentsMargins(0, 0, 0, 0) + sidebar_layout.setSpacing(1) + + style = self.style() + + def sb_group(text): + lbl = QLabel(text) + lbl.setStyleSheet('color: palette(window-text); font-size: 13px; font-weight: bold; padding: 12px 4px 2px 4px;') + sidebar_layout.addWidget(lbl) + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Plain) + sidebar_layout.addWidget(sep) + + def sb_btn(label, icon_name, slot, danger=False, success=False): + b = QPushButton(' ' + label) + b.setIcon(style.standardIcon(icon_name)) + b.clicked.connect(slot) + color = '#c9302c' if danger else ('#449d44' if success else 'palette(window-text)') + b.setStyleSheet(f'text-align: left; padding: 4px 6px; border: none; color: {color};') + b.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + return b + + sb_group(self.tr('Device')) + sidebar_layout.addWidget(sb_btn(self.tr('Connect'), QStyle.SP_DialogApplyButton, self.test_connection, success=True)) + sidebar_layout.addWidget(sb_btn(self.tr('Disconnect'), QStyle.SP_DialogCancelButton, self.disconnect_device, danger=True)) + sidebar_layout.addWidget(sb_btn(self.tr('Info'), QStyle.SP_MessageBoxInformation, lambda: self.run_action('info'))) + sidebar_layout.addWidget(sb_btn(self.tr(self.tr('Obex Info')), QStyle.SP_MessageBoxInformation, lambda: self.run_action('obexinfo'))) + + sb_group(self.tr('Contacts')) + sidebar_layout.addWidget(sb_btn(self.tr(self.tr('Manage Contacts')), QStyle.SP_FileDialogDetailedView, self.open_contacts_manager)) + sidebar_layout.addWidget(sb_btn(self.tr('Export'), QStyle.SP_ArrowDown, self.get_contacts)) + sidebar_layout.addWidget(sb_btn(self.tr('Import'), QStyle.SP_ArrowUp, lambda: self.choose_file_and_run('createcontacts'))) + + sb_group(self.tr('Files')) + sidebar_layout.addWidget(sb_btn(self.tr('File Manager'), QStyle.SP_DirOpenIcon, self.open_file_manager)) + + sb_group(self.tr('Settings')) + sidebar_layout.addWidget(sb_btn(self.tr(self.tr('Timeouts')), QStyle.SP_DialogHelpButton, self.open_settings_timeouts)) + sidebar_layout.addWidget(sb_btn(self.tr('Baudrate'), QStyle.SP_DialogHelpButton, self.open_settings_baudrate)) + sidebar_layout.addWidget(sb_btn(self.tr('Language'), QStyle.SP_DialogHelpButton, self.open_settings_language)) + + sb_group(self.tr('Log / Console')) + sidebar_layout.addWidget(sb_btn(self.tr('Open Log'), QStyle.SP_FileIcon, self.open_log)) + + sidebar_layout.addStretch() + splitter.addWidget(sidebar) + + # Right-hand side as a splitter widget + right_widget = QWidget() + right_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + right_col = QVBoxLayout(right_widget) + right_col.setSpacing(6) + right_col.setContentsMargins(6, 0, 0, 0) + + # Connection type selector + conn_type_row = QHBoxLayout() + self._conn_type_group = QButtonGroup(self) + self._rb_bt = QRadioButton('Bluetooth') + self._rb_serial = QRadioButton(self.tr('Serial')) + self._rb_bt.setChecked(True) + self._conn_type_group.addButton(self._rb_bt) + self._conn_type_group.addButton(self._rb_serial) + conn_type_row.addWidget(self._rb_bt) + conn_type_row.addWidget(self._rb_serial) + conn_type_row.addStretch() + right_col.addLayout(conn_type_row) + + dev_row_widget = QWidget() + dev_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + dev_row = QHBoxLayout(dev_row_widget) + dev_row.setContentsMargins(0, 0, 0, 0) + dev_row.setSpacing(4) + self.device = QComboBox() + self.device.setEditable(False) + self.device.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + dev_row.addWidget(self.device, stretch=1) + + btn_refresh = QPushButton('⟳') + btn_refresh.setFixedWidth(32) + btn_refresh.clicked.connect(self.refresh_devices_and_check) + dev_row.addWidget(btn_refresh) + + self.baud = QLineEdit(backend.DEFAULT_BAUD) + self.baud.setVisible(False) + right_col.addWidget(dev_row_widget) + + # Switch connection type + self._rb_bt.toggled.connect(self._on_conn_type_changed) + self._rb_serial.toggled.connect(self._on_conn_type_changed) + + form_layout = QFormLayout() + form_layout.setSpacing(6) + + self.ui_hersteller = QLineEdit("-"); self.ui_hersteller.setReadOnly(True) + self.ui_modell = QLineEdit("-"); self.ui_modell.setReadOnly(True) + self.ui_mac = QLineEdit("-"); self.ui_mac.setReadOnly(True) + self.ui_firmware = QLineEdit("-"); self.ui_firmware.setReadOnly(True) + self.ui_seriennummer = QLineEdit("-"); self.ui_seriennummer.setReadOnly(True) + self.ui_kontakt_anzahl = QLineEdit("-"); self.ui_kontakt_anzahl.setReadOnly(True) + + form_layout.addRow(self.tr("Device Information"), QLabel("")) + form_layout.addRow(self.tr("Manufacturer:"), self.ui_hersteller) + form_layout.addRow(self.tr("Model / Product:"), self.ui_modell) + form_layout.addRow(self.tr("MAC Address:"), self.ui_mac) + form_layout.addRow(self.tr("Firmware Version:"), self.ui_firmware) + form_layout.addRow(self.tr("Serial Number (IPUI):"), self.ui_seriennummer) + form_layout.addRow(self.tr("Contact Count:"), self.ui_kontakt_anzahl) + right_col.addLayout(form_layout) + + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + right_col.addWidget(line) + + self.output = QTextEdit() + self.output.setReadOnly(True) + self.output.setFont(QFont('Monospace', 9)) + right_col.addWidget(self.output, stretch=1) + self._signals.clear_output.connect(self.output.clear) + + splitter.addWidget(right_widget) + # Anfangsbreiten: Sidebar 160px, Rest bekommt den verbleibenden Platz + splitter.setSizes([160, 600]) + # Splitter-Position beim Beenden speichern und beim Start wiederherstellen + self._splitter = splitter + root.addWidget(splitter, stretch=1) + self.set_log_file(DEFAULT_LOG_FILE) + + # Splitter-Position aus den Settings laden + from PySide6.QtCore import QSettings + _s = QSettings('QuickSync4LinuxGui', 'QuickSync4LinuxGui') + splitter_state = _s.value('mainSplitterState') + if splitter_state: + self._splitter.restoreState(splitter_state) + + sb = QStatusBar() + self.setStatusBar(sb) + self._status_dot = QLabel('●') + self._status_dot.setStyleSheet('color: gray;') + self._status_label = QLabel(self.tr('Checking device status…')) + sb.addWidget(self._status_dot) + sb.addWidget(self._status_label, 1) + + self.refresh_devices() + QTimer.singleShot(500, self.check_device_connection) + + def _parse_and_fill_ui(self, action, text): + if not text: + return + + if action == 'contact_count': + self.ui_kontakt_anzahl.setText(text) + return + + def find_val(pattern, string): + match = re.search(pattern, string, re.IGNORECASE) + return match.group(1).strip() if match else None + + current_dev = self.current_device() + if BT_MAC_RE.match(current_dev or ''): + self.ui_mac.setText(current_dev.split('@')[0]) + + hersteller = find_val(r'(?:Hersteller|Manufacturer)\s*:\s*(.*)', text) + modell = find_val(r'(?:Modell|Model|Product)\s*:\s*(.*)', text) + mac = find_val(r'(?:MAC-Adresse|MAC)\s*:\s*(.*)', text) + firmware = find_val(r'(?:Firmware-Version|Firmware)\s*:\s*([0-9\.]+)', text) + seriennummer = find_val(r'(?:Seriennummer|Serial(?:\s*\(IPUI\))?)\s*:\s*(.*)', text) + anzahl = find_val(r'(?:Received|Found|Total)\s*([0-9]+)\s*(?:contacts|Kontakte)', text) + + if hersteller: self.ui_hersteller.setText(hersteller) + if modell: self.ui_modell.setText(modell.split(',')[-1].strip() if ',' in modell else modell) + if mac: self.ui_mac.setText(mac) + if firmware: self.ui_firmware.setText(firmware) + if seriennummer: self.ui_seriennummer.setText(seriennummer) + if anzahl: self.ui_kontakt_anzahl.setText(anzahl) + + def _on_conn_type_changed(self): + """Switch between Bluetooth and serial mode.""" + if self._rb_bt.isChecked(): + self.device.setEditable(False) + else: + self.device.setEditable(True) + self.refresh_devices() + + def refresh_devices(self, prefer=None): + self.device.blockSignals(True) + self.device.clear() + + if self._rb_serial.isChecked(): + # Show serial devices + import glob + serial_ports = sorted( + glob.glob('/dev/ttyACM*') + + glob.glob('/dev/ttyUSB*') + + glob.glob('/dev/rfcomm*') + ) + self._device_map = {p: p for p in serial_ports} + for p in serial_ports: + self.device.addItem(p) + self.device.setEditable(True) + if prefer and prefer in serial_ports: + self.device.setCurrentText(prefer) + elif serial_ports: + self.device.setCurrentIndex(0) + else: + # Show Bluetooth devices + raw = backend.discover_devices() + entries = [(f'{label} ({mac}) [Bluetooth]', mac) for mac, label in raw] + self._device_map = {display: mac for display, mac in entries} + for display, _ in entries: + self.device.addItem(display) + self.device.setEditable(False) + + if prefer: + for display, mac in entries: + if mac == prefer: + self.device.setCurrentText(display) + self.device.blockSignals(False) + return + + # Prefer Gigaset devices + for display, mac in entries: + if 'gigaset' in display.lower(): + self.device.setCurrentText(display) + self.device.blockSignals(False) + return + + if entries: + self.device.setCurrentIndex(0) + elif prefer: + self.device.setCurrentText(prefer) + + self.device.blockSignals(False) + + def refresh_devices_and_check(self): + self.refresh_devices() + self.check_device_connection() + + def current_device(self): + text = self.device.currentText().strip() + return self._device_map.get(text, text) + + def current_device_label(self): + text = self.device.currentText().strip() + for suffix in (' [Bluetooth]', ' (Serial)'): + if text.endswith(suffix): + return text[: -len(suffix)] + return text or self.current_device() + + def check_connection_or_warn(self) -> bool: + if not self.current_device(): + QMessageBox.warning(self, self.tr('No Device'), self.tr('Please select a device first.')) + return False + if self._device_connected is not True: + QMessageBox.warning(self, self.tr('Not Connected'), self.tr('Please connect to a device first.')) + return False + return True + + def _open_connection(self): + """Open a direct connection to the current device.""" + dev = self.current_device() + if not dev: + raise RuntimeError(self.tr('No device selected.')) + baud = int(self.baud.text()) if self.baud.text() else 9600 + return quicksync.open_connection(dev, baud) + + def _set_connection_state(self, connected: bool): + self._device_connected = connected + + def is_device_connected(self): + return bool(self.current_device()) and self._device_connected is not False + + def _append_output(self, text: str): + if text == 'clear': + self.output.clear() + return + self.output.append(text) + self._log_raw_output(text) + + def _log_raw_output(self, text: str): + backend.log(text) + + + def set_log_file(self, path: str | None = None): + self._append_output(self.tr('Log file: {} (max. 1 MB, 3 backups)').format(backend.DEFAULT_LOG_FILE)) + + def _update_status_bar(self, text: str, colour: str): + self._status_dot.setStyleSheet(f'color: {colour};') + self._status_label.setText(text) + + def _show_info_window(self, title, text): + dlg = QDialog(self) + dlg.setWindowTitle(title) + dlg.resize(520, 400) + layout = QVBoxLayout(dlg) + out = QTextEdit() + out.setReadOnly(True) + out.setFont(QFont('Monospace', 9)) + out.setPlainText(text) + layout.addWidget(out) + btn = _make_dialog_buttons(self, QDialogButtonBox.Close) + btn.rejected.connect(dlg.reject) + layout.addWidget(btn) + dlg.exec() + + def run_action(self, action, options=None, file=None): + dev = self.current_device() + if not dev: + self.output.clear() + self._append_output(self.tr('✗ No device selected or connection lost')) + return + + if self._device_connected is False and action not in ('info', 'obexinfo'): + QMessageBox.warning(self, self.tr('Connection Lost'), + self.tr('The device is currently disconnected. Please reconnect.')) + return + + self.output.clear() + sig = self._signals + + def worker(): + ser = None + try: + ser = self._open_connection() + if action == 'info': + result = quicksync.get_info(ser) + sig.show_info.emit(self.tr('Device Info'), result) + sig.clear_output.emit() + sig.action_finished.emit('info', result) + elif action == 'obexinfo': + result = quicksync.get_obex_info(ser) + sig.show_info.emit(self.tr('Obex Info'), result) + sig.clear_output.emit() + sig.action_finished.emit('obexinfo', result) + elif action == 'getcontacts': + if file: + vcf = quicksync.get_contacts(ser) + with open(file, 'wb') as f: + f.write(vcf) + sig.action_finished.emit('getcontacts', vcf.decode('utf-8', errors='replace')) + elif action in ('setcontacts', 'createcontacts'): + if file: + with open(file, 'rb') as f: + vcf = f.read() + quicksync.set_contacts(ser, vcf) + elif action == 'download': + if options and file: + quicksync.download_file(ser, options, file) + elif action == 'upload': + if options and file: + quicksync.upload_file(ser, options, file) + elif action == 'delete': + if options: + quicksync.delete_file(ser, options) + else: + sig.append_text.emit(f'{self.tr("Unknown action")}: {action}') + except Exception as e: + sig.append_text.emit(f'✗ {self.tr("Error")}: {e}') + finally: + if ser: + quicksync.close_connection(ser) + + threading.Thread(target=worker, daemon=True).start() + + def choose_file_and_run(self, action): + if not self.check_connection_or_warn(): + return + path, _ = QFileDialog.getOpenFileName(self, self.tr('Choose VCF file'), os.path.expanduser('~'), self.tr('VCF files (*.vcf);;All files (*)')) + if path: + self.run_action(action, None, path) + + def get_contacts(self): + if not self.check_connection_or_warn(): + return + path, _ = QFileDialog.getSaveFileName(self, self.tr('Save contacts as'), os.path.expanduser('~'), self.tr('VCF files (*.vcf);;All files (*)')) + if path: + self.run_action('getcontacts', None, path) + + + def open_log(self): + # 1. Nutze das Attribut der GUI, falls gesetzt, andernfalls direkt das Backend-Standard-Log + log_path = getattr(self, '_log_file', None) or backend.DEFAULT_LOG_FILE + + # 2. Resolve the path absolutely and expand the tilde + path_target = os.path.abspath(os.path.expanduser(log_path)) + + # 3. Determine the folder, regardless of whether a file or folder was passed + initial_dir = path_target if os.path.isdir(path_target) else os.path.dirname(path_target) + + # Safety net for fresh installs where the folder does not exist yet + if not os.path.exists(initial_dir): + try: + os.makedirs(initial_dir, exist_ok=True) + except Exception: + initial_dir = os.path.expanduser('~') + + # Open the dialog in the correct directory + path, _ = QFileDialog.getOpenFileName( + self, self.tr('Choose log file'), initial_dir, self.tr('Log files (*.log);;All files (*)') + ) + + if not path: + return + try: + subprocess.Popen(['xdg-open', path]) + except Exception as e: + self._append_output(f'✗ {self.tr("Could not open log file")}: {e}') + + def open_settings_timeouts(self): + dlg = QDialog(self) + dlg.setWindowTitle(self.tr('Settings')) + layout = QVBoxLayout(dlg) + form = QFormLayout() + from PySide6.QtWidgets import QSpinBox + chk = QSpinBox(); chk.setRange(1, 3600); chk.setValue(backend.CHECK_TIMEOUT); form.addRow(self.tr('Connection timeout (s):'), chk) + dsk = QSpinBox(); dsk.setRange(1, 3600); dsk.setValue(backend.DISCOVER_TIMEOUT); form.addRow(self.tr('Bluetooth timeout (s):'), dsk) + cli = QSpinBox(); cli.setRange(1, 36000); cli.setValue(backend.CLI_DEFAULT_TIMEOUT); form.addRow(self.tr('CLI timeout (s):'), cli) + layout.addLayout(form) + btns = _make_dialog_buttons(self) + btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); layout.addWidget(btns) + if dlg.exec() == QDialog.Accepted: + backend.CHECK_TIMEOUT = chk.value() + backend.DISCOVER_TIMEOUT = dsk.value() + backend.CLI_DEFAULT_TIMEOUT = cli.value() + backend.save_settings() + + def open_settings_baudrate(self): + dlg = QDialog(self) + dlg.setWindowTitle(self.tr('Settings')) + layout = QVBoxLayout(dlg) + form = QFormLayout() + from PySide6.QtWidgets import QComboBox as _CB + baud_combo = _CB() + baud_combo.setEditable(True) + for b in ['1200', '2400', '4800', '9600', '19200', '38400', '57600', '115200']: + baud_combo.addItem(b) + baud_combo.setCurrentText(backend.DEFAULT_BAUD) + form.addRow(self.tr('Baud rate (Serial):'), baud_combo) + layout.addLayout(form) + btns = _make_dialog_buttons(self) + btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); layout.addWidget(btns) + if dlg.exec() == QDialog.Accepted: + backend.DEFAULT_BAUD = baud_combo.currentText() + self.baud.setText(baud_combo.currentText()) + backend.save_settings() + + def open_settings_language(self): + from PySide6.QtCore import QSettings, QTranslator, QLocale + dlg = QDialog(self) + dlg.setWindowTitle(self.tr('Language')) + layout = QVBoxLayout(dlg) + form = QFormLayout() + lang_combo = QComboBox() + lang_combo.addItem('English', 'en') + lang_combo.addItem('Deutsch', 'de') + _s = QSettings('QuickSync4LinuxGui', 'QuickSync4LinuxGui') + current_lang = _s.value('language', '') + idx = lang_combo.findData(current_lang) + if idx >= 0: + lang_combo.setCurrentIndex(idx) + form.addRow(self.tr('Language') + ':', lang_combo) + layout.addLayout(form) + info = QLabel(self.tr('The language change takes effect after restarting the application.')) + info.setWordWrap(True) + layout.addWidget(info) + btns = _make_dialog_buttons(self) + btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); layout.addWidget(btns) + if dlg.exec() == QDialog.Accepted: + selected_lang = lang_combo.currentData() + _s.setValue('language', selected_lang) + + def open_file_manager(self): + if not self.check_connection_or_warn(): + return + if hasattr(self, '_file_manager_win') and self._file_manager_win is not None: + self._file_manager_win.raise_() + self._file_manager_win.activateWindow() + return + self._file_manager_win = FileManagerWindow(self) + self._file_manager_win.finished.connect(lambda: setattr(self, '_file_manager_win', None)) + + def open_contacts_manager(self): + if not self.check_connection_or_warn(): + return + if hasattr(self, '_contacts_win') and self._contacts_win is not None: + self._contacts_win.raise_() + self._contacts_win.activateWindow() + return + self._contacts_win = ContactsWindow(self) + self._contacts_win.finished.connect(lambda: setattr(self, '_contacts_win', None)) + + def open_file_manager(self): + if not self.check_connection_or_warn(): + return + if hasattr(self, '_file_manager_win') and self._file_manager_win is not None: + self._file_manager_win.raise_() + self._file_manager_win.activateWindow() + return + self._file_manager_win = FileManagerWindow(self) + self._file_manager_win.finished.connect(lambda: setattr(self, '_file_manager_win', None)) + + + + def download_file(self): + remote = simple_input(self, self.tr('Remote file'), self.tr('Enter remote file name:')) + if not remote: return + out, _ = QFileDialog.getSaveFileName(self, self.tr('Save as')) + if out: self.run_action('download', remote, out) + + def upload_file(self): + path, _ = QFileDialog.getOpenFileName(self, self.tr('Choose file to upload')) + if not path: return + remote = simple_input(self, self.tr('Remote name'), self.tr('Remote file name on the device:')) + if not remote: return + self.run_action('upload', remote, path) + + def delete_file(self): + remote = simple_input(self, self.tr('Remote file'), self.tr('Remote file name to delete:')) + if remote: self.run_action('delete', remote) + + def check_device_connection(self): + label = self.current_device_label() + self._update_status_bar(f'{label}: {self.tr("Checking device status…")}' if label else self.tr('Checking device status…'), '#888888') + sig = self._signals + + def worker(): + connected = False + status_text = self.tr('No device connected') + status_color = '#d9534f' + ser = None + dev = self.current_device() + try: + if dev: + ser = self._open_connection() + info_text = quicksync.get_info(ser) + status_text = f'{label} {self.tr("connected")}' if label else self.tr('Device connected') + status_color = '#5cb85c' + connected = True + sig.append_text.emit(f'✓ {status_text}') + self._log_raw_output(info_text) + sig.action_finished.emit('info', info_text) + try: + vcf_bytes = quicksync.get_contacts(ser) + count = len(vcard.parseCards(vcf_bytes.decode('utf-8', errors='replace'))) + sig.action_finished.emit('contact_count', str(count)) + except Exception: + pass + except Exception as e: + key = backend.interpret_connection_error(str(e), dev) + msg = _translate_err(self, key) if key else f'✗ {self.tr("Error")}: {e}' + sig.append_text.emit(msg) + status_text = msg + status_color = '#d9534f' + finally: + if ser: + quicksync.close_connection(ser) + sig.connection_state.emit(connected) + sig.status_update.emit(status_text, status_color) + + threading.Thread(target=worker, daemon=True).start() + + def test_connection(self): + self.output.clear() + self._append_output(self.tr('--- Establishing connection ---')) + label = self.current_device_label() + self._update_status_bar(f'{label}: {self.tr("--- Establishing connection ---")}' if label else self.tr('--- Establishing connection ---'), '#888888') + sig = self._signals + + def worker(): + connected = False + result = '' + ser = None + dev = self.current_device() + try: + if not dev: + result = self.tr('No device selected.') + else: + ser = self._open_connection() + info_text = quicksync.get_info(ser) + result = f'✓ {self.tr("Device connected")}: {label or dev}' + connected = True + self._log_raw_output(info_text) + sig.action_finished.emit('info', info_text) + except Exception as e: + key = backend.interpret_connection_error(str(e), dev) + result = _translate_err(self, key) if key else f'✗ {self.tr("Connection to")} {label} ({dev}) {self.tr("failed")}: {e}' + finally: + if ser: + quicksync.close_connection(ser) + + status = ((f'{label} {self.tr("connected")}' if label else self.tr('Device connected'), '#5cb85c') if connected else (self.tr('No device connected'), '#d9534f')) + sig.connection_state.emit(connected) + sig.append_text.emit(result) + sig.status_update.emit(*status) + + threading.Thread(target=worker, daemon=True).start() + + def closeEvent(self, event): + from PySide6.QtCore import QSettings + _s = QSettings('QuickSync4LinuxGui', 'QuickSync4LinuxGui') + _s.setValue('mainSplitterState', self._splitter.saveState()) + super().closeEvent(event) + + def disconnect_device(self): + dev = self.current_device() + label = self.current_device_label() or dev + if dev and btserial.isBluetoothAddress(dev): + try: subprocess.run(['bluetoothctl', 'disconnect', dev.split('@')[0]], capture_output=True, timeout=5) + except Exception: pass + self.output.clear() + self._append_output(f'✓ {self.tr("Disconnected from")} {label}') + self._set_connection_state(False) + self._update_status_bar(self.tr('No device connected'), '#d9534f') + +# ─── Additional window classes ──────────────────────────────────────────────── + +class _FileManagerSignals(QObject): + setup_ui = Signal(list, str) + set_status = Signal(str) + do_reload = Signal() + show_preview = Signal(str) # tmp_path + show_preview_err = Signal(str) # error text + +class FileManagerWindow(QDialog): + """File manager with a KDE/Dolphin-style layout and fixed Gigaset parser.""" + + IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.bmp', '.gif'} + + def __init__(self, parent): + super().__init__(parent) + self.parent_win = parent + self.setWindowTitle(self.tr('File Manager')) + self.resize(950, 550) + self._files: list[dict] = [] + self._all_files: list[dict] = [] + self._fm_signals = _FileManagerSignals() + + # Hauptlayout (Vertikal) + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + # 1. Toolbar (Dolphin-like) + toolbar = QHBoxLayout() + toolbar.setContentsMargins(4, 2, 4, 2) + from PySide6.QtWidgets import QStyle + + def tbtn(label, icon_name, slot, is_danger=False): + b = QPushButton(' ' + label) + b.setIcon(self.style().standardIcon(icon_name)) + b.clicked.connect(slot) + if is_danger: + b.setStyleSheet("QPushButton { color: #cc241d; }") + else: + b.setStyleSheet("QPushButton { text-align: left; }") + return b + + btn_reload = tbtn(self.tr('Reload'), QStyle.SP_BrowserReload, self.reload) + btn_download = tbtn(self.tr('Download'), QStyle.SP_ArrowDown, self.download_selected) + btn_upload = tbtn(self.tr('Upload'), QStyle.SP_ArrowUp, self.upload_file) + btn_delete = tbtn(self.tr('Delete'), QStyle.SP_TrashIcon, self.delete_selected, is_danger=True) + + toolbar.addWidget(btn_reload) + toolbar.addWidget(btn_download) + toolbar.addWidget(btn_upload) + toolbar.addWidget(btn_delete) + toolbar.addStretch() + layout.addLayout(toolbar) + + # Separator below toolbar + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Plain) + layout.addWidget(sep) + + # 2. Haupt-Inhaltsbereich mit Dreifach-Splitting (Sidebar | Tabelle | Info-Panel) + main_hbox = QHBoxLayout() + main_hbox.setSpacing(6) + + # Left: places/folders sidebar (KDE style) + from PySide6.QtWidgets import QListWidget, QListWidgetItem + self.folder_sidebar = QListWidget() + self.folder_sidebar.setFixedWidth(180) + self.folder_sidebar.setStyleSheet("QListWidget { background: palette(window); border: none; font-weight: bold; }") + self.folder_sidebar.itemClicked.connect(self._on_sidebar_folder_changed) + main_hbox.addWidget(self.folder_sidebar) + + # Vertical separator + v_sep1 = QFrame() + v_sep1.setFrameShape(QFrame.VLine) + v_sep1.setFrameShadow(QFrame.Plain) + main_hbox.addWidget(v_sep1) + + # Mitte: Die Dateitabelle + self.table = QTableWidget(0, 3) + self.table.setHorizontalHeaderLabels([self.tr('Name'), self.tr('Date'), self.tr('Size')]) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.verticalHeader().setVisible(False) + self.table.setAlternatingRowColors(True) + self.table.setShowGrid(False) + self.table.setColumnWidth(0, 300) + self.table.setColumnWidth(1, 130) + self.table.setColumnWidth(2, 90) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.itemSelectionChanged.connect(self._on_selection) + main_hbox.addWidget(self.table, stretch=2) + + # Vertical separator + v_sep2 = QFrame() + v_sep2.setFrameShape(QFrame.VLine) + v_sep2.setFrameShadow(QFrame.Plain) + main_hbox.addWidget(v_sep2) + + # Rechts: Dolphins Informations-Panel (Vorschau) + preview_panel = QWidget() + preview_panel.setFixedWidth(220) + preview_layout = QVBoxLayout(preview_panel) + preview_layout.setContentsMargins(4, 0, 4, 0) + + self.preview_label = QLabel(self.tr('No selection')) + self.preview_label.setAlignment(Qt.AlignCenter) + self.preview_label.setStyleSheet('font-weight: bold; color: palette(window-text);') + + self.preview_image = QLabel() + self.preview_image.setAlignment(Qt.AlignCenter) + self.preview_image.setMinimumHeight(180) + self.preview_image.setStyleSheet('background: palette(base); border: 1px solid palette(mid); border-radius: 4px;') + + self.preview_info = QTextEdit() + self.preview_info.setReadOnly(True) + self.preview_info.setFont(QFont('Monospace', 8)) + self.preview_info.setStyleSheet("QTextEdit { border: none; background: transparent; }") + self.preview_info.setMaximumHeight(120) + + preview_layout.addWidget(self.preview_label) + preview_layout.addWidget(self.preview_image) + preview_layout.addWidget(self.preview_info) + preview_layout.addStretch() + main_hbox.addWidget(preview_panel) + + layout.addLayout(main_hbox, stretch=1) + + # Separator above status bar + sep_bottom = QFrame() + sep_bottom.setFrameShape(QFrame.HLine) + sep_bottom.setFrameShadow(QFrame.Plain) + layout.addWidget(sep_bottom) + + # 3. Statuszeile + bottom_row = QHBoxLayout() + bottom_row.setContentsMargins(6, 2, 6, 2) + self.status_label = QLabel('') + self.space_label = QLabel('') + self.space_label.setStyleSheet('color: palette(mid); font-size: 11px;') + bottom_row.addWidget(self.status_label) + bottom_row.addStretch() + bottom_row.addWidget(self.space_label) + layout.addLayout(bottom_row) + + self.setModal(False) + # Signals jetzt verbinden, da status_label und space_label bereits existieren + self._fm_signals.setup_ui.connect(self._setup_ui_data) + self._fm_signals.set_status.connect(self.status_label.setText) + self._fm_signals.do_reload.connect(self.reload) + self._fm_signals.show_preview.connect(self._show_preview) + self._fm_signals.show_preview_err.connect(self.preview_image.setText) + self.show() + QTimer.singleShot(50, self.reload) + + def reload(self): + self.status_label.setText(self.tr('Loading file list …')) + self.space_label.setText('') + self.table.setRowCount(0) + self.folder_sidebar.clear() + self._all_files = [] + self._files = [] + + fm_sig = self._fm_signals + + def worker(): + ser = None + log = self.parent_win._signals + try: + log.append_text.emit(self.tr('Loading file list …')) + fm_sig.set_status.emit(self.tr('Loading file list …')) + ser = self.parent_win._open_connection() + output = quicksync.list_files(ser) + + try: + files = self._parse_listfiles(output) + except Exception as parse_exc: + fm_sig.set_status.emit(f'✗ {self.tr("Parse error")}: {parse_exc}') + return + + import re as _re + total = _re.search(r'Total Space:\s*([\d\.]+\s*\w+)', output, _re.IGNORECASE) + free = _re.search(r'Free Space:\s*([\d\.]+\s*\w+)', output, _re.IGNORECASE) + space_text = f'Free: {free.group(1)} | Total: {total.group(1)}' if total and free else '' + + if not files: + fm_sig.set_status.emit(self.tr('✗ No files found.')) + return + + log.append_text.emit(self.tr('✓ File list loaded: {} file(s) in {} folder(s)').format(len(files), len(set(f["folder"] for f in files)))) + fm_sig.setup_ui.emit(files, space_text) + except Exception as e: + dev = self.parent_win.current_device() + key = backend.interpret_connection_error(str(e), dev) + msg = _translate_err(self, key) if key else f'✗ {self.tr("Error")}: {e}' + log.append_text.emit(f'[FileManager] {msg}') + fm_sig.set_status.emit(msg) + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def _parse_listfiles(self, text): + import re as _re + files = [] + current_folder = '/' + + # Replace stubborn non-breaking spaces (NBSP) with regular spaces + clean_text = text.replace('\xa0', ' ') + + # Robust regex matching IDs, date formats, and file-size suffixes + line_re = _re.compile(r'^(\d+):\s+(.+?)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+[A-Z]\s+([\d\.]+\s+\w+)') + + for line in clean_text.splitlines(): + stripped = line.strip() + if not stripped: + continue + + # Detect folder changes, e.g. === /Pictures + if stripped.startswith('==='): + current_folder = stripped.replace('===', '').strip() + continue + + m = line_re.match(stripped) + if m: + file_id, name, date_str, time_str, size_str = m.groups() + files.append({ + 'id': file_id, + 'folder': current_folder, + 'name': name.strip(), + 'date': f"{date_str} {time_str}", + 'size': size_str, + }) + return files + + def _setup_ui_data(self, files, space_text): + self._all_files = files + self.space_label.setText(space_text) + folders = sorted(list(set(f['folder'] for f in files))) + if not folders: + folders = ['/'] + from PySide6.QtWidgets import QStyle, QListWidgetItem as _LWI + self.folder_sidebar.clear() + for folder in folders: + item = _LWI(self.style().standardIcon(QStyle.SP_DirIcon), folder) + self.folder_sidebar.addItem(item) + if self.folder_sidebar.count() > 0: + self.folder_sidebar.setCurrentRow(0) + self._filter_by_folder(self.folder_sidebar.item(0).text()) + else: + self.status_label.setText(self.tr('✗ No files found.')) + + def _on_sidebar_folder_changed(self, item): + self._filter_by_folder(item.text()) + + def _filter_by_folder(self, folder_name): + self._files = [f for f in self._all_files if f['folder'] == folder_name] + self._populate(self._files) + + def _populate(self, files): + self.table.setRowCount(0) + from PySide6.QtWidgets import QStyle as _QStyle + img_exts = self.IMAGE_EXTS + for f in files: + row = self.table.rowCount() + self.table.insertRow(row) + + ext = os.path.splitext(f['name'])[1].lower() + icon_type = _QStyle.SP_FileDialogDetailedView if ext in img_exts else _QStyle.SP_FileIcon + icon = self.style().standardIcon(icon_type) + + name_item = QTableWidgetItem(f['name']) + name_item.setIcon(icon) + + self.table.setItem(row, 0, name_item) + self.table.setItem(row, 1, QTableWidgetItem(f.get('date', '-'))) + self.table.setItem(row, 2, QTableWidgetItem(f['size'])) + + self.table.resizeRowsToContents() + current_folder = self.folder_sidebar.currentItem().text() if self.folder_sidebar.currentItem() else "" + self.status_label.setText(self.tr('{} file(s) in "{}"').format(len(files), current_folder)) + + def _selected_file(self): + row = self.table.currentRow() + if row < 0 or row >= len(self._files): + return None + return self._files[row] + + def _on_selection(self): + f = self._selected_file() + if not f: + self.preview_image.clear() + self.preview_label.setText(self.tr('No selection')) + self.preview_info.clear() + return + ext = os.path.splitext(f['name'])[1].lower() + self.preview_label.setText(f['name']) + self.preview_info.setText( + f"ID: {f['id']}
" + f"{self.tr('Path')}: {f['folder']}/{f['name']}
" + f"{self.tr('Size')}: {f['size']}
" + f"{self.tr('Date')}: {f['date']}" + ) + if ext in self.IMAGE_EXTS: + self.preview_image.setText(f'⏳ {self.tr("Loading image …")}') + self._load_preview(f) + else: + self.preview_image.setText(self.tr('No preview available')) + + def _load_preview(self, f): + fm_sig = self._fm_signals + def worker(): + ser = None + try: + fd, tmp_path = tempfile.mkstemp(suffix=os.path.splitext(f['name'])[1]) + os.close(fd) + remote_path = f"{f['folder']}/{f['name']}" + ser = self.parent_win._open_connection() + quicksync.download_file(ser, remote_path, tmp_path) + if os.path.exists(tmp_path): + fm_sig.show_preview.emit(tmp_path) + else: + fm_sig.show_preview_err.emit('✗ Preview not available') + except Exception as e: + fm_sig.show_preview_err.emit(f'✗ {e}') + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def _show_preview(self, path): + pixmap = QPixmap(path) + try: + os.unlink(path) + except OSError: + pass + if pixmap.isNull(): + self.preview_image.setText(f'✗ {self.tr("Image error")}') + return + scaled = pixmap.scaled(200, 200, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.preview_image.setPixmap(scaled) + + def download_selected(self): + f = self._selected_file() + if not f: + QMessageBox.information(self, self.tr(self.tr('Notice')), self.tr(self.tr('Please select a file.'))) + return + save_path, _ = QFileDialog.getSaveFileName(self, self.tr('Save as'), f['name']) + if not save_path: + return + self.status_label.setText(f'⏳ {self.tr("Downloading")}: {f["name"]} …') + fm_sig = self._fm_signals + def worker(): + ser = None + try: + remote_path = f"{f['folder']}/{f['name']}" + ser = self.parent_win._open_connection() + quicksync.download_file(ser, remote_path, save_path) + fm_sig.set_status.emit(f'✓ {self.tr("Saved")}: {save_path}') + except Exception as e: + fm_sig.set_status.emit(f'✗ {e}') + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def upload_file(self): + path, _ = QFileDialog.getOpenFileName(self, self.tr('Choose file to upload'), os.path.expanduser('~')) + if not path: + return + self.status_label.setText(f'⏳ {self.tr("Uploading")}: {os.path.basename(path)} …') + fm_sig = self._fm_signals + def worker(): + ser = None + try: + ser = self.parent_win._open_connection() + quicksync.upload_file(ser, os.path.basename(path), path) + fm_sig.set_status.emit(self.tr('✓ Upload complete')) + fm_sig.do_reload.emit() + except Exception as e: + fm_sig.set_status.emit(f'✗ {e}') + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def delete_selected(self): + f = self._selected_file() + if not f: + QMessageBox.information(self, self.tr('Notice'), self.tr('Please select a file.')) + return + r = QMessageBox.question(self, self.tr('Delete'), self.tr('Really delete file "{}"?').format(f['name'])) + if r != QMessageBox.Yes: + return + self.status_label.setText(f'⏳ {self.tr("Deleting")}: {f["name"]} …') + fm_sig = self._fm_signals + fm_sig = self._fm_signals + def worker(): + ser = None + try: + remote_path = f"{f['folder']}/{f['name']}" + ser = self.parent_win._open_connection() + quicksync.delete_file(ser, remote_path) + fm_sig.set_status.emit(f'✓ {self.tr("Deleted")}: {f["name"]}') + fm_sig.do_reload.emit() + except Exception as e: + fm_sig.set_status.emit(f'✗ {e}') + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + +class ContactsWindow(QDialog): + COLUMNS = [('name', 'Name', 200), ('cell', 'Mobile', 130), ('home', 'Home', 130), ('work', 'Work', 130), ('email', 'E-Mail', 200)] + + def __init__(self, parent: QuickSyncGUI): + super().__init__(parent) + self.parent_win = parent + self.setWindowTitle(self.tr('Manage Contacts')) + self.resize(900, 500) + self.cards: list[dict] = [] + self._row_to_index: dict[int, int] = {} + self.modified_luids: set[str] = set() + self.deleted_luids: set[str] = set() + self.temp_vcf_path: str | None = None + self._reload_thread: threading.Thread | None = None + + layout = QVBoxLayout(self) + toolbar = QHBoxLayout() + def tbtn(label, slot): + b = QPushButton(label); b.clicked.connect(slot); toolbar.addWidget(b); return b + tbtn(self.tr('New'), self.new_contact) + tbtn(self.tr('Edit'), self.edit_selected) + tbtn(self.tr('Delete'), self.delete_selected) + toolbar.addSpacing(12) + tbtn(self.tr('Reload'), self.reload_with_confirm) + tbtn(self.tr('Save'), self.save) + tbtn(self.tr('Transmit'), self.transmit) + tbtn(self.tr('Close'), self._on_close_request) + toolbar.addStretch() + layout.addLayout(toolbar) + + self.table = QTableWidget(0, len(self.COLUMNS)) + self.table.setHorizontalHeaderLabels([c[1] for c in self.COLUMNS]) + for i, (_, _, w) in enumerate(self.COLUMNS): self.table.setColumnWidth(i, w) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.doubleClicked.connect(lambda _: self.edit_selected()) + layout.addWidget(self.table, stretch=1) + + self.status_label = QLabel('') + layout.addWidget(self.status_label) + self.setModal(False) + self.show() + + # Sofortiger visueller Indikator im Hauptfenster + self.parent_win.ui_kontakt_anzahl.setText(self.tr("loading…")) + QTimer.singleShot(50, self.reload) + + def _refresh_status(self): + pending = sum(1 for c in self.cards if not c.get('luid')) + len(self.modified_luids) + len(self.deleted_luids) + suffix = f' — {pending} ' + self.tr('unsaved change(s)') if pending else '' + self.status_label.setText(f'{len(self.cards)} ' + self.tr('contact(s)') + suffix) + + # Absolut direkte und sichere Aktualisierung des Hauptfenster-Labels aus dem UI-Thread + self.parent_win.ui_kontakt_anzahl.setText(str(len(self.cards))) + + def reload(self): + if self._reload_thread and self._reload_thread.is_alive(): + self.parent_win._signals.append_text.emit(f'⚠ {self.tr("Reload already in progress — please wait.")}') + return + self.parent_win.output.clear() + self.status_label.setText(self.tr('Loading contacts …')) + sig = self.parent_win._signals + + def worker(): + if not self.parent_win.current_device(): + QTimer.singleShot(0, self, lambda: self._on_error(self.tr('Load failed'), RuntimeError(self.tr('No device connected.')))) + return + ser = None + fd, path = tempfile.mkstemp(suffix='.vcf', prefix='quicksync_') + os.close(fd) + try: + ser = self.parent_win._open_connection() + vcf_bytes = quicksync.get_contacts(ser) + with open(path, 'wb') as f: + f.write(vcf_bytes) + vcf = vcf_bytes.decode('utf-8', errors='replace') + if not vcf.strip(): + raise RuntimeError(self.tr('Device returned an empty response.')) + cards = vcard.parseCards(vcf) + sig.append_text.emit(self.tr('{} contact(s) processed').format(len(cards))) + QTimer.singleShot(0, self.parent_win, lambda: self.parent_win.ui_kontakt_anzahl.setText(str(len(cards)))) + QTimer.singleShot(0, self, lambda: self._populate(cards, path)) + except Exception as e: + try: + os.unlink(path) + except OSError: + pass + QTimer.singleShot(0, self, lambda: self._on_error(self.tr('Load failed'), e)) + finally: + if ser: + quicksync.close_connection(ser) + + self._reload_thread = threading.Thread(target=worker, daemon=True) + self._reload_thread.start() + + def reload_with_confirm(self): + pending = sum(1 for c in self.cards if not c.get('luid')) + len(self.modified_luids) + len(self.deleted_luids) + if pending > 0: + r = QMessageBox.question(self, self.tr('Reload'), self.tr('There are unsaved changes. Reload anyway?')) + if r != QMessageBox.Yes: + return + self.modified_luids.clear() + self.deleted_luids.clear() + self.reload() + + def _on_error(self, title, exc): + self.status_label.setText('') + QMessageBox.critical(self, title, str(exc)) + + def _populate(self, cards, temp_path=None): + self.temp_vcf_path = temp_path + self.cards = cards + self._rebuild_table() + + def _rebuild_table(self): + self.table.setRowCount(0) + self._row_to_index = {} + for i, c in enumerate(self.cards): + row = self.table.rowCount() + self.table.insertRow(row) + self._row_to_index[row] = i + values = [ + vcard.displayName(c), + c.get('tels', {}).get('CELL', ''), + c.get('tels', {}).get('HOME', ''), + c.get('tels', {}).get('WORK', ''), + (c.get('emails', {}).get('HOME', '') or c.get('emails', {}).get('WORK', '') or c.get('emails', {}).get('OTHER', '')), + ] + for col, val in enumerate(values): + self.table.setItem(row, col, QTableWidgetItem(val)) + self._refresh_status() + + def new_contact(self): + blank = {'luid': None, 'last_name': '', 'first_name': '', 'middle_name': '', 'prefix': '', 'suffix': '', 'nickname': '', 'org': '', 'title': '', 'bday': '', 'note': '', 'url': '', 'tels': {'HOME': '', 'CELL': '', 'WORK': '', 'FAX': '', 'OTHER': ''}, 'emails': {'HOME': '', 'WORK': '', 'OTHER': ''}, 'addresses': {'HOME': {'pobox': '', 'ext': '', 'street': '', 'city': '', 'region': '', 'zip': '', 'country': ''}, 'WORK': {'pobox': '', 'ext': '', 'street': '', 'city': '', 'region': '', 'zip': '', 'country': ''}}, 'extras': []} + ContactEditor(self, blank, on_save=lambda c: (self.cards.append(c), self._rebuild_table())) + + def edit_selected(self): + rows = self.table.selectedItems() + if not rows: return + idx = self._row_to_index.get(self.table.currentRow()) + if idx is not None: + ContactEditor(self, self.cards[idx], on_save=lambda c: (self.modified_luids.add(c['luid']) if c.get('luid') else None, self._rebuild_table())) + + def delete_selected(self): + rows = self.table.selectedItems() + if not rows: return + idx = self._row_to_index.get(self.table.currentRow()) + if idx is not None: + luid = self.cards[idx].get('luid') + if luid: self.deleted_luids.add(luid) + del self.cards[idx] + self._rebuild_table() + + def save(self): + if not self.temp_vcf_path: return + with open(self.temp_vcf_path, 'w', encoding='utf-8') as f: + for c in self.cards: f.write(vcard.formatCard(c)) + + def transmit(self): + deletes = list(self.deleted_luids) + creates = [c for c in self.cards if not c.get('luid')] + + def worker(): + ser = None + try: + ser = self.parent_win._open_connection() + for luid in deletes: + quicksync.delete_contact(ser, luid) + quicksync.close_connection(ser) + ser = self.parent_win._open_connection() + if creates: + for c in creates: + vcf_bytes = vcard.formatCard(c).encode('utf-8') + quicksync.create_contact(ser, vcf_bytes) + quicksync.close_connection(ser) + ser = self.parent_win._open_connection() + QTimer.singleShot(0, self.reload) + except Exception as e: + QTimer.singleShot(0, lambda: QMessageBox.critical(self, self.tr('Error'), str(e))) + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def _on_close_request(self): self.close() + +class ContactEditor(QDialog): + def __init__(self, parent, card, on_save): + super().__init__(parent) + self.setWindowTitle(self.tr('Edit Contact') if card.get('luid') else self.tr('New Contact')) + self.resize(400, 450) + self.card = card + self.on_save = on_save + self.entries = {} + layout = QVBoxLayout(self) + form = QFormLayout() + + self.entries['first_name'] = QLineEdit(card.get('first_name', '')) + self.entries['last_name'] = QLineEdit(card.get('last_name', '')) + self.entries['cell'] = QLineEdit(card.get('tels', {}).get('CELL', '')) + self.entries['home'] = QLineEdit(card.get('tels', {}).get('HOME', '')) + self.entries['email'] = QLineEdit(card.get('emails', {}).get('HOME', '')) + + form.addRow(self.tr('First name') + ':', self.entries['first_name']) + form.addRow(self.tr('Last name') + ':', self.entries['last_name']) + form.addRow(self.tr('Mobile') + ':', self.entries['cell']) + form.addRow(self.tr('Phone') + ':', self.entries['home']) + form.addRow(self.tr('E-Mail') + ':', self.entries['email']) + layout.addLayout(form) + + btns = _make_dialog_buttons(self) + btns.accepted.connect(self._save); btns.rejected.connect(self.reject) + layout.addWidget(btns) + self.show() + + def _save(self): + self.card['first_name'] = self.entries['first_name'].text() + self.card['last_name'] = self.entries['last_name'].text() + self.card.setdefault('tels', {})['CELL'] = self.entries['cell'].text() + self.card.setdefault('tels', {})['HOME'] = self.entries['home'].text() + self.card.setdefault('emails', {})['HOME'] = self.entries['email'].text() + self.on_save(self.card) + self.accept() + +def simple_input(parent, title, prompt) -> str | None: + dlg = QDialog(parent); dlg.setWindowTitle(title) + l = QVBoxLayout(dlg); l.addWidget(QLabel(prompt)); e = QLineEdit(); l.addWidget(e) + b = _make_dialog_buttons(dlg); b.accepted.connect(dlg.accept); b.rejected.connect(dlg.reject); l.addWidget(b) + if dlg.exec() == QDialog.Accepted: return e.text().strip() or None + return None + +def run(): + import sys + app = QApplication.instance() or QApplication(sys.argv) + + # Load translation: English is the default. A translator is only + # installed if the user explicitly selected a different language + # via the Language settings dialog. + from PySide6.QtCore import QTranslator, QSettings + _s = QSettings('QuickSync4LinuxGui', 'QuickSync4LinuxGui') + saved_lang = _s.value('language', '') + if saved_lang and saved_lang != 'en': + translator = QTranslator(app) + lang_dir = os.path.join(os.path.dirname(__file__), 'lang') + if translator.load(saved_lang, lang_dir): + app.installTranslator(translator) + win = QuickSyncGUI() + win.show() + app.exec() + +if __name__ == '__main__': + run() \ No newline at end of file diff --git a/QuickSync4LinuxGui/QuickSync4LinuxGui/lang/de.qm b/QuickSync4LinuxGui/QuickSync4LinuxGui/lang/de.qm new file mode 100644 index 0000000..cb4a045 Binary files /dev/null and b/QuickSync4LinuxGui/QuickSync4LinuxGui/lang/de.qm differ diff --git a/QuickSync4LinuxGui/QuickSync4LinuxGui/lang/de.ts b/QuickSync4LinuxGui/QuickSync4LinuxGui/lang/de.ts new file mode 100644 index 0000000..6faeda3 --- /dev/null +++ b/QuickSync4LinuxGui/QuickSync4LinuxGui/lang/de.ts @@ -0,0 +1,677 @@ + + + + + QuickSyncGUI + + <b>Device Information</b> + <b>Geräteinformationen</b> + + + Manufacturer: + Hersteller: + + + Model / Product: + Modell / Produkt: + + + MAC Address: + MAC-Adresse: + + + Firmware Version: + Firmware-Version: + + + Serial Number (IPUI): + Seriennummer (IPUI): + + + Contact Count: + Anzahl Kontakte: + + + Device + Gerät + + + Connect + Verbinden + + + Disconnect + Trennen + + + Contacts + Kontakte + + + Manage + Verwalten + + + Export + Exportieren + + + Import + Importieren + + + Files + Dateien + + + File Manager + Dateimanager + + + Settings + Einstellungen + + + Timeouts + Zeitüberschreitungen + + + Baud Rate + Baudrate + + + Log / Console + Log / Konsole + + + Open Log + Log öffnen + + + connected + verbunden + + + No device connected + Kein Gerät verbunden + + + No device connected. + Kein Gerät verbunden. + + + Device connected + Gerät verbunden + + + No Device + Kein Gerät + + + Please select a device first. + Bitte zuerst ein Gerät auswählen. + + + Not Connected + Nicht verbunden + + + Please connect to a device first. + Bitte zuerst eine Verbindung herstellen. + + + Retrieving info... + Info wird abgerufen... + + + Retrieving OBEX info... + OBEX Info wird abgerufen... + + + --- Establishing connection --- + --- Verbindung wird hergestellt --- + + + Checking device status… + Gerätestatus wird geprüft… + + + Device Info + Geräteinformationen + + + Obex Info + Obex Info + + + Connection Lost + Verbindung unterbrochen + + + The device is currently disconnected. Please reconnect. + Das Gerät ist derzeit getrennt. Bitte neu verbinden. + + + No device selected. + Kein Gerät ausgewählt. + + + Loading file list … + Dateiliste wird geladen … + + + ✗ No files found. + ✗ Keine Dateien gefunden. + + + ✗ Preview not available + ✗ Vorschau nicht verfügbar + + + ✓ Upload complete + ✓ Upload abgeschlossen + + + ✗ Upload failed + ✗ Upload fehlgeschlagen + + + ✗ Delete failed + ✗ Löschen fehlgeschlagen + + + Save as + Speichern als + + + Choose file to upload + Datei zum Hochladen wählen + + + Notice + Hinweis + + + Please select a file. + Bitte eine Datei auswählen. + + + Delete + Löschen + + + Choose log file + Log-Datei wählen + + + Log files (*.log);;All files (*) + Log-Dateien (*.log);;Alle Dateien (*) + + + VCF files (*.vcf);;All files (*) + VCF-Dateien (*.vcf);;Alle Dateien (*) + + + Export contacts as + Kontakte exportieren als + + + Import VCF file + VCF-Datei importieren + + + Choose VCF file + VCF-Datei wählen + + + Save contacts as + Kontakte speichern als + + + Manage Contacts + Kontakte verwalten + + + Loading contacts … + Kontakte werden geladen … + + + Edit Contact + Kontakt bearbeiten + + + Load failed + Laden fehlgeschlagen + + + Device returned an empty response. + Gerät hat eine leere Antwort geliefert. + + + Error + Fehler + + + Connection timeout (s): + Verbindungs-Timeout (s): + + + Bluetooth timeout (s): + Bluetooth Timeout (s): + + + CLI timeout (s): + CLI Timeout (s): + + + Baud rate (Serial): + Baudrate (Seriell): + + + QuickSync4LinuxGui + QuickSync4LinuxGui + + + OK + OK + + + Cancel + Abbrechen + + + Close + Schließen + + + Language + Sprache + + + Info + Info + + + Baudrate + Baudrate + + + The language change takes effect after restarting the application. + Die Sprachänderung wird nach einem Neustart der Anwendung wirksam. + + + + + ✗ Device not connected — Please enable Bluetooth on your phone. + ✗ Gerät nicht verbunden — Bitte Bluetooth auf dem Telefon aktivieren. + + + ✗ Device not reachable — Please turn on the screen and unlock your phone. + ✗ Gerät nicht erreichbar — Bitte Bildschirm einschalten und Telefon entsperren. + + + ✗ Device not reachable — Please enable Bluetooth and turn on the screen of your phone. + ✗ Gerät nicht erreichbar — Bitte Bluetooth aktivieren und Bildschirm einschalten. + + + ✗ Connection refused — Please enable Bluetooth on your phone. + ✗ Verbindung abgelehnt — Bitte Bluetooth auf dem Telefon aktivieren. + + + ✗ Device not found — Please enable Bluetooth on your phone. + ✗ Gerät nicht gefunden — Bitte Bluetooth auf dem Telefon aktivieren. + + + ✗ Timeout — Please unlock your phone and try again. + ✗ Zeitüberschreitung — Bitte Telefon entsperren und erneut versuchen. + + + ✗ No device selected or connection lost + ✗ Kein Gerät ausgewählt oder Verbindung verloren + + + Connection to + Verbindung zu + + + failed + fehlgeschlagen + + + Unknown action + Unbekannte Aktion + + + {} file(s) in "{}" + {} Datei(en) in „{}" + + + Serial + Seriell + + + Log file: {} (max. 1 MB, 3 backups) + Log-Datei: {} (max. 1 MB, 3 Backups) + + + Could not open log file + Log-Datei konnte nicht geöffnet werden + + + Disconnected from + Verbindung getrennt zu + + + Remote file + Remote-Datei + + + Enter remote file name: + Remote-Dateiname eingeben: + + + Remote name + Remote-Name + + + Remote file name on the device: + Remote-Dateiname auf dem Gerät: + + + Remote file name to delete: + Remote-Dateiname zum Löschen: + + + + + FileManagerWindow + + {} file(s) in "{}" + {} Datei(en) in „{}" + + + Name + Name + + + Date + Datum + + + Size + Größe + + + Path + Pfad + + + No selection + Keine Auswahl + + + Loading image … + Lade Bild … + + + No preview available + Keine Bildvorschau + + + Image error + Bildfehler + + + Deleting + Lösche + + + Reload + Aktualisieren + + + Download + Herunterladen + + + Upload + Hochladen + + + Really delete file "{}"? + Datei „{}" wirklich löschen? + + + Loading file list … + Dateiliste wird geladen … + + + ✓ File list loaded: {} file(s) in {} folder(s) + ✓ Dateiliste geladen: {} Datei(en) in {} Ordner(n) + + + ✗ No files found. + ✗ Keine Dateien gefunden. + + + ✗ Preview not available + ✗ Vorschau nicht verfügbar + + + ✓ Upload complete + ✓ Upload abgeschlossen + + + ✗ Upload failed + ✗ Upload fehlgeschlagen + + + ✗ Delete failed + ✗ Löschen fehlgeschlagen + + + Save as + Speichern als + + + Choose file to upload + Datei zum Hochladen wählen + + + Notice + Hinweis + + + Please select a file. + Bitte eine Datei auswählen. + + + Delete + Löschen + + + Downloading + Herunterladen + + + Saved + Gespeichert + + + Uploading + Hochladen + + + Deleted + Gelöscht + + + Error + Fehler + + + Parse error + Parse-Fehler + + + + + ContactsWindow + + loading… + wird geladen… + + + Reload already in progress — please wait. + Neu laden läuft bereits — bitte warten. + + + There are unsaved changes. Reload anyway? + Es gibt ungespeicherte Änderungen. Trotzdem neu laden? + + + Mobile + Mobil + + + Home + Privat + + + Work + Geschäftl. + + + contact(s) + Kontakt(e) + + + unsaved change(s) + ungespeicherte Änderung(en) + + + New + Neu + + + Edit + Bearbeiten + + + Delete + Löschen + + + Reload + Neu laden + + + Save + Speichern + + + Transmit + Übertragen + + + Close + Schließen + + + {} contact(s) processed + {} Kontakt(e) verarbeitet + + + Loading contacts … + Kontakte werden geladen … + + + Manage Contacts + Kontakte verwalten + + + Edit Contact + Kontakt bearbeiten + + + Load failed + Laden fehlgeschlagen + + + Device returned an empty response. + Gerät hat eine leere Antwort geliefert. + + + Error + Fehler + + + VCF files (*.vcf);;All files (*) + VCF-Dateien (*.vcf);;Alle Dateien (*) + + + Export contacts as + Kontakte exportieren als + + + Import VCF file + VCF-Datei importieren + + + Choose VCF file + VCF-Datei wählen + + + Save contacts as + Kontakte speichern als + + + Not Connected + Nicht verbunden + + + Please connect to a device first. + Bitte zuerst eine Verbindung herstellen. + + + + + ContactEditor + + Edit Contact + Kontakt bearbeiten + + + New Contact + Neuer Kontakt + + + First name + Vorname + + + Last name + Nachname + + + Mobile + Mobil + + + Phone + Telefon + + + E-Mail + E-Mail + + + OK + OK + + + Cancel + Abbrechen + + + \ No newline at end of file diff --git a/QuickSync4LinuxGui/QuickSync4LinuxGui/vcard.py b/QuickSync4LinuxGui/QuickSync4LinuxGui/vcard.py new file mode 100644 index 0000000..91bccc7 --- /dev/null +++ b/QuickSync4LinuxGui/QuickSync4LinuxGui/vcard.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 + +import quopri +import re + + +CARD_RE = re.compile(r"BEGIN:VCARD[\S\s]*?END:VCARD", re.IGNORECASE) + + +def _decode_value(value, params): + if params.get('ENCODING', '').upper() == 'QUOTED-PRINTABLE': + charset = params.get('CHARSET', 'UTF-8') + try: + return quopri.decodestring(value.encode('ascii', errors='replace')).decode(charset, errors='replace') + except Exception: + return value + return value + + +def _qp_encode(text): + encoded = quopri.encodestring(text.encode('utf-8'), quotetabs=False).decode('ascii') + return encoded.replace('\n', '').replace('\r', '') + + +def _needs_qp(text): + return any(ord(c) > 127 or c == ';' for c in text) + + +def _split_n(value): + parts = value.split(';') + while len(parts) < 5: + parts.append('') + return { + 'last_name': parts[0], + 'first_name': parts[1], + 'middle_name': parts[2], + 'prefix': parts[3], + 'suffix': parts[4], + } + + +def _join_n(card): + return ';'.join([ + card.get('last_name', ''), + card.get('first_name', ''), + card.get('middle_name', ''), + card.get('prefix', ''), + card.get('suffix', ''), + ]) + + +def _split_adr(value): + parts = value.split(';') + while len(parts) < 7: + parts.append('') + return { + 'pobox': parts[0], + 'ext': parts[1], + 'street': parts[2], + 'city': parts[3], + 'region': parts[4], + 'zip': parts[5], + 'country': parts[6], + } + + +def _join_adr(adr): + return ';'.join([ + adr.get('pobox', ''), + adr.get('ext', ''), + adr.get('street', ''), + adr.get('city', ''), + adr.get('region', ''), + adr.get('zip', ''), + adr.get('country', ''), + ]) + + +def parseLines(card_text): + """Yield (name, params_dict, value) tuples for the VCARD body.""" + raw = card_text.replace('\r\n', '\n').split('\n') + + merged = [] + i = 0 + while i < len(raw): + line = raw[i] + upper = line.upper() + is_qp = 'QUOTED-PRINTABLE' in upper + while is_qp and line.endswith('=') and i + 1 < len(raw): + i += 1 + line = line[:-1] + raw[i] + merged.append(line) + i += 1 + + for line in merged: + if ':' not in line: + continue + prefix, value = line.split(':', 1) + head = prefix.split(';') + name = head[0].strip().upper() + if name in ('BEGIN', 'END', 'VERSION'): + continue + params = {} + types = [] + for p in head[1:]: + if '=' in p: + k, v = p.split('=', 1) + params[k.strip().upper()] = v.strip() + else: + types.append(p.strip().upper()) + if types: + params['TYPE'] = types + yield name, params, _decode_value(value, params) + + +def parseCards(vcf_text): + """Parse a full VCF blob, return list of contact dicts.""" + out = [] + for raw_card in CARD_RE.findall(vcf_text): + card = { + 'luid': None, + 'last_name': '', 'first_name': '', 'middle_name': '', 'prefix': '', 'suffix': '', + 'nickname': '', + 'org': '', + 'title': '', + 'bday': '', + 'note': '', + 'tels': {'HOME': '', 'CELL': '', 'WORK': '', 'FAX': '', 'OTHER': ''}, + 'emails': {'HOME': '', 'WORK': '', 'OTHER': ''}, + 'addresses': {'HOME': _split_adr(''), 'WORK': _split_adr('')}, + 'url': '', + 'extras': [], + } + for name, params, value in parseLines(raw_card): + types = params.get('TYPE', []) + if name == 'X-IRMC-LUID': + card['luid'] = value + elif name == 'N': + card.update(_split_n(value)) + elif name == 'FN': + if not card['first_name'] and not card['last_name']: + card['first_name'] = value + elif name == 'NICKNAME': + card['nickname'] = value + elif name == 'ORG': + card['org'] = value + elif name == 'TITLE': + card['title'] = value + elif name == 'BDAY': + card['bday'] = value + elif name == 'NOTE': + card['note'] = value + elif name == 'URL': + card['url'] = value + elif name == 'TEL': + key = next((t for t in ('HOME', 'CELL', 'WORK', 'FAX') if t in types), 'OTHER') + card['tels'][key] = value + elif name == 'EMAIL': + key = next((t for t in ('HOME', 'WORK') if t in types), 'OTHER') + card['emails'][key] = value + elif name == 'ADR': + key = 'WORK' if 'WORK' in types else 'HOME' + card['addresses'][key] = _split_adr(value) + else: + card['extras'].append((name, params, value)) + out.append(card) + return out + + +def _emit(name, value, force_qp=False): + if force_qp or _needs_qp(value): + return f'{name};ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(value)}' + return f'{name}:{value}' + + +def formatCard(card): + """Serialize a contact dict to a VCF 2.1 VCARD block (CRLF line endings).""" + lines = ['BEGIN:VCARD', 'VERSION:2.1'] + if card.get('luid'): + lines.append(f'X-IRMC-LUID:{card["luid"]}') + + n_value = _join_n(card) + if any(ord(c) > 127 for c in n_value): + lines.append(f'N;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(n_value)}') + else: + lines.append(f'N:{n_value}') + + full_name = (card.get('first_name', '') + ' ' + card.get('last_name', '')).strip() + if full_name: + if any(ord(c) > 127 for c in full_name): + lines.append(f'FN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(full_name)}') + else: + lines.append(f'FN:{full_name}') + + if card.get('nickname'): + lines.append(_emit('NICKNAME', card['nickname'])) + if card.get('org'): + lines.append(_emit('ORG', card['org'])) + if card.get('title'): + lines.append(_emit('TITLE', card['title'])) + + for kind in ('HOME', 'CELL', 'WORK', 'FAX', 'OTHER'): + value = card.get('tels', {}).get(kind, '').strip() + if value: + lines.append(f'TEL;{kind}:{value}' if kind != 'OTHER' else f'TEL:{value}') + + for kind in ('HOME', 'WORK', 'OTHER'): + value = card.get('emails', {}).get(kind, '').strip() + if value: + lines.append(f'EMAIL;{kind}:{value}' if kind != 'OTHER' else f'EMAIL:{value}') + + for kind in ('HOME', 'WORK'): + adr = card.get('addresses', {}).get(kind, {}) + adr_value = _join_adr(adr) if adr else '' + if adr_value.strip(';'): + if any(ord(c) > 127 for c in adr_value): + lines.append(f'ADR;{kind};ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(adr_value)}') + else: + lines.append(f'ADR;{kind}:{adr_value}') + + if card.get('bday'): + lines.append(f'BDAY:{card["bday"]}') + if card.get('url'): + lines.append(_emit('URL', card['url'])) + if card.get('note'): + lines.append(_emit('NOTE', card['note'])) + + for name, params, value in card.get('extras', []): + param_str = '' + for k, v in params.items(): + if k == 'TYPE': + for t in v: + param_str += f';{t}' + else: + param_str += f';{k}={v}' + if any(ord(c) > 127 for c in value) and 'ENCODING' not in params: + lines.append(f'{name}{param_str};ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(value)}') + else: + lines.append(f'{name}{param_str}:{value}') + + lines.append('END:VCARD') + return '\r\n'.join(lines) + '\r\n' + + +def displayName(card): + parts = [card.get('first_name', ''), card.get('last_name', '')] + name = ' '.join(p for p in parts if p).strip() + return name or card.get('org', '') or '(unbenannt)' diff --git a/QuickSync4LinuxGui/__init__.py b/QuickSync4LinuxGui/__init__.py new file mode 100644 index 0000000..898d364 --- /dev/null +++ b/QuickSync4LinuxGui/__init__.py @@ -0,0 +1,7 @@ +__title__ = 'QuickSync4LinuxGui' +__author__ = 'Georg Sieber' +__license__ = 'GPL-3.0' +__version__ = '1.0' +__website__ = 'https://github.com/schorschii/QuickSync4Linux' + +__all__ = [__author__, __license__, __version__] diff --git a/QuickSync4LinuxGui/__main__.py b/QuickSync4LinuxGui/__main__.py new file mode 100644 index 0000000..adbe95e --- /dev/null +++ b/QuickSync4LinuxGui/__main__.py @@ -0,0 +1,14 @@ +import sys + +from . import quicksync + +if __name__ == '__main__': + if len(sys.argv) == 1 or (len(sys.argv) > 1 and sys.argv[1] in ('gui', '--gui', '-g')): + try: + from . import gui + gui.run() + except ImportError: + print('PySide6 is not installed. Install it with: pip install PySide6') + sys.exit(1) + else: + quicksync.main() \ No newline at end of file diff --git a/QuickSync4LinuxGui/at.py b/QuickSync4LinuxGui/at.py new file mode 100644 index 0000000..c035c5e --- /dev/null +++ b/QuickSync4LinuxGui/at.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 + +# really horrible: without proper delay, commands are not working or we simply get no response... +class Delay: + AfterInvoke : int = 0.100 + AfterEnterObex : int = 0.500 + AfterExitObex : int = 0.500 + TimeoutRead : int = 2.000 + TimeoutWrite : int = 2.000 + ObexBoundary : int = 1.000 + +# AT commands recognized by the Gigaset devices +class Command: + GetHardwareConnectionState = "AT^SGST\r\n" + GetFirmwareUrl = "AT^SURL\r\n" + GetAreaCodes = "AT^SACO?\r\n" + SetAreaCodes = "AT^SACO={0},{1},{2},{3}\r\n" + SwitchHandsFree = "ATC\r\n" + Dial = "ATD {0}\r\n" + DialInternal = "ATDI {0}\r\n" + Answer = "ATA\r\n" + HangUp = "ATH\r\n" + Ping = "AT\r\n" + Reset = "ATZ\r\n\r\n" + GetDeviceType = "AT+CGMM\r\n" + GetManufacturer = "AT+CGMI\r\n" + GetSerialNumber = "AT+CGSN\r\n" + GetFirmwareVersion = "AT+CGMR\r\n" + GetProductName = "AT^WPPN\r\n" + GetSupportedFeatures = "AT^LOSF=?\r\n" + GetCharset1 = "AT^WPCS\r\n" + GetCharset2 = "AT^WPCS?\r\n" + GetExtendedMessageLevels = "AT+CMEE=?\r\n" + GetCurrentMessageLevel = "AT+CMEE?\r\n" + SetExtendedMessageLevel = "AT+CMEE={0}\r\n" + GetMWI = "AT^HMWI?\r\n" + GetSupportedMultimedia = "AT^HSMM?\r\n" + GetScreenSizeClip = "AT^WPPS CLIP\r\n" + GetScreenSizeFull = "AT^WPPS SCR\r\n" + GetBatteryState = "AT+CBC\r\n" + GetSignalState = "AT+CSQ\r\n" + GetInternalName = "AT^SHSN?\r\n" + GetExtendedModesList = "AT^SQWE=?\r\n" + GetCurrentExtendedMode = "AT^SQWE?\r\n" + PrepareHsImageXml = "AT^DMPC=?\r\n" + RequestPartHsImageXml = "AT^DMPC?\r\n" + AnswerPartHsImageXml = "^DMPC:\r\n" + EnterObex = "AT^SQWE=3\r\n" + EnterMemoryDump = "AT^SQWE=55\r\n" + ExitObex = "+++" + SwitchRoleGeneric = "AT^SRSR {0}\r\n" + SwitchRoleDefault = "AT^SRSR 0\r\n" + SwitchRoleQUC = "AT^SRSR 1\r\n" + SwitchRoleUpdate = "AT^SRSR 2\r\n" + ListMelodies = "AT^RM=?\r\n" + InitializeImageUpload = "AT^DMPU=?\r\n" + SendBasicImageInfo = "AT^DMPU={0},{1},{2}\r\n" + GetImageUploadResult = "AT^DMPU?\r\n" + SendImagePart = "AT^DMPW={0},{1},{2},{3}\r\n" + +class AtException(Exception): + pass +class IncompleteAtResponseException(Exception): + pass + +def removePrefix(s, pre): + if pre and s.startswith(pre): + return s[len(pre):] + return s +def removeSuffix(s, suf): + if suf and s.endswith(suf): + return s[:-len(suf)] + return s + +def formatCommand(cmd, *args): + return cmd.format(*args).encode('ascii') + +def evaluateResponse(buf, request): + if(request.decode('ascii') == Command.ExitObex): + # special handling for the ExitObex command which does not return any text... + return True + + elif(request.decode('ascii').startswith(Command.Dial.format('').strip()) and b'OK\r\n' in buf): + return True + + elif(buf.endswith(b'OK\r\n')): + return removeSuffix(removePrefix(buf, request.strip()), b'OK\r\n').strip() + + elif(buf.endswith(b'ERROR\r\n')): + raise AtException('Device reported an AT command error') + + else: + raise IncompleteAtResponseException() diff --git a/QuickSync4LinuxGui/backend.py b/QuickSync4LinuxGui/backend.py new file mode 100644 index 0000000..2ce05e1 --- /dev/null +++ b/QuickSync4LinuxGui/backend.py @@ -0,0 +1,176 @@ +""" +QuickSync4LinuxGui — Backend +Shared logic and CLI communication. +""" +import os +import re +import configparser +import subprocess +import logging +from logging.handlers import RotatingFileHandler + +# ─── Konstanten & Standardwerte ─────────────────────────────────────────────── +CHECK_TIMEOUT = 10 +DISCOVER_TIMEOUT = 10 +CLI_DEFAULT_TIMEOUT = 300 +DEFAULT_BAUD = '9600' + +BT_MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$') + +_CONFIG_DIR = os.path.expanduser('~/.config/QuickSync4LinuxGui') +DEFAULT_LOG_FILE = os.path.join(_CONFIG_DIR, 'QuickSync4LinuxGui.log') +# Shared config file used by the CLI (QuickSync4Linux) — keep the +# existing name/location/format (INI, [general] section) so both +# GUI and CLI read/write the same settings. +DEFAULT_CONFIG_FILE = os.path.expanduser('~/.config/quicksync4linuxgui.ini') + +# ─── Logger initialisieren ──────────────────────────────────────────────────── +import datetime as _dt + +def _setup_rotating_logger(): + os.makedirs(_CONFIG_DIR, exist_ok=True) + handler = RotatingFileHandler( + DEFAULT_LOG_FILE, + maxBytes=1 * 1024 * 1024, # 1 MB + backupCount=3, + encoding='utf-8', + ) + handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s', + datefmt='%d.%m.%Y %H:%M:%S')) + logger = logging.getLogger('QuickSync4LinuxGui') + logger.setLevel(logging.DEBUG) + if not logger.handlers: + logger.addHandler(handler) + return logger + +logger = _setup_rotating_logger() + +# Trennlinie + Programmstart — nur einmal, über eine Sentinel-Datei +_sentinel = os.path.join(_CONFIG_DIR, '.started') +if not os.path.exists(_sentinel): + # Create sentinel so subprocesses do not log their own start + open(_sentinel, 'w').close() + _start_time = _dt.datetime.now().strftime('%d.%m.%Y %H:%M:%S') + logger.info('─' * 60) + logger.info(f'Program start: {_start_time}') + + import atexit as _atexit + def _log_exit(): + _end_time = _dt.datetime.now().strftime('%d.%m.%Y %H:%M:%S') + logger.info(f'Program end: {_end_time}') + logger.info('─' * 60) + try: os.unlink(_sentinel) + except: pass + _atexit.register(_log_exit) + +# ─── Einstellungen laden/speichern (geteilte quicksync4linuxgui.ini) ────────── +def load_settings(path=DEFAULT_CONFIG_FILE): + try: + config_parser = configparser.ConfigParser() + config_parser.read(path) + if not config_parser.has_section('general'): + return + data = dict(config_parser.items('general')) + global CHECK_TIMEOUT, DISCOVER_TIMEOUT, CLI_DEFAULT_TIMEOUT, DEFAULT_BAUD + if 'check_timeout' in data: CHECK_TIMEOUT = int(data['check_timeout']) + if 'discover_timeout' in data: DISCOVER_TIMEOUT = int(data['discover_timeout']) + if 'cli_default_timeout' in data: CLI_DEFAULT_TIMEOUT = int(data['cli_default_timeout']) + if 'baud' in data: DEFAULT_BAUD = str(data['baud']) + except Exception: + pass + +def save_settings(path=DEFAULT_CONFIG_FILE): + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + config_parser = configparser.ConfigParser() + config_parser.read(path) # bestehende Keys (z.B. "device") erhalten + if not config_parser.has_section('general'): + config_parser.add_section('general') + config_parser.set('general', 'check_timeout', str(CHECK_TIMEOUT)) + config_parser.set('general', 'discover_timeout', str(DISCOVER_TIMEOUT)) + config_parser.set('general', 'cli_default_timeout', str(CLI_DEFAULT_TIMEOUT)) + config_parser.set('general', 'baud', str(DEFAULT_BAUD)) + with open(path, 'w', encoding='utf-8') as f: + config_parser.write(f) + except Exception: + pass + +# Direkt beim Import laden +load_settings() + +# ─── Bluetooth-Geräteerkennung ──────────────────────────────────────────────── +def _get_bt_device_class(mac: str) -> str: + """Returns the device class of a Bluetooth device (e.g. 'phone', 'audio').""" + try: + out = subprocess.check_output( + ['bluetoothctl', 'info', mac], text=True, timeout=5 + ) + for line in out.splitlines(): + if 'Icon:' in line: + return line.split(':', 1)[1].strip().lower() + except Exception: + pass + return '' + +def discover_devices() -> list[tuple[str, str]]: + """Returns a list of (mac, label) for phone devices via bluetoothctl.""" + devices = [] + try: + out = subprocess.check_output( + ['bluetoothctl', 'devices'], text=True, timeout=DISCOVER_TIMEOUT + ) + for line in out.splitlines(): + m = re.match(r'Device\s+([0-9A-Fa-f:]{17})\s+(.*)', line) + if m: + mac, label = m.group(1), m.group(2).strip() + device_class = _get_bt_device_class(mac) + if 'phone' in device_class: + devices.append((mac, label)) + except Exception: + pass + return devices + +# ─── Bluetooth-Verbindungsstatus prüfen ────────────────────────────────────── +def check_bt_connected(mac: str) -> bool | None: + """Checks if a device is actively connected via bluetoothctl. + Returns True if connected, False if not connected, None on error.""" + try: + out = subprocess.check_output( + ['bluetoothctl', 'info', mac], text=True, timeout=5 + ) + for line in out.splitlines(): + if 'Connected:' in line: + return 'yes' in line.lower() + return False + except Exception: + return None + +# ─── Verbindungsfehler interpretieren ───────────────────────────────────────── +def interpret_connection_error(text: str, mac: str = '') -> str | None: + """Returns a symbolic error key or None. The GUI layer translates keys via _translate_err(). + Uses errno numbers for matching — language-independent.""" + t = text.lower() + import re as _re + m = _re.search(r'\[errno\s+(\d+)\]', t) + errno_num = int(m.group(1)) if m else None + + if errno_num == 112 or 'host is down' in t: + if mac: + bt_connected = check_bt_connected(mac) + if bt_connected is False: + return 'ERR_NOT_CONNECTED' + elif bt_connected is True: + return 'ERR_NOT_REACHABLE_LOCKED' + return 'ERR_NOT_REACHABLE' + if errno_num == 111 or 'connection refused' in t: + return 'ERR_REFUSED' + if errno_num == 113 or 'no route to host' in t: + return 'ERR_NOT_FOUND' + if errno_num == 110 or 'timed out' in t or 'timeout' in t: + return 'ERR_TIMEOUT' + return None + +def log(text: str): + for line in text.splitlines(): + if line.strip(): + logger.info(line) \ No newline at end of file diff --git a/QuickSync4LinuxGui/btserial.py b/QuickSync4LinuxGui/btserial.py new file mode 100644 index 0000000..94c22e3 --- /dev/null +++ b/QuickSync4LinuxGui/btserial.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +import socket +import select +import time +import fcntl +import struct +import termios + +from serial import SerialTimeoutException + + +class BluetoothSerial: + """Drop-in replacement for serial.Serial backed by an RFCOMM socket. + Implements only the subset of pyserial's API used by quicksync: + name, write, read, in_waiting.""" + + def __init__(self, address, channel=1, write_timeout=None): + self.name = f'bt:{address}@{channel}' + self._write_timeout = write_timeout + self._sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) + self._sock.connect((address, channel)) + self._sock.setblocking(False) + + @property + def in_waiting(self): + buf = fcntl.ioctl(self._sock.fileno(), termios.FIONREAD, b'\x00\x00\x00\x00') + return struct.unpack('I', buf)[0] + + def write(self, data): + total = 0 + deadline = time.monotonic() + self._write_timeout if self._write_timeout else None + while total < len(data): + remaining = (deadline - time.monotonic()) if deadline is not None else None + if remaining is not None and remaining <= 0: + raise SerialTimeoutException('Write timeout') + _, w, _ = select.select([], [self._sock], [], remaining) + if not w: + raise SerialTimeoutException('Write timeout') + try: + sent = self._sock.send(data[total:]) + except BlockingIOError: + continue + if sent == 0: + raise OSError('Bluetooth socket closed by peer') + total += sent + return total + + def read(self, n): + if n <= 0: + return b'' + try: + return self._sock.recv(n) + except BlockingIOError: + return b'' + + def close(self): + self._sock.close() + + +BT_ADDR_RE = __import__('re').compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}(@\d+)?$') + +def isBluetoothAddress(s): + return BT_ADDR_RE.match(s) is not None + +def parseBluetoothAddress(s): + """Returns (mac, channel) where channel defaults to 1.""" + if '@' in s: + mac, ch = s.rsplit('@', 1) + return mac, int(ch) + return s, 1 diff --git a/QuickSync4LinuxGui/gui.py b/QuickSync4LinuxGui/gui.py new file mode 100644 index 0000000..536904f --- /dev/null +++ b/QuickSync4LinuxGui/gui.py @@ -0,0 +1,1402 @@ +#!/usr/bin/env python3 +"""QuickSync4Linux GUI – PySide6 version (drop-in replacement for the tkinter gui.py)""" + +import json +import os +import re +import subprocess +import tempfile +import threading + +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QComboBox, QLineEdit, QTextEdit, QFrame, + QDialog, QDialogButtonBox, QMessageBox, QFileDialog, + QFormLayout, QSizePolicy, QStatusBar, QAbstractItemView, QStyle, + QTableWidget, QTableWidgetItem, QHeaderView, QListWidget, + QListWidgetItem, QSplitter, QSpinBox, QRadioButton, QButtonGroup, +) +from PySide6.QtGui import QFont, QPixmap, QColor +from PySide6.QtCore import Qt, Signal, QObject, QTimer + +from . import vcard +from . import btserial +from . import quicksync + +from . import backend + +# Reuse constants from backend +DEFAULT_LOG_FILE = backend.DEFAULT_LOG_FILE +DEFAULT_CONFIG_FILE = backend.DEFAULT_CONFIG_FILE +BT_MAC_RE = backend.BT_MAC_RE + +def CHECK_TIMEOUT(): return backend.CHECK_TIMEOUT +def DISCOVER_TIMEOUT(): return backend.DISCOVER_TIMEOUT +def CLI_DEFAULT_TIMEOUT(): return backend.CLI_DEFAULT_TIMEOUT +def DEFAULT_BAUD(): return backend.DEFAULT_BAUD + +# Module-level aliases for compatibility +CHECK_TIMEOUT = backend.CHECK_TIMEOUT +DISCOVER_TIMEOUT = backend.DISCOVER_TIMEOUT +CLI_DEFAULT_TIMEOUT = backend.CLI_DEFAULT_TIMEOUT +DEFAULT_BAUD = backend.DEFAULT_BAUD + +# ─── Backend error-code translation ─────────────────────────────────────────── +_BT_ERROR_STRINGS: dict[str, str] = { + 'ERR_NOT_CONNECTED': '✗ Device not connected — Please enable Bluetooth on your phone.', + 'ERR_NOT_REACHABLE_LOCKED': '✗ Device not reachable — Please turn on the screen and unlock your phone.', + 'ERR_NOT_REACHABLE': '✗ Device not reachable — Please enable Bluetooth and turn on the screen of your phone.', + 'ERR_REFUSED': '✗ Connection refused — Please enable Bluetooth on your phone.', + 'ERR_NOT_FOUND': '✗ Device not found — Please enable Bluetooth on your phone.', + 'ERR_TIMEOUT': '✗ Timeout — Please unlock your phone and try again.', +} + +def _translate_err(widget, key: str) -> str: + """Translate a backend error key through Qt tr() into the active locale.""" + src = _BT_ERROR_STRINGS.get(key) + if src: + return widget.tr(src) + return key + +def _make_dialog_buttons(widget, flags=None): + """Creates a QDialogButtonBox with translatable button labels.""" + from PySide6.QtWidgets import QDialogButtonBox + if flags is None: + flags = QDialogButtonBox.Ok | QDialogButtonBox.Cancel + btns = QDialogButtonBox(flags) + ok_btn = btns.button(QDialogButtonBox.Ok) + cancel_btn = btns.button(QDialogButtonBox.Cancel) + close_btn = btns.button(QDialogButtonBox.Close) + if ok_btn: ok_btn.setText(widget.tr('OK')) + if cancel_btn: cancel_btn.setText(widget.tr('Cancel')) + if close_btn: close_btn.setText(widget.tr('Close')) + return btns + + +def simple_input(parent, title: str, label: str) -> str: + from PySide6.QtWidgets import QInputDialog + text, ok = QInputDialog.getText(parent, title, label) + return text.strip() if ok else '' + +class _WorkerSignals(QObject): + append_text = Signal(str) + status_update = Signal(str, str) + connection_state = Signal(bool) + action_finished = Signal(str, str) + show_info = Signal(str, str) # (title, text) + clear_output = Signal() + +class QuickSyncGUI(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle(self.tr('QuickSync4LinuxGui')) + self.resize(720, 560) + + self._device_map: dict[str, str] = {} + self._device_connected: bool | None = None + self._signals = _WorkerSignals() + self._signals.append_text.connect(self._append_output) + self._signals.status_update.connect(self._update_status_bar) + self._signals.connection_state.connect(self._set_connection_state) + self._signals.action_finished.connect(self._parse_and_fill_ui) + self._signals.show_info.connect(self._show_info_window) + + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(6) + + # QSplitter: Seitenleiste verschiebbar wie in Dolphin + splitter = QSplitter(Qt.Horizontal) + splitter.setChildrenCollapsible(False) + splitter.setHandleWidth(4) + + # Sidebar + sidebar = QWidget() + sidebar.setMinimumWidth(120) + sidebar.setMaximumWidth(300) + sidebar_layout = QVBoxLayout(sidebar) + sidebar_layout.setContentsMargins(0, 0, 0, 0) + sidebar_layout.setSpacing(1) + + style = self.style() + + def sb_group(text): + lbl = QLabel(text) + lbl.setStyleSheet('color: palette(window-text); font-size: 13px; font-weight: bold; padding: 12px 4px 2px 4px;') + sidebar_layout.addWidget(lbl) + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Plain) + sidebar_layout.addWidget(sep) + + def sb_btn(label, icon_name, slot, danger=False, success=False): + b = QPushButton(' ' + label) + b.setIcon(style.standardIcon(icon_name)) + b.clicked.connect(slot) + color = '#c9302c' if danger else ('#449d44' if success else 'palette(window-text)') + b.setStyleSheet(f'text-align: left; padding: 4px 6px; border: none; color: {color};') + b.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + return b + + sb_group(self.tr('Device')) + sidebar_layout.addWidget(sb_btn(self.tr('Connect'), QStyle.SP_DialogApplyButton, self.test_connection, success=True)) + sidebar_layout.addWidget(sb_btn(self.tr('Disconnect'), QStyle.SP_DialogCancelButton, self.disconnect_device, danger=True)) + sidebar_layout.addWidget(sb_btn(self.tr('Info'), QStyle.SP_MessageBoxInformation, lambda: self.run_action('info'))) + sidebar_layout.addWidget(sb_btn(self.tr(self.tr('Obex Info')), QStyle.SP_MessageBoxInformation, lambda: self.run_action('obexinfo'))) + + sb_group(self.tr('Contacts')) + sidebar_layout.addWidget(sb_btn(self.tr(self.tr('Manage Contacts')), QStyle.SP_FileDialogDetailedView, self.open_contacts_manager)) + sidebar_layout.addWidget(sb_btn(self.tr('Export'), QStyle.SP_ArrowDown, self.get_contacts)) + sidebar_layout.addWidget(sb_btn(self.tr('Import'), QStyle.SP_ArrowUp, lambda: self.choose_file_and_run('createcontacts'))) + + sb_group(self.tr('Files')) + sidebar_layout.addWidget(sb_btn(self.tr('File Manager'), QStyle.SP_DirOpenIcon, self.open_file_manager)) + + sb_group(self.tr('Settings')) + sidebar_layout.addWidget(sb_btn(self.tr(self.tr('Timeouts')), QStyle.SP_DialogHelpButton, self.open_settings_timeouts)) + sidebar_layout.addWidget(sb_btn(self.tr('Baudrate'), QStyle.SP_DialogHelpButton, self.open_settings_baudrate)) + sidebar_layout.addWidget(sb_btn(self.tr('Language'), QStyle.SP_DialogHelpButton, self.open_settings_language)) + + sb_group(self.tr('Log / Console')) + sidebar_layout.addWidget(sb_btn(self.tr('Open Log'), QStyle.SP_FileIcon, self.open_log)) + + sidebar_layout.addStretch() + splitter.addWidget(sidebar) + + # Right-hand side as a splitter widget + right_widget = QWidget() + right_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + right_col = QVBoxLayout(right_widget) + right_col.setSpacing(6) + right_col.setContentsMargins(6, 0, 0, 0) + + # Connection type selector + conn_type_row = QHBoxLayout() + self._conn_type_group = QButtonGroup(self) + self._rb_bt = QRadioButton('Bluetooth') + self._rb_serial = QRadioButton(self.tr('Serial')) + self._rb_bt.setChecked(True) + self._conn_type_group.addButton(self._rb_bt) + self._conn_type_group.addButton(self._rb_serial) + conn_type_row.addWidget(self._rb_bt) + conn_type_row.addWidget(self._rb_serial) + conn_type_row.addStretch() + right_col.addLayout(conn_type_row) + + dev_row_widget = QWidget() + dev_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + dev_row = QHBoxLayout(dev_row_widget) + dev_row.setContentsMargins(0, 0, 0, 0) + dev_row.setSpacing(4) + self.device = QComboBox() + self.device.setEditable(False) + self.device.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + dev_row.addWidget(self.device, stretch=1) + + btn_refresh = QPushButton('⟳') + btn_refresh.setFixedWidth(32) + btn_refresh.clicked.connect(self.refresh_devices_and_check) + dev_row.addWidget(btn_refresh) + + self.baud = QLineEdit(backend.DEFAULT_BAUD) + self.baud.setVisible(False) + right_col.addWidget(dev_row_widget) + + # Switch connection type + self._rb_bt.toggled.connect(self._on_conn_type_changed) + self._rb_serial.toggled.connect(self._on_conn_type_changed) + + form_layout = QFormLayout() + form_layout.setSpacing(6) + + self.ui_hersteller = QLineEdit("-"); self.ui_hersteller.setReadOnly(True) + self.ui_modell = QLineEdit("-"); self.ui_modell.setReadOnly(True) + self.ui_mac = QLineEdit("-"); self.ui_mac.setReadOnly(True) + self.ui_firmware = QLineEdit("-"); self.ui_firmware.setReadOnly(True) + self.ui_seriennummer = QLineEdit("-"); self.ui_seriennummer.setReadOnly(True) + self.ui_kontakt_anzahl = QLineEdit("-"); self.ui_kontakt_anzahl.setReadOnly(True) + + form_layout.addRow(self.tr("Device Information"), QLabel("")) + form_layout.addRow(self.tr("Manufacturer:"), self.ui_hersteller) + form_layout.addRow(self.tr("Model / Product:"), self.ui_modell) + form_layout.addRow(self.tr("MAC Address:"), self.ui_mac) + form_layout.addRow(self.tr("Firmware Version:"), self.ui_firmware) + form_layout.addRow(self.tr("Serial Number (IPUI):"), self.ui_seriennummer) + form_layout.addRow(self.tr("Contact Count:"), self.ui_kontakt_anzahl) + right_col.addLayout(form_layout) + + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + right_col.addWidget(line) + + self.output = QTextEdit() + self.output.setReadOnly(True) + self.output.setFont(QFont('Monospace', 9)) + right_col.addWidget(self.output, stretch=1) + self._signals.clear_output.connect(self.output.clear) + + splitter.addWidget(right_widget) + # Anfangsbreiten: Sidebar 160px, Rest bekommt den verbleibenden Platz + splitter.setSizes([160, 600]) + # Splitter-Position beim Beenden speichern und beim Start wiederherstellen + self._splitter = splitter + root.addWidget(splitter, stretch=1) + self.set_log_file(DEFAULT_LOG_FILE) + + # Splitter-Position aus den Settings laden + from PySide6.QtCore import QSettings + _s = QSettings('QuickSync4LinuxGui', 'QuickSync4LinuxGui') + splitter_state = _s.value('mainSplitterState') + if splitter_state: + self._splitter.restoreState(splitter_state) + + sb = QStatusBar() + self.setStatusBar(sb) + self._status_dot = QLabel('●') + self._status_dot.setStyleSheet('color: gray;') + self._status_label = QLabel(self.tr('Checking device status…')) + sb.addWidget(self._status_dot) + sb.addWidget(self._status_label, 1) + + self.refresh_devices() + QTimer.singleShot(500, self.check_device_connection) + + def _parse_and_fill_ui(self, action, text): + if not text: + return + + if action == 'contact_count': + self.ui_kontakt_anzahl.setText(text) + return + + def find_val(pattern, string): + match = re.search(pattern, string, re.IGNORECASE) + return match.group(1).strip() if match else None + + current_dev = self.current_device() + if BT_MAC_RE.match(current_dev or ''): + self.ui_mac.setText(current_dev.split('@')[0]) + + hersteller = find_val(r'(?:Hersteller|Manufacturer)\s*:\s*(.*)', text) + modell = find_val(r'(?:Modell|Model|Product)\s*:\s*(.*)', text) + mac = find_val(r'(?:MAC-Adresse|MAC)\s*:\s*(.*)', text) + firmware = find_val(r'(?:Firmware-Version|Firmware)\s*:\s*([0-9\.]+)', text) + seriennummer = find_val(r'(?:Seriennummer|Serial(?:\s*\(IPUI\))?)\s*:\s*(.*)', text) + anzahl = find_val(r'(?:Received|Found|Total)\s*([0-9]+)\s*(?:contacts|Kontakte)', text) + + if hersteller: self.ui_hersteller.setText(hersteller) + if modell: self.ui_modell.setText(modell.split(',')[-1].strip() if ',' in modell else modell) + if mac: self.ui_mac.setText(mac) + if firmware: self.ui_firmware.setText(firmware) + if seriennummer: self.ui_seriennummer.setText(seriennummer) + if anzahl: self.ui_kontakt_anzahl.setText(anzahl) + + def _on_conn_type_changed(self): + """Switch between Bluetooth and serial mode.""" + if self._rb_bt.isChecked(): + self.device.setEditable(False) + else: + self.device.setEditable(True) + self.refresh_devices() + + def refresh_devices(self, prefer=None): + self.device.blockSignals(True) + self.device.clear() + + if self._rb_serial.isChecked(): + # Show serial devices + import glob + serial_ports = sorted( + glob.glob('/dev/ttyACM*') + + glob.glob('/dev/ttyUSB*') + + glob.glob('/dev/rfcomm*') + ) + self._device_map = {p: p for p in serial_ports} + for p in serial_ports: + self.device.addItem(p) + self.device.setEditable(True) + if prefer and prefer in serial_ports: + self.device.setCurrentText(prefer) + elif serial_ports: + self.device.setCurrentIndex(0) + else: + # Show Bluetooth devices + raw = backend.discover_devices() + entries = [(f'{label} ({mac}) [Bluetooth]', mac) for mac, label in raw] + self._device_map = {display: mac for display, mac in entries} + for display, _ in entries: + self.device.addItem(display) + self.device.setEditable(False) + + if prefer: + for display, mac in entries: + if mac == prefer: + self.device.setCurrentText(display) + self.device.blockSignals(False) + return + + # Prefer Gigaset devices + for display, mac in entries: + if 'gigaset' in display.lower(): + self.device.setCurrentText(display) + self.device.blockSignals(False) + return + + if entries: + self.device.setCurrentIndex(0) + elif prefer: + self.device.setCurrentText(prefer) + + self.device.blockSignals(False) + + def refresh_devices_and_check(self): + self.refresh_devices() + self.check_device_connection() + + def current_device(self): + text = self.device.currentText().strip() + return self._device_map.get(text, text) + + def current_device_label(self): + text = self.device.currentText().strip() + for suffix in (' [Bluetooth]', ' (Serial)'): + if text.endswith(suffix): + return text[: -len(suffix)] + return text or self.current_device() + + def check_connection_or_warn(self) -> bool: + if not self.current_device(): + QMessageBox.warning(self, self.tr('No Device'), self.tr('Please select a device first.')) + return False + if self._device_connected is not True: + QMessageBox.warning(self, self.tr('Not Connected'), self.tr('Please connect to a device first.')) + return False + return True + + def _open_connection(self): + """Open a direct connection to the current device.""" + dev = self.current_device() + if not dev: + raise RuntimeError(self.tr('No device selected.')) + baud = int(self.baud.text()) if self.baud.text() else 9600 + return quicksync.open_connection(dev, baud) + + def _set_connection_state(self, connected: bool): + self._device_connected = connected + + def is_device_connected(self): + return bool(self.current_device()) and self._device_connected is not False + + def _append_output(self, text: str): + if text == 'clear': + self.output.clear() + return + self.output.append(text) + self._log_raw_output(text) + + def _log_raw_output(self, text: str): + backend.log(text) + + + def set_log_file(self, path: str | None = None): + self._append_output(self.tr('Log file: {} (max. 1 MB, 3 backups)').format(backend.DEFAULT_LOG_FILE)) + + def _update_status_bar(self, text: str, colour: str): + self._status_dot.setStyleSheet(f'color: {colour};') + self._status_label.setText(text) + + def _show_info_window(self, title, text): + dlg = QDialog(self) + dlg.setWindowTitle(title) + dlg.resize(520, 400) + layout = QVBoxLayout(dlg) + out = QTextEdit() + out.setReadOnly(True) + out.setFont(QFont('Monospace', 9)) + out.setPlainText(text) + layout.addWidget(out) + btn = _make_dialog_buttons(self, QDialogButtonBox.Close) + btn.rejected.connect(dlg.reject) + layout.addWidget(btn) + dlg.exec() + + def run_action(self, action, options=None, file=None): + dev = self.current_device() + if not dev: + self.output.clear() + self._append_output(self.tr('✗ No device selected or connection lost')) + return + + if self._device_connected is False and action not in ('info', 'obexinfo'): + QMessageBox.warning(self, self.tr('Connection Lost'), + self.tr('The device is currently disconnected. Please reconnect.')) + return + + self.output.clear() + sig = self._signals + + def worker(): + ser = None + try: + ser = self._open_connection() + if action == 'info': + result = quicksync.get_info(ser) + sig.show_info.emit(self.tr('Device Info'), result) + sig.clear_output.emit() + sig.action_finished.emit('info', result) + elif action == 'obexinfo': + result = quicksync.get_obex_info(ser) + sig.show_info.emit(self.tr('Obex Info'), result) + sig.clear_output.emit() + sig.action_finished.emit('obexinfo', result) + elif action == 'getcontacts': + if file: + vcf = quicksync.get_contacts(ser) + with open(file, 'wb') as f: + f.write(vcf) + sig.action_finished.emit('getcontacts', vcf.decode('utf-8', errors='replace')) + elif action in ('setcontacts', 'createcontacts'): + if file: + with open(file, 'rb') as f: + vcf = f.read() + quicksync.set_contacts(ser, vcf) + elif action == 'download': + if options and file: + quicksync.download_file(ser, options, file) + elif action == 'upload': + if options and file: + quicksync.upload_file(ser, options, file) + elif action == 'delete': + if options: + quicksync.delete_file(ser, options) + else: + sig.append_text.emit(f'{self.tr("Unknown action")}: {action}') + except Exception as e: + sig.append_text.emit(f'✗ {self.tr("Error")}: {e}') + finally: + if ser: + quicksync.close_connection(ser) + + threading.Thread(target=worker, daemon=True).start() + + def choose_file_and_run(self, action): + if not self.check_connection_or_warn(): + return + path, _ = QFileDialog.getOpenFileName(self, self.tr('Choose VCF file'), os.path.expanduser('~'), self.tr('VCF files (*.vcf);;All files (*)')) + if path: + self.run_action(action, None, path) + + def get_contacts(self): + if not self.check_connection_or_warn(): + return + path, _ = QFileDialog.getSaveFileName(self, self.tr('Save contacts as'), os.path.expanduser('~'), self.tr('VCF files (*.vcf);;All files (*)')) + if path: + self.run_action('getcontacts', None, path) + + + def open_log(self): + # 1. Nutze das Attribut der GUI, falls gesetzt, andernfalls direkt das Backend-Standard-Log + log_path = getattr(self, '_log_file', None) or backend.DEFAULT_LOG_FILE + + # 2. Resolve the path absolutely and expand the tilde + path_target = os.path.abspath(os.path.expanduser(log_path)) + + # 3. Determine the folder, regardless of whether a file or folder was passed + initial_dir = path_target if os.path.isdir(path_target) else os.path.dirname(path_target) + + # Safety net for fresh installs where the folder does not exist yet + if not os.path.exists(initial_dir): + try: + os.makedirs(initial_dir, exist_ok=True) + except Exception: + initial_dir = os.path.expanduser('~') + + # Open the dialog in the correct directory + path, _ = QFileDialog.getOpenFileName( + self, self.tr('Choose log file'), initial_dir, self.tr('Log files (*.log);;All files (*)') + ) + + if not path: + return + try: + subprocess.Popen(['xdg-open', path]) + except Exception as e: + self._append_output(f'✗ {self.tr("Could not open log file")}: {e}') + + def open_settings_timeouts(self): + dlg = QDialog(self) + dlg.setWindowTitle(self.tr('Settings')) + layout = QVBoxLayout(dlg) + form = QFormLayout() + from PySide6.QtWidgets import QSpinBox + chk = QSpinBox(); chk.setRange(1, 3600); chk.setValue(backend.CHECK_TIMEOUT); form.addRow(self.tr('Connection timeout (s):'), chk) + dsk = QSpinBox(); dsk.setRange(1, 3600); dsk.setValue(backend.DISCOVER_TIMEOUT); form.addRow(self.tr('Bluetooth timeout (s):'), dsk) + cli = QSpinBox(); cli.setRange(1, 36000); cli.setValue(backend.CLI_DEFAULT_TIMEOUT); form.addRow(self.tr('CLI timeout (s):'), cli) + layout.addLayout(form) + btns = _make_dialog_buttons(self) + btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); layout.addWidget(btns) + if dlg.exec() == QDialog.Accepted: + backend.CHECK_TIMEOUT = chk.value() + backend.DISCOVER_TIMEOUT = dsk.value() + backend.CLI_DEFAULT_TIMEOUT = cli.value() + backend.save_settings() + + def open_settings_baudrate(self): + dlg = QDialog(self) + dlg.setWindowTitle(self.tr('Settings')) + layout = QVBoxLayout(dlg) + form = QFormLayout() + from PySide6.QtWidgets import QComboBox as _CB + baud_combo = _CB() + baud_combo.setEditable(True) + for b in ['1200', '2400', '4800', '9600', '19200', '38400', '57600', '115200']: + baud_combo.addItem(b) + baud_combo.setCurrentText(backend.DEFAULT_BAUD) + form.addRow(self.tr('Baud rate (Serial):'), baud_combo) + layout.addLayout(form) + btns = _make_dialog_buttons(self) + btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); layout.addWidget(btns) + if dlg.exec() == QDialog.Accepted: + backend.DEFAULT_BAUD = baud_combo.currentText() + self.baud.setText(baud_combo.currentText()) + backend.save_settings() + + def open_settings_language(self): + from PySide6.QtCore import QSettings, QTranslator, QLocale + dlg = QDialog(self) + dlg.setWindowTitle(self.tr('Language')) + layout = QVBoxLayout(dlg) + form = QFormLayout() + lang_combo = QComboBox() + lang_combo.addItem('English', 'en') + lang_combo.addItem('Deutsch', 'de') + _s = QSettings('QuickSync4LinuxGui', 'QuickSync4LinuxGui') + current_lang = _s.value('language', '') + idx = lang_combo.findData(current_lang) + if idx >= 0: + lang_combo.setCurrentIndex(idx) + form.addRow(self.tr('Language') + ':', lang_combo) + layout.addLayout(form) + info = QLabel(self.tr('The language change takes effect after restarting the application.')) + info.setWordWrap(True) + layout.addWidget(info) + btns = _make_dialog_buttons(self) + btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); layout.addWidget(btns) + if dlg.exec() == QDialog.Accepted: + selected_lang = lang_combo.currentData() + _s.setValue('language', selected_lang) + + def open_file_manager(self): + if not self.check_connection_or_warn(): + return + if hasattr(self, '_file_manager_win') and self._file_manager_win is not None: + self._file_manager_win.raise_() + self._file_manager_win.activateWindow() + return + self._file_manager_win = FileManagerWindow(self) + self._file_manager_win.finished.connect(lambda: setattr(self, '_file_manager_win', None)) + + def open_contacts_manager(self): + if not self.check_connection_or_warn(): + return + if hasattr(self, '_contacts_win') and self._contacts_win is not None: + self._contacts_win.raise_() + self._contacts_win.activateWindow() + return + self._contacts_win = ContactsWindow(self) + self._contacts_win.finished.connect(lambda: setattr(self, '_contacts_win', None)) + + def open_file_manager(self): + if not self.check_connection_or_warn(): + return + if hasattr(self, '_file_manager_win') and self._file_manager_win is not None: + self._file_manager_win.raise_() + self._file_manager_win.activateWindow() + return + self._file_manager_win = FileManagerWindow(self) + self._file_manager_win.finished.connect(lambda: setattr(self, '_file_manager_win', None)) + + + + def download_file(self): + remote = simple_input(self, self.tr('Remote file'), self.tr('Enter remote file name:')) + if not remote: return + out, _ = QFileDialog.getSaveFileName(self, self.tr('Save as')) + if out: self.run_action('download', remote, out) + + def upload_file(self): + path, _ = QFileDialog.getOpenFileName(self, self.tr('Choose file to upload')) + if not path: return + remote = simple_input(self, self.tr('Remote name'), self.tr('Remote file name on the device:')) + if not remote: return + self.run_action('upload', remote, path) + + def delete_file(self): + remote = simple_input(self, self.tr('Remote file'), self.tr('Remote file name to delete:')) + if remote: self.run_action('delete', remote) + + def check_device_connection(self): + label = self.current_device_label() + self._update_status_bar(f'{label}: {self.tr("Checking device status…")}' if label else self.tr('Checking device status…'), '#888888') + sig = self._signals + + def worker(): + connected = False + status_text = self.tr('No device connected') + status_color = '#d9534f' + ser = None + dev = self.current_device() + try: + if dev: + ser = self._open_connection() + info_text = quicksync.get_info(ser) + status_text = f'{label} {self.tr("connected")}' if label else self.tr('Device connected') + status_color = '#5cb85c' + connected = True + sig.append_text.emit(f'✓ {status_text}') + self._log_raw_output(info_text) + sig.action_finished.emit('info', info_text) + try: + vcf_bytes = quicksync.get_contacts(ser) + count = len(vcard.parseCards(vcf_bytes.decode('utf-8', errors='replace'))) + sig.action_finished.emit('contact_count', str(count)) + except Exception: + pass + except Exception as e: + key = backend.interpret_connection_error(str(e), dev) + msg = _translate_err(self, key) if key else f'✗ {self.tr("Error")}: {e}' + sig.append_text.emit(msg) + status_text = msg + status_color = '#d9534f' + finally: + if ser: + quicksync.close_connection(ser) + sig.connection_state.emit(connected) + sig.status_update.emit(status_text, status_color) + + threading.Thread(target=worker, daemon=True).start() + + def test_connection(self): + self.output.clear() + self._append_output(self.tr('--- Establishing connection ---')) + label = self.current_device_label() + self._update_status_bar(f'{label}: {self.tr("--- Establishing connection ---")}' if label else self.tr('--- Establishing connection ---'), '#888888') + sig = self._signals + + def worker(): + connected = False + result = '' + ser = None + dev = self.current_device() + try: + if not dev: + result = self.tr('No device selected.') + else: + ser = self._open_connection() + info_text = quicksync.get_info(ser) + result = f'✓ {self.tr("Device connected")}: {label or dev}' + connected = True + self._log_raw_output(info_text) + sig.action_finished.emit('info', info_text) + except Exception as e: + key = backend.interpret_connection_error(str(e), dev) + result = _translate_err(self, key) if key else f'✗ {self.tr("Connection to")} {label} ({dev}) {self.tr("failed")}: {e}' + finally: + if ser: + quicksync.close_connection(ser) + + status = ((f'{label} {self.tr("connected")}' if label else self.tr('Device connected'), '#5cb85c') if connected else (self.tr('No device connected'), '#d9534f')) + sig.connection_state.emit(connected) + sig.append_text.emit(result) + sig.status_update.emit(*status) + + threading.Thread(target=worker, daemon=True).start() + + def closeEvent(self, event): + from PySide6.QtCore import QSettings + _s = QSettings('QuickSync4LinuxGui', 'QuickSync4LinuxGui') + _s.setValue('mainSplitterState', self._splitter.saveState()) + super().closeEvent(event) + + def disconnect_device(self): + dev = self.current_device() + label = self.current_device_label() or dev + if dev and btserial.isBluetoothAddress(dev): + try: subprocess.run(['bluetoothctl', 'disconnect', dev.split('@')[0]], capture_output=True, timeout=5) + except Exception: pass + self.output.clear() + self._append_output(f'✓ {self.tr("Disconnected from")} {label}') + self._set_connection_state(False) + self._update_status_bar(self.tr('No device connected'), '#d9534f') + +# ─── Additional window classes ──────────────────────────────────────────────── + +class _FileManagerSignals(QObject): + setup_ui = Signal(list, str) + set_status = Signal(str) + do_reload = Signal() + show_preview = Signal(str) # tmp_path + show_preview_err = Signal(str) # error text + +class FileManagerWindow(QDialog): + """File manager with a KDE/Dolphin-style layout and fixed Gigaset parser.""" + + IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.bmp', '.gif'} + + def __init__(self, parent): + super().__init__(parent) + self.parent_win = parent + self.setWindowTitle(self.tr('File Manager')) + self.resize(950, 550) + self._files: list[dict] = [] + self._all_files: list[dict] = [] + self._fm_signals = _FileManagerSignals() + + # Hauptlayout (Vertikal) + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + # 1. Toolbar (Dolphin-like) + toolbar = QHBoxLayout() + toolbar.setContentsMargins(4, 2, 4, 2) + from PySide6.QtWidgets import QStyle + + def tbtn(label, icon_name, slot, is_danger=False): + b = QPushButton(' ' + label) + b.setIcon(self.style().standardIcon(icon_name)) + b.clicked.connect(slot) + if is_danger: + b.setStyleSheet("QPushButton { color: #cc241d; }") + else: + b.setStyleSheet("QPushButton { text-align: left; }") + return b + + btn_reload = tbtn(self.tr('Reload'), QStyle.SP_BrowserReload, self.reload) + btn_download = tbtn(self.tr('Download'), QStyle.SP_ArrowDown, self.download_selected) + btn_upload = tbtn(self.tr('Upload'), QStyle.SP_ArrowUp, self.upload_file) + btn_delete = tbtn(self.tr('Delete'), QStyle.SP_TrashIcon, self.delete_selected, is_danger=True) + + toolbar.addWidget(btn_reload) + toolbar.addWidget(btn_download) + toolbar.addWidget(btn_upload) + toolbar.addWidget(btn_delete) + toolbar.addStretch() + layout.addLayout(toolbar) + + # Separator below toolbar + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Plain) + layout.addWidget(sep) + + # 2. Haupt-Inhaltsbereich mit Dreifach-Splitting (Sidebar | Tabelle | Info-Panel) + main_hbox = QHBoxLayout() + main_hbox.setSpacing(6) + + # Left: places/folders sidebar (KDE style) + from PySide6.QtWidgets import QListWidget, QListWidgetItem + self.folder_sidebar = QListWidget() + self.folder_sidebar.setFixedWidth(180) + self.folder_sidebar.setStyleSheet("QListWidget { background: palette(window); border: none; font-weight: bold; }") + self.folder_sidebar.itemClicked.connect(self._on_sidebar_folder_changed) + main_hbox.addWidget(self.folder_sidebar) + + # Vertical separator + v_sep1 = QFrame() + v_sep1.setFrameShape(QFrame.VLine) + v_sep1.setFrameShadow(QFrame.Plain) + main_hbox.addWidget(v_sep1) + + # Mitte: Die Dateitabelle + self.table = QTableWidget(0, 3) + self.table.setHorizontalHeaderLabels([self.tr('Name'), self.tr('Date'), self.tr('Size')]) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.verticalHeader().setVisible(False) + self.table.setAlternatingRowColors(True) + self.table.setShowGrid(False) + self.table.setColumnWidth(0, 300) + self.table.setColumnWidth(1, 130) + self.table.setColumnWidth(2, 90) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.itemSelectionChanged.connect(self._on_selection) + main_hbox.addWidget(self.table, stretch=2) + + # Vertical separator + v_sep2 = QFrame() + v_sep2.setFrameShape(QFrame.VLine) + v_sep2.setFrameShadow(QFrame.Plain) + main_hbox.addWidget(v_sep2) + + # Rechts: Dolphins Informations-Panel (Vorschau) + preview_panel = QWidget() + preview_panel.setFixedWidth(220) + preview_layout = QVBoxLayout(preview_panel) + preview_layout.setContentsMargins(4, 0, 4, 0) + + self.preview_label = QLabel(self.tr('No selection')) + self.preview_label.setAlignment(Qt.AlignCenter) + self.preview_label.setStyleSheet('font-weight: bold; color: palette(window-text);') + + self.preview_image = QLabel() + self.preview_image.setAlignment(Qt.AlignCenter) + self.preview_image.setMinimumHeight(180) + self.preview_image.setStyleSheet('background: palette(base); border: 1px solid palette(mid); border-radius: 4px;') + + self.preview_info = QTextEdit() + self.preview_info.setReadOnly(True) + self.preview_info.setFont(QFont('Monospace', 8)) + self.preview_info.setStyleSheet("QTextEdit { border: none; background: transparent; }") + self.preview_info.setMaximumHeight(120) + + preview_layout.addWidget(self.preview_label) + preview_layout.addWidget(self.preview_image) + preview_layout.addWidget(self.preview_info) + preview_layout.addStretch() + main_hbox.addWidget(preview_panel) + + layout.addLayout(main_hbox, stretch=1) + + # Separator above status bar + sep_bottom = QFrame() + sep_bottom.setFrameShape(QFrame.HLine) + sep_bottom.setFrameShadow(QFrame.Plain) + layout.addWidget(sep_bottom) + + # 3. Statuszeile + bottom_row = QHBoxLayout() + bottom_row.setContentsMargins(6, 2, 6, 2) + self.status_label = QLabel('') + self.space_label = QLabel('') + self.space_label.setStyleSheet('color: palette(mid); font-size: 11px;') + bottom_row.addWidget(self.status_label) + bottom_row.addStretch() + bottom_row.addWidget(self.space_label) + layout.addLayout(bottom_row) + + self.setModal(False) + # Signals jetzt verbinden, da status_label und space_label bereits existieren + self._fm_signals.setup_ui.connect(self._setup_ui_data) + self._fm_signals.set_status.connect(self.status_label.setText) + self._fm_signals.do_reload.connect(self.reload) + self._fm_signals.show_preview.connect(self._show_preview) + self._fm_signals.show_preview_err.connect(self.preview_image.setText) + self.show() + QTimer.singleShot(50, self.reload) + + def reload(self): + self.status_label.setText(self.tr('Loading file list …')) + self.space_label.setText('') + self.table.setRowCount(0) + self.folder_sidebar.clear() + self._all_files = [] + self._files = [] + + fm_sig = self._fm_signals + + def worker(): + ser = None + log = self.parent_win._signals + try: + log.append_text.emit(self.tr('Loading file list …')) + fm_sig.set_status.emit(self.tr('Loading file list …')) + ser = self.parent_win._open_connection() + output = quicksync.list_files(ser) + + try: + files = self._parse_listfiles(output) + except Exception as parse_exc: + fm_sig.set_status.emit(f'✗ {self.tr("Parse error")}: {parse_exc}') + return + + import re as _re + total = _re.search(r'Total Space:\s*([\d\.]+\s*\w+)', output, _re.IGNORECASE) + free = _re.search(r'Free Space:\s*([\d\.]+\s*\w+)', output, _re.IGNORECASE) + space_text = f'Free: {free.group(1)} | Total: {total.group(1)}' if total and free else '' + + if not files: + fm_sig.set_status.emit(self.tr('✗ No files found.')) + return + + log.append_text.emit(self.tr('✓ File list loaded: {} file(s) in {} folder(s)').format(len(files), len(set(f["folder"] for f in files)))) + fm_sig.setup_ui.emit(files, space_text) + except Exception as e: + dev = self.parent_win.current_device() + key = backend.interpret_connection_error(str(e), dev) + msg = _translate_err(self, key) if key else f'✗ {self.tr("Error")}: {e}' + log.append_text.emit(f'[FileManager] {msg}') + fm_sig.set_status.emit(msg) + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def _parse_listfiles(self, text): + import re as _re + files = [] + current_folder = '/' + + # Replace stubborn non-breaking spaces (NBSP) with regular spaces + clean_text = text.replace('\xa0', ' ') + + # Robust regex matching IDs, date formats, and file-size suffixes + line_re = _re.compile(r'^(\d+):\s+(.+?)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+[A-Z]\s+([\d\.]+\s+\w+)') + + for line in clean_text.splitlines(): + stripped = line.strip() + if not stripped: + continue + + # Detect folder changes, e.g. === /Pictures + if stripped.startswith('==='): + current_folder = stripped.replace('===', '').strip() + continue + + m = line_re.match(stripped) + if m: + file_id, name, date_str, time_str, size_str = m.groups() + files.append({ + 'id': file_id, + 'folder': current_folder, + 'name': name.strip(), + 'date': f"{date_str} {time_str}", + 'size': size_str, + }) + return files + + def _setup_ui_data(self, files, space_text): + self._all_files = files + self.space_label.setText(space_text) + folders = sorted(list(set(f['folder'] for f in files))) + if not folders: + folders = ['/'] + from PySide6.QtWidgets import QStyle, QListWidgetItem as _LWI + self.folder_sidebar.clear() + for folder in folders: + item = _LWI(self.style().standardIcon(QStyle.SP_DirIcon), folder) + self.folder_sidebar.addItem(item) + if self.folder_sidebar.count() > 0: + self.folder_sidebar.setCurrentRow(0) + self._filter_by_folder(self.folder_sidebar.item(0).text()) + else: + self.status_label.setText(self.tr('✗ No files found.')) + + def _on_sidebar_folder_changed(self, item): + self._filter_by_folder(item.text()) + + def _filter_by_folder(self, folder_name): + self._files = [f for f in self._all_files if f['folder'] == folder_name] + self._populate(self._files) + + def _populate(self, files): + self.table.setRowCount(0) + from PySide6.QtWidgets import QStyle as _QStyle + img_exts = self.IMAGE_EXTS + for f in files: + row = self.table.rowCount() + self.table.insertRow(row) + + ext = os.path.splitext(f['name'])[1].lower() + icon_type = _QStyle.SP_FileDialogDetailedView if ext in img_exts else _QStyle.SP_FileIcon + icon = self.style().standardIcon(icon_type) + + name_item = QTableWidgetItem(f['name']) + name_item.setIcon(icon) + + self.table.setItem(row, 0, name_item) + self.table.setItem(row, 1, QTableWidgetItem(f.get('date', '-'))) + self.table.setItem(row, 2, QTableWidgetItem(f['size'])) + + self.table.resizeRowsToContents() + current_folder = self.folder_sidebar.currentItem().text() if self.folder_sidebar.currentItem() else "" + self.status_label.setText(self.tr('{} file(s) in "{}"').format(len(files), current_folder)) + + def _selected_file(self): + row = self.table.currentRow() + if row < 0 or row >= len(self._files): + return None + return self._files[row] + + def _on_selection(self): + f = self._selected_file() + if not f: + self.preview_image.clear() + self.preview_label.setText(self.tr('No selection')) + self.preview_info.clear() + return + ext = os.path.splitext(f['name'])[1].lower() + self.preview_label.setText(f['name']) + self.preview_info.setText( + f"ID: {f['id']}
" + f"{self.tr('Path')}: {f['folder']}/{f['name']}
" + f"{self.tr('Size')}: {f['size']}
" + f"{self.tr('Date')}: {f['date']}" + ) + if ext in self.IMAGE_EXTS: + self.preview_image.setText(f'⏳ {self.tr("Loading image …")}') + self._load_preview(f) + else: + self.preview_image.setText(self.tr('No preview available')) + + def _load_preview(self, f): + fm_sig = self._fm_signals + def worker(): + ser = None + try: + fd, tmp_path = tempfile.mkstemp(suffix=os.path.splitext(f['name'])[1]) + os.close(fd) + remote_path = f"{f['folder']}/{f['name']}" + ser = self.parent_win._open_connection() + quicksync.download_file(ser, remote_path, tmp_path) + if os.path.exists(tmp_path): + fm_sig.show_preview.emit(tmp_path) + else: + fm_sig.show_preview_err.emit('✗ Preview not available') + except Exception as e: + fm_sig.show_preview_err.emit(f'✗ {e}') + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def _show_preview(self, path): + pixmap = QPixmap(path) + try: + os.unlink(path) + except OSError: + pass + if pixmap.isNull(): + self.preview_image.setText(f'✗ {self.tr("Image error")}') + return + scaled = pixmap.scaled(200, 200, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.preview_image.setPixmap(scaled) + + def download_selected(self): + f = self._selected_file() + if not f: + QMessageBox.information(self, self.tr(self.tr('Notice')), self.tr(self.tr('Please select a file.'))) + return + save_path, _ = QFileDialog.getSaveFileName(self, self.tr('Save as'), f['name']) + if not save_path: + return + self.status_label.setText(f'⏳ {self.tr("Downloading")}: {f["name"]} …') + fm_sig = self._fm_signals + def worker(): + ser = None + try: + remote_path = f"{f['folder']}/{f['name']}" + ser = self.parent_win._open_connection() + quicksync.download_file(ser, remote_path, save_path) + fm_sig.set_status.emit(f'✓ {self.tr("Saved")}: {save_path}') + except Exception as e: + fm_sig.set_status.emit(f'✗ {e}') + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def upload_file(self): + path, _ = QFileDialog.getOpenFileName(self, self.tr('Choose file to upload'), os.path.expanduser('~')) + if not path: + return + self.status_label.setText(f'⏳ {self.tr("Uploading")}: {os.path.basename(path)} …') + fm_sig = self._fm_signals + def worker(): + ser = None + try: + ser = self.parent_win._open_connection() + quicksync.upload_file(ser, os.path.basename(path), path) + fm_sig.set_status.emit(self.tr('✓ Upload complete')) + fm_sig.do_reload.emit() + except Exception as e: + fm_sig.set_status.emit(f'✗ {e}') + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def delete_selected(self): + f = self._selected_file() + if not f: + QMessageBox.information(self, self.tr('Notice'), self.tr('Please select a file.')) + return + r = QMessageBox.question(self, self.tr('Delete'), self.tr('Really delete file "{}"?').format(f['name'])) + if r != QMessageBox.Yes: + return + self.status_label.setText(f'⏳ {self.tr("Deleting")}: {f["name"]} …') + fm_sig = self._fm_signals + fm_sig = self._fm_signals + def worker(): + ser = None + try: + remote_path = f"{f['folder']}/{f['name']}" + ser = self.parent_win._open_connection() + quicksync.delete_file(ser, remote_path) + fm_sig.set_status.emit(f'✓ {self.tr("Deleted")}: {f["name"]}') + fm_sig.do_reload.emit() + except Exception as e: + fm_sig.set_status.emit(f'✗ {e}') + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + +class ContactsWindow(QDialog): + COLUMNS = [('name', 'Name', 200), ('cell', 'Mobile', 130), ('home', 'Home', 130), ('work', 'Work', 130), ('email', 'E-Mail', 200)] + + def __init__(self, parent: QuickSyncGUI): + super().__init__(parent) + self.parent_win = parent + self.setWindowTitle(self.tr('Manage Contacts')) + self.resize(900, 500) + self.cards: list[dict] = [] + self._row_to_index: dict[int, int] = {} + self.modified_luids: set[str] = set() + self.deleted_luids: set[str] = set() + self.temp_vcf_path: str | None = None + self._reload_thread: threading.Thread | None = None + + layout = QVBoxLayout(self) + toolbar = QHBoxLayout() + def tbtn(label, slot): + b = QPushButton(label); b.clicked.connect(slot); toolbar.addWidget(b); return b + tbtn(self.tr('New'), self.new_contact) + tbtn(self.tr('Edit'), self.edit_selected) + tbtn(self.tr('Delete'), self.delete_selected) + toolbar.addSpacing(12) + tbtn(self.tr('Reload'), self.reload_with_confirm) + tbtn(self.tr('Save'), self.save) + tbtn(self.tr('Transmit'), self.transmit) + tbtn(self.tr('Close'), self._on_close_request) + toolbar.addStretch() + layout.addLayout(toolbar) + + self.table = QTableWidget(0, len(self.COLUMNS)) + self.table.setHorizontalHeaderLabels([c[1] for c in self.COLUMNS]) + for i, (_, _, w) in enumerate(self.COLUMNS): self.table.setColumnWidth(i, w) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.doubleClicked.connect(lambda _: self.edit_selected()) + layout.addWidget(self.table, stretch=1) + + self.status_label = QLabel('') + layout.addWidget(self.status_label) + self.setModal(False) + self.show() + + # Sofortiger visueller Indikator im Hauptfenster + self.parent_win.ui_kontakt_anzahl.setText(self.tr("loading…")) + QTimer.singleShot(50, self.reload) + + def _refresh_status(self): + pending = sum(1 for c in self.cards if not c.get('luid')) + len(self.modified_luids) + len(self.deleted_luids) + suffix = f' — {pending} ' + self.tr('unsaved change(s)') if pending else '' + self.status_label.setText(f'{len(self.cards)} ' + self.tr('contact(s)') + suffix) + + # Absolut direkte und sichere Aktualisierung des Hauptfenster-Labels aus dem UI-Thread + self.parent_win.ui_kontakt_anzahl.setText(str(len(self.cards))) + + def reload(self): + if self._reload_thread and self._reload_thread.is_alive(): + self.parent_win._signals.append_text.emit(f'⚠ {self.tr("Reload already in progress — please wait.")}') + return + self.parent_win.output.clear() + self.status_label.setText(self.tr('Loading contacts …')) + sig = self.parent_win._signals + + def worker(): + if not self.parent_win.current_device(): + QTimer.singleShot(0, self, lambda: self._on_error(self.tr('Load failed'), RuntimeError(self.tr('No device connected.')))) + return + ser = None + fd, path = tempfile.mkstemp(suffix='.vcf', prefix='quicksync_') + os.close(fd) + try: + ser = self.parent_win._open_connection() + vcf_bytes = quicksync.get_contacts(ser) + with open(path, 'wb') as f: + f.write(vcf_bytes) + vcf = vcf_bytes.decode('utf-8', errors='replace') + if not vcf.strip(): + raise RuntimeError(self.tr('Device returned an empty response.')) + cards = vcard.parseCards(vcf) + sig.append_text.emit(self.tr('{} contact(s) processed').format(len(cards))) + QTimer.singleShot(0, self.parent_win, lambda: self.parent_win.ui_kontakt_anzahl.setText(str(len(cards)))) + QTimer.singleShot(0, self, lambda: self._populate(cards, path)) + except Exception as e: + try: + os.unlink(path) + except OSError: + pass + QTimer.singleShot(0, self, lambda: self._on_error(self.tr('Load failed'), e)) + finally: + if ser: + quicksync.close_connection(ser) + + self._reload_thread = threading.Thread(target=worker, daemon=True) + self._reload_thread.start() + + def reload_with_confirm(self): + pending = sum(1 for c in self.cards if not c.get('luid')) + len(self.modified_luids) + len(self.deleted_luids) + if pending > 0: + r = QMessageBox.question(self, self.tr('Reload'), self.tr('There are unsaved changes. Reload anyway?')) + if r != QMessageBox.Yes: + return + self.modified_luids.clear() + self.deleted_luids.clear() + self.reload() + + def _on_error(self, title, exc): + self.status_label.setText('') + QMessageBox.critical(self, title, str(exc)) + + def _populate(self, cards, temp_path=None): + self.temp_vcf_path = temp_path + self.cards = cards + self._rebuild_table() + + def _rebuild_table(self): + self.table.setRowCount(0) + self._row_to_index = {} + for i, c in enumerate(self.cards): + row = self.table.rowCount() + self.table.insertRow(row) + self._row_to_index[row] = i + values = [ + vcard.displayName(c), + c.get('tels', {}).get('CELL', ''), + c.get('tels', {}).get('HOME', ''), + c.get('tels', {}).get('WORK', ''), + (c.get('emails', {}).get('HOME', '') or c.get('emails', {}).get('WORK', '') or c.get('emails', {}).get('OTHER', '')), + ] + for col, val in enumerate(values): + self.table.setItem(row, col, QTableWidgetItem(val)) + self._refresh_status() + + def new_contact(self): + blank = {'luid': None, 'last_name': '', 'first_name': '', 'middle_name': '', 'prefix': '', 'suffix': '', 'nickname': '', 'org': '', 'title': '', 'bday': '', 'note': '', 'url': '', 'tels': {'HOME': '', 'CELL': '', 'WORK': '', 'FAX': '', 'OTHER': ''}, 'emails': {'HOME': '', 'WORK': '', 'OTHER': ''}, 'addresses': {'HOME': {'pobox': '', 'ext': '', 'street': '', 'city': '', 'region': '', 'zip': '', 'country': ''}, 'WORK': {'pobox': '', 'ext': '', 'street': '', 'city': '', 'region': '', 'zip': '', 'country': ''}}, 'extras': []} + ContactEditor(self, blank, on_save=lambda c: (self.cards.append(c), self._rebuild_table())) + + def edit_selected(self): + rows = self.table.selectedItems() + if not rows: return + idx = self._row_to_index.get(self.table.currentRow()) + if idx is not None: + ContactEditor(self, self.cards[idx], on_save=lambda c: (self.modified_luids.add(c['luid']) if c.get('luid') else None, self._rebuild_table())) + + def delete_selected(self): + rows = self.table.selectedItems() + if not rows: return + idx = self._row_to_index.get(self.table.currentRow()) + if idx is not None: + luid = self.cards[idx].get('luid') + if luid: self.deleted_luids.add(luid) + del self.cards[idx] + self._rebuild_table() + + def save(self): + if not self.temp_vcf_path: return + with open(self.temp_vcf_path, 'w', encoding='utf-8') as f: + for c in self.cards: f.write(vcard.formatCard(c)) + + def transmit(self): + deletes = list(self.deleted_luids) + creates = [c for c in self.cards if not c.get('luid')] + + def worker(): + ser = None + try: + ser = self.parent_win._open_connection() + for luid in deletes: + quicksync.delete_contact(ser, luid) + quicksync.close_connection(ser) + ser = self.parent_win._open_connection() + if creates: + for c in creates: + vcf_bytes = vcard.formatCard(c).encode('utf-8') + quicksync.create_contact(ser, vcf_bytes) + quicksync.close_connection(ser) + ser = self.parent_win._open_connection() + QTimer.singleShot(0, self.reload) + except Exception as e: + QTimer.singleShot(0, lambda: QMessageBox.critical(self, self.tr('Error'), str(e))) + finally: + if ser: + quicksync.close_connection(ser) + threading.Thread(target=worker, daemon=True).start() + + def _on_close_request(self): self.close() + +class ContactEditor(QDialog): + def __init__(self, parent, card, on_save): + super().__init__(parent) + self.setWindowTitle(self.tr('Edit Contact') if card.get('luid') else self.tr('New Contact')) + self.resize(400, 450) + self.card = card + self.on_save = on_save + self.entries = {} + layout = QVBoxLayout(self) + form = QFormLayout() + + self.entries['first_name'] = QLineEdit(card.get('first_name', '')) + self.entries['last_name'] = QLineEdit(card.get('last_name', '')) + self.entries['cell'] = QLineEdit(card.get('tels', {}).get('CELL', '')) + self.entries['home'] = QLineEdit(card.get('tels', {}).get('HOME', '')) + self.entries['email'] = QLineEdit(card.get('emails', {}).get('HOME', '')) + + form.addRow(self.tr('First name') + ':', self.entries['first_name']) + form.addRow(self.tr('Last name') + ':', self.entries['last_name']) + form.addRow(self.tr('Mobile') + ':', self.entries['cell']) + form.addRow(self.tr('Phone') + ':', self.entries['home']) + form.addRow(self.tr('E-Mail') + ':', self.entries['email']) + layout.addLayout(form) + + btns = _make_dialog_buttons(self) + btns.accepted.connect(self._save); btns.rejected.connect(self.reject) + layout.addWidget(btns) + self.show() + + def _save(self): + self.card['first_name'] = self.entries['first_name'].text() + self.card['last_name'] = self.entries['last_name'].text() + self.card.setdefault('tels', {})['CELL'] = self.entries['cell'].text() + self.card.setdefault('tels', {})['HOME'] = self.entries['home'].text() + self.card.setdefault('emails', {})['HOME'] = self.entries['email'].text() + self.on_save(self.card) + self.accept() + +def simple_input(parent, title, prompt) -> str | None: + dlg = QDialog(parent); dlg.setWindowTitle(title) + l = QVBoxLayout(dlg); l.addWidget(QLabel(prompt)); e = QLineEdit(); l.addWidget(e) + b = _make_dialog_buttons(dlg); b.accepted.connect(dlg.accept); b.rejected.connect(dlg.reject); l.addWidget(b) + if dlg.exec() == QDialog.Accepted: return e.text().strip() or None + return None + +def run(): + import sys + app = QApplication.instance() or QApplication(sys.argv) + + # Load translation: English is the default. A translator is only + # installed if the user explicitly selected a different language + # via the Language settings dialog. + from PySide6.QtCore import QTranslator, QSettings + _s = QSettings('QuickSync4LinuxGui', 'QuickSync4LinuxGui') + saved_lang = _s.value('language', '') + if saved_lang and saved_lang != 'en': + translator = QTranslator(app) + lang_dir = os.path.join(os.path.dirname(__file__), 'lang') + if translator.load(saved_lang, lang_dir): + app.installTranslator(translator) + win = QuickSyncGUI() + win.show() + app.exec() + +if __name__ == '__main__': + run() diff --git a/QuickSync4LinuxGui/lang/de.qm b/QuickSync4LinuxGui/lang/de.qm new file mode 100644 index 0000000..c96cebe Binary files /dev/null and b/QuickSync4LinuxGui/lang/de.qm differ diff --git a/QuickSync4LinuxGui/lang/de.ts b/QuickSync4LinuxGui/lang/de.ts new file mode 100644 index 0000000..f2dd50f --- /dev/null +++ b/QuickSync4LinuxGui/lang/de.ts @@ -0,0 +1,673 @@ + + + + + QuickSyncGUI + + <b>Device Information</b> + <b>Geräteinformationen</b> + + + Manufacturer: + Hersteller: + + + Model / Product: + Modell / Produkt: + + + MAC Address: + MAC-Adresse: + + + Firmware Version: + Firmware-Version: + + + Serial Number (IPUI): + Seriennummer (IPUI): + + + Contact Count: + Anzahl Kontakte: + + + Device + Gerät + + + Connect + Verbinden + + + Disconnect + Trennen + + + Contacts + Kontakte + + + Manage + Verwalten + + + Export + Exportieren + + + Import + Importieren + + + Files + Dateien + + + File Manager + Dateimanager + + + Settings + Einstellungen + + + Timeouts + Zeitüberschreitungen + + + Baud Rate + Baudrate + + + Log / Console + Log / Konsole + + + Open Log + Log öffnen + + + connected + verbunden + + + No device connected + Kein Gerät verbunden + + + No device connected. + Kein Gerät verbunden. + + + Device connected + Gerät verbunden + + + No Device + Kein Gerät + + + Please select a device first. + Bitte zuerst ein Gerät auswählen. + + + Not Connected + Nicht verbunden + + + Please connect to a device first. + Bitte zuerst eine Verbindung herstellen. + + + Retrieving info... + Info wird abgerufen... + + + Retrieving OBEX info... + OBEX Info wird abgerufen... + + + --- Establishing connection --- + --- Verbindung wird hergestellt --- + + + Checking device status… + Gerätestatus wird geprüft… + + + Device Info + Geräteinformationen + + + Obex Info + Obex Info + + + Connection Lost + Verbindung unterbrochen + + + The device is currently disconnected. Please reconnect. + Das Gerät ist derzeit getrennt. Bitte neu verbinden. + + + No device selected. + Kein Gerät ausgewählt. + + + Loading file list … + Dateiliste wird geladen … + + + ✗ No files found. + ✗ Keine Dateien gefunden. + + + ✗ Preview not available + ✗ Vorschau nicht verfügbar + + + ✓ Upload complete + ✓ Upload abgeschlossen + + + ✗ Upload failed + ✗ Upload fehlgeschlagen + + + ✗ Delete failed + ✗ Löschen fehlgeschlagen + + + Save as + Speichern als + + + Choose file to upload + Datei zum Hochladen wählen + + + Notice + Hinweis + + + Please select a file. + Bitte eine Datei auswählen. + + + Delete + Löschen + + + Choose log file + Log-Datei wählen + + + Log files (*.log);;All files (*) + Log-Dateien (*.log);;Alle Dateien (*) + + + VCF files (*.vcf);;All files (*) + VCF-Dateien (*.vcf);;Alle Dateien (*) + + + Export contacts as + Kontakte exportieren als + + + Import VCF file + VCF-Datei importieren + + + Choose VCF file + VCF-Datei wählen + + + Save contacts as + Kontakte speichern als + + + Manage Contacts + Kontakte verwalten + + + Loading contacts … + Kontakte werden geladen … + + + Edit Contact + Kontakt bearbeiten + + + Load failed + Laden fehlgeschlagen + + + Device returned an empty response. + Gerät hat eine leere Antwort geliefert. + + + Error + Fehler + + + Connection timeout (s): + Verbindungs-Timeout (s): + + + Bluetooth timeout (s): + Bluetooth Timeout (s): + + + CLI timeout (s): + CLI Timeout (s): + + + Baud rate (Serial): + Baudrate (Seriell): + + + QuickSync4LinuxGui + QuickSync4LinuxGui + + + OK + OK + + + Cancel + Abbrechen + + + Close + Schließen + + + Language + Sprache + + + Info + Info + + + Baudrate + Baudrate + + + The language change takes effect after restarting the application. + Die Sprachänderung wird nach einem Neustart der Anwendung wirksam. + + + + + ✗ Device not connected — Please enable Bluetooth on your phone. + ✗ Gerät nicht verbunden — Bitte Bluetooth auf dem Telefon aktivieren. + + + ✗ Device not reachable — Please turn on the screen and unlock your phone. + ✗ Gerät nicht erreichbar — Bitte Bildschirm einschalten und Telefon entsperren. + + + ✗ Device not reachable — Please enable Bluetooth and turn on the screen of your phone. + ✗ Gerät nicht erreichbar — Bitte Bluetooth aktivieren und Bildschirm einschalten. + + + ✗ Connection refused — Please enable Bluetooth on your phone. + ✗ Verbindung abgelehnt — Bitte Bluetooth auf dem Telefon aktivieren. + + + ✗ Device not found — Please enable Bluetooth on your phone. + ✗ Gerät nicht gefunden — Bitte Bluetooth auf dem Telefon aktivieren. + + + ✗ Timeout — Please unlock your phone and try again. + ✗ Zeitüberschreitung — Bitte Telefon entsperren und erneut versuchen. + + + ✗ No device selected or connection lost + ✗ Kein Gerät ausgewählt oder Verbindung verloren + + + Connection to + Verbindung zu + + + failed + fehlgeschlagen + + + Unknown action + Unbekannte Aktion + + + {} file(s) in "{}" + {} Datei(en) in „{}" + + + Serial + Seriell + + + Log file: {} (max. 1 MB, 3 backups) + Log-Datei: {} (max. 1 MB, 3 Backups) + + + Could not open log file + Log-Datei konnte nicht geöffnet werden + + + Disconnected from + Verbindung getrennt zu + + + Remote file + Remote-Datei + + + Enter remote file name: + Remote-Dateiname eingeben: + + + Remote name + Remote-Name + + + Remote file name on the device: + Remote-Dateiname auf dem Gerät: + + + Remote file name to delete: + Remote-Dateiname zum Löschen: + + + + + FileManagerWindow + + Name + Name + + + Date + Datum + + + Size + Größe + + + Path + Pfad + + + No selection + Keine Auswahl + + + Loading image … + Lade Bild … + + + No preview available + Keine Bildvorschau + + + Image error + Bildfehler + + + Deleting + Lösche + + + Reload + Aktualisieren + + + Download + Herunterladen + + + Upload + Hochladen + + + Really delete file "{}"? + Datei „{}" wirklich löschen? + + + Loading file list … + Dateiliste wird geladen … + + + ✓ File list loaded: {} file(s) in {} folder(s) + ✓ Dateiliste geladen: {} Datei(en) in {} Ordner(n) + + + ✗ No files found. + ✗ Keine Dateien gefunden. + + + ✗ Preview not available + ✗ Vorschau nicht verfügbar + + + ✓ Upload complete + ✓ Upload abgeschlossen + + + ✗ Upload failed + ✗ Upload fehlgeschlagen + + + ✗ Delete failed + ✗ Löschen fehlgeschlagen + + + Save as + Speichern als + + + Choose file to upload + Datei zum Hochladen wählen + + + Notice + Hinweis + + + Please select a file. + Bitte eine Datei auswählen. + + + Delete + Löschen + + + Downloading + Herunterladen + + + Saved + Gespeichert + + + Uploading + Hochladen + + + Deleted + Gelöscht + + + Error + Fehler + + + Parse error + Parse-Fehler + + + + + ContactsWindow + + loading… + wird geladen… + + + Reload already in progress — please wait. + Neu laden läuft bereits — bitte warten. + + + There are unsaved changes. Reload anyway? + Es gibt ungespeicherte Änderungen. Trotzdem neu laden? + + + Mobile + Mobil + + + Home + Privat + + + Work + Geschäftl. + + + contact(s) + Kontakt(e) + + + unsaved change(s) + ungespeicherte Änderung(en) + + + New + Neu + + + Edit + Bearbeiten + + + Delete + Löschen + + + Reload + Neu laden + + + Save + Speichern + + + Transmit + Übertragen + + + Close + Schließen + + + {} contact(s) processed + {} Kontakt(e) verarbeitet + + + Loading contacts … + Kontakte werden geladen … + + + Manage Contacts + Kontakte verwalten + + + Edit Contact + Kontakt bearbeiten + + + Load failed + Laden fehlgeschlagen + + + Device returned an empty response. + Gerät hat eine leere Antwort geliefert. + + + Error + Fehler + + + VCF files (*.vcf);;All files (*) + VCF-Dateien (*.vcf);;Alle Dateien (*) + + + Export contacts as + Kontakte exportieren als + + + Import VCF file + VCF-Datei importieren + + + Choose VCF file + VCF-Datei wählen + + + Save contacts as + Kontakte speichern als + + + Not Connected + Nicht verbunden + + + Please connect to a device first. + Bitte zuerst eine Verbindung herstellen. + + + + + ContactEditor + + Edit Contact + Kontakt bearbeiten + + + New Contact + Neuer Kontakt + + + First name + Vorname + + + Last name + Nachname + + + Mobile + Mobil + + + Phone + Telefon + + + E-Mail + E-Mail + + + OK + OK + + + Cancel + Abbrechen + + + \ No newline at end of file diff --git a/QuickSync4LinuxGui/obex.py b/QuickSync4LinuxGui/obex.py new file mode 100644 index 0000000..372fdbf --- /dev/null +++ b/QuickSync4LinuxGui/obex.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 + +from xml.dom import minidom, expatbuilder +from enum import Enum +import struct + +# https://en.wikipedia.org/wiki/OBject_EXchange +# https://btprodspecificationrefs.blob.core.windows.net/ext-ref/IrDA/OBEX15.pdf + +class Connection: + Version = b"\x10" + Flags = b"\x00" + MaxPacketSize = b"\xff\xfe" + +class SetPathFlags: + LayerUp = 0b00000001 # backup a level before applying name (equivalent to ../) + DontCreate = 0b00000010 # don't create folder if it does not exist + Constants = 0x00 # reserved, always 0x00 + +class Mask: + # the final bit indicates the last packet for the request/response + Final = 0b10000000 + NotFinal = 0b01111111 + +class OpCode: + Connect = 0x80 + Disconnect = 0x81 + Put = 0x02 + Get = 0x03 + SetPath = 0x85 + Session = 0x87 + Abort = 0xff + +class ReCode(int, Enum): + # HTTP 1xx codes + Continue = 0x10 + + # HTTP 2xx codes + Success = 0x20 + Created = 0x21 + Accepted = 0x22 + NonAuthoritative = 0x23 + NoContent = 0x24 + ResetContent = 0x25 + PartialContent = 0x26 + + # HTTP 3xx codes + MultipleChoices = 0x30 + MovedPermanently = 0x31 + MovedTemporarily = 0x32 + SeeOther = 0x33 + NotModified = 0x34 + UseProxy = 0x35 + + # HTTP 4xx codes + BadRequest = 0x40 + Unauthorized = 0x41 + PaymentRequired = 0x42 + Forbidden = 0x43 + NotFound = 0x44 + MethodNotAllowed = 0x45 + NotAcceptable = 0x46 + ProxyAuthRequired = 0x47 + RequestTimeOut = 0x48 + Conflict = 0x49 + Gone = 0x4a + LengthRequired = 0x4b + PreconditionFail = 0x4c + ReqEntityTooLarge = 0x4d + RequestUrlTooLarge = 0x4e + UnsupportedMedia = 0x4f + + # HTTP 5xx codes + InternalServerError = 0x50 + NotImplemented = 0x51 + BadGateway = 0x52 + ServiceUnavailable = 0x53 + GatewayTimeout = 0x54 + VersionNotSupported = 0x55 + + # special codes + DatabaseFull = 0x60 + DatabaseLocked = 0x61 + +class Header: + Count = 0xc0 + Name = 0x01 + Type = 0x42 + Length = 0xc3 + TimeIso8601 = 0x44 + Time4byte = 0xc4 + Description = 0x05 + Target = 0x46 + HTTP = 0x47 + Body = 0x48 + EndOfBody = 0x49 + Who = 0x4a + ConnectionId = 0xcb + AppParameters = 0x4c + AuthChallenge = 0x4d + AuthResponse = 0x4e + CreatorId = 0xcf + WanUuid = 0x50 + ObjectClass = 0x51 + SessionParams = 0x52 + SessionSeqNo = 0x93 + ActionId = 0x94 + DestName = 0x15 + Permissions = 0xd6 + SingleResponseMode = 0x97 + SingleResponseParams = 0x98 + # 0x19 to 0x2f = Reserved + # 0x30 to 0x3f = User defined + +class AppParametersCommand: + MemoryStatusTotal = b"\x32\x01\x01" + MemoryStatusFree = b"\x32\x01\x02" + +class ServiceUuid: + IrMcSync = b"\x49\x52\x4d\x43\x2d\x53\x59\x4e\x43" + DesSync = b"\x6b\x01\xcb\x31\x41\x06\x11\xd4\x9a\x77\x00\x50\xda\x3f\x47\x1f" + FolderBrowsing = b"\xf9\xec\x7b\xc4\x95\x3c\x11\xd2\x98\x4e\x52\x54\x00\xdc\x9e\x09" + SyncMl = b"\x53\x59\x4e\x43\x4d\x4c\x2d\x53\x59\x4e\x43" + +class FolderPath: + ClipPictures = "/Clip Pictures" + ScreenSavers = "/Pictures" + Ringtones = "/Sounds" + +class FilePath: + PhoneBook = "/telecom/pb.vcf" + InfoLog = "/telecom/pb/info.log" + DevInfo = "/telecom/devinfo.txt" + LuidCC = "/telecom/pb/luid/cc.log" + Luid0 = "/telecom/pb/luid/0.log" + VCardLuid = "/telecom/pb/luid/{0}.vcf" + NewVCardGQS = "/telecom/pb/luid/zapis.vcf" + NewVCardGDS = "/telecom/pb/luid/.vcf" + IncomingCalls = "telecom/ich.log" + OutgoingCalls = "telecom/och.log" + MissedCalls = "telecom/mch.log" + +class ObjectMimeType: + FolderListing = "x-obex/folder-listing\0" + Capability = "x-obex/capability\0" + +class QuickSyncOperation: + Download = 1 + Upload = 2 + +class ObexException(Exception): + pass +class InvalidObexLengthException(Exception): + pass + +def compileMessage(opcode, payload=b''): + if(not isinstance(payload, (bytes, bytearray))): + payload = payload.encode('ascii') + return struct.pack('B', opcode) + struct.pack('>H', len(payload)+3) + payload + +def compileConnect(optionalHeaders): + return compileMessage( + OpCode.Connect, + Connection.Version + Connection.Flags + Connection.MaxPacketSize + optionalHeaders + ) + +def compileNameHeader(text): + return compileMessage(Header.Name, b'\x00'+text.encode('utf-16-le')+b'\x00') + +def compileLengthHeader(length): + return struct.pack('B', Header.Length) + struct.pack('>I', length) + +def parseMemoryResponse(data, offset=1): + if(data[offset] == 1): + return data[offset + 1] + elif(data[offset] == 2): + return struct.unpack('>H', data[offset+1:])[0] + elif(data[offset] == 4): + return struct.unpack('>I', data[offset+1:])[0] + else: return 0 + +def evaluateResponse(buf, results, ser, isUpload): + if(len(buf) < 3 + or len(buf) != struct.unpack('>H', buf[1:3])[0]): + raise InvalidObexLengthException() + + elif(buf[0] & Mask.NotFinal == ReCode.Continue and buf[0] & Mask.Final): + if(isUpload): + return True + else: + results.extend(parseHeaders(buf[3:])) + ser.write(compileMessage(OpCode.Get+Mask.Final)) + return False + + elif(buf[0] & Mask.NotFinal == ReCode.Success): + results.extend(parseHeaders(buf[3:])) + return True + + else: + errorString = 'Unknown Error' + try: + errorString = str(ReCode(buf[0] & Mask.NotFinal)) + except ValueError: pass + raise ObexException('Device reported an obex command error, code {:02X} ({})'.format(buf[0], errorString)) + +def parseHeaders(obj): + results = [] + + if(len(obj) < 3): + return results + + currOffset = 0 + while True: + currLength = 1 + + if(len(obj) <= currOffset): break + + if(obj[currOffset] == Header.Length): + currLength = 5 + print('Payload Length:', struct.unpack('>I', obj[currOffset+1:currOffset+5])[0]) + + elif(obj[currOffset] == Header.Count): + currLength = 5 + print('Payload Count:', struct.unpack('>I', obj[currOffset+1:currOffset+5])[0]) + + elif(obj[currOffset] == Header.Body + or obj[currOffset] == Header.EndOfBody + or obj[currOffset] == Header.AppParameters): + if(len(obj) < currOffset+3): break + currLength = struct.unpack('>H', obj[currOffset+1:currOffset+3])[0] + if(currLength == 0): break + + currHeader = obj[currOffset:currOffset+currLength] + if(len(currHeader) != currLength): + raise InvalidObexLengthException() + + results.append(currHeader[3:]) + + else: + #print('Unknown Header:', struct.pack('B', obj[currOffset])) + break + + currOffset += currLength + + return results + +def parseFileListXml(xmlstring): + files = [] + maxLenName = 0 + document = minidom.parseString(xmlstring).documentElement + for file in document.getElementsByTagName('file'): + maxLenName = max(maxLenName, len(file.getAttribute('name'))) + files.append({ + 'name': file.getAttribute('name'), + 'size': file.getAttribute('size'), + 'fileid': file.getAttribute('fileid'), + 'modified': file.getAttribute('modified'), + 'user-perm': file.getAttribute('user-perm'), + 'group-perm': file.getAttribute('group-perm'), + }) + return files, maxLenName diff --git a/QuickSync4LinuxGui/pyproject.toml b/QuickSync4LinuxGui/pyproject.toml new file mode 100644 index 0000000..39ac284 --- /dev/null +++ b/QuickSync4LinuxGui/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "QuickSync4LinuxGui" +version = "1.0" +authors = [ + { name="https://github.com/pandinus" }, +] +description = "Graphical user interface for QuickSync4Linux" +readme = "../README.md" +requires-python = ">=3.10" +license = {file = "../LICENSE.txt"} +dependencies = [ + "QuickSync4Linux", + "PySide6", +] + +[project.urls] +Homepage = "https://github.com/schorschii/QuickSync4Linux" +Issues = "https://github.com/schorschii/QuickSync4Linux/issues" + +[project.gui-scripts] +quicksync-gui = "QuickSync4LinuxGui.gui:run" + +[tool.hatch.build.targets.wheel] +packages = ["QuickSync4LinuxGui"] \ No newline at end of file diff --git a/QuickSync4LinuxGui/quicksync.py b/QuickSync4LinuxGui/quicksync.py new file mode 100755 index 0000000..c6e0205 --- /dev/null +++ b/QuickSync4LinuxGui/quicksync.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import configparser +import datetime +import struct +import serial +import time +import argparse +import re +import sys + +from . import at +from . import obex +from . import btserial +from .__init__ import __version__ + + +# ─── Connection ─────────────────────────────────────────────────────────────── + +def open_connection(device: str, baud: int = 9600): + """Open a serial or Bluetooth connection to the device. + Returns a serial/BluetoothSerial object.""" + if btserial.isBluetoothAddress(device): + mac, channel = btserial.parseBluetoothAddress(device) + return btserial.BluetoothSerial(mac, channel, write_timeout=at.Delay.TimeoutWrite) + else: + return serial.Serial(device, baud, write_timeout=at.Delay.TimeoutWrite) + + +def close_connection(ser): + """Close the serial/Bluetooth connection.""" + try: + ser.close() + except Exception: + pass + + +# ─── Low-level communication ────────────────────────────────────────────────── + +def send_and_read(ser, data, wait=None, is_obex=False, verbose=0): + """Send data and read response from device.""" + if verbose: + print() + print('=== SEND ===') + if verbose >= 2: print(data.hex()) + print(data.decode('ascii', errors='backslashreplace')) + + ser.write(data) + + if verbose: + print() + print('=== RECEIVE ===') + + results = [] + buf = b'' + while True: + default_delay = at.Delay.AfterInvoke if not is_obex else 0 + time.sleep(wait if wait else default_delay) + + if ser.in_waiting == 0 and not wait: + continue + + tmp = ser.read(ser.in_waiting) + buf += tmp + if verbose: + if verbose >= 2: print(tmp.hex()) + print(tmp.decode('ascii', errors='backslashreplace'), end='') + + if is_obex: + try: + if obex.evaluateResponse(buf, results, ser, is_obex == obex.QuickSyncOperation.Upload): + return b''.join(results) + else: + buf = b'' + except obex.InvalidObexLengthException: + continue + else: + try: + return at.evaluateResponse(buf, data) + except at.IncompleteAtResponseException: + continue + + +def _exit_obex(ser): + """Exit OBEX mode and reset device.""" + time.sleep(at.Delay.ObexBoundary) + send_and_read(ser, at.formatCommand(at.Command.ExitObex), wait=at.Delay.ObexBoundary) + time.sleep(at.Delay.AfterExitObex) + send_and_read(ser, at.formatCommand(at.Command.Reset), wait=at.Delay.AfterExitObex) + + +def _enter_obex_dessync(ser): + """Enter OBEX mode and connect to DesSync service.""" + send_and_read(ser, at.formatCommand(at.Command.EnterObex), wait=at.Delay.AfterEnterObex) + send_and_read(ser, + obex.compileConnect(obex.compileMessage(obex.Header.Target, obex.ServiceUuid.DesSync)), + is_obex=True + ) + + +# ─── Device info ────────────────────────────────────────────────────────────── + +def get_info(ser, verbose=0) -> str: + """Query device info and return as formatted string.""" + lines = [] + for title, command in { + 'Manufacturer': at.Command.GetManufacturer, + 'Type': at.Command.GetDeviceType, + 'Product': at.Command.GetProductName, + 'Serial (IPUI)': at.Command.GetSerialNumber, + 'Internal Name': at.Command.GetInternalName, + 'Battery State': at.Command.GetBatteryState, + 'Signal State': at.Command.GetSignalState, + 'Firmware': at.Command.GetFirmwareVersion, + 'Firmware URL': at.Command.GetFirmwareUrl, + 'Melodies': at.Command.ListMelodies, + 'Area Codes': at.Command.GetAreaCodes, + 'Hardware Connection State': at.Command.GetHardwareConnectionState, + 'Supported Features': at.Command.GetSupportedFeatures, + 'Supported Multimedia': at.Command.GetSupportedMultimedia, + 'Screen Size Clip': at.Command.GetScreenSizeClip, + 'Screen Size Full': at.Command.GetScreenSizeFull, + 'Extended Modes List': at.Command.GetExtendedModesList, + 'Current Extended Mode': at.Command.GetCurrentExtendedMode, + }.items(): + try: + response = send_and_read(ser, at.formatCommand(command), verbose=verbose).decode('ascii') + except Exception as e: + response = '[ERROR: ' + str(e) + ']' + lines.append(title + ': ' + response) + return '\n'.join(lines) + + +def get_obex_info(ser, verbose=0) -> str: + """Query OBEX device info and return as formatted string.""" + lines = [] + _enter_obex_dessync(ser) + + for path in [obex.FilePath.InfoLog, obex.FilePath.DevInfo, + obex.FilePath.LuidCC, obex.FilePath.Luid0]: + lines.append('') + lines.append('=== ' + path) + lines.append(send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileNameHeader(path) + ), + is_obex=True, + verbose=verbose + ).decode('utf8')) + + _exit_obex(ser) + return '\n'.join(lines) + + +# ─── Contacts ───────────────────────────────────────────────────────────────── + +def get_contacts(ser, verbose=0) -> bytes: + """Download all contacts from device, return raw VCF bytes.""" + _enter_obex_dessync(ser) + + vcf = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.PhoneBook) + ), + is_obex=True, + verbose=verbose + ) + + _exit_obex(ser) + return vcf + + +def set_contacts(ser, vcf_data: bytes, verbose=0): + """Upload VCF data (bytes) to device, replacing all contacts.""" + if isinstance(vcf_data, str): + vcf_data = vcf_data.encode('utf-8') + # Ensure CRLF line endings + vcf_data = vcf_data.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') + + _enter_obex_dessync(ser) + + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.PhoneBook) + + obex.compileLengthHeader(len(vcf_data)) + + obex.compileMessage(obex.Header.EndOfBody, vcf_data) + ), + is_obex=True, + verbose=verbose + ) + + _exit_obex(ser) + + +def create_contact(ser, vcf_data: bytes, verbose=0): + """Create a new contact on device.""" + if isinstance(vcf_data, str): + vcf_data = vcf_data.encode('utf-8') + vcf_data = vcf_data.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') + + _enter_obex_dessync(ser) + + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.NewVCardGQS) + + obex.compileLengthHeader(len(vcf_data)) + + obex.compileMessage(obex.Header.EndOfBody, vcf_data) + ), + is_obex=True, + verbose=verbose + ) + + _exit_obex(ser) + + +def edit_contact(ser, luid: str, vcf_data: bytes, verbose=0): + """Edit an existing contact on device by luid.""" + if isinstance(vcf_data, str): + vcf_data = vcf_data.encode('utf-8') + vcf_data = vcf_data.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') + + _enter_obex_dessync(ser) + + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.VCardLuid.format(luid)) + + obex.compileLengthHeader(len(vcf_data)) + + obex.compileMessage(obex.Header.EndOfBody, vcf_data) + ), + is_obex=True, + verbose=verbose + ) + + _exit_obex(ser) + + +def delete_contact(ser, luid: str, verbose=0): + """Delete a contact from device by luid.""" + _enter_obex_dessync(ser) + + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(obex.FilePath.VCardLuid.format(luid)) + ), + is_obex=True, + verbose=verbose + ) + + _exit_obex(ser) + + +# ─── Files ──────────────────────────────────────────────────────────────────── + +def list_files(ser, verbose=0) -> str: + """List all files on device, return formatted string.""" + lines = [] + _enter_obex_dessync(ser) + + total_bytes = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileMessage(obex.Header.AppParameters, obex.AppParametersCommand.MemoryStatusTotal) + ), + is_obex=True, + verbose=verbose + ) + free_bytes = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileMessage(obex.Header.AppParameters, obex.AppParametersCommand.MemoryStatusFree) + ), + is_obex=True, + verbose=verbose + ) + lines.append('Total Space: ' + str(obex.parseMemoryResponse(total_bytes) / 1024) + ' KiB') + lines.append('Free Space: ' + str(obex.parseMemoryResponse(free_bytes) / 1024) + ' KiB') + + for folder in [obex.FolderPath.ScreenSavers, obex.FolderPath.ClipPictures, obex.FolderPath.Ringtones]: + lines.append('') + lines.append('=== ' + folder) + send_and_read(ser, + obex.compileMessage( + obex.OpCode.SetPath, + struct.pack('B', obex.SetPathFlags.DontCreate) + + struct.pack('B', obex.SetPathFlags.Constants) + + obex.compileNameHeader(folder) + ), + is_obex=True, + verbose=verbose + ) + file_list_xml = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileMessage(obex.Header.Type, obex.ObjectMimeType.FolderListing) + ), + is_obex=True, + verbose=verbose + ).decode('utf8') + files, max_len = obex.parseFileListXml(''.join(file_list_xml)) + for f in files: + lines.append( + (f['fileid'] + ':').ljust(4) + ' ' + + f['name'].ljust(max_len) + ' ' + + datetime.datetime.strptime(f['modified'], '%Y%m%dT%H%M%S').strftime('%Y-%m-%d %H:%M') + ' ' + + f['user-perm'] + ' ' + + str(round(int(f['size']) / 1024, 1)) + ' KiB' + ) + + _exit_obex(ser) + return '\n'.join(lines) + + +def download_file(ser, remote_path: str, local_path: str, verbose=0): + """Download a file from device to local path.""" + _enter_obex_dessync(ser) + + content = send_and_read(ser, + obex.compileMessage( + obex.OpCode.Get + obex.Mask.Final, + obex.compileNameHeader(remote_path) + ), + is_obex=True, + verbose=verbose + ) + with open(local_path, 'wb') as f: + f.write(content) + + _exit_obex(ser) + + +def upload_file(ser, remote_name: str, local_path: str, verbose=0): + """Upload a local file to device.""" + with open(local_path, 'rb') as f: + data = f.read() + + _enter_obex_dessync(ser) + + chunk_size = 958 + counter = 0 + chunks = [data[i:i + chunk_size] for i in range(0, len(data), chunk_size)] + for chunk in chunks: + final_flag = obex.Mask.Final if counter == len(chunks) - 1 else 0 + body_header = obex.Header.EndOfBody if counter == len(chunks) - 1 else obex.Header.Body + name_header = obex.compileNameHeader(remote_name) if counter == 0 else b'' + length_header = obex.compileLengthHeader(len(data)) if counter == 0 else b'' + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + final_flag, + name_header + length_header + obex.compileMessage(body_header, chunk) + ), + is_obex=obex.QuickSyncOperation.Upload, + verbose=verbose + ) + counter += 1 + + _exit_obex(ser) + + +def delete_file(ser, remote_path: str, verbose=0): + """Delete a file from device.""" + _enter_obex_dessync(ser) + + send_and_read(ser, + obex.compileMessage( + obex.OpCode.Put + obex.Mask.Final, + obex.compileNameHeader(remote_path) + ), + is_obex=True, + verbose=verbose + ) + + _exit_obex(ser) + + +def dial(ser, number: str, verbose=0): + """Dial a phone number.""" + send_and_read(ser, at.formatCommand(at.Command.Dial, number), wait=0, verbose=verbose) + + +# ─── CLI entry point ────────────────────────────────────────────────────────── + +def main(): + # read config + config = {} + config_parser = configparser.ConfigParser() + config_parser.read(str(Path.home()) + '/.config/quicksync4linuxgui.ini') + if config_parser.has_section('general'): + config = dict(config_parser.items('general')) + + # parse arguments + parser = argparse.ArgumentParser( + prog='QuickSync4Linux', + description='Communicate with Gigaset devices', + epilog=f'Version {__version__}, (c) Georg Sieber 2023-2024. ' + f'If you like this program please consider making a donation using the sponsor button on GitHub ' + f'(https://github.com/schorschii/QuickSync4Linux) to support the development. ' + f'It depends on users like you if this software gets further updates.' + ) + parser.add_argument('action', help='one of: info, obexinfo, dial, getcontacts, setcontacts, ' + 'createcontact, editcontact, deletecontact, listfiles, upload, download, delete') + parser.add_argument('options', nargs='?', help='e.g. a phone number for "dial", a luid for contact ' + 'operations or a file name for file actions') + parser.add_argument('-d', '--device', default=config.get('device', '/dev/ttyACM0'), + help='serial port device or Bluetooth MAC (optionally with @channel, default channel 1)') + parser.add_argument('-b', '--baud', default=config.get('baud', 9600)) + parser.add_argument('-f', '--file', default='-', help='file to read from or write into, stdout/stdin default') + parser.add_argument('-v', '--verbose', action='count', default=0, + help='print complete AT/Obex serial communication') + args = parser.parse_args() + + # open connection + try: + ser = open_connection(args.device, args.baud) + except (OSError, serial.SerialException) as e: + print(f'✗ Connection to {args.device} failed: {e}', file=sys.stderr) + sys.exit(1) + if args.verbose: + print('Connected to:', ser.name) + + try: + if args.action == 'info': + print(get_info(ser, verbose=args.verbose)) + + elif args.action == 'obexinfo': + print(get_obex_info(ser, verbose=args.verbose)) + + elif args.action == 'dial': + if not args.options: + raise Exception('Please provide a number to call') + dial(ser, args.options, verbose=args.verbose) + + elif args.action == 'getcontacts': + if args.file == '-' or args.file == '': + sys.stdout.buffer.write(get_contacts(ser, verbose=args.verbose)) + else: + with open(args.file, 'wb') as f: + f.write(get_contacts(ser, verbose=args.verbose)) + + elif args.action in ('setcontacts', 'createcontacts'): + if args.file == '-' or args.file == '': + vcf = sys.stdin.buffer.read() + else: + with open(args.file, 'rb') as f: + vcf = f.read() + set_contacts(ser, vcf, verbose=args.verbose) + + elif args.action == 'createcontact': + if args.file == '-' or args.file == '': + vcf = sys.stdin.buffer.read() + else: + with open(args.file, 'rb') as f: + vcf = f.read() + create_contact(ser, vcf, verbose=args.verbose) + + elif args.action == 'editcontact': + if not args.options: + raise Exception('Please provide the luid of the contact to edit') + if args.file == '-' or args.file == '': + vcf = sys.stdin.buffer.read() + else: + with open(args.file, 'rb') as f: + vcf = f.read() + edit_contact(ser, args.options, vcf, verbose=args.verbose) + + elif args.action == 'deletecontact': + if not args.options: + raise Exception('Please provide the luid of the contact to delete') + delete_contact(ser, args.options, verbose=args.verbose) + + elif args.action == 'listfiles': + print(list_files(ser, verbose=args.verbose)) + + elif args.action == 'download': + if not args.options: + raise Exception('Please provide the remote file name') + if args.file == '-' or args.file == '': + raise Exception('Please specify the output file via --file parameter') + download_file(ser, args.options, args.file, verbose=args.verbose) + + elif args.action == 'upload': + if not args.options: + raise Exception('Please provide the remote file name') + if args.file == '-' or args.file == '': + raise Exception('Please specify the input file via --file parameter') + upload_file(ser, args.options, args.file, verbose=args.verbose) + + elif args.action == 'delete': + if not args.options: + raise Exception('Please provide the remote file name to delete') + delete_file(ser, args.options, verbose=args.verbose) + + else: + print('Unknown action:', args.action) + sys.exit(1) + + finally: + close_connection(ser) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/QuickSync4LinuxGui/vcard.py b/QuickSync4LinuxGui/vcard.py new file mode 100644 index 0000000..91bccc7 --- /dev/null +++ b/QuickSync4LinuxGui/vcard.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 + +import quopri +import re + + +CARD_RE = re.compile(r"BEGIN:VCARD[\S\s]*?END:VCARD", re.IGNORECASE) + + +def _decode_value(value, params): + if params.get('ENCODING', '').upper() == 'QUOTED-PRINTABLE': + charset = params.get('CHARSET', 'UTF-8') + try: + return quopri.decodestring(value.encode('ascii', errors='replace')).decode(charset, errors='replace') + except Exception: + return value + return value + + +def _qp_encode(text): + encoded = quopri.encodestring(text.encode('utf-8'), quotetabs=False).decode('ascii') + return encoded.replace('\n', '').replace('\r', '') + + +def _needs_qp(text): + return any(ord(c) > 127 or c == ';' for c in text) + + +def _split_n(value): + parts = value.split(';') + while len(parts) < 5: + parts.append('') + return { + 'last_name': parts[0], + 'first_name': parts[1], + 'middle_name': parts[2], + 'prefix': parts[3], + 'suffix': parts[4], + } + + +def _join_n(card): + return ';'.join([ + card.get('last_name', ''), + card.get('first_name', ''), + card.get('middle_name', ''), + card.get('prefix', ''), + card.get('suffix', ''), + ]) + + +def _split_adr(value): + parts = value.split(';') + while len(parts) < 7: + parts.append('') + return { + 'pobox': parts[0], + 'ext': parts[1], + 'street': parts[2], + 'city': parts[3], + 'region': parts[4], + 'zip': parts[5], + 'country': parts[6], + } + + +def _join_adr(adr): + return ';'.join([ + adr.get('pobox', ''), + adr.get('ext', ''), + adr.get('street', ''), + adr.get('city', ''), + adr.get('region', ''), + adr.get('zip', ''), + adr.get('country', ''), + ]) + + +def parseLines(card_text): + """Yield (name, params_dict, value) tuples for the VCARD body.""" + raw = card_text.replace('\r\n', '\n').split('\n') + + merged = [] + i = 0 + while i < len(raw): + line = raw[i] + upper = line.upper() + is_qp = 'QUOTED-PRINTABLE' in upper + while is_qp and line.endswith('=') and i + 1 < len(raw): + i += 1 + line = line[:-1] + raw[i] + merged.append(line) + i += 1 + + for line in merged: + if ':' not in line: + continue + prefix, value = line.split(':', 1) + head = prefix.split(';') + name = head[0].strip().upper() + if name in ('BEGIN', 'END', 'VERSION'): + continue + params = {} + types = [] + for p in head[1:]: + if '=' in p: + k, v = p.split('=', 1) + params[k.strip().upper()] = v.strip() + else: + types.append(p.strip().upper()) + if types: + params['TYPE'] = types + yield name, params, _decode_value(value, params) + + +def parseCards(vcf_text): + """Parse a full VCF blob, return list of contact dicts.""" + out = [] + for raw_card in CARD_RE.findall(vcf_text): + card = { + 'luid': None, + 'last_name': '', 'first_name': '', 'middle_name': '', 'prefix': '', 'suffix': '', + 'nickname': '', + 'org': '', + 'title': '', + 'bday': '', + 'note': '', + 'tels': {'HOME': '', 'CELL': '', 'WORK': '', 'FAX': '', 'OTHER': ''}, + 'emails': {'HOME': '', 'WORK': '', 'OTHER': ''}, + 'addresses': {'HOME': _split_adr(''), 'WORK': _split_adr('')}, + 'url': '', + 'extras': [], + } + for name, params, value in parseLines(raw_card): + types = params.get('TYPE', []) + if name == 'X-IRMC-LUID': + card['luid'] = value + elif name == 'N': + card.update(_split_n(value)) + elif name == 'FN': + if not card['first_name'] and not card['last_name']: + card['first_name'] = value + elif name == 'NICKNAME': + card['nickname'] = value + elif name == 'ORG': + card['org'] = value + elif name == 'TITLE': + card['title'] = value + elif name == 'BDAY': + card['bday'] = value + elif name == 'NOTE': + card['note'] = value + elif name == 'URL': + card['url'] = value + elif name == 'TEL': + key = next((t for t in ('HOME', 'CELL', 'WORK', 'FAX') if t in types), 'OTHER') + card['tels'][key] = value + elif name == 'EMAIL': + key = next((t for t in ('HOME', 'WORK') if t in types), 'OTHER') + card['emails'][key] = value + elif name == 'ADR': + key = 'WORK' if 'WORK' in types else 'HOME' + card['addresses'][key] = _split_adr(value) + else: + card['extras'].append((name, params, value)) + out.append(card) + return out + + +def _emit(name, value, force_qp=False): + if force_qp or _needs_qp(value): + return f'{name};ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(value)}' + return f'{name}:{value}' + + +def formatCard(card): + """Serialize a contact dict to a VCF 2.1 VCARD block (CRLF line endings).""" + lines = ['BEGIN:VCARD', 'VERSION:2.1'] + if card.get('luid'): + lines.append(f'X-IRMC-LUID:{card["luid"]}') + + n_value = _join_n(card) + if any(ord(c) > 127 for c in n_value): + lines.append(f'N;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(n_value)}') + else: + lines.append(f'N:{n_value}') + + full_name = (card.get('first_name', '') + ' ' + card.get('last_name', '')).strip() + if full_name: + if any(ord(c) > 127 for c in full_name): + lines.append(f'FN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(full_name)}') + else: + lines.append(f'FN:{full_name}') + + if card.get('nickname'): + lines.append(_emit('NICKNAME', card['nickname'])) + if card.get('org'): + lines.append(_emit('ORG', card['org'])) + if card.get('title'): + lines.append(_emit('TITLE', card['title'])) + + for kind in ('HOME', 'CELL', 'WORK', 'FAX', 'OTHER'): + value = card.get('tels', {}).get(kind, '').strip() + if value: + lines.append(f'TEL;{kind}:{value}' if kind != 'OTHER' else f'TEL:{value}') + + for kind in ('HOME', 'WORK', 'OTHER'): + value = card.get('emails', {}).get(kind, '').strip() + if value: + lines.append(f'EMAIL;{kind}:{value}' if kind != 'OTHER' else f'EMAIL:{value}') + + for kind in ('HOME', 'WORK'): + adr = card.get('addresses', {}).get(kind, {}) + adr_value = _join_adr(adr) if adr else '' + if adr_value.strip(';'): + if any(ord(c) > 127 for c in adr_value): + lines.append(f'ADR;{kind};ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(adr_value)}') + else: + lines.append(f'ADR;{kind}:{adr_value}') + + if card.get('bday'): + lines.append(f'BDAY:{card["bday"]}') + if card.get('url'): + lines.append(_emit('URL', card['url'])) + if card.get('note'): + lines.append(_emit('NOTE', card['note'])) + + for name, params, value in card.get('extras', []): + param_str = '' + for k, v in params.items(): + if k == 'TYPE': + for t in v: + param_str += f';{t}' + else: + param_str += f';{k}={v}' + if any(ord(c) > 127 for c in value) and 'ENCODING' not in params: + lines.append(f'{name}{param_str};ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:{_qp_encode(value)}') + else: + lines.append(f'{name}{param_str}:{value}') + + lines.append('END:VCARD') + return '\r\n'.join(lines) + '\r\n' + + +def displayName(card): + parts = [card.get('first_name', ''), card.get('last_name', '')] + name = ' '.join(p for p in parts if p).strip() + return name or card.get('org', '') or '(unbenannt)' diff --git a/README.md b/README.md index 8327336..47aef81 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,88 @@ -# QuickSync4Linux -It is annoying that companies always forget to implement their software for the most important operating system. This is a minimal implementation of the Gigaset QuickSync software for Linux. +# QuickSync4LinuxGui + +A graphical user interface (GUI) for [QuickSync4Linux](https://github.com/schorschii/QuickSync4Linux). It extends the original command-line utility with a modern PySide6-based interface to manage your Gigaset device, contacts, and files without touching the command line. The communication with the device is based on AT commands over a USB/Bluetooth serial port. For file transfer, the device is set into Obex mode. -## Hardware Setup -Make sure your user is in the dialout group in order to access the serial port. -``` +## Prerequisites & Installation + +### 1. Hardware Setup + +Make sure your user is in the `dialout` group in order to access the serial port. + +```bash sudo usermod -aG dialout # logout and login again to apply group membership ``` -## Usage -First, find out the correct serial port device. After connecting, a serial port like `/dev/ttyACM0` (USB on Linux), `/dev/tty.usbmodem` (USB on macOS) or `/dev/rfcomm0` ([Bluetooth on Linux](https://gist.github.com/0/c73e2557d875446b9603)) should appear. `/dev/ttyACM0` is used by default. If your device differs, you can use the `--device` parameter for every command or create a config file `~/.config/quicksync4linux.ini`. - -
-Example: ~/.config/quicksync4linux.ini +### 2. Install Python Packages -``` -[general] -device = /dev/rfcomm0 -baud = 9600 -``` -
+Install the CLI package only: -Then, you can use one of the following commands: +```bash +pip install -e . ``` -# read device metadata -python3 -m QuickSync4Linux info -python3 -m QuickSync4Linux obexinfo -# read device contacts and print VCF to stdout (use --file to store it into a file instead) -python3 -m QuickSync4Linux getcontacts +To install the GUI as well, install the separate GUI package. This also installs `QuickSync4Linux` and the required Qt libraries: -# create new contacts on device from vcf file -python3 -m QuickSync4Linux createcontacts --file mycontacts.vcf +```bash +pip install -e ./QuickSync4LinuxGui +``` -# overwrite a contact with given luid 517 -# luid = Local Unique IDentifier; can be found in `getcontacts` vcf output -python3 -m QuickSync4Linux editcontact 517 --file mycontact.vcf +### 3. System Dependencies -# delete contact with luid 517 from device -python3 -m QuickSync4Linux deletecontact 517 +- `bluez` / `bluez-utils` (provides `bluetoothctl` for automatic Bluetooth device discovery) +- `xdg-utils` (provides `xdg-open` to view log files from the GUI) -# show files on device -python3 -m QuickSync4Linux listfiles +## Usage -# download file "/Pictures/Gigaset.jpg" from device into local file "gigaset.jpg" -python3 -m QuickSync4Linux download "/Pictures/Gigaset.jpg" --file gigaset.jpg +To start the graphical interface: -# upload local file "cousin.jpg" into "/Clip Pictures/cousin.jpg" on device -python3 -m QuickSync4Linux upload "/Clip Pictures/cousin.jpg" --file cousin.jpg +```bash +python3 -m QuickSync4LinuxGui +``` -# delete file "/Clip Pictures/cousin.jpg" on device -python3 -m QuickSync4Linux delete "/Clip Pictures/cousin.jpg" +Alternatively, install the desktop entry to launch the GUI from your application menu or file manager: -# start a call -python3 -m QuickSync4Linux dial 1234567890 +```bash +cp QuickSync4LinuxGui.desktop ~/.local/share/applications/ +update-desktop-database ~/.local/share/applications/ ``` -For debug purposes and reporting issues, please start the script with the `-v` parameter and have a look at the serial communication. +The CLI remains fully usable without GUI components: -## Formats -### VCF Structure -The Gigaset devices expect a VCF like the following example: -``` -BEGIN:VCARD -VERSION:2.1 -X-IRMC-LUID:769 -N:Last Name;First Name -TEL;HOME:+49123456789 -TEL;CELL:+49456789123 -TEL;WORK:+49789123456 -BDAY:2020-01-01T09:00 -END:VCARD +```bash +python3 -m QuickSync4Linux listfiles -d +python3 -m QuickSync4Linux getcontacts -d -f contacts.vcf ``` -And with special chars encoded as Quoted Printable: -``` -BEGIN:VCARD -VERSION:2.1 -X-IRMC-LUID:1099 -N;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:|\\=C2=A7\;;=C3=A4=C3=B6=C3=BC= -=C3=9F -TEL;HOME:+49123 -END:VCARD -``` +## GUI Features -### Picture Format -Important: your image size should match the screen/clip size which can be found by the `info` command. The device will crash and reboot otherwise when trying to open a non-conform file. +- **Automatic Device Discovery:** Scans paired Bluetooth devices and available serial ports (`/dev/ttyACM*`, `/dev/ttyUSB*`, `/dev/rfcomm*`). +- **Device Information:** Displays manufacturer, model, firmware version, and contact count. +- **Contact Manager:** Browse, add, edit, or delete contacts and sync them back to the device. +- **File Manager:** Dolphin-styled file browser with folder navigation, download, upload, and image preview. +- **Settings:** Configure timeouts and serial baud rate via settings dialogs. +- **Logging:** Logs are saved to `~/.config/QuickSync4LinuxGui/` and can be opened via the sidebar. -When using GIMP for image creation, use the following values in the JPG export dialog: -- set "Quality" to 80 or below -- **disable** "Save Exif data" -- **disable** "Save XMP data" -- **disable** "Save thumbnail" -- **disable** "Save color profile" -- **disable** "Progressive" in the "Advanced Options" +## Screenshots -### Sound Format -Sounds must use the g722 codec and must be uploaded with the `.L22` file extension. Own sounds can easily be converted into g722 using ffmpeg: -``` -ffmpeg -i "Another brick in the wall part2.wav" -ar 16000 -acodec g722 "AnotherBrick2.g722" +Main window: -python3 -m QuickSync4Linux upload "/Sounds/AnotherBrick2.L22" --file "AnotherBrick2.g722" -``` +![Main window](Screenshots/QuickSync4LinuxGui%20-%20Main%20Window.png) -Please cut your audio track into a reasonable length before converting and uploading it. +Contact manager: -## Dial When Clicking `tel:` Links -To start a call with you Gigaset when clicking `tel:` links, you need to register QuickSync4Linux as `tel:` handler in your operating system. On Linux, you do this by copying `quicksync4linux.desktop` into `/usr/share/applications` and then execute `update-desktop-database`. +![Contact manager](Screenshots/QuickSync4LinuxGui%20-%20Contact%20Manager.png) -`quicksync` must be in you `PATH` variable. You can simply create a symlink for this: `sudo ln -s /path/to/your/quicksync.py /usr/local/bin/quicksync`. +File manager: -## Tested Devices -Please let me know if you tested this script with another device (regardless of whether it was successful or not). +![File manager](Screenshots/QuickSync4LinuxGui%20-%20File%20Manager.png) -- Gigaset S68H (Bluetooth working, no USB port) -- Gigaset CL660HX (USB working, no Bluetooth) -- Gigaset SL450HX (USB + Bluetooth working) -- Gigaset S700H PRO (USB + Bluetooth working) -- Gigaset SL400H (USB working, Bluetooth is impossible to pair in GNOME because the PIN is not visible on the desktop at the moment you need to enter it in the handset) -- Gigaset SL610 PRO (USB working but often reports serial timeouts. Bluetooth not tested) -- Gigaset S650H PRO (USB + Bluetooth working) +Settings: -## Common Errors -- `Device reported an AT command error` - Make sure you are on the home screen on the device. Do not open the contacts, menu or Media Pool when transferring data. +![Settings timeouts](Screenshots/QuickSync4LinuxGui%20-%20Settings%20Timeouts.png) -## Support -If you like this project please consider making a donation using the sponsor button on [GitHub](https://github.com/schorschii/QuickSync4Linux) to support further development. If your financial resources do not allow this, you can at least leave a star for the Github repo. +![Settings baudrate](Screenshots/QuickSync4LinuxGui%20-%20Settings%20Baudrate.png) -Furthermore, you can hire me for commercial support or adjustments and support for new devices. Please [contact me](https://georg-sieber.de/?page=impressum) if you are interested. +![Settings Language](Screenshots/QuickSync4LinuxGui%20-%20Settings%20Language.png) diff --git a/Screenshots/QuickSync4LinuxGui - Contact Manager.png b/Screenshots/QuickSync4LinuxGui - Contact Manager.png new file mode 100644 index 0000000..e20fb86 Binary files /dev/null and b/Screenshots/QuickSync4LinuxGui - Contact Manager.png differ diff --git a/Screenshots/QuickSync4LinuxGui - File Manager.png b/Screenshots/QuickSync4LinuxGui - File Manager.png new file mode 100644 index 0000000..1eb58fe Binary files /dev/null and b/Screenshots/QuickSync4LinuxGui - File Manager.png differ diff --git a/Screenshots/QuickSync4LinuxGui - Main Window.png b/Screenshots/QuickSync4LinuxGui - Main Window.png new file mode 100644 index 0000000..8ff786d Binary files /dev/null and b/Screenshots/QuickSync4LinuxGui - Main Window.png differ diff --git a/Screenshots/QuickSync4LinuxGui - Settings Baudrate.png b/Screenshots/QuickSync4LinuxGui - Settings Baudrate.png new file mode 100644 index 0000000..53a0ded Binary files /dev/null and b/Screenshots/QuickSync4LinuxGui - Settings Baudrate.png differ diff --git a/Screenshots/QuickSync4LinuxGui - Settings Language.png b/Screenshots/QuickSync4LinuxGui - Settings Language.png new file mode 100644 index 0000000..3eb5ca0 Binary files /dev/null and b/Screenshots/QuickSync4LinuxGui - Settings Language.png differ diff --git a/Screenshots/QuickSync4LinuxGui - Settings Timeouts.png b/Screenshots/QuickSync4LinuxGui - Settings Timeouts.png new file mode 100644 index 0000000..7a4712b Binary files /dev/null and b/Screenshots/QuickSync4LinuxGui - Settings Timeouts.png differ diff --git a/pyproject.toml b/pyproject.toml index 05449d8..bcf01d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,4 +27,8 @@ quicksync = "QuickSync4Linux.quicksync:main" exclude = [ "assets/", "venv/", + "QuickSync4LinuxGui/", ] + +[tool.hatch.build.targets.wheel] +packages = ["QuickSync4Linux"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f6c1a1f..606d0f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,6 @@ -pyserial +# Required dependencies for the QuickSync4Linux CLI +pyserial>=3.5 + +# GUI support is provided by the separate QuickSync4LinuxGui package: +# pip install -e . +# pip install -e ./QuickSync4LinuxGui \ No newline at end of file