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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -3,4 +3,5 @@ ffpyplayer
requests==2.32.4
bcrypt==4.2.1
aiohttp==3.9.1
asyncio==3.4.3
asyncio==3.4.3
evdev>=1.6.0

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):

View File

@@ -0,0 +1,182 @@
# USB Card Reader Authentication
This document describes the USB card reader authentication feature for the Kiwy Signage Player.
## Overview
The player now supports user authentication via USB card readers when accessing the edit/drawing interface. When a user clicks the edit button (pencil icon), they must swipe their card to authenticate before being allowed to edit the image.
## How It Works
1. **Edit Button Click**: User clicks the pencil icon to edit the current image
2. **Validation Checks**:
- Verify current media is an image (not video)
- Check if editing is allowed for this media (`edit_on_player` permission from server)
3. **Card Reader Prompt**:
- Display "Please swipe your card..." message
- Wait for card swipe (5 second timeout)
- Read card data from USB card reader
- Store the card data (no validation required)
4. **Open Edit Interface**: Edit interface opens with card data stored
5. **Save & Upload**: When user saves the edited image:
- Card data is included in the metadata JSON
- Both image and metadata (with card data) are uploaded to server
- Server receives `user_card_data` field for tracking who edited the image
## Card Reader Setup
### Hardware Requirements
- USB card reader (HID/keyboard emulation type)
- Compatible cards (magnetic stripe or RFID depending on reader)
### Software Requirements
The player requires the `evdev` Python library to interface with USB input devices:
```bash
# Install via apt (recommended for Raspberry Pi)
sudo apt-get install python3-evdev
# Or via pip
pip3 install evdev
```
### Fallback Mode
If `evdev` is not available, the player will:
- Log a warning message
- Use a default card value (`DEFAULT_USER_12345`) for testing
- This allows development and testing without hardware
## Card Data Storage
The card data is captured as a raw string and stored without validation or mapping:
- **No preprocessing**: Card data is stored exactly as received from the reader
- **Format**: Whatever the card reader sends (typically numeric or alphanumeric)
- **Sent to server**: Raw card data is included in the `user_card_data` field of the metadata JSON
- **Server-side processing**: The server can validate, map, or process the card data as needed
### Metadata JSON Format
When an image is saved, the metadata includes:
```json
{
"time_of_modification": "2025-12-08T10:30:00",
"original_name": "image.jpg",
"new_name": "image_e_v1.jpg",
"original_path": "/path/to/image.jpg",
"version": 1,
"user_card_data": "123456789"
}
```
If no card is swiped (timeout), `user_card_data` will be `null`.
## Testing the Card Reader
A test utility is provided to verify card reader functionality:
```bash
cd /home/pi/Desktop/Kiwy-Signage/working_files
python3 test_card_reader.py
```
The test tool will:
1. List all available input devices
2. Auto-detect the card reader (or let you select manually)
3. Listen for card swipes and display the data received
4. Show how the data will be processed
### Test Output Example
```
✓ Card data received: '123456789'
Length: 9 characters
Processed ID: card_123456789
```
## Implementation Details
### Main Components
1. **CardReader Class** (`main.py`)
- Handles USB device detection
- Reads input events from card reader
- Provides async callback interface
- Includes timeout handling (5 seconds)
2. **Card Read Flow** (`show_edit_interface()` method)
- Validates media type and permissions
- Initiates card read
- Stores raw card data
- Opens edit popup
3. **Metadata Creation** (`_save_metadata()` method)
- Includes card data in metadata JSON
- No processing or validation of card data
- Sent to server as-is
### Card Data Format
Card readers typically send data as keyboard input:
- Each character is sent as a key press event
- Data ends with an ENTER key press
- Reader format: `[CARD_DATA][ENTER]`
The CardReader class:
- Captures key press events
- Builds the card data string character by character
- Completes reading when ENTER is detected
- Returns the complete card data to the callback
### Security Considerations
1. **Server-Side Validation**: Card validation should be implemented on the server
2. **Timeout**: 5-second timeout prevents infinite waiting for card swipe
3. **Logging**: All card reads are logged with the raw card data
4. **Permissions**: Edit permission must be enabled on the server (`edit_on_player`)
5. **Raw Data**: Card data is sent as-is; server is responsible for validation and authorization
## Troubleshooting
### Card Reader Not Detected
- Check USB connection
- Run `ls /dev/input/` to see available devices
- Run the test script to verify detection
- Check `evdev` is installed: `python3 -c "import evdev"`
### Card Swipes Not Recognized
- Verify card reader sends keyboard events
- Test with the `test_card_reader.py` utility
- Check card format is compatible with reader
- Ensure card is swiped smoothly at proper speed
### Card Data Not Captured
- Check card data format in logs
- Enable debug logging to see raw card data
- Test in fallback mode (without evdev) to isolate hardware issues
- Verify card swipe completes within 5-second timeout
### Permission Denied Errors
- User may need to be in the `input` group:
```bash
sudo usermod -a -G input $USER
```
- Reboot after adding user to group
## Future Enhancements
Potential improvements for the card reader system:
1. **Server Validation**: Server validates cards against database and returns authorization
2. **Card Enrollment**: Server-side UI for registering new cards
3. **Multiple Card Types**: Support for different card formats (barcode, RFID, magnetic)
4. **Client-side Validation**: Add optional local card validation before opening edit
5. **Audit Trail**: Server tracks all card usage with timestamps
6. **RFID Support**: Test and optimize for RFID readers
7. **Barcode Scanners**: Support USB barcode scanners as alternative
8. **Retry Logic**: Allow re-swipe if card read fails
## Related Files
- `/src/main.py` - Main implementation (CardReader class, authentication flow)
- `/src/edit_drowing.py` - Drawing/editing interface (uses authenticated user)
- `/working_files/test_card_reader.py` - Card reader test utility
- `/requirements.txt` - Dependencies (includes evdev)

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Test script for USB card reader functionality
"""
import evdev
from evdev import InputDevice, categorize, ecodes
import time
def list_input_devices():
"""List all available input devices"""
print("\n=== Available Input Devices ===")
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
for i, device in enumerate(devices):
print(f"\n[{i}] {device.path}")
print(f" Name: {device.name}")
print(f" Phys: {device.phys}")
capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities:
print(f" Type: Keyboard/HID Input Device")
return devices
def test_card_reader(device_index=None):
"""Test reading from a card reader device"""
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
if device_index is not None:
if device_index >= len(devices):
print(f"Error: Device index {device_index} out of range")
return
device = devices[device_index]
else:
# Try to find a card reader automatically
device = None
for dev in devices:
if 'keyboard' in dev.name.lower() or 'card' in dev.name.lower() or 'reader' in dev.name.lower():
device = dev
print(f"Found potential card reader: {dev.name}")
break
if not device and devices:
# Use first keyboard device
for dev in devices:
capabilities = dev.capabilities()
if ecodes.EV_KEY in capabilities:
device = dev
print(f"Using keyboard device: {dev.name}")
break
if not device:
print("No suitable input device found!")
return
print(f"\n=== Testing Card Reader ===")
print(f"Device: {device.name}")
print(f"Path: {device.path}")
print("\nSwipe your card now (press Ctrl+C to exit)...\n")
card_data = ""
try:
for event in device.read_loop():
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':
print(f"\n✓ Card data received: '{card_data}'")
print(f" Length: {len(card_data)} characters")
print(f" Processed ID: card_{card_data.strip().upper()}")
print("\nReady for next card swipe...")
card_data = ""
# Build card data string
elif key_code.startswith('KEY_'):
char = key_code.replace('KEY_', '')
if len(char) == 1: # Single character
card_data += char
print(f"Reading: {card_data}", end='\r', flush=True)
elif char.isdigit(): # Handle numeric keys
card_data += char
print(f"Reading: {card_data}", end='\r', flush=True)
except KeyboardInterrupt:
print("\n\nTest stopped by user")
except Exception as e:
print(f"\nError: {e}")
if __name__ == "__main__":
print("USB Card Reader Test Tool")
print("=" * 50)
devices = list_input_devices()
if not devices:
print("\nNo input devices found!")
exit(1)
print("\n" + "=" * 50)
choice = input("\nEnter device number to test (or press Enter for auto-detect): ").strip()
if choice:
try:
device_index = int(choice)
test_card_reader(device_index)
except ValueError:
print("Invalid device number!")
else:
test_card_reader()