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:
298
src/main.py
298
src/main.py
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user