Compare commits
6 Commits
07b7e96edd
...
af1e671c7f
| Author | SHA1 | Date | |
|---|---|---|---|
| af1e671c7f | |||
|
|
9664ad541b | ||
|
|
89e5ad86dd | ||
|
|
f573af0505 | ||
|
|
fba2007bdf | ||
|
|
72d382b96b |
@@ -1 +0,0 @@
|
|||||||
1763799978.6257727
|
|
||||||
1
.player_stop_requested
Normal file
1
.player_stop_requested
Normal file
@@ -0,0 +1 @@
|
|||||||
|
User requested exit via password
|
||||||
BIN
config/resources/access-card.png
Normal file
BIN
config/resources/access-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
config/resources/edit-pen.png
Normal file
BIN
config/resources/edit-pen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
config/resources/pencil.png
Normal file
BIN
config/resources/pencil.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
media/wp1993621.webp
Normal file
BIN
media/wp1993621.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
@@ -3,4 +3,5 @@ ffpyplayer
|
|||||||
requests==2.32.4
|
requests==2.32.4
|
||||||
bcrypt==4.2.1
|
bcrypt==4.2.1
|
||||||
aiohttp==3.9.1
|
aiohttp==3.9.1
|
||||||
asyncio==3.4.3
|
asyncio==3.4.3
|
||||||
|
evdev>=1.6.0
|
||||||
100
src/edit_drowing.py
Normal file
100
src/edit_drowing.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
@@ -237,7 +237,8 @@ def download_media_files(playlist, media_dir):
|
|||||||
updated_media = {
|
updated_media = {
|
||||||
'file_name': file_name,
|
'file_name': file_name,
|
||||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||||
'duration': duration
|
'duration': duration,
|
||||||
|
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
|
||||||
}
|
}
|
||||||
updated_playlist.append(updated_media)
|
updated_playlist.append(updated_media)
|
||||||
|
|
||||||
|
|||||||
878
src/main.py
878
src/main.py
@@ -35,23 +35,788 @@ from kivy.clock import Clock
|
|||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.properties import BooleanProperty
|
from kivy.properties import BooleanProperty
|
||||||
from kivy.logger import Logger
|
from kivy.logger import Logger
|
||||||
|
|
||||||
|
# Try to import evdev for card reader support (after Logger is available)
|
||||||
|
try:
|
||||||
|
import evdev
|
||||||
|
from evdev import InputDevice, categorize, ecodes
|
||||||
|
EVDEV_AVAILABLE = True
|
||||||
|
Logger.info("CardReader: evdev library loaded successfully")
|
||||||
|
except ImportError:
|
||||||
|
EVDEV_AVAILABLE = False
|
||||||
|
Logger.warning("CardReader: evdev not available - card reader functionality will use fallback mode")
|
||||||
from kivy.animation import Animation
|
from kivy.animation import Animation
|
||||||
from kivy.lang import Builder
|
from kivy.lang import Builder
|
||||||
from kivy.graphics import Rectangle
|
from kivy.graphics import Rectangle
|
||||||
from kivy.graphics.texture import Texture
|
from kivy.graphics.texture import Texture
|
||||||
from get_playlists import (
|
from get_playlists_v2 import (
|
||||||
update_playlist_if_needed,
|
update_playlist_if_needed,
|
||||||
send_playing_status_feedback,
|
send_playing_status_feedback,
|
||||||
send_playlist_restart_feedback,
|
send_playlist_restart_feedback,
|
||||||
send_player_error_feedback
|
send_player_error_feedback
|
||||||
)
|
)
|
||||||
from keyboard_widget import KeyboardWidget
|
from keyboard_widget import KeyboardWidget
|
||||||
|
from kivy.graphics import Color, Line, Ellipse
|
||||||
|
from kivy.uix.floatlayout import FloatLayout
|
||||||
|
from kivy.uix.slider import Slider
|
||||||
|
|
||||||
# Load the KV file
|
# Load the KV file
|
||||||
Builder.load_file('signage_player.kv')
|
Builder.load_file('signage_player.kv')
|
||||||
|
|
||||||
# Removed VLCVideoWidget - using Kivy's built-in Video widget instead
|
# Removed VLCVideoWidget - using Kivy's built-in Video widget instead
|
||||||
|
|
||||||
|
class DrawingLayer(Widget):
|
||||||
|
"""Layer for drawing on top of images"""
|
||||||
|
def __init__(self, **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
|
||||||
|
|
||||||
|
def on_touch_down(self, touch):
|
||||||
|
if not self.drawing_enabled or not self.collide_point(*touch.pos):
|
||||||
|
return False
|
||||||
|
|
||||||
|
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:
|
||||||
|
touch.ud['line'].points += [touch.x, touch.y]
|
||||||
|
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
|
||||||
|
Logger.debug(f"DrawingLayer: Color set to {color_tuple}")
|
||||||
|
|
||||||
|
def set_thickness(self, value):
|
||||||
|
"""Set line thickness"""
|
||||||
|
self.current_width = value
|
||||||
|
Logger.debug(f"DrawingLayer: Thickness set to {value}")
|
||||||
|
|
||||||
|
|
||||||
|
class CardReader:
|
||||||
|
"""USB Card Reader Handler for user authentication"""
|
||||||
|
def __init__(self):
|
||||||
|
self.device = None
|
||||||
|
self.card_data = ""
|
||||||
|
self.reading = False
|
||||||
|
self.last_read_time = 0
|
||||||
|
self.read_timeout = 5 # seconds
|
||||||
|
self.evdev_available = EVDEV_AVAILABLE
|
||||||
|
|
||||||
|
def find_card_reader(self):
|
||||||
|
"""Find USB card reader device"""
|
||||||
|
if not self.evdev_available:
|
||||||
|
Logger.error("CardReader: evdev library not available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
|
||||||
|
# Look for devices that might be card readers (keyboards, HID devices)
|
||||||
|
for device in devices:
|
||||||
|
# Card readers typically show up as keyboard input devices
|
||||||
|
if 'keyboard' in device.name.lower() or 'card' in device.name.lower() or 'reader' in device.name.lower():
|
||||||
|
Logger.info(f"CardReader: Found potential card reader: {device.name} at {device.path}")
|
||||||
|
self.device = device
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If no specific card reader found, use first keyboard device
|
||||||
|
for device in devices:
|
||||||
|
capabilities = device.capabilities()
|
||||||
|
if ecodes.EV_KEY in capabilities:
|
||||||
|
Logger.info(f"CardReader: Using keyboard device as card reader: {device.name}")
|
||||||
|
self.device = device
|
||||||
|
return True
|
||||||
|
|
||||||
|
Logger.warning("CardReader: No suitable input device found")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"CardReader: Error finding device: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read_card_async(self, callback):
|
||||||
|
"""Start reading card data asynchronously"""
|
||||||
|
if not self.evdev_available:
|
||||||
|
Logger.warning("CardReader: evdev not available - waiting for manual timeout/cancel")
|
||||||
|
# Fallback: In development mode without evdev, don't auto-authenticate
|
||||||
|
# Let the popup timeout or user cancel manually
|
||||||
|
# This allows testing the popup behavior
|
||||||
|
# To enable auto-authentication for testing, uncomment the next line:
|
||||||
|
# Clock.schedule_once(lambda dt: callback("DEFAULT_USER_12345"), 0.5)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.device:
|
||||||
|
if not self.find_card_reader():
|
||||||
|
Logger.error("CardReader: Cannot start reading - no device found")
|
||||||
|
callback(None)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.reading = True
|
||||||
|
self.card_data = ""
|
||||||
|
self.last_read_time = time.time()
|
||||||
|
|
||||||
|
# Start reading in a separate thread
|
||||||
|
thread = threading.Thread(target=self._read_card_thread, args=(callback,))
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _read_card_thread(self, callback):
|
||||||
|
"""Thread function to read card data"""
|
||||||
|
try:
|
||||||
|
Logger.info("CardReader: Waiting for card swipe...")
|
||||||
|
for event in self.device.read_loop():
|
||||||
|
if not self.reading:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for timeout
|
||||||
|
if time.time() - self.last_read_time > self.read_timeout:
|
||||||
|
Logger.warning("CardReader: Read timeout")
|
||||||
|
self.reading = False
|
||||||
|
callback(None)
|
||||||
|
break
|
||||||
|
|
||||||
|
if event.type == ecodes.EV_KEY:
|
||||||
|
key_event = categorize(event)
|
||||||
|
if key_event.keystate == 1: # Key down
|
||||||
|
key_code = key_event.keycode
|
||||||
|
|
||||||
|
# Handle Enter key (card read complete)
|
||||||
|
if key_code == 'KEY_ENTER':
|
||||||
|
Logger.info(f"CardReader: Card read complete: {self.card_data}")
|
||||||
|
self.reading = False
|
||||||
|
callback(self.card_data)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build card data string
|
||||||
|
elif key_code.startswith('KEY_'):
|
||||||
|
char = key_code.replace('KEY_', '')
|
||||||
|
if len(char) == 1: # Single character
|
||||||
|
self.card_data += char
|
||||||
|
self.last_read_time = time.time()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"CardReader: Error reading card: {e}")
|
||||||
|
self.reading = False
|
||||||
|
callback(None)
|
||||||
|
|
||||||
|
def stop_reading(self):
|
||||||
|
"""Stop reading card data"""
|
||||||
|
self.reading = False
|
||||||
|
|
||||||
|
|
||||||
|
class CardSwipePopup(Popup):
|
||||||
|
"""Popup that shows card swipe prompt with 5 second timeout"""
|
||||||
|
def __init__(self, callback, resources_path, **kwargs):
|
||||||
|
super(CardSwipePopup, self).__init__(**kwargs)
|
||||||
|
self.callback = callback
|
||||||
|
self.timeout_event = None
|
||||||
|
|
||||||
|
# Popup settings
|
||||||
|
self.title = 'Card Authentication Required'
|
||||||
|
self.size_hint = (0.5, 0.4)
|
||||||
|
self.auto_dismiss = False
|
||||||
|
self.separator_height = 2
|
||||||
|
|
||||||
|
# Main layout
|
||||||
|
layout = BoxLayout(orientation='vertical', padding=20, spacing=20)
|
||||||
|
|
||||||
|
# Card swipe icon (using image)
|
||||||
|
icon_path = os.path.join(resources_path, 'access-card.png')
|
||||||
|
icon_image = Image(
|
||||||
|
source=icon_path,
|
||||||
|
size_hint=(1, 0.4),
|
||||||
|
allow_stretch=True,
|
||||||
|
keep_ratio=True
|
||||||
|
)
|
||||||
|
layout.add_widget(icon_image)
|
||||||
|
|
||||||
|
# Message
|
||||||
|
self.message_label = Label(
|
||||||
|
text='Please swipe your card...',
|
||||||
|
font_size='20sp',
|
||||||
|
size_hint=(1, 0.2)
|
||||||
|
)
|
||||||
|
layout.add_widget(self.message_label)
|
||||||
|
|
||||||
|
# Countdown timer display
|
||||||
|
self.countdown_label = Label(
|
||||||
|
text='5',
|
||||||
|
font_size='48sp',
|
||||||
|
color=(0.9, 0.6, 0.2, 1),
|
||||||
|
size_hint=(1, 0.2)
|
||||||
|
)
|
||||||
|
layout.add_widget(self.countdown_label)
|
||||||
|
|
||||||
|
# Cancel button
|
||||||
|
cancel_btn = Button(
|
||||||
|
text='Cancel',
|
||||||
|
size_hint=(1, 0.2),
|
||||||
|
background_color=(0.9, 0.3, 0.2, 1)
|
||||||
|
)
|
||||||
|
cancel_btn.bind(on_press=self.cancel)
|
||||||
|
layout.add_widget(cancel_btn)
|
||||||
|
|
||||||
|
self.content = layout
|
||||||
|
|
||||||
|
# Start countdown
|
||||||
|
self.remaining_time = 5
|
||||||
|
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
|
||||||
|
|
||||||
|
# Schedule timeout
|
||||||
|
self.timeout_event = Clock.schedule_once(self.on_timeout, 5)
|
||||||
|
|
||||||
|
def update_countdown(self, dt):
|
||||||
|
"""Update countdown display"""
|
||||||
|
self.remaining_time -= 1
|
||||||
|
self.countdown_label.text = str(self.remaining_time)
|
||||||
|
if self.remaining_time <= 0:
|
||||||
|
Clock.unschedule(self.countdown_event)
|
||||||
|
|
||||||
|
def on_timeout(self, dt):
|
||||||
|
"""Called when timeout occurs"""
|
||||||
|
Logger.warning("CardSwipePopup: Timeout - no card swiped")
|
||||||
|
self.message_label.text = 'Timeout - No card detected'
|
||||||
|
Clock.schedule_once(lambda dt: self.finish(None), 0.5)
|
||||||
|
|
||||||
|
def card_received(self, card_data):
|
||||||
|
"""Called when card data is received"""
|
||||||
|
Logger.info(f"CardSwipePopup: Card received: {card_data}")
|
||||||
|
self.message_label.text = '✓ Card detected'
|
||||||
|
self.countdown_label.text = '✓'
|
||||||
|
self.countdown_label.color = (0.2, 0.9, 0.3, 1)
|
||||||
|
Clock.schedule_once(lambda dt: self.finish(card_data), 0.5)
|
||||||
|
|
||||||
|
def cancel(self, instance):
|
||||||
|
"""Cancel button pressed"""
|
||||||
|
Logger.info("CardSwipePopup: Cancelled by user")
|
||||||
|
self.finish(None)
|
||||||
|
|
||||||
|
def finish(self, card_data):
|
||||||
|
"""Clean up and call callback"""
|
||||||
|
# Cancel scheduled events
|
||||||
|
if self.timeout_event:
|
||||||
|
Clock.unschedule(self.timeout_event)
|
||||||
|
if self.countdown_event:
|
||||||
|
Clock.unschedule(self.countdown_event)
|
||||||
|
|
||||||
|
# Dismiss popup
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
# Call callback with result
|
||||||
|
if self.callback:
|
||||||
|
self.callback(card_data)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
self.drawing_layer = None
|
||||||
|
|
||||||
|
# Pause playback
|
||||||
|
self.was_paused = self.player.is_paused
|
||||||
|
if not self.was_paused:
|
||||||
|
self.player.is_paused = True
|
||||||
|
Clock.unschedule(self.player.next_media)
|
||||||
|
if self.player.current_widget and isinstance(self.player.current_widget, Video):
|
||||||
|
self.player.current_widget.state = 'pause'
|
||||||
|
|
||||||
|
# Show cursor
|
||||||
|
try:
|
||||||
|
Window.show_cursor = True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build UI - Full screen without title bar
|
||||||
|
self.title = '' # No title
|
||||||
|
self.size_hint = (1, 1) # Full screen
|
||||||
|
self.auto_dismiss = False
|
||||||
|
self.separator_height = 0 # Remove separator
|
||||||
|
|
||||||
|
# Main container (FloatLayout for overlays)
|
||||||
|
main_container = FloatLayout()
|
||||||
|
|
||||||
|
# Background image (full screen)
|
||||||
|
self.image_widget = Image(
|
||||||
|
source=image_path,
|
||||||
|
allow_stretch=True,
|
||||||
|
keep_ratio=True,
|
||||||
|
size_hint=(1, 1),
|
||||||
|
pos_hint={'x': 0, 'y': 0}
|
||||||
|
)
|
||||||
|
main_container.add_widget(self.image_widget)
|
||||||
|
|
||||||
|
# Drawing layer on top of image (full screen)
|
||||||
|
self.drawing_layer = DrawingLayer(
|
||||||
|
size_hint=(1, 1),
|
||||||
|
pos_hint={'x': 0, 'y': 0}
|
||||||
|
)
|
||||||
|
main_container.add_widget(self.drawing_layer)
|
||||||
|
|
||||||
|
# Top toolbar (horizontal, 56dp height - 20% smaller than 70dp, 50% transparency)
|
||||||
|
top_toolbar = BoxLayout(
|
||||||
|
orientation='horizontal',
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=56,
|
||||||
|
pos_hint={'top': 1, 'x': 0},
|
||||||
|
spacing=10,
|
||||||
|
padding=[10, 8]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add semi-transparent background to toolbar
|
||||||
|
from kivy.graphics import Color, Rectangle
|
||||||
|
with top_toolbar.canvas.before:
|
||||||
|
Color(0.1, 0.1, 0.1, 0.5) # 50% transparency
|
||||||
|
top_toolbar.bg_rect = Rectangle(size=top_toolbar.size, pos=top_toolbar.pos)
|
||||||
|
|
||||||
|
top_toolbar.bind(pos=self._update_toolbar_bg, size=self._update_toolbar_bg)
|
||||||
|
|
||||||
|
# Action buttons in top toolbar (buttons with slightly rounded corners)
|
||||||
|
top_toolbar.add_widget(Widget()) # Spacer
|
||||||
|
|
||||||
|
undo_btn = Button(
|
||||||
|
text='Undo',
|
||||||
|
font_size='16sp',
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=100,
|
||||||
|
background_normal='',
|
||||||
|
background_color=(0.9, 0.6, 0.2, 0.9),
|
||||||
|
on_press=lambda x: self.drawing_layer.undo()
|
||||||
|
)
|
||||||
|
undo_btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
|
||||||
|
top_toolbar.add_widget(undo_btn)
|
||||||
|
|
||||||
|
clear_btn = Button(
|
||||||
|
text='Clear',
|
||||||
|
font_size='16sp',
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=100,
|
||||||
|
background_normal='',
|
||||||
|
background_color=(0.9, 0.3, 0.2, 0.9),
|
||||||
|
on_press=lambda x: self.drawing_layer.clear_all()
|
||||||
|
)
|
||||||
|
clear_btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
|
||||||
|
top_toolbar.add_widget(clear_btn)
|
||||||
|
|
||||||
|
save_btn = Button(
|
||||||
|
text='Save',
|
||||||
|
font_size='16sp',
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=100,
|
||||||
|
background_normal='',
|
||||||
|
background_color=(0.2, 0.8, 0.2, 0.9),
|
||||||
|
on_press=self.save_image
|
||||||
|
)
|
||||||
|
save_btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
|
||||||
|
top_toolbar.add_widget(save_btn)
|
||||||
|
|
||||||
|
cancel_btn = Button(
|
||||||
|
text='Cancel',
|
||||||
|
font_size='16sp',
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=100,
|
||||||
|
background_normal='',
|
||||||
|
background_color=(0.6, 0.2, 0.2, 0.9),
|
||||||
|
on_press=self.close_without_saving
|
||||||
|
)
|
||||||
|
cancel_btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
|
||||||
|
top_toolbar.add_widget(cancel_btn)
|
||||||
|
|
||||||
|
top_toolbar.add_widget(Widget()) # Spacer
|
||||||
|
|
||||||
|
main_container.add_widget(top_toolbar)
|
||||||
|
|
||||||
|
# Right sidebar (vertical, 56dp width to match toolbar height, 50% transparency)
|
||||||
|
right_sidebar = BoxLayout(
|
||||||
|
orientation='vertical',
|
||||||
|
size_hint=(None, 1),
|
||||||
|
width=56,
|
||||||
|
pos_hint={'right': 1, 'y': 0},
|
||||||
|
spacing=10,
|
||||||
|
padding=[8, 66, 8, 10] # Extra top padding to avoid toolbar (56 + 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add semi-transparent background to sidebar
|
||||||
|
with right_sidebar.canvas.before:
|
||||||
|
Color(0.1, 0.1, 0.1, 0.5) # 50% transparency
|
||||||
|
right_sidebar.bg_rect = Rectangle(size=right_sidebar.size, pos=right_sidebar.pos)
|
||||||
|
|
||||||
|
right_sidebar.bind(pos=self._update_sidebar_bg, size=self._update_sidebar_bg)
|
||||||
|
|
||||||
|
# Color section with icon
|
||||||
|
color_header = BoxLayout(
|
||||||
|
orientation='vertical',
|
||||||
|
size_hint_y=None,
|
||||||
|
height=55,
|
||||||
|
spacing=2
|
||||||
|
)
|
||||||
|
pen_icon_path = os.path.join(self.player.resources_path, 'edit-pen.png')
|
||||||
|
color_icon_img = Image(
|
||||||
|
source=pen_icon_path,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=28,
|
||||||
|
allow_stretch=True,
|
||||||
|
keep_ratio=True
|
||||||
|
)
|
||||||
|
color_label = Label(
|
||||||
|
text='Color',
|
||||||
|
font_size='11sp',
|
||||||
|
bold=True,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=25
|
||||||
|
)
|
||||||
|
color_header.add_widget(color_icon_img)
|
||||||
|
color_header.add_widget(color_label)
|
||||||
|
right_sidebar.add_widget(color_header)
|
||||||
|
|
||||||
|
# Color buttons (round)
|
||||||
|
colors = [
|
||||||
|
('R', (1, 0, 0, 1)),
|
||||||
|
('B', (0, 0, 1, 1)),
|
||||||
|
('G', (0, 1, 0, 1)),
|
||||||
|
('K', (0, 0, 0, 1)),
|
||||||
|
('W', (1, 1, 1, 1))
|
||||||
|
]
|
||||||
|
|
||||||
|
for text, color in colors:
|
||||||
|
color_btn = Button(
|
||||||
|
text=text,
|
||||||
|
font_size='18sp',
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=50,
|
||||||
|
background_normal='',
|
||||||
|
background_color=color,
|
||||||
|
on_press=lambda x, c=color: self.drawing_layer.set_color(c)
|
||||||
|
)
|
||||||
|
color_btn.bind(pos=self._make_round, size=self._make_round)
|
||||||
|
right_sidebar.add_widget(color_btn)
|
||||||
|
|
||||||
|
# Spacer
|
||||||
|
right_sidebar.add_widget(Widget(size_hint_y=0.2))
|
||||||
|
|
||||||
|
# Thickness section with icon
|
||||||
|
thickness_header = BoxLayout(
|
||||||
|
orientation='vertical',
|
||||||
|
size_hint_y=None,
|
||||||
|
height=55,
|
||||||
|
spacing=2
|
||||||
|
)
|
||||||
|
thickness_icon_img = Image(
|
||||||
|
source=pen_icon_path,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=28,
|
||||||
|
allow_stretch=True,
|
||||||
|
keep_ratio=True
|
||||||
|
)
|
||||||
|
thickness_label = Label(
|
||||||
|
text='Size',
|
||||||
|
font_size='11sp',
|
||||||
|
bold=True,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=25
|
||||||
|
)
|
||||||
|
thickness_header.add_widget(thickness_icon_img)
|
||||||
|
thickness_header.add_widget(thickness_label)
|
||||||
|
right_sidebar.add_widget(thickness_header)
|
||||||
|
|
||||||
|
# Thickness buttons (round, visual representation)
|
||||||
|
thicknesses = [
|
||||||
|
('S', 2),
|
||||||
|
('M', 5),
|
||||||
|
('L', 10)
|
||||||
|
]
|
||||||
|
|
||||||
|
for text, thickness in thicknesses:
|
||||||
|
thick_btn = Button(
|
||||||
|
text=text,
|
||||||
|
font_size='20sp',
|
||||||
|
bold=True,
|
||||||
|
size_hint=(1, None),
|
||||||
|
height=50,
|
||||||
|
background_normal='',
|
||||||
|
background_color=(0.3, 0.3, 0.3, 0.9),
|
||||||
|
on_press=lambda x, t=thickness: self.drawing_layer.set_thickness(t)
|
||||||
|
)
|
||||||
|
thick_btn.bind(pos=self._make_round, size=self._make_round)
|
||||||
|
right_sidebar.add_widget(thick_btn)
|
||||||
|
|
||||||
|
right_sidebar.add_widget(Widget()) # Bottom spacer
|
||||||
|
|
||||||
|
main_container.add_widget(right_sidebar)
|
||||||
|
|
||||||
|
self.content = main_container
|
||||||
|
self.top_toolbar = top_toolbar
|
||||||
|
self.right_sidebar = right_sidebar
|
||||||
|
|
||||||
|
# Bind to dismiss
|
||||||
|
self.bind(on_dismiss=self.on_popup_dismiss)
|
||||||
|
|
||||||
|
Logger.info(f"EditPopup: Opened for image {os.path.basename(image_path)}")
|
||||||
|
|
||||||
|
def _update_toolbar_bg(self, instance, value):
|
||||||
|
"""Update toolbar background rectangle"""
|
||||||
|
self.top_toolbar.bg_rect.pos = instance.pos
|
||||||
|
self.top_toolbar.bg_rect.size = instance.size
|
||||||
|
|
||||||
|
def _update_sidebar_bg(self, instance, value):
|
||||||
|
"""Update sidebar background rectangle"""
|
||||||
|
self.right_sidebar.bg_rect.pos = instance.pos
|
||||||
|
self.right_sidebar.bg_rect.size = instance.size
|
||||||
|
|
||||||
|
def _make_rounded_btn(self, instance, value):
|
||||||
|
"""Make toolbar button with slightly rounded corners"""
|
||||||
|
instance.canvas.before.clear()
|
||||||
|
with instance.canvas.before:
|
||||||
|
from kivy.graphics import RoundedRectangle
|
||||||
|
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:
|
||||||
|
from kivy.graphics import RoundedRectangle
|
||||||
|
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:
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 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.top_toolbar.opacity = 0
|
||||||
|
self.right_sidebar.opacity = 0
|
||||||
|
|
||||||
|
# Force canvas update
|
||||||
|
self.content.canvas.ask_update()
|
||||||
|
|
||||||
|
# Small delay to ensure rendering is complete
|
||||||
|
from kivy.clock import Clock
|
||||||
|
def do_export(dt):
|
||||||
|
try:
|
||||||
|
# Export only the visible content (image + drawings, no toolbars)
|
||||||
|
self.content.export_to_png(output_path)
|
||||||
|
|
||||||
|
# Restore toolbars
|
||||||
|
self.top_toolbar.opacity = 1
|
||||||
|
self.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)
|
||||||
|
|
||||||
|
Logger.info(f"EditPopup: Saved edited image to {output_path}")
|
||||||
|
|
||||||
|
# Upload to server asynchronously (non-blocking)
|
||||||
|
import threading
|
||||||
|
upload_thread = threading.Thread(
|
||||||
|
target=self._upload_to_server,
|
||||||
|
args=(output_path, json_filename),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
upload_thread.start()
|
||||||
|
|
||||||
|
# Show confirmation
|
||||||
|
self.title = f'Saved as {output_filename}'
|
||||||
|
Clock.schedule_once(lambda dt: self.dismiss(), 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.top_toolbar.opacity = 1
|
||||||
|
self.right_sidebar.opacity = 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"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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
|
||||||
|
import json
|
||||||
|
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")
|
||||||
|
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"""
|
||||||
|
# Resume playback if it wasn't paused before
|
||||||
|
if not self.was_paused:
|
||||||
|
self.player.is_paused = False
|
||||||
|
self.player.play_current_media()
|
||||||
|
|
||||||
|
# Restart control hide timer
|
||||||
|
self.player.schedule_hide_controls()
|
||||||
|
|
||||||
|
Logger.info("EditPopup: Dismissed, playback resumed")
|
||||||
|
|
||||||
# Custom keyboard container with close button
|
# Custom keyboard container with close button
|
||||||
class KeyboardContainer(BoxLayout):
|
class KeyboardContainer(BoxLayout):
|
||||||
def __init__(self, vkeyboard, **kwargs):
|
def __init__(self, vkeyboard, **kwargs):
|
||||||
@@ -497,6 +1262,9 @@ class SignagePlayer(Widget):
|
|||||||
self.consecutive_errors = 0 # Track consecutive playback errors
|
self.consecutive_errors = 0 # Track consecutive playback errors
|
||||||
self.max_consecutive_errors = 10 # Maximum errors before stopping
|
self.max_consecutive_errors = 10 # Maximum errors before stopping
|
||||||
self.intro_played = False # Track if intro has been played
|
self.intro_played = False # Track if intro has been played
|
||||||
|
# Card reader for authentication
|
||||||
|
self.card_reader = None
|
||||||
|
self._pending_edit_image = None
|
||||||
# Paths
|
# Paths
|
||||||
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
self.config_dir = os.path.join(self.base_dir, 'config')
|
self.config_dir = os.path.join(self.base_dir, 'config')
|
||||||
@@ -594,20 +1362,14 @@ class SignagePlayer(Widget):
|
|||||||
# Run blocking network I/O in thread pool
|
# Run blocking network I/O in thread pool
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
# Find latest playlist file
|
|
||||||
latest_playlist = await loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
self.get_latest_playlist_file
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for updates (network I/O in thread pool)
|
# Check for updates (network I/O in thread pool)
|
||||||
|
# Note: get_playlists_v2 doesn't need latest_playlist parameter
|
||||||
updated = await loop.run_in_executor(
|
updated = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
update_playlist_if_needed,
|
update_playlist_if_needed,
|
||||||
latest_playlist,
|
|
||||||
self.config,
|
self.config,
|
||||||
self.media_dir,
|
self.playlists_dir,
|
||||||
self.playlists_dir
|
self.media_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
@@ -665,7 +1427,8 @@ class SignagePlayer(Widget):
|
|||||||
|
|
||||||
if self.playlist:
|
if self.playlist:
|
||||||
self.ids.status_label.text = f"Playlist loaded: {len(self.playlist)} items"
|
self.ids.status_label.text = f"Playlist loaded: {len(self.playlist)} items"
|
||||||
if not self.is_playing:
|
# Only start playback if intro has finished
|
||||||
|
if not self.is_playing and self.intro_played:
|
||||||
Clock.schedule_once(self.start_playback, 1)
|
Clock.schedule_once(self.start_playback, 1)
|
||||||
else:
|
else:
|
||||||
self.ids.status_label.text = "No media in playlist"
|
self.ids.status_label.text = "No media in playlist"
|
||||||
@@ -807,13 +1570,13 @@ class SignagePlayer(Widget):
|
|||||||
# Video file
|
# Video file
|
||||||
Logger.info(f"SignagePlayer: Media type: VIDEO")
|
Logger.info(f"SignagePlayer: Media type: VIDEO")
|
||||||
self.play_video(media_path, duration)
|
self.play_video(media_path, duration)
|
||||||
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif']:
|
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
|
||||||
# Image file
|
# Image file
|
||||||
Logger.info(f"SignagePlayer: Media type: IMAGE")
|
Logger.info(f"SignagePlayer: Media type: IMAGE")
|
||||||
self.play_image(media_path, duration)
|
self.play_image(media_path, duration)
|
||||||
else:
|
else:
|
||||||
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
|
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
|
||||||
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif")
|
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
|
||||||
Logger.warning(f"SignagePlayer: Skipping to next media...")
|
Logger.warning(f"SignagePlayer: Skipping to next media...")
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
self.next_media()
|
self.next_media()
|
||||||
@@ -1070,6 +1833,95 @@ class SignagePlayer(Widget):
|
|||||||
popup = SettingsPopup(player_instance=self, was_paused=was_paused)
|
popup = SettingsPopup(player_instance=self, was_paused=was_paused)
|
||||||
popup.open()
|
popup.open()
|
||||||
|
|
||||||
|
def show_edit_interface(self, instance=None):
|
||||||
|
"""
|
||||||
|
Show edit interface for current image
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Validate media type (must be image)
|
||||||
|
2. Check edit_on_player permission from server
|
||||||
|
3. Prompt for card swipe (5 second timeout)
|
||||||
|
4. Open edit interface with card data stored
|
||||||
|
5. When saved, card data is included in metadata and sent to server
|
||||||
|
"""
|
||||||
|
# Check if current media is an image
|
||||||
|
if not self.playlist or self.current_index >= len(self.playlist):
|
||||||
|
Logger.warning("SignagePlayer: No media to edit")
|
||||||
|
return
|
||||||
|
|
||||||
|
media_item = self.playlist[self.current_index]
|
||||||
|
file_name = media_item.get('file_name', '')
|
||||||
|
file_extension = os.path.splitext(file_name)[1].lower()
|
||||||
|
|
||||||
|
# Check 1: Only allow editing images
|
||||||
|
if file_extension not in ['.jpg', '.jpeg', '.png', '.bmp', '.webp']:
|
||||||
|
Logger.warning(f"SignagePlayer: Cannot edit {file_extension} files, only images")
|
||||||
|
# Show error message briefly
|
||||||
|
self.ids.status_label.text = 'Can only edit image files'
|
||||||
|
self.ids.status_label.opacity = 1
|
||||||
|
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check 2: Verify edit_on_player permission from server
|
||||||
|
edit_allowed = media_item.get('edit_on_player', False)
|
||||||
|
if not edit_allowed:
|
||||||
|
Logger.warning(f"SignagePlayer: Edit not allowed for {file_name} (edit_on_player=false)")
|
||||||
|
# Show error message briefly
|
||||||
|
self.ids.status_label.text = 'Edit not permitted for this media'
|
||||||
|
self.ids.status_label.opacity = 1
|
||||||
|
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store image info
|
||||||
|
self._pending_edit_image = file_name
|
||||||
|
|
||||||
|
# Show card swipe popup with 5 second timeout
|
||||||
|
self._card_swipe_popup = CardSwipePopup(callback=self._on_card_swipe_result, resources_path=self.resources_path)
|
||||||
|
self._card_swipe_popup.open()
|
||||||
|
|
||||||
|
# Initialize card reader if not already created
|
||||||
|
if not self.card_reader:
|
||||||
|
self.card_reader = CardReader()
|
||||||
|
|
||||||
|
# Start reading card asynchronously
|
||||||
|
self.card_reader.read_card_async(self._on_card_data_received)
|
||||||
|
|
||||||
|
def _on_card_data_received(self, card_data):
|
||||||
|
"""Called when card reader gets data"""
|
||||||
|
if hasattr(self, '_card_swipe_popup') and self._card_swipe_popup:
|
||||||
|
self._card_swipe_popup.card_received(card_data)
|
||||||
|
|
||||||
|
def _on_card_swipe_result(self, card_data):
|
||||||
|
"""Handle result from card swipe popup (card data or None if timeout/cancelled)"""
|
||||||
|
# Get image path
|
||||||
|
file_name = self._pending_edit_image
|
||||||
|
image_path = os.path.join(self.media_dir, file_name)
|
||||||
|
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
Logger.error(f"SignagePlayer: Image not found: {image_path}")
|
||||||
|
self.ids.status_label.text = 'Image file not found'
|
||||||
|
self.ids.status_label.opacity = 1
|
||||||
|
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# If no card data (timeout or cancelled), don't open edit interface
|
||||||
|
if not card_data:
|
||||||
|
Logger.info("SignagePlayer: Edit cancelled - no card data")
|
||||||
|
self.ids.status_label.text = 'Edit cancelled - card required'
|
||||||
|
self.ids.status_label.opacity = 1
|
||||||
|
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store the card data (will be sent to server when save is done)
|
||||||
|
user_card_data = card_data.strip()
|
||||||
|
|
||||||
|
Logger.info(f"SignagePlayer: Card data captured: {user_card_data}")
|
||||||
|
Logger.info(f"SignagePlayer: Opening edit interface for {file_name}")
|
||||||
|
|
||||||
|
# Open edit popup with card data (will be used when saving)
|
||||||
|
popup = EditPopup(player_instance=self, image_path=image_path, user_card_data=user_card_data)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
def show_exit_popup(self, instance=None):
|
def show_exit_popup(self, instance=None):
|
||||||
"""Show exit password popup"""
|
"""Show exit password popup"""
|
||||||
# Pause playback when exit popup opens
|
# Pause playback when exit popup opens
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"hostname": "tv-terasa",
|
"hostname": "tv-terasa",
|
||||||
"auth_code": "iiSyZDLWGyqNIxeRt54XYREgvAio11RwwU1_oJev6WI",
|
"auth_code": "vkrxEO6eOTxkzXJBtoN4OuXc8eaX2mC3AB9ZePrnick",
|
||||||
"player_id": 1,
|
"player_id": 1,
|
||||||
"player_name": "TV-acasa 1",
|
"player_name": "TV-acasa",
|
||||||
"playlist_id": 1,
|
"playlist_id": 1,
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"authenticated": true,
|
"authenticated": true,
|
||||||
"server_url": "https://digi-signage.moto-adv.com:443"
|
"server_url": "http://digi-signage.moto-adv.com"
|
||||||
}
|
}
|
||||||
@@ -275,6 +275,8 @@ class PlayerAuth:
|
|||||||
feedback_url = f"{server_url}/api/player-feedback"
|
feedback_url = f"{server_url}/api/player-feedback"
|
||||||
headers = {'Authorization': f'Bearer {auth_code}'}
|
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||||
payload = {
|
payload = {
|
||||||
|
'hostname': self.auth_data.get('hostname'),
|
||||||
|
'quickconnect_code': self.auth_data.get('quickconnect_code'),
|
||||||
'message': message,
|
'message': message,
|
||||||
'status': status,
|
'status': status,
|
||||||
'playlist_version': playlist_version,
|
'playlist_version': playlist_version,
|
||||||
|
|||||||
@@ -224,12 +224,12 @@
|
|||||||
pos: self.pos
|
pos: self.pos
|
||||||
radius: [dp(15)]
|
radius: [dp(15)]
|
||||||
|
|
||||||
# New control panel overlay (bottom center, 1/6 width, 90% transparent)
|
# New control panel overlay (bottom center, width for 6 buttons, 90% transparent)
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
id: controls_layout
|
id: controls_layout
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
width: root.width / 6 if root.width > 0 else dp(260)
|
width: dp(370)
|
||||||
height: dp(70)
|
height: dp(70)
|
||||||
pos: (root.width - self.width) / 2, dp(10)
|
pos: (root.width - self.width) / 2, dp(10)
|
||||||
opacity: 1
|
opacity: 1
|
||||||
@@ -261,6 +261,14 @@
|
|||||||
border: (0, 0, 0, 0)
|
border: (0, 0, 0, 0)
|
||||||
on_press: root.toggle_pause()
|
on_press: root.toggle_pause()
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: edit_btn
|
||||||
|
size_hint: None, None
|
||||||
|
size: dp(50), dp(50)
|
||||||
|
background_normal: root.resources_path + '/pencil.png'
|
||||||
|
background_down: root.resources_path + '/pencil.png'
|
||||||
|
border: (0, 0, 0, 0)
|
||||||
|
on_press: root.show_edit_interface()
|
||||||
Button:
|
Button:
|
||||||
id: settings_btn
|
id: settings_btn
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
|
|||||||
182
working_files/CARD_READER_AUTHENTICATION.md
Normal file
182
working_files/CARD_READER_AUTHENTICATION.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# USB Card Reader Authentication
|
||||||
|
|
||||||
|
This document describes the USB card reader authentication feature for the Kiwy Signage Player.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The player now supports user authentication via USB card readers when accessing the edit/drawing interface. When a user clicks the edit button (pencil icon), they must swipe their card to authenticate before being allowed to edit the image.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Edit Button Click**: User clicks the pencil icon to edit the current image
|
||||||
|
2. **Validation Checks**:
|
||||||
|
- Verify current media is an image (not video)
|
||||||
|
- Check if editing is allowed for this media (`edit_on_player` permission from server)
|
||||||
|
3. **Card Reader Prompt**:
|
||||||
|
- Display "Please swipe your card..." message
|
||||||
|
- Wait for card swipe (5 second timeout)
|
||||||
|
- Read card data from USB card reader
|
||||||
|
- Store the card data (no validation required)
|
||||||
|
4. **Open Edit Interface**: Edit interface opens with card data stored
|
||||||
|
5. **Save & Upload**: When user saves the edited image:
|
||||||
|
- Card data is included in the metadata JSON
|
||||||
|
- Both image and metadata (with card data) are uploaded to server
|
||||||
|
- Server receives `user_card_data` field for tracking who edited the image
|
||||||
|
|
||||||
|
## Card Reader Setup
|
||||||
|
|
||||||
|
### Hardware Requirements
|
||||||
|
- USB card reader (HID/keyboard emulation type)
|
||||||
|
- Compatible cards (magnetic stripe or RFID depending on reader)
|
||||||
|
|
||||||
|
### Software Requirements
|
||||||
|
The player requires the `evdev` Python library to interface with USB input devices:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install via apt (recommended for Raspberry Pi)
|
||||||
|
sudo apt-get install python3-evdev
|
||||||
|
|
||||||
|
# Or via pip
|
||||||
|
pip3 install evdev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fallback Mode
|
||||||
|
If `evdev` is not available, the player will:
|
||||||
|
- Log a warning message
|
||||||
|
- Use a default card value (`DEFAULT_USER_12345`) for testing
|
||||||
|
- This allows development and testing without hardware
|
||||||
|
|
||||||
|
## Card Data Storage
|
||||||
|
|
||||||
|
The card data is captured as a raw string and stored without validation or mapping:
|
||||||
|
|
||||||
|
- **No preprocessing**: Card data is stored exactly as received from the reader
|
||||||
|
- **Format**: Whatever the card reader sends (typically numeric or alphanumeric)
|
||||||
|
- **Sent to server**: Raw card data is included in the `user_card_data` field of the metadata JSON
|
||||||
|
- **Server-side processing**: The server can validate, map, or process the card data as needed
|
||||||
|
|
||||||
|
### Metadata JSON Format
|
||||||
|
When an image is saved, the metadata includes:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"time_of_modification": "2025-12-08T10:30:00",
|
||||||
|
"original_name": "image.jpg",
|
||||||
|
"new_name": "image_e_v1.jpg",
|
||||||
|
"original_path": "/path/to/image.jpg",
|
||||||
|
"version": 1,
|
||||||
|
"user_card_data": "123456789"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If no card is swiped (timeout), `user_card_data` will be `null`.
|
||||||
|
|
||||||
|
## Testing the Card Reader
|
||||||
|
|
||||||
|
A test utility is provided to verify card reader functionality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/pi/Desktop/Kiwy-Signage/working_files
|
||||||
|
python3 test_card_reader.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The test tool will:
|
||||||
|
1. List all available input devices
|
||||||
|
2. Auto-detect the card reader (or let you select manually)
|
||||||
|
3. Listen for card swipes and display the data received
|
||||||
|
4. Show how the data will be processed
|
||||||
|
|
||||||
|
### Test Output Example
|
||||||
|
```
|
||||||
|
✓ Card data received: '123456789'
|
||||||
|
Length: 9 characters
|
||||||
|
Processed ID: card_123456789
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Main Components
|
||||||
|
|
||||||
|
1. **CardReader Class** (`main.py`)
|
||||||
|
- Handles USB device detection
|
||||||
|
- Reads input events from card reader
|
||||||
|
- Provides async callback interface
|
||||||
|
- Includes timeout handling (5 seconds)
|
||||||
|
|
||||||
|
2. **Card Read Flow** (`show_edit_interface()` method)
|
||||||
|
- Validates media type and permissions
|
||||||
|
- Initiates card read
|
||||||
|
- Stores raw card data
|
||||||
|
- Opens edit popup
|
||||||
|
|
||||||
|
3. **Metadata Creation** (`_save_metadata()` method)
|
||||||
|
- Includes card data in metadata JSON
|
||||||
|
- No processing or validation of card data
|
||||||
|
- Sent to server as-is
|
||||||
|
|
||||||
|
### Card Data Format
|
||||||
|
|
||||||
|
Card readers typically send data as keyboard input:
|
||||||
|
- Each character is sent as a key press event
|
||||||
|
- Data ends with an ENTER key press
|
||||||
|
- Reader format: `[CARD_DATA][ENTER]`
|
||||||
|
|
||||||
|
The CardReader class:
|
||||||
|
- Captures key press events
|
||||||
|
- Builds the card data string character by character
|
||||||
|
- Completes reading when ENTER is detected
|
||||||
|
- Returns the complete card data to the callback
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
1. **Server-Side Validation**: Card validation should be implemented on the server
|
||||||
|
2. **Timeout**: 5-second timeout prevents infinite waiting for card swipe
|
||||||
|
3. **Logging**: All card reads are logged with the raw card data
|
||||||
|
4. **Permissions**: Edit permission must be enabled on the server (`edit_on_player`)
|
||||||
|
5. **Raw Data**: Card data is sent as-is; server is responsible for validation and authorization
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Card Reader Not Detected
|
||||||
|
- Check USB connection
|
||||||
|
- Run `ls /dev/input/` to see available devices
|
||||||
|
- Run the test script to verify detection
|
||||||
|
- Check `evdev` is installed: `python3 -c "import evdev"`
|
||||||
|
|
||||||
|
### Card Swipes Not Recognized
|
||||||
|
- Verify card reader sends keyboard events
|
||||||
|
- Test with the `test_card_reader.py` utility
|
||||||
|
- Check card format is compatible with reader
|
||||||
|
- Ensure card is swiped smoothly at proper speed
|
||||||
|
|
||||||
|
### Card Data Not Captured
|
||||||
|
- Check card data format in logs
|
||||||
|
- Enable debug logging to see raw card data
|
||||||
|
- Test in fallback mode (without evdev) to isolate hardware issues
|
||||||
|
- Verify card swipe completes within 5-second timeout
|
||||||
|
|
||||||
|
### Permission Denied Errors
|
||||||
|
- User may need to be in the `input` group:
|
||||||
|
```bash
|
||||||
|
sudo usermod -a -G input $USER
|
||||||
|
```
|
||||||
|
- Reboot after adding user to group
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for the card reader system:
|
||||||
|
|
||||||
|
1. **Server Validation**: Server validates cards against database and returns authorization
|
||||||
|
2. **Card Enrollment**: Server-side UI for registering new cards
|
||||||
|
3. **Multiple Card Types**: Support for different card formats (barcode, RFID, magnetic)
|
||||||
|
4. **Client-side Validation**: Add optional local card validation before opening edit
|
||||||
|
5. **Audit Trail**: Server tracks all card usage with timestamps
|
||||||
|
6. **RFID Support**: Test and optimize for RFID readers
|
||||||
|
7. **Barcode Scanners**: Support USB barcode scanners as alternative
|
||||||
|
8. **Retry Logic**: Allow re-swipe if card read fails
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- `/src/main.py` - Main implementation (CardReader class, authentication flow)
|
||||||
|
- `/src/edit_drowing.py` - Drawing/editing interface (uses authenticated user)
|
||||||
|
- `/working_files/test_card_reader.py` - Card reader test utility
|
||||||
|
- `/requirements.txt` - Dependencies (includes evdev)
|
||||||
114
working_files/test_card_reader.py
Normal file
114
working_files/test_card_reader.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for USB card reader functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
from evdev import InputDevice, categorize, ecodes
|
||||||
|
import time
|
||||||
|
|
||||||
|
def list_input_devices():
|
||||||
|
"""List all available input devices"""
|
||||||
|
print("\n=== Available Input Devices ===")
|
||||||
|
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
|
||||||
|
|
||||||
|
for i, device in enumerate(devices):
|
||||||
|
print(f"\n[{i}] {device.path}")
|
||||||
|
print(f" Name: {device.name}")
|
||||||
|
print(f" Phys: {device.phys}")
|
||||||
|
capabilities = device.capabilities()
|
||||||
|
if ecodes.EV_KEY in capabilities:
|
||||||
|
print(f" Type: Keyboard/HID Input Device")
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def test_card_reader(device_index=None):
|
||||||
|
"""Test reading from a card reader device"""
|
||||||
|
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
|
||||||
|
|
||||||
|
if device_index is not None:
|
||||||
|
if device_index >= len(devices):
|
||||||
|
print(f"Error: Device index {device_index} out of range")
|
||||||
|
return
|
||||||
|
device = devices[device_index]
|
||||||
|
else:
|
||||||
|
# Try to find a card reader automatically
|
||||||
|
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:
|
||||||
|
# Use first keyboard device
|
||||||
|
for dev in devices:
|
||||||
|
capabilities = dev.capabilities()
|
||||||
|
if ecodes.EV_KEY in capabilities:
|
||||||
|
device = dev
|
||||||
|
print(f"Using keyboard device: {dev.name}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not device:
|
||||||
|
print("No suitable input device found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n=== Testing Card Reader ===")
|
||||||
|
print(f"Device: {device.name}")
|
||||||
|
print(f"Path: {device.path}")
|
||||||
|
print("\nSwipe your card now (press Ctrl+C to exit)...\n")
|
||||||
|
|
||||||
|
card_data = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
for event in device.read_loop():
|
||||||
|
if event.type == ecodes.EV_KEY:
|
||||||
|
key_event = categorize(event)
|
||||||
|
|
||||||
|
if key_event.keystate == 1: # Key down
|
||||||
|
key_code = key_event.keycode
|
||||||
|
|
||||||
|
# Handle Enter key (card read complete)
|
||||||
|
if key_code == 'KEY_ENTER':
|
||||||
|
print(f"\n✓ Card data received: '{card_data}'")
|
||||||
|
print(f" Length: {len(card_data)} characters")
|
||||||
|
print(f" Processed ID: card_{card_data.strip().upper()}")
|
||||||
|
print("\nReady for next card swipe...")
|
||||||
|
card_data = ""
|
||||||
|
|
||||||
|
# Build card data string
|
||||||
|
elif key_code.startswith('KEY_'):
|
||||||
|
char = key_code.replace('KEY_', '')
|
||||||
|
if len(char) == 1: # Single character
|
||||||
|
card_data += char
|
||||||
|
print(f"Reading: {card_data}", end='\r', flush=True)
|
||||||
|
elif char.isdigit(): # Handle numeric keys
|
||||||
|
card_data += char
|
||||||
|
print(f"Reading: {card_data}", end='\r', flush=True)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nTest stopped by user")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("USB Card Reader Test Tool")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
devices = list_input_devices()
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
print("\nNo input devices found!")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
choice = input("\nEnter device number to test (or press Enter for auto-detect): ").strip()
|
||||||
|
|
||||||
|
if choice:
|
||||||
|
try:
|
||||||
|
device_index = int(choice)
|
||||||
|
test_card_reader(device_index)
|
||||||
|
except ValueError:
|
||||||
|
print("Invalid device number!")
|
||||||
|
else:
|
||||||
|
test_card_reader()
|
||||||
Reference in New Issue
Block a user