Compare commits
5 Commits
706af95557
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02e9ea1aaa | |||
|
|
4c3ddbef73 | ||
|
|
87e059e0f4 | ||
|
|
46d9fcf6e3 | ||
|
|
f1a84d05d5 |
1
.player_stop_requested
Normal file
1
.player_stop_requested
Normal file
@@ -0,0 +1 @@
|
||||
User requested exit via password
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"server_ip": "digiserver",
|
||||
"port": "80",
|
||||
"screen_name": "rpi-tvholba1",
|
||||
"server_ip": "digi-signage.moto-adv.com",
|
||||
"port": "443",
|
||||
"screen_name": "tv-terasa",
|
||||
"quickconnect_key": "8887779",
|
||||
"orientation": "Landscape",
|
||||
"touch": "True",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
53
setup_wifi_control.sh
Normal file
53
setup_wifi_control.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Setup script to allow passwordless sudo for WiFi control commands
|
||||
|
||||
echo "Setting up passwordless sudo for WiFi control..."
|
||||
echo ""
|
||||
|
||||
# Create sudoers file for WiFi commands
|
||||
SUDOERS_FILE="/etc/sudoers.d/kiwy-signage-wifi"
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "This script must be run as root (use sudo)"
|
||||
echo "Usage: sudo bash setup_wifi_control.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the username who invoked sudo
|
||||
ACTUAL_USER="${SUDO_USER:-$USER}"
|
||||
|
||||
echo "Configuring passwordless sudo for user: $ACTUAL_USER"
|
||||
echo ""
|
||||
|
||||
# Create sudoers entry
|
||||
cat > "$SUDOERS_FILE" << EOF
|
||||
# Allow $ACTUAL_USER to control WiFi without password for Kiwy Signage Player
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill block wifi
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill unblock wifi
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 down
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 up
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/dhclient wlan0
|
||||
EOF
|
||||
|
||||
# Set correct permissions
|
||||
chmod 0440 "$SUDOERS_FILE"
|
||||
|
||||
echo "✓ Created sudoers file: $SUDOERS_FILE"
|
||||
echo ""
|
||||
|
||||
# Validate the sudoers file
|
||||
if visudo -c -f "$SUDOERS_FILE"; then
|
||||
echo "✓ Sudoers file validated successfully"
|
||||
echo ""
|
||||
echo "Setup complete! User '$ACTUAL_USER' can now control WiFi without password."
|
||||
echo ""
|
||||
echo "Test with:"
|
||||
echo " sudo rfkill block wifi"
|
||||
echo " sudo rfkill unblock wifi"
|
||||
else
|
||||
echo "✗ Error: Sudoers file validation failed"
|
||||
echo "Removing invalid file..."
|
||||
rm -f "$SUDOERS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
@@ -271,8 +271,53 @@ def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, kee
|
||||
os.remove(filepath)
|
||||
logger.info(f"🗑️ Deleted old playlist: {f}")
|
||||
|
||||
# TODO: Clean up unused media files
|
||||
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest versions)")
|
||||
# Clean up unused media files
|
||||
logger.info("🔍 Checking for unused media files...")
|
||||
|
||||
# Get list of media files referenced in current playlist
|
||||
current_playlist_file = os.path.join(playlist_dir, f'server_playlist_v{current_version}.json')
|
||||
referenced_files = set()
|
||||
|
||||
if os.path.exists(current_playlist_file):
|
||||
try:
|
||||
with open(current_playlist_file, 'r') as f:
|
||||
playlist_data = json.load(f)
|
||||
for item in playlist_data.get('playlist', []):
|
||||
file_name = item.get('file_name', '')
|
||||
if file_name:
|
||||
referenced_files.add(file_name)
|
||||
|
||||
logger.info(f"📋 Current playlist references {len(referenced_files)} media files")
|
||||
|
||||
# Get all files in media directory (excluding edited_media subfolder)
|
||||
if os.path.exists(media_dir):
|
||||
media_files = [f for f in os.listdir(media_dir)
|
||||
if os.path.isfile(os.path.join(media_dir, f))]
|
||||
|
||||
deleted_count = 0
|
||||
for media_file in media_files:
|
||||
# Skip if file is in current playlist
|
||||
if media_file in referenced_files:
|
||||
continue
|
||||
|
||||
# Delete unreferenced file
|
||||
media_path = os.path.join(media_dir, media_file)
|
||||
try:
|
||||
os.remove(media_path)
|
||||
logger.info(f"🗑️ Deleted unused media: {media_file}")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Could not delete {media_file}: {e}")
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"✅ Deleted {deleted_count} unused media files")
|
||||
else:
|
||||
logger.info("✅ No unused media files to delete")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error reading playlist for media cleanup: {e}")
|
||||
|
||||
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest playlist versions)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error during cleanup: {e}")
|
||||
|
||||
341
src/main.py
341
src/main.py
@@ -56,6 +56,7 @@ from get_playlists_v2 import (
|
||||
send_player_error_feedback
|
||||
)
|
||||
from keyboard_widget import KeyboardWidget
|
||||
from network_monitor import NetworkMonitor
|
||||
from kivy.graphics import Color, Line, Ellipse
|
||||
from kivy.uix.floatlayout import FloatLayout
|
||||
from kivy.uix.slider import Slider
|
||||
@@ -382,13 +383,26 @@ class EditPopup(Popup):
|
||||
self.user_card_data = user_card_data # Store card data to send to server on save
|
||||
self.drawing_layer = None
|
||||
|
||||
# Pause playback
|
||||
# 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:
|
||||
@@ -703,6 +717,32 @@ class EditPopup(Popup):
|
||||
# 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}")
|
||||
import shutil
|
||||
import time
|
||||
|
||||
# Get original file info before overwrite
|
||||
orig_size = os.path.getsize(self.image_path)
|
||||
orig_mtime = os.path.getmtime(self.image_path)
|
||||
|
||||
# Overwrite the file
|
||||
shutil.copy2(output_path, self.image_path)
|
||||
|
||||
# Force file system sync to ensure data is written to disk
|
||||
os.sync()
|
||||
|
||||
# Verify the overwrite
|
||||
new_size = os.path.getsize(self.image_path)
|
||||
new_mtime = os.path.getmtime(self.image_path)
|
||||
|
||||
Logger.info(f"EditPopup: ✓ File overwritten:")
|
||||
Logger.info(f" - Size: {orig_size} -> {new_size} bytes (changed: {new_size != orig_size})")
|
||||
Logger.info(f" - Modified time: {orig_mtime} -> {new_mtime} (changed: {new_mtime > orig_mtime})")
|
||||
Logger.info(f"EditPopup: ✓ File synced to disk")
|
||||
|
||||
# Restore toolbars
|
||||
self.top_toolbar.opacity = 1
|
||||
self.right_sidebar.opacity = 1
|
||||
@@ -711,9 +751,7 @@ class EditPopup(Popup):
|
||||
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||
new_version if version_match else 1, output_filename)
|
||||
|
||||
Logger.info(f"EditPopup: Saved edited image to {output_path}")
|
||||
|
||||
# Upload to server asynchronously (non-blocking)
|
||||
# Upload to server in background (continues after popup closes)
|
||||
import threading
|
||||
upload_thread = threading.Thread(
|
||||
target=self._upload_to_server,
|
||||
@@ -721,10 +759,42 @@ class EditPopup(Popup):
|
||||
daemon=True
|
||||
)
|
||||
upload_thread.start()
|
||||
Logger.info(f"EditPopup: Background upload thread started")
|
||||
|
||||
# Show confirmation
|
||||
self.title = f'Saved as {output_filename}'
|
||||
Clock.schedule_once(lambda dt: self.dismiss(), 1)
|
||||
# NOW show saving popup AFTER everything is done
|
||||
def show_saving_and_dismiss(dt):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.label import Label
|
||||
|
||||
# Create label with background
|
||||
save_label = Label(
|
||||
text='✓ Saved! Reloading player...',
|
||||
font_size='36sp',
|
||||
color=(1, 1, 1, 1),
|
||||
bold=True
|
||||
)
|
||||
|
||||
saving_popup = Popup(
|
||||
title='',
|
||||
content=save_label,
|
||||
size_hint=(0.8, 0.3),
|
||||
auto_dismiss=False,
|
||||
separator_height=0,
|
||||
background_color=(0.2, 0.7, 0.2, 0.95) # Green background
|
||||
)
|
||||
saving_popup.open()
|
||||
Logger.info("EditPopup: Saving confirmation popup opened")
|
||||
|
||||
# Dismiss both popups after 2 seconds
|
||||
def dismiss_all(dt):
|
||||
saving_popup.dismiss()
|
||||
Logger.info(f"EditPopup: Dismissing to resume playback...")
|
||||
self.dismiss()
|
||||
|
||||
Clock.schedule_once(dismiss_all, 2.0)
|
||||
|
||||
# Small delay to ensure UI is ready, then show popup
|
||||
Clock.schedule_once(show_saving_and_dismiss, 0.1)
|
||||
|
||||
except Exception as e:
|
||||
Logger.error(f"EditPopup: Error in export: {e}")
|
||||
@@ -734,6 +804,8 @@ class EditPopup(Popup):
|
||||
# Restore toolbars
|
||||
self.top_toolbar.opacity = 1
|
||||
self.right_sidebar.opacity = 1
|
||||
# Still dismiss on error after brief delay
|
||||
Clock.schedule_once(lambda dt: self.dismiss(), 1)
|
||||
|
||||
Clock.schedule_once(do_export, 0.1)
|
||||
return
|
||||
@@ -851,16 +923,35 @@ class EditPopup(Popup):
|
||||
self.dismiss()
|
||||
|
||||
def on_popup_dismiss(self, *args):
|
||||
"""Resume playback when popup closes"""
|
||||
# Resume playback if it wasn't paused before
|
||||
"""Resume playback when popup closes - reload current image and continue"""
|
||||
from kivy.clock import Clock
|
||||
|
||||
# Force remove current widget immediately
|
||||
if self.player.current_widget:
|
||||
Logger.info("EditPopup: Removing current widget to force reload")
|
||||
self.player.ids.content_area.remove_widget(self.player.current_widget)
|
||||
self.player.current_widget = None
|
||||
Logger.info("EditPopup: ✓ Widget removed, ready for fresh load")
|
||||
|
||||
# Resume playback if it wasn't paused before editing
|
||||
if not self.was_paused:
|
||||
self.player.is_paused = False
|
||||
self.player.play_current_media()
|
||||
|
||||
# Update button icon to pause (to show it's playing)
|
||||
self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/pause.png'
|
||||
self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/pause.png'
|
||||
|
||||
# Add delay to ensure file write is complete and synced
|
||||
def reload_media(dt):
|
||||
Logger.info("EditPopup: ▶ Resuming playback and reloading edited image (force_reload=True)")
|
||||
self.player.play_current_media(force_reload=True)
|
||||
|
||||
Clock.schedule_once(reload_media, 0.5)
|
||||
else:
|
||||
Logger.info("EditPopup: Dismissed, keeping paused state")
|
||||
|
||||
# Restart control hide timer
|
||||
self.player.schedule_hide_controls()
|
||||
|
||||
Logger.info("EditPopup: Dismissed, playback resumed")
|
||||
|
||||
# Custom keyboard container with close button
|
||||
class KeyboardContainer(BoxLayout):
|
||||
@@ -1126,8 +1217,8 @@ class SettingsPopup(Popup):
|
||||
self.ids.edit_enabled_checkbox.active = self.player.config.get('edit_feature_enabled', True)
|
||||
|
||||
# Update status info
|
||||
self.ids.playlist_info.text = f'Playlist Version: {self.player.playlist_version}'
|
||||
self.ids.media_count_info.text = f'Media Count: {len(self.player.playlist)}'
|
||||
self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}'
|
||||
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"}'
|
||||
|
||||
# Bind to dismiss event to manage cursor visibility and resume playback
|
||||
@@ -1267,6 +1358,93 @@ class SettingsPopup(Popup):
|
||||
else:
|
||||
self.ids.connection_status.color = (1, 0, 0, 1) # Red
|
||||
|
||||
def reset_player_auth(self):
|
||||
"""Reset player authentication by deleting player_auth.json"""
|
||||
try:
|
||||
auth_file = os.path.join(os.path.dirname(__file__), 'player_auth.json')
|
||||
if os.path.exists(auth_file):
|
||||
os.remove(auth_file)
|
||||
Logger.info(f"SettingsPopup: Deleted authentication file: {auth_file}")
|
||||
self._show_temp_message('✓ Authentication reset - will reauthenticate on restart', (0, 1, 0, 1))
|
||||
else:
|
||||
Logger.info("SettingsPopup: No authentication file found")
|
||||
self._show_temp_message('No authentication file found', (1, 0.7, 0, 1))
|
||||
except Exception as e:
|
||||
Logger.error(f"SettingsPopup: Error resetting authentication: {e}")
|
||||
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
|
||||
|
||||
def reset_playlist_version(self):
|
||||
"""Reset playlist version to 0 and rename playlist file"""
|
||||
try:
|
||||
playlists_dir = os.path.join(self.player.base_dir, 'playlists')
|
||||
|
||||
# Find current playlist file
|
||||
import glob
|
||||
playlist_files = glob.glob(os.path.join(playlists_dir, 'server_playlist_v*.json'))
|
||||
|
||||
if playlist_files:
|
||||
# Get the first (should be only one) playlist file
|
||||
current_playlist = playlist_files[0]
|
||||
new_playlist = os.path.join(playlists_dir, 'server_playlist_v0.json')
|
||||
|
||||
# Rename to v0
|
||||
os.rename(current_playlist, new_playlist)
|
||||
Logger.info(f"SettingsPopup: Renamed {os.path.basename(current_playlist)} to server_playlist_v0.json")
|
||||
|
||||
# Update player's playlist version
|
||||
self.player.playlist_version = 0
|
||||
|
||||
# Update display
|
||||
self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}'
|
||||
|
||||
self._show_temp_message('✓ Playlist reset to v0 - will resync from server', (0, 1, 0, 1))
|
||||
else:
|
||||
Logger.info("SettingsPopup: No playlist file found")
|
||||
self._show_temp_message('No playlist file found', (1, 0.7, 0, 1))
|
||||
|
||||
except Exception as e:
|
||||
Logger.error(f"SettingsPopup: Error resetting playlist: {e}")
|
||||
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
|
||||
|
||||
def restart_player(self):
|
||||
"""Restart playlist from the first item"""
|
||||
try:
|
||||
Logger.info("SettingsPopup: Restarting player from first item")
|
||||
|
||||
# Reset to first item
|
||||
self.player.current_index = 0
|
||||
|
||||
# Show message
|
||||
self._show_temp_message('✓ Restarting playlist from beginning...', (0, 1, 0, 1))
|
||||
|
||||
# Close settings after a short delay
|
||||
def close_and_restart(dt):
|
||||
self.dismiss()
|
||||
# Start playing from first item
|
||||
self.player.play_current_media()
|
||||
|
||||
Clock.schedule_once(close_and_restart, 2)
|
||||
|
||||
except Exception as e:
|
||||
Logger.error(f"SettingsPopup: Error restarting player: {e}")
|
||||
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
|
||||
|
||||
def _show_temp_message(self, message, color):
|
||||
"""Show a temporary popup message for 2 seconds"""
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.label import Label
|
||||
|
||||
popup = Popup(
|
||||
title='',
|
||||
content=Label(text=message, color=color),
|
||||
size_hint=(0.6, 0.3),
|
||||
auto_dismiss=True
|
||||
)
|
||||
popup.open()
|
||||
|
||||
# Auto-close after 2 seconds
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
|
||||
def save_and_close(self):
|
||||
"""Save configuration and close popup"""
|
||||
# Update config
|
||||
@@ -1310,6 +1488,7 @@ class SignagePlayer(Widget):
|
||||
self.current_widget = None
|
||||
self.is_playing = False
|
||||
self.is_paused = False
|
||||
self.auto_resume_event = None # Track scheduled auto-resume
|
||||
self.config = {}
|
||||
self.playlist_version = None
|
||||
self.consecutive_errors = 0 # Track consecutive playback errors
|
||||
@@ -1318,6 +1497,8 @@ class SignagePlayer(Widget):
|
||||
# Card reader for authentication
|
||||
self.card_reader = None
|
||||
self._pending_edit_image = None
|
||||
# Network monitor
|
||||
self.network_monitor = None
|
||||
# Paths
|
||||
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
self.config_dir = os.path.join(self.base_dir, 'config')
|
||||
@@ -1364,6 +1545,9 @@ class SignagePlayer(Widget):
|
||||
# Load configuration
|
||||
self.load_config()
|
||||
|
||||
# Initialize network monitor
|
||||
self.start_network_monitoring()
|
||||
|
||||
# Play intro video first
|
||||
self.play_intro_video()
|
||||
|
||||
@@ -1404,6 +1588,30 @@ class SignagePlayer(Widget):
|
||||
Logger.info("SignagePlayer: Configuration saved")
|
||||
except Exception as 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 coroutine to check for playlist updates without blocking UI"""
|
||||
@@ -1568,8 +1776,17 @@ class SignagePlayer(Widget):
|
||||
self.current_index = 0
|
||||
self.play_current_media()
|
||||
|
||||
def play_current_media(self):
|
||||
"""Play the current media item"""
|
||||
def play_current_media(self, force_reload=False):
|
||||
"""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):
|
||||
# End of playlist, restart
|
||||
self.restart_playlist()
|
||||
@@ -1626,7 +1843,7 @@ class SignagePlayer(Widget):
|
||||
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
|
||||
# Image file
|
||||
Logger.info(f"SignagePlayer: Media type: IMAGE")
|
||||
self.play_image(media_path, duration)
|
||||
self.play_image(media_path, duration, force_reload=force_reload)
|
||||
else:
|
||||
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
|
||||
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
|
||||
@@ -1726,17 +1943,37 @@ class SignagePlayer(Widget):
|
||||
except Exception as e:
|
||||
Logger.warning(f"SignagePlayer: Could not log video info: {e}")
|
||||
|
||||
def play_image(self, image_path, duration):
|
||||
def play_image(self, image_path, duration, force_reload=False):
|
||||
"""Play an image file"""
|
||||
try:
|
||||
Logger.info(f"SignagePlayer: Creating AsyncImage widget...")
|
||||
self.current_widget = AsyncImage(
|
||||
source=image_path,
|
||||
allow_stretch=True,
|
||||
keep_ratio=True, # Maintain aspect ratio
|
||||
size_hint=(1, 1),
|
||||
pos_hint={'center_x': 0.5, 'center_y': 0.5}
|
||||
)
|
||||
# Log file info before loading
|
||||
file_size = os.path.getsize(image_path)
|
||||
file_mtime = os.path.getmtime(image_path)
|
||||
Logger.info(f"SignagePlayer: Loading image: {os.path.basename(image_path)}")
|
||||
Logger.info(f" - Size: {file_size:,} bytes, Modified: {file_mtime}")
|
||||
|
||||
if force_reload:
|
||||
Logger.info(f"SignagePlayer: Force reload - using Image widget (no async cache)")
|
||||
# Use regular Image widget instead of AsyncImage to bypass all caching
|
||||
from kivy.uix.image import Image
|
||||
self.current_widget = Image(
|
||||
source=image_path,
|
||||
allow_stretch=True,
|
||||
keep_ratio=True,
|
||||
size_hint=(1, 1),
|
||||
pos_hint={'center_x': 0.5, 'center_y': 0.5}
|
||||
)
|
||||
# Force reload the texture
|
||||
self.current_widget.reload()
|
||||
else:
|
||||
Logger.info(f"SignagePlayer: Creating AsyncImage widget...")
|
||||
self.current_widget = AsyncImage(
|
||||
source=image_path,
|
||||
allow_stretch=True,
|
||||
keep_ratio=True, # Maintain aspect ratio
|
||||
size_hint=(1, 1),
|
||||
pos_hint={'center_x': 0.5, 'center_y': 0.5}
|
||||
)
|
||||
Logger.info(f"SignagePlayer: Adding image widget to content area...")
|
||||
self.ids.content_area.add_widget(self.current_widget)
|
||||
# Schedule next media after duration (unschedule first to prevent overlaps)
|
||||
@@ -1753,7 +1990,7 @@ class SignagePlayer(Widget):
|
||||
def next_media(self, dt=None):
|
||||
"""Move to next media item"""
|
||||
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
|
||||
|
||||
Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})")
|
||||
@@ -1773,15 +2010,52 @@ class SignagePlayer(Widget):
|
||||
self.play_current_media()
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# 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:
|
||||
self.ids.play_pause_btn.text = '⏸'
|
||||
# Resume by playing current media
|
||||
# Playing - change icon to pause and cancel auto-resume
|
||||
# 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()
|
||||
|
||||
def restart_playlist(self):
|
||||
@@ -2119,6 +2393,11 @@ class SignagePlayerApp(App):
|
||||
def on_stop(self):
|
||||
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
|
||||
try:
|
||||
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": "rpi-tvholba1",
|
||||
"auth_code": "73XSgIh2iBu3jaU1VOWSrYSS7c9fCPuZuRd7ygYDcjc",
|
||||
"hostname": "tv-terasa",
|
||||
"auth_code": "vkrxEO6eOTxkzXJBtoN4OuXc8eaX2mC3AB9ZePrnick",
|
||||
"player_id": 1,
|
||||
"player_name": "Tv-Anunturi Hol Ba1",
|
||||
"player_name": "TV-acasa",
|
||||
"playlist_id": 1,
|
||||
"orientation": "Landscape",
|
||||
"authenticated": true,
|
||||
"server_url": "http://digiserver"
|
||||
"server_url": "http://digi-signage.moto-adv.com"
|
||||
}
|
||||
@@ -256,7 +256,7 @@
|
||||
id: play_pause_btn
|
||||
size_hint: None, None
|
||||
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'
|
||||
border: (0, 0, 0, 0)
|
||||
on_press: root.toggle_pause()
|
||||
@@ -526,6 +526,42 @@
|
||||
Widget:
|
||||
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
|
||||
Button:
|
||||
id: test_connection_btn
|
||||
@@ -549,36 +585,39 @@
|
||||
Widget:
|
||||
size_hint_y: 0.05
|
||||
|
||||
# Status information
|
||||
Label:
|
||||
id: playlist_info
|
||||
text: 'Playlist Version: N/A'
|
||||
# Status information row
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
id: media_count_info
|
||||
text: 'Media Count: 0'
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
Label:
|
||||
id: playlist_info
|
||||
text: 'Playlist: N/A'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(12)
|
||||
|
||||
Label:
|
||||
id: status_info
|
||||
text: 'Status: Idle'
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
Label:
|
||||
id: media_count_info
|
||||
text: 'Media: 0'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(12)
|
||||
|
||||
Label:
|
||||
id: status_info
|
||||
text: 'Status: Idle'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(12)
|
||||
|
||||
Widget:
|
||||
size_hint_y: 0.2
|
||||
size_hint_y: 0.05
|
||||
|
||||
# Action buttons
|
||||
BoxLayout:
|
||||
|
||||
61
src/test_network_monitor.py
Normal file
61
src/test_network_monitor.py
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for network monitor functionality
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from kivy.app import App
|
||||
from kivy.clock import Clock
|
||||
from network_monitor import NetworkMonitor
|
||||
|
||||
class TestMonitorApp(App):
|
||||
"""Minimal Kivy app to test network monitor"""
|
||||
|
||||
def build(self):
|
||||
"""Build the app"""
|
||||
from kivy.uix.label import Label
|
||||
return Label(text='Network Monitor Test Running\nCheck terminal for output')
|
||||
|
||||
def on_start(self):
|
||||
"""Start monitoring when app starts"""
|
||||
server_url = "https://digi-signage.moto-adv.com"
|
||||
|
||||
print("=" * 60)
|
||||
print("Network Monitor Test")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(f"Server URL: {server_url}")
|
||||
print("Check interval: 0.5 minutes (30 seconds for testing)")
|
||||
print("WiFi restart duration: 1 minute (for testing)")
|
||||
print()
|
||||
|
||||
# Create monitor with short intervals for testing
|
||||
self.monitor = NetworkMonitor(
|
||||
server_url=server_url,
|
||||
check_interval_min=0.5, # 30 seconds
|
||||
check_interval_max=0.5, # 30 seconds
|
||||
wifi_restart_duration=1 # 1 minute
|
||||
)
|
||||
|
||||
# Perform immediate test
|
||||
print("Performing immediate connectivity test...")
|
||||
self.monitor._check_connectivity()
|
||||
|
||||
# Start monitoring for future checks
|
||||
print("\nStarting periodic network monitoring...")
|
||||
self.monitor.start_monitoring()
|
||||
|
||||
print("\nMonitoring is active. Press Ctrl+C to stop.")
|
||||
print("Next check will occur in ~30 seconds.")
|
||||
print()
|
||||
|
||||
def on_stop(self):
|
||||
"""Stop monitoring when app stops"""
|
||||
if hasattr(self, 'monitor'):
|
||||
self.monitor.stop_monitoring()
|
||||
print("\nNetwork monitoring stopped")
|
||||
print("Test completed!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
TestMonitorApp().run()
|
||||
Reference in New Issue
Block a user