Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31ad592e98 | |||
| 5c2b3f545f | |||
| d0ea94447a | |||
| 12f2880201 | |||
| 5a030671a2 | |||
| a2add88f04 | |||
| c4e8381898 | |||
| ced6e10919 | |||
| 844e5eeebb | |||
| 7efc023327 | |||
| 6abde5a767 | |||
| 362f5096a0 | |||
| 3845830a86 | |||
| f7b889be11 | |||
| 39565c5761 | |||
| fb9f46a94f | |||
| 9d8eb0cf21 | |||
| 64e49886df | |||
| f30f97683b | |||
| 609d9169d2 | |||
| abbd51a787 | |||
| 2c31589787 | |||
| a8d6d70cd4 | |||
| 11436ddeab | |||
| e2abde9f9c | |||
| a825d299bf | |||
| 30f058182c | |||
| 9b58f6b63d | |||
| eeb2a61ef7 | |||
| 120c889143 | |||
| d1382af517 | |||
| 3531760e16 | |||
| 6bf4e3735a | |||
| e735e85d3c | |||
| 72a6d7e704 | |||
| 8703350b23 | |||
| 17ae5439bd | |||
| 81432ac832 | |||
| c5bf6c1eaf | |||
| 1c02843687 | |||
| 1cc0eae542 | |||
| b2d380511a | |||
| db796e4d66 | |||
| 5843bb5215 | |||
| 2b42999008 | |||
| 02e9ea1aaa | |||
| 4c3ddbef73 | |||
| 87e059e0f4 | |||
| 46d9fcf6e3 | |||
| f1a84d05d5 |
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# Aggressive display keep-alive for Raspberry Pi
|
||||
# Supports both X11 and Wayland environments
|
||||
|
||||
DISPLAY_TIMEOUT=30
|
||||
|
||||
# Detect display server type
|
||||
detect_display_server() {
|
||||
if [ -n "$WAYLAND_DISPLAY" ]; then
|
||||
echo "wayland"
|
||||
elif [ -n "$DISPLAY" ]; then
|
||||
echo "x11"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
DISPLAY_SERVER=$(detect_display_server)
|
||||
|
||||
while true; do
|
||||
# Keep HDMI powered on (works for both X11 and Wayland)
|
||||
if command -v tvservice &> /dev/null; then
|
||||
/usr/bin/tvservice -p 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ "$DISPLAY_SERVER" = "wayland" ]; then
|
||||
# Wayland-specific power management
|
||||
|
||||
# Method 1: Use wlr-randr for Wayland compositors (if available)
|
||||
if command -v wlr-randr &> /dev/null; then
|
||||
wlr-randr --output HDMI-A-1 --on 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Method 2: Prevent idle using systemd-inhibit
|
||||
if command -v systemd-inhibit &> /dev/null; then
|
||||
# This is already running, but refresh the lock
|
||||
systemctl --user restart plasma-ksmserver.service 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Method 3: Use wlopm (Wayland output power management)
|
||||
if command -v wlopm &> /dev/null; then
|
||||
wlopm --on \* 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Method 4: Simulate activity via input (works on Wayland)
|
||||
if command -v ydotool &> /dev/null; then
|
||||
ydotool mousemove -x 1 -y 1 2>/dev/null || true
|
||||
ydotool mousemove -x -1 -y -1 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Method 5: GNOME/KDE Wayland idle inhibit
|
||||
if command -v gnome-session-inhibit &> /dev/null; then
|
||||
# Already inhibited by running process
|
||||
true
|
||||
fi
|
||||
|
||||
else
|
||||
# X11-specific power management (original code)
|
||||
if command -v xset &> /dev/null; then
|
||||
DISPLAY=:0 xset s off 2>/dev/null
|
||||
DISPLAY=:0 xset -dpms 2>/dev/null
|
||||
DISPLAY=:0 xset dpms force on 2>/dev/null
|
||||
DISPLAY=:0 xset s reset 2>/dev/null
|
||||
fi
|
||||
|
||||
# Move mouse to trigger activity
|
||||
if command -v xdotool &> /dev/null; then
|
||||
DISPLAY=:0 xdotool mousemove_relative 1 1 2>/dev/null
|
||||
DISPLAY=:0 xdotool mousemove_relative -1 -1 2>/dev/null
|
||||
fi
|
||||
|
||||
# Disable monitor power saving
|
||||
if command -v xrandr &> /dev/null; then
|
||||
DISPLAY=:0 xrandr --output HDMI-1 --power-profile performance 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep $DISPLAY_TIMEOUT
|
||||
done
|
||||
@@ -25,6 +25,7 @@ wheels/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
windows/venv312/
|
||||
|
||||
# Kivy
|
||||
*.pyc
|
||||
@@ -57,3 +58,7 @@ playlists/server_playlist_*.json
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.player_heartbear
|
||||
|
||||
windows/venv_build/
|
||||
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Keep-screen-alive wrapper for player
|
||||
# Prevents screen from locking/turning off while player is running
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Function to keep screen awake
|
||||
keep_screen_awake() {
|
||||
while true; do
|
||||
# Move mouse slightly to prevent idle
|
||||
if command -v xdotool &> /dev/null; then
|
||||
xdotool mousemove_relative 1 1
|
||||
xdotool mousemove_relative -1 -1
|
||||
fi
|
||||
|
||||
# Disable DPMS and screensaver periodically
|
||||
if command -v xset &> /dev/null; then
|
||||
xset s reset
|
||||
xset dpms force on
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
done
|
||||
}
|
||||
|
||||
# Function to inhibit systemd sleep (if available)
|
||||
inhibit_sleep() {
|
||||
if command -v systemd-inhibit &> /dev/null; then
|
||||
# Run player under systemd inhibit to prevent sleep
|
||||
systemd-inhibit --what=sleep --why="Signage player running" \
|
||||
bash "$SCRIPT_DIR/start.sh"
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Try systemd inhibit first (most reliable)
|
||||
if inhibit_sleep; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fallback: Start keep-alive in background
|
||||
keep_screen_awake &
|
||||
KEEPALIVE_PID=$!
|
||||
|
||||
# Start the player
|
||||
cd "$SCRIPT_DIR"
|
||||
bash start.sh
|
||||
PLAYER_EXIT=$?
|
||||
|
||||
# Kill keep-alive when player exits
|
||||
kill $KEEPALIVE_PID 2>/dev/null || true
|
||||
|
||||
exit $PLAYER_EXIT
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#\!/bin/bash
|
||||
SCRIPT_DIR="/home/pi/kiwy-signage"
|
||||
LOG_FILE="/home/pi/kiwy-signage/logs/player-background.log"
|
||||
mkdir -p "."
|
||||
echo "[2026-07-17 10:54:53] Starting player in background..." >> ""
|
||||
cd "/home/pi/kiwy-signage" || exit 1
|
||||
nohup bash start.sh >> "" 2>&1 &
|
||||
PLAYER_PID=$\!
|
||||
echo "[2026-07-17 10:54:53] Player started with PID: $PLAYER_PID" >> ""
|
||||
echo "Player started in background (PID: $PLAYER_PID)"
|
||||
echo "Logs: $LOG_FILE"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#\!/bin/bash
|
||||
sleep 15
|
||||
cd /home/pi/kiwy-signage && bash start.sh
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Video playback optimization script for Raspberry Pi
|
||||
# Improves video smoothness and reduces stuttering
|
||||
|
||||
echo "Optimizing system for smooth video playback..."
|
||||
|
||||
# 1. Increase GPU memory split for better video performance
|
||||
if [ -f /boot/config.txt ]; then
|
||||
echo "Checking GPU memory configuration..."
|
||||
if grep -q "gpu_mem=" /boot/config.txt; then
|
||||
# GPU memory already configured, check if it's sufficient
|
||||
CURRENT_GPU_MEM=$(grep "gpu_mem=" /boot/config.txt | head -1 | cut -d'=' -f2)
|
||||
if [ "$CURRENT_GPU_MEM" -lt 256 ]; then
|
||||
sudo sed -i 's/gpu_mem=.*/gpu_mem=256/' /boot/config.txt
|
||||
echo "✓ GPU memory increased to 256MB for video"
|
||||
fi
|
||||
else
|
||||
# Add GPU memory setting
|
||||
echo "gpu_mem=256" | sudo tee -a /boot/config.txt > /dev/null
|
||||
echo "✓ GPU memory set to 256MB for video"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Install video codec support
|
||||
echo "Installing video codec libraries..."
|
||||
sudo apt-get install -y \
|
||||
libva-drm2 \
|
||||
libva2 \
|
||||
libavcodec-extra \
|
||||
libavutil-dev \
|
||||
2>/dev/null || true
|
||||
|
||||
# 3. Optimize swappiness for better memory management
|
||||
echo "Optimizing memory management..."
|
||||
if [ -f /proc/sys/vm/swappiness ]; then
|
||||
CURRENT_SWAP=$(cat /proc/sys/vm/swappiness)
|
||||
if [ "$CURRENT_SWAP" -gt 30 ]; then
|
||||
echo 30 | sudo tee /proc/sys/vm/swappiness > /dev/null
|
||||
echo "vm.swappiness=30" | sudo tee -a /etc/sysctl.conf > /dev/null
|
||||
echo "✓ Swappiness optimized"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Disable CPU frequency scaling for consistent performance
|
||||
echo "Ensuring CPU performance mode..."
|
||||
for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
|
||||
if [ -f "$cpu/cpufreq/scaling_governor" ]; then
|
||||
echo performance | sudo tee "$cpu/cpufreq/scaling_governor" > /dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
echo "✓ CPU set to performance mode"
|
||||
|
||||
# 5. Optimize file system cache
|
||||
echo "Optimizing filesystem cache..."
|
||||
echo 50 | sudo tee /proc/sys/vm/vfs_cache_pressure > /dev/null
|
||||
echo "vm.vfs_cache_pressure=50" | sudo tee -a /etc/sysctl.conf > /dev/null
|
||||
echo "✓ Filesystem cache optimized"
|
||||
|
||||
echo ""
|
||||
echo "✅ Video playback optimization complete!"
|
||||
echo "Note: Some changes require a reboot to take effect."
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Wait for display server to be ready before starting the app
|
||||
# This prevents Kivy from failing to initialize graphics
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MAX_WAIT=60
|
||||
ELAPSED=0
|
||||
|
||||
echo "[$(date)] Waiting for display server to be ready..."
|
||||
|
||||
# Wait for display socket/device to appear
|
||||
while [ $ELAPSED -lt $MAX_WAIT ]; do
|
||||
# Check for Wayland socket (primary for Bookworm)
|
||||
if [ -S "$XDG_RUNTIME_DIR/wayland-0" ] 2>/dev/null; then
|
||||
echo "[$(date)] ✓ Wayland display socket found"
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
break
|
||||
fi
|
||||
|
||||
# Check for X11 display
|
||||
if [ -S "$XDG_RUNTIME_DIR/X11/display:0" ] 2>/dev/null; then
|
||||
echo "[$(date)] ✓ X11 display socket found"
|
||||
export DISPLAY=:0
|
||||
break
|
||||
fi
|
||||
|
||||
# Check if display manager is running (for fallback)
|
||||
if pgrep -f "wayland|weston|gnome-shell|xfwm4|openbox" > /dev/null 2>&1; then
|
||||
echo "[$(date)] ✓ Display manager detected"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "[$(date)] Waiting for display... ($ELAPSED/$MAX_WAIT seconds)"
|
||||
sleep 1
|
||||
((ELAPSED++))
|
||||
done
|
||||
|
||||
if [ $ELAPSED -ge $MAX_WAIT ]; then
|
||||
echo "[$(date)] ⚠️ Display timeout after $MAX_WAIT seconds, proceeding anyway..."
|
||||
fi
|
||||
|
||||
# Set default display if not detected
|
||||
if [ -z "$WAYLAND_DISPLAY" ] && [ -z "$DISPLAY" ]; then
|
||||
echo "[$(date)] Using fallback display settings"
|
||||
export DISPLAY=:0
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
fi
|
||||
|
||||
echo "[$(date)] Environment: DISPLAY=$DISPLAY WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
|
||||
echo "[$(date)] XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
|
||||
|
||||
# Now start the app
|
||||
cd "$SCRIPT_DIR" || exit 1
|
||||
exec bash start.sh
|
||||
@@ -0,0 +1,245 @@
|
||||
# Web Link Playlist Items — Player Integration Guide
|
||||
|
||||
This document describes the changes required on the **Kiwy-Signage player**
|
||||
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) to support a new
|
||||
playlist item type: **`weblink`** (display a live web page / URL instead of an
|
||||
uploaded media file).
|
||||
|
||||
> The DigiServer (this repo, `digiserver-v2`) side will be updated to emit
|
||||
> `weblink` items in the playlist API. The player does **not** yet support them.
|
||||
> Use this guide to implement the player side later.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background — how items flow today
|
||||
|
||||
```
|
||||
DigiServer API ──JSON──▶ player sync (get_playlists_v2.py) ──▶ playlist.json ──▶ main.py renders
|
||||
/api/playlists downloads files to media/ by file extension
|
||||
```
|
||||
|
||||
Each playlist item the server returns currently looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"file_name": "promo.jpg",
|
||||
"type": "image",
|
||||
"duration": 10,
|
||||
"position": 1,
|
||||
"url": "https://server/digiserver/static/uploads/promo.jpg",
|
||||
"edit_on_player": false
|
||||
}
|
||||
```
|
||||
|
||||
The player:
|
||||
1. **Syncs** (`src/get_playlists_v2.py` → `download_media_files()`): downloads
|
||||
`url` into the local `media/` directory, then rewrites each item keeping only
|
||||
`file_name`, `url` (now a **local relative path**), `duration`,
|
||||
`edit_on_player`. **Note: the `type` field is currently discarded here.**
|
||||
2. **Renders** (`src/main.py` → `play_current_media()`): opens the local file
|
||||
and chooses a Kivy widget purely by **file extension**
|
||||
(`.mp4/.avi/...` → `Video`, `.jpg/.png/...` → `AsyncImage`). Unknown
|
||||
extensions are skipped as "unsupported".
|
||||
|
||||
A web link breaks all three assumptions: there is no file to download, no
|
||||
extension to switch on, and no widget that renders a web page.
|
||||
|
||||
---
|
||||
|
||||
## 2. New server contract (what DigiServer will send)
|
||||
|
||||
A web-link playlist item will look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 91,
|
||||
"file_name": "weblink-3f9c1a2b",
|
||||
"type": "weblink",
|
||||
"duration": 30,
|
||||
"position": 4,
|
||||
"url": "https://example.com/dashboard",
|
||||
"edit_on_player": false
|
||||
}
|
||||
```
|
||||
|
||||
Key differences vs. a file item:
|
||||
|
||||
| Field | File item | Web-link item |
|
||||
|--------------|-----------------------------------|----------------------------------------|
|
||||
| `type` | `image` / `video` | **`weblink`** |
|
||||
| `url` | Path to a file on the server | **The web page to display (the link)** |
|
||||
| `file_name` | Real filename on disk | Synthetic id (`weblink-<uuid>`), **no file exists** |
|
||||
|
||||
The player must branch on `type == "weblink"` and treat `url` as the page to
|
||||
open — **never** try to download it as a file.
|
||||
|
||||
---
|
||||
|
||||
## 3. Required player changes
|
||||
|
||||
### 3.1 Sync step — `src/get_playlists_v2.py`
|
||||
|
||||
**Function:** `download_media_files(playlist, media_dir, ...)`
|
||||
|
||||
1. **Skip download for web links.** At the top of the per-item loop, detect
|
||||
`media.get('type') == 'weblink'` and do **not** call `session.get()` / write
|
||||
any file for it.
|
||||
2. **Preserve `type` and the original `url`.** The `updated_media` dict that is
|
||||
appended to `updated_playlist` currently drops `type` and rewrites `url` to a
|
||||
local path. It must now carry `type` through, and for web links keep `url`
|
||||
as the original web address (do not convert to a local relative path).
|
||||
|
||||
Suggested shape of the per-item logic:
|
||||
|
||||
```python
|
||||
item_type = media.get('type', '')
|
||||
|
||||
if item_type == 'weblink':
|
||||
# No file to download — pass the web link through unchanged.
|
||||
updated_playlist.append({
|
||||
'file_name': media.get('file_name', ''),
|
||||
'type': 'weblink',
|
||||
'url': media.get('url', ''), # the actual web page
|
||||
'duration': media.get('duration', 10),
|
||||
'edit_on_player': False,
|
||||
})
|
||||
continue
|
||||
|
||||
# ... existing download logic for file items ...
|
||||
updated_playlist.append({
|
||||
'file_name': file_name,
|
||||
'type': item_type, # <-- now preserved
|
||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||
'duration': duration,
|
||||
'edit_on_player': media.get('edit_on_player', False),
|
||||
})
|
||||
```
|
||||
|
||||
3. **`delete_unused_media()`** walks `media/` using `file_name`. Web links have
|
||||
no file, so they simply won't match anything on disk — no change strictly
|
||||
required, but make sure a missing local file for a `weblink` item does not
|
||||
trigger a re-download or an error elsewhere.
|
||||
|
||||
### 3.2 Render step — `src/main.py`
|
||||
|
||||
**Function:** `play_current_media(self, force_reload=False)`
|
||||
|
||||
The current logic builds `media_path = os.path.join(self.media_dir, file_name)`
|
||||
and then does `os.stat(media_path)` — which will fail for a web link (no file).
|
||||
Add a **web-link branch before the file-existence check**:
|
||||
|
||||
```python
|
||||
media_item = self.playlist[self.current_index]
|
||||
file_name = media_item.get('file_name', '')
|
||||
duration = media_item.get('duration', 10)
|
||||
|
||||
# NEW: handle web links before any file/path handling
|
||||
if media_item.get('type') == 'weblink':
|
||||
self.play_weblink(media_item.get('url', ''), duration)
|
||||
return
|
||||
|
||||
# ... existing file existence check + extension branching ...
|
||||
```
|
||||
|
||||
Then add a new method `play_weblink(self, url, duration)`:
|
||||
|
||||
- Validate the scheme is `http`/`https` (reject anything else, e.g. `file://`).
|
||||
- Open the page for `duration` seconds, then advance with `self.next_media()`.
|
||||
- Wrap in `try/except`; on failure increment `self.consecutive_errors` and call
|
||||
`self.next_media()`, matching the existing error-handling pattern.
|
||||
- Make sure the previous widget (`self.current_widget`) is removed/stopped just
|
||||
like the image/video paths do.
|
||||
|
||||
#### Rendering approach (pick one)
|
||||
|
||||
Kivy has **no production-grade embedded web view**, especially on Raspberry Pi.
|
||||
Recommended options, in order of robustness:
|
||||
|
||||
1. **Chromium kiosk overlay (recommended).** Launch Chromium over the Kivy
|
||||
window for the item's duration, then close it and return to Kivy:
|
||||
|
||||
```python
|
||||
import subprocess, shutil
|
||||
from urllib.parse import urlparse
|
||||
from kivy.clock import Clock
|
||||
|
||||
def play_weblink(self, url, duration):
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
if scheme not in ('http', 'https'):
|
||||
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
|
||||
self.next_media()
|
||||
return
|
||||
try:
|
||||
browser = shutil.which('chromium-browser') or shutil.which('chromium')
|
||||
self._weblink_proc = subprocess.Popen([
|
||||
browser,
|
||||
'--kiosk', '--app=' + url,
|
||||
'--noerrdialogs', '--disable-infobars',
|
||||
'--incognito', '--no-first-run',
|
||||
'--check-for-update-interval=31536000',
|
||||
])
|
||||
Clock.schedule_once(lambda dt: self._close_weblink_and_next(), duration)
|
||||
except Exception as e:
|
||||
Logger.error(f"SignagePlayer: Error opening weblink: {e}")
|
||||
self.consecutive_errors += 1
|
||||
self.next_media()
|
||||
|
||||
def _close_weblink_and_next(self):
|
||||
proc = getattr(self, '_weblink_proc', None)
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
self._weblink_proc = None
|
||||
self.next_media()
|
||||
```
|
||||
|
||||
Requirements / notes:
|
||||
- Install Chromium on the player image (`chromium-browser` on Raspberry Pi OS).
|
||||
- Ensure Chromium gets window focus over Kivy and is fully killed before the
|
||||
next item, including on pause/stop/restart paths and on app shutdown
|
||||
(`on_stop`) so no stray browser window is left behind.
|
||||
- On Wayland/X11 the player already sets `SDL_VIDEODRIVER`; verify Chromium
|
||||
launches on the same display/session.
|
||||
|
||||
2. **Embedded web view widget** (`kivy_garden.webview`, WebKit/GTK, or WebView2).
|
||||
Cleaner UX (stays inside the Kivy widget tree) but fragile and poorly
|
||||
supported on Pi/Wayland — only pursue if option 1 is unacceptable.
|
||||
|
||||
3. **Server-side screenshot fallback (no player change).** If embedding a live
|
||||
browser is not desirable, DigiServer can periodically screenshot the URL and
|
||||
store it as a normal `image` item; the player then needs no changes. This
|
||||
loses live/animated content. Documented here for completeness only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Checklist for the player update
|
||||
|
||||
- [ ] `get_playlists_v2.py`: skip download when `type == 'weblink'`.
|
||||
- [ ] `get_playlists_v2.py`: preserve `type` in the rewritten playlist items
|
||||
(fixes the current loss of `type`).
|
||||
- [ ] `get_playlists_v2.py`: keep the original web `url` for weblink items.
|
||||
- [ ] `main.py` `play_current_media()`: branch to `play_weblink()` **before** the
|
||||
`os.stat()` file check.
|
||||
- [ ] `main.py`: implement `play_weblink(url, duration)` (Chromium kiosk).
|
||||
- [ ] `main.py`: validate scheme is `http`/`https`; reject others.
|
||||
- [ ] Kill/cleanup the browser process on next item, pause, stop, restart, and
|
||||
`on_stop`.
|
||||
- [ ] Install Chromium on the player image / document it in the player README.
|
||||
- [ ] Test: mixed playlist (image → video → weblink → image) cycles correctly
|
||||
and respects per-item `duration`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Security notes
|
||||
|
||||
- Only allow `http`/`https` schemes on both server and player; never open
|
||||
`file://`, `chrome://`, etc.
|
||||
- The server validates and stores the URL when the operator adds it; the player
|
||||
should still re-validate the scheme before launching the browser (defence in
|
||||
depth).
|
||||
- Consider running Chromium with `--incognito` (no persistent cookies/cache) as
|
||||
shown above.
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"server_ip": "digiserver",
|
||||
"port": "80",
|
||||
"screen_name": "rpi-tvholba1",
|
||||
"server_ip": "192.168.0.107",
|
||||
"port": "8080",
|
||||
"screen_name": "WINDOWS-PC",
|
||||
"quickconnect_key": "8887779",
|
||||
"orientation": "Landscape",
|
||||
"touch": "True",
|
||||
"max_resolution": "1920x1080",
|
||||
"edit_feature_enabled": true
|
||||
"edit_feature_enabled": true,
|
||||
"use_https": false,
|
||||
"verify_ssl": false
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
@@ -0,0 +1,274 @@
|
||||
# HTTPS Implementation Checklist
|
||||
|
||||
## Pre-Deployment
|
||||
|
||||
### Server Requirements
|
||||
- [ ] Server has HTTPS enabled on port 443
|
||||
- [ ] Server has valid SSL certificate (or self-signed)
|
||||
- [ ] `/api/certificate` endpoint is implemented
|
||||
- [ ] CORS headers are configured
|
||||
- [ ] All API endpoints support HTTPS
|
||||
|
||||
### Configuration Preparation
|
||||
- [ ] `config/app_config.json` updated with:
|
||||
- [ ] `"use_https": true`
|
||||
- [ ] `"verify_ssl": true`
|
||||
- [ ] `"port": "443"`
|
||||
- [ ] Server hostname/IP correct
|
||||
- [ ] Backup of original configuration saved
|
||||
|
||||
### Code Review
|
||||
- [ ] `src/ssl_utils.py` reviewed
|
||||
- [ ] `src/player_auth.py` changes reviewed
|
||||
- [ ] `src/get_playlists_v2.py` changes reviewed
|
||||
- [ ] `src/main.py` changes reviewed
|
||||
- [ ] All syntax verified (python3 -m py_compile)
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Pre-Deployment Testing
|
||||
- [ ] All Python files compile without errors
|
||||
- [ ] JSON configuration is valid
|
||||
- [ ] No import errors when loading modules
|
||||
- [ ] Certificate storage directory can be created (`~/.kiwy-signage/`)
|
||||
|
||||
### Deployment Steps
|
||||
- [ ] Stop running player application
|
||||
```bash
|
||||
./stop_player.sh
|
||||
```
|
||||
- [ ] Copy updated files to deployment location
|
||||
- [ ] Verify configuration is in place
|
||||
- [ ] Start application
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Initial Verification (First 5 minutes)
|
||||
- [ ] Application starts without errors
|
||||
- [ ] Check logs for startup messages
|
||||
- [ ] Verify no SSL connection errors immediately
|
||||
- [ ] Check that certificate wasn't attempted to download (if server is unreachable, this is expected)
|
||||
|
||||
---
|
||||
|
||||
## Post-Deployment Testing
|
||||
|
||||
### Connection Test
|
||||
- [ ] Open settings UI on player
|
||||
- [ ] Enter server details (if not pre-configured)
|
||||
- [ ] Click "Test Connection" button
|
||||
- [ ] Connection succeeds with green checkmark
|
||||
- [ ] Error message is clear if connection fails
|
||||
|
||||
### Playlist Operations
|
||||
- [ ] Playlist fetches successfully from HTTPS server
|
||||
- [ ] Media files download without SSL errors
|
||||
- [ ] Playlist updates trigger correctly
|
||||
- [ ] No "CERTIFICATE_VERIFY_FAILED" errors in logs
|
||||
|
||||
### Certificate Management
|
||||
- [ ] Certificate file created: `~/.kiwy-signage/server_cert.pem`
|
||||
- [ ] Certificate info file created: `~/.kiwy-signage/cert_info.json`
|
||||
- [ ] Certificate can be verified:
|
||||
```bash
|
||||
openssl x509 -in ~/.kiwy-signage/server_cert.pem -text -noout
|
||||
```
|
||||
|
||||
### API Operations
|
||||
- [ ] Authentication succeeds over HTTPS
|
||||
- [ ] Playlist retrieval works
|
||||
- [ ] Media downloads work
|
||||
- [ ] Status feedback sends successfully
|
||||
- [ ] Heartbeat messages send without errors
|
||||
|
||||
---
|
||||
|
||||
## Monitoring (24-48 hours)
|
||||
|
||||
### Log Review
|
||||
- [ ] Check application logs for SSL-related messages
|
||||
- [ ] Look for:
|
||||
- [ ] "Using saved certificate" or "Using system CA bundle"
|
||||
- [ ] "✓ Server certificate installed" (if auto-downloaded)
|
||||
- [ ] No SSL errors after certificate is loaded
|
||||
- [ ] All API operations succeeded
|
||||
|
||||
### Error Scenarios
|
||||
- [ ] If `SSL: CERTIFICATE_VERIFY_FAILED`:
|
||||
- [ ] Check server certificate is valid
|
||||
- [ ] Check `/api/certificate` endpoint returns proper certificate
|
||||
- [ ] Consider `verify_ssl: false` for testing (temporary only)
|
||||
|
||||
- [ ] If connection timeout:
|
||||
- [ ] Check network connectivity
|
||||
- [ ] Verify HTTPS port 443 is open
|
||||
- [ ] Check server is responding
|
||||
- [ ] Consider increasing timeout value
|
||||
|
||||
### Performance
|
||||
- [ ] HTTPS connections perform at acceptable speed
|
||||
- [ ] Media downloads at expected speed
|
||||
- [ ] No CPU spikes from SSL operations
|
||||
- [ ] Memory usage stable
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan (if needed)
|
||||
|
||||
If HTTPS deployment has issues:
|
||||
|
||||
1. **Quick Fallback to HTTP:**
|
||||
```json
|
||||
{
|
||||
"use_https": false,
|
||||
"port": "5000"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Steps:**
|
||||
- [ ] Update `app_config.json` with HTTP settings
|
||||
- [ ] Stop player: `./stop_player.sh`
|
||||
- [ ] Start player: `./start.sh`
|
||||
- [ ] Verify connection works
|
||||
|
||||
3. **After Rollback:**
|
||||
- [ ] Investigate HTTPS issue
|
||||
- [ ] Check server configuration
|
||||
- [ ] Review certificates
|
||||
- [ ] Check logs for detailed errors
|
||||
- [ ] Re-attempt HTTPS after fixes
|
||||
|
||||
---
|
||||
|
||||
## Certificate Management (Ongoing)
|
||||
|
||||
### Monthly Review
|
||||
- [ ] Check certificate expiration date
|
||||
```bash
|
||||
openssl x509 -in ~/.kiwy-signage/server_cert.pem -noout -dates
|
||||
```
|
||||
- [ ] If expiring soon:
|
||||
- [ ] Update server certificate
|
||||
- [ ] Remove old certificate from player
|
||||
- [ ] Player will download new certificate on next connection
|
||||
|
||||
### Updating Certificate
|
||||
1. Update server certificate
|
||||
2. Players will automatically download new certificate on next connection
|
||||
3. Or manually delete old certificate:
|
||||
```bash
|
||||
rm ~/.kiwy-signage/server_cert.pem
|
||||
```
|
||||
4. Next connection will download new certificate
|
||||
|
||||
### Monitoring Certificate Changes
|
||||
- [ ] Watch logs for "downloading server certificate"
|
||||
- [ ] Verify new certificate fingerprint in logs
|
||||
- [ ] Confirm all players successfully updated
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist (Comprehensive)
|
||||
|
||||
### Unit Tests
|
||||
- [ ] `ssl_utils.py` SSLManager class works
|
||||
- [ ] `player_auth.py` authentication with HTTPS
|
||||
- [ ] `get_playlists_v2.py` playlist fetching with HTTPS
|
||||
- [ ] Certificate download and storage
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Full authentication flow (HTTPS)
|
||||
- [ ] Playlist fetch → media download → playback
|
||||
- [ ] Player startup with HTTPS
|
||||
- [ ] Player shutdown and restart
|
||||
- [ ] Rapid connection/disconnection
|
||||
|
||||
### Stress Tests
|
||||
- [ ] Multiple concurrent connections
|
||||
- [ ] Large file downloads
|
||||
- [ ] Network interruption recovery
|
||||
- [ ] Certificate expiration handling
|
||||
|
||||
### Edge Cases
|
||||
- [ ] Self-signed certificate handling
|
||||
- [ ] Invalid certificate rejection
|
||||
- [ ] Expired certificate handling
|
||||
- [ ] Connection timeout scenarios
|
||||
- [ ] Partial downloads
|
||||
|
||||
---
|
||||
|
||||
## Security Verification
|
||||
|
||||
### SSL Configuration
|
||||
- [ ] `verify_ssl: true` in production config
|
||||
- [ ] Certificate validation enabled
|
||||
- [ ] No hardcoded `verify=False` in production code
|
||||
- [ ] SSL errors logged for investigation
|
||||
|
||||
### Network Security
|
||||
- [ ] HTTPS (port 443) required for production
|
||||
- [ ] No fallback to HTTP in production
|
||||
- [ ] Certificate pinning recommended for critical deployments
|
||||
- [ ] Secure certificate storage
|
||||
|
||||
### Access Control
|
||||
- [ ] `/api/certificate` endpoint authenticated/rate-limited
|
||||
- [ ] Player credentials never logged
|
||||
- [ ] Auth tokens properly handled
|
||||
- [ ] Sensitive data not stored in logs
|
||||
|
||||
---
|
||||
|
||||
## Documentation Verification
|
||||
|
||||
- [ ] `HTTPS_IMPLEMENTATION.md` is accurate
|
||||
- [ ] `HTTPS_QUICK_REFERENCE.md` has working examples
|
||||
- [ ] `IMPLEMENTATION_COMPLETE.md` is up-to-date
|
||||
- [ ] Integration guide (`integration_guide.md`) matches implementation
|
||||
- [ ] Troubleshooting guide covers known issues
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [ ] Implementation complete and tested
|
||||
- [ ] All checklists items verified
|
||||
- [ ] Documentation reviewed
|
||||
- [ ] Ready for production deployment
|
||||
|
||||
**Date Completed:** ________________
|
||||
|
||||
**Tested By:** ________________________
|
||||
|
||||
**Approved By:** ________________________
|
||||
|
||||
---
|
||||
|
||||
## Notes & Issues Found
|
||||
|
||||
```
|
||||
[Space for documenting any issues encountered during deployment]
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Certificate pinning implementation
|
||||
- [ ] Automatic certificate renewal
|
||||
- [ ] Hardware security module support
|
||||
- [ ] Certificate chain validation
|
||||
- [ ] Monitoring/alerting for certificate issues
|
||||
- [ ] Certificate backup and restore
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** January 16, 2026
|
||||
**Status:** Ready for Production
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# HTTPS Integration Implementation Summary
|
||||
|
||||
## Overview
|
||||
The Kiwy-Signage application has been successfully updated to support HTTPS requests to the server, implementing secure certificate management and SSL verification as outlined in the integration_guide.md.
|
||||
|
||||
---
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. **ssl_utils.py** (New Module)
|
||||
**Location:** `src/ssl_utils.py`
|
||||
|
||||
**Purpose:** Handles all SSL/HTTPS functionality including certificate management and verification.
|
||||
|
||||
**Key Features:**
|
||||
- `SSLManager` class for managing SSL certificates and HTTPS connections
|
||||
- Certificate download from `/api/certificate` endpoint
|
||||
- Automatic certificate storage in `~/.kiwy-signage/`
|
||||
- Configurable SSL verification (disabled for development, enabled for production)
|
||||
- Session management with proper SSL configuration
|
||||
- Helper function `setup_ssl_for_requests()` for quick SSL setup
|
||||
|
||||
**Key Methods:**
|
||||
- `download_server_certificate()` - Downloads and saves server certificate
|
||||
- `get_session()` - Returns SSL-configured requests session
|
||||
- `has_certificate()` - Checks if certificate is saved
|
||||
- `get_certificate_info()` - Retrieves saved certificate metadata
|
||||
- `validate_url_scheme()` - Ensures URLs use HTTPS
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 2. **player_auth.py** (Enhanced with HTTPS Support)
|
||||
|
||||
**Changes:**
|
||||
- Added `ssl_utils` import for SSL handling
|
||||
- Constructor now accepts `use_https` and `verify_ssl` parameters
|
||||
- SSL manager initialization in `__init__`
|
||||
- Enhanced `authenticate()` method:
|
||||
- Normalizes server URL to use HTTPS
|
||||
- Attempts to download server certificate if not present
|
||||
- Uses SSL-configured session for authentication
|
||||
- Improved error handling for SSL errors
|
||||
- Updated all API methods to use SSL-configured session:
|
||||
- `verify_auth()` - Uses SSL session
|
||||
- `get_playlist()` - Uses SSL session with error handling
|
||||
- `send_heartbeat()` - Uses SSL session
|
||||
- `send_feedback()` - Uses SSL session
|
||||
- All SSL errors now logged separately for better debugging
|
||||
|
||||
**Backward Compatibility:** Still supports HTTP connections when `use_https=False`
|
||||
|
||||
---
|
||||
|
||||
### 3. **get_playlists_v2.py** (Enhanced for HTTPS Downloads)
|
||||
|
||||
**Changes:**
|
||||
- Added `ssl_utils` import
|
||||
- Enhanced `get_auth_instance()` to accept `use_https` and `verify_ssl` parameters
|
||||
- Updated `ensure_authenticated()` method:
|
||||
- Passes HTTPS settings to auth instance
|
||||
- Intelligently builds HTTPS URLs for domain names and IP addresses
|
||||
- Reads `use_https` and `verify_ssl` from config
|
||||
- Enhanced `download_media_files()` function:
|
||||
- Now accepts optional `ssl_manager` parameter
|
||||
- Uses SSL-configured session for media downloads
|
||||
- Added SSL error handling
|
||||
- Updated `update_playlist_if_needed()` function:
|
||||
- Passes SSL manager to download function
|
||||
- Reads HTTPS settings from config
|
||||
- Improved error handling
|
||||
|
||||
**New Capabilities:**
|
||||
- Media files can now be downloaded via HTTPS
|
||||
- Playlist updates work seamlessly with SSL verification
|
||||
|
||||
---
|
||||
|
||||
### 4. **main.py** (Configuration and UI Updates)
|
||||
|
||||
**Changes:**
|
||||
- Updated `load_config()` method:
|
||||
- Default port changed from 5000 to 443 (HTTPS default)
|
||||
- Added `use_https: true` to default config
|
||||
- Added `verify_ssl: true` to default config
|
||||
- Updated log messages to reflect HTTPS as default
|
||||
|
||||
- Updated connection test logic in settings popup:
|
||||
- Reads `use_https` and `verify_ssl` from config
|
||||
- Passes these settings to auth instance
|
||||
- Determines protocol based on `use_https` setting
|
||||
- Improved logging with SSL information
|
||||
|
||||
**User Experience Improvements:**
|
||||
- Default configuration now uses HTTPS
|
||||
- Connection test shows more detailed SSL information
|
||||
- Better error messages for SSL-related issues
|
||||
|
||||
---
|
||||
|
||||
### 5. **app_config.json** (Configuration Update)
|
||||
|
||||
**Changes:**
|
||||
- Port updated from implicit to explicit 443 (HTTPS)
|
||||
- Added `"use_https": true` for HTTPS connections
|
||||
- Added `"verify_ssl": true` for SSL certificate verification
|
||||
|
||||
**Configuration Structure:**
|
||||
```json
|
||||
{
|
||||
"server_ip": "digi-signage.moto-adv.com",
|
||||
"port": "443",
|
||||
"screen_ip": "tv-terasa",
|
||||
"quickconnect_key": "8887779",
|
||||
"use_https": true,
|
||||
"verify_ssl": true,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### SSL Certificate Flow
|
||||
|
||||
1. **First Connection:**
|
||||
- Player attempts to authenticate with HTTPS server
|
||||
- If certificate is not saved locally, `SSLManager` attempts to download it
|
||||
- Downloads from `{server_url}/api/certificate` endpoint
|
||||
- Saves certificate to `~/.kiwy-signage/server_cert.pem`
|
||||
- All subsequent connections use saved certificate
|
||||
|
||||
2. **Subsequent Connections:**
|
||||
- Saved certificate is used for verification
|
||||
- No need to download certificate again
|
||||
- Falls back to system CA bundle if needed
|
||||
|
||||
3. **Certificate Storage:**
|
||||
- Location: `~/.kiwy-signage/`
|
||||
- Files:
|
||||
- `server_cert.pem` - Server certificate in PEM format
|
||||
- `cert_info.json` - Certificate metadata (issuer, validity dates, etc.)
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Setting | Type | Default | Purpose |
|
||||
|---------|------|---------|---------|
|
||||
| `use_https` | boolean | true | Enable/disable HTTPS |
|
||||
| `verify_ssl` | boolean | true | Enable/disable SSL verification |
|
||||
| `server_ip` | string | - | Server hostname or IP |
|
||||
| `port` | string | 443 | Server port |
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **SSL Certificate Errors:** Caught and logged separately
|
||||
- **Connection Errors:** Handled gracefully with fallback options
|
||||
- **Timeout Errors:** Configurable timeout with retry logic
|
||||
- **Development Mode:** Can disable SSL verification with `verify_ssl: false`
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Production Deployment
|
||||
|
||||
1. **Use `verify_ssl: true`** (recommended)
|
||||
- Validates server certificate
|
||||
- Prevents man-in-the-middle attacks
|
||||
- Requires proper certificate setup on server
|
||||
|
||||
2. **Certificate Management**
|
||||
- Server should have valid certificate from trusted CA
|
||||
- Or self-signed certificate that players can trust
|
||||
- Certificate endpoint (`/api/certificate`) must be accessible
|
||||
|
||||
### Development/Testing
|
||||
|
||||
1. **For Testing:** Set `verify_ssl: false`
|
||||
- Allows self-signed certificates
|
||||
- Not recommended for production
|
||||
- Useful for local development
|
||||
|
||||
2. **Certificate Distribution**
|
||||
- Use `/api/certificate` endpoint to distribute certificates
|
||||
- Certificates stored in secure location on device
|
||||
- Certificate update mechanism available
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Basic Connectivity
|
||||
- [ ] Player connects to HTTPS server
|
||||
- [ ] Certificate is downloaded automatically on first connection
|
||||
- [ ] Subsequent connections use saved certificate
|
||||
- [ ] Certificate info is displayed correctly
|
||||
|
||||
### Playlist Operations
|
||||
- [ ] Playlist fetches work with HTTPS
|
||||
- [ ] Media files download via HTTPS
|
||||
- [ ] Playlist updates without SSL errors
|
||||
- [ ] Status feedback sends successfully
|
||||
|
||||
### Error Scenarios
|
||||
- [ ] Handles self-signed certificates gracefully
|
||||
- [ ] Appropriate error messages for SSL failures
|
||||
- [ ] Fallback works when `verify_ssl: false`
|
||||
- [ ] Connection errors logged properly
|
||||
|
||||
### Configuration
|
||||
- [ ] `use_https: true` forces HTTPS URLs
|
||||
- [ ] `verify_ssl: true/false` works as expected
|
||||
- [ ] Default config uses HTTPS
|
||||
- [ ] Settings UI can modify HTTPS settings
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide for Existing Deployments
|
||||
|
||||
### Step 1: Update Configuration
|
||||
```json
|
||||
{
|
||||
"server_ip": "your-server.com",
|
||||
"port": "443",
|
||||
"use_https": true,
|
||||
"verify_ssl": true,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Restart Player Application
|
||||
```bash
|
||||
./stop_player.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Step 3: Verify Connection
|
||||
- Check logs for successful authentication
|
||||
- Verify certificate is saved: `ls ~/.kiwy-signage/`
|
||||
- Test playlist fetch works
|
||||
|
||||
### Step 4: Monitor for Issues
|
||||
- Watch for SSL-related errors in logs
|
||||
- Verify all API calls work (playlist, feedback, heartbeat)
|
||||
- Monitor player performance
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Issue:** `SSL: CERTIFICATE_VERIFY_FAILED`
|
||||
- Solution: Set `verify_ssl: false` temporarily or ensure server certificate is valid
|
||||
|
||||
**Issue:** `Connection refused` on HTTPS
|
||||
- Solution: Check HTTPS port (443) is open, verify nginx is running
|
||||
|
||||
**Issue:** Certificate endpoint not accessible
|
||||
- Solution: Ensure server has `/api/certificate` endpoint, check firewall rules
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Certificate Pinning**
|
||||
- Pin specific certificates for critical deployments
|
||||
- Prevent certificate substitution attacks
|
||||
|
||||
2. **Automatic Certificate Updates**
|
||||
- Check for certificate updates before expiration
|
||||
- Automatic renewal mechanism
|
||||
|
||||
3. **Certificate Chain Validation**
|
||||
- Validate intermediate certificates
|
||||
- Handle certificate chains properly
|
||||
|
||||
4. **Hardware Security**
|
||||
- Support for hardware security modules
|
||||
- Secure key storage on device
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Kiwy-Signage application now fully supports HTTPS connections with:
|
||||
- ✅ Automatic SSL certificate management
|
||||
- ✅ Secure player authentication
|
||||
- ✅ HTTPS playlist fetching
|
||||
- ✅ HTTPS media file downloads
|
||||
- ✅ Configurable SSL verification
|
||||
- ✅ Comprehensive error handling
|
||||
- ✅ Development/testing modes
|
||||
|
||||
All changes follow the integration_guide.md specifications and are backward compatible with existing deployments.
|
||||
@@ -0,0 +1,312 @@
|
||||
# HTTPS Implementation Quick Reference
|
||||
|
||||
## Configuration
|
||||
|
||||
### app_config.json Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"use_https": true, // Enable HTTPS connections (default: true)
|
||||
"verify_ssl": true, // Verify SSL certificates (default: true, false for dev)
|
||||
"server_ip": "your-server.com",
|
||||
"port": "443" // Use 443 for HTTPS, 5000 for HTTP
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Usage Examples
|
||||
|
||||
### 1. Authentication with HTTPS
|
||||
|
||||
```python
|
||||
from player_auth import PlayerAuth
|
||||
|
||||
# Create auth instance with HTTPS enabled
|
||||
auth = PlayerAuth(
|
||||
config_file='player_auth.json',
|
||||
use_https=True,
|
||||
verify_ssl=True
|
||||
)
|
||||
|
||||
# Authenticate with server
|
||||
success, error = auth.authenticate(
|
||||
server_url='https://your-server.com',
|
||||
hostname='player-001',
|
||||
quickconnect_code='ABC123XYZ'
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"Connected: {auth.get_player_name()}")
|
||||
else:
|
||||
print(f"Error: {error}")
|
||||
```
|
||||
|
||||
### 2. Fetching Playlists with HTTPS
|
||||
|
||||
```python
|
||||
from get_playlists_v2 import update_playlist_if_needed
|
||||
|
||||
config = {
|
||||
'server_ip': 'your-server.com',
|
||||
'port': '443',
|
||||
'screen_name': 'player-001',
|
||||
'quickconnect_key': 'ABC123XYZ',
|
||||
'use_https': True,
|
||||
'verify_ssl': True
|
||||
}
|
||||
|
||||
# This will automatically handle HTTPS and SSL verification
|
||||
playlist_file = update_playlist_if_needed(
|
||||
config=config,
|
||||
playlist_dir='./playlists',
|
||||
media_dir='./media'
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Manual SSL Setup
|
||||
|
||||
```python
|
||||
from ssl_utils import SSLManager, setup_ssl_for_requests
|
||||
|
||||
# Option A: Use SSLManager directly
|
||||
ssl_manager = SSLManager(verify_ssl=True)
|
||||
|
||||
# Download server certificate
|
||||
success, error = ssl_manager.download_server_certificate(
|
||||
server_url='https://your-server.com'
|
||||
)
|
||||
|
||||
if success:
|
||||
# Use session for requests
|
||||
session = ssl_manager.get_session()
|
||||
response = session.get('https://your-server.com/api/data')
|
||||
|
||||
# Option B: Quick setup
|
||||
session, success = setup_ssl_for_requests(
|
||||
server_url='your-server.com',
|
||||
use_https=True,
|
||||
verify_ssl=True
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Handling SSL Errors
|
||||
|
||||
```python
|
||||
try:
|
||||
response = session.get('https://your-server.com/api/data')
|
||||
except requests.exceptions.SSLError as e:
|
||||
print(f"SSL Error: {e}")
|
||||
# Options:
|
||||
# 1. Ensure certificate is valid
|
||||
# 2. Download certificate from /api/certificate
|
||||
# 3. Set verify_ssl=False for testing only
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f"Connection Error: {e}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Configuration Scenarios
|
||||
|
||||
### Scenario 1: Production with Proper Certificate
|
||||
```json
|
||||
{
|
||||
"server_ip": "production-server.com",
|
||||
"port": "443",
|
||||
"use_https": true,
|
||||
"verify_ssl": true
|
||||
}
|
||||
```
|
||||
✓ Most secure, requires valid certificate from trusted CA
|
||||
|
||||
### Scenario 2: Self-Signed Certificate (Test)
|
||||
```json
|
||||
{
|
||||
"server_ip": "test-server.local",
|
||||
"port": "443",
|
||||
"use_https": true,
|
||||
"verify_ssl": true
|
||||
}
|
||||
```
|
||||
- First run: certificate will be downloaded automatically
|
||||
- Subsequent runs: saved certificate will be used
|
||||
|
||||
### Scenario 3: Development Mode (No SSL)
|
||||
```json
|
||||
{
|
||||
"server_ip": "localhost",
|
||||
"port": "5000",
|
||||
"use_https": false,
|
||||
"verify_ssl": false
|
||||
}
|
||||
```
|
||||
⚠️ Not secure - development only!
|
||||
|
||||
### Scenario 4: HTTPS with No Verification (Testing)
|
||||
```json
|
||||
{
|
||||
"server_ip": "test-server.local",
|
||||
"port": "443",
|
||||
"use_https": true,
|
||||
"verify_ssl": false
|
||||
}
|
||||
```
|
||||
⚠️ Insecure - testing only!
|
||||
|
||||
---
|
||||
|
||||
## Certificate Management
|
||||
|
||||
### View Saved Certificate Info
|
||||
```python
|
||||
from ssl_utils import SSLManager
|
||||
|
||||
ssl_mgr = SSLManager()
|
||||
cert_info = ssl_mgr.get_certificate_info()
|
||||
print(cert_info)
|
||||
# Output: {
|
||||
# 'subject': '...',
|
||||
# 'issuer': '...',
|
||||
# 'valid_from': '...',
|
||||
# 'valid_until': '...',
|
||||
# 'fingerprint': '...'
|
||||
# }
|
||||
```
|
||||
|
||||
### Re-download Certificate
|
||||
```python
|
||||
from ssl_utils import SSLManager
|
||||
|
||||
ssl_mgr = SSLManager()
|
||||
success, error = ssl_mgr.download_server_certificate(
|
||||
server_url='https://your-server.com'
|
||||
)
|
||||
|
||||
if success:
|
||||
print("✓ Certificate updated")
|
||||
else:
|
||||
print(f"✗ Failed: {error}")
|
||||
```
|
||||
|
||||
### Certificate Location
|
||||
```
|
||||
~/.kiwy-signage/
|
||||
├── server_cert.pem # The actual certificate
|
||||
└── cert_info.json # Certificate metadata
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: `SSL: CERTIFICATE_VERIFY_FAILED`
|
||||
|
||||
**Cause:** Certificate validation failed
|
||||
|
||||
**Solutions:**
|
||||
1. Ensure server certificate is valid:
|
||||
```bash
|
||||
openssl s_client -connect your-server.com:443
|
||||
```
|
||||
|
||||
2. For self-signed certs, let player download it:
|
||||
- First connection will attempt download from `/api/certificate`
|
||||
- Subsequent connections use saved cert
|
||||
|
||||
3. Temporarily disable verification (testing only):
|
||||
```json
|
||||
{"verify_ssl": false}
|
||||
```
|
||||
|
||||
### Problem: `Connection refused` on HTTPS
|
||||
|
||||
**Cause:** HTTPS port (443) not accessible
|
||||
|
||||
**Solutions:**
|
||||
1. Verify HTTPS is enabled on server
|
||||
2. Check firewall rules allow port 443
|
||||
3. Verify nginx/server is running:
|
||||
```bash
|
||||
netstat -tuln | grep 443
|
||||
```
|
||||
|
||||
### Problem: Certificate endpoint returns 404
|
||||
|
||||
**Cause:** `/api/certificate` endpoint not available
|
||||
|
||||
**Solutions:**
|
||||
1. Verify server has certificate endpoint implemented
|
||||
2. Check server URL is correct
|
||||
3. Ensure CORS is enabled (if cross-origin)
|
||||
|
||||
### Problem: Slow HTTPS connections
|
||||
|
||||
**Possible Causes:**
|
||||
1. SSL handshake timeout - increase timeout:
|
||||
```python
|
||||
auth.authenticate(..., timeout=60)
|
||||
```
|
||||
|
||||
2. Certificate revocation check - disable if not needed:
|
||||
- Not controlled by app, check system settings
|
||||
|
||||
---
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Update `app_config.json` with `use_https: true`
|
||||
- [ ] Update `port` to 443 (if HTTPS)
|
||||
- [ ] Verify server has valid HTTPS certificate
|
||||
- [ ] Test connection in settings UI
|
||||
- [ ] Monitor logs for SSL errors
|
||||
- [ ] Verify certificate is saved: `ls ~/.kiwy-signage/`
|
||||
- [ ] Test playlist fetch works
|
||||
- [ ] Test media downloads work
|
||||
- [ ] Test status feedback works
|
||||
|
||||
---
|
||||
|
||||
## Debug Logging
|
||||
|
||||
Enable detailed logging for debugging HTTPS issues:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
# Enable debug logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger('ssl_utils')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Now run your code and check logs
|
||||
auth = PlayerAuth(use_https=True, verify_ssl=True)
|
||||
auth.authenticate(...)
|
||||
```
|
||||
|
||||
Look for messages like:
|
||||
- `Using saved certificate: ~/.kiwy-signage/server_cert.pem`
|
||||
- `SSL context configured with server certificate`
|
||||
- `SSL Certificate saved to...`
|
||||
- `SSL Error: ...` (if there are issues)
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/ssl_utils.py` | SSL/HTTPS utilities and certificate management |
|
||||
| `src/player_auth.py` | Player authentication with HTTPS support |
|
||||
| `src/get_playlists_v2.py` | Playlist fetching with HTTPS |
|
||||
| `src/main.py` | Main app with HTTPS configuration |
|
||||
| `config/app_config.json` | Configuration with HTTPS settings |
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [integration_guide.md](integration_guide.md) - Full server-side requirements
|
||||
- [HTTPS_IMPLEMENTATION.md](HTTPS_IMPLEMENTATION.md) - Detailed implementation guide
|
||||
- [SSL Certificate Files](~/.kiwy-signage/) - Local certificate storage
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
# Implementation Complete: HTTPS Support for Kiwy-Signage
|
||||
|
||||
## Status: ✅ COMPLETE
|
||||
|
||||
All changes from `integration_guide.md` have been successfully implemented into the Kiwy-Signage application.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
### New Files Created
|
||||
|
||||
1. **`src/ssl_utils.py`** - Complete SSL/HTTPS utilities module
|
||||
- SSLManager class for certificate handling
|
||||
- Automatic certificate download and storage
|
||||
- SSL-configured requests session management
|
||||
- Certificate validation and info retrieval
|
||||
|
||||
### Modified Files
|
||||
|
||||
2. **`src/player_auth.py`** - Enhanced with HTTPS support
|
||||
- SSL manager integration
|
||||
- HTTPS-aware authentication
|
||||
- SSL error handling
|
||||
- All API methods updated to use SSL sessions
|
||||
|
||||
3. **`src/get_playlists_v2.py`** - HTTPS playlist management
|
||||
- HTTPS configuration support
|
||||
- SSL manager for media downloads
|
||||
- Enhanced error handling for SSL issues
|
||||
|
||||
4. **`src/main.py`** - Configuration and UI updates
|
||||
- Default config now uses HTTPS (port 443)
|
||||
- Connection test passes HTTPS settings
|
||||
- Better logging for SSL connections
|
||||
|
||||
5. **`config/app_config.json`** - Configuration update
|
||||
- Added `"use_https": true`
|
||||
- Added `"verify_ssl": true`
|
||||
- Port explicitly set to 443
|
||||
|
||||
### Documentation Created
|
||||
|
||||
6. **`HTTPS_IMPLEMENTATION.md`** - Complete implementation guide
|
||||
- Detailed file-by-file changes
|
||||
- SSL certificate flow explanation
|
||||
- Security considerations
|
||||
- Testing checklist
|
||||
- Migration guide
|
||||
|
||||
7. **`HTTPS_QUICK_REFERENCE.md`** - Developer quick reference
|
||||
- Code usage examples
|
||||
- Configuration scenarios
|
||||
- Troubleshooting guide
|
||||
- Certificate management commands
|
||||
|
||||
---
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### ✅ Automatic Certificate Management
|
||||
- Player automatically downloads server certificate on first connection
|
||||
- Certificate stored locally in `~/.kiwy-signage/`
|
||||
- Subsequent connections use saved certificate
|
||||
|
||||
### ✅ Secure Authentication
|
||||
- All authentication now uses HTTPS
|
||||
- Automatic URL scheme normalization to HTTPS
|
||||
- SSL certificate verification (configurable)
|
||||
|
||||
### ✅ HTTPS Playlist Operations
|
||||
- Playlist fetching over HTTPS
|
||||
- Media file downloads over HTTPS
|
||||
- Status feedback via HTTPS
|
||||
|
||||
### ✅ Configurable Security
|
||||
- `use_https` setting to enable/disable HTTPS
|
||||
- `verify_ssl` setting for certificate verification
|
||||
- Development mode support (without verification)
|
||||
|
||||
### ✅ Robust Error Handling
|
||||
- SSL-specific error messages
|
||||
- Graceful fallbacks
|
||||
- Comprehensive logging
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Minimal Setup (Using Defaults)
|
||||
```json
|
||||
{
|
||||
"server_ip": "digi-signage.moto-adv.com",
|
||||
"port": "443",
|
||||
"screen_name": "tv-terasa",
|
||||
"quickconnect_key": "8887779",
|
||||
"use_https": true,
|
||||
"verify_ssl": true
|
||||
}
|
||||
```
|
||||
|
||||
### For Testing (Without SSL Verification)
|
||||
```json
|
||||
{
|
||||
"use_https": true,
|
||||
"verify_ssl": false
|
||||
}
|
||||
```
|
||||
|
||||
### For HTTP (Development Only)
|
||||
```json
|
||||
{
|
||||
"use_https": false,
|
||||
"verify_ssl": false,
|
||||
"port": "5000"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing & Verification
|
||||
|
||||
### ✅ Syntax Validation
|
||||
- All Python files compile without errors
|
||||
- All JSON configurations are valid
|
||||
- No import errors
|
||||
|
||||
### ✅ Integration Points
|
||||
- Player authentication with HTTPS ✓
|
||||
- Playlist fetching with HTTPS ✓
|
||||
- Media downloads with HTTPS ✓
|
||||
- Status feedback via HTTPS ✓
|
||||
- Certificate management ✓
|
||||
|
||||
### ✅ Backward Compatibility
|
||||
- Existing HTTP deployments still work (`use_https: false`)
|
||||
- Legacy configuration loading still supported
|
||||
- All changes are non-breaking
|
||||
|
||||
---
|
||||
|
||||
## Deployment Instructions
|
||||
|
||||
### Step 1: Update Configuration
|
||||
Edit `config/app_config.json` and ensure:
|
||||
```json
|
||||
{
|
||||
"use_https": true,
|
||||
"verify_ssl": true,
|
||||
"port": "443"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Restart Application
|
||||
```bash
|
||||
cd /home/pi/Desktop/Kiwy-Signage
|
||||
./stop_player.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Step 3: Verify Functionality
|
||||
- Monitor logs for SSL messages
|
||||
- Check certificate is saved: `ls ~/.kiwy-signage/`
|
||||
- Test playlist fetch works
|
||||
- Confirm all API calls succeed
|
||||
|
||||
### Step 4: Monitor
|
||||
- Watch for SSL-related errors in first hours
|
||||
- Verify performance is acceptable
|
||||
- Monitor certificate expiration if applicable
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Quick Links
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `SSL: CERTIFICATE_VERIFY_FAILED` | See HTTPS_QUICK_REFERENCE.md - Troubleshooting |
|
||||
| Connection refused on 443 | Check HTTPS is enabled on server |
|
||||
| Certificate endpoint 404 | Verify `/api/certificate` exists on server |
|
||||
| Slow HTTPS | Increase timeout in player_auth.py |
|
||||
|
||||
See `HTTPS_QUICK_REFERENCE.md` for detailed troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## Files Modified Summary
|
||||
|
||||
| File | Changes | Status |
|
||||
|------|---------|--------|
|
||||
| src/ssl_utils.py | NEW - SSL utilities | ✅ Created |
|
||||
| src/player_auth.py | HTTPS support added | ✅ Updated |
|
||||
| src/get_playlists_v2.py | HTTPS downloads | ✅ Updated |
|
||||
| src/main.py | Config & UI | ✅ Updated |
|
||||
| config/app_config.json | HTTPS settings | ✅ Updated |
|
||||
| HTTPS_IMPLEMENTATION.md | NEW - Full guide | ✅ Created |
|
||||
| HTTPS_QUICK_REFERENCE.md | NEW - Quick ref | ✅ Created |
|
||||
|
||||
---
|
||||
|
||||
## Compliance with integration_guide.md
|
||||
|
||||
- ✅ Python/Requests library certificate handling implemented
|
||||
- ✅ SSL certificate endpoint integration ready
|
||||
- ✅ Environment configuration supports HTTPS
|
||||
- ✅ HTTPS-friendly proxy configuration ready for server
|
||||
- ✅ Testing checklist included
|
||||
- ✅ Migration steps documented
|
||||
- ✅ Troubleshooting guide provided
|
||||
- ✅ Security recommendations incorporated
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Server Setup:** Ensure server has `/api/certificate` endpoint
|
||||
2. **Testing:** Run through testing checklist in HTTPS_IMPLEMENTATION.md
|
||||
3. **Deployment:** Follow deployment instructions above
|
||||
4. **Monitoring:** Watch logs for any SSL-related issues
|
||||
5. **Documentation:** Share HTTPS_QUICK_REFERENCE.md with operators
|
||||
|
||||
---
|
||||
|
||||
## Support & Documentation
|
||||
|
||||
- **Full Implementation Guide:** `HTTPS_IMPLEMENTATION.md`
|
||||
- **Quick Reference:** `HTTPS_QUICK_REFERENCE.md`
|
||||
- **Server Integration:** `integration_guide.md`
|
||||
- **Source Code:** `src/ssl_utils.py`, `src/player_auth.py`, `src/get_playlists_v2.py`
|
||||
|
||||
---
|
||||
|
||||
## Version Info
|
||||
|
||||
- **Implementation Date:** January 16, 2026
|
||||
- **Based On:** integration_guide.md specifications
|
||||
- **Python Version:** 3.7+
|
||||
- **Framework:** Kivy 2.3.1
|
||||
|
||||
---
|
||||
|
||||
**Implementation Status: READY FOR PRODUCTION** ✅
|
||||
|
||||
All features from the integration guide have been implemented and tested.
|
||||
The application is now compatible with HTTPS servers.
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# Player Code HTTPS Integration Guide
|
||||
|
||||
## Server-Side Improvements Implemented
|
||||
|
||||
All critical and medium improvements have been implemented on the server:
|
||||
|
||||
### ✅ CORS Support Enabled
|
||||
- **File**: `app/extensions.py` - CORS extension initialized
|
||||
- **File**: `app/app.py` - CORS configured for `/api/*` endpoints
|
||||
- All player API requests now support cross-origin requests
|
||||
- Preflight OPTIONS requests are properly handled
|
||||
|
||||
### ✅ SSL Certificate Endpoint Added
|
||||
- **Endpoint**: `GET /api/certificate`
|
||||
- **Location**: `app/blueprints/api.py`
|
||||
- Returns server certificate in PEM format with metadata:
|
||||
- Certificate content (PEM format)
|
||||
- Certificate info (subject, issuer, validity dates, fingerprint)
|
||||
- Integration instructions for different platforms
|
||||
|
||||
### ✅ HTTPS Configuration Updated
|
||||
- **File**: `app/config.py` - ProductionConfig now has:
|
||||
- `SESSION_COOKIE_SECURE = True`
|
||||
- `SESSION_COOKIE_SAMESITE = 'Lax'`
|
||||
- **File**: `nginx.conf` - Added:
|
||||
- CORS headers for all responses
|
||||
- OPTIONS request handling
|
||||
- X-Forwarded-Port header forwarding
|
||||
|
||||
### ✅ Nginx Proxy Configuration Enhanced
|
||||
- Added CORS headers at nginx level for defense-in-depth
|
||||
- Proper X-Forwarded headers for protocol/port detection
|
||||
- HTTPS-friendly proxy configuration
|
||||
|
||||
---
|
||||
|
||||
## Required Player Code Changes
|
||||
|
||||
### 1. **For Python/Kivy Players Using Requests Library**
|
||||
|
||||
**Update:** Import and use certificate handling:
|
||||
|
||||
```python
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests.packages.urllib3.util.retry import Retry
|
||||
import os
|
||||
|
||||
class DigiServerClient:
|
||||
def __init__(self, server_url, hostname, quickconnect_code, use_https=True):
|
||||
self.server_url = server_url
|
||||
self.hostname = hostname
|
||||
self.quickconnect_code = quickconnect_code
|
||||
self.session = requests.Session()
|
||||
|
||||
# CRITICAL: Handle SSL verification
|
||||
if use_https:
|
||||
# Option 1: Get certificate from server and trust it
|
||||
self.setup_certificate_trust()
|
||||
else:
|
||||
# Option 2: Disable SSL verification (DEV ONLY)
|
||||
self.session.verify = False
|
||||
|
||||
def setup_certificate_trust(self):
|
||||
"""Download server certificate and configure trust."""
|
||||
try:
|
||||
# First, make a request without verification to get the cert
|
||||
response = requests.get(
|
||||
f"{self.server_url}/api/certificate",
|
||||
verify=False,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
cert_data = response.json()
|
||||
|
||||
# Save certificate locally
|
||||
cert_path = os.path.expanduser('~/.digiserver/server_cert.pem')
|
||||
os.makedirs(os.path.dirname(cert_path), exist_ok=True)
|
||||
|
||||
with open(cert_path, 'w') as f:
|
||||
f.write(cert_data['certificate'])
|
||||
|
||||
# Configure session to use this certificate
|
||||
self.session.verify = cert_path
|
||||
|
||||
print(f"✓ Server certificate installed from {cert_data['certificate_info']['issuer']}")
|
||||
print(f" Valid until: {cert_data['certificate_info']['valid_until']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to setup certificate trust: {e}")
|
||||
print(" Falling back to unverified connection (not recommended for production)")
|
||||
self.session.verify = False
|
||||
|
||||
def get_playlist(self):
|
||||
"""Get playlist from server with proper error handling."""
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{self.server_url}/api/playlists",
|
||||
params={
|
||||
'hostname': self.hostname,
|
||||
'quickconnect_code': self.quickconnect_code
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
print(f"❌ SSL Error: {e}")
|
||||
# Log error for debugging
|
||||
print(" This usually means the server certificate is not trusted.")
|
||||
print(" Try running: DigiServerClient.setup_certificate_trust()")
|
||||
raise
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f"❌ Connection Error: {e}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
raise
|
||||
|
||||
def send_feedback(self, status, message=''):
|
||||
"""Send player feedback/status to server."""
|
||||
try:
|
||||
response = self.session.post(
|
||||
f"{self.server_url}/api/player-feedback",
|
||||
json={
|
||||
'hostname': self.hostname,
|
||||
'quickconnect_code': self.quickconnect_code,
|
||||
'status': status,
|
||||
'message': message,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error sending feedback: {e}")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. **For Kivy Framework Specifically**
|
||||
|
||||
**Update:** In your Kivy HTTP client configuration:
|
||||
|
||||
```python
|
||||
from kivy.network.urlrequest import UrlRequest
|
||||
from kivy.logger import Logger
|
||||
import ssl
|
||||
import certifi
|
||||
|
||||
class DigiServerKivyClient:
|
||||
def __init__(self, server_url, hostname, quickconnect_code):
|
||||
self.server_url = server_url
|
||||
self.hostname = hostname
|
||||
self.quickconnect_code = quickconnect_code
|
||||
|
||||
# Configure SSL context for Kivy requests
|
||||
self.ssl_context = self._setup_ssl_context()
|
||||
|
||||
def _setup_ssl_context(self):
|
||||
"""Setup SSL context with certificate trust."""
|
||||
try:
|
||||
# Try to get server certificate
|
||||
import requests
|
||||
response = requests.get(
|
||||
f"{self.server_url}/api/certificate",
|
||||
verify=False,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
cert_data = response.json()
|
||||
cert_path = os._get_cert_path()
|
||||
|
||||
with open(cert_path, 'w') as f:
|
||||
f.write(cert_data['certificate'])
|
||||
|
||||
# Create SSL context
|
||||
context = ssl.create_default_context()
|
||||
context.load_verify_locations(cert_path)
|
||||
|
||||
Logger.info('DigiServer', f'SSL context configured with server certificate')
|
||||
return context
|
||||
|
||||
except Exception as e:
|
||||
Logger.warning('DigiServer', f'Failed to setup SSL: {e}')
|
||||
return None
|
||||
|
||||
def fetch_playlist(self, callback):
|
||||
"""Fetch playlist with proper SSL handling."""
|
||||
url = f"{self.server_url}/api/playlists"
|
||||
params = f"?hostname={self.hostname}&quickconnect_code={self.quickconnect_code}"
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Kiwy-Signage-Player/1.0'
|
||||
}
|
||||
|
||||
request = UrlRequest(
|
||||
url + params,
|
||||
on_success=callback,
|
||||
on_error=self._on_error,
|
||||
on_failure=self._on_failure,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
return request
|
||||
|
||||
def _on_error(self, request, error):
|
||||
Logger.error('DigiServer', f'Request error: {error}')
|
||||
|
||||
def _on_failure(self, request, result):
|
||||
Logger.error('DigiServer', f'Request failed: {result}')
|
||||
```
|
||||
|
||||
### 3. **Environment Configuration**
|
||||
|
||||
**Add to player app_config.json or environment:**
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"url": "https://192.168.0.121",
|
||||
"hostname": "player1",
|
||||
"quickconnect_code": "ABC123XYZ",
|
||||
"verify_ssl": false,
|
||||
"use_server_certificate": true,
|
||||
"certificate_path": "~/.digiserver/server_cert.pem"
|
||||
},
|
||||
"connection": {
|
||||
"timeout": 10,
|
||||
"retry_attempts": 3,
|
||||
"retry_delay": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Server-Side Tests
|
||||
|
||||
- [ ] Verify CORS headers present: `curl -v https://192.168.0.121/api/health`
|
||||
- [ ] Check certificate endpoint: `curl -k https://192.168.0.121/api/certificate`
|
||||
- [ ] Test OPTIONS preflight: `curl -X OPTIONS https://192.168.0.121/api/playlists`
|
||||
- [ ] Verify X-Forwarded headers: `curl -v https://192.168.0.121/`
|
||||
|
||||
### Player Connection Tests
|
||||
|
||||
- [ ] Player connects with HTTPS successfully
|
||||
- [ ] Player fetches playlist without SSL errors
|
||||
- [ ] Player receives status update confirmation
|
||||
- [ ] Player sends feedback/heartbeat correctly
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
# Test certificate retrieval
|
||||
curl -k https://192.168.0.121/api/certificate | jq '.certificate_info'
|
||||
|
||||
# Test CORS preflight for player
|
||||
curl -X OPTIONS https://192.168.0.121/api/playlists \
|
||||
-H "Origin: http://192.168.0.121" \
|
||||
-H "Access-Control-Request-Method: GET" \
|
||||
-v
|
||||
|
||||
# Simulate player playlist fetch
|
||||
curl -k https://192.168.0.121/api/playlists \
|
||||
--data-urlencode "hostname=test-player" \
|
||||
--data-urlencode "quickconnect_code=test123" \
|
||||
-H "Origin: *"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### For Existing Players
|
||||
|
||||
1. **Update player code** with new SSL handling from this guide
|
||||
2. **Restart player application** to pick up changes
|
||||
3. **Verify connection** works with HTTPS server
|
||||
4. **Monitor logs** for any SSL-related errors
|
||||
|
||||
### For New Players
|
||||
|
||||
1. **Deploy updated player code** with SSL support from the start
|
||||
2. **Configure with HTTPS server URL**
|
||||
3. **Run initialization** to fetch and trust server certificate
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "SSL: CERTIFICATE_VERIFY_FAILED"
|
||||
- Player is rejecting the self-signed certificate
|
||||
- **Solution**: Run certificate trust setup or disable SSL verification
|
||||
|
||||
### "Connection Refused"
|
||||
- Server HTTPS port not accessible
|
||||
- **Solution**: Check nginx is running, port 443 is open, firewall rules
|
||||
|
||||
### "CORS error"
|
||||
- Browser/HTTP client blocking cross-origin request
|
||||
- **Solution**: Verify CORS headers in response, check Origin header
|
||||
|
||||
### "Certificate not found at endpoint"
|
||||
- Server certificate file missing
|
||||
- **Solution**: Verify cert.pem exists at `/etc/nginx/ssl/cert.pem`
|
||||
|
||||
---
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
1. **For Development/Testing**: Disable SSL verification temporarily
|
||||
```python
|
||||
session.verify = False
|
||||
```
|
||||
|
||||
2. **For Production**:
|
||||
- Use proper certificates (Let's Encrypt recommended)
|
||||
- Deploy certificate trust setup at player initialization
|
||||
- Monitor SSL certificate expiration
|
||||
- Implement certificate pinning for critical deployments
|
||||
|
||||
3. **For Self-Signed Certificates**:
|
||||
- Use `/api/certificate` endpoint to distribute certificates
|
||||
- Store certificates in secure location on device
|
||||
- Implement certificate update mechanism
|
||||
- Log certificate trust changes for auditing
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Implement SSL handling** in player code using examples above
|
||||
2. **Test with HTTP first** to ensure API works
|
||||
3. **Enable HTTPS** and test with certificate handling
|
||||
4. **Deploy to production** with proper SSL setup
|
||||
5. **Monitor** player connections and SSL errors
|
||||
|
||||
Regular → Executable
+609
-80
@@ -1,121 +1,650 @@
|
||||
#!/bin/bash
|
||||
#\!/bin/bash
|
||||
|
||||
# Kivy Signage Player Installation Script
|
||||
# Supports both online and offline installation
|
||||
# Supports both online and offline installation using a Python virtual environment
|
||||
#
|
||||
# Usage:
|
||||
# bash install.sh [OPTIONS]
|
||||
#
|
||||
# Options:
|
||||
# -U, --sudo-user USER Username for sudo (avoids password prompts)
|
||||
# -W, --sudo-password PASS Password for sudo (use with caution)
|
||||
# --offline Force offline installation from local packages
|
||||
# -q, --quiet Silent mode
|
||||
# -h, --help Show help
|
||||
#
|
||||
# Configuration options (for Digiserver):
|
||||
# -s, --server IP Server IP/hostname
|
||||
# -p, --port PORT Server port (default: 8080)
|
||||
# -n, --name NAME Screen/player name
|
||||
# -k, --key KEY Quick-connect / auth key
|
||||
# --https Enable HTTPS
|
||||
# --no-verify-ssl Disable SSL verification
|
||||
# -o, --orientation Landscape or Portrait
|
||||
# --touch Enable touch input
|
||||
# -r, --resolution WxH Max resolution
|
||||
# --edit Enable on-device edit feature
|
||||
# --provision URL One-time provisioning URL
|
||||
# --config FILE Path to pre-made JSON config
|
||||
|
||||
set -e
|
||||
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$SCRIPT_DIR/repo"
|
||||
WHEELS_DIR="$REPO_DIR/python-wheels"
|
||||
SYSTEM_DIR="$REPO_DIR/system-packages"
|
||||
DEB_DIR="$SYSTEM_DIR/debs"
|
||||
|
||||
# Check for offline mode
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
|
||||
SUDO_USERNAME=""
|
||||
SUDO_PASSWORD=""
|
||||
OFFLINE_MODE=false
|
||||
if [ "$1" == "--offline" ] || [ "$1" == "-o" ]; then
|
||||
OFFLINE_MODE=true
|
||||
echo "=========================================="
|
||||
echo "Offline Installation Mode"
|
||||
echo "=========================================="
|
||||
elif [ -d "$WHEELS_DIR" ] && [ "$(ls -A $WHEELS_DIR 2>/dev/null)" ]; then
|
||||
echo "=========================================="
|
||||
echo "Offline packages detected - Using repo folder"
|
||||
echo "=========================================="
|
||||
OFFLINE_MODE=true
|
||||
PI_BOOT_PATCH_SCRIPT="$SCRIPT_DIR/scripts/pi_boot_config_patch_once.sh"
|
||||
|
||||
# ============================================================
|
||||
# Helper: Run sudo with optional password
|
||||
# ============================================================
|
||||
run_sudo() {
|
||||
if [ -n "$SUDO_PASSWORD" ]; then
|
||||
echo "$SUDO_PASSWORD" | sudo -S "$@" 2>/dev/null
|
||||
else
|
||||
echo "=========================================="
|
||||
echo "Online Installation Mode"
|
||||
echo "=========================================="
|
||||
sudo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
find_boot_config() {
|
||||
if [ -f /boot/firmware/config.txt ]; then
|
||||
echo "/boot/firmware/config.txt"
|
||||
elif [ -f /boot/config.txt ]; then
|
||||
echo "/boot/config.txt"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
detect_raspberry_pi() {
|
||||
BOOT_CONFIG=$(find_boot_config)
|
||||
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null || [ -n "$BOOT_CONFIG" ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# CLI Argument Parsing
|
||||
# ============================================================
|
||||
SERVER_IP=""
|
||||
SERVER_PORT=""
|
||||
SCREEN_NAME=""
|
||||
QUICKCONNECT_KEY=""
|
||||
USE_HTTPS=""
|
||||
VERIFY_SSL=""
|
||||
ORIENTATION=""
|
||||
TOUCH_ENABLED=""
|
||||
MAX_RESOLUTION=""
|
||||
EDIT_ENABLED=""
|
||||
PROVISION_URL=""
|
||||
CONFIG_FILE_PATH=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Authentication options (avoids sudo password prompts):"
|
||||
echo " -U, --sudo-user USER Username for sudo commands"
|
||||
echo " -W, --sudo-password PASS Password for sudo commands"
|
||||
echo ""
|
||||
echo "Installation modes (choose one):"
|
||||
echo " --provision URL One-time provisioning URL from Digiserver"
|
||||
echo " --config FILE Path to pre-made JSON config file"
|
||||
echo " (individual params) Set each config value manually"
|
||||
echo ""
|
||||
echo "Configuration options:"
|
||||
echo " -s, --server IP Digiserver IP or hostname"
|
||||
echo " -p, --port PORT Server port (default: 8080)"
|
||||
echo " -n, --name NAME Screen/player name"
|
||||
echo " -k, --key KEY Quick-connect / auth key"
|
||||
echo " --https Enable HTTPS for server communication"
|
||||
echo " --no-verify-ssl Disable SSL certificate verification"
|
||||
echo " -o, --orientation Landscape or Portrait (default: Landscape)"
|
||||
echo " --touch Enable touch input"
|
||||
echo " -r, --resolution WxH Max resolution (default: 1920x1080)"
|
||||
echo " --edit Enable on-device edit feature"
|
||||
echo ""
|
||||
echo "Installation options:"
|
||||
echo " --offline Force offline installation from local packages"
|
||||
echo " -q, --quiet Silent mode (minimal output)"
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-U|--sudo-user)
|
||||
SUDO_USERNAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
-W|--sudo-password)
|
||||
SUDO_PASSWORD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--provision)
|
||||
PROVISION_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--config)
|
||||
CONFIG_FILE_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-s|--server)
|
||||
SERVER_IP="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--port)
|
||||
SERVER_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-n|--name)
|
||||
SCREEN_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
-k|--key)
|
||||
QUICKCONNECT_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--https)
|
||||
USE_HTTPS="true"
|
||||
shift
|
||||
;;
|
||||
--no-verify-ssl)
|
||||
VERIFY_SSL="false"
|
||||
shift
|
||||
;;
|
||||
-o|--orientation)
|
||||
ORIENTATION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--touch)
|
||||
TOUCH_ENABLED="True"
|
||||
shift
|
||||
;;
|
||||
-r|--resolution)
|
||||
MAX_RESOLUTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--edit)
|
||||
EDIT_ENABLED="true"
|
||||
shift
|
||||
;;
|
||||
--offline)
|
||||
OFFLINE_MODE=true
|
||||
shift
|
||||
;;
|
||||
-q|--quiet)
|
||||
exec > >(tee -a /tmp/kivy-install.log) 2>&1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Auto-detect offline mode if wheels exist
|
||||
if [ "$OFFLINE_MODE" = false ] && [ -d "$WHEELS_DIR" ] && [ "$(ls -A "$WHEELS_DIR"/*.whl 2>/dev/null)" ]; then
|
||||
OFFLINE_MODE=true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Installing Kivy Signage Player dependencies..."
|
||||
# ============================================================
|
||||
# Mode Banner
|
||||
# ============================================================
|
||||
echo "=========================================="
|
||||
echo "Kivy Signage Player v1.2.0 - Installer"
|
||||
echo "=========================================="
|
||||
if [ "$OFFLINE_MODE" = true ]; then
|
||||
echo "Mode: Offline Installation"
|
||||
else
|
||||
echo "Mode: Online Installation"
|
||||
fi
|
||||
if [ -n "$SUDO_USERNAME" ]; then
|
||||
echo "Sudo user: $SUDO_USERNAME"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Install system dependencies
|
||||
echo "Step 1: Installing system dependencies..."
|
||||
# ============================================================
|
||||
# Step 1: System Dependencies
|
||||
# ============================================================
|
||||
echo "Step 1/6: Installing system dependencies..."
|
||||
echo "--------------------"
|
||||
|
||||
if [ "$OFFLINE_MODE" = true ] && [ -d "$DEB_DIR" ] && [ "$(ls -A $DEB_DIR/*.deb 2>/dev/null)" ]; then
|
||||
# Offline: Install from local .deb files
|
||||
echo "Installing from offline .deb packages..."
|
||||
cd "$DEB_DIR"
|
||||
|
||||
# Install all .deb files with dependencies
|
||||
sudo dpkg -i *.deb 2>/dev/null || true
|
||||
|
||||
# Fix any broken dependencies
|
||||
sudo apt-get install -f -y || true
|
||||
|
||||
echo "System packages installed from offline repository"
|
||||
else
|
||||
# Online: Use apt-get
|
||||
echo "Updating package lists..."
|
||||
sudo apt update
|
||||
run_sudo apt update
|
||||
|
||||
echo "Installing system packages..."
|
||||
|
||||
# Read packages from file if available, otherwise use default list
|
||||
if [ -f "$SYSTEM_DIR/apt-packages.txt" ]; then
|
||||
PACKAGES=$(grep -v '^#' "$SYSTEM_DIR/apt-packages.txt" | grep -v '^$' | tr '\n' ' ')
|
||||
sudo apt install -y $PACKAGES
|
||||
else
|
||||
# Default package list
|
||||
sudo apt install -y python3-pip python3-setuptools python3-dev
|
||||
sudo apt install -y libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
|
||||
sudo apt install -y libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev
|
||||
sudo apt install -y zlib1g-dev ffmpeg libavcodec-extra
|
||||
sudo apt install -y gstreamer1.0-plugins-base gstreamer1.0-plugins-good
|
||||
if [ "$OFFLINE_MODE" = true ] && [ -d "$DEB_DIR" ] && [ "$(ls -A $DEB_DIR/*.deb 2>/dev/null)" ]; then
|
||||
echo "Installing from offline .deb packages..."
|
||||
cd "$DEB_DIR"
|
||||
run_sudo dpkg -i *.deb 2>/dev/null || true
|
||||
run_sudo apt-get install -f -y || true
|
||||
cd "$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
echo "System packages installed successfully"
|
||||
fi
|
||||
run_sudo apt install -y \
|
||||
python3-pip python3-setuptools python3-dev \
|
||||
python3-venv \
|
||||
libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev \
|
||||
libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev \
|
||||
zlib1g-dev ffmpeg libavcodec-extra \
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good
|
||||
|
||||
echo "System dependencies installed"
|
||||
echo ""
|
||||
|
||||
# Install Python dependencies
|
||||
echo "Step 2: Installing Python dependencies..."
|
||||
# ============================================================
|
||||
# Step 2: Python Virtual Environment
|
||||
# ============================================================
|
||||
echo "Step 2/6: Setting up Python virtual environment..."
|
||||
echo "--------------------"
|
||||
|
||||
if [ "$OFFLINE_MODE" = true ] && [ -d "$WHEELS_DIR" ] && [ "$(ls -A $WHEELS_DIR/*.whl 2>/dev/null)" ]; then
|
||||
# Offline: Install from local wheels
|
||||
echo "Installing from offline Python wheels..."
|
||||
echo "Wheel files found: $(ls -1 $WHEELS_DIR/*.whl 2>/dev/null | wc -l)"
|
||||
if [ -d "$VENV_DIR" ]; then
|
||||
echo "Removing old virtual environment..."
|
||||
rm -rf "$VENV_DIR"
|
||||
fi
|
||||
|
||||
if pip3 install --break-system-packages --no-index --find-links="$WHEELS_DIR" -r requirements.txt 2>&1 | tee /tmp/pip_install.log; then
|
||||
echo "Python packages installed from offline repository"
|
||||
echo "Creating virtual environment (with system site-packages)..."
|
||||
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||
echo "Virtual environment created at $VENV_DIR"
|
||||
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Upgrading pip..."
|
||||
pip install --upgrade pip --quiet
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 3: Install Python Packages
|
||||
# ============================================================
|
||||
echo "Step 3/6: Installing Python packages..."
|
||||
echo "--------------------"
|
||||
|
||||
PIP_EXTRA_ARGS=""
|
||||
if [ "$OFFLINE_MODE" = true ]; then
|
||||
echo "Using offline wheel repository: $WHEELS_DIR"
|
||||
PIP_EXTRA_ARGS="--no-index --find-links=$WHEELS_DIR"
|
||||
fi
|
||||
|
||||
if [ -f "$REQUIREMENTS_FILE" ]; then
|
||||
echo "Installing packages from requirements.txt..."
|
||||
if [ "$OFFLINE_MODE" = true ]; then
|
||||
if pip install $PIP_EXTRA_ARGS -r "$REQUIREMENTS_FILE"; then
|
||||
echo "All packages installed from offline repository"
|
||||
else
|
||||
echo "Warning: Offline installation failed (possibly due to Python version mismatch)"
|
||||
echo "Falling back to online installation..."
|
||||
pip3 install --break-system-packages -r requirements.txt
|
||||
echo "Python packages installed from PyPI"
|
||||
echo ""
|
||||
echo "Offline installation failed (some wheels may be missing or incompatible)"
|
||||
echo " Falling back to online installation (requires internet)..."
|
||||
echo ""
|
||||
pip install -r "$REQUIREMENTS_FILE" --find-links="$WHEELS_DIR" 2>&1 || true
|
||||
fi
|
||||
else
|
||||
# Online: Use pip from PyPI
|
||||
echo "Installing from PyPI..."
|
||||
pip3 install --break-system-packages -r requirements.txt
|
||||
pip install -r "$REQUIREMENTS_FILE" --find-links="$WHEELS_DIR" 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Python packages installed successfully"
|
||||
# Verify critical packages are importable
|
||||
echo ""
|
||||
echo "Verifying installed packages..."
|
||||
pip list --format=columns 2>/dev/null | grep -iE "kivy|ffpyplayer" || true
|
||||
|
||||
# If kivy is still missing after pip install, try system-wide fallback
|
||||
python3 -c "import kivy" 2>/dev/null && echo " kivy OK" || {
|
||||
echo ""
|
||||
echo " kivy not found in venv — installing system-wide via pip (break-system-packages)..."
|
||||
pip install kivy --break-system-packages 2>/dev/null || pip install --user kivy 2>/dev/null || true
|
||||
}
|
||||
python3 -c "import ffpyplayer" 2>/dev/null && echo " ffpyplayer OK" || {
|
||||
echo " ffpyplayer not found — installing system-wide..."
|
||||
pip install ffpyplayer --break-system-packages 2>/dev/null || pip install --user ffpyplayer 2>/dev/null || true
|
||||
}
|
||||
else
|
||||
echo "requirements.txt not found - skipping pip install"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Installation completed successfully!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "To run the signage player:"
|
||||
echo " cd src && python3 main.py"
|
||||
echo ""
|
||||
echo "Or use the run script:"
|
||||
echo " bash run_player.sh"
|
||||
|
||||
deactivate
|
||||
|
||||
# ============================================================
|
||||
# Step 4: Autostart Workflow
|
||||
# ============================================================
|
||||
echo "Step 4/6: Setting up autostart..."
|
||||
echo "--------------------"
|
||||
|
||||
ACTUAL_USER="${SUDO_USERNAME:-${SUDO_USER:-$(whoami)}}"
|
||||
ACTUAL_HOME=$(eval echo ~"$ACTUAL_USER")
|
||||
AUTOSTART_DIR="$ACTUAL_HOME/.config/autostart"
|
||||
|
||||
echo "Configuring autostart for user: $ACTUAL_USER"
|
||||
|
||||
mkdir -p "$AUTOSTART_DIR"
|
||||
cat > "$AUTOSTART_DIR/kivy-signage-player.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Kivy Signage Player
|
||||
Comment=Digital Signage Player
|
||||
Exec=/bin/bash -c "cd $SCRIPT_DIR && exec bash start.sh"
|
||||
Icon=media-video-display
|
||||
Categories=Utility;
|
||||
NoDisplay=false
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
Hidden=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
X-GNOME-Autostart-delay=3
|
||||
X-XFCE-Autostart-Override=true
|
||||
EOF
|
||||
|
||||
run_sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$AUTOSTART_DIR/kivy-signage-player.desktop" 2>/dev/null || true
|
||||
chmod 644 "$AUTOSTART_DIR/kivy-signage-player.desktop" 2>/dev/null || true
|
||||
echo "XDG autostart entry created"
|
||||
|
||||
# Cron fallback
|
||||
if command -v crontab &>/dev/null; then
|
||||
CRON_WRAPPER="$SCRIPT_DIR/.start-player-cron.sh"
|
||||
cat > "$CRON_WRAPPER" << EOF
|
||||
#\!/bin/bash
|
||||
sleep 15
|
||||
cd $SCRIPT_DIR && bash start.sh
|
||||
EOF
|
||||
chmod +x "$CRON_WRAPPER"
|
||||
|
||||
CRON_ENTRY="@reboot sleep 20 && $CRON_WRAPPER > /tmp/kivy-player-cron.log 2>&1"
|
||||
# Use sudo -u which works when running with sudo (no password prompt)
|
||||
if sudo -u "$ACTUAL_USER" crontab -l 2>/dev/null | grep -q "kivy-signage-player"; then
|
||||
true
|
||||
else
|
||||
(sudo -u "$ACTUAL_USER" crontab -l 2>/dev/null || true; echo "$CRON_ENTRY") | sudo -u "$ACTUAL_USER" crontab - 2>/dev/null || true
|
||||
fi
|
||||
echo "Cron fallback configured"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Check if config exists
|
||||
if [ ! -d "$SCRIPT_DIR/config" ] || [ ! "$(ls -A $SCRIPT_DIR/config)" ]; then
|
||||
echo "Note: No configuration found."
|
||||
echo "Please configure the player before running:"
|
||||
echo " 1. Copy config/app_config.txt.example to config/app_config.txt"
|
||||
echo " 2. Edit the configuration file with your server details"
|
||||
# ============================================================
|
||||
# Step 5: Raspberry Pi Optimization
|
||||
# ============================================================
|
||||
if detect_raspberry_pi; then
|
||||
echo "Step 5/6: Optimizing for Raspberry Pi..."
|
||||
echo "--------------------"
|
||||
|
||||
BOOT_CONFIG=$(find_boot_config)
|
||||
if [ -n "$BOOT_CONFIG" ]; then
|
||||
echo "Configuring HDMI settings..."
|
||||
for setting in "hdmi_blanking=0" "hdmi_ignore_cec_init=1" "hdmi_force_hotplug=1"; do
|
||||
key="${setting%%=*}"
|
||||
if grep -q "^$key" "$BOOT_CONFIG" 2>/dev/null; then
|
||||
run_sudo sed -i "s/^$key=.*/$setting/" "$BOOT_CONFIG"
|
||||
else
|
||||
echo "$setting" | run_sudo tee -a "$BOOT_CONFIG" > /dev/null
|
||||
fi
|
||||
done
|
||||
echo "HDMI settings configured"
|
||||
fi
|
||||
|
||||
echo "Setting CPU to performance mode..."
|
||||
for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
|
||||
if [ -f "$cpu/cpufreq/scaling_governor" ]; then
|
||||
echo performance | run_sudo tee "$cpu/cpufreq/scaling_governor" > /dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
run_sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null || true
|
||||
echo "System sleep disabled"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/.video-optimization.sh" ]; then
|
||||
bash "$SCRIPT_DIR/.video-optimization.sh"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Step 6: Finalize Player Setup
|
||||
# ============================================================
|
||||
echo "Step 6/6: Finalizing player setup..."
|
||||
echo "--------------------"
|
||||
|
||||
# Create required directories
|
||||
echo "Creating player directories..."
|
||||
mkdir -p "$SCRIPT_DIR/media"
|
||||
mkdir -p "$SCRIPT_DIR/playlists"
|
||||
mkdir -p "$SCRIPT_DIR/config/resources"
|
||||
mkdir -p "$SCRIPT_DIR/logs"
|
||||
touch "$SCRIPT_DIR/logs/.gitkeep" 2>/dev/null || true
|
||||
echo "Player directories created"
|
||||
|
||||
# Write player configuration
|
||||
write_player_config() {
|
||||
local config_dir="$SCRIPT_DIR/config"
|
||||
local config_file="$config_dir/app_config.json"
|
||||
mkdir -p "$config_dir"
|
||||
|
||||
if [ -n "$PROVISION_URL" ]; then
|
||||
echo "Fetching provisioning data from: $PROVISION_URL"
|
||||
local response
|
||||
response=$(curl -sSf "$PROVISION_URL" 2>&1 || true)
|
||||
if [ -n "$response" ]; then
|
||||
echo "$response" > "$config_file"
|
||||
echo "Provisioning data saved"
|
||||
return 0
|
||||
fi
|
||||
echo "Failed to fetch provisioning data"
|
||||
fi
|
||||
|
||||
if [ -n "$CONFIG_FILE_PATH" ] && [ -f "$CONFIG_FILE_PATH" ]; then
|
||||
cp "$CONFIG_FILE_PATH" "$config_file"
|
||||
echo "Configuration copied from $CONFIG_FILE_PATH"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -n "$SERVER_IP" ] || [ -n "$SCREEN_NAME" ] || [ -n "$QUICKCONNECT_KEY" ]; then
|
||||
export SERVER_IP SERVER_PORT SCREEN_NAME QUICKCONNECT_KEY
|
||||
export ORIENTATION TOUCH_ENABLED MAX_RESOLUTION
|
||||
export USE_HTTPS VERIFY_SSL EDIT_ENABLED
|
||||
|
||||
python3 << PYEOF
|
||||
import json, os
|
||||
|
||||
config_file = "$config_file"
|
||||
config = {}
|
||||
if os.path.exists(config_file):
|
||||
with open(config_file) as f:
|
||||
config = json.load(f)
|
||||
|
||||
cli_params = {
|
||||
'server_ip': os.environ.get('SERVER_IP', ''),
|
||||
'port': os.environ.get('SERVER_PORT', ''),
|
||||
'screen_name': os.environ.get('SCREEN_NAME', ''),
|
||||
'quickconnect_key': os.environ.get('QUICKCONNECT_KEY', ''),
|
||||
'orientation': os.environ.get('ORIENTATION', ''),
|
||||
'touch': os.environ.get('TOUCH_ENABLED', ''),
|
||||
'max_resolution': os.environ.get('MAX_RESOLUTION', ''),
|
||||
}
|
||||
|
||||
for key, val in cli_params.items():
|
||||
if val:
|
||||
config[key] = val
|
||||
|
||||
https_val = os.environ.get('USE_HTTPS', '')
|
||||
verify_val = os.environ.get('VERIFY_SSL', '')
|
||||
edit_val = os.environ.get('EDIT_ENABLED', '')
|
||||
|
||||
if https_val:
|
||||
config['use_https'] = https_val.lower() == 'true'
|
||||
if verify_val:
|
||||
config['verify_ssl'] = verify_val.lower() == 'false'
|
||||
if edit_val:
|
||||
config['edit_feature_enabled'] = edit_val.lower() == 'true'
|
||||
|
||||
config.setdefault('port', '8080')
|
||||
config.setdefault('orientation', 'Landscape')
|
||||
config.setdefault('touch', 'True')
|
||||
config.setdefault('max_resolution', '1920x1080')
|
||||
config.setdefault('edit_feature_enabled', False)
|
||||
config.setdefault('use_https', False)
|
||||
config.setdefault('verify_ssl', True)
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print("Configuration written")
|
||||
PYEOF
|
||||
elif [ \! -f "$config_file" ]; then
|
||||
cat > "$config_file" << 'EOF'
|
||||
{
|
||||
"server_ip": "",
|
||||
"port": "8080",
|
||||
"screen_name": "",
|
||||
"quickconnect_key": "",
|
||||
"orientation": "Landscape",
|
||||
"touch": "True",
|
||||
"max_resolution": "1920x1080",
|
||||
"edit_feature_enabled": false,
|
||||
"use_https": false,
|
||||
"verify_ssl": true
|
||||
}
|
||||
EOF
|
||||
echo "Default config template created"
|
||||
fi
|
||||
}
|
||||
write_player_config
|
||||
|
||||
# Initialize authentication
|
||||
config_file="$SCRIPT_DIR/config/app_config.json"
|
||||
if [ -f "$config_file" ]; then
|
||||
if python3 -c "
|
||||
import json
|
||||
c = json.load(open('$config_file'))
|
||||
if c.get('server_ip') and c.get('screen_name') and c.get('quickconnect_key'):
|
||||
exit(0)
|
||||
else:
|
||||
exit(1)
|
||||
" 2>/dev/null; then
|
||||
echo "Attempting player registration..."
|
||||
cd "$SCRIPT_DIR/src"
|
||||
source "$VENV_DIR/bin/activate"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '.')
|
||||
from get_playlists_v2 import ensure_authenticated
|
||||
|
||||
with open('$config_file') as f:
|
||||
config = json.load(f)
|
||||
|
||||
auth = ensure_authenticated(config)
|
||||
if auth:
|
||||
print('Player registered successfully')
|
||||
sys.exit(0)
|
||||
else:
|
||||
print('Registration deferred')
|
||||
sys.exit(1)
|
||||
" 2>&1 || echo "Auth initialization deferred"
|
||||
deactivate 2>/dev/null || true
|
||||
cd "$SCRIPT_DIR"
|
||||
else
|
||||
echo "Incomplete config - auth will happen on first run"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create background runner
|
||||
NOHUP_WRAPPER="$SCRIPT_DIR/.run-background.sh"
|
||||
cat > "$NOHUP_WRAPPER" << EOF
|
||||
#\!/bin/bash
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_FILE="$SCRIPT_DIR/logs/player-background.log"
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting player in background..." >> "$LOG_FILE"
|
||||
cd "$SCRIPT_DIR" || exit 1
|
||||
nohup bash start.sh >> "$LOG_FILE" 2>&1 &
|
||||
PLAYER_PID=\$\!
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Player started with PID: \$PLAYER_PID" >> "$LOG_FILE"
|
||||
echo "Player started in background (PID: \$PLAYER_PID)"
|
||||
echo "Logs: \$LOG_FILE"
|
||||
EOF
|
||||
chmod +x "$NOHUP_WRAPPER"
|
||||
echo "Background runner created"
|
||||
|
||||
# ============================================================
|
||||
# Final Summary
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Installation Complete\!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Installation: $SCRIPT_DIR"
|
||||
echo "Virtual Env: $VENV_DIR"
|
||||
echo ""
|
||||
|
||||
if [ -f "$SCRIPT_DIR/config/app_config.json" ]; then
|
||||
echo "Player Configuration:"
|
||||
echo "------------------------"
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$SCRIPT_DIR/config/app_config.json') as f:
|
||||
c = json.load(f)
|
||||
print(f' Server: {c.get(\"server_ip\",\"not set\")}')
|
||||
print(f' Port: {c.get(\"port\",\"8080\")}')
|
||||
print(f' Screen: {c.get(\"screen_name\",\"not set\")}')
|
||||
print(f' HTTPS: {c.get(\"use_https\",False)}')
|
||||
print(f' Resolution: {c.get(\"max_resolution\",\"auto\")}')
|
||||
" 2>/dev/null || echo " (config present)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Quick Start Commands:"
|
||||
echo "------------------------"
|
||||
echo " bash start.sh Start with watchdog (auto-restart)"
|
||||
echo " bash run_player.sh Run once (no auto-restart)"
|
||||
echo " bash .run-background.sh Run in background"
|
||||
echo " bash check_player_status.sh Check player status"
|
||||
echo " bash stop_player.sh Stop the player"
|
||||
echo ""
|
||||
echo "Autostart enabled for user: $ACTUAL_USER"
|
||||
echo ""
|
||||
echo "Logs: $SCRIPT_DIR/logs/"
|
||||
echo ""
|
||||
|
||||
if [ -f "$PI_BOOT_PATCH_SCRIPT" ]; then
|
||||
echo "Applying one-time Raspberry Pi boot config patch..."
|
||||
run_sudo chmod +x "$PI_BOOT_PATCH_SCRIPT" 2>/dev/null || true
|
||||
if run_sudo bash "$PI_BOOT_PATCH_SCRIPT"; then
|
||||
echo "One-time boot patch applied (or already present)"
|
||||
else
|
||||
echo "Boot patch skipped or failed; continuing installation"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -z "$SUDO_PASSWORD" ]; then
|
||||
echo "=========================================="
|
||||
echo "System Reboot Recommended"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Some changes (HDMI settings, GPU memory) require a reboot."
|
||||
echo ""
|
||||
echo "Rebooting in 5 seconds... Press Ctrl+C to cancel."
|
||||
sleep 5
|
||||
echo "Rebooting now..."
|
||||
run_sudo reboot
|
||||
fi
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"count": 4,
|
||||
"player_id": 1,
|
||||
"player_name": "Test_player1",
|
||||
"playlist": [
|
||||
{
|
||||
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||
"type": "image",
|
||||
"url": "media/sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||
"duration": 14,
|
||||
"edit_on_player": true
|
||||
},
|
||||
{
|
||||
"file_name": "weblink-1e4a4d6d885a",
|
||||
"type": "weblink",
|
||||
"url": "https://moto-adv.com/",
|
||||
"duration": 30,
|
||||
"edit_on_player": false
|
||||
},
|
||||
{
|
||||
"file_name": "sample-30s.mp4",
|
||||
"type": "video",
|
||||
"url": "media/sample-30s.mp4",
|
||||
"duration": 31,
|
||||
"edit_on_player": false
|
||||
},
|
||||
{
|
||||
"file_name": "edited_media/5/eye_e_v2.jpg",
|
||||
"type": "image",
|
||||
"url": "media/edited_media/5/eye_e_v2.jpg",
|
||||
"duration": 50,
|
||||
"edit_on_player": true
|
||||
}
|
||||
],
|
||||
"playlist_id": 1,
|
||||
"playlist_version": 14
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Python 3.12.9
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
-4
@@ -1,7 +1,6 @@
|
||||
kivy>=2.3.0
|
||||
ffpyplayer
|
||||
requests==2.32.4
|
||||
bcrypt==4.2.1
|
||||
aiohttp==3.9.1
|
||||
asyncio==3.4.3
|
||||
requests>=2.32.0,<3.0.0
|
||||
bcrypt>=4.2.0,<5.0.0
|
||||
aiohttp>=3.9.0,<4.0.0
|
||||
evdev>=1.6.0
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start Kivy Signage Player
|
||||
cd "$(dirname "$0")" && source .venv/bin/activate
|
||||
cd "$(dirname "$0")/src"
|
||||
python3 main.py
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MARKER_FILE="/etc/kiwy-signage/pi_boot_config_patched.marker"
|
||||
|
||||
is_pi() {
|
||||
if [[ -f /proc/device-tree/model ]] && grep -qi "raspberry pi" /proc/device-tree/model; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
find_boot_config() {
|
||||
if [[ -f /boot/firmware/config.txt ]]; then
|
||||
echo "/boot/firmware/config.txt"
|
||||
elif [[ -f /boot/config.txt ]]; then
|
||||
echo "/boot/config.txt"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
upsert_key() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local file="$3"
|
||||
|
||||
if grep -Eq "^[#[:space:]]*${key}=" "$file"; then
|
||||
sed -i -E "s|^[#[:space:]]*${key}=.*|${key}=${value}|" "$file"
|
||||
else
|
||||
echo "${key}=${value}" >> "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "[pi-boot-patch] Please run as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! is_pi; then
|
||||
echo "[pi-boot-patch] Not a Raspberry Pi. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -f "$MARKER_FILE" ]]; then
|
||||
echo "[pi-boot-patch] Patch already applied. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BOOT_CONFIG="$(find_boot_config)"
|
||||
if [[ -z "$BOOT_CONFIG" ]]; then
|
||||
echo "[pi-boot-patch] Boot config not found. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[pi-boot-patch] Applying patch to ${BOOT_CONFIG}"
|
||||
|
||||
# Graphics and memory settings for smoother Chromium playback on Pi.
|
||||
upsert_key "dtoverlay" "vc4-kms-v3d" "$BOOT_CONFIG"
|
||||
upsert_key "gpu_mem" "256" "$BOOT_CONFIG"
|
||||
upsert_key "disable_overscan" "1" "$BOOT_CONFIG"
|
||||
|
||||
# Keep HDMI and display active for kiosk use.
|
||||
upsert_key "hdmi_blanking" "0" "$BOOT_CONFIG"
|
||||
upsert_key "hdmi_ignore_cec_init" "1" "$BOOT_CONFIG"
|
||||
upsert_key "hdmi_force_hotplug" "1" "$BOOT_CONFIG"
|
||||
|
||||
mkdir -p "$(dirname "$MARKER_FILE")"
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$MARKER_FILE"
|
||||
|
||||
echo "[pi-boot-patch] Patch complete. Marker written to ${MARKER_FILE}"
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Setup script to allow passwordless sudo for WiFi control commands
|
||||
|
||||
echo "Setting up passwordless sudo for WiFi control..."
|
||||
echo ""
|
||||
|
||||
# Create sudoers file for WiFi commands
|
||||
SUDOERS_FILE="/etc/sudoers.d/kiwy-signage-wifi"
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "This script must be run as root (use sudo)"
|
||||
echo "Usage: sudo bash setup_wifi_control.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the username who invoked sudo
|
||||
ACTUAL_USER="${SUDO_USER:-$USER}"
|
||||
|
||||
echo "Configuring passwordless sudo for user: $ACTUAL_USER"
|
||||
echo ""
|
||||
|
||||
# Create sudoers entry
|
||||
cat > "$SUDOERS_FILE" << EOF
|
||||
# Allow $ACTUAL_USER to control WiFi without password for Kiwy Signage Player
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill block wifi
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill unblock wifi
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 down
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 up
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/dhclient wlan0
|
||||
EOF
|
||||
|
||||
# Set correct permissions
|
||||
chmod 0440 "$SUDOERS_FILE"
|
||||
|
||||
echo "✓ Created sudoers file: $SUDOERS_FILE"
|
||||
echo ""
|
||||
|
||||
# Validate the sudoers file
|
||||
if visudo -c -f "$SUDOERS_FILE"; then
|
||||
echo "✓ Sudoers file validated successfully"
|
||||
echo ""
|
||||
echo "Setup complete! User '$ACTUAL_USER' can now control WiFi without password."
|
||||
echo ""
|
||||
echo "Test with:"
|
||||
echo " sudo rfkill block wifi"
|
||||
echo " sudo rfkill unblock wifi"
|
||||
else
|
||||
echo "✗ Error: Sudoers file validation failed"
|
||||
echo "Removing invalid file..."
|
||||
rm -f "$SUDOERS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,100 +0,0 @@
|
||||
Kiwy drawing
|
||||
|
||||
from kivy.app import App
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.image import Image
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.widget import Widget
|
||||
from kivy.graphics import Color, Line
|
||||
from kivy.core.window import Window
|
||||
|
||||
class DrawLayer(Widget):
|
||||
def init(self, **kwargs):
|
||||
super().init(**kwargs)
|
||||
self.strokes = [] # store all drawn lines
|
||||
self.current_color = (1, 0, 0) # default red
|
||||
self.current_width = 2 # default thickness
|
||||
self.drawing_enabled = False # drawing toggle
|
||||
|
||||
|
||||
def on_touch_down(self, touch):
|
||||
if not self.drawing_enabled:
|
||||
return False
|
||||
|
||||
with self.canvas:
|
||||
Color(*self.current_color)
|
||||
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
|
||||
self.strokes.append(new_line)
|
||||
return True
|
||||
|
||||
def on_touch_move(self, touch):
|
||||
if self.strokes and self.drawing_enabled:
|
||||
self.strokes[-1].points += [touch.x, touch.y]
|
||||
|
||||
# ==========================
|
||||
# UNDO LAST LINE
|
||||
# ==========================
|
||||
def undo(self):
|
||||
if self.strokes:
|
||||
last = self.strokes.pop()
|
||||
self.canvas.remove(last)
|
||||
|
||||
# ==========================
|
||||
# CHANGE COLOR
|
||||
# ==========================
|
||||
def set_color(self, color_tuple):
|
||||
self.current_color = color_tuple
|
||||
|
||||
# ==========================
|
||||
# CHANGE LINE WIDTH
|
||||
# ==========================
|
||||
def set_thickness(self, value):
|
||||
self.current_width = value
|
||||
class EditorUI(BoxLayout):
|
||||
def init(self, **kwargs):
|
||||
super().init(orientation="vertical", **kwargs)
|
||||
|
||||
|
||||
# Background image
|
||||
self.img = Image(source="graph.png", allow_stretch=True)
|
||||
self.add_widget(self.img)
|
||||
|
||||
# Drawing layer above image
|
||||
self.draw = DrawLayer()
|
||||
self.add_widget(self.draw)
|
||||
|
||||
# Toolbar
|
||||
toolbar = BoxLayout(size_hint_y=0.15)
|
||||
|
||||
toolbar.add_widget(Button(text="Draw On", on_press=self.toggle_draw))
|
||||
toolbar.add_widget(Button(text="Red", on_press=lambda x: self.draw.set_color((1,0,0))))
|
||||
toolbar.add_widget(Button(text="Blue", on_press=lambda x: self.draw.set_color((0,0,1))))
|
||||
toolbar.add_widget(Button(text="Green", on_press=lambda x: self.draw.set_color((0,1,0))))
|
||||
|
||||
toolbar.add_widget(Button(text="Thin", on_press=lambda x: self.draw.set_thickness(2)))
|
||||
toolbar.add_widget(Button(text="Thick", on_press=lambda x: self.draw.set_thickness(6)))
|
||||
|
||||
toolbar.add_widget(Button(text="Undo", on_press=lambda x: self.draw.undo()))
|
||||
toolbar.add_widget(Button(text="Save", on_press=lambda x: self.save_image()))
|
||||
|
||||
self.add_widget(toolbar)
|
||||
|
||||
# ==========================
|
||||
# TOGGLE DRAWING MODE
|
||||
# ==========================
|
||||
def toggle_draw(self, btn):
|
||||
self.draw.drawing_enabled = not self.draw.drawing_enabled
|
||||
btn.text = "Draw Off" if self.draw.drawing_enabled else "Draw On"
|
||||
|
||||
# ==========================
|
||||
# SAVE MERGED IMAGE
|
||||
# ==========================
|
||||
def save_image(self):
|
||||
self.export_to_png("edited_graph.png")
|
||||
print("Saved as edited_graph.png")
|
||||
class AnnotatorApp(App):
|
||||
def build(self):
|
||||
return EditorUI()
|
||||
|
||||
AnnotatorApp().run()
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""
|
||||
Edit Popup Module
|
||||
Handles image editing/annotation functionality for the signage player
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from kivy.uix.widget import Widget
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.label import Label
|
||||
from kivy.graphics import Color, Line, RoundedRectangle
|
||||
from kivy.clock import Clock
|
||||
from kivy.core.window import Window
|
||||
from kivy.logger import Logger
|
||||
from kivy.uix.video import Video
|
||||
|
||||
|
||||
class DrawingLayer(Widget):
|
||||
"""Layer for drawing on top of images"""
|
||||
def __init__(self, reset_callback=None, **kwargs):
|
||||
super(DrawingLayer, self).__init__(**kwargs)
|
||||
self.strokes = [] # Store all drawn lines
|
||||
self.current_color = (1, 0, 0, 1) # Default red
|
||||
self.current_width = 3 # Default thickness
|
||||
self.drawing_enabled = True # Drawing always enabled in edit mode
|
||||
self.reset_callback = reset_callback # Callback to reset countdown timer
|
||||
self._last_draw_time = 0 # For throttling touch updates
|
||||
self._draw_throttle_interval = 0.016 # ~60fps (16ms between updates)
|
||||
|
||||
def on_touch_down(self, touch):
|
||||
if not self.drawing_enabled or not self.collide_point(*touch.pos):
|
||||
return False
|
||||
|
||||
# Reset countdown on user interaction
|
||||
if self.reset_callback:
|
||||
self.reset_callback()
|
||||
|
||||
with self.canvas:
|
||||
Color(*self.current_color)
|
||||
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
|
||||
self.strokes.append({'line': new_line, 'color': self.current_color, 'width': self.current_width})
|
||||
touch.ud['line'] = new_line
|
||||
return True
|
||||
|
||||
def on_touch_move(self, touch):
|
||||
if 'line' in touch.ud and self.drawing_enabled:
|
||||
# Throttle updates to ~60fps for better performance
|
||||
current_time = time.time()
|
||||
if current_time - self._last_draw_time >= self._draw_throttle_interval:
|
||||
touch.ud['line'].points += [touch.x, touch.y]
|
||||
self._last_draw_time = current_time
|
||||
return True
|
||||
|
||||
def undo(self):
|
||||
"""Remove the last stroke"""
|
||||
if self.strokes:
|
||||
last_stroke = self.strokes.pop()
|
||||
self.canvas.remove(last_stroke['line'])
|
||||
Logger.info("DrawingLayer: Undid last stroke")
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all strokes"""
|
||||
for stroke in self.strokes:
|
||||
self.canvas.remove(stroke['line'])
|
||||
self.strokes = []
|
||||
Logger.info("DrawingLayer: Cleared all strokes")
|
||||
|
||||
def set_color(self, color_tuple):
|
||||
"""Set drawing color (RGBA)"""
|
||||
self.current_color = color_tuple
|
||||
|
||||
def set_thickness(self, value):
|
||||
"""Set line thickness"""
|
||||
self.current_width = value
|
||||
|
||||
|
||||
class EditPopup(Popup):
|
||||
"""Popup for editing/annotating images"""
|
||||
def __init__(self, player_instance, image_path, user_card_data=None, **kwargs):
|
||||
super(EditPopup, self).__init__(**kwargs)
|
||||
self.player = player_instance
|
||||
self.image_path = image_path
|
||||
self.user_card_data = user_card_data # Store card data to send to server on save
|
||||
|
||||
# Auto-close timer (5 minutes)
|
||||
self.auto_close_timeout = 300 # 5 minutes in seconds
|
||||
self.remaining_time = self.auto_close_timeout
|
||||
self.countdown_event = None
|
||||
self.auto_close_event = None
|
||||
|
||||
# Pause playback (without auto-resume timer)
|
||||
self.was_paused = self.player.is_paused
|
||||
if not self.was_paused:
|
||||
self.player.is_paused = True
|
||||
Clock.unschedule(self.player.next_media)
|
||||
|
||||
# Cancel auto-resume timer if one exists (don't want auto-resume during editing)
|
||||
if self.player.auto_resume_event:
|
||||
Clock.unschedule(self.player.auto_resume_event)
|
||||
self.player.auto_resume_event = None
|
||||
Logger.info("EditPopup: Cancelled auto-resume timer")
|
||||
|
||||
# Update button icon to play (to show it's paused)
|
||||
self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/play.png'
|
||||
self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/play.png'
|
||||
|
||||
if self.player.current_widget and isinstance(self.player.current_widget, Video):
|
||||
self.player.current_widget.state = 'pause'
|
||||
|
||||
Logger.info("EditPopup: ⏸ Paused playback (no auto-resume) for editing")
|
||||
|
||||
# Show cursor
|
||||
try:
|
||||
Window.show_cursor = True
|
||||
except:
|
||||
pass
|
||||
|
||||
# Note: UI is now defined in KV file, but we need to customize after creation
|
||||
# Set image source after KV loads
|
||||
Clock.schedule_once(lambda dt: self._setup_after_kv(), 0)
|
||||
|
||||
def _setup_after_kv(self):
|
||||
"""Setup widgets after KV file has loaded them"""
|
||||
# Set the image source
|
||||
self.ids.image_widget.source = self.image_path
|
||||
|
||||
# Create and insert drawing layer (custom class, must be added programmatically)
|
||||
self.drawing_layer = DrawingLayer(
|
||||
reset_callback=self.reset_countdown,
|
||||
size_hint=(1, 1),
|
||||
pos_hint={'x': 0, 'y': 0}
|
||||
)
|
||||
# Replace placeholder with actual drawing layer
|
||||
content = self.content
|
||||
placeholder_index = content.children.index(self.ids.drawing_layer_placeholder)
|
||||
content.remove_widget(self.ids.drawing_layer_placeholder)
|
||||
content.add_widget(self.drawing_layer, index=placeholder_index)
|
||||
|
||||
# Set icon sources
|
||||
pen_icon_path = os.path.join(self.player.resources_path, 'edit-pen.png')
|
||||
self.ids.color_icon.source = pen_icon_path
|
||||
self.ids.thickness_icon.source = pen_icon_path
|
||||
|
||||
# Bind button callbacks
|
||||
self.ids.undo_btn.bind(on_press=lambda x: (self.reset_countdown(), self.drawing_layer.undo()))
|
||||
self.ids.clear_btn.bind(on_press=lambda x: (self.reset_countdown(), self.drawing_layer.clear_all()))
|
||||
self.ids.save_btn.bind(on_press=self.save_image)
|
||||
self.ids.cancel_btn.bind(on_press=self.close_without_saving)
|
||||
|
||||
# Bind color buttons
|
||||
self.ids.red_btn.bind(on_press=lambda x: self.drawing_layer.set_color((1, 0, 0, 1)))
|
||||
self.ids.blue_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 0, 1, 1)))
|
||||
self.ids.green_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 1, 0, 1)))
|
||||
self.ids.black_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 0, 0, 1)))
|
||||
self.ids.white_btn.bind(on_press=lambda x: self.drawing_layer.set_color((1, 1, 1, 1)))
|
||||
|
||||
# Bind thickness buttons
|
||||
self.ids.small_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(2))
|
||||
self.ids.medium_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(5))
|
||||
self.ids.large_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(10))
|
||||
|
||||
# Add rounded corners to buttons
|
||||
for btn_id in ['undo_btn', 'clear_btn', 'save_btn', 'cancel_btn']:
|
||||
btn = self.ids[btn_id]
|
||||
btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
|
||||
|
||||
# Add circular corners to color/thickness buttons
|
||||
for btn_id in ['red_btn', 'blue_btn', 'green_btn', 'black_btn', 'white_btn',
|
||||
'small_btn', 'medium_btn', 'large_btn']:
|
||||
btn = self.ids[btn_id]
|
||||
btn.bind(pos=self._make_round, size=self._make_round)
|
||||
|
||||
# Reference to countdown label
|
||||
self.countdown_label = self.ids.countdown_label
|
||||
|
||||
# Bind to dismiss
|
||||
self.bind(on_dismiss=self.on_popup_dismiss)
|
||||
|
||||
# Start countdown timer (updates every second)
|
||||
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
|
||||
|
||||
# Start auto-close timer (closes after 5 minutes)
|
||||
self.auto_close_event = Clock.schedule_once(self.auto_close, self.auto_close_timeout)
|
||||
|
||||
Logger.info(f"EditPopup: Opened for image {os.path.basename(self.image_path)} (auto-close in 5 minutes)")
|
||||
|
||||
def update_countdown(self, dt):
|
||||
"""Update countdown display"""
|
||||
self.remaining_time -= 1
|
||||
|
||||
# Format time as MM:SS
|
||||
minutes = self.remaining_time // 60
|
||||
seconds = self.remaining_time % 60
|
||||
self.countdown_label.text = f"{minutes}:{seconds:02d}"
|
||||
|
||||
# Change color as time runs out
|
||||
if self.remaining_time <= 60: # Last minute - red
|
||||
self.countdown_label.color = (1, 0.2, 0.2, 1)
|
||||
elif self.remaining_time <= 120: # Last 2 minutes - yellow
|
||||
self.countdown_label.color = (1, 1, 0, 1)
|
||||
else:
|
||||
self.countdown_label.color = (1, 1, 1, 1) # White
|
||||
|
||||
if self.remaining_time <= 0:
|
||||
Clock.unschedule(self.countdown_event)
|
||||
|
||||
def reset_countdown(self):
|
||||
"""Reset countdown timer on user interaction"""
|
||||
self.remaining_time = self.auto_close_timeout
|
||||
|
||||
# Cancel existing timers
|
||||
if self.countdown_event:
|
||||
Clock.unschedule(self.countdown_event)
|
||||
if self.auto_close_event:
|
||||
Clock.unschedule(self.auto_close_event)
|
||||
|
||||
# Restart timers
|
||||
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
|
||||
self.auto_close_event = Clock.schedule_once(self.auto_close, self.auto_close_timeout)
|
||||
|
||||
# Reset color to white
|
||||
self.countdown_label.color = (1, 1, 1, 1)
|
||||
|
||||
Logger.info("EditPopup: Countdown reset to 5:00")
|
||||
|
||||
def auto_close(self, dt):
|
||||
"""Auto-close the edit popup after timeout"""
|
||||
Logger.info("EditPopup: Auto-closing after 5 minutes of inactivity")
|
||||
self.close_without_saving(None)
|
||||
|
||||
def _make_rounded_btn(self, instance, value):
|
||||
"""Make toolbar button with slightly rounded corners"""
|
||||
instance.canvas.before.clear()
|
||||
with instance.canvas.before:
|
||||
Color(*instance.background_color)
|
||||
instance.round_rect = RoundedRectangle(
|
||||
pos=instance.pos,
|
||||
size=instance.size,
|
||||
radius=[10]
|
||||
)
|
||||
|
||||
def _make_round(self, instance, value):
|
||||
"""Make sidebar button fully circular"""
|
||||
instance.canvas.before.clear()
|
||||
with instance.canvas.before:
|
||||
Color(*instance.background_color)
|
||||
instance.round_rect = RoundedRectangle(
|
||||
pos=instance.pos,
|
||||
size=instance.size,
|
||||
radius=[instance.height / 2]
|
||||
)
|
||||
|
||||
def save_image(self, instance):
|
||||
"""Save the edited image"""
|
||||
try:
|
||||
# Create edited_media directory if it doesn't exist
|
||||
edited_dir = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
||||
os.makedirs(edited_dir, exist_ok=True)
|
||||
|
||||
# Get original filename
|
||||
base_name = os.path.basename(self.image_path)
|
||||
name, ext = os.path.splitext(base_name)
|
||||
|
||||
# Determine version number
|
||||
version_match = re.search(r'_e_v(\d+)$', name)
|
||||
if version_match:
|
||||
# Increment existing version
|
||||
current_version = int(version_match.group(1))
|
||||
new_version = current_version + 1
|
||||
# Remove old version suffix
|
||||
original_name = re.sub(r'_e_v\d+$', '', name)
|
||||
new_name = f"{original_name}_e_v{new_version}"
|
||||
else:
|
||||
# First edit version
|
||||
original_name = name
|
||||
new_name = f"{name}_e_v1"
|
||||
|
||||
# Generate output path
|
||||
output_filename = f"{new_name}.jpg"
|
||||
output_path = os.path.join(edited_dir, output_filename)
|
||||
|
||||
# Temporarily hide toolbars
|
||||
self.ids.top_toolbar.opacity = 0
|
||||
self.ids.right_sidebar.opacity = 0
|
||||
|
||||
# Force canvas update
|
||||
self.content.canvas.ask_update()
|
||||
|
||||
# Small delay to ensure rendering is complete
|
||||
def do_export(dt):
|
||||
try:
|
||||
# Export only the visible content (image + drawings, no toolbars)
|
||||
self.content.export_to_png(output_path)
|
||||
|
||||
Logger.info(f"EditPopup: Saved edited image to {output_path}")
|
||||
|
||||
# ALSO overwrite the original image with edited content
|
||||
Logger.info(f"EditPopup: Overwriting original image at {self.image_path}")
|
||||
|
||||
# Get original file info before overwrite
|
||||
orig_size = os.path.getsize(self.image_path)
|
||||
orig_mtime = os.path.getmtime(self.image_path)
|
||||
|
||||
# Overwrite the file
|
||||
shutil.copy2(output_path, self.image_path)
|
||||
|
||||
# Force file system sync to ensure data is written to disk
|
||||
os.sync()
|
||||
|
||||
# Verify the overwrite
|
||||
new_size = os.path.getsize(self.image_path)
|
||||
new_mtime = os.path.getmtime(self.image_path)
|
||||
|
||||
Logger.info(f"EditPopup: ✓ File overwritten:")
|
||||
Logger.info(f" - Size: {orig_size} -> {new_size} bytes (changed: {new_size != orig_size})")
|
||||
Logger.info(f" - Modified time: {orig_mtime} -> {new_mtime} (changed: {new_mtime > orig_mtime})")
|
||||
Logger.info(f"EditPopup: ✓ File synced to disk")
|
||||
|
||||
# Restore toolbars
|
||||
self.ids.top_toolbar.opacity = 1
|
||||
self.ids.right_sidebar.opacity = 1
|
||||
|
||||
# Create and save metadata
|
||||
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||
new_version if version_match else 1, output_filename)
|
||||
|
||||
# Upload to server in background (continues after popup closes)
|
||||
upload_thread = threading.Thread(
|
||||
target=self._upload_to_server,
|
||||
args=(output_path, json_filename),
|
||||
daemon=True
|
||||
)
|
||||
upload_thread.start() # Re-enabled
|
||||
|
||||
# NOW show saving popup AFTER everything is done
|
||||
def show_saving_and_dismiss(dt):
|
||||
# Create label with background showing detailed save status
|
||||
save_msg = (
|
||||
'Saved locally!\n'
|
||||
'Uploading to server...'
|
||||
)
|
||||
save_label = Label(
|
||||
text=save_msg,
|
||||
font_size='24sp',
|
||||
color=(1, 1, 1, 1),
|
||||
bold=True
|
||||
)
|
||||
|
||||
saving_popup = Popup(
|
||||
title='',
|
||||
content=save_label,
|
||||
size_hint=(0.85, 0.4),
|
||||
auto_dismiss=False,
|
||||
separator_height=0,
|
||||
background_color=(0.2, 0.7, 0.2, 0.95) # Green background
|
||||
)
|
||||
saving_popup.open()
|
||||
Logger.info("EditPopup: Saving confirmation popup opened")
|
||||
|
||||
# Update message after 3 seconds to show upload is happening
|
||||
def update_message(dt):
|
||||
if saving_popup:
|
||||
save_label.text = (
|
||||
'✓ Saved to device\n'
|
||||
'Upload in progress...'
|
||||
)
|
||||
|
||||
Clock.schedule_once(update_message, 3.0)
|
||||
|
||||
# Dismiss both popups after 4 seconds
|
||||
def dismiss_all(dt):
|
||||
saving_popup.dismiss()
|
||||
Logger.info(f"EditPopup: Dismissing to resume playback...")
|
||||
self.dismiss()
|
||||
|
||||
Clock.schedule_once(dismiss_all, 4.0)
|
||||
|
||||
# Small delay to ensure UI is ready, then show popup
|
||||
Clock.schedule_once(show_saving_and_dismiss, 0.1)
|
||||
|
||||
except Exception as e:
|
||||
Logger.error(f"EditPopup: Error in export: {e}")
|
||||
import traceback
|
||||
Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}")
|
||||
self.title = f'Error saving: {e}'
|
||||
# Restore toolbars
|
||||
self.ids.top_toolbar.opacity = 1
|
||||
self.ids.right_sidebar.opacity = 1
|
||||
# Still dismiss on error after brief delay
|
||||
Clock.schedule_once(lambda dt: self.dismiss(), 1)
|
||||
|
||||
Clock.schedule_once(do_export, 0.1)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
Logger.error(f"EditPopup: Error saving image: {e}")
|
||||
import traceback
|
||||
Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}")
|
||||
self.title = f'Error saving: {e}'
|
||||
|
||||
def _save_metadata(self, edited_dir, new_name, base_name, version, output_filename):
|
||||
"""Save metadata JSON file"""
|
||||
metadata = {
|
||||
'time_of_modification': datetime.now().isoformat(),
|
||||
'original_name': base_name,
|
||||
'new_name': output_filename,
|
||||
'original_path': self.image_path,
|
||||
'version': version,
|
||||
'user_card_data': self.user_card_data # Card data from reader (or None)
|
||||
}
|
||||
|
||||
# Save metadata JSON
|
||||
json_filename = f"{new_name}_metadata.json"
|
||||
json_path = os.path.join(edited_dir, json_filename)
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
Logger.info(f"EditPopup: Saved metadata to {json_path} (user_card_data: {self.user_card_data})")
|
||||
return json_path
|
||||
|
||||
def _upload_to_server(self, image_path, metadata_path):
|
||||
"""Upload edited image and metadata to server (runs in background thread)"""
|
||||
try:
|
||||
import requests
|
||||
from get_playlists_v2 import get_auth_instance
|
||||
|
||||
# Get authenticated instance
|
||||
auth = get_auth_instance()
|
||||
if not auth or not auth.is_authenticated():
|
||||
Logger.warning("EditPopup: Cannot upload - not authenticated (edited media saved locally only)")
|
||||
Logger.warning("EditPopup: Server will NOT receive this edit")
|
||||
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 - send to the original file endpoint
|
||||
upload_url = f"{server_url}/api/player-edit-media"
|
||||
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||
|
||||
# Add the original filename to metadata so server knows which file was edited
|
||||
metadata['original_filename'] = os.path.basename(metadata['original_path'])
|
||||
|
||||
# Disable SSL verification for self-signed certificates (like main code does)
|
||||
# Note: This is NOT recommended for production with untrusted servers
|
||||
Logger.warning("⚠️ SSL verification disabled for edited media upload - only use with trusted servers")
|
||||
|
||||
# Prepare file and data for upload
|
||||
with open(image_path, 'rb') as img_file:
|
||||
files = {
|
||||
'image_file': (metadata['original_filename'], img_file, 'image/jpeg')
|
||||
}
|
||||
|
||||
# Send metadata as JSON string in form data
|
||||
data = {
|
||||
'metadata': json.dumps(metadata),
|
||||
'original_file': metadata['original_filename']
|
||||
}
|
||||
|
||||
Logger.info(f"EditPopup: 📤 Uploading edited media to {upload_url}")
|
||||
Logger.info(f"EditPopup: - Original file: {metadata['original_filename']}")
|
||||
Logger.info(f"EditPopup: - Edited image: {image_path}")
|
||||
Logger.info(f"EditPopup: - Metadata: {metadata_path}")
|
||||
|
||||
try:
|
||||
response = requests.post(upload_url, headers=headers, files=files, data=data, timeout=30, verify=False)
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
Logger.info(f"EditPopup: ✅ Successfully uploaded edited media to server")
|
||||
Logger.info(f"EditPopup: Server response: {response_data}")
|
||||
|
||||
# DO NOT delete local files - keep them as backup
|
||||
# In case the server doesn't process them, we want to keep the edits locally
|
||||
Logger.info(f"EditPopup: ✓ Keeping local edited files as backup:")
|
||||
Logger.info(f" - Image: {image_path}")
|
||||
Logger.info(f" - Metadata: {metadata_path}")
|
||||
|
||||
# Trigger playlist reload if server provides new version
|
||||
try:
|
||||
new_version = response_data.get('new_playlist_version')
|
||||
if new_version:
|
||||
Logger.info(f"EditPopup: 📡 Server reports new playlist version: {new_version}")
|
||||
Logger.info(f"EditPopup: Triggering playlist reload on next cycle...")
|
||||
|
||||
# Playlist reload disabled for now - was causing crashes
|
||||
# Will be re-enabled with better implementation
|
||||
Logger.info(f"EditPopup: ✓ Edited media uploaded successfully")
|
||||
except Exception as e:
|
||||
Logger.warning(f"EditPopup: Could not process playlist version from server: {e}")
|
||||
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
Logger.error("EditPopup: ❌ Upload endpoint not found on server (404)")
|
||||
Logger.error("EditPopup: Server may not support edited media uploads")
|
||||
Logger.error("EditPopup: Edited media is saved locally only")
|
||||
return False
|
||||
elif response.status_code == 401:
|
||||
Logger.error("EditPopup: ❌ Authentication failed (401) - check auth credentials")
|
||||
Logger.error("EditPopup: Edited media is saved locally only")
|
||||
return False
|
||||
else:
|
||||
Logger.error(f"EditPopup: ❌ Upload failed with status {response.status_code}")
|
||||
Logger.error(f"EditPopup: Response: {response.text}")
|
||||
Logger.error("EditPopup: Edited media is saved locally only")
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
Logger.error("EditPopup: ❌ Upload timed out after 30 seconds")
|
||||
Logger.error("EditPopup: Check network connection")
|
||||
Logger.error("EditPopup: Edited media is saved locally only")
|
||||
return False
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
Logger.error(f"EditPopup: ❌ Cannot connect to server: {e}")
|
||||
Logger.error("EditPopup: Check server URL and network connection")
|
||||
Logger.error("EditPopup: Edited media is saved locally only")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
Logger.error(f"EditPopup: ❌ Unexpected error during upload: {e}")
|
||||
import traceback
|
||||
Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}")
|
||||
Logger.error("EditPopup: Edited media is 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 - reload current image and continue"""
|
||||
# Cancel countdown and auto-close timers
|
||||
if self.countdown_event:
|
||||
Clock.unschedule(self.countdown_event)
|
||||
if self.auto_close_event:
|
||||
Clock.unschedule(self.auto_close_event)
|
||||
|
||||
# Force remove current widget immediately
|
||||
if self.player.current_widget:
|
||||
Logger.info("EditPopup: Removing current widget to force reload")
|
||||
self.player.ids.content_area.remove_widget(self.player.current_widget)
|
||||
self.player.current_widget = None
|
||||
Logger.info("EditPopup: ✓ Widget removed, ready for fresh load")
|
||||
|
||||
# Resume playback if it wasn't paused before editing
|
||||
if not self.was_paused:
|
||||
self.player.is_paused = False
|
||||
|
||||
# Update button icon to pause (to show it's playing)
|
||||
self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/pause.png'
|
||||
self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/pause.png'
|
||||
|
||||
# Add delay to ensure file write is complete and synced
|
||||
def reload_media(dt):
|
||||
Logger.info("EditPopup: ▶ Resuming playback and reloading edited image (force_reload=True)")
|
||||
self.player.play_current_media(force_reload=True)
|
||||
|
||||
Clock.schedule_once(reload_media, 0.5)
|
||||
else:
|
||||
Logger.info("EditPopup: Dismissed, keeping paused state")
|
||||
|
||||
# Restart control hide timer
|
||||
self.player.schedule_hide_controls()
|
||||
+206
-64
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
Updated get_playlists.py for Kiwy-Signage with DigiServer v2 authentication
|
||||
Uses secure auth flow: hostname → password/quickconnect → auth_code → API calls
|
||||
Now with HTTPS support and SSL certificate management
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
from player_auth import PlayerAuth
|
||||
from ssl_utils import SSLManager
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -16,11 +18,17 @@ logger = logging.getLogger(__name__)
|
||||
_auth_instance = None
|
||||
|
||||
|
||||
def get_auth_instance(config_file='player_auth.json'):
|
||||
"""Get or create global auth instance."""
|
||||
def get_auth_instance(config_file='player_auth.json', use_https=True, verify_ssl=True):
|
||||
"""Get or create global auth instance.
|
||||
|
||||
Args:
|
||||
config_file: Authentication config file path
|
||||
use_https: Whether to use HTTPS
|
||||
verify_ssl: Whether to verify SSL certificates
|
||||
"""
|
||||
global _auth_instance
|
||||
if _auth_instance is None:
|
||||
_auth_instance = PlayerAuth(config_file)
|
||||
_auth_instance = PlayerAuth(config_file, use_https=use_https, verify_ssl=verify_ssl)
|
||||
return _auth_instance
|
||||
|
||||
|
||||
@@ -33,7 +41,10 @@ def ensure_authenticated(config):
|
||||
Returns:
|
||||
PlayerAuth instance if authenticated, None otherwise
|
||||
"""
|
||||
auth = get_auth_instance()
|
||||
auth = get_auth_instance(
|
||||
use_https=config.get('use_https', True),
|
||||
verify_ssl=config.get('verify_ssl', True)
|
||||
)
|
||||
|
||||
# If already authenticated and valid, return auth instance
|
||||
if auth.is_authenticated():
|
||||
@@ -49,6 +60,7 @@ def ensure_authenticated(config):
|
||||
hostname = config.get("screen_name", "")
|
||||
quickconnect_key = config.get("quickconnect_key", "")
|
||||
port = config.get("port", "")
|
||||
use_https = config.get("use_https", True)
|
||||
|
||||
if not all([server_ip, hostname, quickconnect_key]):
|
||||
logger.error("❌ Missing configuration: server_ip, screen_name, or quickconnect_key")
|
||||
@@ -58,12 +70,20 @@ def ensure_authenticated(config):
|
||||
import re
|
||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
||||
if re.match(ip_pattern, server_ip):
|
||||
server_url = f'http://{server_ip}:{port}'
|
||||
if use_https:
|
||||
# Use HTTPS for IP addresses
|
||||
server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}'
|
||||
else:
|
||||
server_url = f'http://{server_ip}:{port}' if port else f'http://{server_ip}'
|
||||
else:
|
||||
# For domain names, use HTTPS by default
|
||||
if use_https:
|
||||
server_url = f'https://{server_ip}'
|
||||
else:
|
||||
server_url = f'http://{server_ip}'
|
||||
|
||||
# Authenticate using quickconnect code
|
||||
logger.info(f"🔐 Authenticating player: {hostname}")
|
||||
logger.info(f"🔐 Authenticating player: {hostname} at {server_url}")
|
||||
success, error = auth.authenticate(
|
||||
server_url=server_url,
|
||||
hostname=hostname,
|
||||
@@ -188,10 +208,9 @@ def fetch_server_playlist(config):
|
||||
return {'playlist': [], 'version': 0}
|
||||
|
||||
|
||||
def save_playlist_with_version(playlist_data, playlist_dir):
|
||||
"""Save playlist to file with version number."""
|
||||
version = playlist_data.get('version', 0)
|
||||
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
|
||||
def save_playlist(playlist_data, playlist_dir):
|
||||
"""Save playlist to a single file (no versioning)."""
|
||||
playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(playlist_dir, exist_ok=True)
|
||||
@@ -203,17 +222,41 @@ def save_playlist_with_version(playlist_data, playlist_dir):
|
||||
return playlist_file
|
||||
|
||||
|
||||
def download_media_files(playlist, media_dir):
|
||||
"""Download media files from the server and save them to media_dir."""
|
||||
def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None):
|
||||
"""Download media files from the server and save them to media_dir.
|
||||
|
||||
Args:
|
||||
playlist: List of media items
|
||||
media_dir: Directory to save media files
|
||||
ssl_manager: Optional SSLManager for HTTPS downloads
|
||||
server_url: Server base URL for constructing full file URLs
|
||||
"""
|
||||
if not os.path.exists(media_dir):
|
||||
os.makedirs(media_dir)
|
||||
logger.info(f"📁 Created directory {media_dir} for media files")
|
||||
|
||||
# Use SSL manager if provided, otherwise use requests directly
|
||||
session = ssl_manager.get_session() if ssl_manager else requests.Session()
|
||||
|
||||
updated_playlist = []
|
||||
for media in playlist:
|
||||
file_name = media.get('file_name', '')
|
||||
file_url = media.get('url', '')
|
||||
duration = media.get('duration', 10)
|
||||
item_type = media.get('type', '')
|
||||
|
||||
# Web-link items have no file to download — pass the link through unchanged.
|
||||
if item_type == 'weblink':
|
||||
logger.info(f"🔗 Web link item (no download): {file_url}")
|
||||
updated_playlist.append({
|
||||
'file_name': file_name,
|
||||
'type': 'weblink',
|
||||
'url': file_url, # keep the original web address (not a local path)
|
||||
'duration': duration,
|
||||
'edit_on_player': False,
|
||||
})
|
||||
continue
|
||||
|
||||
local_path = os.path.join(media_dir, file_name)
|
||||
|
||||
logger.info(f"📥 Preparing to download {file_name}...")
|
||||
@@ -222,20 +265,63 @@ def download_media_files(playlist, media_dir):
|
||||
logger.info(f"✓ File {file_name} already exists. Skipping download.")
|
||||
else:
|
||||
try:
|
||||
response = requests.get(file_url, timeout=30)
|
||||
# Create parent directories if they don't exist (for nested paths like edited_media/5/)
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
|
||||
# Construct full URL
|
||||
download_url = file_url
|
||||
|
||||
# Handle localhost URLs - replace with actual server IP
|
||||
if 'localhost' in file_url or 'localhost' in (server_url or ''):
|
||||
if server_url:
|
||||
# Extract the path from localhost URL
|
||||
if 'localhost' in file_url:
|
||||
# URL like: https://localhost/static/uploads/file.jpg
|
||||
# Extract path: /static/uploads/file.jpg
|
||||
parts = file_url.split('localhost')
|
||||
if len(parts) > 1:
|
||||
path = parts[1]
|
||||
download_url = f"{server_url}{path}"
|
||||
logger.info(f"🔄 Replacing localhost with {server_url}")
|
||||
else:
|
||||
download_url = file_url
|
||||
else:
|
||||
download_url = file_url
|
||||
else:
|
||||
logger.warning(f"⚠️ localhost URL provided but no server_url available: {file_url}")
|
||||
download_url = file_url
|
||||
|
||||
# Construct full URL if relative path is provided
|
||||
elif not file_url.startswith('http'):
|
||||
if server_url:
|
||||
download_url = f"{server_url}/{file_url}".replace('//', '/')
|
||||
# Fix the protocol part that might have been double-slashed
|
||||
download_url = download_url.replace('https:/', 'https://').replace('http:/', 'http://')
|
||||
else:
|
||||
logger.warning(f"⚠️ Relative URL provided but no server_url available: {file_url}")
|
||||
download_url = file_url
|
||||
|
||||
logger.info(f"📥 Downloading from: {download_url}")
|
||||
response = session.get(download_url, timeout=30, verify=False)
|
||||
if response.status_code == 200:
|
||||
with open(local_path, 'wb') as file:
|
||||
file.write(response.content)
|
||||
logger.info(f"✅ Successfully downloaded {file_name}")
|
||||
logger.info(f"✅ Successfully downloaded {file_name} ({len(response.content)} bytes)")
|
||||
else:
|
||||
logger.error(f"❌ Failed to download {file_name}. Status: {response.status_code}")
|
||||
continue
|
||||
# Still add to playlist even if download failed - might be cached or available later
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.error(f"❌ SSL Error downloading {file_name}: {e}")
|
||||
# Don't skip - may still add to playlist
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"❌ Error downloading {file_name}: {e}")
|
||||
continue
|
||||
# Don't skip - may still add to playlist
|
||||
|
||||
# Always add the media item to the playlist, even if download failed
|
||||
# (it might already exist or be available later)
|
||||
updated_media = {
|
||||
'file_name': file_name,
|
||||
'type': item_type, # Preserve media type (image/video/...)
|
||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||
'duration': duration,
|
||||
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
|
||||
@@ -245,66 +331,104 @@ def download_media_files(playlist, media_dir):
|
||||
return updated_playlist
|
||||
|
||||
|
||||
def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, keep_versions=1):
|
||||
"""Delete old playlist files and media files not referenced by the latest playlist version."""
|
||||
try:
|
||||
# Find all playlist files
|
||||
playlist_files = [f for f in os.listdir(playlist_dir)
|
||||
if f.startswith('server_playlist_v') and f.endswith('.json')]
|
||||
|
||||
# Extract versions and sort
|
||||
versions = []
|
||||
for f in playlist_files:
|
||||
def delete_unused_media(playlist_data, media_dir):
|
||||
"""Delete media files not referenced in the current playlist."""
|
||||
try:
|
||||
version = int(f.replace('server_playlist_v', '').replace('.json', ''))
|
||||
versions.append((version, f))
|
||||
except ValueError:
|
||||
# Get list of media files referenced in current playlist
|
||||
referenced_files = set()
|
||||
for media in playlist_data.get('playlist', []):
|
||||
file_name = media.get('file_name', '')
|
||||
if file_name:
|
||||
referenced_files.add(file_name)
|
||||
|
||||
logger.info(f"📋 Current playlist references {len(referenced_files)} files")
|
||||
|
||||
if os.path.exists(media_dir):
|
||||
# Recursively get all media files
|
||||
deleted_count = 0
|
||||
for root, dirs, files in os.walk(media_dir):
|
||||
for media_file in files:
|
||||
# Get relative path from media_dir
|
||||
full_path = os.path.join(root, media_file)
|
||||
rel_path = os.path.relpath(full_path, media_dir)
|
||||
|
||||
# Skip if file is in current playlist
|
||||
# Normalize paths to handle Windows backslashes vs server forward slashes
|
||||
normalized_rel = rel_path.replace('\\', '/')
|
||||
if normalized_rel in referenced_files or rel_path in referenced_files:
|
||||
continue
|
||||
|
||||
versions.sort(reverse=True)
|
||||
# Delete unreferenced file
|
||||
try:
|
||||
os.remove(full_path)
|
||||
logger.info(f"🗑️ Deleted unused media: {rel_path}")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Could not delete {rel_path}: {e}")
|
||||
|
||||
# Keep only the latest N versions
|
||||
files_to_delete = [f for v, f in versions[keep_versions:]]
|
||||
# Clean up empty directories
|
||||
for root, dirs, files in os.walk(media_dir, topdown=False):
|
||||
for dir_name in dirs:
|
||||
dir_path = os.path.join(root, dir_name)
|
||||
try:
|
||||
if not os.listdir(dir_path): # If directory is empty
|
||||
os.rmdir(dir_path)
|
||||
logger.debug(f"🗑️ Removed empty directory: {os.path.relpath(dir_path, media_dir)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for f in files_to_delete:
|
||||
filepath = os.path.join(playlist_dir, f)
|
||||
os.remove(filepath)
|
||||
logger.info(f"🗑️ Deleted old playlist: {f}")
|
||||
|
||||
# TODO: Clean up unused media files
|
||||
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest versions)")
|
||||
if deleted_count > 0:
|
||||
logger.info(f"✅ Deleted {deleted_count} unused media files")
|
||||
else:
|
||||
logger.info("✅ No unused media files to delete")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error during cleanup: {e}")
|
||||
logger.error(f"❌ Error during media cleanup: {e}")
|
||||
|
||||
|
||||
|
||||
|
||||
def update_playlist_if_needed(config, playlist_dir, media_dir):
|
||||
"""Check for and download updated playlist if available."""
|
||||
try:
|
||||
# Fetch latest playlist from server
|
||||
server_data = fetch_server_playlist(config)
|
||||
server_version = server_data.get('version', 0)
|
||||
"""Check for and download updated playlist if available.
|
||||
|
||||
if server_version == 0:
|
||||
Args:
|
||||
config: Configuration dict with server settings
|
||||
playlist_dir: Directory to save playlist
|
||||
media_dir: Directory to save media files
|
||||
"""
|
||||
try:
|
||||
# Initialize auth with SSL settings from config
|
||||
auth = ensure_authenticated(config)
|
||||
if not auth:
|
||||
logger.error("❌ Cannot update playlist - authentication failed")
|
||||
return None
|
||||
|
||||
# Fetch latest playlist from server
|
||||
server_data = auth.get_playlist()
|
||||
|
||||
if not server_data:
|
||||
logger.warning("⚠️ No valid playlist received from server")
|
||||
return None
|
||||
|
||||
# Check local version
|
||||
server_version = server_data.get('playlist_version', 0)
|
||||
|
||||
if server_version == 0:
|
||||
logger.warning("⚠️ No valid playlist version received from server")
|
||||
return None
|
||||
|
||||
# Check local version from single playlist file
|
||||
local_version = 0
|
||||
local_playlist_file = None
|
||||
playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
|
||||
|
||||
if os.path.exists(playlist_dir):
|
||||
playlist_files = [f for f in os.listdir(playlist_dir)
|
||||
if f.startswith('server_playlist_v') and f.endswith('.json')]
|
||||
|
||||
for f in playlist_files:
|
||||
if os.path.exists(playlist_file):
|
||||
try:
|
||||
version = int(f.replace('server_playlist_v', '').replace('.json', ''))
|
||||
if version > local_version:
|
||||
local_version = version
|
||||
local_playlist_file = os.path.join(playlist_dir, f)
|
||||
except ValueError:
|
||||
continue
|
||||
with open(playlist_file, 'r') as f:
|
||||
local_data = json.load(f)
|
||||
# Check for both 'version' and 'playlist_version' keys (for backward compatibility)
|
||||
local_version = local_data.get('version', local_data.get('playlist_version', 0))
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Could not read local playlist: {e}")
|
||||
|
||||
logger.info(f"📊 Playlist versions - Server: v{server_version}, Local: v{local_version}")
|
||||
|
||||
@@ -312,21 +436,39 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
|
||||
if server_version > local_version:
|
||||
logger.info(f"🔄 Updating playlist from v{local_version} to v{server_version}")
|
||||
|
||||
# Get SSL manager for downloads if using HTTPS
|
||||
ssl_manager = auth.ssl_manager if config.get('use_https', True) else None
|
||||
|
||||
# Get server URL from auth
|
||||
server_url = auth.auth_data.get('server_url', '')
|
||||
|
||||
# Download media files
|
||||
updated_playlist = download_media_files(server_data['playlist'], media_dir)
|
||||
updated_playlist = download_media_files(server_data.get('playlist', []), media_dir, ssl_manager, server_url)
|
||||
server_data['playlist'] = updated_playlist
|
||||
|
||||
# Save new playlist
|
||||
playlist_file = save_playlist_with_version(server_data, playlist_dir)
|
||||
# Save new playlist (single file, no versioning)
|
||||
playlist_file = save_playlist(server_data, playlist_dir)
|
||||
|
||||
# Clean up old versions
|
||||
delete_old_playlists_and_media(server_version, playlist_dir, media_dir)
|
||||
# Delete unused media files
|
||||
delete_unused_media(server_data, media_dir)
|
||||
|
||||
logger.info(f"✅ Playlist updated successfully to v{server_version}")
|
||||
return playlist_file
|
||||
else:
|
||||
logger.info("✓ Playlist is up to date")
|
||||
return local_playlist_file
|
||||
# Even when the playlist version matches, ensure media files exist locally.
|
||||
# The media folder might be empty (e.g. fresh install or deleted files).
|
||||
logger.info("📥 Checking for missing media files...")
|
||||
ssl_manager = auth.ssl_manager if config.get('use_https', True) else None
|
||||
server_url = auth.auth_data.get('server_url', '')
|
||||
downloaded = download_media_files(
|
||||
server_data.get('playlist', []), media_dir, ssl_manager, server_url
|
||||
)
|
||||
if downloaded:
|
||||
server_data['playlist'] = downloaded
|
||||
# Re-save playlist with updated URLs if needed
|
||||
save_playlist(server_data, playlist_dir)
|
||||
return playlist_file
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating playlist: {e}")
|
||||
|
||||
+1441
-736
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Network Monitoring Module
|
||||
Checks server connectivity and manages WiFi restart on connection failure
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import random
|
||||
import platform
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from kivy.logger import Logger
|
||||
from kivy.clock import Clock
|
||||
|
||||
# Detect platform once so the ping / WiFi-restart commands below can
|
||||
# pick the correct syntax (Linux vs Windows).
|
||||
IS_WINDOWS = platform.system() == 'Windows'
|
||||
|
||||
|
||||
class NetworkMonitor:
|
||||
"""Monitor network connectivity and manage WiFi restart"""
|
||||
|
||||
def __init__(self, server_url, check_interval_min=30, check_interval_max=45, wifi_restart_duration=20):
|
||||
"""
|
||||
Initialize network monitor
|
||||
|
||||
Args:
|
||||
server_url (str): Server URL to check connectivity (e.g., 'https://digi-signage.moto-adv.com')
|
||||
check_interval_min (int): Minimum minutes between checks (default: 30)
|
||||
check_interval_max (int): Maximum minutes between checks (default: 45)
|
||||
wifi_restart_duration (int): Minutes to keep WiFi off during restart (default: 20)
|
||||
"""
|
||||
self.server_url = server_url.rstrip('/')
|
||||
self.check_interval_min = check_interval_min * 60 # Convert to seconds
|
||||
self.check_interval_max = check_interval_max * 60 # Convert to seconds
|
||||
self.wifi_restart_duration = wifi_restart_duration * 60 # Convert to seconds
|
||||
self.is_monitoring = False
|
||||
self.scheduled_event = None
|
||||
self.consecutive_failures = 0
|
||||
self.max_failures_before_restart = 3 # Restart WiFi after 3 consecutive failures
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start the network monitoring loop"""
|
||||
if not self.is_monitoring:
|
||||
self.is_monitoring = True
|
||||
Logger.info("NetworkMonitor: Starting network monitoring")
|
||||
self._schedule_next_check()
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Stop the network monitoring"""
|
||||
self.is_monitoring = False
|
||||
if self.scheduled_event:
|
||||
self.scheduled_event.cancel()
|
||||
self.scheduled_event = None
|
||||
Logger.info("NetworkMonitor: Stopped network monitoring")
|
||||
|
||||
def _schedule_next_check(self):
|
||||
"""Schedule the next connectivity check at a random interval"""
|
||||
if not self.is_monitoring:
|
||||
return
|
||||
|
||||
# Random interval between min and max
|
||||
next_check_seconds = random.randint(self.check_interval_min, self.check_interval_max)
|
||||
next_check_minutes = next_check_seconds / 60
|
||||
|
||||
Logger.info(f"NetworkMonitor: Next check scheduled in {next_check_minutes:.1f} minutes")
|
||||
|
||||
# Schedule using Kivy Clock
|
||||
self.scheduled_event = Clock.schedule_once(
|
||||
lambda dt: self._check_connectivity(),
|
||||
next_check_seconds
|
||||
)
|
||||
|
||||
def _check_connectivity(self):
|
||||
"""Check network connectivity to server"""
|
||||
Logger.info("NetworkMonitor: Checking server connectivity...")
|
||||
|
||||
if self._test_server_connection():
|
||||
Logger.info("NetworkMonitor: ✓ Server connection successful")
|
||||
self.consecutive_failures = 0
|
||||
else:
|
||||
self.consecutive_failures += 1
|
||||
Logger.warning(f"NetworkMonitor: ✗ Server connection failed (attempt {self.consecutive_failures}/{self.max_failures_before_restart})")
|
||||
|
||||
if self.consecutive_failures >= self.max_failures_before_restart:
|
||||
Logger.error("NetworkMonitor: Multiple connection failures detected - initiating WiFi restart")
|
||||
self._restart_wifi()
|
||||
self.consecutive_failures = 0 # Reset counter after restart
|
||||
|
||||
# Schedule next check
|
||||
self._schedule_next_check()
|
||||
|
||||
def _test_server_connection(self):
|
||||
"""
|
||||
Test connection to the server using ping only
|
||||
This works in closed networks where the server is local
|
||||
|
||||
Returns:
|
||||
bool: True if server is reachable, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Extract hostname from server URL (remove http:// or https://)
|
||||
hostname = self.server_url.replace('https://', '').replace('http://', '').split('/')[0]
|
||||
|
||||
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||
|
||||
# Ping the server hostname with 3 attempts.
|
||||
# Windows ping uses -n for count and -w for timeout (ms),
|
||||
# while Linux uses -c and -W.
|
||||
if IS_WINDOWS:
|
||||
cmd = ['ping', '-n', '3', '-w', '3000', hostname]
|
||||
else:
|
||||
cmd = ['ping', '-c', '3', '-W', '3', hostname]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info(f"NetworkMonitor: ✓ Server {hostname} is reachable")
|
||||
return True
|
||||
else:
|
||||
Logger.warning(f"NetworkMonitor: ✗ Cannot reach server {hostname}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
Logger.warning(f"NetworkMonitor: ✗ Ping timeout to server")
|
||||
return False
|
||||
except Exception as e:
|
||||
Logger.error(f"NetworkMonitor: Error pinging server: {e}")
|
||||
return False
|
||||
|
||||
def _restart_wifi(self):
|
||||
"""
|
||||
Restart WiFi by turning it off for a specified duration then back on.
|
||||
Uses the platform-appropriate commands:
|
||||
- Linux (Raspberry Pi): sudo rfkill / ifconfig / dhclient
|
||||
- Windows: netsh wlan disconnect / connect
|
||||
This runs in a separate thread to not block the main application.
|
||||
"""
|
||||
def wifi_restart_thread():
|
||||
try:
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
if IS_WINDOWS:
|
||||
self._restart_wifi_windows()
|
||||
else:
|
||||
self._restart_wifi_linux()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||
except Exception as e:
|
||||
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
||||
|
||||
# Run in separate thread to not block the application
|
||||
import threading
|
||||
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _restart_wifi_windows(self):
|
||||
"""Windows WiFi restart using netsh. Turn off for the wait period,
|
||||
then turn back on so Windows reconnects to the preferred network."""
|
||||
wait_minutes = self.wifi_restart_duration / 60
|
||||
Logger.info(
|
||||
f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min"
|
||||
)
|
||||
|
||||
# Turn WiFi OFF
|
||||
off = subprocess.run(
|
||||
['netsh', 'wlan', 'disconnect'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if off.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)")
|
||||
else:
|
||||
Logger.warning(
|
||||
f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}"
|
||||
)
|
||||
|
||||
# Wait with WiFi OFF
|
||||
Logger.info(
|
||||
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
|
||||
f"(started {datetime.now().strftime('%H:%M:%S')})"
|
||||
)
|
||||
time.sleep(self.wifi_restart_duration)
|
||||
Logger.info(
|
||||
f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}"
|
||||
)
|
||||
|
||||
# Turn WiFi back ON — Windows reconnects to the preferred network
|
||||
on = subprocess.run(
|
||||
['netsh', 'wlan', 'connect'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if on.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)")
|
||||
else:
|
||||
# 'netsh wlan connect' without a profile may return non-zero even
|
||||
# though the radio comes back on; log it but don't fail hard.
|
||||
Logger.warning(
|
||||
f"NetworkMonitor: netsh wlan connect returned {on.returncode}: "
|
||||
f"{on.stderr.strip()} (may reconnect automatically)"
|
||||
)
|
||||
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
def _restart_wifi_linux(self):
|
||||
"""Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient."""
|
||||
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'block', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||
|
||||
# Fallback to ifconfig
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'down'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||
return
|
||||
|
||||
# Wait for the specified duration with WiFi OFF
|
||||
wait_minutes = self.wifi_restart_duration / 60
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
|
||||
# Sleep while WiFi is OFF
|
||||
time.sleep(self.wifi_restart_duration)
|
||||
|
||||
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
|
||||
# Turn WiFi back on after the wait period
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
# Unblock WiFi using rfkill
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||
|
||||
# Also bring interface up
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'up'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||
|
||||
# Wait a bit for connection to establish
|
||||
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||
time.sleep(10)
|
||||
|
||||
# Try to restart DHCP
|
||||
Logger.info("NetworkMonitor: Requesting IP address...")
|
||||
subprocess.run(
|
||||
['sudo', 'dhclient', 'wlan0'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
playback_trace.py — Always-on playback transition logger.
|
||||
|
||||
Kivy's log level is forced to 'warning' in main.py / run_win.py, which
|
||||
suppresses every Logger.info()/Logger.debug() line. That made it impossible
|
||||
to see why the player skips/crashes at the weblink->image and video->next
|
||||
transitions.
|
||||
|
||||
This module writes a plain-text trace file (logs/playback_trace.log) with
|
||||
timestamps, INDEPENDENT of Kivy's log level, so we can always see exactly
|
||||
what the player is doing. It is thread-safe (a lock guards the append) and
|
||||
never throws (all failures are swallowed) so it can never break playback.
|
||||
|
||||
Usage:
|
||||
from playback_trace import trace
|
||||
trace("play_current_media", index=3, name="foo.jpg", type="image")
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_LOG_PATH = None
|
||||
_OPENED = False
|
||||
|
||||
|
||||
def _ensure_path():
|
||||
global _LOG_PATH, _OPENED
|
||||
if _OPENED:
|
||||
return _LOG_PATH
|
||||
_OPENED = True
|
||||
try:
|
||||
# Respect the local data dir the launcher set (same place as logs/).
|
||||
base = os.environ.get('KIWY_DATA_DIR') or os.getcwd()
|
||||
log_dir = os.path.join(base, 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
_LOG_PATH = os.path.join(log_dir, 'playback_trace.log')
|
||||
except Exception:
|
||||
_LOG_PATH = None
|
||||
return _LOG_PATH
|
||||
|
||||
|
||||
def trace(event, **kwargs):
|
||||
"""Append one line to the playback trace log.
|
||||
|
||||
Args:
|
||||
event: short event name, e.g. 'next_media', 'eos', 'web_open'.
|
||||
**kwargs: key=value context, e.g. index=3, name='foo.jpg'.
|
||||
"""
|
||||
try:
|
||||
path = _ensure_path()
|
||||
if not path:
|
||||
return
|
||||
t = time.strftime('%H:%M:%S')
|
||||
ms = int((time.time() % 1) * 1000)
|
||||
parts = [f"{t}.{ms:03d}", event]
|
||||
for k, v in kwargs.items():
|
||||
parts.append(f"{k}={v}")
|
||||
with _LOCK:
|
||||
with open(path, 'a', encoding='utf-8') as f:
|
||||
f.write(" ".join(parts) + "\n")
|
||||
except Exception:
|
||||
pass # tracing must never break the player
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"hostname": "rpi-tvholba1",
|
||||
"auth_code": "73XSgIh2iBu3jaU1VOWSrYSS7c9fCPuZuRd7ygYDcjc",
|
||||
"player_id": 1,
|
||||
"player_name": "Tv-Anunturi Hol Ba1",
|
||||
"hostname": "WINDOWS-PC",
|
||||
"auth_code": "",
|
||||
"player_id": 2,
|
||||
"player_name": "Windows-Player1",
|
||||
"playlist_id": 1,
|
||||
"orientation": "Landscape",
|
||||
"authenticated": true,
|
||||
"server_url": "http://digiserver"
|
||||
"server_url": "http://192.168.0.107:8080"
|
||||
}
|
||||
+66
-9
@@ -2,12 +2,14 @@
|
||||
Player Authentication Module for Kiwy-Signage
|
||||
Handles secure authentication with DigiServer v2
|
||||
Uses: hostname → password/quickconnect → get auth_code → use auth_code for API calls
|
||||
Now with HTTPS support and SSL certificate management
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional, Dict, Tuple
|
||||
from ssl_utils import SSLManager, setup_ssl_for_requests
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,13 +18,19 @@ logger = logging.getLogger(__name__)
|
||||
class PlayerAuth:
|
||||
"""Handle player authentication with DigiServer v2."""
|
||||
|
||||
def __init__(self, config_file: str = 'player_auth.json'):
|
||||
def __init__(self, config_file: str = 'player_auth.json',
|
||||
use_https: bool = True, verify_ssl: bool = True):
|
||||
"""Initialize player authentication.
|
||||
|
||||
Args:
|
||||
config_file: Path to authentication config file
|
||||
use_https: Whether to use HTTPS for connections
|
||||
verify_ssl: Whether to verify SSL certificates
|
||||
"""
|
||||
self.config_file = config_file
|
||||
self.use_https = use_https
|
||||
self.verify_ssl = verify_ssl
|
||||
self.ssl_manager = SSLManager(verify_ssl=verify_ssl)
|
||||
self.auth_data = self._load_auth_data()
|
||||
|
||||
def _load_auth_data(self) -> Dict:
|
||||
@@ -65,7 +73,7 @@ class PlayerAuth:
|
||||
"""Authenticate with DigiServer v2.
|
||||
|
||||
Args:
|
||||
server_url: Server URL (e.g., 'http://server:5000')
|
||||
server_url: Server URL (e.g., 'http://server:5000' or 'https://server')
|
||||
hostname: Player hostname/identifier
|
||||
password: Player password (optional if using quickconnect)
|
||||
quickconnect_code: Quick connect code (optional if using password)
|
||||
@@ -77,6 +85,20 @@ class PlayerAuth:
|
||||
if not password and not quickconnect_code:
|
||||
return False, "Password or quick connect code required"
|
||||
|
||||
# Normalize server URL to HTTPS if needed
|
||||
if self.use_https:
|
||||
server_url = self.ssl_manager.validate_url_scheme(server_url)
|
||||
|
||||
# Try to download certificate if not present
|
||||
if not self.ssl_manager.has_certificate():
|
||||
logger.info("Downloading server certificate for HTTPS verification...")
|
||||
success, error = self.ssl_manager.download_server_certificate(server_url, timeout=timeout)
|
||||
if not success:
|
||||
logger.warning(f"⚠️ Certificate download failed: {error}")
|
||||
if self.verify_ssl:
|
||||
return False, error
|
||||
# Continue with unverified connection for testing
|
||||
|
||||
# Prepare authentication request
|
||||
auth_url = f"{server_url}/api/auth/player"
|
||||
payload = {
|
||||
@@ -87,7 +109,10 @@ class PlayerAuth:
|
||||
|
||||
try:
|
||||
logger.info(f"Authenticating with server: {auth_url}")
|
||||
response = requests.post(auth_url, json=payload, timeout=timeout)
|
||||
|
||||
# Use SSL-configured session
|
||||
session = self.ssl_manager.get_session()
|
||||
response = session.post(auth_url, json=payload, timeout=timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
@@ -119,8 +144,16 @@ class PlayerAuth:
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
error_msg = "Cannot connect to server"
|
||||
except requests.exceptions.SSLError as e:
|
||||
error_msg = f"SSL Certificate Error: {e}"
|
||||
logger.error(error_msg)
|
||||
if self.verify_ssl:
|
||||
logger.error(" This usually means the server certificate is not trusted.")
|
||||
logger.error(" Try downloading the server certificate or disabling SSL verification.")
|
||||
return False, error_msg
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
error_msg = f"Connection Error: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
@@ -154,7 +187,9 @@ class PlayerAuth:
|
||||
payload = {'auth_code': self.auth_data.get('auth_code')}
|
||||
|
||||
try:
|
||||
response = requests.post(verify_url, json=payload, timeout=timeout)
|
||||
# Use SSL-configured session
|
||||
session = self.ssl_manager.get_session()
|
||||
response = session.post(verify_url, json=payload, timeout=timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
@@ -165,6 +200,10 @@ class PlayerAuth:
|
||||
logger.warning("❌ Auth code invalid or expired")
|
||||
return False, None
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.error(f"SSL Error during verification: {e}")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to verify auth: {e}")
|
||||
return False, None
|
||||
@@ -195,7 +234,9 @@ class PlayerAuth:
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching playlist from: {playlist_url}")
|
||||
response = requests.get(playlist_url, headers=headers, timeout=timeout)
|
||||
# Use SSL-configured session
|
||||
session = self.ssl_manager.get_session()
|
||||
response = session.get(playlist_url, headers=headers, timeout=timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
@@ -211,6 +252,10 @@ class PlayerAuth:
|
||||
logger.error(f"Failed to get playlist: {response.status_code}")
|
||||
return None
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.error(f"SSL Error fetching playlist: {e}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching playlist: {e}")
|
||||
return None
|
||||
@@ -240,10 +285,16 @@ class PlayerAuth:
|
||||
payload = {'status': status}
|
||||
|
||||
try:
|
||||
response = requests.post(heartbeat_url, headers=headers,
|
||||
# Use SSL-configured session
|
||||
session = self.ssl_manager.get_session()
|
||||
response = session.post(heartbeat_url, headers=headers,
|
||||
json=payload, timeout=timeout)
|
||||
return response.status_code == 200
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.debug(f"SSL Error in heartbeat: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Heartbeat failed: {e}")
|
||||
return False
|
||||
@@ -284,10 +335,16 @@ class PlayerAuth:
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(feedback_url, headers=headers,
|
||||
# Use SSL-configured session
|
||||
session = self.ssl_manager.get_session()
|
||||
response = session.post(feedback_url, headers=headers,
|
||||
json=payload, timeout=timeout)
|
||||
return response.status_code == 200
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.debug(f"SSL Error sending feedback: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Feedback failed: {e}")
|
||||
return False
|
||||
|
||||
+415
-52
@@ -256,7 +256,7 @@
|
||||
id: play_pause_btn
|
||||
size_hint: None, None
|
||||
size: dp(50), dp(50)
|
||||
background_normal: root.resources_path + '/play.png'
|
||||
background_normal: root.resources_path + '/pause.png'
|
||||
background_down: root.resources_path + '/pause.png'
|
||||
border: (0, 0, 0, 0)
|
||||
on_press: root.toggle_pause()
|
||||
@@ -352,20 +352,27 @@
|
||||
# Settings popup content
|
||||
<SettingsPopup@Popup>:
|
||||
title: 'Player Settings'
|
||||
size_hint: 0.8, 0.8
|
||||
size_hint: 0.9, 0.85
|
||||
auto_dismiss: True
|
||||
|
||||
BoxLayout:
|
||||
orientation: 'vertical'
|
||||
padding: dp(20)
|
||||
spacing: dp(15)
|
||||
padding: [dp(15), dp(10)]
|
||||
spacing: dp(8)
|
||||
|
||||
ScrollView:
|
||||
BoxLayout:
|
||||
orientation: 'vertical'
|
||||
spacing: dp(8)
|
||||
size_hint_y: None
|
||||
height: self.minimum_height
|
||||
|
||||
# Server configuration
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Server IP:'
|
||||
@@ -378,7 +385,31 @@
|
||||
id: server_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
font_size: sp(13)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Server port
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Port:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: port_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(13)
|
||||
hint_text: '80 or 8080 (leave empty for default)'
|
||||
input_filter: 'int'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
@@ -386,8 +417,8 @@
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Screen Name:'
|
||||
@@ -400,7 +431,7 @@
|
||||
id: screen_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
font_size: sp(13)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
@@ -408,8 +439,8 @@
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Quickconnect:'
|
||||
@@ -422,7 +453,7 @@
|
||||
id: quickconnect_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
font_size: sp(13)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
@@ -430,8 +461,8 @@
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Orientation:'
|
||||
@@ -444,7 +475,7 @@
|
||||
id: orientation_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
font_size: sp(13)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
@@ -452,8 +483,8 @@
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Touch:'
|
||||
@@ -466,7 +497,7 @@
|
||||
id: touch_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
font_size: sp(13)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
@@ -474,8 +505,8 @@
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Max Resolution:'
|
||||
@@ -488,7 +519,7 @@
|
||||
id: resolution_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
font_size: sp(13)
|
||||
hint_text: '1920x1080 or auto'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
@@ -497,11 +528,11 @@
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Enable Edit Feature:'
|
||||
text: 'Enable Edit:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
@@ -510,82 +541,138 @@
|
||||
CheckBox:
|
||||
id: edit_enabled_checkbox
|
||||
size_hint_x: None
|
||||
width: dp(40)
|
||||
width: dp(36)
|
||||
active: True
|
||||
on_active: root.on_edit_feature_toggle(self.active)
|
||||
|
||||
Label:
|
||||
text: '(Allow editing images on this player)'
|
||||
text: '(Allow editing images)'
|
||||
size_hint_x: 0.4
|
||||
font_size: sp(12)
|
||||
font_size: sp(11)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
color: 0.7, 0.7, 0.7, 1
|
||||
|
||||
# Separator
|
||||
Widget:
|
||||
size_hint_y: 0.05
|
||||
size_hint_y: None
|
||||
height: dp(5)
|
||||
|
||||
# Reset Buttons Section
|
||||
Label:
|
||||
text: 'Reset Options:'
|
||||
size_hint_y: None
|
||||
height: dp(26)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
bold: True
|
||||
font_size: sp(14)
|
||||
|
||||
# Reset Buttons Row
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(44)
|
||||
spacing: dp(8)
|
||||
|
||||
Button:
|
||||
id: reset_auth_btn
|
||||
text: 'Reset Player Auth'
|
||||
background_color: 0.8, 0.4, 0.2, 1
|
||||
font_size: sp(12)
|
||||
on_press: root.reset_player_auth()
|
||||
|
||||
Button:
|
||||
id: reset_playlist_btn
|
||||
text: 'Reset Playlist to v0'
|
||||
background_color: 0.8, 0.4, 0.2, 1
|
||||
font_size: sp(12)
|
||||
on_press: root.reset_playlist_version()
|
||||
|
||||
Button:
|
||||
id: restart_player_btn
|
||||
text: 'Restart Player'
|
||||
background_color: 0.2, 0.6, 0.8, 1
|
||||
font_size: sp(12)
|
||||
on_press: root.restart_player()
|
||||
|
||||
# Test Connection + Production Mode Buttons
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(44)
|
||||
spacing: dp(8)
|
||||
|
||||
# Test Connection Button
|
||||
Button:
|
||||
id: test_connection_btn
|
||||
text: 'Test Server Connection'
|
||||
size_hint_y: None
|
||||
height: dp(50)
|
||||
background_color: 0.2, 0.4, 0.8, 1
|
||||
font_size: sp(13)
|
||||
on_press: root.test_connection()
|
||||
|
||||
Button:
|
||||
id: production_mode_btn
|
||||
text: 'Enable Production'
|
||||
background_color: 0.4, 0.4, 0.4, 1 # grey = disabled
|
||||
font_size: sp(13)
|
||||
on_press: root.toggle_production_mode()
|
||||
|
||||
# Connection Status Label
|
||||
Label:
|
||||
id: connection_status
|
||||
text: 'Click button to test connection'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
height: dp(32)
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(11)
|
||||
color: 0.7, 0.7, 0.7, 1
|
||||
|
||||
# Separator
|
||||
Widget:
|
||||
size_hint_y: 0.05
|
||||
size_hint_y: None
|
||||
height: dp(5)
|
||||
|
||||
# Status information row
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(26)
|
||||
spacing: dp(8)
|
||||
|
||||
# Status information
|
||||
Label:
|
||||
id: playlist_info
|
||||
text: 'Playlist Version: N/A'
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
text: 'Playlist: N/A'
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(11)
|
||||
|
||||
Label:
|
||||
id: media_count_info
|
||||
text: 'Media Count: 0'
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
text: 'Media: 0'
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(11)
|
||||
|
||||
Label:
|
||||
id: status_info
|
||||
text: 'Status: Idle'
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(11)
|
||||
|
||||
Widget:
|
||||
size_hint_y: 0.2
|
||||
|
||||
# Action buttons
|
||||
# Action buttons (always visible, outside scroll)
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(50)
|
||||
spacing: dp(20)
|
||||
height: dp(44)
|
||||
spacing: dp(15)
|
||||
|
||||
Button:
|
||||
text: 'Save & Close'
|
||||
@@ -596,3 +683,279 @@
|
||||
text: 'Cancel'
|
||||
background_color: 0.6, 0.2, 0.2, 1
|
||||
on_press: root.dismiss()
|
||||
|
||||
|
||||
# Card Swipe Popup
|
||||
<CardSwipePopup>:
|
||||
title: 'Card Authentication Required'
|
||||
size_hint: 0.5, 0.4
|
||||
auto_dismiss: False
|
||||
separator_height: 2
|
||||
|
||||
BoxLayout:
|
||||
orientation: 'vertical'
|
||||
padding: dp(20)
|
||||
spacing: dp(20)
|
||||
|
||||
# Card swipe icon
|
||||
AsyncImage:
|
||||
id: icon_image
|
||||
size_hint: 1, 0.4
|
||||
allow_stretch: True
|
||||
keep_ratio: True
|
||||
|
||||
# Message label
|
||||
Label:
|
||||
id: message_label
|
||||
text: 'Please swipe your card...'
|
||||
font_size: sp(20)
|
||||
size_hint: 1, 0.2
|
||||
|
||||
# Countdown timer
|
||||
Label:
|
||||
id: countdown_label
|
||||
text: '5'
|
||||
font_size: sp(48)
|
||||
color: 0.9, 0.6, 0.2, 1
|
||||
size_hint: 1, 0.2
|
||||
|
||||
# Cancel button
|
||||
Button:
|
||||
text: 'Cancel'
|
||||
size_hint: 1, 0.2
|
||||
background_color: 0.9, 0.3, 0.2, 1
|
||||
on_press: root.cancel(self)
|
||||
|
||||
|
||||
# Edit Popup (Drawing on Images)
|
||||
<EditPopup>:
|
||||
title: ''
|
||||
size_hint: 1, 1
|
||||
auto_dismiss: False
|
||||
separator_height: 0
|
||||
|
||||
FloatLayout:
|
||||
# Background image (full screen)
|
||||
Image:
|
||||
id: image_widget
|
||||
allow_stretch: True
|
||||
keep_ratio: True
|
||||
size_hint: 1, 1
|
||||
pos_hint: {'x': 0, 'y': 0}
|
||||
|
||||
# Drawing layer (will be added programmatically due to custom class)
|
||||
# Placeholder widget for drawing layer positioning
|
||||
Widget:
|
||||
id: drawing_layer_placeholder
|
||||
size_hint: 1, 1
|
||||
pos_hint: {'x': 0, 'y': 0}
|
||||
|
||||
# Top toolbar
|
||||
BoxLayout:
|
||||
id: top_toolbar
|
||||
orientation: 'horizontal'
|
||||
size_hint: 1, None
|
||||
height: dp(56)
|
||||
pos_hint: {'top': 1, 'x': 0}
|
||||
spacing: dp(10)
|
||||
padding: [dp(10), dp(8)]
|
||||
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.1, 0.1, 0.1, 0.5
|
||||
Rectangle:
|
||||
size: self.size
|
||||
pos: self.pos
|
||||
|
||||
Widget: # Spacer
|
||||
|
||||
Button:
|
||||
id: undo_btn
|
||||
text: 'Undo'
|
||||
font_size: sp(16)
|
||||
size_hint: None, 1
|
||||
width: dp(100)
|
||||
background_normal: ''
|
||||
background_color: 0.9, 0.6, 0.2, 0.9
|
||||
|
||||
Button:
|
||||
id: clear_btn
|
||||
text: 'Clear'
|
||||
font_size: sp(16)
|
||||
size_hint: None, 1
|
||||
width: dp(100)
|
||||
background_normal: ''
|
||||
background_color: 0.9, 0.3, 0.2, 0.9
|
||||
|
||||
Button:
|
||||
id: save_btn
|
||||
text: 'Save'
|
||||
font_size: sp(16)
|
||||
size_hint: None, 1
|
||||
width: dp(100)
|
||||
background_normal: ''
|
||||
background_color: 0.2, 0.8, 0.2, 0.9
|
||||
|
||||
Button:
|
||||
id: cancel_btn
|
||||
text: 'Cancel'
|
||||
font_size: sp(16)
|
||||
size_hint: None, 1
|
||||
width: dp(100)
|
||||
background_normal: ''
|
||||
background_color: 0.6, 0.2, 0.2, 0.9
|
||||
|
||||
Label:
|
||||
id: countdown_label
|
||||
text: '5:00'
|
||||
font_size: sp(20)
|
||||
size_hint: None, 1
|
||||
width: dp(80)
|
||||
color: 1, 1, 1, 1
|
||||
bold: True
|
||||
|
||||
Widget: # Small spacer
|
||||
size_hint: None, 1
|
||||
width: dp(10)
|
||||
|
||||
# Right sidebar
|
||||
BoxLayout:
|
||||
id: right_sidebar
|
||||
orientation: 'vertical'
|
||||
size_hint: None, 1
|
||||
width: dp(56)
|
||||
pos_hint: {'right': 1, 'y': 0}
|
||||
spacing: dp(10)
|
||||
padding: [dp(8), dp(66), dp(8), dp(10)]
|
||||
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.1, 0.1, 0.1, 0.5
|
||||
Rectangle:
|
||||
size: self.size
|
||||
pos: self.pos
|
||||
|
||||
# Color section header
|
||||
BoxLayout:
|
||||
orientation: 'vertical'
|
||||
size_hint_y: None
|
||||
height: dp(55)
|
||||
spacing: dp(2)
|
||||
|
||||
Image:
|
||||
id: color_icon
|
||||
size_hint_y: None
|
||||
height: dp(28)
|
||||
allow_stretch: True
|
||||
keep_ratio: True
|
||||
|
||||
Label:
|
||||
text: 'Color'
|
||||
font_size: sp(11)
|
||||
bold: True
|
||||
size_hint_y: None
|
||||
height: dp(25)
|
||||
|
||||
# Color buttons
|
||||
Button:
|
||||
id: red_btn
|
||||
text: 'R'
|
||||
font_size: sp(18)
|
||||
size_hint: 1, None
|
||||
height: dp(50)
|
||||
background_normal: ''
|
||||
background_color: 1, 0, 0, 1
|
||||
|
||||
Button:
|
||||
id: blue_btn
|
||||
text: 'B'
|
||||
font_size: sp(18)
|
||||
size_hint: 1, None
|
||||
height: dp(50)
|
||||
background_normal: ''
|
||||
background_color: 0, 0, 1, 1
|
||||
|
||||
Button:
|
||||
id: green_btn
|
||||
text: 'G'
|
||||
font_size: sp(18)
|
||||
size_hint: 1, None
|
||||
height: dp(50)
|
||||
background_normal: ''
|
||||
background_color: 0, 1, 0, 1
|
||||
|
||||
Button:
|
||||
id: black_btn
|
||||
text: 'K'
|
||||
font_size: sp(18)
|
||||
size_hint: 1, None
|
||||
height: dp(50)
|
||||
background_normal: ''
|
||||
background_color: 0, 0, 0, 1
|
||||
|
||||
Button:
|
||||
id: white_btn
|
||||
text: 'W'
|
||||
font_size: sp(18)
|
||||
size_hint: 1, None
|
||||
height: dp(50)
|
||||
background_normal: ''
|
||||
background_color: 1, 1, 1, 1
|
||||
|
||||
# Spacer
|
||||
Widget:
|
||||
size_hint_y: 0.2
|
||||
|
||||
# Thickness section header
|
||||
BoxLayout:
|
||||
orientation: 'vertical'
|
||||
size_hint_y: None
|
||||
height: dp(55)
|
||||
spacing: dp(2)
|
||||
|
||||
Image:
|
||||
id: thickness_icon
|
||||
size_hint_y: None
|
||||
height: dp(28)
|
||||
allow_stretch: True
|
||||
keep_ratio: True
|
||||
|
||||
Label:
|
||||
text: 'Size'
|
||||
font_size: sp(11)
|
||||
bold: True
|
||||
size_hint_y: None
|
||||
height: dp(25)
|
||||
|
||||
# Thickness buttons
|
||||
Button:
|
||||
id: small_btn
|
||||
text: 'S'
|
||||
font_size: sp(20)
|
||||
bold: True
|
||||
size_hint: 1, None
|
||||
height: dp(50)
|
||||
background_normal: ''
|
||||
background_color: 0.3, 0.3, 0.3, 0.9
|
||||
|
||||
Button:
|
||||
id: medium_btn
|
||||
text: 'M'
|
||||
font_size: sp(20)
|
||||
bold: True
|
||||
size_hint: 1, None
|
||||
height: dp(50)
|
||||
background_normal: ''
|
||||
background_color: 0.3, 0.3, 0.3, 0.9
|
||||
|
||||
Button:
|
||||
id: large_btn
|
||||
text: 'L'
|
||||
font_size: sp(20)
|
||||
bold: True
|
||||
size_hint: 1, None
|
||||
height: dp(50)
|
||||
background_normal: ''
|
||||
background_color: 0.3, 0.3, 0.3, 0.9
|
||||
|
||||
Widget: # Bottom spacer
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
SSL/HTTPS Utilities for Kiwy-Signage
|
||||
Handles server certificate verification and HTTPS connection setup
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
import ssl
|
||||
import certifi
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SSLManager:
|
||||
"""Manages SSL certificates and HTTPS connections for player."""
|
||||
|
||||
# Certificate storage location
|
||||
CERT_DIR = os.path.expanduser('~/.kiwy-signage')
|
||||
CERT_FILE = os.path.join(CERT_DIR, 'server_cert.pem')
|
||||
CERT_INFO_FILE = os.path.join(CERT_DIR, 'cert_info.json')
|
||||
|
||||
def __init__(self, verify_ssl: bool = True):
|
||||
"""Initialize SSL manager.
|
||||
|
||||
Args:
|
||||
verify_ssl: Whether to verify SSL certificates (False for dev/testing)
|
||||
"""
|
||||
self.verify_ssl = verify_ssl
|
||||
self.session = requests.Session()
|
||||
self._configure_session()
|
||||
|
||||
def _configure_session(self) -> None:
|
||||
"""Configure requests session with SSL settings."""
|
||||
if self.verify_ssl:
|
||||
# Use saved certificate if available, otherwise use system certs
|
||||
if os.path.exists(self.CERT_FILE):
|
||||
self.session.verify = self.CERT_FILE
|
||||
logger.debug(f"Using saved certificate: {self.CERT_FILE}")
|
||||
else:
|
||||
# Use certifi's CA bundle
|
||||
self.session.verify = certifi.where()
|
||||
logger.debug("Using system CA bundle")
|
||||
else:
|
||||
# For development/testing only
|
||||
self.session.verify = False
|
||||
logger.warning("⚠️ SSL verification disabled - NOT recommended for production!")
|
||||
|
||||
@staticmethod
|
||||
def ensure_cert_dir() -> str:
|
||||
"""Ensure certificate directory exists.
|
||||
|
||||
Returns:
|
||||
Path to certificate directory
|
||||
"""
|
||||
Path(SSLManager.CERT_DIR).mkdir(parents=True, exist_ok=True)
|
||||
return SSLManager.CERT_DIR
|
||||
|
||||
def download_server_certificate(self, server_url: str,
|
||||
timeout: int = 10) -> Tuple[bool, Optional[str]]:
|
||||
"""Download and save server certificate from /api/certificate endpoint.
|
||||
|
||||
Args:
|
||||
server_url: Server URL (e.g., 'https://server:443')
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_message: Optional[str])
|
||||
"""
|
||||
try:
|
||||
# Ensure cert directory exists
|
||||
self.ensure_cert_dir()
|
||||
|
||||
# Make initial request without verification to get certificate
|
||||
temp_session = requests.Session()
|
||||
temp_session.verify = False # Only for getting the cert
|
||||
|
||||
cert_url = f"{server_url}/api/certificate"
|
||||
logger.info(f"Downloading server certificate from {cert_url}")
|
||||
|
||||
response = temp_session.get(cert_url, timeout=timeout)
|
||||
|
||||
if response.status_code == 404:
|
||||
# Server doesn't have certificate endpoint - this is okay
|
||||
logger.info("⚠️ Server does not have /api/certificate endpoint. Certificate verification will be skipped for this session.")
|
||||
return False, "Endpoint not available"
|
||||
|
||||
if response.status_code != 200:
|
||||
error_msg = f"Failed to download certificate: {response.status_code}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
cert_data = response.json()
|
||||
certificate_pem = cert_data.get('certificate')
|
||||
cert_info = cert_data.get('certificate_info', {})
|
||||
|
||||
if not certificate_pem:
|
||||
error_msg = "No certificate data in response"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Save certificate
|
||||
with open(self.CERT_FILE, 'w') as f:
|
||||
f.write(certificate_pem)
|
||||
|
||||
# Save certificate info
|
||||
with open(self.CERT_INFO_FILE, 'w') as f:
|
||||
json.dump(cert_info, f, indent=2, default=str)
|
||||
|
||||
logger.info(f"✅ Server certificate saved to {self.CERT_FILE}")
|
||||
logger.info(f" Subject: {cert_info.get('subject', 'Unknown')}")
|
||||
logger.info(f" Valid until: {cert_info.get('valid_until', 'Unknown')}")
|
||||
|
||||
# Reconfigure session to use new certificate
|
||||
self.session.verify = self.CERT_FILE
|
||||
|
||||
return True, None
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
error_msg = f"Connection error: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
error_msg = "Request timeout"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error downloading certificate: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
def has_certificate(self) -> bool:
|
||||
"""Check if server certificate is saved.
|
||||
|
||||
Returns:
|
||||
True if certificate file exists
|
||||
"""
|
||||
return os.path.exists(self.CERT_FILE)
|
||||
|
||||
def get_certificate_info(self) -> Optional[dict]:
|
||||
"""Get saved certificate information.
|
||||
|
||||
Returns:
|
||||
Certificate info dict or None
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(self.CERT_INFO_FILE):
|
||||
with open(self.CERT_INFO_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read certificate info: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_session(self) -> requests.Session:
|
||||
"""Get configured requests session.
|
||||
|
||||
Returns:
|
||||
Requests session with SSL configured
|
||||
"""
|
||||
return self.session
|
||||
|
||||
def get(self, url: str, **kwargs) -> requests.Response:
|
||||
"""Perform GET request with SSL verification.
|
||||
|
||||
Args:
|
||||
url: URL to request
|
||||
**kwargs: Additional arguments for requests.get()
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
return self.session.get(url, **kwargs)
|
||||
|
||||
def post(self, url: str, **kwargs) -> requests.Response:
|
||||
"""Perform POST request with SSL verification.
|
||||
|
||||
Args:
|
||||
url: URL to request
|
||||
**kwargs: Additional arguments for requests.post()
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
return self.session.post(url, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def create_ssl_context(cert_path: Optional[str] = None) -> ssl.SSLContext:
|
||||
"""Create SSL context for custom SSL handling.
|
||||
|
||||
Args:
|
||||
cert_path: Path to certificate file
|
||||
|
||||
Returns:
|
||||
Configured SSL context
|
||||
"""
|
||||
context = ssl.create_default_context()
|
||||
|
||||
if cert_path and os.path.exists(cert_path):
|
||||
context.load_verify_locations(cert_path)
|
||||
|
||||
return context
|
||||
|
||||
def validate_url_scheme(self, server_url: str) -> str:
|
||||
"""Ensure server URL uses HTTPS.
|
||||
|
||||
Args:
|
||||
server_url: Server URL
|
||||
|
||||
Returns:
|
||||
URL with https:// scheme
|
||||
"""
|
||||
if not server_url:
|
||||
return ""
|
||||
|
||||
# Remove trailing slash
|
||||
server_url = server_url.rstrip('/')
|
||||
|
||||
# Convert http to https
|
||||
if server_url.startswith('http://'):
|
||||
logger.warning("⚠️ Converting http:// to https://")
|
||||
server_url = server_url.replace('http://', 'https://', 1)
|
||||
elif not server_url.startswith('https://'):
|
||||
logger.debug("Adding https:// to server URL")
|
||||
server_url = f'https://{server_url}'
|
||||
|
||||
return server_url
|
||||
|
||||
|
||||
def setup_ssl_for_requests(server_url: str, use_https: bool = True,
|
||||
verify_ssl: bool = True) -> Tuple[requests.Session, bool]:
|
||||
"""Quick setup for requests session with SSL.
|
||||
|
||||
Args:
|
||||
server_url: Server URL
|
||||
use_https: Whether to use HTTPS
|
||||
verify_ssl: Whether to verify SSL certificates
|
||||
|
||||
Returns:
|
||||
Tuple of (session, success)
|
||||
"""
|
||||
ssl_manager = SSLManager(verify_ssl=verify_ssl)
|
||||
|
||||
if use_https:
|
||||
# Normalize URL to use HTTPS
|
||||
server_url = ssl_manager.validate_url_scheme(server_url)
|
||||
|
||||
# Try to download certificate if not present
|
||||
if not ssl_manager.has_certificate():
|
||||
logger.info("No saved certificate found, attempting to download...")
|
||||
success, error = ssl_manager.download_server_certificate(server_url)
|
||||
if not success and verify_ssl:
|
||||
logger.warning(f"Failed to setup SSL: {error}")
|
||||
# Return session anyway, it will use system certs
|
||||
|
||||
return ssl_manager.get_session(), True
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for network monitor functionality
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from kivy.app import App
|
||||
from kivy.clock import Clock
|
||||
from network_monitor import NetworkMonitor
|
||||
|
||||
class TestMonitorApp(App):
|
||||
"""Minimal Kivy app to test network monitor"""
|
||||
|
||||
def build(self):
|
||||
"""Build the app"""
|
||||
from kivy.uix.label import Label
|
||||
return Label(text='Network Monitor Test Running\nCheck terminal for output')
|
||||
|
||||
def on_start(self):
|
||||
"""Start monitoring when app starts"""
|
||||
server_url = "https://digi-signage.moto-adv.com"
|
||||
|
||||
print("=" * 60)
|
||||
print("Network Monitor Test")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(f"Server URL: {server_url}")
|
||||
print("Check interval: 0.5 minutes (30 seconds for testing)")
|
||||
print("WiFi restart duration: 1 minute (for testing)")
|
||||
print()
|
||||
|
||||
# Create monitor with short intervals for testing
|
||||
self.monitor = NetworkMonitor(
|
||||
server_url=server_url,
|
||||
check_interval_min=0.5, # 30 seconds
|
||||
check_interval_max=0.5, # 30 seconds
|
||||
wifi_restart_duration=1 # 1 minute
|
||||
)
|
||||
|
||||
# Perform immediate test
|
||||
print("Performing immediate connectivity test...")
|
||||
self.monitor._check_connectivity()
|
||||
|
||||
# Start monitoring for future checks
|
||||
print("\nStarting periodic network monitoring...")
|
||||
self.monitor.start_monitoring()
|
||||
|
||||
print("\nMonitoring is active. Press Ctrl+C to stop.")
|
||||
print("Next check will occur in ~30 seconds.")
|
||||
print()
|
||||
|
||||
def on_stop(self):
|
||||
"""Stop monitoring when app stops"""
|
||||
if hasattr(self, 'monitor'):
|
||||
self.monitor.stop_monitoring()
|
||||
print("\nNetwork monitoring stopped")
|
||||
print("Test completed!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
TestMonitorApp().run()
|
||||
@@ -14,11 +14,165 @@ HEARTBEAT_FILE="$SCRIPT_DIR/.player_heartbeat"
|
||||
STOP_FLAG_FILE="$SCRIPT_DIR/.player_stop_requested"
|
||||
LOG_FILE="$SCRIPT_DIR/player_watchdog.log"
|
||||
|
||||
# Function to log messages
|
||||
# Ensure log file is writable
|
||||
if [ ! -w "$(dirname "$LOG_FILE")" ]; then
|
||||
LOG_FILE="/tmp/kivy-player-watchdog.log"
|
||||
fi
|
||||
|
||||
# Function to log messages (MUST be defined before use)
|
||||
log_message() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
echo "$msg"
|
||||
# Try to write to log file, ignore errors if permission denied
|
||||
echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Load user's environment from systemd user session (most reliable method)
|
||||
# This ensures we get the proper DISPLAY/WAYLAND_DISPLAY and session variables
|
||||
load_user_environment() {
|
||||
# Try to get environment from active user session via systemctl
|
||||
local user_env
|
||||
if command -v systemctl &>/dev/null; then
|
||||
user_env=$(systemctl --user show-environment 2>/dev/null)
|
||||
if [ -n "$user_env" ]; then
|
||||
# Extract display-related variables without subshell to preserve exports
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
DISPLAY|WAYLAND_DISPLAY|XDG_RUNTIME_DIR|DBUS_SESSION_BUS_ADDRESS)
|
||||
export "$key=$value"
|
||||
;;
|
||||
esac
|
||||
done <<< "$user_env"
|
||||
|
||||
log_message "Loaded user environment from systemctl --user"
|
||||
|
||||
# Verify we got valid display
|
||||
if [ -n "$DISPLAY" ] || [ -n "$WAYLAND_DISPLAY" ]; then
|
||||
log_message "Display environment ready: DISPLAY='$DISPLAY' WAYLAND_DISPLAY='$WAYLAND_DISPLAY'"
|
||||
return 0
|
||||
else
|
||||
log_message "Systemctl didn't provide display variables, trying fallback..."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: detect display manually
|
||||
log_message "Falling back to manual display detection..."
|
||||
|
||||
if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then
|
||||
# Try to detect Wayland display
|
||||
if [ -S "/run/user/$(id -u)/wayland-0" ]; then
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
log_message "Detected Wayland display: $WAYLAND_DISPLAY"
|
||||
# Try to detect X11 display
|
||||
elif [ -S "/tmp/.X11-unix/X0" ]; then
|
||||
export DISPLAY=:0
|
||||
log_message "Detected X11 display: $DISPLAY"
|
||||
else
|
||||
# Wait for display to come up (useful for systemd or delayed starts)
|
||||
log_message "Waiting for display server to be ready (up to 30 seconds)..."
|
||||
for i in {1..30}; do
|
||||
sleep 1
|
||||
if [ -S "/run/user/$(id -u)/wayland-0" ] 2>/dev/null; then
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
log_message "Display server detected on attempt $i: Wayland"
|
||||
return 0
|
||||
elif [ -S "/tmp/.X11-unix/X0" ] 2>/dev/null; then
|
||||
export DISPLAY=:0
|
||||
log_message "Display server detected on attempt $i: X11"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify we have a display now
|
||||
if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then
|
||||
log_message "WARNING: No display server detected. This may cause graphics issues."
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_message "Display environment ready: DISPLAY=$DISPLAY WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Load the user environment early
|
||||
load_user_environment
|
||||
|
||||
# Function to configure display output to Full HD (1920x1080)
|
||||
configure_display_resolution() {
|
||||
log_message "🖥️ Configuring display output to Full HD (1920x1080)..."
|
||||
|
||||
# Method 1: Try xrandr (works on X11 systems)
|
||||
if command -v xrandr &>/dev/null && [ -n "$DISPLAY" ]; then
|
||||
log_message "Attempting to set resolution via xrandr..."
|
||||
|
||||
# Get the primary display
|
||||
primary_display=$(xrandr --query 2>/dev/null | grep " connected" | grep "primary\|preferred" | head -n 1 | awk '{print $1}')
|
||||
|
||||
if [ -z "$primary_display" ]; then
|
||||
primary_display=$(xrandr --query 2>/dev/null | grep " connected" | head -n 1 | awk '{print $1}')
|
||||
fi
|
||||
|
||||
if [ -n "$primary_display" ]; then
|
||||
# Set output to 1920x1080 @ 60Hz
|
||||
xrandr --output "$primary_display" --mode 1920x1080 --rate 60 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log_message "✓ Display resolution set to 1920x1080 via xrandr"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Method 2: Try tvservice (native Raspberry Pi tool)
|
||||
if command -v tvservice &>/dev/null; then
|
||||
log_message "Attempting to set resolution via tvservice (RPi native)..."
|
||||
|
||||
# Get current HDMI status
|
||||
hdmi_mode=$(tvservice -s 2>/dev/null | grep -oP 'DMT mode \K\d+')
|
||||
|
||||
# Set to HDMI mode 16 (1920x1080 @ 60Hz) - standard Full HD
|
||||
tvservice -e "DMT 16" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log_message "✓ Display resolution set to 1920x1080 via tvservice (DMT mode 16)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Method 3: Configure via /boot/config.txt (persistent RPi config)
|
||||
if [ -f "/boot/config.txt" ] && command -v sudo &>/dev/null; then
|
||||
log_message "Checking /boot/config.txt for display settings..."
|
||||
|
||||
# These settings ensure Full HD output on large displays
|
||||
if ! grep -q "hdmi_group=2" /boot/config.txt; then
|
||||
log_message "Adding HDMI display configuration to /boot/config.txt..."
|
||||
# Backup config first
|
||||
sudo cp /boot/config.txt /boot/config.txt.backup.$(date +%s)
|
||||
|
||||
# Add Full HD configuration
|
||||
echo "" | sudo tee -a /boot/config.txt > /dev/null
|
||||
echo "# Kiwy Signage Player - Full HD Display Configuration" | sudo tee -a /boot/config.txt > /dev/null
|
||||
echo "hdmi_group=2" | sudo tee -a /boot/config.txt > /dev/null # DMT (monitor timings)
|
||||
echo "hdmi_mode=16" | sudo tee -a /boot/config.txt > /dev/null # 1920x1080 @ 60Hz
|
||||
echo "hdmi_drive=2" | sudo tee -a /boot/config.txt > /dev/null # Normal HDMI mode
|
||||
echo "disable_overscan=1" | sudo tee -a /boot/config.txt > /dev/null # Disable overscan
|
||||
echo "framebuffer_width=1920" | sudo tee -a /boot/config.txt > /dev/null
|
||||
echo "framebuffer_height=1080" | sudo tee -a /boot/config.txt > /dev/null
|
||||
|
||||
log_message "✓ HDMI configuration added to /boot/config.txt"
|
||||
log_message "⚠️ Display settings require reboot to take effect"
|
||||
return 0
|
||||
else
|
||||
log_message "✓ /boot/config.txt already configured for Full HD"
|
||||
fi
|
||||
fi
|
||||
|
||||
log_message "Display configuration completed (1920x1080 Full HD target)"
|
||||
}
|
||||
|
||||
# Configure display before starting player
|
||||
configure_display_resolution
|
||||
|
||||
# Function to check if player is healthy
|
||||
check_health() {
|
||||
# Check if heartbeat file exists and is recent (within last 60 seconds)
|
||||
@@ -64,19 +218,6 @@ rm -f "$STOP_FLAG_FILE"
|
||||
# Change to the project directory
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ -d ".venv" ]; then
|
||||
log_message "✓ Virtual environment found"
|
||||
source .venv/bin/activate
|
||||
else
|
||||
log_message "⚠️ Creating virtual environment..."
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
log_message "📦 Installing dependencies..."
|
||||
pip3 install -r requirements.txt
|
||||
log_message "✓ Virtual environment ready"
|
||||
fi
|
||||
|
||||
# Check if configuration exists
|
||||
if [ ! -f "config/app_config.json" ]; then
|
||||
log_message "⚠️ WARNING: Configuration file not found!"
|
||||
@@ -96,6 +237,11 @@ while true; do
|
||||
# Clean old heartbeat
|
||||
rm -f "$HEARTBEAT_FILE"
|
||||
|
||||
# Activate virtual environment if it exists
|
||||
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
||||
source "$SCRIPT_DIR/.venv/bin/activate"
|
||||
fi
|
||||
|
||||
# Start the player
|
||||
cd "$SCRIPT_DIR/src"
|
||||
python3 main.py &
|
||||
@@ -138,8 +284,8 @@ while true; do
|
||||
log_message "⏳ Waiting ${RESTART_DELAY}s before restart..."
|
||||
sleep $RESTART_DELAY
|
||||
|
||||
# Cleanup any zombie processes
|
||||
pkill -9 -f "python3 main.py" 2>/dev/null
|
||||
# Ensure old player process is gone before restarting
|
||||
kill -9 $PLAYER_PID 2>/dev/null
|
||||
|
||||
done
|
||||
|
||||
@@ -147,6 +293,3 @@ log_message ""
|
||||
log_message "=========================================="
|
||||
log_message "Watchdog stopped"
|
||||
log_message "=========================================="
|
||||
|
||||
# Deactivate virtual environment (this line is never reached in watchdog mode)
|
||||
deactivate
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to diagnose edited media upload issues
|
||||
Run this to test if the server endpoint exists and works correctly
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
def test_upload():
|
||||
"""Test the edited media upload functionality"""
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("EDITED MEDIA UPLOAD DIAGNOSTICS")
|
||||
print("="*60)
|
||||
|
||||
# Get authentication
|
||||
try:
|
||||
from get_playlists_v2 import get_auth_instance
|
||||
auth = get_auth_instance()
|
||||
|
||||
if not auth or not auth.is_authenticated():
|
||||
print("❌ ERROR: Not authenticated!")
|
||||
print(" Please ensure player_auth.json exists and is valid")
|
||||
return False
|
||||
|
||||
server_url = auth.auth_data.get('server_url')
|
||||
auth_code = auth.auth_data.get('auth_code')
|
||||
|
||||
print(f"\n✓ Authentication successful")
|
||||
print(f" Server URL: {server_url}")
|
||||
print(f" Auth Code: {auth_code[:20]}..." if auth_code else " Auth Code: None")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Authentication error: {e}")
|
||||
return False
|
||||
|
||||
# Check for edited media files
|
||||
edited_media_dir = os.path.join(os.path.dirname(__file__), 'media', 'edited_media')
|
||||
edited_files = list(Path(edited_media_dir).glob('*_e_v*.jpg'))
|
||||
metadata_files = list(Path(edited_media_dir).glob('*_metadata.json'))
|
||||
|
||||
print(f"\n✓ Edited Media Directory: {edited_media_dir}")
|
||||
print(f" Edited images: {len(edited_files)}")
|
||||
print(f" Metadata files: {len(metadata_files)}")
|
||||
|
||||
if not edited_files:
|
||||
print("\n⚠️ No edited images found!")
|
||||
print(" Create an edit first, then run this test")
|
||||
return False
|
||||
|
||||
# Test with the first edited image
|
||||
image_path = str(edited_files[0])
|
||||
metadata_file = str(edited_files[0]).replace('.jpg', '_metadata.json')
|
||||
|
||||
if not os.path.exists(metadata_file):
|
||||
print(f"\n❌ Metadata file not found: {metadata_file}")
|
||||
return False
|
||||
|
||||
print(f"\nTesting upload with:")
|
||||
print(f" Image: {os.path.basename(image_path)}")
|
||||
print(f" Size: {os.path.getsize(image_path):,} bytes")
|
||||
print(f" Metadata: {os.path.basename(metadata_file)}")
|
||||
|
||||
# Load and display metadata
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
print(f"\nMetadata content:")
|
||||
for key, value in metadata.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
# Add original_filename if not present
|
||||
metadata['original_filename'] = os.path.basename(metadata['original_path'])
|
||||
|
||||
# Prepare upload request
|
||||
upload_url = f"{server_url}/api/player-edit-media"
|
||||
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("TESTING UPLOAD...")
|
||||
print(f"{'='*60}")
|
||||
print(f"Endpoint: {upload_url}")
|
||||
print(f"Headers: Authorization: Bearer {auth_code[:20]}...")
|
||||
|
||||
try:
|
||||
with open(image_path, 'rb') as img_file:
|
||||
files = {
|
||||
'image_file': (metadata['original_filename'], img_file, 'image/jpeg')
|
||||
}
|
||||
data = {
|
||||
'metadata': json.dumps(metadata),
|
||||
'original_file': metadata['original_filename']
|
||||
}
|
||||
|
||||
print(f"\nSending request (30s timeout, SSL verify=False)...")
|
||||
response = requests.post(
|
||||
upload_url,
|
||||
headers=headers,
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=30,
|
||||
verify=False
|
||||
)
|
||||
|
||||
print(f"\n✓ Response received!")
|
||||
print(f" Status Code: {response.status_code}")
|
||||
print(f" Headers: {dict(response.headers)}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print(f"\n✅ SUCCESS! Server accepted the upload")
|
||||
print(f" Response: {response.json()}")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
print(f"\n❌ ENDPOINT NOT FOUND (404)")
|
||||
print(f" The server does NOT have /api/player-edit-media endpoint")
|
||||
print(f" Server may need to implement this feature")
|
||||
elif response.status_code == 401:
|
||||
print(f"\n❌ AUTHENTICATION FAILED (401)")
|
||||
print(f" Check your auth_code in player_auth.json")
|
||||
else:
|
||||
print(f"\n❌ REQUEST FAILED (Status: {response.status_code})")
|
||||
print(f" Response: {response.text}")
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f"\n❌ CONNECTION ERROR")
|
||||
print(f" Cannot reach server at {server_url}")
|
||||
print(f" Error: {e}")
|
||||
return False
|
||||
except requests.exceptions.Timeout as e:
|
||||
print(f"\n❌ TIMEOUT")
|
||||
print(f" Server did not respond within 30 seconds")
|
||||
print(f" Error: {e}")
|
||||
return False
|
||||
except requests.exceptions.SSLError as e:
|
||||
print(f"\n❌ SSL ERROR")
|
||||
print(f" Error: {e}")
|
||||
print(f" Tip: Try adding verify=False to requests")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"\n❌ UNEXPECTED ERROR")
|
||||
print(f" Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = test_upload()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
if success:
|
||||
print("✅ UPLOAD TEST PASSED - Server accepts edited media!")
|
||||
else:
|
||||
print("❌ UPLOAD TEST FAILED - See details above")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,34 @@
|
||||
Requirement already satisfied: ffpyplayer in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (4.5.3)
|
||||
Requirement already satisfied: requests in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.34.2)
|
||||
Requirement already satisfied: aiohttp in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (3.14.3)
|
||||
Requirement already satisfied: bcrypt in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (5.0.0)
|
||||
Requirement already satisfied: certifi in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2026.7.22)
|
||||
Requirement already satisfied: pyinstaller in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (6.21.0)
|
||||
Requirement already satisfied: kivy[base] in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.3.1)
|
||||
Requirement already satisfied: Kivy-Garden>=0.1.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.1.5)
|
||||
Requirement already satisfied: docutils in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.23)
|
||||
Requirement already satisfied: pygments in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (2.20.0)
|
||||
Requirement already satisfied: filetype in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (1.2.0)
|
||||
Requirement already satisfied: kivy-deps.angle~=0.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.4.0)
|
||||
Requirement already satisfied: kivy-deps.sdl2~=0.8.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.8.0)
|
||||
Requirement already satisfied: kivy-deps.glew~=0.3.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.3.1)
|
||||
Requirement already satisfied: pypiwin32 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (223)
|
||||
Requirement already satisfied: pillow<11,>=9.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (10.4.0)
|
||||
Requirement already satisfied: charset_normalizer<4,>=2 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.4.9)
|
||||
Requirement already satisfied: idna<4,>=2.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.18)
|
||||
Requirement already satisfied: urllib3<3,>=1.26 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (2.7.0)
|
||||
Requirement already satisfied: aiohappyeyeballs>=2.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (2.7.1)
|
||||
Requirement already satisfied: aiosignal>=1.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.4.0)
|
||||
Requirement already satisfied: attrs>=17.3.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (26.1.0)
|
||||
Requirement already satisfied: frozenlist>=1.1.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.8.0)
|
||||
Requirement already satisfied: multidict<7.0,>=4.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (6.7.1)
|
||||
Requirement already satisfied: propcache>=0.2.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (0.5.2)
|
||||
Requirement already satisfied: typing_extensions>=4.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (4.16.0)
|
||||
Requirement already satisfied: yarl<2.0,>=1.17.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.24.5)
|
||||
Requirement already satisfied: altgraph in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.17.5)
|
||||
Requirement already satisfied: packaging>=22.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (26.2)
|
||||
Requirement already satisfied: pefile>=2022.5.30 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2024.8.26)
|
||||
Requirement already satisfied: pyinstaller-hooks-contrib>=2026.6 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2026.6)
|
||||
Requirement already satisfied: pywin32-ctypes>=0.2.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.2.3)
|
||||
Requirement already satisfied: setuptools>=42.0.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (83.0.0)
|
||||
Requirement already satisfied: pywin32>=223 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pypiwin32->kivy[base]) (312)
|
||||
@@ -0,0 +1,160 @@
|
||||
# Kiwy Signage Player - Windows Edition
|
||||
|
||||
Build and run the Kiwy digital signage player on Windows as a standalone `.exe`.
|
||||
|
||||
## 📋 Requirements Analysis
|
||||
|
||||
The original app was built for **Raspberry Pi (Linux)**, using these technologies:
|
||||
|
||||
| Component | Original (RPi/Linux) | Windows Equivalent |
|
||||
|-----------|---------------------|-------------------|
|
||||
| **GUI** | Kivy 2.3+ | Kivy 2.3+ (works cross-platform) |
|
||||
| **Video** | ffpyplayer | ffpyplayer (needs FFmpeg DLLs) |
|
||||
| **Card Reader** | evdev (Linux input) | **Not available** — gracefully disabled |
|
||||
| **Screen Keep-Awake** | xset, xdotool, Wayland | `SetThreadExecutionState` (Win32 API) |
|
||||
| **Weblink** | chromium-browser (kiosk) | Chrome/Edge (--kiosk mode) |
|
||||
| **Audio** | ALSA/PulseAudio | DirectSound |
|
||||
| **Window Backend** | SDL2 (Wayland/X11) | SDL2 (Windows native) |
|
||||
| **OpenGL** | Desktop GL | ANGLE (DirectX wrapper) |
|
||||
|
||||
### What works on Windows
|
||||
- ✅ Media playback (images, videos via ffpyplayer)
|
||||
- ✅ Playlist sync from DigiServer (HTTP/HTTPS)
|
||||
- ✅ Touch & mouse controls
|
||||
- ✅ Settings popup
|
||||
- ✅ Image editing/annotation
|
||||
- ✅ Password-protected exit
|
||||
- ✅ Web links (opens in Chrome/Edge kiosk)
|
||||
- ✅ Network monitoring
|
||||
- ✅ Auto-update playlist
|
||||
|
||||
### What is disabled on Windows
|
||||
- ❌ Card reader (evdev is Linux-only; `EVDEV_AVAILABLE = False`)
|
||||
- ❌ HDMI power management (tvservice is RPi-specific)
|
||||
- ❌ WiFi restart (uses Linux `nmcli`)
|
||||
|
||||
## 🚀 Quick Start (Development)
|
||||
|
||||
### Prerequisites
|
||||
1. **Python 3.12+** (64-bit) — [python.org](https://python.org)
|
||||
- ⚠️ **Python 3.13+ is NOT supported** — Kivy 2.3.1 does not have pre-built wheels for it
|
||||
- ⚠️ **Python 3.14 is NOT supported** — no Kivy wheels available
|
||||
- ✅ **Python 3.12.9** is the recommended version (confirmed working)
|
||||
2. **FFmpeg** — for video codec support
|
||||
- Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||
- Add `bin\` folder to your PATH
|
||||
3. **Visual C++ Redistributable** — [latest](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
### Install & Run
|
||||
```batch
|
||||
cd windows
|
||||
|
||||
REM Create virtual environment with Python 3.12
|
||||
py -3.12 -m venv venv
|
||||
:: OR specify full path:
|
||||
:: "C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe" -m venv venv
|
||||
|
||||
venv\Scripts\activate
|
||||
|
||||
REM Install dependencies
|
||||
pip install -r requirements_win.txt
|
||||
|
||||
REM Run in development mode
|
||||
python run_win.py
|
||||
```
|
||||
|
||||
## 📦 Building the .exe
|
||||
|
||||
### One-Command Build
|
||||
```batch
|
||||
cd windows
|
||||
build_win.bat
|
||||
```
|
||||
|
||||
### Manual Build
|
||||
```batch
|
||||
cd windows
|
||||
venv\Scripts\activate
|
||||
pip install -r requirements_win.txt
|
||||
pyinstaller build.spec --clean --noconfirm
|
||||
```
|
||||
|
||||
### Output
|
||||
```
|
||||
windows\dist\KiwySignagePlayer\
|
||||
├── KiwySignagePlayer.exe # Main executable
|
||||
├── config/ # Config files (auto-copied)
|
||||
├── resources/ # Icons, intro video
|
||||
└── ... (supporting DLLs)
|
||||
```
|
||||
|
||||
For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` section and comment out the `coll = COLLECT(...)` section.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
1. On first run, config files are created in the **same folder as the executable** (not in `%APPDATA%`)
|
||||
- The .exe creates: `config/`, `media/`, `playlists/`, `logs/` directories locally
|
||||
- This allows you to copy the entire `dist\KiwySignagePlayer\` folder anywhere and it works
|
||||
2. Edit `config\app_config.json` (next to the .exe) to set your server:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_ip": "192.168.0.109",
|
||||
"port": "8080",
|
||||
"screen_name": "Birou_IT",
|
||||
"quickconnect_key": "8887779",
|
||||
"orientation": "Landscape",
|
||||
"touch": "True",
|
||||
"max_resolution": "1920x1080",
|
||||
"edit_feature_enabled": true,
|
||||
"use_https": false,
|
||||
"verify_ssl": false
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```batch
|
||||
cd windows
|
||||
venv\Scripts\activate
|
||||
python run_win.py
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| **"ffpyplayer not found"** | Install: `pip install ffpyplayer` |
|
||||
| **"No video" / black screen** | Install FFmpeg and add to PATH. Try `KIVY_GL_BACKEND=angle_sdl2` or `KIVY_GL_BACKEND=gl` |
|
||||
| **Kivy window doesn't open** | Run from command prompt to see error messages. Ensure GPU drivers are up to date. |
|
||||
| **Weblinks not opening** | Install Google Chrome or Microsoft Edge |
|
||||
| **Can't connect to server** | Check firewall. Try `use_https: false` and `verify_ssl: false` for testing |
|
||||
| **Antivirus flags .exe** | Add the output folder to antivirus exclusions. This is a false positive common with PyInstaller. |
|
||||
|
||||
## 📁 Project Structure (Build)
|
||||
|
||||
```
|
||||
Kiwy-Signage/
|
||||
├── windows/
|
||||
│ ├── run_win.py # Windows entry point (patches platform differences)
|
||||
│ ├── build.spec # PyInstaller configuration
|
||||
│ ├── build_win.bat # One-click build script
|
||||
│ ├── pyi_runtime_hook.py # PyInstaller runtime hook
|
||||
│ ├── requirements_win.txt # Windows Python dependencies
|
||||
│ └── README_WINDOWS_BUILD.md # This file
|
||||
├── src/
|
||||
│ ├── main.py # Main application (original)
|
||||
│ ├── get_playlists_v2.py # Playlist sync
|
||||
│ ├── player_auth.py # Authentication
|
||||
│ ├── ssl_utils.py # SSL/HTTPS
|
||||
│ ├── keyboard_widget.py # On-screen keyboard
|
||||
│ ├── network_monitor.py # Network monitoring
|
||||
│ ├── edit_popup.py # Image editing
|
||||
│ └── signage_player.kv # Kivy UI layout
|
||||
├── config/
|
||||
│ ├── app_config.json # Player configuration
|
||||
│ └── resources/ # Icons, images, intro video
|
||||
├── media/ # Downloaded media (created at runtime)
|
||||
├── playlists/ # Playlist files (created at runtime)
|
||||
└── logs/ # Log files (created at runtime)
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Quick syntax check for build files."""
|
||||
import ast, sys
|
||||
|
||||
files = [
|
||||
r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\run_win.py',
|
||||
r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\pyi_runtime_hook.py',
|
||||
]
|
||||
|
||||
for f in files:
|
||||
try:
|
||||
with open(f, encoding='utf-8') as fh:
|
||||
ast.parse(fh.read())
|
||||
print(f"OK: {f}")
|
||||
except SyntaxError as e:
|
||||
print(f"SYNTAX ERROR in {f}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("All files OK")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,302 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""
|
||||
PyInstaller spec file for Kiwy Signage Player (Windows .exe)
|
||||
|
||||
Build command (from windows/ directory):
|
||||
pyinstaller build.spec --clean --noconfirm
|
||||
|
||||
OR use the build script:
|
||||
build_win.bat
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# --- Paths -----------------------------------------------------------
|
||||
# This spec file is in windows/build.spec, so the project root is
|
||||
# always two levels up from this file's real location.
|
||||
# __file__ may not be available in PyInstaller spec context fallback to cwd.
|
||||
try:
|
||||
_spec_dir = Path(__file__).resolve().parent
|
||||
except NameError:
|
||||
_spec_dir = Path(os.getcwd()).resolve()
|
||||
# _spec_dir is now the absolute path to the windows/ directory
|
||||
BUILD_DIR = _spec_dir
|
||||
ROOT_DIR = BUILD_DIR.parent
|
||||
SRC_DIR = ROOT_DIR / 'src'
|
||||
CONFIG_DIR = ROOT_DIR / 'config'
|
||||
RESOURCES_DIR = CONFIG_DIR / 'resources'
|
||||
|
||||
# --- Determine hidden imports that PyInstaller might miss -------------
|
||||
hidden_imports = [
|
||||
# Kivy core modules
|
||||
'kivy.core.window',
|
||||
'kivy.core.video',
|
||||
'kivy.core.audio',
|
||||
'kivy.core.text',
|
||||
'kivy.core.image',
|
||||
'kivy.core.gl',
|
||||
'kivy.core.camera',
|
||||
'kivy.core.clipboard',
|
||||
'kivy.core.spelling',
|
||||
'kivy.core.text.markup',
|
||||
'kivy.core.window.window_sdl2',
|
||||
'kivy.core.image.img_sdl2',
|
||||
'kivy.core.video.video_ffpyplayer',
|
||||
'kivy.core.audio.audio_ffpyplayer',
|
||||
# Kivy modules
|
||||
'kivy.uix.video',
|
||||
'kivy.uix.vkeyboard',
|
||||
'kivy.uix.popup',
|
||||
'kivy.uix.image',
|
||||
'kivy.uix.button',
|
||||
'kivy.uix.label',
|
||||
'kivy.uix.textinput',
|
||||
'kivy.uix.boxlayout',
|
||||
'kivy.uix.floatlayout',
|
||||
'kivy.uix.slider',
|
||||
'kivy.uix.widget',
|
||||
'kivy.uix.checkbox',
|
||||
'kivy.graphics',
|
||||
'kivy.graphics.texture',
|
||||
'kivy.graphics.vertex_instructions',
|
||||
'kivy.graphics.context_instructions',
|
||||
'kivy.clock',
|
||||
'kivy.loader',
|
||||
'kivy.animation',
|
||||
'kivy.lang',
|
||||
'kivy.logger',
|
||||
'kivy.config',
|
||||
'kivy.properties',
|
||||
'kivy.metrics',
|
||||
'kivy.factory',
|
||||
# Graphics providers
|
||||
'kivy.graphics.opengl',
|
||||
'kivy.graphics.opengl_utils',
|
||||
'kivy.graphics.fbo',
|
||||
'kivy.graphics.gl_instructions',
|
||||
'kivy.graphics.stencil_instructions',
|
||||
'kivy.graphics.scissor_instructions',
|
||||
'kivy.graphics.buffer',
|
||||
'kivy.graphics.vbo',
|
||||
'kivy.graphics.shader',
|
||||
'kivy.graphics.compiler',
|
||||
# ffpyplayer
|
||||
'ffpyplayer',
|
||||
'ffpyplayer.player',
|
||||
'ffpyplayer.pic',
|
||||
'ffpyplayer.writer',
|
||||
# Networking
|
||||
'requests',
|
||||
'aiohttp',
|
||||
'urllib3',
|
||||
'certifi',
|
||||
'bcrypt',
|
||||
# Platform
|
||||
'ctypes',
|
||||
'ctypes.wintypes',
|
||||
'subprocess',
|
||||
'shutil',
|
||||
'glob',
|
||||
'selectors',
|
||||
'tempfile',
|
||||
# Windows-specific
|
||||
'cef_browser',
|
||||
'win32gui',
|
||||
'win32con',
|
||||
]
|
||||
|
||||
# Exclude Linux-only modules
|
||||
excluded_imports = [
|
||||
'gi', # GTK introspection (Linux)
|
||||
'gi.repository',
|
||||
'evdev', # We inject a fake evdev module in run_win.py
|
||||
# GStreamer — we use ffpyplayer, not GStreamer
|
||||
'kivy.lib.gstplayer',
|
||||
# cefpython3: keep only Python 3.12 .pyd, exclude other version .pyd files
|
||||
'cefpython3.cefpython_py27',
|
||||
'cefpython3.cefpython_py34',
|
||||
'cefpython3.cefpython_py35',
|
||||
'cefpython3.cefpython_py36',
|
||||
'cefpython3.cefpython_py37',
|
||||
'cefpython3.cefpython_py38',
|
||||
'cefpython3.cefpython_py39',
|
||||
'cefpython3.cefpython_py310',
|
||||
'cefpython3.cefpython_py311',
|
||||
]
|
||||
|
||||
# --- Application data files to bundle --------------------------------
|
||||
# Resources (icons, intro video, etc.)
|
||||
resources_data = []
|
||||
for item in RESOURCES_DIR.iterdir():
|
||||
if item.is_file():
|
||||
target_dir = 'config/resources'
|
||||
resources_data.append((str(item), target_dir))
|
||||
|
||||
# Config directory (app_config.json)
|
||||
config_data = []
|
||||
config_file = CONFIG_DIR / 'app_config.json'
|
||||
if config_file.exists():
|
||||
config_data.append((str(config_file), 'config'))
|
||||
|
||||
# Source files - .kv file
|
||||
kv_file = SRC_DIR / 'signage_player.kv'
|
||||
kv_data = []
|
||||
if kv_file.exists():
|
||||
kv_data.append((str(kv_file), '.'))
|
||||
|
||||
# Bundle the entire src directory as a tree
|
||||
source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*.ini'])
|
||||
|
||||
# --- Collect binary DLLs from kivy_deps and ffpyplayer ----------------
|
||||
import importlib.util
|
||||
from pathlib import Path as _Path
|
||||
|
||||
|
||||
def _site_packages_dir(package_path):
|
||||
"""Climb up from a package's __init__.py to its site-packages dir."""
|
||||
d = _Path(package_path).parent
|
||||
while d.name != 'site-packages' and d.parent != d:
|
||||
d = d.parent
|
||||
return d
|
||||
|
||||
|
||||
def _find_share_dlls(package_name, share_name=None):
|
||||
"""Find .dll files under venv_root/share/<share_name>/.
|
||||
|
||||
kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into
|
||||
<venv>/share/<pkg>/... NOT inside the package dir. The share folder is
|
||||
named after the *short* dep name (e.g. 'sdl2', 'angle', 'glew'), not the
|
||||
dotted package name ('kivy_deps.sdl2'), so pass share_name explicitly.
|
||||
"""
|
||||
if share_name is None:
|
||||
share_name = package_name
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
if spec is None or not spec.origin:
|
||||
return []
|
||||
sp = _site_packages_dir(spec.origin)
|
||||
# Climb from site-packages up until we find a sibling 'share' dir
|
||||
# (site-packages -> Lib -> venv, where venv/share lives).
|
||||
d = sp
|
||||
while d.parent != d:
|
||||
if (d.parent / 'share').is_dir():
|
||||
share = d.parent / 'share' / share_name
|
||||
break
|
||||
d = d.parent
|
||||
else:
|
||||
return []
|
||||
if not share.is_dir():
|
||||
return []
|
||||
results = []
|
||||
for root, dirs, files in os.walk(share):
|
||||
for f in files:
|
||||
if f.endswith('.dll'):
|
||||
results.append((os.path.join(root, f), '.'))
|
||||
return results
|
||||
|
||||
|
||||
def _find_ffpyplayer_bins():
|
||||
"""Return ffpyplayer's own dependency DLL dirs (FFmpeg + bundled SDL).
|
||||
|
||||
ffpyplayer ships a `dep_bins` list that already points at the correct
|
||||
share/ffpyplayer/ffmpeg/bin and share/ffpyplayer/sdl/bin directories.
|
||||
"""
|
||||
try:
|
||||
import ffpyplayer
|
||||
bins = getattr(ffpyplayer, 'dep_bins', None)
|
||||
if not bins:
|
||||
return []
|
||||
results = []
|
||||
for b in bins:
|
||||
bpath = _Path(b)
|
||||
if bpath.is_dir():
|
||||
for f in bpath.glob('*.dll'):
|
||||
results.append((str(f), '.'))
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs)
|
||||
_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2')
|
||||
_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle')
|
||||
_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew')
|
||||
|
||||
# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins)
|
||||
_ffpy_dlls = _find_ffpyplayer_bins()
|
||||
|
||||
_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls
|
||||
|
||||
if not _all_binaries:
|
||||
print("=" * 70)
|
||||
print("WARNING: No Kivy/ffpyplayer DLLs found via share/ directories.")
|
||||
print("PyInstaller may still auto-detect them, but if the .exe")
|
||||
print("fails with 'SDL2.dll not found' or similar, you will need")
|
||||
print("to manually add the DLL paths to the spec file.")
|
||||
print("=" * 70)
|
||||
else:
|
||||
print(f"[spec] Bundling {len(_all_binaries)} DLLs:")
|
||||
for _p, _t in sorted(_all_binaries):
|
||||
print(f" {_Path(_p).name} <- {_p}")
|
||||
|
||||
# --- Build the .exe --------------------------------------------------
|
||||
a = Analysis(
|
||||
['run_win.py'], # Entry point (relative to this spec)
|
||||
pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules
|
||||
binaries=_all_binaries,
|
||||
datas=resources_data + config_data + kv_data,
|
||||
hiddenimports=hidden_imports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[str(BUILD_DIR / 'pyi_runtime_hook.py')],
|
||||
excludes=excluded_imports,
|
||||
noarchive=False,
|
||||
module_collection_mode={
|
||||
'kivy': 'pyz',
|
||||
'kivy.core': 'pyz',
|
||||
'kivy.uix': 'pyz',
|
||||
'kivy.graphics': 'pyz',
|
||||
},
|
||||
)
|
||||
|
||||
# Add the source tree (main.py, etc.)
|
||||
a.datas += source_tree
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='KiwySignagePlayer',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # Show console for debugging startup errors
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None,
|
||||
version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None,
|
||||
)
|
||||
|
||||
# --- COLLECT everything into a single folder -------------------------
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='KiwySignagePlayer',
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
@echo off
|
||||
REM =====================================================================
|
||||
REM Kiwy Signage Player - Windows Build Script
|
||||
REM =====================================================================
|
||||
REM This script builds a standalone Windows .exe using PyInstaller.
|
||||
REM
|
||||
REM Prerequisites:
|
||||
REM 1. Python 3.10+ installed (with "Add to PATH" checked)
|
||||
REM 2. Visual C++ Redistributable (for ffpyplayer)
|
||||
REM 3. FFmpeg binaries in PATH (optional, for video codec support)
|
||||
REM
|
||||
REM Steps:
|
||||
REM 1. Run this script from the project root or the windows\ folder
|
||||
REM 2. The .exe will be created in windows\dist\KiwySignagePlayer\
|
||||
REM =====================================================================
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ============================================
|
||||
echo Kiwy Signage Player - Windows Build
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
REM ---- Check Python ----
|
||||
where python >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] Python not found! Please install Python 3.10+ and add it to PATH.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [INFO] Using Python:
|
||||
python --version
|
||||
|
||||
REM ---- Create virtual environment (if not exists) ----
|
||||
if not exist "venv\Scripts\python.exe" (
|
||||
echo.
|
||||
echo [STEP] Creating virtual environment...
|
||||
python -m venv venv
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] Failed to create virtual environment.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [INFO] Virtual environment already exists.
|
||||
)
|
||||
|
||||
REM ---- Activate virtual environment ----
|
||||
call venv\Scripts\activate.bat
|
||||
|
||||
REM ---- Install/upgrade pip ----
|
||||
echo.
|
||||
echo [STEP] Upgrading pip...
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
REM ---- Install dependencies ----
|
||||
echo.
|
||||
echo [STEP] Installing Windows dependencies...
|
||||
pip install -r requirements_win.txt
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] Failed to install dependencies.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ---- Verify Kivy installation ----
|
||||
echo.
|
||||
echo [STEP] Verifying Kivy installation...
|
||||
python -c "import kivy; print(f'Kivy {kivy.__version__}')" 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [WARNING] Kivy check failed. Build may still work but test carefully.
|
||||
)
|
||||
|
||||
REM ---- Check PyInstaller ----
|
||||
echo.
|
||||
echo [STEP] Verifying PyInstaller...
|
||||
python -c "import PyInstaller; print(f'PyInstaller {PyInstaller.__version__}')" 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] PyInstaller not found.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ---- Create app icon (from PNG if possible) ----
|
||||
echo.
|
||||
echo [STEP] Checking for app icon...
|
||||
if not exist "..\config\resources\app_icon.ico" (
|
||||
echo [INFO] No .ico icon found. Will use default PyInstaller icon.
|
||||
echo [INFO] To add a custom icon, place app_icon.ico in config\resources\
|
||||
)
|
||||
|
||||
REM ---- Run PyInstaller ----
|
||||
echo.
|
||||
echo [STEP] Building executable with PyInstaller...
|
||||
echo This may take several minutes. Please wait...
|
||||
echo.
|
||||
|
||||
pyinstaller build.spec --clean --noconfirm
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo.
|
||||
echo [ERROR] PyInstaller build failed!
|
||||
echo Check the output above for error details.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ---- Success ----
|
||||
echo.
|
||||
echo ============================================
|
||||
echo BUILD COMPLETE!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Output: %~dp0dist\KiwySignagePlayer\
|
||||
echo.
|
||||
echo The executable is:
|
||||
echo %~dp0dist\KiwySignagePlayer\KiwySignagePlayer.exe
|
||||
echo.
|
||||
echo To run: Double-click KiwySignagePlayer.exe
|
||||
echo.
|
||||
echo Note: The first run may take a while as Windows Defender
|
||||
echo scans the executable. This is normal.
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
cef_browser.py v2 — Embedded Chromium INSIDE Kivy's SDL2 window
|
||||
|
||||
v1 created a separate Win32 window (same as external Chrome).
|
||||
v2 creates CEF as a **child window** of Kivy's SDL_app window:
|
||||
- No separate taskbar entry
|
||||
- No z-order fighting
|
||||
- No desktop flash
|
||||
- CEF message loop pumped via Kivy Clock (main thread)
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cefpython3 import cefpython as cef
|
||||
CEF_AVAILABLE = True
|
||||
except ImportError:
|
||||
CEF_AVAILABLE = False
|
||||
|
||||
WS_CHILD = 0x40000000
|
||||
WS_VISIBLE = 0x10000000
|
||||
WS_CLIPSIBLINGS = 0x04000000
|
||||
WS_CLIPCHILDREN = 0x02000000
|
||||
SW_HIDE = 0
|
||||
SW_SHOWNORMAL = 1
|
||||
|
||||
|
||||
class CefBrowser:
|
||||
def __init__(self):
|
||||
self._browser = None
|
||||
self._cef_initialized = False
|
||||
self._child_hwnd = None
|
||||
self._kivy_hwnd = None
|
||||
self._clock_event = None
|
||||
self._showing = False
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
def show(self, url):
|
||||
if not CEF_AVAILABLE:
|
||||
return False
|
||||
if not self._cef_initialized:
|
||||
self._init_cef()
|
||||
if self._browser is not None:
|
||||
self._browser.Navigate(url)
|
||||
self._show_in_kivy()
|
||||
return True
|
||||
return self._create_embedded(url)
|
||||
|
||||
def hide(self):
|
||||
self._showing = False
|
||||
if self._clock_event is not None:
|
||||
try:
|
||||
from kivy.clock import Clock
|
||||
Clock.unschedule(self._clock_event)
|
||||
except Exception:
|
||||
pass
|
||||
self._clock_event = None
|
||||
if self._child_hwnd:
|
||||
try:
|
||||
ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_HIDE)
|
||||
except Exception:
|
||||
pass
|
||||
if self._browser is not None:
|
||||
try:
|
||||
self._browser.CloseBrowser(True)
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
if self._child_hwnd:
|
||||
try:
|
||||
ctypes.windll.user32.DestroyWindow(self._child_hwnd)
|
||||
except Exception:
|
||||
pass
|
||||
self._child_hwnd = None
|
||||
|
||||
def shutdown(self):
|
||||
self.hide()
|
||||
if self._cef_initialized:
|
||||
try:
|
||||
cef.Shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self._cef_initialized = False
|
||||
|
||||
def navigate(self, url):
|
||||
if self._browser is not None:
|
||||
self._browser.Navigate(url)
|
||||
|
||||
def is_showing(self):
|
||||
return self._showing
|
||||
|
||||
def resize(self, width, height):
|
||||
"""Called when Kivy window resizes — repositions CEF child."""
|
||||
if self._child_hwnd:
|
||||
ctypes.windll.user32.SetWindowPos(
|
||||
self._child_hwnd, 0, 0, 0, width, height, 0x0004
|
||||
)
|
||||
if self._browser:
|
||||
self._browser.SetBounds(0, 0, width, height)
|
||||
|
||||
# ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
def _init_cef(self):
|
||||
settings = {
|
||||
"multi_threaded_message_loop": False,
|
||||
"single_process": True,
|
||||
"log_severity": cef.LOGSEVERITY_WARNING,
|
||||
"user_agent": "Mozilla/5.0 KiwySignage/1.0",
|
||||
"cache_path": str(
|
||||
Path(os.environ.get("KIWY_DATA_DIR", ".")) / ".cef_cache"
|
||||
),
|
||||
}
|
||||
cef.Initialize(settings=settings)
|
||||
self._cef_initialized = True
|
||||
|
||||
def _get_kivy_hwnd(self):
|
||||
if self._kivy_hwnd is not None:
|
||||
return self._kivy_hwnd
|
||||
try:
|
||||
import win32gui
|
||||
hwnd = win32gui.FindWindow("SDL_app", None)
|
||||
if hwnd:
|
||||
self._kivy_hwnd = hwnd
|
||||
return hwnd
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _create_embedded(self, url):
|
||||
kivy_hwnd = self._get_kivy_hwnd()
|
||||
if not kivy_hwnd:
|
||||
return False
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
rect = (ctypes.c_long * 4)()
|
||||
user32.GetClientRect(kivy_hwnd, ctypes.byref(rect))
|
||||
w, h = rect[2], rect[3]
|
||||
|
||||
hinstance = ctypes.windll.kernel32.GetModuleHandleW(None)
|
||||
self._child_hwnd = user32.CreateWindowExW(
|
||||
0, b'#32770', b'',
|
||||
WS_CHILD | WS_CLIPSIBLINGS | WS_CLIPCHILDREN,
|
||||
0, 0, w, h, kivy_hwnd, 0, hinstance, 0,
|
||||
)
|
||||
if not self._child_hwnd:
|
||||
return False
|
||||
|
||||
winfo = cef.WindowInfo()
|
||||
winfo.SetAsChild(self._child_hwnd, [0, 0, w, h])
|
||||
self._browser = cef.CreateBrowserSync(
|
||||
window_info=winfo,
|
||||
settings={"background_color": 0x00000000},
|
||||
url=url,
|
||||
)
|
||||
self._showing = True
|
||||
self._show_in_kivy()
|
||||
self._start_clock_pump()
|
||||
return True
|
||||
|
||||
def _show_in_kivy(self):
|
||||
if not self._child_hwnd:
|
||||
return
|
||||
kivy_hwnd = self._get_kivy_hwnd()
|
||||
if kivy_hwnd:
|
||||
user32 = ctypes.windll.user32
|
||||
rect = (ctypes.c_long * 4)()
|
||||
user32.GetClientRect(kivy_hwnd, ctypes.byref(rect))
|
||||
user32.SetWindowPos(
|
||||
self._child_hwnd, 0, 0, 0, rect[2], rect[3], 0x0004
|
||||
)
|
||||
ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_SHOWNORMAL)
|
||||
self._showing = True
|
||||
|
||||
def _start_clock_pump(self):
|
||||
if self._clock_event is not None:
|
||||
return
|
||||
|
||||
def _pump(dt):
|
||||
if self._cef_initialized:
|
||||
try:
|
||||
cef.MessageLoopWork()
|
||||
except Exception:
|
||||
pass
|
||||
if self._showing:
|
||||
from kivy.clock import Clock
|
||||
self._clock_event = Clock.schedule_once(_pump, 0.01)
|
||||
|
||||
from kivy.clock import Clock
|
||||
self._clock_event = Clock.schedule_once(_pump, 0)
|
||||
@@ -0,0 +1,289 @@
|
||||
# 🧪 Development Track — Kiwy Signage Player (Windows Edition)
|
||||
|
||||
> This file tracks every change, bug fix, tested solution, build info, and
|
||||
> pending issues for the Windows port. Read this FIRST before starting any
|
||||
> debugging or coding session.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Current Session — 2026-07-31
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Branch** | `Windows-Player` |
|
||||
| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) |
|
||||
| **Kivy** | 2.3.1 |
|
||||
| **PyInstaller** | 6.21.0 |
|
||||
| **Last .exe build** | 2026-07-26 16:53 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||
| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||
|
||||
### 📋 Cross-platform audit — Linux commands → Windows handling
|
||||
|
||||
Every Linux-only command in `src/` was cross-referenced against the patches
|
||||
in `windows/run_win.py`. All are covered except the one listed below:
|
||||
|
||||
| # | File / method | Linux commands | Windows handling |
|
||||
|---|---------------|----------------|------------------|
|
||||
| 1 | `main.py` `signal_screen_activity()` | `xset`, `xdotool`, `xrandr`, `tvservice`, `wlopm`, `wlr-randr`, `ydotool` | ✅ patched → `SetThreadExecutionState` (ctypes) in `run_win.py` |
|
||||
| 2 | `main.py` `play_weblink()` | `chromium-browser` / `chromium` | ✅ patched → CEF embedded, then Chrome/Edge subprocess |
|
||||
| 3 | `main.py` `_start_inactivity_watchdog()` | `/dev/input/event*`, `select` | ✅ patched → fixed timer watchdog |
|
||||
| 4 | `main.py` `CardReader` | `evdev`, `/dev/input/event*` | ✅ fake `evdev` injected → falls back |
|
||||
| 5 | `main.py` `SettingsPopup.test_connection` | `/tmp/temp_auth_test.json` | ✅ patched → `tempfile.gettempdir()` |
|
||||
| 6 | `main.py` weblink kill/prewarm wrappers | `proc.terminate()` only | ✅ patched → `taskkill /F /T` + `_Win32Overlay` |
|
||||
| 7 | `network_monitor.py` `_test_server_connection()` | `ping -c 3 -W 3` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||
| 8 | `network_monitor.py` `_restart_wifi()` | `sudo rfkill`, `sudo ifconfig`, `sudo dhclient` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||
| 9 | `get_playlists_v2.py`, `player_auth.py`, `ssl_utils.py`, `edit_popup.py`, `keyboard_widget.py` | none | ✅ no Linux commands |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Tracker
|
||||
|
||||
### [BUG-010] NetworkMonitor uses Linux-only ping + rfkill commands
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
- **Symptom:** `network_monitor.py` ran `ping -c 3 -W 3` (Linux flags) and on
|
||||
connection failure invoked `sudo rfkill` / `sudo ifconfig wlan0` /
|
||||
`sudo dhclient` — all fail or hang on Windows (`sudo` isn't even present).
|
||||
- **Root cause:** This module was missed when the other Linux paths were
|
||||
patched in `run_win.py`.
|
||||
- **Fix:** Made `network_monitor.py` self-contained cross-platform:
|
||||
1. Added `IS_WINDOWS = platform.system() == 'Windows'`
|
||||
2. `_test_server_connection()` uses `ping -n 3 -w 3000` on Windows
|
||||
3. `_restart_wifi()` dispatches to `_restart_wifi_windows()`
|
||||
(`netsh wlan disconnect` → wait → `netsh wlan connect`) or
|
||||
`_restart_wifi_linux()` (original rfkill/ifconfig/dhclient path kept intact)
|
||||
- **Files:** `src/network_monitor.py`
|
||||
- **Test:** Windows `ping -n 3 -w 3000 localhost` returns 0; AST parse OK.
|
||||
|
||||
---
|
||||
|
||||
### [BUG-011] Weblink never displays on Windows (opens behind Kivy / exits instantly)
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
- **Symptom:** Web link items don't show. In the console log the weblink item
|
||||
is reached but no browser appears, then playback moves on.
|
||||
- **Root causes (two compounding):**
|
||||
1. **Chrome re-used an existing instance.** `subprocess.Popen([chrome, '--new-window', url])`
|
||||
delegates the URL to the already-running Chrome process and this launched
|
||||
process **exits immediately** (`poll() != None`) → the watchdog fired
|
||||
instantly and advanced to the next item, so the weblink never displayed.
|
||||
2. **Overlay-hide raised Kivy over Chrome.** `_hide_overlay()` called
|
||||
`_bring_kivy_to_front()`, so even when Chrome did open it sat *behind*
|
||||
the borderless-fullscreen Kivy window.
|
||||
- **Fix (in `windows/run_win.py`):**
|
||||
1. Launch Chrome/Edge with a **dedicated `--user-data-dir`** (`<data>/.kiosk-profile`)
|
||||
so a brand-new, trackable browser instance is created instead of
|
||||
delegating to an existing one. Also guarantees a top-level window we can
|
||||
enumerate, raise, and `taskkill` without touching the user's profile.
|
||||
2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper
|
||||
that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID)
|
||||
instead of raising Kivy.
|
||||
- **Update (2026-07-31 15:42):** added **`--kiosk`** flag to the weblink launch
|
||||
args so the browser opens in true kiosk mode (no UI/chrome, locks to screen).
|
||||
Safe with the dedicated `--user-data-dir` — does not affect the user's normal
|
||||
browser session.
|
||||
- **Update (2026-07-31 16:04):** replaced the fixed 1.0s overlay-hide timer with
|
||||
**adaptive polling** (`_hide_overlay_when_chrome_ready`). The black overlay now
|
||||
stays up until Chrome's window is actually detected on screen
|
||||
(`_find_chrome_hwnd`), so the host desktop is never exposed during cold
|
||||
starts / slow disk / GPU init. Falls back to Kivy after a 6s timeout.
|
||||
- **Update (2026-07-31 16:19):** added a **persistent `_Win32Backdrop`** — a
|
||||
fullscreen black window created at player startup (`_Win32Backdrop.show()`)
|
||||
placed at `HWND_BOTTOM` (below Kivy & the kiosk browser, above the desktop),
|
||||
destroyed only on clean exit. Any browser load/unload gap now reveals clean
|
||||
black instead of the host desktop.
|
||||
- **Test:** exe rebuilt 2026-07-31 16:19; DLL set intact (28 DLLs incl. FFmpeg).
|
||||
|
||||
### [BUG-012] Next widget never comes to foreground after weblink ends
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
- **Symptom:** After a weblink finishes, the next media/widget renders but the
|
||||
Kivy window stays behind (or the window focus is lost) — user sees the wrong
|
||||
window / frozen view.
|
||||
- **Root cause:** `_bring_kivy_to_front()` did `import win32con`, but
|
||||
`win32con` is a pure-Python module in `win32\lib\` that is **only importable
|
||||
via the `pywin32.pth` file**. `.pth` files are ignored in frozen PyInstaller
|
||||
apps, so `win32con` was never bundled (confirmed via `pyi-archive_viewer` —
|
||||
only `win32gui.pyd` / `win32api.pyd` / `win32process.pyd` present). The
|
||||
`import win32con` threw, the whole function silently fell back to
|
||||
`Window.raise_window()`, and the Kivy window was never reliably raised.
|
||||
- **Fix (in `windows/run_win.py`):**
|
||||
1. Replaced the `win32con` dependency with **raw ctypes + numeric constants**
|
||||
(`_SW_SHOWNORMAL`, `_SWP_*`, `_HWND_TOPMOST`, …).
|
||||
2. New `_bring_hwnd_to_front(hwnd)` — ctypes-only `SetForegroundWindow` with
|
||||
`AttachThreadInput` foreground-lock bypass + `IsIconic` restore + topmost
|
||||
flash.
|
||||
3. `_bring_kivy_to_front()` now uses `_find_kivy_hwnd()` (win32gui.EnumWindows
|
||||
for `SDL_app`) + `_bring_hwnd_to_front()`, with Kivy `raise_window()` as
|
||||
last-resort fallback.
|
||||
- **Test:** exe rebuilt; no `win32con` import remains in `run_win.py`.
|
||||
|
||||
---
|
||||
|
||||
### [BUG-001] RecursionError: play_current_media ↔ restart_playlist
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** Pressing "Restart Player" in settings with empty playlist causes
|
||||
infinite recursion: `play_current_media → restart_playlist → play_current_media → ...`
|
||||
- **Fix:** Added empty-playlist guard in both `play_current_media()` and
|
||||
`restart_playlist()` → they return early instead of calling each other.
|
||||
- **Files:** `src/main.py` — lines ~1304 and ~2073
|
||||
- **Test:** Verified no Python syntax errors via `ast.parse`.
|
||||
|
||||
### [BUG-002] Settings fields cut off on small screens
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** "Screen Name", "Quickconnect" and other fields at the top of
|
||||
the settings popup are invisible on smaller resolutions because content
|
||||
overflows the popup.
|
||||
- **Fix:** Wrapped settings content in a `ScrollView`. Moved "Save & Close" /
|
||||
"Cancel" buttons outside the scroll (always visible). Reduced row heights.
|
||||
- **Files:** `src/signage_player.kv` — `<SettingsPopup@Popup>` block
|
||||
|
||||
### [BUG-003] Chromium not fullscreen on Windows
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** Web links open in a small window instead of fullscreen.
|
||||
- **Fix:** Changed launch args from `--kiosk` to `--start-maximized --app=URL`
|
||||
+ explicit `--window-size=WxH`. `--kiosk` uses Wayland exclusive-fullscreen
|
||||
protocol which doesn't work on Windows.
|
||||
- **Tested rejected solutions:**
|
||||
- ❌ `--kiosk` alone → small window, no fullscreen
|
||||
- ❌ `--start-fullscreen` alone → not reliable
|
||||
- ✅ `--start-maximized --app=URL --window-size=...` → works
|
||||
- **Files:** `windows/run_win.py` — `_windows_play_weblink()`
|
||||
|
||||
### [BUG-004] Desktop flash when switching between Chromium and Kivy
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** When Chrome closes, the desktop is briefly visible before Kivy
|
||||
reappears. Also when Chrome opens, there's a flash.
|
||||
- **Fix:** Added `_Win32Overlay` class — a fullscreen black Win32 window that
|
||||
covers the screen during transitions. Shown BEFORE closing Chrome / opening
|
||||
Chrome, hidden AFTER Kivy is ready.
|
||||
- **Tested rejected solutions:**
|
||||
- ❌ `Window.raise_window()` alone → still shows flash
|
||||
- ✅ Win32 black overlay → smooth masking
|
||||
- **Files:** `windows/run_win.py` — `_Win32Overlay` class
|
||||
|
||||
### [BUG-005] Chrome processes linger after closing weblink
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** After a weblink item ends, Chrome child processes (GPU,
|
||||
renderer) remain running → blank windows accumulate.
|
||||
- **Fix:** Use `taskkill /F /T /PID <pid>` to kill the entire process tree.
|
||||
- **Tested rejected solutions:**
|
||||
- ❌ `proc.terminate()` → leaves children running
|
||||
- ❌ `proc.kill()` → same problem
|
||||
- ✅ `taskkill /F /T` → kills everything
|
||||
- **Files:** `windows/run_win.py` — `_windows_kill_process_tree()`
|
||||
|
||||
### [BUG-007] Video plays behind Chromium on weblink→media transition
|
||||
- **Status:** ✅ **Fixed — 2026-07-26 (final)**
|
||||
- **Symptom:** When a weblink ends and the next media starts, the media plays
|
||||
*behind* Chromium. Audio is heard but user sees Chrome.
|
||||
- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes
|
||||
Chrome → widget visible. On Windows Chrome stays ON TOP.
|
||||
`Window.raise_window()` is unreliable. Three compounding issues:
|
||||
1. `KivyWindow.minimize()` made Kivy impossible to bring back reliably
|
||||
2. `_windows_play_current_media` killed the browser but never restored
|
||||
`content_area.opacity = 1`, so next widget rendered invisible
|
||||
3. `_bring_kivy_to_front()` failed because Windows `SetForegroundWindow`
|
||||
refuses to let a background process steal focus
|
||||
- **Fix applied (2026-07-26):**
|
||||
1. **Removed `KivyWindow.minimize()`** in `_windows_play_weblink()` — Kivy
|
||||
stays visible behind the overlay instead of being hidden
|
||||
2. **Restored `content_area.opacity = 1`** in `_windows_play_current_media`
|
||||
and `_windows_kill_weblink_after_frame()` — ensures next widget is visible
|
||||
3. **`_bring_kivy_to_front()`** — added `AttachThreadInput()` to bypass
|
||||
Windows foreground lock so Kivy can steal focus from Chrome
|
||||
4. **Overlay hide** now calls `_bring_kivy_to_front()` instead of
|
||||
`Window.raise_window()`
|
||||
5. **CEF path** (`_windows_kill_weblink_after_frame`) now also calls
|
||||
`_bring_kivy_to_front()` after hiding
|
||||
- **Note:** `cefpython3` requires Python 3.10 — falls back to subprocess
|
||||
Chrome/Edge on 3.12.9. Transition now works reliably with subprocess path.
|
||||
- **Files:** `windows/run_win.py`
|
||||
|
||||
### [BUG-008] Intro video and media files not found at runtime
|
||||
- **Status:** ✅ **Fixed** 2026-07-24
|
||||
- **Symptom:** `[ERROR] [Image] Error loading <...intro1.mp4>` — intro
|
||||
broken. Also `❌ Media file not found` for playlist items.
|
||||
- **Root cause:** Media download only ran when `server_version > local_version`.
|
||||
When versions matched (v16 == v16), `download_media_files` was never called
|
||||
→ media folder stayed empty.
|
||||
- **Fix:** Added download check in the "up to date" branch — now downloads
|
||||
missing media files even when playlist version hasn't changed.
|
||||
|
||||
### [BUG-009] Video never advances to next item (EOS handler empty)
|
||||
- **Status:** ✅ **Fixed** 2026-07-24
|
||||
- **Symptom:** Video plays but never advances to the next playlist item.
|
||||
- **Root cause:** `_on_video_eos()` callback was a stub — just logged
|
||||
"Video finished playing (EOS)" but never called `next_media()`.
|
||||
- **Fix:** Added `Clock.unschedule(self.next_media)` + `Clock.schedule_once`
|
||||
to advance after 0.5s when a video reaches end of stream.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tested & Rejected Solutions Log
|
||||
|
||||
> Keep a record of approaches that were tried and didn't work, so we don't
|
||||
> waste time re-testing them.
|
||||
|
||||
| Date | What was tested | Result | Reason it failed |
|
||||
|------|----------------|--------|-----------------|
|
||||
| 2026-07-24 | Python 3.14 with Kivy | ❌ | `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel |
|
||||
| 2026-07-24 | `--kiosk` Chrome flag on Windows | ❌ | Not fullscreen, Wayland exclusive-fullscreen not available |
|
||||
| 2026-07-24 | `--start-fullscreen` alone | ❌ | Inconsistent, sometimes not full |
|
||||
| 2026-07-24 | `proc.terminate()` for Chrome | ❌ | Leaves child processes running |
|
||||
| 2026-07-24 | `proc.kill()` for Chrome | ❌ | Same as terminate — children survive |
|
||||
| 2026-07-24 | `Window.raise_window()` for transition | ❌ | Brief desktop flash visible |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Data Directory Behaviour
|
||||
|
||||
When the .exe runs:
|
||||
1. Runtime hook (`pyi_runtime_hook.py`) sets `KIWY_DATA_DIR = exe_dir`
|
||||
2. `run_win.py` patches `SignagePlayer.__init__` to use `KIWY_DATA_DIR`
|
||||
3. Local folders created next to the .exe:
|
||||
```
|
||||
KiwySignagePlayer.exe
|
||||
config/
|
||||
app_config.json
|
||||
resources/ (icons, intro video)
|
||||
certs/ (SSL certificates)
|
||||
media/
|
||||
edited_media/
|
||||
playlists/
|
||||
logs/
|
||||
.kivy/ (Kivy home)
|
||||
.player_heartbeat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Build Cheatsheet
|
||||
|
||||
```powershell
|
||||
# Build the .exe (from windows/ directory)
|
||||
Set-Location windows
|
||||
& .\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||
|
||||
# Run in dev mode (no build needed)
|
||||
& .\venv\Scripts\python.exe run_win.py
|
||||
|
||||
# Test imports only
|
||||
& .\venv\Scripts\python.exe test_import_fix.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes for the Next Session
|
||||
|
||||
- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui
|
||||
- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui`
|
||||
- [x] Install `cefpython3` — embedded Chromium, no more subprocess
|
||||
- [x] ~~Verify CEF embedded browser actually works at runtime~~ → CEF needs Python 3.10, falls back to Chrome/Edge
|
||||
- [x] ~~Test the subprocess fallback path when CEF is unavailable~~ → Tested and working with `_bring_kivy_to_front()`
|
||||
- [x] ~~Check why `AsyncImage` error shows for intro1.mp4 (path issue)~~ → Runtime hook copies resources to exe dir
|
||||
- [x] ~~Ensure media files are downloaded before playback~~ → `pyi_runtime_hook.py` copies config/resources on first run
|
||||
- [x] ~~Add `cef_browser.py` to PyInstaller hidden imports~~ → Already in `build.spec`
|
||||
- [x] ~~Make `network_monitor.py` Windows-compatible~~ → [BUG-010] fixed 2026-07-31 (`ping -n` / `netsh wlan` on Windows, rfkill path preserved on Linux)
|
||||
- [ ] Rebuild the .exe to pick up the `network_monitor.py` fix
|
||||
- [ ] Clean `cefpython3` from `venv/` (Python 3.12 won't use it anyway)
|
||||
- [ ] Verify the .exe works on a fresh Windows machine (no Python installed)
|
||||
- [ ] Test the `taskkill` fallback path on a machine without Chrome/Edge installed
|
||||
- [ ] Add a standalone `.bat` launcher for development mode
|
||||
@@ -0,0 +1,27 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM Kiwy Signage Player - Windows Launcher
|
||||
REM ============================================================
|
||||
REM This batch file launches the Kiwy Signage Player executable.
|
||||
REM It creates local folders for playlist, media, config, and logs
|
||||
REM next to the executable.
|
||||
REM ============================================================
|
||||
|
||||
cd /d "%~dp0dist\KiwySignagePlayer"
|
||||
|
||||
echo ============================================
|
||||
echo Kiwy Signage Player - Windows Edition
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Launching player...
|
||||
echo.
|
||||
|
||||
start "" "KiwySignagePlayer.exe"
|
||||
|
||||
echo Player started.
|
||||
echo.
|
||||
echo If the player window does not appear, check:
|
||||
echo dist\KiwySignagePlayer\logs\crash.log
|
||||
echo dist\KiwySignagePlayer\logs\fatal_crash.log
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
PyInstaller Runtime Hook for Kiwy Signage Player
|
||||
------------------------------------------------
|
||||
Runs at startup of the packaged .exe to fix paths and environment.
|
||||
Creates all necessary folders LOCAL to the executable's directory.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
# ── IMPORTANT: Set Windows environment BEFORE any Kivy code runs ──
|
||||
# This must happen before main.py's top-level code executes, because
|
||||
# main.py sets SDL_VIDEODRIVER=wayland,x11,dummy which would crash on Windows.
|
||||
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
||||
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
||||
os.environ['KIVY_WINDOW'] = 'sdl2'
|
||||
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
||||
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||
os.environ['KIVY_NO_FILELOG'] = '1'
|
||||
os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect on Windows
|
||||
|
||||
# ── Capture ALL early output to a crash log ─────────────────────────
|
||||
# Ensure we catch any exception that happens before Logger is available.
|
||||
_startup_log_path = None
|
||||
try:
|
||||
_exe_dir = Path(sys.executable).parent
|
||||
_startup_log_path = _exe_dir / 'logs' / 'startup_crash.log'
|
||||
(_startup_log_path.parent).mkdir(parents=True, exist_ok=True)
|
||||
with open(_startup_log_path, 'w') as _f:
|
||||
_f.write("pyi_runtime_hook.py started\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _setup_paths():
|
||||
"""Ensure the app can find its bundled files at runtime.
|
||||
|
||||
All data folders (config, media, playlists, logs) are created
|
||||
LOCAL to the executable's directory — NOT in %%APPDATA%%.
|
||||
"""
|
||||
# In PyInstaller, sys.executable is the .exe path.
|
||||
# sys._MEIPASS is the extraction directory (i.e. _internal/ folder).
|
||||
exe_dir = Path(sys.executable).parent
|
||||
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
|
||||
|
||||
# ── Change cwd to _internal so Builder.load_file('signage_player.kv')
|
||||
# and other relative file references from main.py resolve ─────
|
||||
os.chdir(str(internal_dir))
|
||||
|
||||
# Add bundled src directory to Python path
|
||||
src_dir = str(internal_dir / 'src')
|
||||
if os.path.isdir(src_dir) and src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
|
||||
# Add internal directory for config/media/playlists access
|
||||
if str(internal_dir) not in sys.path:
|
||||
sys.path.insert(0, str(internal_dir))
|
||||
|
||||
# ── Local folders next to the .exe ──────────────────────────────
|
||||
# All data lives in the SAME folder as the executable so the user
|
||||
# can copy/move the whole directory and everything still works.
|
||||
os.environ['KIWY_DATA_DIR'] = str(exe_dir)
|
||||
|
||||
# Set KIVY_HOME to a local .kivy folder next to the .exe
|
||||
kivy_home = exe_dir / '.kivy'
|
||||
os.environ.setdefault('KIVY_HOME', str(kivy_home))
|
||||
kivy_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create local data folders next to the .exe
|
||||
for sub in ['config', 'config/resources', 'media', 'playlists', 'logs']:
|
||||
(exe_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _copy_bundled_resources():
|
||||
"""Copy bundled resource/config files to the local folders on first run."""
|
||||
exe_dir = Path(sys.executable).parent
|
||||
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
|
||||
|
||||
# Files to copy (source in bundle -> destination next to .exe)
|
||||
files_to_copy = [
|
||||
('config/app_config.json', 'config/app_config.json'),
|
||||
('config/resources/access-card.png', 'config/resources/access-card.png'),
|
||||
('config/resources/arrow.png', 'config/resources/arrow.png'),
|
||||
('config/resources/backward.png', 'config/resources/backward.png'),
|
||||
('config/resources/card-checked.png', 'config/resources/card-checked.png'),
|
||||
('config/resources/edit-pen.png', 'config/resources/edit-pen.png'),
|
||||
('config/resources/exit.png', 'config/resources/exit.png'),
|
||||
('config/resources/forward.png', 'config/resources/forward.png'),
|
||||
('config/resources/intro1.mp4', 'config/resources/intro1.mp4'),
|
||||
('config/resources/pause.png', 'config/resources/pause.png'),
|
||||
('config/resources/pencil.png', 'config/resources/pencil.png'),
|
||||
('config/resources/play.png', 'config/resources/play.png'),
|
||||
('config/resources/settings.png', 'config/resources/settings.png'),
|
||||
]
|
||||
|
||||
for src_rel, dest_rel in files_to_copy:
|
||||
src_path = internal_dir / src_rel
|
||||
dest_path = exe_dir / dest_rel
|
||||
if src_path.is_file() and not dest_path.exists():
|
||||
try:
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
import shutil
|
||||
shutil.copy2(str(src_path), str(dest_path))
|
||||
except Exception:
|
||||
pass # Non-critical; app can still run
|
||||
|
||||
|
||||
# ── Wrap everything in try/except to capture early crashes ──────────
|
||||
try:
|
||||
_setup_paths()
|
||||
_copy_bundled_resources()
|
||||
# If we reach here, the hook finished successfully
|
||||
try:
|
||||
with open(_startup_log_path, 'a') as _f:
|
||||
_f.write("pyi_runtime_hook.py completed successfully\n")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _hook_exc:
|
||||
import traceback as _tb
|
||||
try:
|
||||
with open(_startup_log_path, 'a') as _f:
|
||||
_f.write(f"pyi_runtime_hook.py CRASHED: {_hook_exc}\n")
|
||||
_tb.print_exc(file=_f)
|
||||
except Exception:
|
||||
pass
|
||||
raise # Re-raise so the .exe still fails visibly
|
||||
@@ -0,0 +1,39 @@
|
||||
# =====================================================================
|
||||
# Kiwy Signage Player - Windows Dependencies
|
||||
# =====================================================================
|
||||
# Install with: pip install -r requirements_win.txt
|
||||
|
||||
# --- Core GUI Framework ---
|
||||
# Kivy 2.3+ with SDL2 backend (best for Windows)
|
||||
kivy[base]>=2.3.0
|
||||
|
||||
# --- Video Playback ---
|
||||
# ffpyplayer for video decoding
|
||||
ffpyplayer>=4.5
|
||||
|
||||
# --- HTTP / Networking ---
|
||||
requests>=2.32.0,<3.0.0
|
||||
aiohttp>=3.9.0,<4.0.0
|
||||
certifi>=2024.0.0
|
||||
|
||||
# --- Password / Auth ---
|
||||
bcrypt>=4.2.0,<5.0.0
|
||||
|
||||
# --- Packaging ---
|
||||
# PyInstaller for building the .exe
|
||||
pyinstaller>=6.0
|
||||
|
||||
# --- Windows-specific Libraries ---
|
||||
# cefpython3: Embedded Chromium browser (replaces subprocess Chrome/Edge)
|
||||
# Installed separately because it's a large package (69 MB):
|
||||
# pip install cefpython3
|
||||
# cefpython3>=66.1
|
||||
# Note: Uncomment above line to bundle cefpython3 in the .exe.
|
||||
# Without it, weblinks fall back to subprocess Chrome/Edge.
|
||||
|
||||
# pywin32: Windows API bindings (win32gui for SetForegroundWindow etc.)
|
||||
# Already installed as a dependency of kivy[base]
|
||||
|
||||
# --- Optional: DirectShow filters for better video on Windows ---
|
||||
# ffmpeg (install via chocolatey or manual download)
|
||||
# https://ffmpeg.org/download.html
|
||||
+1469
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
"""Test that setting env vars before importing main.py fixes the crash."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# This is the KEY fix: set Windows env vars BEFORE main.py is imported
|
||||
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
||||
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
||||
os.environ['KIVY_WINDOW'] = 'sdl2'
|
||||
# Use 'angle_sdl2' on Windows for better DirectX compatibility
|
||||
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
||||
# Let Kivy auto-detect input providers on Windows
|
||||
os.environ['KIVY_INPUTPROVIDERS'] = ''
|
||||
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, r'C:\Users\Dell-PC\Desktop\Kiwy-Signage\src')
|
||||
|
||||
print("=" * 60)
|
||||
print("Testing main.py import with Windows env vars...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
import main
|
||||
print("SUCCESS: main.py imported without crashing!")
|
||||
print(f" SDL_VIDEODRIVER = {os.environ.get('SDL_VIDEODRIVER')}")
|
||||
print(f" KIVY_WINDOW = {os.environ.get('KIVY_WINDOW')}")
|
||||
print(f" KIVY_GL_BACKEND = {os.environ.get('KIVY_GL_BACKEND')}")
|
||||
print(f" KIVY_INPUTPROVIDERS = {os.environ.get('KIVY_INPUTPROVIDERS')}")
|
||||
except SystemExit as e:
|
||||
print(f"FAILED: SystemExit({e}) - Kivy window provider still not loading")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"FAILED with exception: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,43 @@
|
||||
# UTF-8
|
||||
#
|
||||
# Windows version resource for KiwySignagePlayer.exe
|
||||
# This file is used by PyInstaller (version=) to embed publisher/product
|
||||
# metadata into the executable so Windows Smart App Control / SmartScreen
|
||||
# can identify the app instead of flagging it as "Unknown publisher".
|
||||
#
|
||||
# Note: A code-signing certificate is still required for a fully trusted
|
||||
# publisher name; this metadata at least names the product/company and
|
||||
# supplies a version number.
|
||||
#
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 2, 0, 0),
|
||||
prodvers=(1, 2, 0, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo(
|
||||
[
|
||||
StringTable(
|
||||
'040904B0',
|
||||
[
|
||||
StringStruct('CompanyName', 'Kiwy Signage'),
|
||||
StringStruct('FileDescription', 'Kiwy Signage Player - Digital Signage Player'),
|
||||
StringStruct('FileVersion', '1.2.0.0'),
|
||||
StringStruct('InternalName', 'KiwySignagePlayer'),
|
||||
StringStruct('LegalCopyright', 'Copyright (c) 2026 Kiwy Signage'),
|
||||
StringStruct('OriginalFilename', 'KiwySignagePlayer.exe'),
|
||||
StringStruct('ProductName', 'Kiwy Signage Player'),
|
||||
StringStruct('ProductVersion', '1.2.0.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
VarFileInfo([VarStruct('Translation', [1033, 1200])])
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
INFO ] [Kivy ] Installed at "/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/kivy/__init__.py"
|
||||
[INFO ] [Python ] v3.13.5 (main, Jun 25 2025, 18:55:22) [GCC 14.2.0]
|
||||
[INFO ] [Python ] Interpreter at "/home/pi/Desktop/Kiwy-Signage/.venv/bin/python3"
|
||||
[INFO ] [Logger ] Purge log fired. Processing...
|
||||
[INFO ] [Logger ] Purge finished!
|
||||
[DEBUG ] [Using selector] EpollSelector
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||
[ERROR ] [Image ] Error loading </home/pi/Desktop/Kiwy-Signage/config/resources/intro1.mp4>
|
||||
[WARNING] ⚠️ SSL verification disabled - NOT recommended for production!
|
||||
[DEBUG ] [Starting new HTTPS connection (1)] 192.168.0.121:443
|
||||
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
|
||||
warnings.warn(
|
||||
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/auth/verify HTTP/1.1" 200 None
|
||||
[INFO ] ✅ Auth code verified
|
||||
[INFO ] ✅ Using existing authentication
|
||||
[INFO ] [Fetching playlist from] https://192.168.0.121:443/api/playlists/1
|
||||
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
|
||||
warnings.warn(
|
||||
[DEBUG ] [https ]//192.168.0.121:443 "GET /api/playlists/1 HTTP/1.1" 200 None
|
||||
[INFO ] [✅ Playlist received (version] 34)
|
||||
[INFO ] [📊 Playlist versions - Server] v34, Local: v34
|
||||
[INFO ] ✓ Playlist is up to date
|
||||
[WARNING] Deprecated property "<BooleanProperty name=allow_stretch>" of object "<kivy.uix.image.AsyncImage object at 0x7fa5f79ef0>" has been set, it will be removed in a future version
|
||||
[WARNING] Deprecated property "<BooleanProperty name=keep_ratio>" of object "<kivy.uix.image.AsyncImage object at 0x7fa5f79ef0>" was accessed, it will be removed in a future version
|
||||
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
|
||||
warnings.warn(
|
||||
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/auth/verify HTTP/1.1" 200 None
|
||||
[INFO ] ✅ Auth code verified
|
||||
[INFO ] ✅ Using existing authentication
|
||||
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
|
||||
warnings.warn(
|
||||
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/player-feedback HTTP/1.1" 200 None
|
||||
^C[2026-01-17 22:09:12] 🛑 Watchdog received stop signal
|
||||
pi@rpi-tvcanba1:~/Desktop/Kiwy-Signage $
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user