Compare commits

..

14 Commits

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

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

- UI improvements:
  * Smoother intro-to-playlist transition
  * Fix edited media directory creation for nested paths
2025-12-14 16:57:47 +02:00
Kiwy Signage Player
b2d380511a Refactor: Move UI definitions to KV file and modularize edit popup
- Created src/edit_popup.py module for EditPopup and DrawingLayer classes
- Moved EditPopup UI definition to signage_player.kv (reduced main.py by 533 lines)
- Moved CardSwipePopup UI definition to signage_player.kv (reduced main.py by 41 lines)
- Improved code organization with better separation of concerns
- main.py reduced from 2,384 to 1,811 lines (24% reduction)
- All functionality preserved, no breaking changes
2025-12-14 14:48:35 +02:00
Kiwy Signage Player
db796e4d66 updated card check 2025-12-13 19:39:00 +02:00
Kiwy Signage Player
5843bb5215 renamed card checked 2025-12-13 19:09:06 +02:00
2b42999008 aded card check 2025-12-13 17:05:49 +00:00
02e9ea1aaa updated to monitor the netowrk and reset wifi if is not working 2025-12-10 15:53:30 +02:00
Kiwy Signage Player
4c3ddbef73 updated to correctly play the playlist and reload images after edited 2025-12-10 00:09:20 +02:00
Kiwy Signage Player
87e059e0f4 updated to corect function the play pause function 2025-12-09 18:53:34 +02:00
Kiwy Signage Player
46d9fcf6e3 delete watch dog 2025-12-08 21:52:13 +02:00
Kiwy Signage Player
f1a84d05d5 updated buttons in settings 2025-12-08 21:52:03 +02:00
Kiwy Signage Player
706af95557 deleted unnecesary files 2025-12-08 18:17:09 +02:00
02227a12e5 updeated to read specific card 2025-12-08 15:45:37 +02:00
9d32f43ac7 Add edit feature enable/disable setting
- Added checkbox in Settings screen to enable/disable edit feature
- Setting stored in app_config.json as 'edit_feature_enabled'
- Edit workflow now validates: player setting, media type, server permission, card auth
- Shows appropriate error message when edit is blocked at any validation step
- Defaults to enabled (true) if not set
- All conditions must be met for edit interface to open
2025-12-08 14:30:12 +02:00
15 changed files with 1981 additions and 874 deletions

View File

@@ -1 +0,0 @@
User requested exit via password

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

53
setup_wifi_control.sh Normal file
View File

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

View File

@@ -1,100 +0,0 @@
Kiwy drawing
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.image import Image
from kivy.uix.button import Button
from kivy.uix.widget import Widget
from kivy.graphics import Color, Line
from kivy.core.window import Window
class DrawLayer(Widget):
def init(self, **kwargs):
super().init(**kwargs)
self.strokes = [] # store all drawn lines
self.current_color = (1, 0, 0) # default red
self.current_width = 2 # default thickness
self.drawing_enabled = False # drawing toggle
def on_touch_down(self, touch):
if not self.drawing_enabled:
return False
with self.canvas:
Color(*self.current_color)
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
self.strokes.append(new_line)
return True
def on_touch_move(self, touch):
if self.strokes and self.drawing_enabled:
self.strokes[-1].points += [touch.x, touch.y]
# ==========================
# UNDO LAST LINE
# ==========================
def undo(self):
if self.strokes:
last = self.strokes.pop()
self.canvas.remove(last)
# ==========================
# CHANGE COLOR
# ==========================
def set_color(self, color_tuple):
self.current_color = color_tuple
# ==========================
# CHANGE LINE WIDTH
# ==========================
def set_thickness(self, value):
self.current_width = value
class EditorUI(BoxLayout):
def init(self, **kwargs):
super().init(orientation="vertical", **kwargs)
# Background image
self.img = Image(source="graph.png", allow_stretch=True)
self.add_widget(self.img)
# Drawing layer above image
self.draw = DrawLayer()
self.add_widget(self.draw)
# Toolbar
toolbar = BoxLayout(size_hint_y=0.15)
toolbar.add_widget(Button(text="Draw On", on_press=self.toggle_draw))
toolbar.add_widget(Button(text="Red", on_press=lambda x: self.draw.set_color((1,0,0))))
toolbar.add_widget(Button(text="Blue", on_press=lambda x: self.draw.set_color((0,0,1))))
toolbar.add_widget(Button(text="Green", on_press=lambda x: self.draw.set_color((0,1,0))))
toolbar.add_widget(Button(text="Thin", on_press=lambda x: self.draw.set_thickness(2)))
toolbar.add_widget(Button(text="Thick", on_press=lambda x: self.draw.set_thickness(6)))
toolbar.add_widget(Button(text="Undo", on_press=lambda x: self.draw.undo()))
toolbar.add_widget(Button(text="Save", on_press=lambda x: self.save_image()))
self.add_widget(toolbar)
# ==========================
# TOGGLE DRAWING MODE
# ==========================
def toggle_draw(self, btn):
self.draw.drawing_enabled = not self.draw.drawing_enabled
btn.text = "Draw Off" if self.draw.drawing_enabled else "Draw On"
# ==========================
# SAVE MERGED IMAGE
# ==========================
def save_image(self):
self.export_to_png("edited_graph.png")
print("Saved as edited_graph.png")
class AnnotatorApp(App):
def build(self):
return EditorUI()
AnnotatorApp().run()

531
src/edit_popup.py Normal file
View File

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

View File

@@ -188,10 +188,9 @@ def fetch_server_playlist(config):
return {'playlist': [], 'version': 0} return {'playlist': [], 'version': 0}
def save_playlist_with_version(playlist_data, playlist_dir): def save_playlist(playlist_data, playlist_dir):
"""Save playlist to file with version number.""" """Save playlist to a single file (no versioning)."""
version = playlist_data.get('version', 0) playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
# Ensure directory exists # Ensure directory exists
os.makedirs(playlist_dir, exist_ok=True) os.makedirs(playlist_dir, exist_ok=True)
@@ -222,6 +221,9 @@ def download_media_files(playlist, media_dir):
logger.info(f"✓ File {file_name} already exists. Skipping download.") logger.info(f"✓ File {file_name} already exists. Skipping download.")
else: else:
try: try:
# Create parent directories if they don't exist (for nested paths like edited_media/5/)
os.makedirs(os.path.dirname(local_path), exist_ok=True)
response = requests.get(file_url, timeout=30) response = requests.get(file_url, timeout=30)
if response.status_code == 200: if response.status_code == 200:
with open(local_path, 'wb') as file: with open(local_path, 'wb') as file:
@@ -245,37 +247,59 @@ def download_media_files(playlist, media_dir):
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:
try:
version = int(f.replace('server_playlist_v', '').replace('.json', ''))
versions.append((version, f))
except ValueError:
continue
versions.sort(reverse=True) if os.path.exists(media_dir):
# Recursively get all media files
deleted_count = 0
for root, dirs, files in os.walk(media_dir):
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)
# Keep only the latest N versions # Skip if file is in current playlist
files_to_delete = [f for v, f in versions[keep_versions:]] if rel_path in referenced_files:
continue
for f in files_to_delete: # Delete unreferenced file
filepath = os.path.join(playlist_dir, f) try:
os.remove(filepath) os.remove(full_path)
logger.info(f"🗑️ Deleted old playlist: {f}") logger.info(f"🗑️ Deleted unused media: {rel_path}")
deleted_count += 1
except Exception as e:
logger.warning(f"⚠️ Could not delete {rel_path}: {e}")
# TODO: Clean up unused media files # Clean up empty directories
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest 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
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: 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):
@@ -289,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) try:
if f.startswith('server_playlist_v') and f.endswith('.json')] with open(playlist_file, 'r') as f:
local_data = json.load(f)
for f in playlist_files: local_version = local_data.get('version', 0)
try: except Exception as e:
version = int(f.replace('server_playlist_v', '').replace('.json', '')) logger.warning(f"⚠️ Could not read local playlist: {e}")
if version > local_version:
local_version = version
local_playlist_file = os.path.join(playlist_dir, f)
except ValueError:
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}")
@@ -316,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}")

File diff suppressed because it is too large Load Diff

235
src/network_monitor.py Normal file
View File

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

View File

@@ -256,7 +256,7 @@
id: play_pause_btn id: play_pause_btn
size_hint: None, None size_hint: None, None
size: dp(50), dp(50) size: dp(50), dp(50)
background_normal: root.resources_path + '/play.png' background_normal: root.resources_path + '/pause.png'
background_down: root.resources_path + '/pause.png' background_down: root.resources_path + '/pause.png'
border: (0, 0, 0, 0) border: (0, 0, 0, 0)
on_press: root.toggle_pause() on_press: root.toggle_pause()
@@ -493,9 +493,75 @@
write_tab: False write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Edit Feature Enable/Disable
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Enable Edit Feature:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
CheckBox:
id: edit_enabled_checkbox
size_hint_x: None
width: dp(40)
active: True
on_active: root.on_edit_feature_toggle(self.active)
Label:
text: '(Allow editing images on this player)'
size_hint_x: 0.4
font_size: sp(12)
text_size: self.size
halign: 'left'
valign: 'middle'
color: 0.7, 0.7, 0.7, 1
Widget: Widget:
size_hint_y: 0.05 size_hint_y: 0.05
# Reset Buttons Section
Label:
text: 'Reset Options:'
size_hint_y: None
height: dp(30)
text_size: self.size
halign: 'left'
valign: 'middle'
bold: True
font_size: sp(16)
# Reset Buttons Row
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(50)
spacing: dp(10)
Button:
id: reset_auth_btn
text: 'Reset Player Auth'
background_color: 0.8, 0.4, 0.2, 1
on_press: root.reset_player_auth()
Button:
id: reset_playlist_btn
text: 'Reset Playlist to v0'
background_color: 0.8, 0.4, 0.2, 1
on_press: root.reset_playlist_version()
Button:
id: restart_player_btn
text: 'Restart Player'
background_color: 0.2, 0.6, 0.8, 1
on_press: root.restart_player()
# Test Connection Button # Test Connection Button
Button: Button:
id: test_connection_btn id: test_connection_btn
@@ -519,36 +585,39 @@
Widget: Widget:
size_hint_y: 0.05 size_hint_y: 0.05
# Status information # Status information row
Label: BoxLayout:
id: playlist_info orientation: 'horizontal'
text: 'Playlist Version: N/A'
size_hint_y: None size_hint_y: None
height: dp(30) height: dp(30)
text_size: self.size spacing: dp(10)
halign: 'left'
valign: 'middle'
Label: Label:
id: media_count_info id: playlist_info
text: 'Media Count: 0' text: 'Playlist: N/A'
size_hint_y: None text_size: self.size
height: dp(30) halign: 'center'
text_size: self.size valign: 'middle'
halign: 'left' font_size: sp(12)
valign: 'middle'
Label: Label:
id: status_info id: media_count_info
text: 'Status: Idle' text: 'Media: 0'
size_hint_y: None text_size: self.size
height: dp(30) halign: 'center'
text_size: self.size valign: 'middle'
halign: 'left' font_size: sp(12)
valign: 'middle'
Label:
id: status_info
text: 'Status: Idle'
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(12)
Widget: Widget:
size_hint_y: 0.2 size_hint_y: 0.05
# Action buttons # Action buttons
BoxLayout: BoxLayout:
@@ -566,3 +635,279 @@
text: 'Cancel' text: 'Cancel'
background_color: 0.6, 0.2, 0.2, 1 background_color: 0.6, 0.2, 0.2, 1
on_press: root.dismiss() on_press: root.dismiss()
# Card Swipe Popup
<CardSwipePopup>:
title: 'Card Authentication Required'
size_hint: 0.5, 0.4
auto_dismiss: False
separator_height: 2
BoxLayout:
orientation: 'vertical'
padding: dp(20)
spacing: dp(20)
# Card swipe icon
AsyncImage:
id: icon_image
size_hint: 1, 0.4
allow_stretch: True
keep_ratio: True
# Message label
Label:
id: message_label
text: 'Please swipe your card...'
font_size: sp(20)
size_hint: 1, 0.2
# Countdown timer
Label:
id: countdown_label
text: '5'
font_size: sp(48)
color: 0.9, 0.6, 0.2, 1
size_hint: 1, 0.2
# Cancel button
Button:
text: 'Cancel'
size_hint: 1, 0.2
background_color: 0.9, 0.3, 0.2, 1
on_press: root.cancel(self)
# Edit Popup (Drawing on Images)
<EditPopup>:
title: ''
size_hint: 1, 1
auto_dismiss: False
separator_height: 0
FloatLayout:
# Background image (full screen)
Image:
id: image_widget
allow_stretch: True
keep_ratio: True
size_hint: 1, 1
pos_hint: {'x': 0, 'y': 0}
# Drawing layer (will be added programmatically due to custom class)
# Placeholder widget for drawing layer positioning
Widget:
id: drawing_layer_placeholder
size_hint: 1, 1
pos_hint: {'x': 0, 'y': 0}
# Top toolbar
BoxLayout:
id: top_toolbar
orientation: 'horizontal'
size_hint: 1, None
height: dp(56)
pos_hint: {'top': 1, 'x': 0}
spacing: dp(10)
padding: [dp(10), dp(8)]
canvas.before:
Color:
rgba: 0.1, 0.1, 0.1, 0.5
Rectangle:
size: self.size
pos: self.pos
Widget: # Spacer
Button:
id: undo_btn
text: 'Undo'
font_size: sp(16)
size_hint: None, 1
width: dp(100)
background_normal: ''
background_color: 0.9, 0.6, 0.2, 0.9
Button:
id: clear_btn
text: 'Clear'
font_size: sp(16)
size_hint: None, 1
width: dp(100)
background_normal: ''
background_color: 0.9, 0.3, 0.2, 0.9
Button:
id: save_btn
text: 'Save'
font_size: sp(16)
size_hint: None, 1
width: dp(100)
background_normal: ''
background_color: 0.2, 0.8, 0.2, 0.9
Button:
id: cancel_btn
text: 'Cancel'
font_size: sp(16)
size_hint: None, 1
width: dp(100)
background_normal: ''
background_color: 0.6, 0.2, 0.2, 0.9
Label:
id: countdown_label
text: '5:00'
font_size: sp(20)
size_hint: None, 1
width: dp(80)
color: 1, 1, 1, 1
bold: True
Widget: # Small spacer
size_hint: None, 1
width: dp(10)
# Right sidebar
BoxLayout:
id: right_sidebar
orientation: 'vertical'
size_hint: None, 1
width: dp(56)
pos_hint: {'right': 1, 'y': 0}
spacing: dp(10)
padding: [dp(8), dp(66), dp(8), dp(10)]
canvas.before:
Color:
rgba: 0.1, 0.1, 0.1, 0.5
Rectangle:
size: self.size
pos: self.pos
# Color section header
BoxLayout:
orientation: 'vertical'
size_hint_y: None
height: dp(55)
spacing: dp(2)
Image:
id: color_icon
size_hint_y: None
height: dp(28)
allow_stretch: True
keep_ratio: True
Label:
text: 'Color'
font_size: sp(11)
bold: True
size_hint_y: None
height: dp(25)
# Color buttons
Button:
id: red_btn
text: 'R'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 1, 0, 0, 1
Button:
id: blue_btn
text: 'B'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0, 0, 1, 1
Button:
id: green_btn
text: 'G'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0, 1, 0, 1
Button:
id: black_btn
text: 'K'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0, 0, 0, 1
Button:
id: white_btn
text: 'W'
font_size: sp(18)
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 1, 1, 1, 1
# Spacer
Widget:
size_hint_y: 0.2
# Thickness section header
BoxLayout:
orientation: 'vertical'
size_hint_y: None
height: dp(55)
spacing: dp(2)
Image:
id: thickness_icon
size_hint_y: None
height: dp(28)
allow_stretch: True
keep_ratio: True
Label:
text: 'Size'
font_size: sp(11)
bold: True
size_hint_y: None
height: dp(25)
# Thickness buttons
Button:
id: small_btn
text: 'S'
font_size: sp(20)
bold: True
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0.3, 0.3, 0.3, 0.9
Button:
id: medium_btn
text: 'M'
font_size: sp(20)
bold: True
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0.3, 0.3, 0.3, 0.9
Button:
id: large_btn
text: 'L'
font_size: sp(20)
bold: True
size_hint: 1, None
height: dp(50)
background_normal: ''
background_color: 0.3, 0.3, 0.3, 0.9
Widget: # Bottom spacer

View File

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

View File

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

View File

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