Compare commits
20 Commits
07b7e96edd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c02843687 | ||
|
|
1cc0eae542 | ||
|
|
b2d380511a | ||
|
|
db796e4d66 | ||
|
|
5843bb5215 | ||
| 2b42999008 | |||
| 02e9ea1aaa | |||
|
|
4c3ddbef73 | ||
|
|
87e059e0f4 | ||
|
|
46d9fcf6e3 | ||
|
|
f1a84d05d5 | ||
|
|
706af95557 | ||
| 02227a12e5 | |||
| 9d32f43ac7 | |||
| af1e671c7f | |||
|
|
9664ad541b | ||
|
|
89e5ad86dd | ||
|
|
f573af0505 | ||
|
|
fba2007bdf | ||
|
|
72d382b96b |
@@ -1 +0,0 @@
|
|||||||
1763799978.6257727
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
BIN
config/resources/access-card.png
Normal file
BIN
config/resources/access-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
config/resources/card-checked.png
Normal file
BIN
config/resources/card-checked.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
BIN
config/resources/edit-pen.png
Normal file
BIN
config/resources/edit-pen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
config/resources/pencil.png
Normal file
BIN
config/resources/pencil.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
@@ -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
53
setup_wifi_control.sh
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Setup script to allow passwordless sudo for WiFi control commands
|
||||||
|
|
||||||
|
echo "Setting up passwordless sudo for WiFi control..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create sudoers file for WiFi commands
|
||||||
|
SUDOERS_FILE="/etc/sudoers.d/kiwy-signage-wifi"
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "This script must be run as root (use sudo)"
|
||||||
|
echo "Usage: sudo bash setup_wifi_control.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the username who invoked sudo
|
||||||
|
ACTUAL_USER="${SUDO_USER:-$USER}"
|
||||||
|
|
||||||
|
echo "Configuring passwordless sudo for user: $ACTUAL_USER"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create sudoers entry
|
||||||
|
cat > "$SUDOERS_FILE" << EOF
|
||||||
|
# Allow $ACTUAL_USER to control WiFi without password for Kiwy Signage Player
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill block wifi
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill unblock wifi
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 down
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 up
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/dhclient wlan0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set correct permissions
|
||||||
|
chmod 0440 "$SUDOERS_FILE"
|
||||||
|
|
||||||
|
echo "✓ Created sudoers file: $SUDOERS_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Validate the sudoers file
|
||||||
|
if visudo -c -f "$SUDOERS_FILE"; then
|
||||||
|
echo "✓ Sudoers file validated successfully"
|
||||||
|
echo ""
|
||||||
|
echo "Setup complete! User '$ACTUAL_USER' can now control WiFi without password."
|
||||||
|
echo ""
|
||||||
|
echo "Test with:"
|
||||||
|
echo " sudo rfkill block wifi"
|
||||||
|
echo " sudo rfkill unblock wifi"
|
||||||
|
else
|
||||||
|
echo "✗ Error: Sudoers file validation failed"
|
||||||
|
echo "Removing invalid file..."
|
||||||
|
rm -f "$SUDOERS_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
531
src/edit_popup.py
Normal file
531
src/edit_popup.py
Normal 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()
|
||||||
@@ -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}")
|
||||||
|
|||||||
755
src/main.py
755
src/main.py
@@ -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
235
src/network_monitor.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Network Monitoring Module
|
||||||
|
Checks server connectivity and manages WiFi restart on connection failure
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from kivy.logger import Logger
|
||||||
|
from kivy.clock import Clock
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkMonitor:
|
||||||
|
"""Monitor network connectivity and manage WiFi restart"""
|
||||||
|
|
||||||
|
def __init__(self, server_url, check_interval_min=30, check_interval_max=45, wifi_restart_duration=20):
|
||||||
|
"""
|
||||||
|
Initialize network monitor
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url (str): Server URL to check connectivity (e.g., 'https://digi-signage.moto-adv.com')
|
||||||
|
check_interval_min (int): Minimum minutes between checks (default: 30)
|
||||||
|
check_interval_max (int): Maximum minutes between checks (default: 45)
|
||||||
|
wifi_restart_duration (int): Minutes to keep WiFi off during restart (default: 20)
|
||||||
|
"""
|
||||||
|
self.server_url = server_url.rstrip('/')
|
||||||
|
self.check_interval_min = check_interval_min * 60 # Convert to seconds
|
||||||
|
self.check_interval_max = check_interval_max * 60 # Convert to seconds
|
||||||
|
self.wifi_restart_duration = wifi_restart_duration * 60 # Convert to seconds
|
||||||
|
self.is_monitoring = False
|
||||||
|
self.scheduled_event = None
|
||||||
|
self.consecutive_failures = 0
|
||||||
|
self.max_failures_before_restart = 3 # Restart WiFi after 3 consecutive failures
|
||||||
|
|
||||||
|
def start_monitoring(self):
|
||||||
|
"""Start the network monitoring loop"""
|
||||||
|
if not self.is_monitoring:
|
||||||
|
self.is_monitoring = True
|
||||||
|
Logger.info("NetworkMonitor: Starting network monitoring")
|
||||||
|
self._schedule_next_check()
|
||||||
|
|
||||||
|
def stop_monitoring(self):
|
||||||
|
"""Stop the network monitoring"""
|
||||||
|
self.is_monitoring = False
|
||||||
|
if self.scheduled_event:
|
||||||
|
self.scheduled_event.cancel()
|
||||||
|
self.scheduled_event = None
|
||||||
|
Logger.info("NetworkMonitor: Stopped network monitoring")
|
||||||
|
|
||||||
|
def _schedule_next_check(self):
|
||||||
|
"""Schedule the next connectivity check at a random interval"""
|
||||||
|
if not self.is_monitoring:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Random interval between min and max
|
||||||
|
next_check_seconds = random.randint(self.check_interval_min, self.check_interval_max)
|
||||||
|
next_check_minutes = next_check_seconds / 60
|
||||||
|
|
||||||
|
Logger.info(f"NetworkMonitor: Next check scheduled in {next_check_minutes:.1f} minutes")
|
||||||
|
|
||||||
|
# Schedule using Kivy Clock
|
||||||
|
self.scheduled_event = Clock.schedule_once(
|
||||||
|
lambda dt: self._check_connectivity(),
|
||||||
|
next_check_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_connectivity(self):
|
||||||
|
"""Check network connectivity to server"""
|
||||||
|
Logger.info("NetworkMonitor: Checking server connectivity...")
|
||||||
|
|
||||||
|
if self._test_server_connection():
|
||||||
|
Logger.info("NetworkMonitor: ✓ Server connection successful")
|
||||||
|
self.consecutive_failures = 0
|
||||||
|
else:
|
||||||
|
self.consecutive_failures += 1
|
||||||
|
Logger.warning(f"NetworkMonitor: ✗ Server connection failed (attempt {self.consecutive_failures}/{self.max_failures_before_restart})")
|
||||||
|
|
||||||
|
if self.consecutive_failures >= self.max_failures_before_restart:
|
||||||
|
Logger.error("NetworkMonitor: Multiple connection failures detected - initiating WiFi restart")
|
||||||
|
self._restart_wifi()
|
||||||
|
self.consecutive_failures = 0 # Reset counter after restart
|
||||||
|
|
||||||
|
# Schedule next check
|
||||||
|
self._schedule_next_check()
|
||||||
|
|
||||||
|
def _test_server_connection(self):
|
||||||
|
"""
|
||||||
|
Test connection to the server using ping only
|
||||||
|
This works in closed networks where the server is local
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if server is reachable, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Extract hostname from server URL (remove http:// or https://)
|
||||||
|
hostname = self.server_url.replace('https://', '').replace('http://', '').split('/')[0]
|
||||||
|
|
||||||
|
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||||
|
|
||||||
|
# Ping the server hostname with 3 attempts
|
||||||
|
result = subprocess.run(
|
||||||
|
['ping', '-c', '3', '-W', '3', hostname],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info(f"NetworkMonitor: ✓ Server {hostname} is reachable")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
Logger.warning(f"NetworkMonitor: ✗ Cannot reach server {hostname}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
Logger.warning(f"NetworkMonitor: ✗ Ping timeout to server")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"NetworkMonitor: Error pinging server: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _restart_wifi(self):
|
||||||
|
"""
|
||||||
|
Restart WiFi by turning it off for a specified duration then back on
|
||||||
|
This runs in a separate thread to not block the main application
|
||||||
|
"""
|
||||||
|
def wifi_restart_thread():
|
||||||
|
try:
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||||
|
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', 'rfkill', 'block', 'wifi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||||
|
Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||||
|
|
||||||
|
# Fallback to ifconfig
|
||||||
|
result2 = subprocess.run(
|
||||||
|
['sudo', 'ifconfig', 'wlan0', 'down'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result2.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||||
|
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||||
|
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for the specified duration with WiFi OFF
|
||||||
|
wait_minutes = self.wifi_restart_duration / 60
|
||||||
|
Logger.info(f"NetworkMonitor: ====================================")
|
||||||
|
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||||
|
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
Logger.info(f"NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Sleep while WiFi is OFF
|
||||||
|
time.sleep(self.wifi_restart_duration)
|
||||||
|
|
||||||
|
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
|
||||||
|
# Turn WiFi back on after the wait period
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Unblock WiFi using rfkill
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||||
|
|
||||||
|
# Also bring interface up
|
||||||
|
result2 = subprocess.run(
|
||||||
|
['sudo', 'ifconfig', 'wlan0', 'up'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result2.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||||
|
|
||||||
|
# Wait a bit for connection to establish
|
||||||
|
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Try to restart DHCP
|
||||||
|
Logger.info("NetworkMonitor: Requesting IP address...")
|
||||||
|
subprocess.run(
|
||||||
|
['sudo', 'dhclient', 'wlan0'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
||||||
|
|
||||||
|
# Run in separate thread to not block the application
|
||||||
|
import threading
|
||||||
|
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||||
|
thread.start()
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
61
src/test_network_monitor.py
Normal file
61
src/test_network_monitor.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for network monitor functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from kivy.app import App
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from network_monitor import NetworkMonitor
|
||||||
|
|
||||||
|
class TestMonitorApp(App):
|
||||||
|
"""Minimal Kivy app to test network monitor"""
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
"""Build the app"""
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
return Label(text='Network Monitor Test Running\nCheck terminal for output')
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
"""Start monitoring when app starts"""
|
||||||
|
server_url = "https://digi-signage.moto-adv.com"
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Network Monitor Test")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print(f"Server URL: {server_url}")
|
||||||
|
print("Check interval: 0.5 minutes (30 seconds for testing)")
|
||||||
|
print("WiFi restart duration: 1 minute (for testing)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create monitor with short intervals for testing
|
||||||
|
self.monitor = NetworkMonitor(
|
||||||
|
server_url=server_url,
|
||||||
|
check_interval_min=0.5, # 30 seconds
|
||||||
|
check_interval_max=0.5, # 30 seconds
|
||||||
|
wifi_restart_duration=1 # 1 minute
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform immediate test
|
||||||
|
print("Performing immediate connectivity test...")
|
||||||
|
self.monitor._check_connectivity()
|
||||||
|
|
||||||
|
# Start monitoring for future checks
|
||||||
|
print("\nStarting periodic network monitoring...")
|
||||||
|
self.monitor.start_monitoring()
|
||||||
|
|
||||||
|
print("\nMonitoring is active. Press Ctrl+C to stop.")
|
||||||
|
print("Next check will occur in ~30 seconds.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
"""Stop monitoring when app stops"""
|
||||||
|
if hasattr(self, 'monitor'):
|
||||||
|
self.monitor.stop_monitoring()
|
||||||
|
print("\nNetwork monitoring stopped")
|
||||||
|
print("Test completed!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
TestMonitorApp().run()
|
||||||
182
working_files/CARD_READER_AUTHENTICATION.md
Normal file
182
working_files/CARD_READER_AUTHENTICATION.md
Normal 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)
|
||||||
170
working_files/CARD_READER_FIX.md
Normal file
170
working_files/CARD_READER_FIX.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# Card Reader Fix - Multi-USB Device Support
|
||||||
|
|
||||||
|
## Problem Description
|
||||||
|
|
||||||
|
When a USB touchscreen was connected to the Raspberry Pi, the card reader authentication was not working. The system reported "no authentication was received" even though the card reader was physically connected on a different USB port.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
The original `find_card_reader()` function used overly broad matching criteria:
|
||||||
|
1. It would select the **first** device with "keyboard" in its name
|
||||||
|
2. USB touchscreens often register as HID keyboard devices (for touch input)
|
||||||
|
3. The touchscreen would be detected first, blocking the actual card reader
|
||||||
|
4. No exclusion logic existed to filter out touch devices
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
The fix implements a **priority-based device selection** with **exclusion filters**:
|
||||||
|
|
||||||
|
### 1. Device Exclusion List
|
||||||
|
Devices containing these keywords are now skipped:
|
||||||
|
- `touch`, `touchscreen`
|
||||||
|
- `mouse`, `mice`
|
||||||
|
- `trackpad`, `touchpad`
|
||||||
|
- `pen`, `stylus`
|
||||||
|
- `video`, `button`, `lid`
|
||||||
|
|
||||||
|
### 2. Three-Priority Device Search
|
||||||
|
|
||||||
|
**Priority 1: Explicit Card Readers**
|
||||||
|
- Devices with "card", "reader", "rfid", or "hid" in their name
|
||||||
|
- Must have keyboard capabilities (EV_KEY)
|
||||||
|
- Excludes any device matching exclusion keywords
|
||||||
|
|
||||||
|
**Priority 2: USB Keyboards**
|
||||||
|
- Devices with both "usb" AND "keyboard" in their name
|
||||||
|
- Card readers typically appear as "USB Keyboard" or similar
|
||||||
|
- Excludes touch devices and other non-card peripherals
|
||||||
|
|
||||||
|
**Priority 3: Fallback to Any Keyboard**
|
||||||
|
- Any keyboard device not in the exclusion list
|
||||||
|
- Used only if no card reader or USB keyboard is found
|
||||||
|
|
||||||
|
### 3. Enhanced Logging
|
||||||
|
|
||||||
|
The system now logs:
|
||||||
|
- All detected input devices at startup
|
||||||
|
- Which devices are being skipped and why
|
||||||
|
- Which device is ultimately selected as the card reader
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Using the Test Script
|
||||||
|
|
||||||
|
Run the enhanced test script to identify your card reader:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/pi/Desktop/Kiwy-Signage/working_files
|
||||||
|
python3 test_card_reader.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. List all input devices with helpful indicators:
|
||||||
|
- `** LIKELY CARD READER **` - devices with "card" or "reader" in name
|
||||||
|
- `(Excluded: ...)` - devices that will be skipped
|
||||||
|
- `(USB Keyboard - could be card reader)` - potential card readers
|
||||||
|
|
||||||
|
2. Auto-detect the card reader using the same logic as the main app
|
||||||
|
|
||||||
|
3. Allow manual selection by device number if auto-detection is wrong
|
||||||
|
|
||||||
|
### Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Available Input Devices ===
|
||||||
|
|
||||||
|
[0] /dev/input/event0
|
||||||
|
Name: USB Touchscreen Controller
|
||||||
|
Phys: usb-0000:01:00.0-1.1/input0
|
||||||
|
Type: Keyboard/HID Input Device
|
||||||
|
(Excluded: appears to be touch/mouse/other non-card device)
|
||||||
|
|
||||||
|
[1] /dev/input/event1
|
||||||
|
Name: HID 08ff:0009
|
||||||
|
Phys: usb-0000:01:00.0-1.2/input0
|
||||||
|
Type: Keyboard/HID Input Device
|
||||||
|
** LIKELY CARD READER **
|
||||||
|
|
||||||
|
[2] /dev/input/event2
|
||||||
|
Name: Logitech USB Keyboard
|
||||||
|
Phys: usb-0000:01:00.0-1.3/input0
|
||||||
|
Type: Keyboard/HID Input Device
|
||||||
|
(USB Keyboard - could be card reader)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying the Fix
|
||||||
|
|
||||||
|
1. **Check Logs**: When the main app starts, check the logs for device detection:
|
||||||
|
```bash
|
||||||
|
tail -f /path/to/logfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for messages like:
|
||||||
|
```
|
||||||
|
CardReader: Scanning input devices...
|
||||||
|
CardReader: Skipping excluded device: USB Touchscreen Controller
|
||||||
|
CardReader: Found card reader: HID 08ff:0009 at /dev/input/event1
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test Card Swipe**:
|
||||||
|
- Start the signage player
|
||||||
|
- Click the edit button (pencil icon)
|
||||||
|
- Swipe a card
|
||||||
|
- Should successfully authenticate
|
||||||
|
|
||||||
|
3. **Multiple USB Devices**: Test with various USB configurations:
|
||||||
|
- Touchscreen + card reader
|
||||||
|
- Mouse + keyboard + card reader
|
||||||
|
- Multiple USB hubs
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### If Auto-Detection Fails
|
||||||
|
|
||||||
|
If the automatic detection still selects the wrong device, you can:
|
||||||
|
|
||||||
|
1. **Check device names**: Run `test_card_reader.py` to see all devices
|
||||||
|
2. **Identify your card reader**: Note the exact name of your card reader
|
||||||
|
3. **Add custom exclusions**: If needed, add more keywords to the exclusion list
|
||||||
|
4. **Manual override**: Modify the priority logic to match your specific hardware
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
Ensure the user running the app has permission to access input devices:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add user to input group
|
||||||
|
sudo usermod -a -G input $USER
|
||||||
|
|
||||||
|
# Logout and login again for changes to take effect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. **src/main.py**
|
||||||
|
- Updated `CardReader.find_card_reader()` method
|
||||||
|
- Added exclusion keyword list
|
||||||
|
- Implemented priority-based search
|
||||||
|
- Enhanced logging
|
||||||
|
|
||||||
|
2. **working_files/test_card_reader.py**
|
||||||
|
- Updated `list_input_devices()` to show device classifications
|
||||||
|
- Updated `test_card_reader()` to use same logic as main app
|
||||||
|
- Added visual indicators for device types
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This fix is backward compatible:
|
||||||
|
- Works with single-device setups (no touchscreen)
|
||||||
|
- Works with multiple USB devices
|
||||||
|
- Fallback behavior unchanged for systems without card readers
|
||||||
|
- No changes to card data format or server communication
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for specific use cases:
|
||||||
|
|
||||||
|
1. **Configuration file**: Allow specifying device path or name pattern
|
||||||
|
2. **Device caching**: Remember the working device path to avoid re-scanning
|
||||||
|
3. **Hot-plug support**: Detect when card reader is plugged in after app starts
|
||||||
|
4. **Multi-reader support**: Support for multiple card readers simultaneously
|
||||||
162
working_files/test_card_reader.py
Normal file
162
working_files/test_card_reader.py
Normal 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()
|
||||||
Reference in New Issue
Block a user