#!/usr/bin/env python3 """ NFC Network Client with Global Input Capture Moonlight Drive-In Theater System This client captures NFC scans using global keyboard capture (works even without focus) and forwards them to the main server over the network. """ import tkinter as tk from tkinter import ttk, scrolledtext import requests import socket import time import threading from typing import Optional, Dict, List from datetime import datetime from pynput import keyboard import sys # Version and identification CLIENT_VERSION = "1.1.0" CLIENT_NAME = "NFC Network Client" # Server discovery and connection settings DEFAULT_PORTS = [5001, 5000] SCAN_TIMEOUT = 3.0 MIN_TAG_LENGTH = 4 MAX_TAG_LENGTH = 32 # Network timeouts CONNECTION_TIMEOUT = 2 REQUEST_TIMEOUT = 5 class NFCNetworkClient: """Network client for remote NFC scanning with global input capture""" def __init__(self, root: tk.Tk): self.root = root self.root.title(f"{CLIENT_NAME} v{CLIENT_VERSION}") self.root.geometry("700x600") # Connection state self.server_url: Optional[str] = None self.connected = False self.auto_connect = True # NFC scanning state self.current_scan = [] self.scan_start_time = None self.is_scanning = False self.last_scan_time = 0 # Global keyboard listener self.keyboard_listener: Optional[keyboard.Listener] = None self.listener_running = False # UI setup self.setup_ui() # Start keyboard listener immediately self.start_keyboard_listener() # Auto-connect on startup if self.auto_connect: self.root.after(500, self.connect_to_server) def setup_ui(self): """Setup the user interface""" # Main container main_frame = ttk.Frame(self.root, padding="10") main_frame.grid(row=0, column=0, sticky="nsew") self.root.grid_rowconfigure(0, weight=1) self.root.grid_columnconfigure(0, weight=1) # Title title_label = ttk.Label( main_frame, text=f"{CLIENT_NAME} v{CLIENT_VERSION}", font=("Arial", 14, "bold") ) title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10)) # Status frame status_frame = ttk.LabelFrame(main_frame, text="Connection Status", padding="10") status_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 10)) self.status_label = ttk.Label( status_frame, text="Disconnected", font=("Arial", 11) ) self.status_label.pack() # Server info frame server_frame = ttk.LabelFrame(main_frame, text="Server Configuration", padding="10") server_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 10)) ttk.Label(server_frame, text="Server Address:").grid(row=0, column=0, sticky="w") self.server_entry = ttk.Entry(server_frame, width=40) self.server_entry.grid(row=0, column=1, padx=(10, 0)) self.server_entry.insert(0, "moonlight-drivein") # Control buttons btn_frame = ttk.Frame(main_frame) btn_frame.grid(row=3, column=0, columnspan=2, pady=(0, 10)) self.connect_btn = ttk.Button( btn_frame, text="Connect", command=self.connect_to_server ) self.connect_btn.pack(side="left", padx=5) self.disconnect_btn = ttk.Button( btn_frame, text="Disconnect", command=self.disconnect_from_server, state="disabled" ) self.disconnect_btn.pack(side="left", padx=5) # Listener status frame listener_frame = ttk.LabelFrame(main_frame, text="Global Input Capture", padding="10") listener_frame.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(0, 10)) self.listener_label = ttk.Label( listener_frame, text="Initializing...", font=("Arial", 10) ) self.listener_label.pack() listener_info = ttk.Label( listener_frame, text="NFC scans will be captured even when this window is not focused", font=("Arial", 9), foreground="gray" ) listener_info.pack() # Log frame log_frame = ttk.LabelFrame(main_frame, text="Activity Log", padding="10") log_frame.grid(row=5, column=0, columnspan=2, sticky="nsew", pady=(0, 10)) main_frame.grid_rowconfigure(5, weight=1) self.log_text = scrolledtext.ScrolledText( log_frame, height=15, wrap=tk.WORD, state="disabled", font=("Courier", 9) ) self.log_text.pack(fill="both", expand=True) # Bind window close event self.root.protocol("WM_DELETE_WINDOW", self.on_closing) def log(self, message: str, level: str = "INFO"): """Add message to log with timestamp""" timestamp = datetime.now().strftime("%H:%M:%S") formatted = f"[{timestamp}] [{level}] {message}\n" self.log_text.config(state="normal") self.log_text.insert(tk.END, formatted) self.log_text.see(tk.END) self.log_text.config(state="disabled") # Print to console as well print(formatted.strip()) def update_status(self, text: str, color: str = "black"): """Update connection status display""" self.status_label.config(text=text, foreground=color) def start_keyboard_listener(self): """Start global keyboard listener""" try: if self.keyboard_listener is not None: self.stop_keyboard_listener() self.keyboard_listener = keyboard.Listener( on_press=self.on_key_press ) self.keyboard_listener.start() self.listener_running = True self.listener_label.config( text="Active - Capturing NFC scans globally", foreground="green" ) self.log("Global keyboard listener started", "SUCCESS") except Exception as e: self.listener_label.config( text=f"Error: {str(e)}", foreground="red" ) self.log(f"Failed to start keyboard listener: {e}", "ERROR") def stop_keyboard_listener(self): """Stop global keyboard listener""" if self.keyboard_listener is not None: try: self.keyboard_listener.stop() self.listener_running = False self.listener_label.config( text="Stopped", foreground="gray" ) self.log("Global keyboard listener stopped", "INFO") except Exception as e: self.log(f"Error stopping keyboard listener: {e}", "ERROR") def on_key_press(self, key): """Handle global keyboard input""" try: current_time = time.time() # Get the character char = None if hasattr(key, 'char') and key.char: char = key.char elif key == keyboard.Key.enter: # Enter key completes the scan if self.is_scanning and self.current_scan: self.complete_scan() return else: # Ignore other special keys during scanning if self.is_scanning: return return # Start new scan if enough time has passed if not self.is_scanning: if current_time - self.last_scan_time > 1.0: self.start_scan() # Add character to current scan if self.is_scanning and char: self.current_scan.append(char) # Auto-complete if scan is taking too long if current_time - self.scan_start_time > SCAN_TIMEOUT: self.complete_scan() except Exception as e: self.log(f"Error processing key: {e}", "ERROR") def start_scan(self): """Start a new NFC scan""" self.is_scanning = True self.current_scan = [] self.scan_start_time = time.time() def complete_scan(self): """Complete current scan and send to server""" if not self.current_scan: self.is_scanning = False return tag_uid = ''.join(self.current_scan).strip() self.current_scan = [] self.is_scanning = False self.last_scan_time = time.time() # Validate tag if len(tag_uid) < MIN_TAG_LENGTH or len(tag_uid) > MAX_TAG_LENGTH: self.log(f"Invalid tag length: {len(tag_uid)} chars", "WARN") return # Send to server if connected if self.connected and self.server_url: self.send_tag_to_server(tag_uid) else: self.log(f"[SCAN] {tag_uid} (not connected)", "WARN") def send_tag_to_server(self, tag_uid: str): """Send scanned tag to server""" try: url = f"{self.server_url}/api/nfc_scan" data = { "tag_uid": tag_uid, "source": "network_client", "client_version": CLIENT_VERSION } self.log(f"[SCAN] Sending tag: {tag_uid}") response = requests.post( url, json=data, timeout=REQUEST_TIMEOUT ) if response.status_code == 200: result = response.json() if result.get("success"): content_title = result.get("content_title", "Unknown") self.log(f"[SUCCESS] Playing: {content_title}", "SUCCESS") else: error = result.get("error", "Unknown error") self.log(f"[ERROR] {error}", "ERROR") else: self.log(f"[ERROR] Server returned status {response.status_code}", "ERROR") except requests.exceptions.Timeout: self.log("[ERROR] Request timeout", "ERROR") except requests.exceptions.ConnectionError: self.log("[ERROR] Connection failed - server unreachable", "ERROR") self.disconnect_from_server() except Exception as e: self.log(f"[ERROR] Failed to send tag: {e}", "ERROR") def try_connect(self, hostname: str) -> Optional[str]: """Try to connect to server at hostname""" for port in DEFAULT_PORTS: try: url = f"http://{hostname}:{port}" response = requests.get( f"{url}/api/status", timeout=CONNECTION_TIMEOUT ) if response.status_code == 200: data = response.json() if data.get("server_type") == "moonlight_drivein": return url except: continue return None def resolve_hostname(self, hostname: str) -> List[str]: """Resolve hostname to list of addresses to try""" addresses = [] # Try as-is addresses.append(hostname) # Try with .local for mDNS if not hostname.endswith(".local"): addresses.append(f"{hostname}.local") # Try with Tailscale domain if looks like hostname if "." not in hostname: addresses.append(f"{hostname}.tail-scale.ts.net") return addresses def connect_to_server(self): """Connect to the server""" hostname = self.server_entry.get().strip() if not hostname: self.log("Please enter a server address", "ERROR") return self.log(f"Attempting connection to: {hostname}") self.update_status("Connecting...", "orange") self.connect_btn.config(state="disabled") # Try in background thread def connect_thread(): addresses = self.resolve_hostname(hostname) for addr in addresses: self.log(f"Trying: {addr}") url = self.try_connect(addr) if url: self.root.after(0, lambda: self.connection_success(url)) return self.root.after(0, self.connection_failed) threading.Thread(target=connect_thread, daemon=True).start() def connection_success(self, url: str): """Handle successful connection""" self.server_url = url self.connected = True self.update_status(f"Connected to {url}", "green") self.log(f"Connected successfully to {url}", "SUCCESS") self.connect_btn.config(state="disabled") self.disconnect_btn.config(state="normal") self.server_entry.config(state="disabled") def connection_failed(self): """Handle connection failure""" self.update_status("Connection failed", "red") self.log("Failed to connect to server", "ERROR") self.connect_btn.config(state="normal") def disconnect_from_server(self): """Disconnect from server""" self.connected = False self.server_url = None self.update_status("Disconnected", "gray") self.log("Disconnected from server", "INFO") self.connect_btn.config(state="normal") self.disconnect_btn.config(state="disabled") self.server_entry.config(state="normal") def on_closing(self): """Handle window close event""" self.log("Shutting down...") # Stop keyboard listener self.stop_keyboard_listener() # Close window self.root.destroy() def main(): """Main entry point""" print(f"\n{CLIENT_NAME} v{CLIENT_VERSION}") print("="* 50) print("Global Input Capture Enabled") print("NFC scans will be captured even when window is not focused") print("="* 50 + "\n") root = tk.Tk() app = NFCNetworkClient(root) try: root.mainloop() except KeyboardInterrupt: print("\nShutdown requested...") finally: if hasattr(app, 'keyboard_listener') and app.keyboard_listener: app.keyboard_listener.stop() if __name__ == "__main__": main()