diff --git a/config/resources/access-card.png b/config/resources/access-card.png new file mode 100644 index 0000000..b080ee9 Binary files /dev/null and b/config/resources/access-card.png differ diff --git a/requirements.txt b/requirements.txt index 0c88956..f1d6cf6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ ffpyplayer requests==2.32.4 bcrypt==4.2.1 aiohttp==3.9.1 -asyncio==3.4.3 \ No newline at end of file +asyncio==3.4.3 +evdev>=1.6.0 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 95e5ac9..2ebdd5b 100644 --- a/src/main.py +++ b/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): diff --git a/working_files/CARD_READER_AUTHENTICATION.md b/working_files/CARD_READER_AUTHENTICATION.md new file mode 100644 index 0000000..d33653b --- /dev/null +++ b/working_files/CARD_READER_AUTHENTICATION.md @@ -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) diff --git a/working_files/test_card_reader.py b/working_files/test_card_reader.py new file mode 100644 index 0000000..4adda28 --- /dev/null +++ b/working_files/test_card_reader.py @@ -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()