1061 lines
39 KiB
Python
1061 lines
39 KiB
Python
"""
|
|
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
|
|
|
|
import os
|
|
import threading
|
|
import platform
|
|
import time
|
|
import datetime
|
|
import glob
|
|
import configparser
|
|
import subprocess
|
|
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
|
|
from PIL import Image, ImageDraw
|
|
|
|
# Optional system tray support
|
|
try:
|
|
import pystray
|
|
from pystray import MenuItem as item
|
|
PYSTRAY_AVAILABLE = True
|
|
except ImportError:
|
|
PYSTRAY_AVAILABLE = False
|
|
print("Warning: pystray not available. System tray functionality disabled.")
|
|
|
|
# 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)
|
|
# Initialize conf folder with default templates
|
|
self.initialize_conf_folder()
|
|
# 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 initialize_conf_folder(self):
|
|
"""
|
|
Initialize conf folder with default templates and configuration.
|
|
Creates folder and files if they don't exist.
|
|
"""
|
|
conf_dir = 'conf'
|
|
|
|
# Create conf folder if it doesn't exist
|
|
if not os.path.exists(conf_dir):
|
|
os.makedirs(conf_dir, exist_ok=True)
|
|
print(f"Created conf folder: {conf_dir}")
|
|
|
|
# Define default SVG template (minimal 35mm x 25mm with placeholders)
|
|
default_svg_template = '''<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="35mm" height="25mm" viewBox="0 0 35 25">
|
|
<defs>
|
|
<style type="text/css">
|
|
.text { font-family: Arial; font-weight: bold; font-size: 3.5px; fill: #000000; }
|
|
.checkmark { fill: #008000; }
|
|
</style>
|
|
</defs>
|
|
<g id="content">
|
|
<text x="2" y="7" class="text">Nr. Comanda: {Article}</text>
|
|
<text x="2" y="12" class="text">Nr. Art.: {NrArt}</text>
|
|
<text x="2" y="17" class="text">Serial No.: {Serial}</text>
|
|
<circle cx="31" cy="7" r="2.5" class="checkmark"/>
|
|
<path d="M30,7 L30.8,7.8 L32,6" stroke="white" stroke-width="0.5" fill="none"/>
|
|
</g>
|
|
</svg>'''
|
|
|
|
# OK template (green checkmark)
|
|
ok_svg_template = '''<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="35mm" height="25mm" viewBox="0 0 35 25">
|
|
<defs>
|
|
<style type="text/css">
|
|
.text { font-family: Arial; font-weight: bold; font-size: 3.5px; fill: #000000; }
|
|
.checkmark { fill: #008000; }
|
|
.ok-text { font-family: Arial; font-weight: bold; font-size: 4px; fill: #008000; }
|
|
</style>
|
|
</defs>
|
|
<g id="content">
|
|
<text x="2" y="7" class="text">Nr. Comanda: {Article}</text>
|
|
<text x="2" y="12" class="text">Nr. Art.: {NrArt}</text>
|
|
<text x="2" y="17" class="text">Serial No.: {Serial}</text>
|
|
<text x="29" y="5" class="ok-text">OK</text>
|
|
<circle cx="31" cy="12" r="2.5" class="checkmark"/>
|
|
<path d="M30,12 L30.8,12.8 L32,11" stroke="white" stroke-width="0.5" fill="none"/>
|
|
</g>
|
|
</svg>'''
|
|
|
|
# NOK template (red text and checkmark)
|
|
nok_svg_template = '''<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="35mm" height="25mm" viewBox="0 0 35 25">
|
|
<defs>
|
|
<style type="text/css">
|
|
.text { font-family: Arial; font-weight: bold; font-size: 3.5px; fill: #CC0000; }
|
|
.checkmark { fill: #CC0000; }
|
|
.nok-text { font-family: Arial; font-weight: bold; font-size: 4px; fill: #CC0000; }
|
|
</style>
|
|
</defs>
|
|
<g id="content">
|
|
<text x="2" y="7" class="text">Nr. Comanda: {Article}</text>
|
|
<text x="2" y="12" class="text">Nr. Art.: {NrArt}</text>
|
|
<text x="2" y="17" class="text">Serial No.: {Serial}</text>
|
|
<text x="27" y="5" class="nok-text">NOK</text>
|
|
<circle cx="31" cy="12" r="2.5" class="checkmark"/>
|
|
<path d="M30,12 L30.8,12.8 L32,11" stroke="white" stroke-width="0.5" fill="none"/>
|
|
</g>
|
|
</svg>'''
|
|
|
|
# Create SVG template files if they don't exist
|
|
templates = {
|
|
'label_template.svg': default_svg_template,
|
|
'label_template_ok.svg': ok_svg_template,
|
|
'label_template_nok.svg': nok_svg_template,
|
|
}
|
|
|
|
for filename, content in templates.items():
|
|
file_path = os.path.join(conf_dir, filename)
|
|
if not os.path.exists(file_path):
|
|
try:
|
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
print(f"Created template: {file_path}")
|
|
except Exception as e:
|
|
print(f"Failed to create {file_path}: {e}")
|
|
|
|
# Create default app.conf if it doesn't exist
|
|
config_path = os.path.join(conf_dir, 'app.conf')
|
|
if not os.path.exists(config_path):
|
|
default_config = '''[Settings]
|
|
file_path = C:\\Users\\Public\\Documents\\check.txt
|
|
printer = PDF
|
|
'''
|
|
try:
|
|
with open(config_path, 'w', encoding='utf-8') as f:
|
|
f.write(default_config)
|
|
print(f"Created config: {config_path}")
|
|
except Exception as e:
|
|
print(f"Failed to create {config_path}: {e}")
|
|
|
|
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 not PYSTRAY_AVAILABLE:
|
|
print("System tray not available - window will minimize normally")
|
|
return
|
|
|
|
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"""
|
|
# On Windows, always use native dialog to avoid Kivy FileChooser win32timezone issues in packaged apps
|
|
if os.name == 'nt':
|
|
selected_file = self._browse_file_windows_native()
|
|
if selected_file:
|
|
self.file_input.text = selected_file
|
|
self.save_config()
|
|
return
|
|
|
|
from kivy.uix.filechooser import FileChooserListView
|
|
|
|
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 _browse_file_windows_native(self):
|
|
"""Open a native Windows file dialog via PowerShell/.NET and return selected file path."""
|
|
ps_script = (
|
|
"Add-Type -AssemblyName System.Windows.Forms; "
|
|
"$dialog = New-Object System.Windows.Forms.OpenFileDialog; "
|
|
"$dialog.Title = 'Select File to Monitor'; "
|
|
"$dialog.Filter = 'Text files (*.txt)|*.txt|All files (*.*)|*.*'; "
|
|
"$dialog.Multiselect = $false; "
|
|
"if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { "
|
|
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; "
|
|
"Write-Output $dialog.FileName "
|
|
"}"
|
|
)
|
|
|
|
try:
|
|
creationflags = getattr(subprocess, 'CREATE_NO_WINDOW', 0)
|
|
result = subprocess.run(
|
|
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps_script],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
creationflags=creationflags
|
|
)
|
|
|
|
selected = result.stdout.strip()
|
|
if selected and os.path.isfile(selected):
|
|
return selected
|
|
|
|
if result.stderr.strip():
|
|
print(f"Windows file dialog error: {result.stderr.strip()}")
|
|
except Exception as e:
|
|
print(f"Native Windows browse failed: {e}")
|
|
|
|
# Fallback: manual path entry popup (no external dependencies)
|
|
self.show_manual_path_popup()
|
|
return None
|
|
|
|
def show_manual_path_popup(self):
|
|
"""Fallback popup for manually entering monitored file path."""
|
|
content = BoxLayout(orientation='vertical', spacing=8, padding=10)
|
|
|
|
input_box = TextInput(
|
|
text=self.file_input.text.strip(),
|
|
multiline=False,
|
|
hint_text='Enter full file path, e.g. C:\\Users\\Public\\Documents\\check.txt'
|
|
)
|
|
content.add_widget(Label(text='Browse unavailable. Enter file path manually:'))
|
|
content.add_widget(input_box)
|
|
|
|
buttons = BoxLayout(size_hint_y=0.35, spacing=8)
|
|
popup = Popup(title='Set Monitor File', content=content, size_hint=(0.9, 0.4))
|
|
|
|
def apply_path(_instance):
|
|
path = input_box.text.strip()
|
|
if path and os.path.isfile(path):
|
|
self.file_input.text = path
|
|
self.save_config()
|
|
popup.dismiss()
|
|
self.show_popup('Success', f'File set:\n{path}', auto_dismiss_after=2)
|
|
else:
|
|
self.show_popup('Error', 'Invalid file path', auto_dismiss_after=3)
|
|
|
|
ok_btn = Button(text='Apply')
|
|
ok_btn.bind(on_press=apply_path)
|
|
buttons.add_widget(ok_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
|
|
Expected format: article;nr_art;serial;status;count
|
|
status: 1=OK, 0=NOK
|
|
count: number of labels to print (default=1)
|
|
"""
|
|
if not self.monitored_file or not os.path.exists(self.monitored_file):
|
|
return None, None, None, '1', 1
|
|
|
|
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, '1', 1
|
|
|
|
# Parse file content - expecting format: article;nr_art;serial;template_type;count
|
|
# or key=value pairs on separate lines
|
|
lines = content.split('\n')
|
|
article = ""
|
|
nr_art = ""
|
|
serial = ""
|
|
status_flag = "1"
|
|
count = 1 # Default to 1 label
|
|
|
|
# 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()
|
|
if len(parts) >= 4:
|
|
status_flag = parts[3].strip()
|
|
if len(parts) >= 5:
|
|
try:
|
|
count = int(parts[4].strip())
|
|
if count < 1:
|
|
count = 1
|
|
if count > 100: # Safety limit
|
|
count = 100
|
|
except ValueError:
|
|
count = 1
|
|
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
|
|
elif key in ['status', 'result', 'quality', 'ok', 'is_ok', 'nok']:
|
|
status_flag = value
|
|
elif key in ['count', 'quantity', 'copies']:
|
|
try:
|
|
count = int(value)
|
|
if count < 1:
|
|
count = 1
|
|
if count > 100:
|
|
count = 100
|
|
except ValueError:
|
|
count = 1
|
|
|
|
# Normalize status flag: 1 = OK label, 0 = NOK label
|
|
normalized_status = str(status_flag).strip().lower()
|
|
if normalized_status in ['0', 'nok', 'no', 'false', 'rejected', 'refused', 'fail']:
|
|
status_flag = '0'
|
|
else:
|
|
status_flag = '1'
|
|
|
|
return article, nr_art, serial, status_flag, count
|
|
except Exception as e:
|
|
print(f"Error reading file: {e}")
|
|
return None, None, None, '1', 1
|
|
|
|
def print_label(self, instance):
|
|
"""Handle print button press - read from file and print"""
|
|
# Read variables from file including status and count
|
|
article, nr_art, serial, status_flag, count = 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
|
|
|
|
# Select template based on status_flag (1=OK, 0=NOK)
|
|
if status_flag == '0':
|
|
template_path = os.path.join('conf', 'label_template_nok.svg')
|
|
template_name = "NOK"
|
|
else:
|
|
template_path = os.path.join('conf', 'label_template_ok.svg')
|
|
template_name = "OK"
|
|
|
|
# Verify template exists, fallback to default if not
|
|
if not os.path.exists(template_path):
|
|
template_path = os.path.join('conf', 'label_template.svg')
|
|
template_name = "DEFAULT"
|
|
|
|
# Create combined label text using semicolon separator
|
|
# status_flag: 1 = OK label, 0 = NOK label
|
|
label_text = f"{article};{nr_art};{serial};{status_flag}"
|
|
|
|
# Show loading popup with count info
|
|
popup = Popup(
|
|
title='Printing',
|
|
content=BoxLayout(
|
|
orientation='vertical',
|
|
padding=10,
|
|
spacing=10
|
|
),
|
|
size_hint=(0.8, 0.3)
|
|
)
|
|
|
|
popup.content.add_widget(Label(text=f'Processing {count} label(s)...\nTemplate: {template_name}\nPlease wait'))
|
|
popup.open()
|
|
|
|
# Print in background thread (using PDF by default)
|
|
def print_thread():
|
|
pdf_filename = None
|
|
success = False
|
|
all_success = True
|
|
try:
|
|
# Send all copies in ONE print job to prevent blank labels
|
|
# being ejected between separate jobs on thermal printers.
|
|
success = print_label_standalone(
|
|
label_text, printer, preview=0,
|
|
svg_template=template_path, copies=count
|
|
)
|
|
all_success = success
|
|
|
|
# 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 all_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", f"{count} label(s) 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()
|