new fixes, yay

This commit is contained in:
trav
2026-01-29 15:20:01 -08:00
parent cafd4b6084
commit 48979b52f6
19 changed files with 679 additions and 53 deletions

176
docs/SCREENSAVER.md Normal file
View File

@ -0,0 +1,176 @@
# Custo Screensaver
A custom screensaver for the kiosk that displays animated "custo" text and manages display power.
## How It Works
The screensaver has two stages:
```
0 min ──────────────────> 10 min ──────────────────> 30 min
(user active) "custo" appears screen blanks
(wavy animation) (backlight off)
```
1. **Stage 1 - Animation** (after 10 minutes idle): A fullscreen black window appears with the word "custo" in large white text. The text gently waves up and down and fades in/out.
2. **Stage 2 - Screen Blank** (after 30 minutes total / 20 more minutes): The display backlight turns off completely to save power.
**Wake triggers**: Any of these will restore the screen and exit the screensaver:
- Touch/tap the screen
- Press any key
- Move the mouse significantly (>50 pixels)
## Components
| File | Location | Purpose |
|------|----------|---------|
| `custo-screensaver.sh` | `~/.local/bin/` | Monitors idle time via GNOME D-Bus, launches display |
| `custo-display.py` | `~/.local/bin/` | GTK4 app showing animated text, handles blanking |
| `custo-screensaver.desktop` | `~/.config/autostart/` | Starts the monitor on login |
| `90-backlight.rules` | `/etc/udev/rules.d/` | Allows non-root backlight control |
## Configuration
Edit `~/.local/bin/custo-screensaver.sh`:
```bash
start_after=10 # minutes until screensaver appears
blank_after=20 # additional minutes until screen blanks
```
To change the wave animation amplitude, edit `~/.local/bin/custo-display.py`:
```python
wave_offset = 60 * math.sin(self.time * 0.4) # ±60 pixels
```
## Installation
### Automatic
Run the install script from the repo:
```bash
./install.sh
```
This will:
- Copy screensaver files to `~/.local/bin/`
- Set up autostart
- Install the udev rule (requires sudo)
- Kill any existing screensaver instances
### Manual
1. Copy the scripts:
```bash
cp screensaver/custo-screensaver.sh ~/.local/bin/
cp screensaver/custo-display.py ~/.local/bin/
chmod +x ~/.local/bin/custo-screensaver.sh
chmod +x ~/.local/bin/custo-display.py
```
2. Set up autostart:
```bash
mkdir -p ~/.config/autostart
cp screensaver/custo-screensaver.desktop ~/.config/autostart/
```
3. Install udev rule for backlight control:
```bash
sudo cp screensaver/90-backlight.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
```
4. Add your user to the video group (if not already):
```bash
sudo usermod -aG video $USER
```
Log out and back in for group changes to take effect.
## Troubleshooting
### Check if screensaver is running
```bash
ps aux | grep custo-screensaver
```
Should show exactly ONE instance of `custo-screensaver.sh`.
### Kill all screensaver instances
```bash
pkill -f custo-screensaver.sh
pkill -f custo-display.py
```
### Test the screensaver manually
```bash
# Test the display (Ctrl+C or tap to exit)
python3 ~/.local/bin/custo-display.py --blank-after=1
# Start the monitor with short timeout for testing
start_after=1 ~/.local/bin/custo-screensaver.sh
```
### Screen doesn't blank (permission denied)
The backlight file requires write permission. Check:
```bash
ls -la /sys/class/backlight/intel_backlight/brightness
```
If it shows `-rw-r--r--` (no group write), the udev rule isn't active:
```bash
sudo udevadm control --reload-rules
sudo udevadm trigger
```
Or manually fix permissions:
```bash
sudo chgrp video /sys/class/backlight/intel_backlight/brightness
sudo chmod g+w /sys/class/backlight/intel_backlight/brightness
```
### Multiple instances running
The script now auto-kills duplicates on startup. If duplicates persist:
```bash
pkill -f custo-screensaver.sh
~/.local/bin/custo-screensaver.sh &
```
### Screensaver not starting on login
Check autostart is enabled:
```bash
cat ~/.config/autostart/custo-screensaver.desktop
```
The `X-GNOME-Autostart-enabled` should be `true`.
### Check idle time
```bash
dbus-send --print-reply --dest=org.gnome.Mutter.IdleMonitor \
/org/gnome/Mutter/IdleMonitor/Core \
org.gnome.Mutter.IdleMonitor.GetIdletime
```
Returns idle time in milliseconds.
## Dependencies
- GNOME/Mutter (for idle monitoring via D-Bus)
- GTK4 (for the display window)
- Python 3 with PyGObject
- `bc` (for bash arithmetic)

View File

@ -62,6 +62,46 @@ else
echo "Skipping Node.js dependencies (npm not found)"
fi
# Install screensaver
echo
echo "Installing screensaver..."
# Kill any existing screensaver instances
echo " Stopping existing screensaver instances..."
pkill -f custo-screensaver.sh 2>/dev/null || true
pkill -f custo-display.py 2>/dev/null || true
# Create directories
mkdir -p ~/.local/bin
mkdir -p ~/.config/autostart
# Copy screensaver files
echo " Copying screensaver files..."
cp screensaver/custo-screensaver.sh ~/.local/bin/
cp screensaver/custo-display.py ~/.local/bin/
chmod +x ~/.local/bin/custo-screensaver.sh
chmod +x ~/.local/bin/custo-display.py
# Set up autostart
cp screensaver/custo-screensaver.desktop ~/.config/autostart/
# Install udev rule for backlight control
echo " Installing udev rule for backlight control (requires sudo)..."
if [ -f screensaver/90-backlight.rules ]; then
sudo cp screensaver/90-backlight.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
fi
# Check if user is in video group
if ! groups | grep -q video; then
echo " Adding $USER to video group for backlight control..."
sudo usermod -aG video $USER
echo " NOTE: Log out and back in for group changes to take effect."
fi
echo " Screensaver installed. It will start automatically on next login."
echo
echo "=== Installation Complete ==="
echo
@ -74,4 +114,6 @@ echo " python kiosk.py"
echo
echo "Notes:"
echo " - Make sure ssb-server is running before using SSB features"
echo " - Screensaver: activates after 10 min idle, blanks screen after 30 min total"
echo " - See docs/SCREENSAVER.md for screensaver configuration"
echo

View File

@ -99,3 +99,12 @@ journalctl --user -u ssb-server.service --no-pager -n 50
```bash
ps aux | grep kiosk.py
```
### SSB Health Check (Automatic)
When users click "Generate Tag" or "Lookup Item", the kiosk automatically:
1. Runs `ssb-server whoami` to verify the server is running
2. Checks the response is valid JSON with an `id` field
3. If check fails, shows "SSB rebooting..." and runs `systemctl --user restart ssb-server.service`
4. Waits 2 seconds and retries `ssb-server whoami`
5. If still failing, shows a popup error asking user to contact kiosk steward

View File

@ -29,6 +29,7 @@ class Screen3(tk.Frame):
self.freeze_frame = None
self.countdown_text = None
self.last_photo = None
self.photo_taken = False
# Video feed
self.canvas = tk.Canvas(self, width=self.vid.get(cv2.CAP_PROP_FRAME_WIDTH), height=self.vid.get(cv2.CAP_PROP_FRAME_HEIGHT))
@ -105,6 +106,7 @@ class Screen3(tk.Frame):
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(rgb_image)
pil_image.save('freeze_frame.jpg')
self.photo_taken = True
self.display_taken_photo()
self.is_capturing = False
@ -116,6 +118,9 @@ class Screen3(tk.Frame):
self.canvas.create_image(0, 0, image=self.last_photo, anchor='nw')
def done(self):
if not self.photo_taken:
messagebox.showwarning("Photo Required", "Please take a photo first")
return
self.release_resources()
from kiosk.screens.description import Screen5
self.master.switch_frame(Screen5)
@ -184,9 +189,18 @@ class Screen14(tk.Frame):
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
self.frame_count = 0
self.TIMEOUT_SECONDS = 120 # 2 minutes idle timeout
self.start_time = time.time()
self.update_frame()
def update_frame(self):
# Check for idle timeout
if time.time() - self.start_time >= self.TIMEOUT_SECONDS:
self.cap.release()
from kiosk.screens.home import Screen0
self.master.switch_frame(Screen0)
return
ret, frame = self.cap.read()
self.frame_count += 1

View File

@ -14,7 +14,7 @@ class Screen5(tk.Frame):
master.add_home_button(self)
# Adding the information label
self.info_label = RoundedLabel(self, text="Please enter a description of your item (maybe some stories of its history or why it's important to you or identifying features). You may can use Markdown formatting. Once posted this text is on the internet FOREVER.", font=GlobalVars.TEXT_FONT, wraplength=900, bg='white')
self.info_label = RoundedLabel(self, text="Please enter a description of your item (maybe some stories of its history or why it's important to you or identifying features). You can use Markdown formatting. Once posted this text is on the internet FOREVER.", font=GlobalVars.TEXT_FONT, wraplength=900, bg='white')
self.info_label.pack(pady=40)
# Adding the text entry field

View File

@ -1,12 +1,15 @@
"""Home screen (Screen0)."""
import tkinter as tk
from tkinter import font as tkfont
from tkinter import messagebox
from PIL import Image, ImageTk
import os
import time
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel
from kiosk.utils import check_ssb_health, restart_ssb_service
class Screen0(tk.Frame):
@ -41,10 +44,52 @@ class Screen0(tk.Frame):
# Deferred imports to avoid circular dependencies
def go_to_generate_tag():
from kiosk.screens.intro import HaveYouGeneratedTagScreen
# Quick SSB health check
success, error = check_ssb_health()
if not success:
# Show reboot message, attempt restart
info_label.set_text("SSB rebooting...")
self.update() # Force UI update
restart_success, restart_error = restart_ssb_service()
if restart_success:
time.sleep(2) # Give service time to stabilize
success, error = check_ssb_health()
if not success:
info_label.set_text("this is a touchscreen kiosk\n\n more information on the project at\n\nwww.cust.ooo")
messagebox.showerror(
"SSB Error",
"SSB service is not functioning.\n\nPlease contact the kiosk steward for assistance."
)
return # Don't proceed to next screen
master.switch_frame(HaveYouGeneratedTagScreen)
def go_to_lookup():
from kiosk.screens.camera import Screen14
# Quick SSB health check
success, error = check_ssb_health()
if not success:
# Show reboot message, attempt restart
info_label.set_text("SSB rebooting...")
self.update() # Force UI update
restart_success, restart_error = restart_ssb_service()
if restart_success:
time.sleep(2) # Give service time to stabilize
success, error = check_ssb_health()
if not success:
info_label.set_text("this is a touchscreen kiosk\n\n more information on the project at\n\nwww.cust.ooo")
messagebox.showerror(
"SSB Error",
"SSB service is not functioning.\n\nPlease contact the kiosk steward for assistance."
)
return # Don't proceed to next screen
master.switch_frame(Screen14)
def go_to_no_qr():

View File

@ -81,24 +81,24 @@ It can print stickers and ribbons to affix to an item you care about."""
nav_frame = tk.Frame(self, bg=BG_COLOR)
nav_frame.grid(row=1, column=0, columnspan=2, sticky='ew', pady=20)
# Dot progress indicator
self.dots_label = tk.Label(nav_frame, text="", font=GlobalVars.TEXT_FONT, bg=BG_COLOR)
self.dots_label.pack(side='left', expand=True)
self.dot_count = 0
# Countdown number indicator
self.countdown_label = tk.Label(nav_frame, text="8", font=("Helvetica", 20), bg=BG_COLOR, fg='gray40')
self.countdown_label.pack(side='left', expand=True)
self.countdown = 8
def add_dot():
self.dot_count += 1
self.dots_label.config(text="." * self.dot_count)
if self.dot_count < 8:
self.after(1000, add_dot)
def update_countdown():
self.countdown -= 1
if self.countdown > 0:
self.countdown_label.config(text=str(self.countdown))
self.after(1000, update_countdown)
self.after(1000, add_dot)
self.after(1000, update_countdown)
self.forward_btn = tk.Button(nav_frame, text="Forward →", command=lambda: master.switch_frame(InfoPage2),
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
def show_forward():
self.dots_label.pack_forget()
self.countdown_label.pack_forget()
self.forward_btn.pack(side='right', padx=40)
# Show forward button after 8 second delay
@ -150,24 +150,24 @@ by tagging an item you are saying to it, 'you matter, I will take care of you'""
tk.Button(nav_frame, text="← Back", command=lambda: master.switch_frame(InfoPage1),
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=40)
# Dot progress indicator (centered)
self.dots_label = tk.Label(nav_frame, text="", font=GlobalVars.TEXT_FONT, bg=BG_COLOR)
self.dots_label.pack(side='left', expand=True)
self.dot_count = 0
# Countdown number indicator
self.countdown_label = tk.Label(nav_frame, text="8", font=("Helvetica", 20), bg=BG_COLOR, fg='gray40')
self.countdown_label.pack(side='left', expand=True)
self.countdown = 8
def add_dot():
self.dot_count += 1
self.dots_label.config(text="." * self.dot_count)
if self.dot_count < 8:
self.after(1000, add_dot)
def update_countdown():
self.countdown -= 1
if self.countdown > 0:
self.countdown_label.config(text=str(self.countdown))
self.after(1000, update_countdown)
self.after(1000, add_dot)
self.after(1000, update_countdown)
self.forward_btn = tk.Button(nav_frame, text="Forward →", command=lambda: master.switch_frame(InfoPage3),
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
def show_forward():
self.dots_label.pack_forget()
self.countdown_label.pack_forget()
self.forward_btn.pack(side='right', padx=40)
# Show forward button after 8 second delay
@ -210,24 +210,24 @@ The QR codes contain a Scuttlebutt messageID, scanning it works best at www.cust
tk.Button(nav_frame, text="← Back", command=lambda: master.switch_frame(InfoPage2),
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=40)
# Dot progress indicator (centered)
self.dots_label = tk.Label(nav_frame, text="", font=GlobalVars.TEXT_FONT, bg=BG_COLOR)
self.dots_label.pack(side='left', expand=True)
self.dot_count = 0
# Countdown number indicator
self.countdown_label = tk.Label(nav_frame, text="8", font=("Helvetica", 20), bg=BG_COLOR, fg='gray40')
self.countdown_label.pack(side='left', expand=True)
self.countdown = 8
def add_dot():
self.dot_count += 1
self.dots_label.config(text="." * self.dot_count)
if self.dot_count < 8:
self.after(1000, add_dot)
def update_countdown():
self.countdown -= 1
if self.countdown > 0:
self.countdown_label.config(text=str(self.countdown))
self.after(1000, update_countdown)
self.after(1000, add_dot)
self.after(1000, update_countdown)
self.forward_btn = tk.Button(nav_frame, text="Forward →", command=go_to_screen1,
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
def show_forward():
self.dots_label.pack_forget()
self.countdown_label.pack_forget()
self.forward_btn.pack(side='right', padx=40)
# Show forward button after 8 second delay

View File

@ -386,6 +386,8 @@ class NoQRPrintScreen(tk.Frame):
# Get config values
ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
margin = CONFIG.get("ribbon", {}).get("margin", 50)
has_cutter = CONFIG.get("ribbon", {}).get("has_cutter", False)
top_margin = margin + (0 if has_cutter else 100)
printable_width = ribbon_width - 50 # 25px side margins
# Load the drawing
@ -398,12 +400,12 @@ class NoQRPrintScreen(tk.Frame):
drawing_height = int(printable_width * multiplier)
# Total label height: just margins + drawing (no QR, no scan-tag)
total_height = margin + drawing_height + margin
total_height = top_margin + drawing_height + margin
# Create full-width image with margins
merged_image = Image.new('L', (ribbon_width, total_height), "white")
# Paste drawing 25px in from left, margin down from top
merged_image.paste(drawing, (25, margin))
# Paste drawing 25px in from left, top_margin down from top
merged_image.paste(drawing, (25, top_margin))
merged_image.save("merged_image.png")
# Get the ZPL code for the image

View File

@ -133,6 +133,8 @@ class Screen13(tk.Frame):
# Get config values
ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
margin = CONFIG.get("ribbon", {}).get("margin", 50)
has_cutter = CONFIG.get("ribbon", {}).get("has_cutter", False)
top_margin = margin + (0 if has_cutter else 100)
printable_width = ribbon_width - 50 # 25px side margins
multiplier = {'small': 0.5, 'medium': 1.0, 'large': 1.5}.get(GlobalVars.ribbon_size, 1.0)
@ -146,18 +148,18 @@ class Screen13(tk.Frame):
qr_size = qr_img.width
# Total label height includes top/bottom margins and scan-tag
total_height = margin + drawing_height + scan_tag_height + qr_size + margin
total_height = top_margin + drawing_height + scan_tag_height + qr_size + margin
# Create full-width image with margins
merged_image = Image.new('L', (ribbon_width, total_height), "white")
# Paste drawing 25px in from left, margin down from top
merged_image.paste(drawing, (25, margin))
# Paste drawing 25px in from left, top_margin down from top
merged_image.paste(drawing, (25, top_margin))
# Paste scan-tag centered below drawing
scan_tag_x = (ribbon_width - scan_tag.width) // 2
merged_image.paste(scan_tag, (scan_tag_x, margin + drawing_height))
merged_image.paste(scan_tag, (scan_tag_x, top_margin + drawing_height))
# Center QR horizontally within the full ribbon width, below scan-tag
qr_x = (ribbon_width - qr_size) // 2
merged_image.paste(qr_img, (qr_x, margin + drawing_height + scan_tag_height))
merged_image.paste(qr_img, (qr_x, top_margin + drawing_height + scan_tag_height))
merged_image.save("merged_image.png")
# Get the ZPL code for the image
@ -194,6 +196,10 @@ class Screen13(tk.Frame):
print('traceback.format_exc():\n%s' % traceback.format_exc())
exit()
# Delete the photo to prevent reuse in next session
if os.path.exists(path_to_image):
os.remove(path_to_image)
from kiosk.screens.completion import Screen10
self.master.switch_frame(Screen10) # Switching to Screen10 after Done
@ -209,7 +215,8 @@ class PrintFailedScreen(tk.Frame):
# Troubleshooting instructions
troubleshoot_text = """if print failed:
0. if the issue is simply a vertical misalignment (the print is not vertically centered on the sticker), a simple reprint may work.
for more involved errors:
1. un-hook the red nylon cord and carefully pull out printer.
2. there are teal latches on either side of the printer: pull them towards you and lift the lid all the way open.
3. make sure there is enough resin and printable nylon/stickers and they are lined up straight
@ -217,8 +224,7 @@ class PrintFailedScreen(tk.Frame):
5. close printer and press the button on top of printer to stop the green light from flashing
6. put printer back, re-hook the nylon cord and try printing again.
if you're still having trouble you could try searching the internet for Zebra GX430T troubleshooting
AND OR
if you're still having trouble you could try searching the internet for Zebra GX430T troubleshooting AND OR
please email maintenance@cust.ooo
thanks!"""

View File

@ -2,6 +2,8 @@
import os
import re
import glob
import json
import subprocess
import cv2
@ -75,6 +77,55 @@ def get_preferred_camera_index(preferred_name_fragment=None):
return 0
def check_ssb_health() -> tuple[bool, str | None]:
"""
Run 'ssb-server whoami' and verify it returns valid JSON with an 'id' field.
Returns: (True, None) on success, (False, error_message) on failure.
"""
try:
result = subprocess.run(
['ssb-server', 'whoami'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
data = json.loads(result.stdout)
if data.get('id'): # Just check that id exists and is truthy
return (True, None)
return (False, "ssb-server whoami returned no id")
return (False, f"ssb-server whoami failed: {result.stderr}")
except subprocess.TimeoutExpired:
return (False, "ssb-server whoami timed out")
except json.JSONDecodeError:
return (False, "Failed to parse ssb-server response")
except FileNotFoundError:
return (False, "ssb-server command not found")
except Exception as e:
return (False, str(e))
def restart_ssb_service() -> tuple[bool, str | None]:
"""
Restart ssb-server.service using systemctl --user.
Returns: (True, None) on success, (False, error_message) on failure.
"""
try:
result = subprocess.run(
['systemctl', '--user', 'restart', 'ssb-server.service'],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
return (True, None)
return (False, f"Service restart failed: {result.stderr}")
except subprocess.TimeoutExpired:
return (False, "Service restart timed out")
except Exception as e:
return (False, str(e))
def sanitize_for_ssb(text):
"""Sanitize text for safe passage through shell and JSON."""
# Order matters: escape backslashes first
@ -85,8 +136,6 @@ def sanitize_for_ssb(text):
text = text.replace('$', '\\$')
# Escape backticks to prevent shell command substitution
text = text.replace('`', '\\`')
# Escape newlines for JSON
text = text.replace('\n', '\\n')
# Escape carriage returns
text = text.replace('\r', '')
# Escape tabs

View File

@ -106,10 +106,15 @@ class DrawingMixin:
img = Image.open(file_path)
img = img.convert('1')
if img.size != (self.original_width, self.original_height):
img = img.resize((self.original_width, self.original_height), Image.LANCZOS)
self.drawing = img
# Crop if larger than canvas (top-left aligned crop)
if img.size[0] > self.original_width or img.size[1] > self.original_height:
img = img.crop((0, 0, min(img.size[0], self.original_width),
min(img.size[1], self.original_height)))
# Paste onto blank canvas (preserves pixel accuracy, smaller images get white border)
self.drawing = Image.new('1', (self.original_width, self.original_height), 1)
self.drawing.paste(img, (0, 0)) # Top-left aligned
self.draw = ImageDraw.Draw(self.drawing)
# Display the image at scaled size

View File

@ -25,9 +25,13 @@ class RoundedLabel(tk.Canvas):
# Draw rounded rectangle
self._draw_rounded_rect(0, 0, width, height, radius, bg)
# Add text
self.create_text(width // 2, height // 2, text=text, font=font, fill=fg,
width=wraplength, justify=justify)
# Add text (with tag for later updates)
self.text_id = self.create_text(width // 2, height // 2, text=text, font=font, fill=fg,
width=wraplength, justify=justify, tags="label_text")
def set_text(self, text):
"""Update the text displayed in the label."""
self.itemconfig(self.text_id, text=text)
def _draw_rounded_rect(self, x1, y1, x2, y2, radius, fill):
"""Draw a rounded rectangle on the canvas."""

0
no-cut.mp4 Normal file
View File

BIN
qr.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

View File

@ -0,0 +1,7 @@
# Udev rule to allow video group write access to backlight brightness
# This enables the screensaver to turn off the display without root privileges
# Install to: /etc/udev/rules.d/90-backlight.rules
# Then reload: sudo udevadm control --reload-rules && sudo udevadm trigger
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chgrp video /sys/class/backlight/%k/brightness"
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chmod g+w /sys/class/backlight/%k/brightness"

188
screensaver/custo-display.py Executable file
View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Fullscreen text display for screensaver with animations and screen blanking"""
import gi
gi.require_version('Gtk', '4.0')
from gi.repository import Gtk, Gdk, GLib
import math
import sys
import argparse
import os
TEXT = "custo"
# Backlight control paths
BACKLIGHT_PATH = "/sys/class/backlight/intel_backlight/brightness"
MAX_BRIGHTNESS_PATH = "/sys/class/backlight/intel_backlight/max_brightness"
def get_max_brightness():
"""Read the maximum brightness value"""
try:
with open(MAX_BRIGHTNESS_PATH) as f:
return f.read().strip()
except Exception:
return None
def set_brightness(value):
"""Set the backlight brightness"""
try:
with open(BACKLIGHT_PATH, 'w') as f:
f.write(str(value))
return True
except Exception as e:
print(f"custo-display: Could not set brightness: {e}")
return False
class ScreensaverWindow(Gtk.ApplicationWindow):
def __init__(self, blank_after_minutes=20, *args, **kwargs):
super().__init__(*args, **kwargs)
self.blank_after_minutes = blank_after_minutes
self.is_blanked = False
self.original_brightness = None
self.set_css_classes(['screensaver-window'])
# Create label with large white text
self.label = Gtk.Label(label=TEXT)
self.label.set_css_classes(['screensaver-text'])
# Center the label
self.set_child(self.label)
# Animation state
self.time = 0.0
self.elapsed_seconds = 0
# Key press controller - close on any key
key_controller = Gtk.EventControllerKey()
key_controller.connect('key-pressed', self.on_input)
self.add_controller(key_controller)
# Click/touch controller - close on click or tap
click_controller = Gtk.GestureClick()
click_controller.connect('pressed', self.on_input)
self.add_controller(click_controller)
# Motion controller - close on mouse movement
motion_controller = Gtk.EventControllerMotion()
motion_controller.connect('motion', self.on_motion)
self.add_controller(motion_controller)
# Track initial position to ignore tiny movements
self.initial_x = None
self.initial_y = None
self.motion_threshold = 50 # pixels of movement before closing
# Go fullscreen
self.fullscreen()
# Start animation (100ms tick = 10fps, gentle on CPU)
GLib.timeout_add(100, self.animate)
# Store max brightness for restoration
self.original_brightness = get_max_brightness()
print(f"custo-display: Started. Screen will blank after {blank_after_minutes} minutes.")
def on_input(self, *args):
self.restore_and_close()
return True
def on_motion(self, controller, x, y):
if self.initial_x is None:
self.initial_x = x
self.initial_y = y
return
# Only close if mouse moved significantly
dx = abs(x - self.initial_x)
dy = abs(y - self.initial_y)
if dx > self.motion_threshold or dy > self.motion_threshold:
self.restore_and_close()
def restore_and_close(self):
"""Restore brightness and close the window"""
if self.is_blanked and self.original_brightness:
print("custo-display: Restoring brightness")
set_brightness(self.original_brightness)
self.close()
def blank_screen(self):
"""Turn off the backlight"""
if not self.is_blanked:
print("custo-display: Blanking screen (backlight off)")
if set_brightness(0):
self.is_blanked = True
# Hide the label since screen is blank
self.label.set_visible(False)
def animate(self):
self.time += 0.025 # Slower time progression
self.elapsed_seconds += 0.1 # 100ms per tick
# Check if it's time to blank the screen
elapsed_minutes = self.elapsed_seconds / 60
if elapsed_minutes >= self.blank_after_minutes and not self.is_blanked:
self.blank_screen()
# Skip animation updates if screen is blanked
if self.is_blanked:
return True
# Slow fade: oscillates between 0.3 and 1.0 over ~12 seconds
fade = 0.65 + 0.35 * math.sin(self.time * 0.25)
# Wavy vertical offset using sine (increased amplitude for more visible movement)
wave_offset = 60 * math.sin(self.time * 0.4)
# Apply opacity and transform
self.label.set_opacity(fade)
self.label.set_margin_top(int(100 + wave_offset))
self.label.set_margin_bottom(int(100 - wave_offset))
return True # Continue animation
class ScreensaverApp(Gtk.Application):
def __init__(self, blank_after_minutes=20):
super().__init__(application_id='com.custo.screensaver')
self.blank_after_minutes = blank_after_minutes
def do_activate(self):
# Load CSS
css_provider = Gtk.CssProvider()
css_provider.load_from_string('''
.screensaver-window {
background-color: black;
}
.screensaver-text {
color: white;
font-size: 120px;
font-weight: bold;
}
''')
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
win = ScreensaverWindow(
blank_after_minutes=self.blank_after_minutes,
application=self
)
win.present()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Custo screensaver display')
parser.add_argument('--blank-after', type=int, default=20,
help='Minutes until screen blanks (default: 20)')
args = parser.parse_args()
app = ScreensaverApp(blank_after_minutes=args.blank_after)
app.run(None)

View File

@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Custo Screensaver
Exec=/home/trav/.local/bin/custo-screensaver.sh
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true

View File

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# custo-screensaver.sh: Display "custo" after idle timeout on GNOME/Wayland
# Two-stage behavior:
# Stage 1: After start_after minutes, show "custo" animation
# Stage 2: After blank_after more minutes, tell display to blank screen
start_after=10 # minutes until screensaver appears
blank_after=20 # additional minutes until screen blanks (total: 30 min)
# Kill any existing instances of this script (prevent duplicates)
for pid in $(pgrep -f 'custo-screensaver.sh'); do
if [ "$pid" != "$$" ]; then
echo "custo-screensaver: Killing existing instance (PID $pid)"
kill "$pid" 2>/dev/null
fi
done
# Also kill any orphaned display processes
pkill -f 'custo-display.py' 2>/dev/null
cmd="python3 $HOME/.local/bin/custo-display.py"
get_idle_minutes() {
idle_time=$(dbus-send --print-reply --dest=org.gnome.Mutter.IdleMonitor \
/org/gnome/Mutter/IdleMonitor/Core org.gnome.Mutter.IdleMonitor.GetIdletime 2>/dev/null | grep -Po '(?<=uint64\s)\d+')
if [ -n "$idle_time" ]; then
echo "$idle_time / 60000" | bc
else
echo "0"
fi
}
get_inhibitors() {
for i in $(dbus-send --print-reply --dest=org.gnome.SessionManager \
/org/gnome/SessionManager org.gnome.SessionManager.GetInhibitors 2>/dev/null | grep -Po 'Inhibitor\d+'); do
if dbus-send --print-reply --dest=org.gnome.SessionManager \
/org/gnome/SessionManager/"$i" org.gnome.SessionManager.Inhibitor.GetFlags 2>/dev/null | grep -q 'uint32\s8'; then
true; return
fi
done
false; return
}
echo "custo-screensaver: Started (PID $$). Will activate after $start_after minutes of idle."
echo "custo-screensaver: Screen will blank after $blank_after additional minutes."
screensaver_pid=""
screensaver_start_time=""
while :; do
idle_mins=$(get_idle_minutes)
if [ "$idle_mins" -ge $start_after ] && [ -z "$screensaver_pid" ]; then
if ! get_inhibitors; then
echo "custo-screensaver: Activating (idle: ${idle_mins}m)"
$cmd --blank-after=$blank_after &
screensaver_pid=$!
screensaver_start_time=$(date +%s)
fi
elif [ "$idle_mins" -lt $start_after ] && [ -n "$screensaver_pid" ]; then
echo "custo-screensaver: Deactivating (user activity detected)"
pkill -P $screensaver_pid 2>/dev/null
kill $screensaver_pid 2>/dev/null
screensaver_pid=""
screensaver_start_time=""
fi
sleep 1
done