Compare commits

...

6 Commits

Author SHA1 Message Date
af1e671c7f Add USB card reader authentication for edit feature
- Implemented CardReader class to read data from USB card readers
- Added CardSwipePopup with 5-second timeout and visual feedback
- Card data is captured and included in edit metadata
- Card data sent to server when edited images are uploaded
- Added evdev dependency for USB input device handling
- Fallback mode when evdev not available (for development)
- Created test utility (test_card_reader.py) for card reader testing
- Added comprehensive documentation (CARD_READER_AUTHENTICATION.md)
- Added access-card.png icon for authentication popup
- Edit interface requires card swipe or times out after 5 seconds
2025-12-08 14:05:04 +02:00
Kiwy Signage Player
9664ad541b moved files 2025-12-06 00:12:37 +02:00
Kiwy Signage Player
89e5ad86dd Add media editing features: WebP support, edit permissions, user auth, server upload
- Migrated to get_playlists_v2 with improved auth system
- Added WebP image format support for playback and editing
- Implemented edit_on_player permission check from server playlist
- Added user authentication layer for edit function (placeholder: player_1)
- Implemented versioned saving with metadata (user, timestamp, version)
- Added server upload functionality for edited media
- Fixed playlist update after intro video completion
- Added hostname and quickconnect_code to player feedback
- Improved error handling for upload failures (non-blocking)
2025-12-06 00:07:48 +02:00
Kiwy Signage Player
f573af0505 update 2025-12-05 00:36:38 +02:00
Kiwy Signage Player
fba2007bdf Improve edit interface: full-screen layout with toolbars, versioned saving to edited_media folder 2025-12-05 00:25:53 +02:00
Kiwy Signage Player
72d382b96b Add image editing feature with drawing tools
- Added pencil edit button to player controls
- Created EditPopup with drawing layer for image annotation
- Drawing tools: color selection (red/blue/green/black), thickness control
- Features: undo last stroke, clear all strokes, save edited image
- Playback automatically pauses during editing
- Only images (.jpg, .jpeg, .png, .bmp) can be edited
- Edited images saved with '_edited' suffix in same directory
- Drawing layer with touch support for annotations
- Full toolbar with color, thickness, and action controls
2025-12-04 22:40:05 +02:00
18 changed files with 1281 additions and 21 deletions

View File

@@ -1 +0,0 @@
1763799978.6257727

1
.player_stop_requested Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
config/resources/pencil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
media/wp1993621.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

View File

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

100
src/edit_drowing.py Normal file
View 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()

View File

@@ -237,7 +237,8 @@ def download_media_files(playlist, media_dir):
updated_media = {
'file_name': file_name,
'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)

View File

@@ -35,23 +35,788 @@ from kivy.clock import Clock
from kivy.core.window import Window
from kivy.properties import BooleanProperty
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.lang import Builder
from kivy.graphics import Rectangle
from kivy.graphics.texture import Texture
from get_playlists import (
from get_playlists_v2 import (
update_playlist_if_needed,
send_playing_status_feedback,
send_playlist_restart_feedback,
send_player_error_feedback
)
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
Builder.load_file('signage_player.kv')
# 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
class KeyboardContainer(BoxLayout):
def __init__(self, vkeyboard, **kwargs):
@@ -497,6 +1262,9 @@ class SignagePlayer(Widget):
self.consecutive_errors = 0 # Track consecutive playback errors
self.max_consecutive_errors = 10 # Maximum errors before stopping
self.intro_played = False # Track if intro has been played
# Card reader for authentication
self.card_reader = None
self._pending_edit_image = None
# Paths
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.config_dir = os.path.join(self.base_dir, 'config')
@@ -594,20 +1362,14 @@ class SignagePlayer(Widget):
# Run blocking network I/O in thread pool
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)
# Note: get_playlists_v2 doesn't need latest_playlist parameter
updated = await loop.run_in_executor(
None,
update_playlist_if_needed,
latest_playlist,
self.config,
self.media_dir,
self.playlists_dir
self.playlists_dir,
self.media_dir
)
if updated:
@@ -665,7 +1427,8 @@ class SignagePlayer(Widget):
if self.playlist:
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)
else:
self.ids.status_label.text = "No media in playlist"
@@ -807,13 +1570,13 @@ class SignagePlayer(Widget):
# Video file
Logger.info(f"SignagePlayer: Media type: VIDEO")
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
Logger.info(f"SignagePlayer: Media type: IMAGE")
self.play_image(media_path, duration)
else:
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif")
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
Logger.warning(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1
self.next_media()
@@ -1070,6 +1833,95 @@ class SignagePlayer(Widget):
popup = SettingsPopup(player_instance=self, was_paused=was_paused)
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):
"""Show exit password popup"""
# Pause playback when exit popup opens

View File

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

View File

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

View File

@@ -224,12 +224,12 @@
pos: self.pos
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:
id: controls_layout
orientation: 'horizontal'
size_hint: None, None
width: root.width / 6 if root.width > 0 else dp(260)
width: dp(370)
height: dp(70)
pos: (root.width - self.width) / 2, dp(10)
opacity: 1
@@ -261,6 +261,14 @@
border: (0, 0, 0, 0)
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:
id: settings_btn
size_hint: None, None

View File

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

View File

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