""" Label Printer GUI Application using Kivy Simplified mobile-friendly interface for printing labels. """ from kivy.app import App from kivy.uix.boxlayout import BoxLayout from kivy.uix.gridlayout import GridLayout from kivy.uix.label import Label from kivy.uix.textinput import TextInput from kivy.uix.button import Button from kivy.uix.spinner import Spinner from kivy.uix.scrollview import ScrollView from kivy.uix.popup import Popup from kivy.core.window import Window from kivy.uix.image import Image as KivyImage from kivy.graphics import Color, Rectangle from kivy.uix.filechooser import FileChooserListView import os import threading import platform import time import datetime import glob import configparser from print_label import print_label_standalone, get_available_printers from kivy.clock import Clock from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler import pystray from pystray import MenuItem as item from PIL import Image, ImageDraw # Set window size - portrait/phone dimensions (375x667 like iPhone) # Adjusted to be slightly wider for touch-friendly UI Window.size = (420, 700) class FileMonitorHandler(FileSystemEventHandler): """Handler for file system events""" def __init__(self, app, file_path): self.app = app self.file_path = file_path self.last_modified = 0 def on_modified(self, event): """Called when a file is modified""" if event.src_path == self.file_path: # Debounce - avoid multiple triggers current_time = time.time() if current_time - self.last_modified > 1: # 1 second debounce self.last_modified = current_time Clock.schedule_once(lambda dt: self.app.on_file_changed(), 0) class LabelPrinterApp(App): """Simplified Kivy application for label printing with file monitoring""" def __init__(self, **kwargs): super().__init__(**kwargs) # Build printer display names and mapping to full names full_printers = get_available_printers() self.printer_display_map = {} # display_name -> full_name self.available_printers = [] for full_name in full_printers: display_name = self._shorten_printer_name(full_name) # Ensure unique display names if display_name in self.printer_display_map: display_name = full_name[:20] self.printer_display_map[display_name] = full_name self.available_printers.append(display_name) # File monitoring variables self.observer = None self.monitored_file = None self.monitoring_active = False # Configuration file path self.config_file = os.path.join('conf', 'app.conf') # System tray variables self.tray_icon = None self.is_minimized = False # Clean old PDF backup files on startup self.cleanup_old_pdfs() # Clean old log files on startup self.cleanup_old_logs() def _shorten_printer_name(self, name, max_len=20): """Shorten printer name for display (max 20 chars). For network printers like \\\\server\\printer, show just the printer part.""" if name.startswith('\\\\'): # Network printer: \\server\printer -> extract printer name parts = name.strip('\\').split('\\') if len(parts) >= 2: short = parts[-1] # Just the printer name else: short = name else: short = name # Truncate to max_len if len(short) > max_len: short = short[:max_len] return short def _get_full_printer_name(self, display_name): """Resolve display name back to full printer name for printing.""" return self.printer_display_map.get(display_name, display_name) def cleanup_old_pdfs(self, days=5): """ Delete PDF files older than specified days from pdf_backup folder. Args: days (int): Delete files older than this many days (default: 5) """ pdf_backup_dir = 'pdf_backup' # Create folder if it doesn't exist if not os.path.exists(pdf_backup_dir): os.makedirs(pdf_backup_dir, exist_ok=True) return try: current_time = time.time() cutoff_time = current_time - (days * 24 * 3600) # Convert days to seconds for filename in os.listdir(pdf_backup_dir): file_path = os.path.join(pdf_backup_dir, filename) # Only process PDF files if not filename.endswith('.pdf'): continue # Check if file is older than cutoff if os.path.isfile(file_path): file_mtime = os.path.getmtime(file_path) if file_mtime < cutoff_time: try: os.remove(file_path) print(f"Deleted old PDF: {filename}") except Exception as e: print(f"Failed to delete {filename}: {e}") except Exception as e: print(f"Error during PDF cleanup: {e}") def cleanup_old_logs(self, days=5): """ Delete log files older than specified days from logs folder. Args: days (int): Delete files older than this many days (default: 5) """ logs_dir = 'logs' # Create folder if it doesn't exist if not os.path.exists(logs_dir): os.makedirs(logs_dir, exist_ok=True) return try: current_time = time.time() cutoff_time = current_time - (days * 24 * 3600) # Convert days to seconds for filename in os.listdir(logs_dir): file_path = os.path.join(logs_dir, filename) # Process both .log and .csv files if not (filename.endswith('.log') or filename.endswith('.csv')): continue # Check if file is older than cutoff if os.path.isfile(file_path): file_mtime = os.path.getmtime(file_path) if file_mtime < cutoff_time: try: os.remove(file_path) print(f"Deleted old log: {filename}") except Exception as e: print(f"Failed to delete {filename}: {e}") except Exception as e: print(f"Error during log cleanup: {e}") def load_config(self): """ Load configuration from conf/app.conf file. Returns dict with 'file_path' and 'printer' keys. """ config = {'file_path': '', 'printer': ''} if not os.path.exists(self.config_file): return config try: parser = configparser.ConfigParser() parser.read(self.config_file, encoding='utf-8') if 'Settings' in parser: config['file_path'] = parser['Settings'].get('file_path', '') config['printer'] = parser['Settings'].get('printer', '') print(f"Configuration loaded from {self.config_file}") except Exception as e: print(f"Error loading config: {e}") return config def save_config(self): """ Save current configuration to conf/app.conf file. Saves file_path and selected printer. """ # Ensure conf folder exists os.makedirs(os.path.dirname(self.config_file), exist_ok=True) try: parser = configparser.ConfigParser() parser['Settings'] = { 'file_path': self.file_input.text.strip(), 'printer': self.printer_spinner.text } with open(self.config_file, 'w', encoding='utf-8') as f: parser.write(f) print(f"Configuration saved to {self.config_file}") except Exception as e: print(f"Error saving config: {e}") def log_print_action(self, article, nr_art, serial, printer, pdf_filename, success): """ Log the print action to a CSV log file (table format). Args: article (str): Article/Comanda number nr_art (str): Nr. Art. value serial (str): Serial number printer (str): Printer name pdf_filename (str): Path to the generated PDF file success (bool): Whether the print was successful """ logs_dir = 'logs' # Create logs folder if it doesn't exist os.makedirs(logs_dir, exist_ok=True) try: # Create log filename with date log_date = datetime.datetime.now().strftime("%Y%m%d") log_filename = os.path.join(logs_dir, f"print_log_{log_date}.csv") # Create log entry values timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") status = "SUCCESS" if success else "FAILED" # Escape CSV values (handle commas and quotes) def escape_csv(value): """Escape CSV special characters""" value = str(value) if ',' in value or '"' in value or '\n' in value: value = '"' + value.replace('"', '""') + '"' return value # Create CSV line log_line = ( f"{timestamp},{status}," f"{escape_csv(article)}," f"{escape_csv(nr_art)}," f"{escape_csv(serial)}," f"{escape_csv(printer)}," f"{escape_csv(pdf_filename)}\n" ) # Check if file exists to add header on first entry file_exists = os.path.isfile(log_filename) # Write to log file with open(log_filename, 'a', encoding='utf-8') as f: # Add header if file is new if not file_exists: f.write("Timestamp,Status,Article,Nr Art,Serial No,Printer,PDF File\n") # Append log entry f.write(log_line) print(f"Log entry saved to: {log_filename}") except Exception as e: print(f"Error saving log entry: {e}") def build(self): """Build the simplified file monitoring UI""" self.title = "Label Printing - File Monitor" # Main container - single column layout main_layout = BoxLayout(orientation='vertical', spacing=8, padding=12) # Title title = Label( text='[b]Label Printing - File Monitor[/b]', markup=True, size_hint_y=0.08, font_size='18sp', color=(1, 1, 1, 1) ) main_layout.add_widget(title) # Scroll view for form fields scroll = ScrollView(size_hint_y=0.60) form_layout = GridLayout(cols=1, spacing=8, size_hint_y=None, padding=8) form_layout.bind(minimum_height=form_layout.setter('height')) # File path input file_label = Label( text='Monitor File Path:', size_hint_y=None, height=30, font_size='12sp' ) form_layout.add_widget(file_label) # File path row with input and browse button file_row = BoxLayout(orientation='horizontal', size_hint_y=None, height=45, spacing=5) self.file_input = TextInput( multiline=False, size_hint_x=0.75, font_size='12sp', background_color=(0.95, 0.95, 0.95, 1), padding=(10, 10), hint_text='Enter file path to monitor' ) file_row.add_widget(self.file_input) browse_button = Button( text='Browse', size_hint_x=0.25, font_size='11sp', background_color=(0.3, 0.5, 0.7, 1), background_normal='' ) browse_button.bind(on_press=self.browse_file) file_row.add_widget(browse_button) form_layout.add_widget(file_row) # Monitoring status self.status_label = Label( text='Status: Not monitoring', size_hint_y=None, height=30, font_size='11sp', color=(1, 0.5, 0, 1) ) form_layout.add_widget(self.status_label) # Add spacing form_layout.add_widget(Label(text='', size_hint_y=None, height=20)) # Printer selection printer_label = Label( text='Select Printer:', size_hint_y=None, height=30, font_size='12sp' ) form_layout.add_widget(printer_label) printer_spinner = Spinner( text=self.available_printers[0] if self.available_printers else "No Printers", values=self.available_printers, size_hint_y=None, height=45, font_size='12sp', sync_height=True, ) self.printer_spinner = printer_spinner # Save config when printer changes printer_spinner.bind(text=self.on_printer_changed) form_layout.add_widget(printer_spinner) scroll.add_widget(form_layout) main_layout.add_widget(scroll) # Buttons layout buttons_layout = BoxLayout(orientation='vertical', size_hint_y=0.30, spacing=5) # Start/Stop monitoring button self.monitor_button = Button( text='START MONITORING', size_hint_y=0.7, font_size='14sp', background_color=(0.2, 0.5, 0.8, 1), background_normal='', bold=True ) self.monitor_button.bind(on_press=self.toggle_monitoring) buttons_layout.add_widget(self.monitor_button) # Minimize to tray button minimize_button = Button( text='MINIMIZE TO TRAY', size_hint_y=0.3, font_size='11sp', background_color=(0.5, 0.5, 0.5, 1), background_normal='' ) minimize_button.bind(on_press=self.minimize_to_tray) buttons_layout.add_widget(minimize_button) main_layout.add_widget(buttons_layout) # Load configuration after UI is built Clock.schedule_once(lambda dt: self.apply_config(), 0.5) return main_layout def apply_config(self): """ Load and apply saved configuration. Auto-start monitoring if both file and printer are configured. """ config = self.load_config() # Apply file path if config['file_path']: self.file_input.text = config['file_path'] print(f"Loaded file path: {config['file_path']}") # Apply printer selection if config['printer'] and config['printer'] in self.available_printers: self.printer_spinner.text = config['printer'] print(f"Loaded printer: {config['printer']}") # Auto-start monitoring if both are configured and file exists if config['file_path'] and config['printer']: if os.path.exists(config['file_path']) and os.path.isfile(config['file_path']): print("Auto-starting monitoring with saved configuration...") self.start_monitoring() else: print(f"Configured file not found: {config['file_path']}") def on_printer_changed(self, spinner, text): """ Called when printer selection changes. Save configuration. """ self.save_config() def create_tray_icon_image(self): """ Create a simple icon for the system tray. Returns PIL Image. """ # Create a 64x64 icon with a simple printer symbol width = 64 height = 64 image = Image.new('RGB', (width, height), color='white') draw = ImageDraw.Draw(image) # Draw a simple printer icon (rectangle with lines) draw.rectangle([10, 20, 54, 40], fill='blue', outline='black', width=2) draw.rectangle([15, 40, 49, 50], fill='lightblue', outline='black', width=2) draw.rectangle([20, 10, 44, 20], fill='lightgray', outline='black', width=1) return image def minimize_to_tray(self, instance=None): """ Minimize the application to system tray. """ if self.is_minimized: return # Hide the window Window.hide() self.is_minimized = True # Create tray icon if not exists if not self.tray_icon: icon_image = self.create_tray_icon_image() menu = pystray.Menu( item('Restore', self.restore_from_tray), item('Exit', self.exit_from_tray) ) self.tray_icon = pystray.Icon( 'Label Printer', icon_image, 'Label Printer - Monitoring', menu ) # Run tray icon in separate thread tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True) tray_thread.start() def restore_from_tray(self, icon=None, item=None): """ Restore the application from system tray. """ if not self.is_minimized: return # Stop tray icon if self.tray_icon: self.tray_icon.stop() self.tray_icon = None # Show the window Clock.schedule_once(lambda dt: Window.show(), 0) self.is_minimized = False def exit_from_tray(self, icon=None, item=None): """ Exit the application from system tray. """ # Stop tray icon if self.tray_icon: self.tray_icon.stop() self.tray_icon = None # Stop monitoring if self.observer: self.observer.stop() self.observer.join() # Stop the app Clock.schedule_once(lambda dt: self.stop(), 0) def browse_file(self, instance): """Open file browser to select file to monitor""" content = BoxLayout(orientation='vertical', spacing=10, padding=10) # File chooser file_chooser = FileChooserListView( path=os.path.expanduser('~'), size_hint=(1, 0.9) ) content.add_widget(file_chooser) # Buttons buttons = BoxLayout(size_hint_y=0.1, spacing=10) popup = Popup( title='Select File to Monitor', content=content, size_hint=(0.9, 0.9) ) def select_file(instance): if file_chooser.selection: self.file_input.text = file_chooser.selection[0] # Save config when file is selected self.save_config() popup.dismiss() select_btn = Button(text='Select') select_btn.bind(on_press=select_file) buttons.add_widget(select_btn) cancel_btn = Button(text='Cancel') cancel_btn.bind(on_press=popup.dismiss) buttons.add_widget(cancel_btn) content.add_widget(buttons) popup.open() def toggle_monitoring(self, instance): """Start or stop file monitoring""" if not self.monitoring_active: self.start_monitoring() else: self.stop_monitoring() def start_monitoring(self): """Start monitoring the specified file""" file_path = self.file_input.text.strip() if not file_path: self.show_popup("Error", "Please enter a file path to monitor", auto_dismiss_after=3) return if not os.path.exists(file_path): self.show_popup("Error", "File does not exist", auto_dismiss_after=3) return if not os.path.isfile(file_path): self.show_popup("Error", "Path is not a file", auto_dismiss_after=3) return try: # Stop existing observer if any if self.observer: self.observer.stop() self.observer.join() # Create and start new observer self.monitored_file = os.path.abspath(file_path) event_handler = FileMonitorHandler(self, self.monitored_file) self.observer = Observer() self.observer.schedule(event_handler, os.path.dirname(self.monitored_file), recursive=False) self.observer.start() self.monitoring_active = True self.monitor_button.text = 'STOP MONITORING' self.monitor_button.background_color = (0.8, 0.2, 0.2, 1) self.status_label.text = f'Status: Monitoring {os.path.basename(file_path)}' self.status_label.color = (0, 1, 0, 1) self.show_popup("Success", f"Started monitoring:\n{file_path}", auto_dismiss_after=2) except Exception as e: self.show_popup("Error", f"Failed to start monitoring:\n{str(e)}", auto_dismiss_after=3) def stop_monitoring(self): """Stop file monitoring""" try: if self.observer: self.observer.stop() self.observer.join() self.observer = None self.monitoring_active = False self.monitor_button.text = 'START MONITORING' self.monitor_button.background_color = (0.2, 0.5, 0.8, 1) self.status_label.text = 'Status: Not monitoring' self.status_label.color = (1, 0.5, 0, 1) self.show_popup("Success", "Stopped monitoring", auto_dismiss_after=2) except Exception as e: self.show_popup("Error", f"Failed to stop monitoring:\n{str(e)}", auto_dismiss_after=3) def on_file_changed(self): """Called when monitored file changes""" if not self.monitoring_active or not self.monitored_file: return # Automatically print label when file changes self.print_label(None) def clear_file_after_print(self): """Clear the monitored file and write '-' to prevent re-printing""" if not self.monitored_file or not os.path.exists(self.monitored_file): return try: with open(self.monitored_file, 'w', encoding='utf-8') as f: f.write('-') print(f"Cleared file: {self.monitored_file}") except Exception as e: print(f"Error clearing file: {e}") def read_file_variables(self): """Read variables from monitored file""" if not self.monitored_file or not os.path.exists(self.monitored_file): return None, None, None try: with open(self.monitored_file, 'r', encoding='utf-8') as f: content = f.read().strip() # Skip if file is empty or only contains "-" (cleared marker) if not content or content == '-': return None, None, None # Parse file content - expecting format: article;nr_art;serial # or key=value pairs on separate lines lines = content.split('\n') article = "" nr_art = "" serial = "" # Try semicolon-separated format first if ';' in content: parts = content.split(';') if len(parts) >= 1: article = parts[0].strip() if len(parts) >= 2: nr_art = parts[1].strip() if len(parts) >= 3: serial = parts[2].strip() else: # Try key=value format for line in lines: if '=' in line: key, value = line.split('=', 1) key = key.strip().lower() value = value.strip() if key in ['article', 'articol', 'comanda', 'nr_comanda']: article = value elif key in ['nr_art', 'nr-art', 'nrart']: nr_art = value elif key in ['serial', 'serial_no', 'serial-no', 'serialno']: serial = value return article, nr_art, serial except Exception as e: print(f"Error reading file: {e}") return None, None, None def print_label(self, instance): """Handle print button press - read from file and print""" # Read variables from file article, nr_art, serial = self.read_file_variables() # Resolve display name to full printer name printer = self._get_full_printer_name(self.printer_spinner.text) # Validate input if not article and not nr_art and not serial: self.show_popup("Error", "No data in file or file not set", auto_dismiss_after=3) return # Create combined label text using semicolon separator label_text = f"{article};{nr_art};{serial}" # Show loading popup popup = Popup( title='Printing', content=BoxLayout( orientation='vertical', padding=10, spacing=10 ), size_hint=(0.8, 0.3) ) popup.content.add_widget(Label(text='Processing label...\nPlease wait')) popup.open() # Print in background thread (using PDF by default) def print_thread(): pdf_filename = None success = False try: success = print_label_standalone(label_text, printer, preview=0, use_pdf=True) # Get the PDF filename that was created # Files are saved to pdf_backup/ with timestamp pdf_files = glob.glob('pdf_backup/final_label_*.pdf') if pdf_files: # Get the most recently created PDF file pdf_filename = max(pdf_files, key=os.path.getctime) if success: # Log the successful print action self.log_print_action(article, nr_art, serial, printer, pdf_filename or "unknown", True) # Clear the file and write "-" to prevent re-printing Clock.schedule_once(lambda dt: self.clear_file_after_print(), 0) # Use Clock.schedule_once to update UI from main thread Clock.schedule_once(lambda dt: popup.dismiss(), 0) Clock.schedule_once(lambda dt: self.show_popup("Success", "Label printed successfully!", auto_dismiss_after=3), 0.1) else: # Log the failed print action self.log_print_action(article, nr_art, serial, printer, pdf_filename or "unknown", False) Clock.schedule_once(lambda dt: popup.dismiss(), 0) Clock.schedule_once(lambda dt: self.show_popup("Error", "Failed to print label", auto_dismiss_after=3), 0.1) except Exception as e: # Log the error self.log_print_action(article, nr_art, serial, printer, pdf_filename or "unknown", False) Clock.schedule_once(lambda dt: popup.dismiss(), 0) Clock.schedule_once(lambda dt: self.show_popup("Error", f"Print error: {str(e)}", auto_dismiss_after=3), 0.1) thread = threading.Thread(target=print_thread) thread.daemon = True thread.start() def show_popup(self, title, message, auto_dismiss_after=None): """Show a popup message Args: title (str): Popup title message (str): Popup message auto_dismiss_after (float): Seconds after which to auto-dismiss (None = manual dismiss only) """ popup = Popup( title=title, content=BoxLayout( orientation='vertical', padding=10, spacing=10 ), size_hint=(0.8, 0.4) ) popup.content.add_widget(Label(text=message)) close_button = Button(text='OK', size_hint_y=0.3) close_button.bind(on_press=popup.dismiss) popup.content.add_widget(close_button) popup.open() # Auto-dismiss if specified if auto_dismiss_after: Clock.schedule_once(lambda dt: popup.dismiss(), auto_dismiss_after) if __name__ == '__main__': app = LabelPrinterApp() app.run()