Compare commits
12 Commits
2142ed7629
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e348c79113 | ||
|
|
b8d7a2e405 | ||
|
|
8a638acdd8 | ||
|
|
02ab142d96 | ||
|
|
a5e3a0c522 | ||
|
|
25824d4728 | ||
|
|
201a8fae97 | ||
|
|
1ea1490e60 | ||
|
|
f9fcb9f121 | ||
|
|
fbd32d7fb8 | ||
|
|
8123b9a99f | ||
|
|
3fcae8ea5e |
@@ -17,7 +17,7 @@ See `docs/rg35xx-plus.md` for target device details, boot chain, and deployment.
|
||||
```sh
|
||||
make # native build — uses pkg-config
|
||||
make clean # remove build artifacts
|
||||
./build/sdlamp2 [audio_directory]
|
||||
./build/sdlamp2 audio # run with the repo's audio directory
|
||||
```
|
||||
|
||||
Building for the arm64 target device via the Docker container (from the `docker-arm64/` directory):
|
||||
@@ -34,7 +34,7 @@ No test suite, no linter.
|
||||
|
||||
## Architecture
|
||||
|
||||
Single-file C program: `src/sdlamp2.c` (~650 lines). One generated header: `src/controls_png.h` (embedded PNG byte array — regenerate with `./tools/embed_png.py assets/controls.png src/controls_png.h` if the spritesheet changes).
|
||||
Single-file C program: `src/sdlamp2.c` (~650 lines). One generated header: `src/controls_png.h` (embedded PNG byte array — regenerate with `python3 tools/embed_png.py assets/controls.png src/controls_png.h` if the spritesheet changes). Skin template: `python3 tools/gen_skin_template.py [output.png]` generates a labeled grid template for creating custom spritesheets (requires Pillow). Device-specific scripts live in `device/rg35xx/`.
|
||||
|
||||
Key sections in order:
|
||||
- **Decoder struct** — holds all FFmpeg state (format/codec contexts, swr resampler, album art texture)
|
||||
@@ -56,3 +56,4 @@ Uses SDL2 (not SDL3). Uses `#if LIBAVUTIL_VERSION_INT` preprocessor checks to su
|
||||
- Non-fatal errors go to stderr and continue; fatal errors (SDL init failures) abort via `panic_and_abort()`
|
||||
- Update the changelog in `docs/sdlamp2-fsd.md` when making changes
|
||||
- Never run privileged Docker containers or make system-wide changes without explicit approval; explain what's needed and let the owner do it manually
|
||||
- Never install global Python packages; use a temporary venv in `/tmp` when Python dependencies are needed (e.g. `python3 -m venv /tmp/venv && source /tmp/venv/bin/activate && pip install ...`)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 10 KiB |
BIN
assets/controls_template.xcf
Normal file
BIN
assets/controls_template.xcf
Normal file
Binary file not shown.
BIN
assets/skin_template.png
Normal file
BIN
assets/skin_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
253
device/rg35xx/rg35xx-screen-monitor.py
Executable file
253
device/rg35xx/rg35xx-screen-monitor.py
Executable file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Screen idle timeout, power button toggle, and long-press shutdown for RG35XX Plus.
|
||||
|
||||
Monitors /dev/input/event{0,1,2} for activity. Turns off the screen
|
||||
(via Allwinner /dev/disp SET_BRIGHTNESS ioctl) after 15s of no input.
|
||||
Any input event (keys, d-pad, joystick) wakes the screen. While the
|
||||
screen is off, inputs are grabbed (EVIOCGRAB) so SDL in sdlamp2 does
|
||||
not receive the wake event.
|
||||
|
||||
A short power button press (<2s) toggles the screen on/off. A long
|
||||
press (3s+) triggers clean shutdown. If the device is idle (no input
|
||||
and no audio playback) for 10 minutes, it auto-shuts down to save
|
||||
battery.
|
||||
|
||||
Launched by rg35xx-wrapper.sh alongside sdlamp2. Killed on cleanup.
|
||||
|
||||
Usage: rg35xx-screen-monitor.py <sdlamp2_pid>
|
||||
"""
|
||||
import fcntl
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
IDLE_TIMEOUT = 15 # seconds — screen off after no input
|
||||
IDLE_SHUTDOWN_TIMEOUT = 600 # seconds — auto-shutdown after no input AND no playback
|
||||
POWER_SHORT_THRESHOLD = 2.0 # seconds — short press if released before this
|
||||
POWER_LONG_THRESHOLD = 3.0 # seconds — shutdown if held this long
|
||||
|
||||
# Allwinner /dev/disp ioctl commands
|
||||
DISP_GET_BRIGHTNESS = 0x103
|
||||
DISP_SET_BRIGHTNESS = 0x102
|
||||
|
||||
# Input event constants
|
||||
EV_SYN = 0
|
||||
EV_KEY = 1
|
||||
KEY_POWER = 116
|
||||
|
||||
# EVIOCGRAB — exclusive access to input device (_IOW('E', 0x90, int))
|
||||
EVIOCGRAB = 0x40044590
|
||||
|
||||
AUDIO_EXTENSIONS = ('.m4a', '.mp3', '.wav', '.ogg')
|
||||
|
||||
# struct input_event on aarch64: struct timeval (2x long=8 bytes each) + __u16 type + __u16 code + __s32 value
|
||||
INPUT_EVENT_FORMAT = "@llHHi"
|
||||
INPUT_EVENT_SIZE = struct.calcsize(INPUT_EVENT_FORMAT)
|
||||
|
||||
EVENT_DEVICES = ["/dev/input/event0", "/dev/input/event1", "/dev/input/event2"]
|
||||
|
||||
|
||||
def disp_ioctl(fd, cmd, screen=0, value=0):
|
||||
# Allwinner disp ioctls take an array of 4 unsigned longs as arg
|
||||
args = struct.pack("@4L", screen, value, 0, 0)
|
||||
result = fcntl.ioctl(fd, cmd, args)
|
||||
return struct.unpack("@4L", result)[0]
|
||||
|
||||
|
||||
def get_brightness(disp_fd):
|
||||
return disp_ioctl(disp_fd, DISP_GET_BRIGHTNESS)
|
||||
|
||||
|
||||
def set_brightness(disp_fd, value):
|
||||
disp_ioctl(disp_fd, DISP_SET_BRIGHTNESS, value=value)
|
||||
|
||||
|
||||
def grab_inputs(event_fds, grab):
|
||||
"""Grab or release exclusive access to input devices."""
|
||||
for fd in event_fds:
|
||||
try:
|
||||
fcntl.ioctl(fd, EVIOCGRAB, 1 if grab else 0)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def get_audio_file_pos(pid):
|
||||
"""Return the file offset of sdlamp2's open audio file, or None."""
|
||||
fd_dir = f"/proc/{pid}/fd"
|
||||
try:
|
||||
for fd_name in os.listdir(fd_dir):
|
||||
try:
|
||||
target = os.readlink(f"{fd_dir}/{fd_name}")
|
||||
if any(target.endswith(ext) for ext in AUDIO_EXTENSIONS):
|
||||
with open(f"/proc/{pid}/fdinfo/{fd_name}") as f:
|
||||
for line in f:
|
||||
if line.startswith("pos:"):
|
||||
return int(line.split()[1])
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: rg35xx-screen-monitor.py <sdlamp2_pid>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sdlamp2_pid = int(sys.argv[1])
|
||||
|
||||
disp_fd = os.open("/dev/disp", os.O_RDWR)
|
||||
original_brightness = get_brightness(disp_fd)
|
||||
if original_brightness == 0:
|
||||
original_brightness = 50 # sensible default if already off
|
||||
|
||||
screen_on = True
|
||||
power_press_time = None
|
||||
|
||||
# Restore brightness and release grabs on exit so goodbye.png is visible during shutdown
|
||||
def restore_and_exit(signum, frame):
|
||||
grab_inputs(event_fds, False)
|
||||
if not screen_on:
|
||||
set_brightness(disp_fd, original_brightness)
|
||||
os.close(disp_fd)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, restore_and_exit)
|
||||
signal.signal(signal.SIGINT, restore_and_exit)
|
||||
|
||||
# Open input devices (non-blocking)
|
||||
event_fds = []
|
||||
for path in EVENT_DEVICES:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
event_fds.append(fd)
|
||||
except OSError as e:
|
||||
print(f"screen-monitor: cannot open {path}: {e}", file=sys.stderr)
|
||||
|
||||
if not event_fds:
|
||||
print("screen-monitor: no input devices available, exiting", file=sys.stderr)
|
||||
os.close(disp_fd)
|
||||
sys.exit(1)
|
||||
|
||||
# Idle auto-shutdown state
|
||||
last_active_time = time.monotonic()
|
||||
last_audio_pos = get_audio_file_pos(sdlamp2_pid)
|
||||
|
||||
while True:
|
||||
# Dynamic timeout: if power button is held, shorten timeout to detect 3s mark
|
||||
timeout = IDLE_TIMEOUT
|
||||
if power_press_time is not None:
|
||||
remaining = POWER_LONG_THRESHOLD - (time.monotonic() - power_press_time)
|
||||
if remaining <= 0:
|
||||
# Already past threshold — trigger shutdown now
|
||||
grab_inputs(event_fds, False)
|
||||
touch_and_shutdown(disp_fd, original_brightness, screen_on, sdlamp2_pid)
|
||||
return
|
||||
timeout = min(timeout, remaining)
|
||||
|
||||
readable, _, _ = select.select(event_fds, [], [], timeout)
|
||||
|
||||
# Check long-press threshold (whether select returned due to timeout or input)
|
||||
if power_press_time is not None:
|
||||
held = time.monotonic() - power_press_time
|
||||
if held >= POWER_LONG_THRESHOLD:
|
||||
grab_inputs(event_fds, False)
|
||||
touch_and_shutdown(disp_fd, original_brightness, screen_on, sdlamp2_pid)
|
||||
return
|
||||
|
||||
if not readable:
|
||||
# Timeout with no input — turn off screen if idle
|
||||
if screen_on and power_press_time is None:
|
||||
set_brightness(disp_fd, 0)
|
||||
screen_on = False
|
||||
grab_inputs(event_fds, True)
|
||||
|
||||
# Check audio playback activity for idle auto-shutdown
|
||||
audio_pos = get_audio_file_pos(sdlamp2_pid)
|
||||
if audio_pos is not None and audio_pos != last_audio_pos:
|
||||
last_active_time = time.monotonic()
|
||||
last_audio_pos = audio_pos
|
||||
|
||||
# Auto-shutdown if idle long enough (no input + no playback)
|
||||
if time.monotonic() - last_active_time >= IDLE_SHUTDOWN_TIMEOUT:
|
||||
grab_inputs(event_fds, False)
|
||||
touch_and_shutdown(disp_fd, original_brightness, screen_on, sdlamp2_pid)
|
||||
return
|
||||
continue
|
||||
|
||||
# Process input events from all readable fds
|
||||
any_activity = False
|
||||
for fd in readable:
|
||||
while True:
|
||||
try:
|
||||
data = os.read(fd, INPUT_EVENT_SIZE)
|
||||
except BlockingIOError:
|
||||
break
|
||||
if len(data) < INPUT_EVENT_SIZE:
|
||||
break
|
||||
|
||||
_sec, _usec, ev_type, ev_code, ev_value = struct.unpack(
|
||||
INPUT_EVENT_FORMAT, data
|
||||
)
|
||||
|
||||
if ev_type == EV_SYN:
|
||||
continue
|
||||
|
||||
# Power button handling (EV_KEY only)
|
||||
if ev_type == EV_KEY and ev_code == KEY_POWER:
|
||||
if ev_value == 1: # press
|
||||
power_press_time = time.monotonic()
|
||||
elif ev_value == 0 and power_press_time is not None: # release
|
||||
hold_duration = time.monotonic() - power_press_time
|
||||
power_press_time = None
|
||||
if hold_duration < POWER_SHORT_THRESHOLD:
|
||||
# Short press — toggle screen
|
||||
if screen_on:
|
||||
set_brightness(disp_fd, 0)
|
||||
screen_on = False
|
||||
grab_inputs(event_fds, True)
|
||||
else:
|
||||
grab_inputs(event_fds, False)
|
||||
set_brightness(disp_fd, original_brightness)
|
||||
screen_on = True
|
||||
# Between SHORT and LONG threshold: ignore (release before 3s)
|
||||
continue
|
||||
|
||||
any_activity = True
|
||||
|
||||
# Any input activity resets idle shutdown timer
|
||||
if any_activity:
|
||||
last_active_time = time.monotonic()
|
||||
|
||||
# Any activity wakes screen (d-pad, face buttons, etc.)
|
||||
if any_activity and not screen_on:
|
||||
grab_inputs(event_fds, False)
|
||||
set_brightness(disp_fd, original_brightness)
|
||||
screen_on = True
|
||||
|
||||
|
||||
def touch_and_shutdown(disp_fd, original_brightness, screen_on, sdlamp2_pid):
|
||||
"""Signal sdlamp2 to exit and flag for shutdown.
|
||||
|
||||
Caller must release EVIOCGRAB before calling this.
|
||||
"""
|
||||
if not screen_on:
|
||||
set_brightness(disp_fd, original_brightness)
|
||||
os.close(disp_fd)
|
||||
try:
|
||||
open("/tmp/.sdlamp2_shutdown", "w").close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.kill(sdlamp2_pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,10 +4,9 @@
|
||||
#
|
||||
# Launched by dmenu_ln instead of sdlamp2 directly. Handles device-specific
|
||||
# concerns that don't belong in the player binary:
|
||||
# 1. Monitor power button and trigger clean shutdown
|
||||
# 1. Screen idle timeout, power button screen toggle, and long-press shutdown
|
||||
# 2. Display the stock firmware's shutdown screen (goodbye.png → /dev/fb0)
|
||||
# 3. Screen idle timeout (off after 15s) and power button screen toggle
|
||||
# 4. Launch sdlamp2 as the main process
|
||||
# 3. Launch sdlamp2 as the main process
|
||||
#
|
||||
# Install: copy to /mnt/vendor/bin/rg35xx-wrapper.sh
|
||||
# Config: set CMD in dmenu_ln to point here instead of sdlamp2 directly
|
||||
@@ -21,63 +20,42 @@ SCREEN_MONITOR="/mnt/vendor/bin/rg35xx-screen-monitor.py"
|
||||
# works fine even when sdlamp2 replaces the stock menu. Hotspot/AP mode isn't
|
||||
# needed — SSH access works over the shared network.
|
||||
|
||||
# --- Power button monitor ---
|
||||
# axp2202-pek on /dev/input/event0 sends KEY_POWER (code 116).
|
||||
# logind has HandlePowerKey=ignore, so we handle it here.
|
||||
POWER_EVENT_DEV="/dev/input/event0"
|
||||
|
||||
monitor_power_button() {
|
||||
POWER_TIMER_PID=""
|
||||
evtest "$POWER_EVENT_DEV" 2>/dev/null | while read -r line; do
|
||||
case "$line" in
|
||||
*"code 116"*"value 1"*)
|
||||
# Power button pressed — start 3-second hold timer
|
||||
( trap 'exit 0' TERM; sleep 3; touch /tmp/.sdlamp2_shutdown; kill -TERM "$SDLAMP2_PID" 2>/dev/null ) &
|
||||
POWER_TIMER_PID=$!
|
||||
;;
|
||||
*"code 116"*"value 0"*)
|
||||
# Power button released — cancel timer if still running
|
||||
if [ -n "$POWER_TIMER_PID" ]; then
|
||||
kill "$POWER_TIMER_PID" 2>/dev/null
|
||||
wait "$POWER_TIMER_PID" 2>/dev/null
|
||||
POWER_TIMER_PID=""
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# --- Screen idle timeout + power button screen toggle ---
|
||||
python3 "$SCREEN_MONITOR" &
|
||||
SCREEN_MONITOR_PID=$!
|
||||
|
||||
# --- Launch sdlamp2 ---
|
||||
# Run in background so we can capture PID for the power button monitor.
|
||||
"$SDLAMP2" "$AUDIO_DIR" &
|
||||
SDLAMP2_PID=$!
|
||||
|
||||
monitor_power_button &
|
||||
MONITOR_PID=$!
|
||||
# --- Screen monitor (idle timeout + power button toggle + long-press shutdown) ---
|
||||
# Must launch after sdlamp2 so PID is available. Reads /dev/input/event0
|
||||
# directly for power button events — no evtest dependency.
|
||||
python3 "$SCREEN_MONITOR" "$SDLAMP2_PID" &
|
||||
SCREEN_MONITOR_PID=$!
|
||||
|
||||
# Wait for sdlamp2 to finish (signal or normal exit).
|
||||
wait "$SDLAMP2_PID"
|
||||
SDLAMP2_EXIT=$?
|
||||
|
||||
# --- Cleanup ---
|
||||
# Kill the screen monitor (SIGTERM restores brightness) and power button monitor.
|
||||
# Kill the screen monitor (SIGTERM restores brightness).
|
||||
kill "$SCREEN_MONITOR_PID" 2>/dev/null
|
||||
wait "$SCREEN_MONITOR_PID" 2>/dev/null
|
||||
kill "$MONITOR_PID" 2>/dev/null
|
||||
|
||||
# If this was a shutdown, call poweroff and block so the loadapp.sh restart
|
||||
# loop doesn't relaunch dmenu_ln (which would take over the framebuffer and
|
||||
# overwrite sdlamp2's shutdown screen).
|
||||
if [ -f /tmp/.sdlamp2_shutdown ]; then
|
||||
rm -f /tmp/.sdlamp2_shutdown
|
||||
# Display the stock firmware's shutdown screen via framebuffer.
|
||||
# Restore backlight brightness before writing the shutdown screen.
|
||||
# The screen monitor's SIGTERM handler tries to restore brightness, but
|
||||
# the Allwinner /dev/disp driver resets it to 0 when the fd is closed.
|
||||
# Re-set it here so goodbye.png is actually visible.
|
||||
# Then display the stock firmware's shutdown screen via framebuffer.
|
||||
# goodbye.png is 640x480 RGB — exactly matches the display.
|
||||
# /dev/fb0 is 32bpp BGRA, so we swap R/B channels and write raw pixels.
|
||||
python3 -c "
|
||||
import struct, fcntl
|
||||
disp = open('/dev/disp', 'wb')
|
||||
fcntl.ioctl(disp, 0x102, struct.pack('@4L', 0, 50, 0, 0))
|
||||
disp.close()
|
||||
from PIL import Image
|
||||
img = Image.open('/mnt/vendor/res1/shutdown/goodbye.png').convert('RGBA')
|
||||
r, g, b, a = img.split()
|
||||
@@ -143,8 +143,8 @@ The `dmenu_ln` script already supports switching the startup binary via config f
|
||||
|
||||
```sh
|
||||
scp build/sdlamp2 root@rg35xx:/mnt/vendor/bin/sdlamp2
|
||||
scp tools/rg35xx-wrapper.sh root@rg35xx:/mnt/vendor/bin/rg35xx-wrapper.sh
|
||||
scp tools/rg35xx-screen-monitor.py root@rg35xx:/mnt/vendor/bin/rg35xx-screen-monitor.py
|
||||
scp device/rg35xx/rg35xx-wrapper.sh root@rg35xx:/mnt/vendor/bin/rg35xx-wrapper.sh
|
||||
scp device/rg35xx/rg35xx-screen-monitor.py root@rg35xx:/mnt/vendor/bin/rg35xx-screen-monitor.py
|
||||
```
|
||||
|
||||
2. **Add the config check** to `/mnt/vendor/ctrl/dmenu_ln`. In the section where `CMD` overrides are checked (after the existing `muos.ini` / `vpRun.ini` checks, before the `app_scheduling` call), add:
|
||||
@@ -155,7 +155,7 @@ The `dmenu_ln` script already supports switching the startup binary via config f
|
||||
fi
|
||||
```
|
||||
|
||||
The wrapper script handles device-specific concerns (WiFi hotspot, power button monitoring) and launches sdlamp2 as its main foreground process. See `tools/rg35xx-wrapper.sh` for details.
|
||||
The wrapper script handles device-specific concerns (WiFi hotspot, power button monitoring) and launches sdlamp2 as its main foreground process. See `device/rg35xx/rg35xx-wrapper.sh` for details.
|
||||
|
||||
3. **Enable sdlamp2 on boot:**
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Version | 1.0 |
|
||||
| Status | Draft |
|
||||
| Created | 2026-02-10 |
|
||||
| Updated | 2026-02-13 |
|
||||
| Updated | 2026-02-15 |
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
@@ -43,6 +43,58 @@ This document specifies the functional requirements for an SDL2 based media play
|
||||
|
||||
## 6. Changelog
|
||||
|
||||
### 2026-02-15 — Transparent controls spritesheet support
|
||||
|
||||
- **Alpha blending on controls texture**: `SDL_SetTextureBlendMode(controls_texture, SDL_BLENDMODE_BLEND)` enables alpha transparency for the controls spritesheet. Sprite icons now float cleanly on any background color instead of showing white cell backgrounds.
|
||||
- **Transparent skin template**: `gen_skin_template.py` now generates cells with transparent backgrounds (RGBA) instead of white. Gutters use bright magenta (`#FF00FF`) so they're clearly distinguishable from transparent content areas.
|
||||
|
||||
### 2026-02-14 — Skin template system and device script reorganization
|
||||
|
||||
- **Skin template generator**: New `tools/gen_skin_template.py` (requires Pillow) generates a 642x420 PNG template showing the sprite grid layout with labeled gutters. Skin creators can draw over the white 200x200 cells; the 20px gray gutters (never rendered by the app) identify each cell's purpose.
|
||||
- **Separate Prev sprite**: `prev_sprite` now uses the bottom-center cell `{220, 220}` instead of sharing the bottom-right cell with `next_sprite`. This gives Prev and Next distinct sprites in the spritesheet.
|
||||
- **Device scripts moved**: `rg35xx-wrapper.sh` and `rg35xx-screen-monitor.py` moved from `tools/` to `device/rg35xx/`, separating device-specific scripts from dev tools.
|
||||
|
||||
### 2026-02-14 — Softer background, remove panel divider
|
||||
|
||||
- **Background color**: Changed from white (`#FFFFFF`) to a medium gray (`#979797`) for a gentler appearance.
|
||||
- **Remove divider**: Removed the vertical separator line between the controls panel and the album art.
|
||||
|
||||
### 2026-02-14 — Fix residual audio on cassette switch
|
||||
|
||||
- **Clear device queue on switch**: `switch_file()` now calls `SDL_ClearQueuedAudio()` after pausing the audio device, preventing a brief snippet of the previous cassette from playing when the new one starts.
|
||||
|
||||
### 2026-02-14 — Split-screen layout with artwork focus
|
||||
|
||||
- **Vertical left panel**: Transport controls (Prev, Rewind, Play/Stop, FF, Next) are stacked vertically in a 200px-wide left panel. Play/Stop is slightly larger (72x72) at the vertical center; other buttons are 56x56.
|
||||
- **Full-height artwork**: Album art now fills the right panel (420x460 max bounds, centered at x=420, y=240), giving cassette covers nearly the full screen height instead of being constrained to the upper portion.
|
||||
- **Vertical navigation**: D-pad UP/DOWN (and keyboard arrows) now navigate between buttons vertically instead of LEFT/RIGHT horizontally, matching the new stacked layout.
|
||||
- **Dedicated volume controls**: Volume is no longer a focusable UI element. Adjusted via `+`/`-` keys, `SDLK_VOLUMEUP`/`SDLK_VOLUMEDOWN`, or controller shoulder buttons (L1/R1).
|
||||
- **Sprite scaling quality**: Linear filtering (`SDL_HINT_RENDER_SCALE_QUALITY`) enabled for smoother downscaling of 200x200 sprite source to 56x56 button destinations.
|
||||
- **No-art placeholder**: When a file has no embedded album art, a circle sprite from the spritesheet is rendered as a placeholder in the right panel.
|
||||
- **Thin progress bar**: Progress bar moved to the bottom of the left panel (160x4px) as a subtle position indicator.
|
||||
|
||||
### 2026-02-13 — Fix power button screen toggle regression
|
||||
|
||||
- **Power button screen off stays off**: Fixed regression from fbd32d7 where short-pressing the power button to turn off the screen would instantly turn it back on. The generic wake logic (`any_activity`) was being triggered by power button events themselves. Moved `any_activity = True` below the power button handler's `continue` so power events are handled exclusively by the power button handler and don't trigger the wake path.
|
||||
|
||||
### 2026-02-13 — Screen wake fixes and idle auto-shutdown
|
||||
|
||||
- **D-pad wakes screen**: The screen monitor now wakes on any input event type (EV_ABS, EV_KEY, etc.), not just EV_KEY. This fixes d-pad presses (which generate EV_ABS hat events) not waking the screen.
|
||||
- **Wake button doesn't act in app**: Uses EVIOCGRAB to take exclusive access to input devices while the screen is off. SDL in sdlamp2 receives no events while grabbed, so the button press that wakes the screen doesn't also trigger play/stop or switch cassettes. Grabs are released when the screen turns back on.
|
||||
- **Idle auto-shutdown**: After 10 minutes of no input AND no audio playback, the device auto-shuts down to save battery. Playback detection works by monitoring the audio file's read position via `/proc/<pid>/fdinfo/` — if the file offset is advancing, audio is being decoded. The timer resets on any input event or any detected playback activity.
|
||||
- **Goodbye screen on auto-shutdown**: The wrapper now restores backlight brightness via `/dev/disp` `DISP_SET_BRIGHTNESS` ioctl before writing `goodbye.png` to `/dev/fb0`. Previously the screen monitor's SIGTERM handler restored brightness, but the Allwinner driver resets it to 0 when the fd is closed, so the goodbye screen was never visible on idle auto-shutdown.
|
||||
|
||||
### 2026-02-13 — Remember last cassette and pause on switch
|
||||
|
||||
- **Remember last cassette**: The current cassette filename is saved to `last_cassette.txt` in the audio directory on every file switch. On startup, the player resumes the last-loaded cassette instead of always starting with the first file. Falls back to the first file if the saved file is missing or not found.
|
||||
- **Pause on cassette switch**: Switching cassettes (prev/next) now always lands in a paused state, even if the player was playing. This avoids the jarring effect of immediately resuming from a saved position in a different cassette.
|
||||
|
||||
### 2026-02-13 — Fix power button shutdown regression
|
||||
|
||||
- **Consolidated input handling**: Power button long-press shutdown is now handled by `rg35xx-screen-monitor.py` instead of a separate `evtest`-based monitor in the wrapper. Both monitors were reading `/dev/input/event0` simultaneously, causing the `evtest` parser to miss power button events on the device's Linux 4.9 kernel.
|
||||
- **Timer-based long press**: The screen monitor uses a dynamic `select()` timeout to detect the 3-second hold threshold while the button is still held, rather than waiting for release. On long press, it touches `/tmp/.sdlamp2_shutdown` and sends SIGTERM to sdlamp2.
|
||||
- **Removed evtest dependency**: The `monitor_power_button()` function and `evtest` pipe are removed from `rg35xx-wrapper.sh`. The screen monitor accepts the sdlamp2 PID as a command-line argument and launches after sdlamp2.
|
||||
|
||||
### 2026-02-13 — Screen idle timeout and power button toggle
|
||||
|
||||
- **Screen idle timeout**: New Python screen monitor (`rg35xx-screen-monitor.py`) turns off the display after 15 seconds of no input on any device. Audio continues playing. Any button press wakes the screen.
|
||||
|
||||
2771
src/controls_png.h
2771
src/controls_png.h
File diff suppressed because it is too large
Load Diff
142
src/sdlamp2.c
142
src/sdlamp2.c
@@ -66,13 +66,12 @@ static int current_file_index = 0;
|
||||
|
||||
/* --- Focus / navigation --- */
|
||||
|
||||
#define FOCUS_VOLUME 0
|
||||
#define FOCUS_PREV 1
|
||||
#define FOCUS_REWIND 2
|
||||
#define FOCUS_PLAYSTOP 3
|
||||
#define FOCUS_FF 4
|
||||
#define FOCUS_NEXT 5
|
||||
#define FOCUS_COUNT 6
|
||||
#define FOCUS_PREV 0
|
||||
#define FOCUS_REWIND 1
|
||||
#define FOCUS_PLAYSTOP 2
|
||||
#define FOCUS_FF 3
|
||||
#define FOCUS_NEXT 4
|
||||
#define FOCUS_COUNT 5
|
||||
|
||||
static float volume = 0.5f;
|
||||
static int focus_index = FOCUS_PLAYSTOP;
|
||||
@@ -197,6 +196,37 @@ static void adjust_volume(float delta) {
|
||||
save_volume();
|
||||
}
|
||||
|
||||
/* --- Last cassette persistence --- */
|
||||
|
||||
static void save_last_cassette(const char* filename) {
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/last_cassette.txt", audio_dir);
|
||||
FILE* f = fopen(path, "w");
|
||||
if (f) {
|
||||
fprintf(f, "%s\n", filename);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
static int load_last_cassette(void) {
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/last_cassette.txt", audio_dir);
|
||||
FILE* f = fopen(path, "r");
|
||||
if (!f) return 0;
|
||||
char name[256];
|
||||
if (!fgets(name, sizeof(name), f)) {
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
fclose(f);
|
||||
char* nl = strchr(name, '\n');
|
||||
if (nl) *nl = '\0';
|
||||
for (int i = 0; i < num_audio_files; i++) {
|
||||
if (strcmp(audio_files[i], name) == 0) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- File scanning --- */
|
||||
|
||||
static int has_audio_extension(const char* name) {
|
||||
@@ -484,11 +514,15 @@ static void switch_file(int index) {
|
||||
decoder_seek(pos);
|
||||
}
|
||||
|
||||
save_last_cassette(current_file);
|
||||
|
||||
char title[768];
|
||||
snprintf(title, sizeof(title), "SDLamp2 - %s", current_file);
|
||||
SDL_SetWindowTitle(window, title);
|
||||
|
||||
SDL_PauseAudioDevice(audio_device, paused);
|
||||
paused = SDL_TRUE;
|
||||
SDL_PauseAudioDevice(audio_device, 1);
|
||||
SDL_ClearQueuedAudio(audio_device);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -562,30 +596,28 @@ int main(int argc, char** argv) {
|
||||
audio_dir[sizeof(audio_dir) - 1] = '\0';
|
||||
}
|
||||
|
||||
/* Volume slider (left of buttons) */
|
||||
const SDL_Rect volume_bg = {25, 390, 30, 80};
|
||||
|
||||
/* Button positions (bottom of window, centered) */
|
||||
const SDL_Rect prev_btn = {80, 390, 80, 80};
|
||||
const SDL_Rect rewind_btn = {180, 390, 80, 80};
|
||||
const SDL_Rect playstop_btn = {280, 390, 80, 80};
|
||||
const SDL_Rect ff_btn = {380, 390, 80, 80};
|
||||
const SDL_Rect next_btn = {480, 390, 80, 80};
|
||||
/* Left panel: buttons stacked vertically, centered at x=100 */
|
||||
const SDL_Rect prev_btn = {72, 34, 56, 56};
|
||||
const SDL_Rect rewind_btn = {72, 120, 56, 56};
|
||||
const SDL_Rect playstop_btn = {64, 206, 72, 72};
|
||||
const SDL_Rect ff_btn = {72, 308, 56, 56};
|
||||
const SDL_Rect next_btn = {72, 394, 56, 56};
|
||||
|
||||
/* Array of focusable rects indexed by FOCUS_* constants */
|
||||
const SDL_Rect* focus_rects[FOCUS_COUNT] = {&volume_bg, &prev_btn, &rewind_btn,
|
||||
&playstop_btn, &ff_btn, &next_btn};
|
||||
const SDL_Rect* focus_rects[FOCUS_COUNT] = {&prev_btn, &rewind_btn, &playstop_btn, &ff_btn,
|
||||
&next_btn};
|
||||
|
||||
/* Sprite sheet source rects */
|
||||
const SDL_Rect rewind_sprite = {0, 0, 200, 200};
|
||||
const SDL_Rect play_sprite = {220, 0, 200, 200};
|
||||
const SDL_Rect ff_sprite = {440, 0, 200, 200};
|
||||
const SDL_Rect stop_sprite = {0, 220, 200, 200};
|
||||
const SDL_Rect prev_sprite = {440, 220, 200, 200}; /* same circle as next */
|
||||
const SDL_Rect prev_sprite = {220, 220, 200, 200};
|
||||
const SDL_Rect next_sprite = {440, 220, 200, 200};
|
||||
const SDL_Rect circle_sprite = {440, 220, 200, 200}; /* placeholder for no-art */
|
||||
|
||||
/* Progress bar area */
|
||||
const SDL_Rect progress_bg = {20, 360, 600, 15};
|
||||
/* Progress bar — thin bar at bottom of left panel */
|
||||
const SDL_Rect progress_bg = {20, 466, 160, 4};
|
||||
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER) != 0) {
|
||||
panic_and_abort("SDL_Init failed!", SDL_GetError());
|
||||
@@ -649,6 +681,8 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
}
|
||||
|
||||
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1");
|
||||
|
||||
SDL_Surface* controls_surface = IMG_Load("controls.png");
|
||||
if (!controls_surface) {
|
||||
SDL_RWops* rw = SDL_RWFromConstMem(controls_png_data, controls_png_size);
|
||||
@@ -665,6 +699,7 @@ int main(int argc, char** argv) {
|
||||
if (!controls_texture) {
|
||||
panic_and_abort("Could not create controls texture!", SDL_GetError());
|
||||
}
|
||||
SDL_SetTextureBlendMode(controls_texture, SDL_BLENDMODE_BLEND);
|
||||
|
||||
/* Handle SIGTERM/SIGINT for clean shutdown (save position before exit) */
|
||||
signal(SIGTERM, signal_handler);
|
||||
@@ -674,7 +709,7 @@ int main(int argc, char** argv) {
|
||||
scan_audio_files(audio_dir);
|
||||
volume = load_volume();
|
||||
if (num_audio_files > 0) {
|
||||
switch_file(0);
|
||||
switch_file(load_last_cassette());
|
||||
} else {
|
||||
SDL_SetWindowTitle(window, "SDLamp2 - No audio files found");
|
||||
}
|
||||
@@ -754,17 +789,20 @@ int main(int argc, char** argv) {
|
||||
|
||||
case SDL_KEYDOWN:
|
||||
switch (e.key.keysym.sym) {
|
||||
case SDLK_LEFT:
|
||||
case SDLK_UP:
|
||||
focus_index = (focus_index - 1 + FOCUS_COUNT) % FOCUS_COUNT;
|
||||
break;
|
||||
case SDLK_RIGHT:
|
||||
case SDLK_DOWN:
|
||||
focus_index = (focus_index + 1) % FOCUS_COUNT;
|
||||
break;
|
||||
case SDLK_UP:
|
||||
if (focus_index == FOCUS_VOLUME) adjust_volume(0.05f);
|
||||
case SDLK_EQUALS:
|
||||
case SDLK_PLUS:
|
||||
case SDLK_VOLUMEUP:
|
||||
adjust_volume(0.05f);
|
||||
break;
|
||||
case SDLK_DOWN:
|
||||
if (focus_index == FOCUS_VOLUME) adjust_volume(-0.05f);
|
||||
case SDLK_MINUS:
|
||||
case SDLK_VOLUMEDOWN:
|
||||
adjust_volume(-0.05f);
|
||||
break;
|
||||
case SDLK_RETURN:
|
||||
case SDLK_KP_ENTER:
|
||||
@@ -775,17 +813,17 @@ int main(int argc, char** argv) {
|
||||
|
||||
case SDL_CONTROLLERBUTTONDOWN:
|
||||
switch (e.cbutton.button) {
|
||||
case SDL_CONTROLLER_BUTTON_DPAD_LEFT:
|
||||
case SDL_CONTROLLER_BUTTON_DPAD_UP:
|
||||
focus_index = (focus_index - 1 + FOCUS_COUNT) % FOCUS_COUNT;
|
||||
break;
|
||||
case SDL_CONTROLLER_BUTTON_DPAD_RIGHT:
|
||||
case SDL_CONTROLLER_BUTTON_DPAD_DOWN:
|
||||
focus_index = (focus_index + 1) % FOCUS_COUNT;
|
||||
break;
|
||||
case SDL_CONTROLLER_BUTTON_DPAD_UP:
|
||||
if (focus_index == FOCUS_VOLUME) adjust_volume(0.05f);
|
||||
case SDL_CONTROLLER_BUTTON_LEFTSHOULDER:
|
||||
adjust_volume(-0.05f);
|
||||
break;
|
||||
case SDL_CONTROLLER_BUTTON_DPAD_DOWN:
|
||||
if (focus_index == FOCUS_VOLUME) adjust_volume(-0.05f);
|
||||
case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER:
|
||||
adjust_volume(0.05f);
|
||||
break;
|
||||
case SDL_CONTROLLER_BUTTON_A:
|
||||
activate_focused_button();
|
||||
@@ -795,14 +833,10 @@ int main(int argc, char** argv) {
|
||||
|
||||
case SDL_JOYHATMOTION: {
|
||||
Uint8 hat = e.jhat.value;
|
||||
if (hat & SDL_HAT_LEFT) {
|
||||
if (hat & SDL_HAT_UP) {
|
||||
focus_index = (focus_index - 1 + FOCUS_COUNT) % FOCUS_COUNT;
|
||||
} else if (hat & SDL_HAT_RIGHT) {
|
||||
focus_index = (focus_index + 1) % FOCUS_COUNT;
|
||||
} else if (hat & SDL_HAT_UP) {
|
||||
if (focus_index == FOCUS_VOLUME) adjust_volume(0.05f);
|
||||
} else if (hat & SDL_HAT_DOWN) {
|
||||
if (focus_index == FOCUS_VOLUME) adjust_volume(-0.05f);
|
||||
focus_index = (focus_index + 1) % FOCUS_COUNT;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -882,19 +916,23 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
/* --- Rendering --- */
|
||||
SDL_SetRenderDrawColor(renderer, 0xFF, 0xFF, 0xFF, 0xFF);
|
||||
SDL_SetRenderDrawColor(renderer, 0x97, 0x97, 0x97, 0xFF);
|
||||
SDL_RenderClear(renderer);
|
||||
|
||||
/* Album art (centered, aspect-preserving, in upper area) */
|
||||
/* Album art (right panel, centered, aspect-preserving) */
|
||||
if (decoder.album_art && decoder.art_width > 0 && decoder.art_height > 0) {
|
||||
int max_w = 600, max_h = 340;
|
||||
int max_w = 420, max_h = 460;
|
||||
float scale_w = (float)max_w / decoder.art_width;
|
||||
float scale_h = (float)max_h / decoder.art_height;
|
||||
float scale = scale_w < scale_h ? scale_w : scale_h;
|
||||
int draw_w = (int)(decoder.art_width * scale);
|
||||
int draw_h = (int)(decoder.art_height * scale);
|
||||
SDL_Rect art_rect = {(640 - draw_w) / 2, (350 - draw_h) / 2, draw_w, draw_h};
|
||||
SDL_Rect art_rect = {420 - draw_w / 2, 240 - draw_h / 2, draw_w, draw_h};
|
||||
SDL_RenderCopy(renderer, decoder.album_art, NULL, &art_rect);
|
||||
} else {
|
||||
/* No-art placeholder: circle sprite scaled up in right panel */
|
||||
SDL_Rect placeholder = {420 - 100, 240 - 100, 200, 200};
|
||||
SDL_RenderCopy(renderer, controls_texture, &circle_sprite, &placeholder);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
@@ -912,16 +950,6 @@ int main(int argc, char** argv) {
|
||||
SDL_RenderFillRect(renderer, &fill);
|
||||
}
|
||||
|
||||
/* Volume slider */
|
||||
SDL_SetRenderDrawColor(renderer, 0xC0, 0xC0, 0xC0, 0xFF);
|
||||
SDL_RenderFillRect(renderer, &volume_bg);
|
||||
{
|
||||
int fill_h = (int)(volume_bg.h * volume);
|
||||
SDL_Rect vol_fill = {volume_bg.x, volume_bg.y + volume_bg.h - fill_h, volume_bg.w, fill_h};
|
||||
SDL_SetRenderDrawColor(renderer, 0x50, 0x50, 0x50, 0xFF);
|
||||
SDL_RenderFillRect(renderer, &vol_fill);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
SDL_RenderCopy(renderer, controls_texture, &prev_sprite, &prev_btn);
|
||||
SDL_RenderCopy(renderer, controls_texture, &rewind_sprite, &rewind_btn);
|
||||
@@ -930,11 +958,11 @@ int main(int argc, char** argv) {
|
||||
SDL_RenderCopy(renderer, controls_texture, &ff_sprite, &ff_btn);
|
||||
SDL_RenderCopy(renderer, controls_texture, &next_sprite, &next_btn);
|
||||
|
||||
/* Focus highlight — 3px blue border around focused element */
|
||||
/* Focus highlight — 3px red border around focused element */
|
||||
{
|
||||
const SDL_Rect r = *focus_rects[focus_index];
|
||||
const int t = 3;
|
||||
SDL_SetRenderDrawColor(renderer, 0x00, 0x80, 0xFF, 0xFF);
|
||||
SDL_SetRenderDrawColor(renderer, 0xFF, 0x00, 0x00, 0xFF);
|
||||
SDL_Rect top = {r.x - t, r.y - t, r.w + 2 * t, t};
|
||||
SDL_Rect bot = {r.x - t, r.y + r.h, r.w + 2 * t, t};
|
||||
SDL_Rect lft = {r.x - t, r.y, t, r.h};
|
||||
|
||||
96
tools/gen_skin_template.py
Normal file
96
tools/gen_skin_template.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
gen_skin_template.py — Generate a skin template PNG for sdlamp2.
|
||||
|
||||
Creates a 642x420 PNG showing the sprite grid layout with labeled gutters.
|
||||
Each 200x200 cell is transparent (ready to draw on). The 20px gutters between
|
||||
cells are a bright magenta so they're clearly distinguishable from content areas.
|
||||
|
||||
Grid layout:
|
||||
Col 0 (0-199) Col 1 (220-419) Col 2 (440-639)
|
||||
Row 0: REWIND PLAY FF
|
||||
----gutter y=200-219 with labels----
|
||||
Row 1: STOP PREV NEXT
|
||||
|
||||
Two extra pixels on the right (640-641) are gutter fill to reach 642px width,
|
||||
matching the spritesheet dimensions.
|
||||
|
||||
Usage: python3 tools/gen_skin_template.py [output.png]
|
||||
|
||||
Requires Pillow.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
CELL = 200
|
||||
GAP = 20
|
||||
COLS = 3
|
||||
ROWS = 2
|
||||
WIDTH = COLS * CELL + (COLS - 1) * GAP + 2 # 642
|
||||
HEIGHT = ROWS * CELL + (ROWS - 1) * GAP # 420
|
||||
|
||||
CELL_COLOR = (0, 0, 0, 0)
|
||||
GUTTER_COLOR = (255, 0, 255, 255)
|
||||
TEXT_COLOR = (255, 255, 255, 255)
|
||||
|
||||
LABELS = [
|
||||
["REWIND", "PLAY", "FF"],
|
||||
["STOP", "PREV", "NEXT"],
|
||||
]
|
||||
|
||||
|
||||
def cell_x(col):
|
||||
return col * (CELL + GAP)
|
||||
|
||||
|
||||
def cell_y(row):
|
||||
return row * (CELL + GAP)
|
||||
|
||||
|
||||
def main():
|
||||
output_path = sys.argv[1] if len(sys.argv) > 1 else "skin_template.png"
|
||||
|
||||
img = Image.new("RGBA", (WIDTH, HEIGHT), GUTTER_COLOR)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to load a small font for labels
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
|
||||
except OSError:
|
||||
try:
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 11)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw white cells
|
||||
for row in range(ROWS):
|
||||
for col in range(COLS):
|
||||
x = cell_x(col)
|
||||
y = cell_y(row)
|
||||
draw.rectangle([x, y, x + CELL - 1, y + CELL - 1], fill=CELL_COLOR)
|
||||
|
||||
# Label the horizontal gutter (y = 200..219)
|
||||
gutter_y = CELL # 200
|
||||
for col in range(COLS):
|
||||
cx = cell_x(col) + CELL // 2
|
||||
|
||||
# Row 0 label above center of gutter
|
||||
label_0 = LABELS[0][col]
|
||||
bbox = draw.textbbox((0, 0), label_0, font=font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((cx - tw // 2, gutter_y + 1), label_0, fill=TEXT_COLOR, font=font)
|
||||
|
||||
# Row 1 label below center of gutter
|
||||
label_1 = LABELS[1][col]
|
||||
bbox = draw.textbbox((0, 0), label_1, font=font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
th = bbox[3] - bbox[1]
|
||||
draw.text((cx - tw // 2, gutter_y + GAP - th - 2), label_1, fill=TEXT_COLOR, font=font)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"Skin template saved to {output_path} ({WIDTH}x{HEIGHT})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Screen idle timeout and power button toggle for RG35XX Plus.
|
||||
|
||||
Monitors /dev/input/event{0,1,2} for activity. Turns off the screen
|
||||
(via Allwinner /dev/disp SET_BRIGHTNESS ioctl) after 15s of no input.
|
||||
Any button press wakes the screen. A short power button press (<2s)
|
||||
toggles the screen on/off.
|
||||
|
||||
Launched by rg35xx-wrapper.sh alongside sdlamp2. Killed on cleanup.
|
||||
"""
|
||||
import fcntl
|
||||
import os
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
IDLE_TIMEOUT = 15 # seconds
|
||||
POWER_HOLD_THRESHOLD = 2.0 # seconds — short press if released before this
|
||||
|
||||
# Allwinner /dev/disp ioctl commands
|
||||
DISP_GET_BRIGHTNESS = 0x103
|
||||
DISP_SET_BRIGHTNESS = 0x102
|
||||
|
||||
# Input event constants
|
||||
EV_KEY = 1
|
||||
KEY_POWER = 116
|
||||
|
||||
# struct input_event on aarch64: struct timeval (2x long=8 bytes each) + __u16 type + __u16 code + __s32 value
|
||||
INPUT_EVENT_FORMAT = "@llHHi"
|
||||
INPUT_EVENT_SIZE = struct.calcsize(INPUT_EVENT_FORMAT)
|
||||
|
||||
EVENT_DEVICES = ["/dev/input/event0", "/dev/input/event1", "/dev/input/event2"]
|
||||
|
||||
|
||||
def disp_ioctl(fd, cmd, screen=0, value=0):
|
||||
# Allwinner disp ioctls take an array of 4 unsigned longs as arg
|
||||
args = struct.pack("@4L", screen, value, 0, 0)
|
||||
result = fcntl.ioctl(fd, cmd, args)
|
||||
return struct.unpack("@4L", result)[0]
|
||||
|
||||
|
||||
def get_brightness(disp_fd):
|
||||
return disp_ioctl(disp_fd, DISP_GET_BRIGHTNESS)
|
||||
|
||||
|
||||
def set_brightness(disp_fd, value):
|
||||
disp_ioctl(disp_fd, DISP_SET_BRIGHTNESS, value=value)
|
||||
|
||||
|
||||
def main():
|
||||
disp_fd = os.open("/dev/disp", os.O_RDWR)
|
||||
original_brightness = get_brightness(disp_fd)
|
||||
if original_brightness == 0:
|
||||
original_brightness = 50 # sensible default if already off
|
||||
|
||||
screen_on = True
|
||||
power_press_time = None
|
||||
|
||||
# Restore brightness on exit so goodbye.png is visible during shutdown
|
||||
def restore_and_exit(signum, frame):
|
||||
if not screen_on:
|
||||
set_brightness(disp_fd, original_brightness)
|
||||
os.close(disp_fd)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, restore_and_exit)
|
||||
signal.signal(signal.SIGINT, restore_and_exit)
|
||||
|
||||
# Open input devices (non-blocking)
|
||||
event_fds = []
|
||||
for path in EVENT_DEVICES:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
event_fds.append(fd)
|
||||
except OSError as e:
|
||||
print(f"screen-monitor: cannot open {path}: {e}", file=sys.stderr)
|
||||
|
||||
if not event_fds:
|
||||
print("screen-monitor: no input devices available, exiting", file=sys.stderr)
|
||||
os.close(disp_fd)
|
||||
sys.exit(1)
|
||||
|
||||
import select
|
||||
|
||||
while True:
|
||||
readable, _, _ = select.select(event_fds, [], [], IDLE_TIMEOUT)
|
||||
|
||||
if not readable:
|
||||
# Timeout — no input for IDLE_TIMEOUT seconds
|
||||
if screen_on:
|
||||
set_brightness(disp_fd, 0)
|
||||
screen_on = False
|
||||
continue
|
||||
|
||||
# Process input events from all readable fds
|
||||
for fd in readable:
|
||||
while True:
|
||||
try:
|
||||
data = os.read(fd, INPUT_EVENT_SIZE)
|
||||
except BlockingIOError:
|
||||
break
|
||||
if len(data) < INPUT_EVENT_SIZE:
|
||||
break
|
||||
|
||||
_sec, _usec, ev_type, ev_code, ev_value = struct.unpack(
|
||||
INPUT_EVENT_FORMAT, data
|
||||
)
|
||||
|
||||
if ev_type != EV_KEY:
|
||||
continue
|
||||
|
||||
# Power button handling
|
||||
if ev_code == KEY_POWER:
|
||||
if ev_value == 1: # press
|
||||
power_press_time = time.monotonic()
|
||||
elif ev_value == 0 and power_press_time is not None: # release
|
||||
hold_duration = time.monotonic() - power_press_time
|
||||
power_press_time = None
|
||||
if hold_duration < POWER_HOLD_THRESHOLD:
|
||||
# Short press — toggle screen
|
||||
if screen_on:
|
||||
set_brightness(disp_fd, 0)
|
||||
screen_on = False
|
||||
else:
|
||||
set_brightness(disp_fd, original_brightness)
|
||||
screen_on = True
|
||||
continue
|
||||
|
||||
# Any other key press — wake screen if off
|
||||
if ev_value == 1 and not screen_on:
|
||||
set_brightness(disp_fd, original_brightness)
|
||||
screen_on = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user