Compare commits
8 Commits
af1e671c7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02e9ea1aaa | |||
|
|
4c3ddbef73 | ||
|
|
87e059e0f4 | ||
|
|
46d9fcf6e3 | ||
|
|
f1a84d05d5 | ||
|
|
706af95557 | ||
| 02227a12e5 | |||
| 9d32f43ac7 |
@@ -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
53
setup_wifi_control.sh
Normal 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
|
||||||
@@ -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}")
|
||||||
|
|||||||
435
src/main.py
435
src/main.py
@@ -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
235
src/network_monitor.py
Normal 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()
|
||||||
@@ -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:
|
||||||
|
|||||||
61
src/test_network_monitor.py
Normal file
61
src/test_network_monitor.py
Normal 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()
|
||||||
170
working_files/CARD_READER_FIX.md
Normal file
170
working_files/CARD_READER_FIX.md
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user