Add USB card reader authentication for edit feature

- Implemented CardReader class to read data from USB card readers
- Added CardSwipePopup with 5-second timeout and visual feedback
- Card data is captured and included in edit metadata
- Card data sent to server when edited images are uploaded
- Added evdev dependency for USB input device handling
- Fallback mode when evdev not available (for development)
- Created test utility (test_card_reader.py) for card reader testing
- Added comprehensive documentation (CARD_READER_AUTHENTICATION.md)
- Added access-card.png icon for authentication popup
- Edit interface requires card swipe or times out after 5 seconds
This commit is contained in:
2025-12-08 14:05:04 +02:00
parent 9664ad541b
commit af1e671c7f
5 changed files with 577 additions and 20 deletions

View File

@@ -35,6 +35,16 @@ from kivy.clock import Clock
from kivy.core.window import Window
from kivy.properties import BooleanProperty
from kivy.logger import Logger
# Try to import evdev for card reader support (after Logger is available)
try:
import evdev
from evdev import InputDevice, categorize, ecodes
EVDEV_AVAILABLE = True
Logger.info("CardReader: evdev library loaded successfully")
except ImportError:
EVDEV_AVAILABLE = False
Logger.warning("CardReader: evdev not available - card reader functionality will use fallback mode")
from kivy.animation import Animation
from kivy.lang import Builder
from kivy.graphics import Rectangle
@@ -105,13 +115,226 @@ class DrawingLayer(Widget):
Logger.debug(f"DrawingLayer: Thickness set to {value}")
class CardReader:
"""USB Card Reader Handler for user authentication"""
def __init__(self):
self.device = None
self.card_data = ""
self.reading = False
self.last_read_time = 0
self.read_timeout = 5 # seconds
self.evdev_available = EVDEV_AVAILABLE
def find_card_reader(self):
"""Find USB card reader device"""
if not self.evdev_available:
Logger.error("CardReader: evdev library not available")
return False
try:
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
# Look for devices that might be card readers (keyboards, HID devices)
for device in devices:
# Card readers typically show up as keyboard input devices
if 'keyboard' in device.name.lower() or 'card' in device.name.lower() or 'reader' in device.name.lower():
Logger.info(f"CardReader: Found potential card reader: {device.name} at {device.path}")
self.device = device
return True
# If no specific card reader found, use first keyboard device
for device in devices:
capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities:
Logger.info(f"CardReader: Using keyboard device as card reader: {device.name}")
self.device = device
return True
Logger.warning("CardReader: No suitable input device found")
return False
except Exception as e:
Logger.error(f"CardReader: Error finding device: {e}")
return False
def read_card_async(self, callback):
"""Start reading card data asynchronously"""
if not self.evdev_available:
Logger.warning("CardReader: evdev not available - waiting for manual timeout/cancel")
# Fallback: In development mode without evdev, don't auto-authenticate
# Let the popup timeout or user cancel manually
# This allows testing the popup behavior
# To enable auto-authentication for testing, uncomment the next line:
# Clock.schedule_once(lambda dt: callback("DEFAULT_USER_12345"), 0.5)
return
if not self.device:
if not self.find_card_reader():
Logger.error("CardReader: Cannot start reading - no device found")
callback(None)
return
self.reading = True
self.card_data = ""
self.last_read_time = time.time()
# Start reading in a separate thread
thread = threading.Thread(target=self._read_card_thread, args=(callback,))
thread.daemon = True
thread.start()
def _read_card_thread(self, callback):
"""Thread function to read card data"""
try:
Logger.info("CardReader: Waiting for card swipe...")
for event in self.device.read_loop():
if not self.reading:
break
# Check for timeout
if time.time() - self.last_read_time > self.read_timeout:
Logger.warning("CardReader: Read timeout")
self.reading = False
callback(None)
break
if event.type == ecodes.EV_KEY:
key_event = categorize(event)
if key_event.keystate == 1: # Key down
key_code = key_event.keycode
# Handle Enter key (card read complete)
if key_code == 'KEY_ENTER':
Logger.info(f"CardReader: Card read complete: {self.card_data}")
self.reading = False
callback(self.card_data)
break
# Build card data string
elif key_code.startswith('KEY_'):
char = key_code.replace('KEY_', '')
if len(char) == 1: # Single character
self.card_data += char
self.last_read_time = time.time()
except Exception as e:
Logger.error(f"CardReader: Error reading card: {e}")
self.reading = False
callback(None)
def stop_reading(self):
"""Stop reading card data"""
self.reading = False
class CardSwipePopup(Popup):
"""Popup that shows card swipe prompt with 5 second timeout"""
def __init__(self, callback, resources_path, **kwargs):
super(CardSwipePopup, self).__init__(**kwargs)
self.callback = callback
self.timeout_event = None
# Popup settings
self.title = 'Card Authentication Required'
self.size_hint = (0.5, 0.4)
self.auto_dismiss = False
self.separator_height = 2
# Main layout
layout = BoxLayout(orientation='vertical', padding=20, spacing=20)
# Card swipe icon (using image)
icon_path = os.path.join(resources_path, 'access-card.png')
icon_image = Image(
source=icon_path,
size_hint=(1, 0.4),
allow_stretch=True,
keep_ratio=True
)
layout.add_widget(icon_image)
# Message
self.message_label = Label(
text='Please swipe your card...',
font_size='20sp',
size_hint=(1, 0.2)
)
layout.add_widget(self.message_label)
# Countdown timer display
self.countdown_label = Label(
text='5',
font_size='48sp',
color=(0.9, 0.6, 0.2, 1),
size_hint=(1, 0.2)
)
layout.add_widget(self.countdown_label)
# Cancel button
cancel_btn = Button(
text='Cancel',
size_hint=(1, 0.2),
background_color=(0.9, 0.3, 0.2, 1)
)
cancel_btn.bind(on_press=self.cancel)
layout.add_widget(cancel_btn)
self.content = layout
# Start countdown
self.remaining_time = 5
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
# Schedule timeout
self.timeout_event = Clock.schedule_once(self.on_timeout, 5)
def update_countdown(self, dt):
"""Update countdown display"""
self.remaining_time -= 1
self.countdown_label.text = str(self.remaining_time)
if self.remaining_time <= 0:
Clock.unschedule(self.countdown_event)
def on_timeout(self, dt):
"""Called when timeout occurs"""
Logger.warning("CardSwipePopup: Timeout - no card swiped")
self.message_label.text = 'Timeout - No card detected'
Clock.schedule_once(lambda dt: self.finish(None), 0.5)
def card_received(self, card_data):
"""Called when card data is received"""
Logger.info(f"CardSwipePopup: Card received: {card_data}")
self.message_label.text = '✓ Card detected'
self.countdown_label.text = ''
self.countdown_label.color = (0.2, 0.9, 0.3, 1)
Clock.schedule_once(lambda dt: self.finish(card_data), 0.5)
def cancel(self, instance):
"""Cancel button pressed"""
Logger.info("CardSwipePopup: Cancelled by user")
self.finish(None)
def finish(self, card_data):
"""Clean up and call callback"""
# Cancel scheduled events
if self.timeout_event:
Clock.unschedule(self.timeout_event)
if self.countdown_event:
Clock.unschedule(self.countdown_event)
# Dismiss popup
self.dismiss()
# Call callback with result
if self.callback:
self.callback(card_data)
class EditPopup(Popup):
"""Popup for editing/annotating images"""
def __init__(self, player_instance, image_path, authenticated_user=None, **kwargs):
def __init__(self, player_instance, image_path, user_card_data=None, **kwargs):
super(EditPopup, self).__init__(**kwargs)
self.player = player_instance
self.image_path = image_path
self.authenticated_user = authenticated_user or "player_1" # Default to player_1
self.user_card_data = user_card_data # Store card data to send to server on save
self.drawing_layer = None
# Pause playback
@@ -487,7 +710,7 @@ class EditPopup(Popup):
'new_name': output_filename,
'original_path': self.image_path,
'version': version,
'user': self.authenticated_user
'user_card_data': self.user_card_data # Card data from reader (or None)
}
# Save metadata JSON
@@ -496,7 +719,7 @@ class EditPopup(Popup):
with open(json_path, 'w') as f:
json.dump(metadata, f, indent=2)
Logger.info(f"EditPopup: Saved metadata to {json_path}")
Logger.info(f"EditPopup: Saved metadata to {json_path} (user_card_data: {self.user_card_data})")
return json_path
def _upload_to_server(self, image_path, metadata_path):
@@ -1039,6 +1262,9 @@ class SignagePlayer(Widget):
self.consecutive_errors = 0 # Track consecutive playback errors
self.max_consecutive_errors = 10 # Maximum errors before stopping
self.intro_played = False # Track if intro has been played
# Card reader for authentication
self.card_reader = None
self._pending_edit_image = None
# Paths
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.config_dir = os.path.join(self.base_dir, 'config')
@@ -1608,7 +1834,16 @@ class SignagePlayer(Widget):
popup.open()
def show_edit_interface(self, instance=None):
"""Show edit interface for current image"""
"""
Show edit interface for current image
Workflow:
1. Validate media type (must be image)
2. Check edit_on_player permission from server
3. Prompt for card swipe (5 second timeout)
4. Open edit interface with card data stored
5. When saved, card data is included in metadata and sent to server
"""
# Check if current media is an image
if not self.playlist or self.current_index >= len(self.playlist):
Logger.warning("SignagePlayer: No media to edit")
@@ -1637,29 +1872,54 @@ class SignagePlayer(Widget):
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Check 3: Verify user authentication
# TODO: Implement card swipe authentication system
authenticated_user = "player_1" # Placeholder - will be replaced with card authentication
# Store image info
self._pending_edit_image = file_name
if not authenticated_user:
Logger.warning(f"SignagePlayer: User not authenticated for editing")
# Show error message briefly
self.ids.status_label.text = 'User authentication required'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Show card swipe popup with 5 second timeout
self._card_swipe_popup = CardSwipePopup(callback=self._on_card_swipe_result, resources_path=self.resources_path)
self._card_swipe_popup.open()
# Get full path to current image
# Initialize card reader if not already created
if not self.card_reader:
self.card_reader = CardReader()
# Start reading card asynchronously
self.card_reader.read_card_async(self._on_card_data_received)
def _on_card_data_received(self, card_data):
"""Called when card reader gets data"""
if hasattr(self, '_card_swipe_popup') and self._card_swipe_popup:
self._card_swipe_popup.card_received(card_data)
def _on_card_swipe_result(self, card_data):
"""Handle result from card swipe popup (card data or None if timeout/cancelled)"""
# Get image path
file_name = self._pending_edit_image
image_path = os.path.join(self.media_dir, file_name)
if not os.path.exists(image_path):
Logger.error(f"SignagePlayer: Image not found: {image_path}")
self.ids.status_label.text = 'Image file not found'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
Logger.info(f"SignagePlayer: Opening edit interface for {file_name} (user: {authenticated_user})")
# If no card data (timeout or cancelled), don't open edit interface
if not card_data:
Logger.info("SignagePlayer: Edit cancelled - no card data")
self.ids.status_label.text = 'Edit cancelled - card required'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Open edit popup with authenticated user
popup = EditPopup(player_instance=self, image_path=image_path, authenticated_user=authenticated_user)
# Store the card data (will be sent to server when save is done)
user_card_data = card_data.strip()
Logger.info(f"SignagePlayer: Card data captured: {user_card_data}")
Logger.info(f"SignagePlayer: Opening edit interface for {file_name}")
# Open edit popup with card data (will be used when saving)
popup = EditPopup(player_instance=self, image_path=image_path, user_card_data=user_card_data)
popup.open()
def show_exit_popup(self, instance=None):