Compare commits

...

8 Commits

Author SHA1 Message Date
02e9ea1aaa updated to monitor the netowrk and reset wifi if is not working 2025-12-10 15:53:30 +02:00
Kiwy Signage Player
4c3ddbef73 updated to correctly play the playlist and reload images after edited 2025-12-10 00:09:20 +02:00
Kiwy Signage Player
87e059e0f4 updated to corect function the play pause function 2025-12-09 18:53:34 +02:00
Kiwy Signage Player
46d9fcf6e3 delete watch dog 2025-12-08 21:52:13 +02:00
Kiwy Signage Player
f1a84d05d5 updated buttons in settings 2025-12-08 21:52:03 +02:00
Kiwy Signage Player
706af95557 deleted unnecesary files 2025-12-08 18:17:09 +02:00
02227a12e5 updeated to read specific card 2025-12-08 15:45:37 +02:00
9d32f43ac7 Add edit feature enable/disable setting
- Added checkbox in Settings screen to enable/disable edit feature
- Setting stored in app_config.json as 'edit_feature_enabled'
- Edit workflow now validates: player setting, media type, server permission, card auth
- Shows appropriate error message when edit is blocked at any validation step
- Defaults to enabled (true) if not set
- All conditions must be met for edit interface to open
2025-12-08 14:30:12 +02:00
11 changed files with 1109 additions and 84 deletions

View File

@@ -5,5 +5,6 @@
"quickconnect_key": "8887779", "quickconnect_key": "8887779",
"orientation": "Landscape", "orientation": "Landscape",
"touch": "True", "touch": "True",
"max_resolution": "1920x1080" "max_resolution": "1920x1080",
"edit_feature_enabled": true
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

53
setup_wifi_control.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# Setup script to allow passwordless sudo for WiFi control commands
echo "Setting up passwordless sudo for WiFi control..."
echo ""
# Create sudoers file for WiFi commands
SUDOERS_FILE="/etc/sudoers.d/kiwy-signage-wifi"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "This script must be run as root (use sudo)"
echo "Usage: sudo bash setup_wifi_control.sh"
exit 1
fi
# Get the username who invoked sudo
ACTUAL_USER="${SUDO_USER:-$USER}"
echo "Configuring passwordless sudo for user: $ACTUAL_USER"
echo ""
# Create sudoers entry
cat > "$SUDOERS_FILE" << EOF
# Allow $ACTUAL_USER to control WiFi without password for Kiwy Signage Player
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill block wifi
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill unblock wifi
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 down
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 up
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/dhclient wlan0
EOF
# Set correct permissions
chmod 0440 "$SUDOERS_FILE"
echo "✓ Created sudoers file: $SUDOERS_FILE"
echo ""
# Validate the sudoers file
if visudo -c -f "$SUDOERS_FILE"; then
echo "✓ Sudoers file validated successfully"
echo ""
echo "Setup complete! User '$ACTUAL_USER' can now control WiFi without password."
echo ""
echo "Test with:"
echo " sudo rfkill block wifi"
echo " sudo rfkill unblock wifi"
else
echo "✗ Error: Sudoers file validation failed"
echo "Removing invalid file..."
rm -f "$SUDOERS_FILE"
exit 1
fi

View File

@@ -271,8 +271,53 @@ def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, kee
os.remove(filepath) os.remove(filepath)
logger.info(f"🗑️ Deleted old playlist: {f}") logger.info(f"🗑️ Deleted old playlist: {f}")
# TODO: Clean up unused media files # Clean up unused media files
logger.info(f" Cleanup complete (kept {keep_versions} latest versions)") logger.info("🔍 Checking for unused media files...")
# Get list of media files referenced in current playlist
current_playlist_file = os.path.join(playlist_dir, f'server_playlist_v{current_version}.json')
referenced_files = set()
if os.path.exists(current_playlist_file):
try:
with open(current_playlist_file, 'r') as f:
playlist_data = json.load(f)
for item in playlist_data.get('playlist', []):
file_name = item.get('file_name', '')
if file_name:
referenced_files.add(file_name)
logger.info(f"📋 Current playlist references {len(referenced_files)} media files")
# Get all files in media directory (excluding edited_media subfolder)
if os.path.exists(media_dir):
media_files = [f for f in os.listdir(media_dir)
if os.path.isfile(os.path.join(media_dir, f))]
deleted_count = 0
for media_file in media_files:
# Skip if file is in current playlist
if media_file in referenced_files:
continue
# Delete unreferenced file
media_path = os.path.join(media_dir, media_file)
try:
os.remove(media_path)
logger.info(f"🗑️ Deleted unused media: {media_file}")
deleted_count += 1
except Exception as e:
logger.warning(f"⚠️ Could not delete {media_file}: {e}")
if deleted_count > 0:
logger.info(f"✅ Deleted {deleted_count} unused media files")
else:
logger.info("✅ No unused media files to delete")
except Exception as e:
logger.error(f"❌ Error reading playlist for media cleanup: {e}")
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest playlist versions)")
except Exception as e: except Exception as e:
logger.error(f"❌ Error during cleanup: {e}") logger.error(f"❌ Error during cleanup: {e}")

View File

@@ -56,6 +56,7 @@ from get_playlists_v2 import (
send_player_error_feedback send_player_error_feedback
) )
from keyboard_widget import KeyboardWidget from keyboard_widget import KeyboardWidget
from network_monitor import NetworkMonitor
from kivy.graphics import Color, Line, Ellipse from kivy.graphics import Color, Line, Ellipse
from kivy.uix.floatlayout import FloatLayout from kivy.uix.floatlayout import FloatLayout
from kivy.uix.slider import Slider from kivy.uix.slider import Slider
@@ -133,23 +134,68 @@ class CardReader:
try: try:
devices = [evdev.InputDevice(path) for path in evdev.list_devices()] 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 # Log all available devices for debugging
Logger.info("CardReader: Scanning input devices...")
for device in devices: for device in devices:
Logger.info(f" - {device.name} ({device.path})")
# Exclusion list: devices to skip (touchscreens, mice, etc.)
exclusion_keywords = [
'touch', 'touchscreen', 'mouse', 'mice', 'trackpad',
'touchpad', 'pen', 'stylus', 'video', 'button', 'lid'
]
# Priority 1: Look for devices explicitly named as card readers
for device in devices:
device_name_lower = device.name.lower()
# Skip excluded devices
if any(keyword in device_name_lower for keyword in exclusion_keywords):
Logger.info(f"CardReader: Skipping excluded device: {device.name}")
continue
# High priority: explicit card reader or RFID reader
if 'card' in device_name_lower or 'reader' in device_name_lower or 'rfid' in device_name_lower or 'hid' in device_name_lower:
capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities:
Logger.info(f"CardReader: Found card reader: {device.name} at {device.path}")
self.device = device
return True
# Priority 2: Look for USB keyboard devices (but not built-in/PS2 keyboards)
# Card readers usually show as USB HID keyboards
for device in devices:
device_name_lower = device.name.lower()
# Skip excluded devices
if any(keyword in device_name_lower for keyword in exclusion_keywords):
continue
# Look for USB keyboards (card readers often appear as "USB Keyboard" or similar)
if 'usb' in device_name_lower and 'keyboard' in device_name_lower:
capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities:
Logger.info(f"CardReader: Using USB keyboard device as card reader: {device.name} at {device.path}")
self.device = device
return True
# Priority 3: Use any keyboard device that hasn't been excluded
Logger.warning("CardReader: No explicit card reader found, searching for any suitable keyboard device")
for device in devices:
device_name_lower = device.name.lower()
# Skip excluded devices
if any(keyword in device_name_lower for keyword in exclusion_keywords):
continue
capabilities = device.capabilities() capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities: if ecodes.EV_KEY in capabilities:
Logger.info(f"CardReader: Using keyboard device as card reader: {device.name}") Logger.info(f"CardReader: Using keyboard device as card reader: {device.name} at {device.path}")
self.device = device self.device = device
return True return True
Logger.warning("CardReader: No suitable input device found") Logger.warning("CardReader: No suitable input device found after checking all devices")
return False return False
except Exception as e: except Exception as e:
Logger.error(f"CardReader: Error finding device: {e}") Logger.error(f"CardReader: Error finding device: {e}")
@@ -337,13 +383,26 @@ class EditPopup(Popup):
self.user_card_data = user_card_data # Store card data to send to server on save self.user_card_data = user_card_data # Store card data to send to server on save
self.drawing_layer = None self.drawing_layer = None
# Pause playback # Pause playback (without auto-resume timer)
self.was_paused = self.player.is_paused self.was_paused = self.player.is_paused
if not self.was_paused: if not self.was_paused:
self.player.is_paused = True self.player.is_paused = True
Clock.unschedule(self.player.next_media) Clock.unschedule(self.player.next_media)
# Cancel auto-resume timer if one exists (don't want auto-resume during editing)
if self.player.auto_resume_event:
Clock.unschedule(self.player.auto_resume_event)
self.player.auto_resume_event = None
Logger.info("EditPopup: Cancelled auto-resume timer")
# Update button icon to play (to show it's paused)
self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/play.png'
self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/play.png'
if self.player.current_widget and isinstance(self.player.current_widget, Video): if self.player.current_widget and isinstance(self.player.current_widget, Video):
self.player.current_widget.state = 'pause' self.player.current_widget.state = 'pause'
Logger.info("EditPopup: ⏸ Paused playback (no auto-resume) for editing")
# Show cursor # Show cursor
try: try:
@@ -658,6 +717,32 @@ class EditPopup(Popup):
# Export only the visible content (image + drawings, no toolbars) # Export only the visible content (image + drawings, no toolbars)
self.content.export_to_png(output_path) self.content.export_to_png(output_path)
Logger.info(f"EditPopup: Saved edited image to {output_path}")
# ALSO overwrite the original image with edited content
Logger.info(f"EditPopup: Overwriting original image at {self.image_path}")
import shutil
import time
# Get original file info before overwrite
orig_size = os.path.getsize(self.image_path)
orig_mtime = os.path.getmtime(self.image_path)
# Overwrite the file
shutil.copy2(output_path, self.image_path)
# Force file system sync to ensure data is written to disk
os.sync()
# Verify the overwrite
new_size = os.path.getsize(self.image_path)
new_mtime = os.path.getmtime(self.image_path)
Logger.info(f"EditPopup: ✓ File overwritten:")
Logger.info(f" - Size: {orig_size} -> {new_size} bytes (changed: {new_size != orig_size})")
Logger.info(f" - Modified time: {orig_mtime} -> {new_mtime} (changed: {new_mtime > orig_mtime})")
Logger.info(f"EditPopup: ✓ File synced to disk")
# Restore toolbars # Restore toolbars
self.top_toolbar.opacity = 1 self.top_toolbar.opacity = 1
self.right_sidebar.opacity = 1 self.right_sidebar.opacity = 1
@@ -666,9 +751,7 @@ class EditPopup(Popup):
json_filename = self._save_metadata(edited_dir, new_name, base_name, json_filename = self._save_metadata(edited_dir, new_name, base_name,
new_version if version_match else 1, output_filename) new_version if version_match else 1, output_filename)
Logger.info(f"EditPopup: Saved edited image to {output_path}") # Upload to server in background (continues after popup closes)
# Upload to server asynchronously (non-blocking)
import threading import threading
upload_thread = threading.Thread( upload_thread = threading.Thread(
target=self._upload_to_server, target=self._upload_to_server,
@@ -676,10 +759,42 @@ class EditPopup(Popup):
daemon=True daemon=True
) )
upload_thread.start() upload_thread.start()
Logger.info(f"EditPopup: Background upload thread started")
# Show confirmation # NOW show saving popup AFTER everything is done
self.title = f'Saved as {output_filename}' def show_saving_and_dismiss(dt):
Clock.schedule_once(lambda dt: self.dismiss(), 1) from kivy.uix.popup import Popup
from kivy.uix.label import Label
# Create label with background
save_label = Label(
text='✓ Saved! Reloading player...',
font_size='36sp',
color=(1, 1, 1, 1),
bold=True
)
saving_popup = Popup(
title='',
content=save_label,
size_hint=(0.8, 0.3),
auto_dismiss=False,
separator_height=0,
background_color=(0.2, 0.7, 0.2, 0.95) # Green background
)
saving_popup.open()
Logger.info("EditPopup: Saving confirmation popup opened")
# Dismiss both popups after 2 seconds
def dismiss_all(dt):
saving_popup.dismiss()
Logger.info(f"EditPopup: Dismissing to resume playback...")
self.dismiss()
Clock.schedule_once(dismiss_all, 2.0)
# Small delay to ensure UI is ready, then show popup
Clock.schedule_once(show_saving_and_dismiss, 0.1)
except Exception as e: except Exception as e:
Logger.error(f"EditPopup: Error in export: {e}") Logger.error(f"EditPopup: Error in export: {e}")
@@ -689,6 +804,8 @@ class EditPopup(Popup):
# Restore toolbars # Restore toolbars
self.top_toolbar.opacity = 1 self.top_toolbar.opacity = 1
self.right_sidebar.opacity = 1 self.right_sidebar.opacity = 1
# Still dismiss on error after brief delay
Clock.schedule_once(lambda dt: self.dismiss(), 1)
Clock.schedule_once(do_export, 0.1) Clock.schedule_once(do_export, 0.1)
return return
@@ -806,16 +923,35 @@ class EditPopup(Popup):
self.dismiss() self.dismiss()
def on_popup_dismiss(self, *args): def on_popup_dismiss(self, *args):
"""Resume playback when popup closes""" """Resume playback when popup closes - reload current image and continue"""
# Resume playback if it wasn't paused before from kivy.clock import Clock
# Force remove current widget immediately
if self.player.current_widget:
Logger.info("EditPopup: Removing current widget to force reload")
self.player.ids.content_area.remove_widget(self.player.current_widget)
self.player.current_widget = None
Logger.info("EditPopup: ✓ Widget removed, ready for fresh load")
# Resume playback if it wasn't paused before editing
if not self.was_paused: if not self.was_paused:
self.player.is_paused = False self.player.is_paused = False
self.player.play_current_media()
# Update button icon to pause (to show it's playing)
self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/pause.png'
self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/pause.png'
# Add delay to ensure file write is complete and synced
def reload_media(dt):
Logger.info("EditPopup: ▶ Resuming playback and reloading edited image (force_reload=True)")
self.player.play_current_media(force_reload=True)
Clock.schedule_once(reload_media, 0.5)
else:
Logger.info("EditPopup: Dismissed, keeping paused state")
# Restart control hide timer # Restart control hide timer
self.player.schedule_hide_controls() self.player.schedule_hide_controls()
Logger.info("EditPopup: Dismissed, playback resumed")
# Custom keyboard container with close button # Custom keyboard container with close button
class KeyboardContainer(BoxLayout): class KeyboardContainer(BoxLayout):
@@ -1078,10 +1214,11 @@ class SettingsPopup(Popup):
self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape') self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape')
self.ids.touch_input.text = self.player.config.get('touch', 'True') self.ids.touch_input.text = self.player.config.get('touch', 'True')
self.ids.resolution_input.text = self.player.config.get('max_resolution', 'auto') self.ids.resolution_input.text = self.player.config.get('max_resolution', 'auto')
self.ids.edit_enabled_checkbox.active = self.player.config.get('edit_feature_enabled', True)
# Update status info # Update status info
self.ids.playlist_info.text = f'Playlist Version: {self.player.playlist_version}' self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}'
self.ids.media_count_info.text = f'Media Count: {len(self.player.playlist)}' self.ids.media_count_info.text = f'Media: {len(self.player.playlist)}'
self.ids.status_info.text = f'Status: {"Playing" if self.player.is_playing else "Paused" if self.player.is_paused else "Idle"}' self.ids.status_info.text = f'Status: {"Playing" if self.player.is_playing else "Paused" if self.player.is_paused else "Idle"}'
# Bind to dismiss event to manage cursor visibility and resume playback # Bind to dismiss event to manage cursor visibility and resume playback
@@ -1118,6 +1255,12 @@ class SettingsPopup(Popup):
Window.remove_widget(self.keyboard_widget) Window.remove_widget(self.keyboard_widget)
self.keyboard_widget = None self.keyboard_widget = None
def on_edit_feature_toggle(self, is_active):
"""Handle edit feature checkbox toggle"""
Logger.info(f"SettingsPopup: Edit feature {'enabled' if is_active else 'disabled'}")
# Update config immediately so it's available
self.player.config['edit_feature_enabled'] = is_active
def on_popup_dismiss(self, *args): def on_popup_dismiss(self, *args):
"""Handle popup dismissal - resume playback and restart cursor hide timer""" """Handle popup dismissal - resume playback and restart cursor hide timer"""
# Hide and remove keyboard # Hide and remove keyboard
@@ -1215,6 +1358,93 @@ class SettingsPopup(Popup):
else: else:
self.ids.connection_status.color = (1, 0, 0, 1) # Red self.ids.connection_status.color = (1, 0, 0, 1) # Red
def reset_player_auth(self):
"""Reset player authentication by deleting player_auth.json"""
try:
auth_file = os.path.join(os.path.dirname(__file__), 'player_auth.json')
if os.path.exists(auth_file):
os.remove(auth_file)
Logger.info(f"SettingsPopup: Deleted authentication file: {auth_file}")
self._show_temp_message('✓ Authentication reset - will reauthenticate on restart', (0, 1, 0, 1))
else:
Logger.info("SettingsPopup: No authentication file found")
self._show_temp_message('No authentication file found', (1, 0.7, 0, 1))
except Exception as e:
Logger.error(f"SettingsPopup: Error resetting authentication: {e}")
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
def reset_playlist_version(self):
"""Reset playlist version to 0 and rename playlist file"""
try:
playlists_dir = os.path.join(self.player.base_dir, 'playlists')
# Find current playlist file
import glob
playlist_files = glob.glob(os.path.join(playlists_dir, 'server_playlist_v*.json'))
if playlist_files:
# Get the first (should be only one) playlist file
current_playlist = playlist_files[0]
new_playlist = os.path.join(playlists_dir, 'server_playlist_v0.json')
# Rename to v0
os.rename(current_playlist, new_playlist)
Logger.info(f"SettingsPopup: Renamed {os.path.basename(current_playlist)} to server_playlist_v0.json")
# Update player's playlist version
self.player.playlist_version = 0
# Update display
self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}'
self._show_temp_message('✓ Playlist reset to v0 - will resync from server', (0, 1, 0, 1))
else:
Logger.info("SettingsPopup: No playlist file found")
self._show_temp_message('No playlist file found', (1, 0.7, 0, 1))
except Exception as e:
Logger.error(f"SettingsPopup: Error resetting playlist: {e}")
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
def restart_player(self):
"""Restart playlist from the first item"""
try:
Logger.info("SettingsPopup: Restarting player from first item")
# Reset to first item
self.player.current_index = 0
# Show message
self._show_temp_message('✓ Restarting playlist from beginning...', (0, 1, 0, 1))
# Close settings after a short delay
def close_and_restart(dt):
self.dismiss()
# Start playing from first item
self.player.play_current_media()
Clock.schedule_once(close_and_restart, 2)
except Exception as e:
Logger.error(f"SettingsPopup: Error restarting player: {e}")
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
def _show_temp_message(self, message, color):
"""Show a temporary popup message for 2 seconds"""
from kivy.uix.popup import Popup
from kivy.uix.label import Label
popup = Popup(
title='',
content=Label(text=message, color=color),
size_hint=(0.6, 0.3),
auto_dismiss=True
)
popup.open()
# Auto-close after 2 seconds
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
def save_and_close(self): def save_and_close(self):
"""Save configuration and close popup""" """Save configuration and close popup"""
# Update config # Update config
@@ -1224,6 +1454,7 @@ class SettingsPopup(Popup):
self.player.config['orientation'] = self.ids.orientation_input.text self.player.config['orientation'] = self.ids.orientation_input.text
self.player.config['touch'] = self.ids.touch_input.text self.player.config['touch'] = self.ids.touch_input.text
self.player.config['max_resolution'] = self.ids.resolution_input.text self.player.config['max_resolution'] = self.ids.resolution_input.text
self.player.config['edit_feature_enabled'] = self.ids.edit_enabled_checkbox.active
# Save to file # Save to file
self.player.save_config() self.player.save_config()
@@ -1257,6 +1488,7 @@ class SignagePlayer(Widget):
self.current_widget = None self.current_widget = None
self.is_playing = False self.is_playing = False
self.is_paused = False self.is_paused = False
self.auto_resume_event = None # Track scheduled auto-resume
self.config = {} self.config = {}
self.playlist_version = None self.playlist_version = None
self.consecutive_errors = 0 # Track consecutive playback errors self.consecutive_errors = 0 # Track consecutive playback errors
@@ -1265,6 +1497,8 @@ class SignagePlayer(Widget):
# Card reader for authentication # Card reader for authentication
self.card_reader = None self.card_reader = None
self._pending_edit_image = None self._pending_edit_image = None
# Network monitor
self.network_monitor = None
# Paths # Paths
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.config_dir = os.path.join(self.base_dir, 'config') self.config_dir = os.path.join(self.base_dir, 'config')
@@ -1311,6 +1545,9 @@ class SignagePlayer(Widget):
# Load configuration # Load configuration
self.load_config() self.load_config()
# Initialize network monitor
self.start_network_monitoring()
# Play intro video first # Play intro video first
self.play_intro_video() self.play_intro_video()
@@ -1351,6 +1588,30 @@ class SignagePlayer(Widget):
Logger.info("SignagePlayer: Configuration saved") Logger.info("SignagePlayer: Configuration saved")
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error saving config: {e}") Logger.error(f"SignagePlayer: Error saving config: {e}")
def start_network_monitoring(self):
"""Initialize and start network monitoring"""
try:
if self.config and 'server_ip' in self.config:
server_url = self.config.get('server_ip', '')
# Initialize network monitor with:
# - Check every 30-45 minutes
# - Restart WiFi for 20 minutes on connection failure
self.network_monitor = NetworkMonitor(
server_url=server_url,
check_interval_min=30,
check_interval_max=45,
wifi_restart_duration=20
)
# Start monitoring
self.network_monitor.start_monitoring()
Logger.info("SignagePlayer: Network monitoring started")
else:
Logger.warning("SignagePlayer: Cannot start network monitoring - no server configured")
except Exception as e:
Logger.error(f"SignagePlayer: Error starting network monitoring: {e}")
async def async_playlist_update_loop(self): async def async_playlist_update_loop(self):
"""Async coroutine to check for playlist updates without blocking UI""" """Async coroutine to check for playlist updates without blocking UI"""
@@ -1515,8 +1776,17 @@ class SignagePlayer(Widget):
self.current_index = 0 self.current_index = 0
self.play_current_media() self.play_current_media()
def play_current_media(self): def play_current_media(self, force_reload=False):
"""Play the current media item""" """Play the current media item
Args:
force_reload: If True, clears image cache before loading (for edited images)
"""
# Don't play if paused (unless we're explicitly resuming)
if self.is_paused:
Logger.debug(f"SignagePlayer: Skipping play_current_media - player is paused")
return
if not self.playlist or self.current_index >= len(self.playlist): if not self.playlist or self.current_index >= len(self.playlist):
# End of playlist, restart # End of playlist, restart
self.restart_playlist() self.restart_playlist()
@@ -1573,7 +1843,7 @@ class SignagePlayer(Widget):
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']: elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
# Image file # Image file
Logger.info(f"SignagePlayer: Media type: IMAGE") Logger.info(f"SignagePlayer: Media type: IMAGE")
self.play_image(media_path, duration) self.play_image(media_path, duration, force_reload=force_reload)
else: else:
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}") Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp") Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
@@ -1673,17 +1943,37 @@ class SignagePlayer(Widget):
except Exception as e: except Exception as e:
Logger.warning(f"SignagePlayer: Could not log video info: {e}") Logger.warning(f"SignagePlayer: Could not log video info: {e}")
def play_image(self, image_path, duration): def play_image(self, image_path, duration, force_reload=False):
"""Play an image file""" """Play an image file"""
try: try:
Logger.info(f"SignagePlayer: Creating AsyncImage widget...") # Log file info before loading
self.current_widget = AsyncImage( file_size = os.path.getsize(image_path)
source=image_path, file_mtime = os.path.getmtime(image_path)
allow_stretch=True, Logger.info(f"SignagePlayer: Loading image: {os.path.basename(image_path)}")
keep_ratio=True, # Maintain aspect ratio Logger.info(f" - Size: {file_size:,} bytes, Modified: {file_mtime}")
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5} if force_reload:
) Logger.info(f"SignagePlayer: Force reload - using Image widget (no async cache)")
# Use regular Image widget instead of AsyncImage to bypass all caching
from kivy.uix.image import Image
self.current_widget = Image(
source=image_path,
allow_stretch=True,
keep_ratio=True,
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5}
)
# Force reload the texture
self.current_widget.reload()
else:
Logger.info(f"SignagePlayer: Creating AsyncImage widget...")
self.current_widget = AsyncImage(
source=image_path,
allow_stretch=True,
keep_ratio=True, # Maintain aspect ratio
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5}
)
Logger.info(f"SignagePlayer: Adding image widget to content area...") Logger.info(f"SignagePlayer: Adding image widget to content area...")
self.ids.content_area.add_widget(self.current_widget) self.ids.content_area.add_widget(self.current_widget)
# Schedule next media after duration (unschedule first to prevent overlaps) # Schedule next media after duration (unschedule first to prevent overlaps)
@@ -1700,7 +1990,7 @@ class SignagePlayer(Widget):
def next_media(self, dt=None): def next_media(self, dt=None):
"""Move to next media item""" """Move to next media item"""
if self.is_paused: if self.is_paused:
Logger.debug(f"SignagePlayer: Skipping next_media - player is paused") Logger.info(f"SignagePlayer: ⏸ Blocked next_media - player is paused")
return return
Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})") Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})")
@@ -1720,15 +2010,52 @@ class SignagePlayer(Widget):
self.play_current_media() self.play_current_media()
def toggle_pause(self, instance=None): def toggle_pause(self, instance=None):
"""Toggle pause/play""" """Toggle pause/play with auto-resume after 5 minutes"""
self.is_paused = not self.is_paused self.is_paused = not self.is_paused
if self.is_paused: if self.is_paused:
self.ids.play_pause_btn.text = '' # Paused - change icon to play and schedule auto-resume
Logger.info("SignagePlayer: ⏸ PAUSING - is_paused = True")
self.ids.play_pause_btn.background_normal = self.resources_path + '/play.png'
self.ids.play_pause_btn.background_down = self.resources_path + '/play.png'
Clock.unschedule(self.next_media) Clock.unschedule(self.next_media)
# Cancel any existing auto-resume
if self.auto_resume_event:
Clock.unschedule(self.auto_resume_event)
# Schedule auto-resume after 5 minutes (300 seconds)
self.auto_resume_event = Clock.schedule_once(self.auto_resume_playback, 300)
Logger.info("SignagePlayer: Auto-resume scheduled in 5 minutes")
else: else:
self.ids.play_pause_btn.text = '' # Playing - change icon to pause and cancel auto-resume
# Resume by playing current media # Note: is_paused is already set to False by the toggle above
Logger.info("SignagePlayer: ▶ RESUMING - is_paused = False")
self.ids.play_pause_btn.background_normal = self.resources_path + '/pause.png'
self.ids.play_pause_btn.background_down = self.resources_path + '/pause.png'
# Cancel auto-resume if manually resumed
if self.auto_resume_event:
Clock.unschedule(self.auto_resume_event)
self.auto_resume_event = None
# Resume by playing current media (is_paused is now False)
self.play_current_media()
def auto_resume_playback(self, dt):
"""Automatically resume playback after 5 minutes of pause"""
Logger.info("SignagePlayer: Auto-resuming playback after 5 minutes")
self.auto_resume_event = None
if self.is_paused:
# Reset pause state FIRST (before calling play_current_media)
self.is_paused = False
# Update icon to pause
self.ids.play_pause_btn.background_normal = self.resources_path + '/pause.png'
self.ids.play_pause_btn.background_down = self.resources_path + '/pause.png'
# Resume playback (is_paused is now False so it will work)
self.play_current_media() self.play_current_media()
def restart_playlist(self): def restart_playlist(self):
@@ -1838,12 +2165,23 @@ class SignagePlayer(Widget):
Show edit interface for current image Show edit interface for current image
Workflow: Workflow:
1. Validate media type (must be image) 1. Check if edit feature is enabled in settings
2. Check edit_on_player permission from server 2. Validate media type (must be image)
3. Prompt for card swipe (5 second timeout) 3. Check edit_on_player permission from server
4. Open edit interface with card data stored 4. Prompt for card swipe (5 second timeout)
5. When saved, card data is included in metadata and sent to server 5. Open edit interface with card data stored
6. When saved, card data is included in metadata and sent to server
""" """
# Check 0: Verify edit feature is enabled in player settings
edit_feature_enabled = self.config.get('edit_feature_enabled', True)
if not edit_feature_enabled:
Logger.warning("SignagePlayer: Edit feature is disabled in settings")
# Show error message briefly
self.ids.status_label.text = 'Edit feature disabled - Enable in settings'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Check if current media is an image # Check if current media is an image
if not self.playlist or self.current_index >= len(self.playlist): if not self.playlist or self.current_index >= len(self.playlist):
Logger.warning("SignagePlayer: No media to edit") Logger.warning("SignagePlayer: No media to edit")
@@ -2055,6 +2393,11 @@ class SignagePlayerApp(App):
def on_stop(self): def on_stop(self):
Logger.info("SignagePlayerApp: Application stopped") Logger.info("SignagePlayerApp: Application stopped")
# Stop network monitoring
if hasattr(self.root, 'network_monitor') and self.root.network_monitor:
self.root.network_monitor.stop_monitoring()
Logger.info("SignagePlayerApp: Network monitoring stopped")
# Cancel all async tasks # Cancel all async tasks
try: try:
pending = asyncio.all_tasks() pending = asyncio.all_tasks()

235
src/network_monitor.py Normal file
View File

@@ -0,0 +1,235 @@
"""
Network Monitoring Module
Checks server connectivity and manages WiFi restart on connection failure
"""
import subprocess
import time
import random
import requests
from datetime import datetime
from kivy.logger import Logger
from kivy.clock import Clock
class NetworkMonitor:
"""Monitor network connectivity and manage WiFi restart"""
def __init__(self, server_url, check_interval_min=30, check_interval_max=45, wifi_restart_duration=20):
"""
Initialize network monitor
Args:
server_url (str): Server URL to check connectivity (e.g., 'https://digi-signage.moto-adv.com')
check_interval_min (int): Minimum minutes between checks (default: 30)
check_interval_max (int): Maximum minutes between checks (default: 45)
wifi_restart_duration (int): Minutes to keep WiFi off during restart (default: 20)
"""
self.server_url = server_url.rstrip('/')
self.check_interval_min = check_interval_min * 60 # Convert to seconds
self.check_interval_max = check_interval_max * 60 # Convert to seconds
self.wifi_restart_duration = wifi_restart_duration * 60 # Convert to seconds
self.is_monitoring = False
self.scheduled_event = None
self.consecutive_failures = 0
self.max_failures_before_restart = 3 # Restart WiFi after 3 consecutive failures
def start_monitoring(self):
"""Start the network monitoring loop"""
if not self.is_monitoring:
self.is_monitoring = True
Logger.info("NetworkMonitor: Starting network monitoring")
self._schedule_next_check()
def stop_monitoring(self):
"""Stop the network monitoring"""
self.is_monitoring = False
if self.scheduled_event:
self.scheduled_event.cancel()
self.scheduled_event = None
Logger.info("NetworkMonitor: Stopped network monitoring")
def _schedule_next_check(self):
"""Schedule the next connectivity check at a random interval"""
if not self.is_monitoring:
return
# Random interval between min and max
next_check_seconds = random.randint(self.check_interval_min, self.check_interval_max)
next_check_minutes = next_check_seconds / 60
Logger.info(f"NetworkMonitor: Next check scheduled in {next_check_minutes:.1f} minutes")
# Schedule using Kivy Clock
self.scheduled_event = Clock.schedule_once(
lambda dt: self._check_connectivity(),
next_check_seconds
)
def _check_connectivity(self):
"""Check network connectivity to server"""
Logger.info("NetworkMonitor: Checking server connectivity...")
if self._test_server_connection():
Logger.info("NetworkMonitor: ✓ Server connection successful")
self.consecutive_failures = 0
else:
self.consecutive_failures += 1
Logger.warning(f"NetworkMonitor: ✗ Server connection failed (attempt {self.consecutive_failures}/{self.max_failures_before_restart})")
if self.consecutive_failures >= self.max_failures_before_restart:
Logger.error("NetworkMonitor: Multiple connection failures detected - initiating WiFi restart")
self._restart_wifi()
self.consecutive_failures = 0 # Reset counter after restart
# Schedule next check
self._schedule_next_check()
def _test_server_connection(self):
"""
Test connection to the server using ping only
This works in closed networks where the server is local
Returns:
bool: True if server is reachable, False otherwise
"""
try:
# Extract hostname from server URL (remove http:// or https://)
hostname = self.server_url.replace('https://', '').replace('http://', '').split('/')[0]
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
# Ping the server hostname with 3 attempts
result = subprocess.run(
['ping', '-c', '3', '-W', '3', hostname],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
Logger.info(f"NetworkMonitor: ✓ Server {hostname} is reachable")
return True
else:
Logger.warning(f"NetworkMonitor: ✗ Cannot reach server {hostname}")
return False
except subprocess.TimeoutExpired:
Logger.warning(f"NetworkMonitor: ✗ Ping timeout to server")
return False
except Exception as e:
Logger.error(f"NetworkMonitor: Error pinging server: {e}")
return False
def _restart_wifi(self):
"""
Restart WiFi by turning it off for a specified duration then back on
This runs in a separate thread to not block the main application
"""
def wifi_restart_thread():
try:
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
Logger.info("NetworkMonitor: ====================================")
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
result = subprocess.run(
['sudo', 'rfkill', 'block', 'wifi'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF")
else:
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
# Fallback to ifconfig
result2 = subprocess.run(
['sudo', 'ifconfig', 'wlan0', 'down'],
capture_output=True,
text=True,
timeout=10
)
if result2.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
else:
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
return
# Wait for the specified duration with WiFi OFF
wait_minutes = self.wifi_restart_duration / 60
Logger.info(f"NetworkMonitor: ====================================")
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
Logger.info(f"NetworkMonitor: ====================================")
# Sleep while WiFi is OFF
time.sleep(self.wifi_restart_duration)
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
# Turn WiFi back on after the wait period
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
Logger.info("NetworkMonitor: ====================================")
# Unblock WiFi using rfkill
result = subprocess.run(
['sudo', 'rfkill', 'unblock', 'wifi'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
else:
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
# Also bring interface up
result2 = subprocess.run(
['sudo', 'ifconfig', 'wlan0', 'up'],
capture_output=True,
text=True,
timeout=10
)
if result2.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
# Wait a bit for connection to establish
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
time.sleep(10)
# Try to restart DHCP
Logger.info("NetworkMonitor: Requesting IP address...")
subprocess.run(
['sudo', 'dhclient', 'wlan0'],
capture_output=True,
text=True,
timeout=15
)
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
Logger.info("NetworkMonitor: ====================================")
else:
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
except subprocess.TimeoutExpired:
Logger.error("NetworkMonitor: WiFi restart command timeout")
except Exception as e:
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
# Run in separate thread to not block the application
import threading
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
thread.start()

View File

@@ -256,7 +256,7 @@
id: play_pause_btn id: play_pause_btn
size_hint: None, None size_hint: None, None
size: dp(50), dp(50) size: dp(50), dp(50)
background_normal: root.resources_path + '/play.png' background_normal: root.resources_path + '/pause.png'
background_down: root.resources_path + '/pause.png' background_down: root.resources_path + '/pause.png'
border: (0, 0, 0, 0) border: (0, 0, 0, 0)
on_press: root.toggle_pause() on_press: root.toggle_pause()
@@ -493,9 +493,75 @@
write_tab: False write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Edit Feature Enable/Disable
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Enable Edit Feature:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
CheckBox:
id: edit_enabled_checkbox
size_hint_x: None
width: dp(40)
active: True
on_active: root.on_edit_feature_toggle(self.active)
Label:
text: '(Allow editing images on this player)'
size_hint_x: 0.4
font_size: sp(12)
text_size: self.size
halign: 'left'
valign: 'middle'
color: 0.7, 0.7, 0.7, 1
Widget: Widget:
size_hint_y: 0.05 size_hint_y: 0.05
# Reset Buttons Section
Label:
text: 'Reset Options:'
size_hint_y: None
height: dp(30)
text_size: self.size
halign: 'left'
valign: 'middle'
bold: True
font_size: sp(16)
# Reset Buttons Row
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(50)
spacing: dp(10)
Button:
id: reset_auth_btn
text: 'Reset Player Auth'
background_color: 0.8, 0.4, 0.2, 1
on_press: root.reset_player_auth()
Button:
id: reset_playlist_btn
text: 'Reset Playlist to v0'
background_color: 0.8, 0.4, 0.2, 1
on_press: root.reset_playlist_version()
Button:
id: restart_player_btn
text: 'Restart Player'
background_color: 0.2, 0.6, 0.8, 1
on_press: root.restart_player()
# Test Connection Button # Test Connection Button
Button: Button:
id: test_connection_btn id: test_connection_btn
@@ -519,36 +585,39 @@
Widget: Widget:
size_hint_y: 0.05 size_hint_y: 0.05
# Status information # Status information row
Label: BoxLayout:
id: playlist_info orientation: 'horizontal'
text: 'Playlist Version: N/A'
size_hint_y: None size_hint_y: None
height: dp(30) height: dp(30)
text_size: self.size spacing: dp(10)
halign: 'left'
valign: 'middle'
Label: Label:
id: media_count_info id: playlist_info
text: 'Media Count: 0' text: 'Playlist: N/A'
size_hint_y: None text_size: self.size
height: dp(30) halign: 'center'
text_size: self.size valign: 'middle'
halign: 'left' font_size: sp(12)
valign: 'middle'
Label: Label:
id: status_info id: media_count_info
text: 'Status: Idle' text: 'Media: 0'
size_hint_y: None text_size: self.size
height: dp(30) halign: 'center'
text_size: self.size valign: 'middle'
halign: 'left' font_size: sp(12)
valign: 'middle'
Label:
id: status_info
text: 'Status: Idle'
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(12)
Widget: Widget:
size_hint_y: 0.2 size_hint_y: 0.05
# Action buttons # Action buttons
BoxLayout: BoxLayout:

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Test script for network monitor functionality
"""
import sys
import time
from kivy.app import App
from kivy.clock import Clock
from network_monitor import NetworkMonitor
class TestMonitorApp(App):
"""Minimal Kivy app to test network monitor"""
def build(self):
"""Build the app"""
from kivy.uix.label import Label
return Label(text='Network Monitor Test Running\nCheck terminal for output')
def on_start(self):
"""Start monitoring when app starts"""
server_url = "https://digi-signage.moto-adv.com"
print("=" * 60)
print("Network Monitor Test")
print("=" * 60)
print()
print(f"Server URL: {server_url}")
print("Check interval: 0.5 minutes (30 seconds for testing)")
print("WiFi restart duration: 1 minute (for testing)")
print()
# Create monitor with short intervals for testing
self.monitor = NetworkMonitor(
server_url=server_url,
check_interval_min=0.5, # 30 seconds
check_interval_max=0.5, # 30 seconds
wifi_restart_duration=1 # 1 minute
)
# Perform immediate test
print("Performing immediate connectivity test...")
self.monitor._check_connectivity()
# Start monitoring for future checks
print("\nStarting periodic network monitoring...")
self.monitor.start_monitoring()
print("\nMonitoring is active. Press Ctrl+C to stop.")
print("Next check will occur in ~30 seconds.")
print()
def on_stop(self):
"""Stop monitoring when app stops"""
if hasattr(self, 'monitor'):
self.monitor.stop_monitoring()
print("\nNetwork monitoring stopped")
print("Test completed!")
if __name__ == '__main__':
TestMonitorApp().run()

View File

@@ -0,0 +1,170 @@
# Card Reader Fix - Multi-USB Device Support
## Problem Description
When a USB touchscreen was connected to the Raspberry Pi, the card reader authentication was not working. The system reported "no authentication was received" even though the card reader was physically connected on a different USB port.
### Root Cause
The original `find_card_reader()` function used overly broad matching criteria:
1. It would select the **first** device with "keyboard" in its name
2. USB touchscreens often register as HID keyboard devices (for touch input)
3. The touchscreen would be detected first, blocking the actual card reader
4. No exclusion logic existed to filter out touch devices
## Solution
The fix implements a **priority-based device selection** with **exclusion filters**:
### 1. Device Exclusion List
Devices containing these keywords are now skipped:
- `touch`, `touchscreen`
- `mouse`, `mice`
- `trackpad`, `touchpad`
- `pen`, `stylus`
- `video`, `button`, `lid`
### 2. Three-Priority Device Search
**Priority 1: Explicit Card Readers**
- Devices with "card", "reader", "rfid", or "hid" in their name
- Must have keyboard capabilities (EV_KEY)
- Excludes any device matching exclusion keywords
**Priority 2: USB Keyboards**
- Devices with both "usb" AND "keyboard" in their name
- Card readers typically appear as "USB Keyboard" or similar
- Excludes touch devices and other non-card peripherals
**Priority 3: Fallback to Any Keyboard**
- Any keyboard device not in the exclusion list
- Used only if no card reader or USB keyboard is found
### 3. Enhanced Logging
The system now logs:
- All detected input devices at startup
- Which devices are being skipped and why
- Which device is ultimately selected as the card reader
## Testing
### Using the Test Script
Run the enhanced test script to identify your card reader:
```bash
cd /home/pi/Desktop/Kiwy-Signage/working_files
python3 test_card_reader.py
```
The script will:
1. List all input devices with helpful indicators:
- `** LIKELY CARD READER **` - devices with "card" or "reader" in name
- `(Excluded: ...)` - devices that will be skipped
- `(USB Keyboard - could be card reader)` - potential card readers
2. Auto-detect the card reader using the same logic as the main app
3. Allow manual selection by device number if auto-detection is wrong
### Example Output
```
=== Available Input Devices ===
[0] /dev/input/event0
Name: USB Touchscreen Controller
Phys: usb-0000:01:00.0-1.1/input0
Type: Keyboard/HID Input Device
(Excluded: appears to be touch/mouse/other non-card device)
[1] /dev/input/event1
Name: HID 08ff:0009
Phys: usb-0000:01:00.0-1.2/input0
Type: Keyboard/HID Input Device
** LIKELY CARD READER **
[2] /dev/input/event2
Name: Logitech USB Keyboard
Phys: usb-0000:01:00.0-1.3/input0
Type: Keyboard/HID Input Device
(USB Keyboard - could be card reader)
```
### Verifying the Fix
1. **Check Logs**: When the main app starts, check the logs for device detection:
```bash
tail -f /path/to/logfile
```
Look for messages like:
```
CardReader: Scanning input devices...
CardReader: Skipping excluded device: USB Touchscreen Controller
CardReader: Found card reader: HID 08ff:0009 at /dev/input/event1
```
2. **Test Card Swipe**:
- Start the signage player
- Click the edit button (pencil icon)
- Swipe a card
- Should successfully authenticate
3. **Multiple USB Devices**: Test with various USB configurations:
- Touchscreen + card reader
- Mouse + keyboard + card reader
- Multiple USB hubs
## Configuration
### If Auto-Detection Fails
If the automatic detection still selects the wrong device, you can:
1. **Check device names**: Run `test_card_reader.py` to see all devices
2. **Identify your card reader**: Note the exact name of your card reader
3. **Add custom exclusions**: If needed, add more keywords to the exclusion list
4. **Manual override**: Modify the priority logic to match your specific hardware
### Permissions
Ensure the user running the app has permission to access input devices:
```bash
# Add user to input group
sudo usermod -a -G input $USER
# Logout and login again for changes to take effect
```
## Files Modified
1. **src/main.py**
- Updated `CardReader.find_card_reader()` method
- Added exclusion keyword list
- Implemented priority-based search
- Enhanced logging
2. **working_files/test_card_reader.py**
- Updated `list_input_devices()` to show device classifications
- Updated `test_card_reader()` to use same logic as main app
- Added visual indicators for device types
## Compatibility
This fix is backward compatible:
- Works with single-device setups (no touchscreen)
- Works with multiple USB devices
- Fallback behavior unchanged for systems without card readers
- No changes to card data format or server communication
## Future Enhancements
Potential improvements for specific use cases:
1. **Configuration file**: Allow specifying device path or name pattern
2. **Device caching**: Remember the working device path to avoid re-scanning
3. **Hot-plug support**: Detect when card reader is plugged in after app starts
4. **Multi-reader support**: Support for multiple card readers simultaneously

View File

@@ -12,13 +12,32 @@ def list_input_devices():
print("\n=== Available Input Devices ===") print("\n=== Available Input Devices ===")
devices = [evdev.InputDevice(path) for path in evdev.list_devices()] devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
# Exclusion keywords that help identify non-card-reader devices
exclusion_keywords = [
'touch', 'touchscreen', 'mouse', 'mice', 'trackpad',
'touchpad', 'pen', 'stylus', 'video', 'button', 'lid'
]
for i, device in enumerate(devices): for i, device in enumerate(devices):
device_name_lower = device.name.lower()
is_excluded = any(keyword in device_name_lower for keyword in exclusion_keywords)
is_likely_card = 'card' in device_name_lower or 'reader' in device_name_lower or 'rfid' in device_name_lower
print(f"\n[{i}] {device.path}") print(f"\n[{i}] {device.path}")
print(f" Name: {device.name}") print(f" Name: {device.name}")
print(f" Phys: {device.phys}") print(f" Phys: {device.phys}")
capabilities = device.capabilities() capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities: if ecodes.EV_KEY in capabilities:
print(f" Type: Keyboard/HID Input Device") print(f" Type: Keyboard/HID Input Device")
# Add helpful hints
if is_likely_card:
print(f" ** LIKELY CARD READER **")
elif is_excluded:
print(f" (Excluded: appears to be touch/mouse/other non-card device)")
elif 'usb' in device_name_lower and 'keyboard' in device_name_lower:
print(f" (USB Keyboard - could be card reader)")
return devices return devices
@@ -26,27 +45,56 @@ def test_card_reader(device_index=None):
"""Test reading from a card reader device""" """Test reading from a card reader device"""
devices = [evdev.InputDevice(path) for path in evdev.list_devices()] devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
# Exclusion keywords (same as in main app)
exclusion_keywords = [
'touch', 'touchscreen', 'mouse', 'mice', 'trackpad',
'touchpad', 'pen', 'stylus', 'video', 'button', 'lid'
]
if device_index is not None: if device_index is not None:
if device_index >= len(devices): if device_index >= len(devices):
print(f"Error: Device index {device_index} out of range") print(f"Error: Device index {device_index} out of range")
return return
device = devices[device_index] device = devices[device_index]
else: else:
# Try to find a card reader automatically # Try to find a card reader automatically using same logic as main app
device = None 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: # Priority 1: Explicit card readers
# Use first keyboard device for dev in devices:
for dev in devices: device_name_lower = dev.name.lower()
if any(keyword in device_name_lower for keyword in exclusion_keywords):
continue
if 'card' in device_name_lower or 'reader' in device_name_lower or 'rfid' in device_name_lower or 'hid' in device_name_lower:
capabilities = dev.capabilities() capabilities = dev.capabilities()
if ecodes.EV_KEY in capabilities: if ecodes.EV_KEY in capabilities:
device = dev device = dev
print(f"Using keyboard device: {dev.name}") print(f"Found card reader: {dev.name}")
break
# Priority 2: USB keyboards
if not device:
for dev in devices:
device_name_lower = dev.name.lower()
if any(keyword in device_name_lower for keyword in exclusion_keywords):
continue
if 'usb' in device_name_lower and 'keyboard' in device_name_lower:
capabilities = dev.capabilities()
if ecodes.EV_KEY in capabilities:
device = dev
print(f"Using USB keyboard as card reader: {dev.name}")
break
# Priority 3: Any non-excluded keyboard
if not device:
for dev in devices:
device_name_lower = dev.name.lower()
if any(keyword in device_name_lower for keyword in exclusion_keywords):
continue
capabilities = dev.capabilities()
if ecodes.EV_KEY in capabilities:
device = dev
print(f"Using keyboard device as card reader: {dev.name}")
break break
if not device: if not device: