Compare commits

..

20 Commits

Author SHA1 Message Date
Kiwy Signage Player
1c02843687 removed chck point from saved media file 2025-12-14 19:07:44 +02:00
Kiwy Signage Player
1cc0eae542 Perf: Optimize playback and simplify playlist management
- Performance improvements:
  * Throttle drawing updates to 60fps (16ms intervals)
  * Optimize file I/O: use single os.stat() instead of exists+getsize
  * Reduce logger overhead: convert hot-path info logs to debug
  * Preload next media asynchronously for smoother transitions
  * Smart cache invalidation for edited images

- Simplify playlist management:
  * Remove versioning: single server_playlist.json file
  * Create nested directories for edited_media downloads
  * Recursively delete unused media and empty folders
  * Cleaner version tracking without file proliferation

- UI improvements:
  * Smoother intro-to-playlist transition
  * Fix edited media directory creation for nested paths
2025-12-14 16:57:47 +02:00
Kiwy Signage Player
b2d380511a Refactor: Move UI definitions to KV file and modularize edit popup
- Created src/edit_popup.py module for EditPopup and DrawingLayer classes
- Moved EditPopup UI definition to signage_player.kv (reduced main.py by 533 lines)
- Moved CardSwipePopup UI definition to signage_player.kv (reduced main.py by 41 lines)
- Improved code organization with better separation of concerns
- main.py reduced from 2,384 to 1,811 lines (24% reduction)
- All functionality preserved, no breaking changes
2025-12-14 14:48:35 +02:00
Kiwy Signage Player
db796e4d66 updated card check 2025-12-13 19:39:00 +02:00
Kiwy Signage Player
5843bb5215 renamed card checked 2025-12-13 19:09:06 +02:00
2b42999008 aded card check 2025-12-13 17:05:49 +00:00
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
af1e671c7f Add USB card reader authentication for edit feature
- Implemented CardReader class to read data from USB card readers
- Added CardSwipePopup with 5-second timeout and visual feedback
- Card data is captured and included in edit metadata
- Card data sent to server when edited images are uploaded
- Added evdev dependency for USB input device handling
- Fallback mode when evdev not available (for development)
- Created test utility (test_card_reader.py) for card reader testing
- Added comprehensive documentation (CARD_READER_AUTHENTICATION.md)
- Added access-card.png icon for authentication popup
- Edit interface requires card swipe or times out after 5 seconds
2025-12-08 14:05:04 +02:00
Kiwy Signage Player
9664ad541b moved files 2025-12-06 00:12:37 +02:00
Kiwy Signage Player
89e5ad86dd Add media editing features: WebP support, edit permissions, user auth, server upload
- Migrated to get_playlists_v2 with improved auth system
- Added WebP image format support for playback and editing
- Implemented edit_on_player permission check from server playlist
- Added user authentication layer for edit function (placeholder: player_1)
- Implemented versioned saving with metadata (user, timestamp, version)
- Added server upload functionality for edited media
- Fixed playlist update after intro video completion
- Added hostname and quickconnect_code to player feedback
- Improved error handling for upload failures (non-blocking)
2025-12-06 00:07:48 +02:00
Kiwy Signage Player
f573af0505 update 2025-12-05 00:36:38 +02:00
Kiwy Signage Player
fba2007bdf Improve edit interface: full-screen layout with toolbars, versioned saving to edited_media folder 2025-12-05 00:25:53 +02:00
Kiwy Signage Player
72d382b96b Add image editing feature with drawing tools
- Added pencil edit button to player controls
- Created EditPopup with drawing layer for image annotation
- Drawing tools: color selection (red/blue/green/black), thickness control
- Features: undo last stroke, clear all strokes, save edited image
- Playback automatically pauses during editing
- Only images (.jpg, .jpeg, .png, .bmp) can be edited
- Edited images saved with '_edited' suffix in same directory
- Drawing layer with touch support for annotations
- Full toolbar with color, thickness, and action controls
2025-12-04 22:40:05 +02:00
21 changed files with 2538 additions and 171 deletions

View File

@@ -1 +0,0 @@
1763799978.6257727

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.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
config/resources/pencil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

View File

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

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

531
src/edit_popup.py Normal file
View File

@@ -0,0 +1,531 @@
"""
Edit Popup Module
Handles image editing/annotation functionality for the signage player
"""
import os
import threading
from datetime import datetime
import json
import re
import shutil
import time
from kivy.uix.widget import Widget
from kivy.uix.popup import Popup
from kivy.uix.label import Label
from kivy.graphics import Color, Line, RoundedRectangle
from kivy.clock import Clock
from kivy.core.window import Window
from kivy.logger import Logger
from kivy.uix.video import Video
class DrawingLayer(Widget):
"""Layer for drawing on top of images"""
def __init__(self, reset_callback=None, **kwargs):
super(DrawingLayer, self).__init__(**kwargs)
self.strokes = [] # Store all drawn lines
self.current_color = (1, 0, 0, 1) # Default red
self.current_width = 3 # Default thickness
self.drawing_enabled = True # Drawing always enabled in edit mode
self.reset_callback = reset_callback # Callback to reset countdown timer
self._last_draw_time = 0 # For throttling touch updates
self._draw_throttle_interval = 0.016 # ~60fps (16ms between updates)
def on_touch_down(self, touch):
if not self.drawing_enabled or not self.collide_point(*touch.pos):
return False
# Reset countdown on user interaction
if self.reset_callback:
self.reset_callback()
with self.canvas:
Color(*self.current_color)
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
self.strokes.append({'line': new_line, 'color': self.current_color, 'width': self.current_width})
touch.ud['line'] = new_line
return True
def on_touch_move(self, touch):
if 'line' in touch.ud and self.drawing_enabled:
# Throttle updates to ~60fps for better performance
current_time = time.time()
if current_time - self._last_draw_time >= self._draw_throttle_interval:
touch.ud['line'].points += [touch.x, touch.y]
self._last_draw_time = current_time
return True
def undo(self):
"""Remove the last stroke"""
if self.strokes:
last_stroke = self.strokes.pop()
self.canvas.remove(last_stroke['line'])
Logger.info("DrawingLayer: Undid last stroke")
def clear_all(self):
"""Clear all strokes"""
for stroke in self.strokes:
self.canvas.remove(stroke['line'])
self.strokes = []
Logger.info("DrawingLayer: Cleared all strokes")
def set_color(self, color_tuple):
"""Set drawing color (RGBA)"""
self.current_color = color_tuple
def set_thickness(self, value):
"""Set line thickness"""
self.current_width = value
class EditPopup(Popup):
"""Popup for editing/annotating images"""
def __init__(self, player_instance, image_path, user_card_data=None, **kwargs):
super(EditPopup, self).__init__(**kwargs)
self.player = player_instance
self.image_path = image_path
self.user_card_data = user_card_data # Store card data to send to server on save
# Auto-close timer (5 minutes)
self.auto_close_timeout = 300 # 5 minutes in seconds
self.remaining_time = self.auto_close_timeout
self.countdown_event = None
self.auto_close_event = None
# Pause playback (without auto-resume timer)
self.was_paused = self.player.is_paused
if not self.was_paused:
self.player.is_paused = True
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):
self.player.current_widget.state = 'pause'
Logger.info("EditPopup: ⏸ Paused playback (no auto-resume) for editing")
# Show cursor
try:
Window.show_cursor = True
except:
pass
# Note: UI is now defined in KV file, but we need to customize after creation
# Set image source after KV loads
Clock.schedule_once(lambda dt: self._setup_after_kv(), 0)
def _setup_after_kv(self):
"""Setup widgets after KV file has loaded them"""
# Set the image source
self.ids.image_widget.source = self.image_path
# Create and insert drawing layer (custom class, must be added programmatically)
self.drawing_layer = DrawingLayer(
reset_callback=self.reset_countdown,
size_hint=(1, 1),
pos_hint={'x': 0, 'y': 0}
)
# Replace placeholder with actual drawing layer
content = self.content
placeholder_index = content.children.index(self.ids.drawing_layer_placeholder)
content.remove_widget(self.ids.drawing_layer_placeholder)
content.add_widget(self.drawing_layer, index=placeholder_index)
# Set icon sources
pen_icon_path = os.path.join(self.player.resources_path, 'edit-pen.png')
self.ids.color_icon.source = pen_icon_path
self.ids.thickness_icon.source = pen_icon_path
# Bind button callbacks
self.ids.undo_btn.bind(on_press=lambda x: (self.reset_countdown(), self.drawing_layer.undo()))
self.ids.clear_btn.bind(on_press=lambda x: (self.reset_countdown(), self.drawing_layer.clear_all()))
self.ids.save_btn.bind(on_press=self.save_image)
self.ids.cancel_btn.bind(on_press=self.close_without_saving)
# Bind color buttons
self.ids.red_btn.bind(on_press=lambda x: self.drawing_layer.set_color((1, 0, 0, 1)))
self.ids.blue_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 0, 1, 1)))
self.ids.green_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 1, 0, 1)))
self.ids.black_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 0, 0, 1)))
self.ids.white_btn.bind(on_press=lambda x: self.drawing_layer.set_color((1, 1, 1, 1)))
# Bind thickness buttons
self.ids.small_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(2))
self.ids.medium_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(5))
self.ids.large_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(10))
# Add rounded corners to buttons
for btn_id in ['undo_btn', 'clear_btn', 'save_btn', 'cancel_btn']:
btn = self.ids[btn_id]
btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
# Add circular corners to color/thickness buttons
for btn_id in ['red_btn', 'blue_btn', 'green_btn', 'black_btn', 'white_btn',
'small_btn', 'medium_btn', 'large_btn']:
btn = self.ids[btn_id]
btn.bind(pos=self._make_round, size=self._make_round)
# Reference to countdown label
self.countdown_label = self.ids.countdown_label
# Bind to dismiss
self.bind(on_dismiss=self.on_popup_dismiss)
# Start countdown timer (updates every second)
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
# Start auto-close timer (closes after 5 minutes)
self.auto_close_event = Clock.schedule_once(self.auto_close, self.auto_close_timeout)
Logger.info(f"EditPopup: Opened for image {os.path.basename(self.image_path)} (auto-close in 5 minutes)")
def update_countdown(self, dt):
"""Update countdown display"""
self.remaining_time -= 1
# Format time as MM:SS
minutes = self.remaining_time // 60
seconds = self.remaining_time % 60
self.countdown_label.text = f"{minutes}:{seconds:02d}"
# Change color as time runs out
if self.remaining_time <= 60: # Last minute - red
self.countdown_label.color = (1, 0.2, 0.2, 1)
elif self.remaining_time <= 120: # Last 2 minutes - yellow
self.countdown_label.color = (1, 1, 0, 1)
else:
self.countdown_label.color = (1, 1, 1, 1) # White
if self.remaining_time <= 0:
Clock.unschedule(self.countdown_event)
def reset_countdown(self):
"""Reset countdown timer on user interaction"""
self.remaining_time = self.auto_close_timeout
# Cancel existing timers
if self.countdown_event:
Clock.unschedule(self.countdown_event)
if self.auto_close_event:
Clock.unschedule(self.auto_close_event)
# Restart timers
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
self.auto_close_event = Clock.schedule_once(self.auto_close, self.auto_close_timeout)
# Reset color to white
self.countdown_label.color = (1, 1, 1, 1)
Logger.info("EditPopup: Countdown reset to 5:00")
def auto_close(self, dt):
"""Auto-close the edit popup after timeout"""
Logger.info("EditPopup: Auto-closing after 5 minutes of inactivity")
self.close_without_saving(None)
def _make_rounded_btn(self, instance, value):
"""Make toolbar button with slightly rounded corners"""
instance.canvas.before.clear()
with instance.canvas.before:
Color(*instance.background_color)
instance.round_rect = RoundedRectangle(
pos=instance.pos,
size=instance.size,
radius=[10]
)
def _make_round(self, instance, value):
"""Make sidebar button fully circular"""
instance.canvas.before.clear()
with instance.canvas.before:
Color(*instance.background_color)
instance.round_rect = RoundedRectangle(
pos=instance.pos,
size=instance.size,
radius=[instance.height / 2]
)
def save_image(self, instance):
"""Save the edited image"""
try:
# Create edited_media directory if it doesn't exist
edited_dir = os.path.join(self.player.base_dir, 'media', 'edited_media')
os.makedirs(edited_dir, exist_ok=True)
# Get original filename
base_name = os.path.basename(self.image_path)
name, ext = os.path.splitext(base_name)
# Determine version number
version_match = re.search(r'_e_v(\d+)$', name)
if version_match:
# Increment existing version
current_version = int(version_match.group(1))
new_version = current_version + 1
# Remove old version suffix
original_name = re.sub(r'_e_v\d+$', '', name)
new_name = f"{original_name}_e_v{new_version}"
else:
# First edit version
original_name = name
new_name = f"{name}_e_v1"
# Generate output path
output_filename = f"{new_name}.jpg"
output_path = os.path.join(edited_dir, output_filename)
# Temporarily hide toolbars
self.ids.top_toolbar.opacity = 0
self.ids.right_sidebar.opacity = 0
# Force canvas update
self.content.canvas.ask_update()
# Small delay to ensure rendering is complete
def do_export(dt):
try:
# Export only the visible content (image + drawings, no toolbars)
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}")
# 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
self.ids.top_toolbar.opacity = 1
self.ids.right_sidebar.opacity = 1
# Create and save metadata
json_filename = self._save_metadata(edited_dir, new_name, base_name,
new_version if version_match else 1, output_filename)
# Upload to server in background (continues after popup closes)
upload_thread = threading.Thread(
target=self._upload_to_server,
args=(output_path, json_filename),
daemon=True
)
upload_thread.start()
Logger.info(f"EditPopup: Background upload thread started")
# NOW show saving popup AFTER everything is done
def show_saving_and_dismiss(dt):
# 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:
Logger.error(f"EditPopup: Error in export: {e}")
import traceback
Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}")
self.title = f'Error saving: {e}'
# Restore toolbars
self.ids.top_toolbar.opacity = 1
self.ids.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)
return
except Exception as e:
Logger.error(f"EditPopup: Error saving image: {e}")
import traceback
Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}")
self.title = f'Error saving: {e}'
def _save_metadata(self, edited_dir, new_name, base_name, version, output_filename):
"""Save metadata JSON file"""
metadata = {
'time_of_modification': datetime.now().isoformat(),
'original_name': base_name,
'new_name': output_filename,
'original_path': self.image_path,
'version': version,
'user_card_data': self.user_card_data # Card data from reader (or None)
}
# Save metadata JSON
json_filename = f"{new_name}_metadata.json"
json_path = os.path.join(edited_dir, json_filename)
with open(json_path, 'w') as f:
json.dump(metadata, f, indent=2)
Logger.info(f"EditPopup: Saved metadata to {json_path} (user_card_data: {self.user_card_data})")
return json_path
def _upload_to_server(self, image_path, metadata_path):
"""Upload edited image and metadata to server (runs in background thread)"""
try:
import requests
from get_playlists_v2 import get_auth_instance
# Get authenticated instance
auth = get_auth_instance()
if not auth or not auth.is_authenticated():
Logger.warning("EditPopup: Cannot upload - not authenticated (server will not receive edited media)")
return False
server_url = auth.auth_data.get('server_url')
auth_code = auth.auth_data.get('auth_code')
if not server_url or not auth_code:
Logger.warning("EditPopup: Missing server URL or auth code (upload skipped)")
return False
# Load metadata from file
with open(metadata_path, 'r') as meta_file:
metadata = json.load(meta_file)
# Prepare upload URL
upload_url = f"{server_url}/api/player-edit-media"
headers = {'Authorization': f'Bearer {auth_code}'}
# Prepare file and data for upload
with open(image_path, 'rb') as img_file:
files = {
'image_file': (metadata['new_name'], img_file, 'image/jpeg')
}
# Send metadata as JSON string in form data
data = {
'metadata': json.dumps(metadata)
}
Logger.info(f"EditPopup: Uploading edited media to {upload_url}")
response = requests.post(upload_url, headers=headers, files=files, data=data, timeout=30)
if response.status_code == 200:
response_data = response.json()
Logger.info(f"EditPopup: ✅ Successfully uploaded edited media to server: {response_data}")
# Delete local files after successful upload
try:
if os.path.exists(image_path):
os.remove(image_path)
Logger.info(f"EditPopup: Deleted local image file: {os.path.basename(image_path)}")
if os.path.exists(metadata_path):
os.remove(metadata_path)
Logger.info(f"EditPopup: Deleted local metadata file: {os.path.basename(metadata_path)}")
Logger.info("EditPopup: ✅ Local edited files cleaned up after successful upload")
except Exception as e:
Logger.warning(f"EditPopup: Could not delete local files: {e}")
return True
elif response.status_code == 404:
Logger.warning("EditPopup: ⚠️ Upload endpoint not available on server (404) - edited media saved locally only")
return False
else:
Logger.warning(f"EditPopup: ⚠️ Upload failed with status {response.status_code} - edited media saved locally only")
return False
except requests.exceptions.Timeout:
Logger.warning("EditPopup: ⚠️ Upload timed out - edited media saved locally only")
return False
except requests.exceptions.ConnectionError:
Logger.warning("EditPopup: ⚠️ Cannot connect to server - edited media saved locally only")
return False
except Exception as e:
Logger.warning(f"EditPopup: ⚠️ Upload failed: {e} - edited media saved locally only")
import traceback
Logger.debug(f"EditPopup: Upload traceback: {traceback.format_exc()}")
return False
def close_without_saving(self, instance):
"""Close without saving"""
Logger.info("EditPopup: Closed without saving")
self.dismiss()
def on_popup_dismiss(self, *args):
"""Resume playback when popup closes - reload current image and continue"""
# Cancel countdown and auto-close timers
if self.countdown_event:
Clock.unschedule(self.countdown_event)
if self.auto_close_event:
Clock.unschedule(self.auto_close_event)
# 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:
self.player.is_paused = False
# 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
self.player.schedule_hide_controls()

View File

@@ -188,10 +188,9 @@ def fetch_server_playlist(config):
return {'playlist': [], 'version': 0} return {'playlist': [], 'version': 0}
def save_playlist_with_version(playlist_data, playlist_dir): def save_playlist(playlist_data, playlist_dir):
"""Save playlist to file with version number.""" """Save playlist to a single file (no versioning)."""
version = playlist_data.get('version', 0) playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
# Ensure directory exists # Ensure directory exists
os.makedirs(playlist_dir, exist_ok=True) os.makedirs(playlist_dir, exist_ok=True)
@@ -222,6 +221,9 @@ def download_media_files(playlist, media_dir):
logger.info(f"✓ File {file_name} already exists. Skipping download.") logger.info(f"✓ File {file_name} already exists. Skipping download.")
else: else:
try: try:
# Create parent directories if they don't exist (for nested paths like edited_media/5/)
os.makedirs(os.path.dirname(local_path), exist_ok=True)
response = requests.get(file_url, timeout=30) response = requests.get(file_url, timeout=30)
if response.status_code == 200: if response.status_code == 200:
with open(local_path, 'wb') as file: with open(local_path, 'wb') as file:
@@ -237,44 +239,67 @@ def download_media_files(playlist, media_dir):
updated_media = { updated_media = {
'file_name': file_name, 'file_name': file_name,
'url': os.path.relpath(local_path, os.path.dirname(media_dir)), 'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
'duration': duration 'duration': duration,
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
} }
updated_playlist.append(updated_media) updated_playlist.append(updated_media)
return updated_playlist return updated_playlist
def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, keep_versions=1): def delete_unused_media(playlist_data, media_dir):
"""Delete old playlist files and media files not referenced by the latest playlist version.""" """Delete media files not referenced in the current playlist."""
try: try:
# Find all playlist files # Get list of media files referenced in current playlist
playlist_files = [f for f in os.listdir(playlist_dir) referenced_files = set()
if f.startswith('server_playlist_v') and f.endswith('.json')] for media in playlist_data.get('playlist', []):
file_name = media.get('file_name', '')
if file_name:
referenced_files.add(file_name)
# Extract versions and sort logger.info(f"📋 Current playlist references {len(referenced_files)} files")
versions = []
for f in playlist_files: if os.path.exists(media_dir):
try: # Recursively get all media files
version = int(f.replace('server_playlist_v', '').replace('.json', '')) deleted_count = 0
versions.append((version, f)) for root, dirs, files in os.walk(media_dir):
except ValueError: for media_file in files:
# Get relative path from media_dir
full_path = os.path.join(root, media_file)
rel_path = os.path.relpath(full_path, media_dir)
# Skip if file is in current playlist
if rel_path in referenced_files:
continue continue
versions.sort(reverse=True) # Delete unreferenced file
try:
os.remove(full_path)
logger.info(f"🗑️ Deleted unused media: {rel_path}")
deleted_count += 1
except Exception as e:
logger.warning(f"⚠️ Could not delete {rel_path}: {e}")
# Keep only the latest N versions # Clean up empty directories
files_to_delete = [f for v, f in versions[keep_versions:]] for root, dirs, files in os.walk(media_dir, topdown=False):
for dir_name in dirs:
dir_path = os.path.join(root, dir_name)
try:
if not os.listdir(dir_path): # If directory is empty
os.rmdir(dir_path)
logger.debug(f"🗑️ Removed empty directory: {os.path.relpath(dir_path, media_dir)}")
except Exception:
pass
for f in files_to_delete: if deleted_count > 0:
filepath = os.path.join(playlist_dir, f) logger.info(f"✅ Deleted {deleted_count} unused media files")
os.remove(filepath) else:
logger.info(f"🗑️ Deleted old playlist: {f}") logger.info("✅ No unused media files to delete")
# TODO: Clean up unused media files
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest versions)")
except Exception as e: except Exception as e:
logger.error(f"❌ Error during cleanup: {e}") logger.error(f"❌ Error during media cleanup: {e}")
def update_playlist_if_needed(config, playlist_dir, media_dir): def update_playlist_if_needed(config, playlist_dir, media_dir):
@@ -288,22 +313,17 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
logger.warning("⚠️ No valid playlist received from server") logger.warning("⚠️ No valid playlist received from server")
return None return None
# Check local version # Check local version from single playlist file
local_version = 0 local_version = 0
local_playlist_file = None playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
if os.path.exists(playlist_dir): if os.path.exists(playlist_file):
playlist_files = [f for f in os.listdir(playlist_dir)
if f.startswith('server_playlist_v') and f.endswith('.json')]
for f in playlist_files:
try: try:
version = int(f.replace('server_playlist_v', '').replace('.json', '')) with open(playlist_file, 'r') as f:
if version > local_version: local_data = json.load(f)
local_version = version local_version = local_data.get('version', 0)
local_playlist_file = os.path.join(playlist_dir, f) except Exception as e:
except ValueError: logger.warning(f"⚠️ Could not read local playlist: {e}")
continue
logger.info(f"📊 Playlist versions - Server: v{server_version}, Local: v{local_version}") logger.info(f"📊 Playlist versions - Server: v{server_version}, Local: v{local_version}")
@@ -315,17 +335,17 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
updated_playlist = download_media_files(server_data['playlist'], media_dir) updated_playlist = download_media_files(server_data['playlist'], media_dir)
server_data['playlist'] = updated_playlist server_data['playlist'] = updated_playlist
# Save new playlist # Save new playlist (single file, no versioning)
playlist_file = save_playlist_with_version(server_data, playlist_dir) playlist_file = save_playlist(server_data, playlist_dir)
# Clean up old versions # Delete unused media files
delete_old_playlists_and_media(server_version, playlist_dir, media_dir) delete_unused_media(server_data, media_dir)
logger.info(f"✅ Playlist updated successfully to v{server_version}") logger.info(f"✅ Playlist updated successfully to v{server_version}")
return playlist_file return playlist_file
else: else:
logger.info("✓ Playlist is up to date") logger.info("✓ Playlist is up to date")
return local_playlist_file return playlist_file
except Exception as e: except Exception as e:
logger.error(f"❌ Error updating playlist: {e}") logger.error(f"❌ Error updating playlist: {e}")

View File

@@ -32,25 +32,318 @@ from kivy.uix.popup import Popup
from kivy.uix.textinput import TextInput from kivy.uix.textinput import TextInput
from kivy.uix.vkeyboard import VKeyboard from kivy.uix.vkeyboard import VKeyboard
from kivy.clock import Clock from kivy.clock import Clock
from kivy.loader import Loader
from kivy.core.window import Window from kivy.core.window import Window
from kivy.properties import BooleanProperty from kivy.properties import BooleanProperty
from kivy.logger import Logger from kivy.logger import Logger
# Try to import evdev for card reader support (after Logger is available)
try:
import evdev
from evdev import InputDevice, categorize, ecodes
EVDEV_AVAILABLE = True
Logger.info("CardReader: evdev library loaded successfully")
except ImportError:
EVDEV_AVAILABLE = False
Logger.warning("CardReader: evdev not available - card reader functionality will use fallback mode")
from kivy.animation import Animation from kivy.animation import Animation
from kivy.lang import Builder from kivy.lang import Builder
from kivy.graphics import Rectangle from kivy.graphics import Rectangle
from kivy.graphics.texture import Texture from kivy.graphics.texture import Texture
from get_playlists import ( from get_playlists_v2 import (
update_playlist_if_needed, update_playlist_if_needed,
send_playing_status_feedback, send_playing_status_feedback,
send_playlist_restart_feedback, send_playlist_restart_feedback,
send_player_error_feedback send_player_error_feedback
) )
from keyboard_widget import KeyboardWidget from keyboard_widget import KeyboardWidget
from network_monitor import NetworkMonitor
from edit_popup import DrawingLayer, EditPopup
from kivy.graphics import Color, Line, Ellipse
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.slider import Slider
# Load the KV file # Load the KV file
Builder.load_file('signage_player.kv') Builder.load_file('signage_player.kv')
# Removed VLCVideoWidget - using Kivy's built-in Video widget instead
class CardReader:
"""USB Card Reader Handler for user authentication"""
def __init__(self):
self.device = None
self.card_data = ""
self.reading = False
self.last_read_time = 0
self.read_timeout = 5 # seconds
self.evdev_available = EVDEV_AVAILABLE
def find_card_reader(self):
"""Find USB card reader device"""
if not self.evdev_available:
Logger.error("CardReader: evdev library not available")
return False
try:
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
# Log all available devices for debugging
Logger.info("CardReader: Scanning input 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()
if ecodes.EV_KEY in capabilities:
Logger.info(f"CardReader: Using keyboard device as card reader: {device.name} at {device.path}")
self.device = device
return True
Logger.warning("CardReader: No suitable input device found after checking all devices")
return False
except Exception as e:
Logger.error(f"CardReader: Error finding device: {e}")
return False
def read_card_async(self, callback):
"""Start reading card data asynchronously"""
if not self.evdev_available:
Logger.warning("CardReader: evdev not available - waiting for manual timeout/cancel")
# Fallback: In development mode without evdev, don't auto-authenticate
# Let the popup timeout or user cancel manually
# This allows testing the popup behavior
# To enable auto-authentication for testing, uncomment the next line:
# Clock.schedule_once(lambda dt: callback("DEFAULT_USER_12345"), 0.5)
return
if not self.device:
if not self.find_card_reader():
Logger.error("CardReader: Cannot start reading - no device found")
Clock.schedule_once(lambda dt: callback(None), 0)
return
# Reset state completely before starting new read
self.reading = True
self.card_data = "" # Clear any previous data
self.last_read_time = time.time()
Logger.info(f"CardReader: Starting fresh card read (cleared previous data)")
# Start reading in a separate thread
thread = threading.Thread(target=self._read_card_thread, args=(callback,))
thread.daemon = True
thread.start()
def _read_card_thread(self, callback):
"""Thread function to read card data"""
callback_fired = False # Prevent duplicate callbacks
try:
Logger.info("CardReader: Waiting for card swipe...")
for event in self.device.read_loop():
if not self.reading:
Logger.info("CardReader: Reading stopped externally")
break
# Check for timeout
if time.time() - self.last_read_time > self.read_timeout:
Logger.warning("CardReader: Read timeout")
self.reading = False
if not callback_fired:
callback_fired = True
Clock.schedule_once(lambda dt: callback(None), 0)
break
if event.type == ecodes.EV_KEY:
key_event = categorize(event)
if key_event.keystate == 1: # Key down
key_code = key_event.keycode
# Handle Enter key (card read complete)
if key_code == 'KEY_ENTER':
final_card_data = self.card_data.strip()
Logger.info(f"CardReader: Card read complete: '{final_card_data}' (length: {len(final_card_data)})")
self.reading = False
if not callback_fired:
callback_fired = True
if final_card_data: # Have valid data
# Capture card_data now before it can be modified
Clock.schedule_once(lambda dt, data=final_card_data: callback(data), 0)
else: # Empty data
Logger.warning("CardReader: No data collected, sending None")
Clock.schedule_once(lambda dt: callback(None), 0)
break
# Build card data string
elif key_code.startswith('KEY_'):
char = key_code.replace('KEY_', '')
if len(char) == 1: # Single character
self.card_data += char
self.last_read_time = time.time()
except Exception as e:
Logger.error(f"CardReader: Error reading card: {e}")
self.reading = False
if not callback_fired:
callback_fired = True
Clock.schedule_once(lambda dt: callback(None), 0)
def stop_reading(self):
"""Stop reading card data"""
self.reading = False
class CardSwipePopup(Popup):
"""Popup that shows card swipe prompt with 5 second timeout"""
def __init__(self, callback, resources_path, **kwargs):
super(CardSwipePopup, self).__init__(**kwargs)
self.callback = callback
self.resources_path = resources_path
self.timeout_event = None
self.finished = False # Prevent duplicate finish calls
self.received_card_data = None # Store the card data safely
# Set icon source after KV loads
Clock.schedule_once(lambda dt: self._setup_after_kv(), 0)
def _setup_after_kv(self):
"""Setup widgets after KV file has loaded them"""
# Set card icon source
icon_path = os.path.join(self.resources_path, 'access-card.png')
self.ids.icon_image.source = icon_path
# Start countdown
self.remaining_time = 5
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
# Schedule timeout
self.timeout_event = Clock.schedule_once(self.on_timeout, 5)
def update_countdown(self, dt):
"""Update countdown display"""
self.remaining_time -= 1
self.ids.countdown_label.text = str(self.remaining_time)
if self.remaining_time <= 0:
Clock.unschedule(self.countdown_event)
def on_timeout(self, dt):
"""Called when timeout occurs"""
Logger.warning("CardSwipePopup: Timeout - no card swiped")
self.ids.message_label.text = 'Timeout - No card detected'
Clock.schedule_once(lambda dt: self.finish(None), 0.5)
def card_received(self, card_data):
"""Called when card data is received"""
if self.finished:
Logger.warning(f"CardSwipePopup: Ignoring duplicate card_received call (already finished)")
return
Logger.info(f"CardSwipePopup: Card received: '{card_data}' (length: {len(card_data) if card_data else 0})")
# Validate card data
if not card_data or len(card_data) < 3:
Logger.warning(f"CardSwipePopup: Invalid card data received (too short), ignoring")
return
# Store card data to prevent race conditions
self.received_card_data = card_data
# Stop timeout to prevent duplicate finish
if self.timeout_event:
Clock.unschedule(self.timeout_event)
self.timeout_event = None
# Change icon to card-checked.png
checked_icon_path = os.path.join(self.resources_path, 'card-checked.png')
Logger.info(f"CardSwipePopup: Loading checked icon from {checked_icon_path}")
# Verify file exists
if os.path.exists(checked_icon_path):
self.ids.icon_image.source = checked_icon_path
self.ids.icon_image.reload() # Force reload the image
Logger.info("CardSwipePopup: Card-checked icon loaded")
else:
Logger.warning(f"CardSwipePopup: card-checked.png not found at {checked_icon_path}")
self.ids.message_label.text = 'Card detected'
self.ids.countdown_label.opacity = 0 # Hide countdown
# Increase delay to 1 second to give time for image to display
Clock.schedule_once(lambda dt: self.finish(self.received_card_data), 1.0)
def cancel(self, instance):
"""Cancel button pressed"""
Logger.info("CardSwipePopup: Cancelled by user")
self.finish(None)
def finish(self, card_data):
"""Clean up and call callback"""
if self.finished:
Logger.warning("CardSwipePopup: finish() called multiple times, ignoring duplicate")
return
self.finished = True
Logger.info(f"CardSwipePopup: Finishing with card_data: '{card_data}'")
# Cancel scheduled events
if self.timeout_event:
Clock.unschedule(self.timeout_event)
if self.countdown_event:
Clock.unschedule(self.countdown_event)
# Dismiss popup
self.dismiss()
# Call callback with result
if self.callback:
Logger.info(f"CardSwipePopup: Calling callback with card_data: '{card_data}'")
self.callback(card_data)
# Custom keyboard container with close button # Custom keyboard container with close button
class KeyboardContainer(BoxLayout): class KeyboardContainer(BoxLayout):
@@ -313,10 +606,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
@@ -353,6 +647,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
@@ -450,6 +750,86 @@ 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')
playlist_file = os.path.join(playlists_dir, 'server_playlist.json')
if os.path.exists(playlist_file):
# Delete the playlist file to force re-download
os.remove(playlist_file)
Logger.info(f"SettingsPopup: Deleted playlist file - will resync from server")
# Reset 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 - 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
@@ -459,6 +839,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()
@@ -492,11 +873,17 @@ 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
self.max_consecutive_errors = 10 # Maximum errors before stopping self.max_consecutive_errors = 10 # Maximum errors before stopping
self.intro_played = False # Track if intro has been played self.intro_played = False # Track if intro has been played
# Card reader for authentication
self.card_reader = 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')
@@ -543,6 +930,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()
@@ -584,6 +974,30 @@ class SignagePlayer(Widget):
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"""
Logger.info("SignagePlayer: Starting async playlist update loop") Logger.info("SignagePlayer: Starting async playlist update loop")
@@ -594,20 +1008,14 @@ class SignagePlayer(Widget):
# Run blocking network I/O in thread pool # Run blocking network I/O in thread pool
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Find latest playlist file
latest_playlist = await loop.run_in_executor(
None,
self.get_latest_playlist_file
)
# Check for updates (network I/O in thread pool) # Check for updates (network I/O in thread pool)
# Note: get_playlists_v2 doesn't need latest_playlist parameter
updated = await loop.run_in_executor( updated = await loop.run_in_executor(
None, None,
update_playlist_if_needed, update_playlist_if_needed,
latest_playlist,
self.config, self.config,
self.media_dir, self.playlists_dir,
self.playlists_dir self.media_dir
) )
if updated: if updated:
@@ -632,22 +1040,8 @@ class SignagePlayer(Widget):
Logger.debug(f"SignagePlayer: Error sending async feedback: {e}") Logger.debug(f"SignagePlayer: Error sending async feedback: {e}")
def get_latest_playlist_file(self): def get_latest_playlist_file(self):
"""Get the path to the latest playlist file""" """Get the path to the playlist file"""
try: return os.path.join(self.playlists_dir, 'server_playlist.json')
if os.path.exists(self.playlists_dir):
playlist_files = [f for f in os.listdir(self.playlists_dir)
if f.startswith('server_playlist_v') and f.endswith('.json')]
if playlist_files:
# Sort by version number and get the latest
versions = [(int(f.split('_v')[-1].split('.json')[0]), f) for f in playlist_files]
versions.sort(reverse=True)
latest_file = versions[0][1]
return os.path.join(self.playlists_dir, latest_file)
return os.path.join(self.playlists_dir, 'server_playlist_v0.json')
except Exception as e:
Logger.error(f"SignagePlayer: Error getting latest playlist: {e}")
return os.path.join(self.playlists_dir, 'server_playlist_v0.json')
def load_playlist(self, dt=None): def load_playlist(self, dt=None):
"""Load playlist from file""" """Load playlist from file"""
@@ -665,7 +1059,8 @@ class SignagePlayer(Widget):
if self.playlist: if self.playlist:
self.ids.status_label.text = f"Playlist loaded: {len(self.playlist)} items" self.ids.status_label.text = f"Playlist loaded: {len(self.playlist)} items"
if not self.is_playing: # Only start playback if intro has finished
if not self.is_playing and self.intro_played:
Clock.schedule_once(self.start_playback, 1) Clock.schedule_once(self.start_playback, 1)
else: else:
self.ids.status_label.text = "No media in playlist" self.ids.status_label.text = "No media in playlist"
@@ -698,24 +1093,23 @@ class SignagePlayer(Widget):
source=intro_path, source=intro_path,
state='play', state='play',
options={'eos': 'stop'}, options={'eos': 'stop'},
allow_stretch=True, size_hint=(1, 1),
keep_ratio=True, pos_hint={'center_x': 0.5, 'center_y': 0.5}
size=self.size,
pos=(0, 0)
) )
# Bind to video end event # Bind to video end event
def on_intro_end(instance, value): def on_intro_end(instance, value):
if value == 'stop': if value == 'stop':
Logger.info("SignagePlayer: Intro video finished") Logger.info("SignagePlayer: Intro video finished")
# Mark intro as played before removing video
self.intro_played = True
# Remove intro video # Remove intro video
if intro_video in self.ids.content_area.children: if intro_video in self.ids.content_area.children:
self.ids.content_area.remove_widget(intro_video) self.ids.content_area.remove_widget(intro_video)
# Mark intro as played # Start normal playlist immediately to reduce white screen
self.intro_played = True
# Start normal playlist
self.check_playlist_and_play(None) self.check_playlist_and_play(None)
intro_video.bind(state=on_intro_end) intro_video.bind(state=on_intro_end)
@@ -752,8 +1146,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()
@@ -764,23 +1167,22 @@ class SignagePlayer(Widget):
file_name = media_item.get('file_name', '') file_name = media_item.get('file_name', '')
duration = media_item.get('duration', 10) duration = media_item.get('duration', 10)
Logger.info(f"SignagePlayer: ===== Playing item {self.current_index + 1}/{len(self.playlist)} =====") Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
Logger.info(f"SignagePlayer: File: {file_name}")
Logger.info(f"SignagePlayer: Duration: {duration}s")
# Construct full path to media file # Construct full path to media file
media_path = os.path.join(self.media_dir, file_name) media_path = os.path.join(self.media_dir, file_name)
Logger.info(f"SignagePlayer: Full path: {media_path}")
if not os.path.exists(media_path): # Use single os.stat() call to check existence and get size (more efficient)
try:
file_stat = os.stat(media_path)
Logger.debug(f"SignagePlayer: File size: {file_stat.st_size:,} bytes")
except FileNotFoundError:
Logger.error(f"SignagePlayer: ❌ Media file not found: {media_path}") Logger.error(f"SignagePlayer: ❌ Media file not found: {media_path}")
Logger.error(f"SignagePlayer: Skipping to next media...") Logger.error(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1 self.consecutive_errors += 1
self.next_media() self.next_media()
return return
Logger.info(f"SignagePlayer: ✓ File exists (size: {os.path.getsize(media_path):,} bytes)")
# Remove status label if showing # Remove status label if showing
self.ids.status_label.opacity = 0 self.ids.status_label.opacity = 0
@@ -789,7 +1191,7 @@ class SignagePlayer(Widget):
# Properly stop video if it's playing to prevent resource leaks # Properly stop video if it's playing to prevent resource leaks
if isinstance(self.current_widget, Video): if isinstance(self.current_widget, Video):
try: try:
Logger.info(f"SignagePlayer: Stopping previous video widget...") Logger.debug(f"SignagePlayer: Stopping previous video widget...")
self.current_widget.state = 'stop' self.current_widget.state = 'stop'
self.current_widget.unload() self.current_widget.unload()
except Exception as e: except Exception as e:
@@ -797,23 +1199,22 @@ class SignagePlayer(Widget):
self.ids.content_area.remove_widget(self.current_widget) self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None self.current_widget = None
Logger.info(f"SignagePlayer: Previous widget removed") Logger.debug(f"SignagePlayer: Previous widget removed")
# Determine media type and create appropriate widget # Determine media type and create appropriate widget
file_extension = os.path.splitext(file_name)[1].lower() file_extension = os.path.splitext(file_name)[1].lower()
Logger.info(f"SignagePlayer: Extension: {file_extension}")
if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']: if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']:
# Video file # Video file
Logger.info(f"SignagePlayer: Media type: VIDEO") Logger.debug(f"SignagePlayer: Media type: VIDEO")
self.play_video(media_path, duration) self.play_video(media_path, duration)
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif']: elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
# Image file # Image file
Logger.info(f"SignagePlayer: Media type: IMAGE") Logger.debug(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") Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
Logger.warning(f"SignagePlayer: Skipping to next media...") Logger.warning(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1 self.consecutive_errors += 1
self.next_media() self.next_media()
@@ -832,7 +1233,7 @@ class SignagePlayer(Widget):
# Reset error counter on successful playback # Reset error counter on successful playback
self.consecutive_errors = 0 self.consecutive_errors = 0
Logger.info(f"SignagePlayer: Media started successfully (consecutive_errors reset to 0)") Logger.debug(f"SignagePlayer: Media started successfully")
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error playing media: {e}") Logger.error(f"SignagePlayer: Error playing media: {e}")
@@ -859,19 +1260,15 @@ class SignagePlayer(Widget):
self.next_media() self.next_media()
return return
Logger.info(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s") Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
Logger.info(f"SignagePlayer: Video provider: {os.environ.get('KIVY_VIDEO', 'default')}")
# Create Video widget with optimized settings # Create Video widget with optimized settings
Logger.info(f"SignagePlayer: Creating Video widget...")
self.current_widget = Video( self.current_widget = Video(
source=video_path, source=video_path,
state='play', # Start playing immediately state='play', # Start playing immediately
options={ options={
'eos': 'stop', # Stop at end of stream 'eos': 'stop', # Stop at end of stream
}, },
allow_stretch=True,
keep_ratio=True, # Maintain aspect ratio
size_hint=(1, 1), size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5} pos_hint={'center_x': 0.5, 'center_y': 0.5}
) )
@@ -881,14 +1278,16 @@ class SignagePlayer(Widget):
self.current_widget.bind(on_eos=self._on_video_eos) self.current_widget.bind(on_eos=self._on_video_eos)
# Add to content area # Add to content area
Logger.info(f"SignagePlayer: Adding video 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)
Logger.info(f"SignagePlayer: Scheduled next media in {duration}s") Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s")
Clock.unschedule(self.next_media) Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration) Clock.schedule_once(self.next_media, duration)
# Preload next media asynchronously for smoother transitions
self.preload_next_media()
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}") Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}")
self.consecutive_errors += 1 self.consecutive_errors += 1
@@ -897,23 +1296,36 @@ class SignagePlayer(Widget):
def _on_video_eos(self, instance): def _on_video_eos(self, instance):
"""Callback when video reaches end of stream""" """Callback when video reaches end of stream"""
Logger.info("SignagePlayer: Video finished playing (EOS)") Logger.debug("SignagePlayer: Video finished playing (EOS)")
def _on_video_loaded(self, instance, value): def _on_video_loaded(self, instance, value):
"""Callback when video is loaded - log video information""" """Callback when video is loaded - log video information"""
if value: if value:
try: try:
Logger.info(f"SignagePlayer: Video loaded successfully") Logger.debug(f"SignagePlayer: Video loaded: {instance.texture.size if instance.texture else 'No texture'}, {instance.duration}s")
Logger.info(f"SignagePlayer: Video texture: {instance.texture.size if instance.texture else 'No texture'}")
Logger.info(f"SignagePlayer: Video duration: {instance.duration}s")
Logger.info(f"SignagePlayer: Video state: {instance.state}")
except Exception as e: except Exception as e:
Logger.warning(f"SignagePlayer: Could not log video info: {e}") Logger.debug(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
Logger.debug(f"SignagePlayer: Loading image: {os.path.basename(image_path)} ({os.path.getsize(image_path):,} bytes)")
if force_reload:
Logger.debug(f"SignagePlayer: Force reload - bypassing 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:
self.current_widget = AsyncImage( self.current_widget = AsyncImage(
source=image_path, source=image_path,
allow_stretch=True, allow_stretch=True,
@@ -921,13 +1333,14 @@ class SignagePlayer(Widget):
size_hint=(1, 1), size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5} pos_hint={'center_x': 0.5, 'center_y': 0.5}
) )
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)
Logger.info(f"SignagePlayer: Scheduled next media in {duration}s") Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s")
Clock.unschedule(self.next_media) Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration) Clock.schedule_once(self.next_media, duration)
Logger.info(f"SignagePlayer: ✓ Image displayed successfully")
# Preload next media asynchronously for smoother transitions
self.preload_next_media()
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}") Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}")
self.consecutive_errors += 1 self.consecutive_errors += 1
@@ -937,7 +1350,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})")
@@ -956,16 +1369,95 @@ class SignagePlayer(Widget):
Clock.unschedule(self.next_media) Clock.unschedule(self.next_media)
self.play_current_media() self.play_current_media()
def preload_next_media(self):
"""Preload the next media item asynchronously to improve transition smoothness
Only preloads images as videos are loaded on-demand.
Respects force_reload scenarios (e.g., after editing).
"""
if not self.playlist:
return
# Calculate next index (with wraparound)
next_index = (self.current_index + 1) % len(self.playlist)
try:
next_media_item = self.playlist[next_index]
file_name = next_media_item.get('file_name', '')
media_path = os.path.join(self.media_dir, file_name)
# Check if file exists before attempting preload
if not os.path.exists(media_path):
Logger.debug(f"SignagePlayer: Preload skipped - file not found: {file_name}")
return
# Only preload images (videos are handled differently)
file_extension = os.path.splitext(file_name)[1].lower()
if file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
# Check if this is the same file as current (e.g., after editing)
# If so, we need to invalidate cache
current_media = self.playlist[self.current_index]
current_file = current_media.get('file_name', '')
if file_name == current_file:
# Same file - likely edited, don't preload as we'll force reload
Logger.debug(f"SignagePlayer: Preload skipped - same file after edit: {file_name}")
return
# Use Kivy's Loader to preload asynchronously
Logger.debug(f"SignagePlayer: Preloading next image: {file_name}")
Loader.image(media_path)
except Exception as e:
Logger.debug(f"SignagePlayer: Error preloading next media: {e}")
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):
@@ -1070,6 +1562,106 @@ class SignagePlayer(Widget):
popup = SettingsPopup(player_instance=self, was_paused=was_paused) popup = SettingsPopup(player_instance=self, was_paused=was_paused)
popup.open() popup.open()
def show_edit_interface(self, instance=None):
"""
Show edit interface for current image
Workflow:
1. Check if edit feature is enabled in settings
2. Validate media type (must be image)
3. Check edit_on_player permission from server
4. Prompt for card swipe (5 second timeout)
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
if not self.playlist or self.current_index >= len(self.playlist):
Logger.warning("SignagePlayer: No media to edit")
return
media_item = self.playlist[self.current_index]
file_name = media_item.get('file_name', '')
file_extension = os.path.splitext(file_name)[1].lower()
# Check 1: Only allow editing images
if file_extension not in ['.jpg', '.jpeg', '.png', '.bmp', '.webp']:
Logger.warning(f"SignagePlayer: Cannot edit {file_extension} files, only images")
# Show error message briefly
self.ids.status_label.text = 'Can only edit image files'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Check 2: Verify edit_on_player permission from server
edit_allowed = media_item.get('edit_on_player', False)
if not edit_allowed:
Logger.warning(f"SignagePlayer: Edit not allowed for {file_name} (edit_on_player=false)")
# Show error message briefly
self.ids.status_label.text = 'Edit not permitted for this media'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Store image info
self._pending_edit_image = file_name
# Show card swipe popup with 5 second timeout
self._card_swipe_popup = CardSwipePopup(callback=self._on_card_swipe_result, resources_path=self.resources_path)
self._card_swipe_popup.open()
# Initialize card reader if not already created
if not self.card_reader:
self.card_reader = CardReader()
# Start reading card asynchronously
self.card_reader.read_card_async(self._on_card_data_received)
def _on_card_data_received(self, card_data):
"""Called when card reader gets data"""
if hasattr(self, '_card_swipe_popup') and self._card_swipe_popup:
self._card_swipe_popup.card_received(card_data)
def _on_card_swipe_result(self, card_data):
"""Handle result from card swipe popup (card data or None if timeout/cancelled)"""
# Get image path
file_name = self._pending_edit_image
image_path = os.path.join(self.media_dir, file_name)
if not os.path.exists(image_path):
Logger.error(f"SignagePlayer: Image not found: {image_path}")
self.ids.status_label.text = 'Image file not found'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# If no card data (timeout or cancelled), don't open edit interface
if not card_data:
Logger.info("SignagePlayer: Edit cancelled - no card data")
self.ids.status_label.text = 'Edit cancelled - card required'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Store the card data (will be sent to server when save is done)
user_card_data = card_data.strip()
Logger.info(f"SignagePlayer: Card data captured: {user_card_data}")
Logger.info(f"SignagePlayer: Opening edit interface for {file_name}")
# Open edit popup with card data (will be used when saving)
popup = EditPopup(player_instance=self, image_path=image_path, user_card_data=user_card_data)
popup.open()
def show_exit_popup(self, instance=None): def show_exit_popup(self, instance=None):
"""Show exit password popup""" """Show exit password popup"""
# Pause playback when exit popup opens # Pause playback when exit popup opens
@@ -1203,6 +1795,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

@@ -1,10 +1,10 @@
{ {
"hostname": "tv-terasa", "hostname": "tv-terasa",
"auth_code": "iiSyZDLWGyqNIxeRt54XYREgvAio11RwwU1_oJev6WI", "auth_code": "vkrxEO6eOTxkzXJBtoN4OuXc8eaX2mC3AB9ZePrnick",
"player_id": 1, "player_id": 1,
"player_name": "TV-acasa 1", "player_name": "TV-acasa",
"playlist_id": 1, "playlist_id": 1,
"orientation": "Landscape", "orientation": "Landscape",
"authenticated": true, "authenticated": true,
"server_url": "https://digi-signage.moto-adv.com:443" "server_url": "http://digi-signage.moto-adv.com"
} }

View File

@@ -275,6 +275,8 @@ class PlayerAuth:
feedback_url = f"{server_url}/api/player-feedback" feedback_url = f"{server_url}/api/player-feedback"
headers = {'Authorization': f'Bearer {auth_code}'} headers = {'Authorization': f'Bearer {auth_code}'}
payload = { payload = {
'hostname': self.auth_data.get('hostname'),
'quickconnect_code': self.auth_data.get('quickconnect_code'),
'message': message, 'message': message,
'status': status, 'status': status,
'playlist_version': playlist_version, 'playlist_version': playlist_version,

View File

@@ -224,12 +224,12 @@
pos: self.pos pos: self.pos
radius: [dp(15)] radius: [dp(15)]
# New control panel overlay (bottom center, 1/6 width, 90% transparent) # New control panel overlay (bottom center, width for 6 buttons, 90% transparent)
BoxLayout: BoxLayout:
id: controls_layout id: controls_layout
orientation: 'horizontal' orientation: 'horizontal'
size_hint: None, None size_hint: None, None
width: root.width / 6 if root.width > 0 else dp(260) width: dp(370)
height: dp(70) height: dp(70)
pos: (root.width - self.width) / 2, dp(10) pos: (root.width - self.width) / 2, dp(10)
opacity: 1 opacity: 1
@@ -256,11 +256,19 @@
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()
Button:
id: edit_btn
size_hint: None, None
size: dp(50), dp(50)
background_normal: root.resources_path + '/pencil.png'
background_down: root.resources_path + '/pencil.png'
border: (0, 0, 0, 0)
on_press: root.show_edit_interface()
Button: Button:
id: settings_btn id: settings_btn
size_hint: None, None size_hint: None, None
@@ -485,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
@@ -511,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)
spacing: dp(10)
Label:
id: playlist_info
text: 'Playlist: N/A'
text_size: self.size text_size: self.size
halign: 'left' halign: 'center'
valign: 'middle' valign: 'middle'
font_size: sp(12)
Label: Label:
id: media_count_info id: media_count_info
text: 'Media Count: 0' text: 'Media: 0'
size_hint_y: None
height: dp(30)
text_size: self.size text_size: self.size
halign: 'left' halign: 'center'
valign: 'middle' valign: 'middle'
font_size: sp(12)
Label: Label:
id: status_info id: status_info
text: 'Status: Idle' text: 'Status: Idle'
size_hint_y: None
height: dp(30)
text_size: self.size text_size: self.size
halign: 'left' halign: 'center'
valign: 'middle' valign: 'middle'
font_size: sp(12)
Widget: Widget:
size_hint_y: 0.2 size_hint_y: 0.05
# Action buttons # Action buttons
BoxLayout: BoxLayout:
@@ -558,3 +635,279 @@
text: 'Cancel' text: 'Cancel'
background_color: 0.6, 0.2, 0.2, 1 background_color: 0.6, 0.2, 0.2, 1
on_press: root.dismiss() on_press: root.dismiss()
# Card Swipe Popup
<CardSwipePopup>:
title: 'Card Authentication Required'
size_hint: 0.5, 0.4
auto_dismiss: False
separator_height: 2
BoxLayout:
orientation: 'vertical'
padding: dp(20)
spacing: dp(20)
# Card swipe icon
AsyncImage:
id: icon_image
size_hint: 1, 0.4
allow_stretch: True
keep_ratio: True
# Message label
Label:
id: message_label
text: 'Please swipe your card...'
font_size: sp(20)
size_hint: 1, 0.2
# Countdown timer
Label:
id: countdown_label
text: '5'
font_size: sp(48)
color: 0.9, 0.6, 0.2, 1
size_hint: 1, 0.2
# Cancel button
Button:
text: 'Cancel'
size_hint: 1, 0.2
background_color: 0.9, 0.3, 0.2, 1
on_press: root.cancel(self)
# Edit Popup (Drawing on Images)
<EditPopup>:
title: ''
size_hint: 1, 1
auto_dismiss: False
separator_height: 0
FloatLayout:
# Background image (full screen)
Image:
id: image_widget
allow_stretch: True
keep_ratio: True
size_hint: 1, 1
pos_hint: {'x': 0, 'y': 0}
# Drawing layer (will be added programmatically due to custom class)
# Placeholder widget for drawing layer positioning
Widget:
id: drawing_layer_placeholder
size_hint: 1, 1
pos_hint: {'x': 0, 'y': 0}
# Top toolbar
BoxLayout:
id: top_toolbar
orientation: 'horizontal'
size_hint: 1, None
height: dp(56)
pos_hint: {'top': 1, 'x': 0}
spacing: dp(10)
padding: [dp(10), dp(8)]
canvas.before:
Color:
rgba: 0.1, 0.1, 0.1, 0.5
Rectangle:
size: self.size
pos: self.pos
Widget: # Spacer
Button:
id: undo_btn
text: 'Undo'
font_size: sp(16)
size_hint: None, 1
width: dp(100)
background_normal: ''
background_color: 0.9, 0.6, 0.2, 0.9
Button:
id: clear_btn
text: 'Clear'
font_size: sp(16)
size_hint: None, 1
width: dp(100)
background_normal: ''
background_color: 0.9, 0.3, 0.2, 0.9
Button:
id: save_btn
text: 'Save'
font_size: sp(16)
size_hint: None, 1
width: dp(100)
background_normal: ''
background_color: 0.2, 0.8, 0.2, 0.9
Button:
id: cancel_btn
text: 'Cancel'
font_size: sp(16)
size_hint: None, 1
width: dp(100)
background_normal: ''
background_color: 0.6, 0.2, 0.2, 0.9
Label:
id: countdown_label
text: '5:00'
font_size: sp(20)
size_hint: None, 1
width: dp(80)
color: 1, 1, 1, 1
bold: True
Widget: # Small spacer
size_hint: None, 1
width: dp(10)
# Right sidebar
BoxLayout:
id: right_sidebar
orientation: 'vertical'
size_hint: None, 1
width: dp(56)
pos_hint: {'right': 1, 'y': 0}
spacing: dp(10)
padding: [dp(8), dp(66), dp(8), dp(10)]
canvas.before:
Color:
rgba: 0.1, 0.1, 0.1, 0.5
Rectangle:
size: self.size
pos: self.pos
# Color section header
BoxLayout:
orientation: 'vertical'
size_hint_y: None
height: dp(55)
spacing: dp(2)
Image:
id: color_icon
size_hint_y: None
height: dp(28)
allow_stretch: True
keep_ratio: True
Label:
text: 'Color'
font_size: sp(11)
bold: True
size_hint_y: None
height: dp(25)
# Color buttons
Button:
id: red_btn
text: 'R'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 1, 0, 0, 1
Button:
id: blue_btn
text: 'B'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0, 0, 1, 1
Button:
id: green_btn
text: 'G'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0, 1, 0, 1
Button:
id: black_btn
text: 'K'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0, 0, 0, 1
Button:
id: white_btn
text: 'W'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 1, 1, 1, 1
# Spacer
Widget:
size_hint_y: 0.2
# Thickness section header
BoxLayout:
orientation: 'vertical'
size_hint_y: None
height: dp(55)
spacing: dp(2)
Image:
id: thickness_icon
size_hint_y: None
height: dp(28)
allow_stretch: True
keep_ratio: True
Label:
text: 'Size'
font_size: sp(11)
bold: True
size_hint_y: None
height: dp(25)
# Thickness buttons
Button:
id: small_btn
text: 'S'
font_size: sp(20)
bold: True
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0.3, 0.3, 0.3, 0.9
Button:
id: medium_btn
text: 'M'
font_size: sp(20)
bold: True
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0.3, 0.3, 0.3, 0.9
Button:
id: large_btn
text: 'L'
font_size: sp(20)
bold: True
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0.3, 0.3, 0.3, 0.9
Widget: # Bottom spacer

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

View File

@@ -0,0 +1,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

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