Fix global input capture - NFC scans now work regardless of window focus

This commit is contained in:
2025-12-24 02:17:12 +11:00
parent fc0804b5a4
commit 40f23b03cf

View File

@@ -1 +1,442 @@
$(cat /tmp/nfc_client_extracted/nfc_network_client.py | base64 -w 0)
#!/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()