Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbedde6f6b | ||
|
|
f39be6b24b | ||
|
|
a48f7a467b | ||
|
|
22a290e0ed | ||
|
|
5ca6254ec3 | ||
|
|
40b80dc196 | ||
|
|
62f4d267a9 | ||
|
|
28bd9bfa13 | ||
|
|
b9ac998fb4 | ||
|
|
77fd097b4a | ||
|
|
48979b52f6 | ||
|
|
cafd4b6084 | ||
|
|
e1f9417684 | ||
|
|
d33717825d | ||
|
|
d4dbd79cc2 | ||
|
|
9c7adf985c | ||
|
|
cbcef9888b | ||
|
|
d78f84d8ff |
@@ -1,11 +1,24 @@
|
||||
\freeze_frame.jpg
|
||||
qr.jpg
|
||||
users.json
|
||||
merged_image.jpg
|
||||
drawing.png
|
||||
to_print.zpl
|
||||
tmp/*
|
||||
merged_image.png
|
||||
__pycache__/*
|
||||
freeze_frame.png
|
||||
image-temp.jpg
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Runtime-generated media files
|
||||
media-assets/freeze_frame.jpg
|
||||
media-assets/freeze_frame.png
|
||||
media-assets/qr.png
|
||||
media-assets/qr.jpg
|
||||
media-assets/drawing.png
|
||||
media-assets/merged_image.png
|
||||
media-assets/merged_image.jpg
|
||||
media-assets/to_print.zpl
|
||||
media-assets/image-temp.jpg
|
||||
|
||||
# Config (use config.example.json as template)
|
||||
config.json
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
venv/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
kiosk.py is the main program here. It's the front-end to a physical kiosk with 2 Zebra GX430t thermal transfer pritners in it.
|
||||
|
||||
the whole thing is graphical so it's hard for you to test so you'll need to ask the user to test.
|
||||
|
||||
you'll run into errors if you try `python -m py_compile kiosk.py && echo "Syntax OK"`
|
||||
you gotta do `python3 -m py_compile /home/trav/Documents/custodisco-kiosk/kiosk.py && echo "Syntax OK"`
|
||||
|
||||
## QR Code Sizing
|
||||
|
||||
SSB message IDs are always 52 characters (format: `%<43-char-base64>=.sha256`).
|
||||
Due to special characters (`%` and `.`), QR uses **byte mode encoding**.
|
||||
|
||||
With ERROR_CORRECT_H for 52 bytes:
|
||||
- **Version 6** is required
|
||||
- Module count = 4 × 6 + 17 = **41 modules**
|
||||
- QR pixel size = **41 × scale**
|
||||
|
||||
| Scale | QR Size (px) | Notes |
|
||||
|-------|--------------|-------|
|
||||
| 3 | 123 | Small - fits in 650×360 canvas |
|
||||
| 6 | 246 | Medium - fits in canvas |
|
||||
| 8 | 328 | Large - max that fits in 360px height with margin |
|
||||
| 9 | 369 | Too tall for 360px canvas |
|
||||
|
||||
Canvas dimensions: 650×360
|
||||
Merged image: 675×375 (drawing pasted at y=8)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Archive of Migration Items Feature
|
||||
==================================
|
||||
This feature was removed from the kiosk application.
|
||||
Archived on: 2026-01-17
|
||||
|
||||
The migration items feature allowed users to enter a ticket number when creating
|
||||
items, and would save copies of photos and descriptions to a separate directory.
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# Configuration that was in kiosk.py line 116
|
||||
# =============================================================================
|
||||
# MIGRATION_ITEMS_DIR = CONFIG["paths"]["migration_items_dir"]
|
||||
|
||||
# =============================================================================
|
||||
# Global variable that was in GlobalVars class (line 122)
|
||||
# =============================================================================
|
||||
# GlobalVars.migration_ticket = ""
|
||||
|
||||
# =============================================================================
|
||||
# Code from start_over() method (lines 167-183)
|
||||
# This would delete migration files when user started over
|
||||
# =============================================================================
|
||||
"""
|
||||
def start_over(self):
|
||||
global migration_ticket
|
||||
global MIGRATION_ITEMS_DIR
|
||||
|
||||
# Check if migration_ticket is defined
|
||||
#if 'migration_ticket' in globals():
|
||||
# Delete any existing migration files
|
||||
# if migration_ticket:
|
||||
# text_file = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.txt")
|
||||
# image_file = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.jpg")
|
||||
|
||||
# if os.path.exists(text_file):
|
||||
# os.remove(text_file)
|
||||
# if os.path.exists(image_file):
|
||||
# os.remove(image_file)
|
||||
|
||||
# Reset migration_ticket
|
||||
# migration_ticket = ""
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# Commented Screen15 button in Screen0 (line 222)
|
||||
# =============================================================================
|
||||
# tk.Button(right_frame, text="Create Item", command=lambda: master.switch_frame(Screen15), height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
# =============================================================================
|
||||
# Code from Screen3.done() method (lines 667-678)
|
||||
# This would copy the photo to migration items directory
|
||||
# =============================================================================
|
||||
"""
|
||||
def done(self):
|
||||
global migration_ticket
|
||||
global MIGRATION_ITEMS_DIR
|
||||
|
||||
# If migration_ticket is set, copy the photo to MIGRATION_ITEMS_DIR
|
||||
#if migration_ticket:
|
||||
# source_path = 'freeze_frame.jpg'
|
||||
# destination_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.jpg")
|
||||
# try:
|
||||
# shutil.copy2(source_path, destination_path)
|
||||
# print(f"Photo saved as {destination_path}")
|
||||
#except Exception as e:
|
||||
# print(f"Error saving photo: {e}")
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# Code from Screen5.save_info_and_switch() method (lines 920-944)
|
||||
# This would save text and image to migration items directory
|
||||
# =============================================================================
|
||||
"""
|
||||
def save_info_and_switch(self):
|
||||
global info_text
|
||||
global migration_ticket
|
||||
global MIGRATION_ITEMS_DIR
|
||||
|
||||
info_text = self.info_entry.get("1.0", "end-1c")
|
||||
info_text = info_text.replace('\n', '\\n')
|
||||
|
||||
# If migration_ticket is set (not empty), save the text and image to MIGRATION_ITEMS_DIR
|
||||
#if migration_ticket:
|
||||
# Save text
|
||||
# text_file_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.txt")
|
||||
# try:
|
||||
# with open(text_file_path, 'w') as f:
|
||||
# f.write(info_text)
|
||||
# print(f"Text saved as {text_file_path}")
|
||||
# except Exception as e:
|
||||
# print(f"Error saving text: {e}")
|
||||
|
||||
# Save image
|
||||
# source_image_path = 'freeze_frame.jpg'
|
||||
# destination_image_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.jpg")
|
||||
# try:
|
||||
# shutil.copy2(source_image_path, destination_image_path)
|
||||
# print(f"Photo saved as {destination_image_path}")
|
||||
# except Exception as e:
|
||||
# print(f"Error saving photo: {e}")
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# Code from Screen13.printy() method (lines 1353, 1367-1374)
|
||||
# This would append message ID to migration description file
|
||||
# =============================================================================
|
||||
"""
|
||||
def printy(self):
|
||||
global print_type, migration_ticket, MIGRATION_ITEMS_DIR
|
||||
|
||||
# ... other code ...
|
||||
|
||||
# If we have a migration ticket, append the message ID to the description file
|
||||
#if migration_ticket:
|
||||
# description_file_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.txt")
|
||||
# try:
|
||||
# with open(description_file_path, 'a') as f:
|
||||
# f.write(f"\n\n\n{key}")
|
||||
# print(f"Appended message ID to description file: {description_file_path}")
|
||||
# except Exception as e:
|
||||
# print(f"Error appending message ID to description file: {e}")
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# Screen15 class (lines 1547-1595)
|
||||
# The main screen for entering migration ticket numbers
|
||||
# =============================================================================
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
import re
|
||||
import os
|
||||
|
||||
# Note: These would need to be imported from the main kiosk module:
|
||||
# from kiosk import GlobalVars, Screen1, MIGRATION_ITEMS_DIR
|
||||
|
||||
class Screen15(tk.Frame):
|
||||
"""
|
||||
Screen for Create Migration Item.
|
||||
|
||||
This screen allowed users to enter a ticket number (1-4 digits) to associate
|
||||
with their item for migration tracking purposes. Files would be saved to
|
||||
MIGRATION_ITEMS_DIR with the ticket number as the filename.
|
||||
"""
|
||||
def __init__(self, master):
|
||||
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
|
||||
# Create a main container frame
|
||||
main_frame = tk.Frame(self, bg='#bcfef9')
|
||||
main_frame.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Add the home button to the main frame (this will be at the top)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Instructions (now in the centered main frame)
|
||||
tk.Label(main_frame, text="If you're archiving an Item of Migration, please enter the last 3 digits of your ticket number. Otherwise, you can leave this blank.", font=GlobalVars.TEXT_FONT, bg='#bcfef9', wraplength=500).pack(pady=20)
|
||||
|
||||
# Text box for entering ticket number (now in the centered main frame)
|
||||
self.ticket_entry = tk.Entry(main_frame, font=GlobalVars.TEXT_FONT)
|
||||
self.ticket_entry.pack(pady=20)
|
||||
|
||||
# Done button (now in the centered main frame)
|
||||
tk.Button(main_frame, text="Done", command=self.process_ticket, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
|
||||
# Set focus to the ticket entry field
|
||||
self.ticket_entry.focus_set()
|
||||
|
||||
def process_ticket(self):
|
||||
global migration_ticket
|
||||
ticket = self.ticket_entry.get().strip()
|
||||
|
||||
if ticket == "":
|
||||
# If the field is blank, proceed without setting migration_ticket
|
||||
migration_ticket = ""
|
||||
self.master.switch_frame(Screen1) # Go to the Scuttlebutt username selection screen
|
||||
elif re.match(r'^\d{1,4}$', ticket):
|
||||
if self.is_ticket_available(ticket):
|
||||
migration_ticket = ticket
|
||||
self.master.switch_frame(Screen1) # Go to the Scuttlebutt username selection screen
|
||||
else:
|
||||
messagebox.showerror("Invalid Input", "This migration number is already in use. Please try a different number.")
|
||||
else:
|
||||
messagebox.showerror("Invalid Input", "Please check your number and try again. It should be a 1-4 digit number or left blank.")
|
||||
|
||||
def is_ticket_available(self, ticket):
|
||||
# Get all files in the migration items directory
|
||||
files = os.listdir(MIGRATION_ITEMS_DIR)
|
||||
|
||||
# Strip file extensions and check if the ticket number exists
|
||||
existing_numbers = [os.path.splitext(f)[0] for f in files]
|
||||
return ticket not in existing_numbers
|
||||
|
||||
# =============================================================================
|
||||
# Config section that was in config.json and config.example.json
|
||||
# =============================================================================
|
||||
"""
|
||||
{
|
||||
"paths": {
|
||||
"migration_items_dir": "/home/trav/Documents/migration_items"
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"printers": {
|
||||
"sticker": "sticker_printer",
|
||||
"ribbon": "tag-printer"
|
||||
},
|
||||
"camera": {
|
||||
"preferred_name": "NexiGo"
|
||||
},
|
||||
"ribbon": {
|
||||
"width": 450,
|
||||
"margin": 50,
|
||||
"has_cutter": false
|
||||
},
|
||||
"darkness": 15,
|
||||
"background_color": "#bcfef9",
|
||||
"show_no_qr_ribbon": 0,
|
||||
"developer_mode": false
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# Switch to GNOME desktop mode
|
||||
# Run with: sudo ./desktop-mode.sh
|
||||
|
||||
ACCOUNTS_FILE="/var/lib/AccountsService/users/trav"
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run with sudo: sudo $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-detect the right desktop session from what's installed
|
||||
if [ -f /usr/share/wayland-sessions/gnome-wayland.desktop ]; then
|
||||
SESSION="gnome-wayland"
|
||||
elif [ -f /usr/share/wayland-sessions/ubuntu-wayland.desktop ]; then
|
||||
SESSION="ubuntu-wayland"
|
||||
elif [ -f /usr/share/xsessions/gnome.desktop ]; then
|
||||
SESSION="gnome"
|
||||
elif [ -f /usr/share/xsessions/ubuntu.desktop ]; then
|
||||
SESSION="ubuntu"
|
||||
else
|
||||
echo "Error: No supported desktop session found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop the kiosk script service first
|
||||
sudo -u trav XDG_RUNTIME_DIR=/run/user/$(id -u trav) systemctl --user stop org.gnome.Kiosk.Script.service 2>/dev/null
|
||||
sudo -u trav XDG_RUNTIME_DIR=/run/user/$(id -u trav) systemctl --user mask org.gnome.Kiosk.Script.service 2>/dev/null
|
||||
|
||||
# Update AccountsService to use detected desktop session
|
||||
cat > "$ACCOUNTS_FILE" << EOF
|
||||
[User]
|
||||
Session=$SESSION
|
||||
SystemAccount=false
|
||||
EOF
|
||||
|
||||
echo "Detected session: $SESSION"
|
||||
|
||||
echo "Switched to desktop mode."
|
||||
echo "Run 'sudo systemctl restart gdm3' to apply, or log out and back in."
|
||||
@@ -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)
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
# Install script for custodisco-kiosk
|
||||
# Run this after cloning the repo to install all dependencies
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Custodisco Kiosk Installation ==="
|
||||
echo
|
||||
|
||||
# Check for required system tools
|
||||
echo "Checking system dependencies..."
|
||||
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "ERROR: python3 is not installed. Please install Python 3 first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "WARNING: node is not installed. Scuttlebutt features won't work."
|
||||
echo " Install Node.js if you want SSB integration."
|
||||
fi
|
||||
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo "WARNING: npm is not installed. Scuttlebutt features won't work."
|
||||
echo " Install npm if you want SSB integration."
|
||||
fi
|
||||
|
||||
# Install system packages (Debian/Ubuntu)
|
||||
echo
|
||||
echo "Installing system dependencies (may require sudo)..."
|
||||
if command -v apt-get &> /dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jpegoptim jq libzbar0 python3-venv python3-tk
|
||||
else
|
||||
echo "WARNING: Not a Debian/Ubuntu system. Please manually install:"
|
||||
echo " - jpegoptim (image optimization)"
|
||||
echo " - jq (JSON processing)"
|
||||
echo " - libzbar0 (QR code reading)"
|
||||
echo " - python3-venv (virtual environments)"
|
||||
echo " - python3-tk (tkinter GUI)"
|
||||
fi
|
||||
|
||||
# Create and activate virtual environment
|
||||
echo
|
||||
echo "Setting up Python virtual environment..."
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install Python dependencies
|
||||
echo
|
||||
echo "Installing Python dependencies..."
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install Node.js dependencies (if npm is available)
|
||||
if command -v npm &> /dev/null; then
|
||||
echo
|
||||
echo "Installing Node.js dependencies..."
|
||||
npm install
|
||||
else
|
||||
echo
|
||||
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
|
||||
echo "To run the kiosk:"
|
||||
echo " ./run.sh"
|
||||
echo
|
||||
echo "Or manually:"
|
||||
echo " source venv/bin/activate"
|
||||
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
|
||||
@@ -0,0 +1,123 @@
|
||||
# Custodisco Kiosk - Systemd Services & Commands
|
||||
|
||||
## Services Overview
|
||||
|
||||
### 1. ssb-server.service (User Service)
|
||||
- **Purpose**: Runs the Scuttlebutt server for decentralized social features
|
||||
- **Location**: `~/.config/systemd/user/ssb-server.service`
|
||||
- **Auto-starts**: Yes, via `WantedBy=default.target`
|
||||
|
||||
### 2. org.gnome.Kiosk.Script.service (GNOME User Service)
|
||||
- **Purpose**: Runs the kiosk application via ~/.local/bin/gnome-kiosk-script
|
||||
- **Location**: `/usr/lib/systemd/user/org.gnome.Kiosk.Script.service`
|
||||
- **Auto-starts**: Only when session type is `gnome-kiosk-script-wayland`
|
||||
|
||||
### 3. gdm.service (System Service)
|
||||
- **Purpose**: GNOME Display Manager - handles login and session management
|
||||
- **Controls**: Which session type to start (kiosk vs desktop)
|
||||
|
||||
## SSH Commands
|
||||
|
||||
### Check Service Status
|
||||
```bash
|
||||
# SSB server status
|
||||
systemctl --user status ssb-server.service
|
||||
|
||||
# Kiosk script status (only relevant in kiosk mode)
|
||||
systemctl --user status org.gnome.Kiosk.Script.service
|
||||
|
||||
# GDM status
|
||||
systemctl status gdm3
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
```bash
|
||||
# Restart SSB server
|
||||
systemctl --user restart ssb-server.service
|
||||
|
||||
# Restart GDM (will log out user and restart display)
|
||||
sudo systemctl restart gdm3
|
||||
```
|
||||
|
||||
### Switch to Kiosk Mode
|
||||
```bash
|
||||
cd /home/trav/Documents/custodisco-kiosk
|
||||
sudo ./kiosk-mode.sh
|
||||
sudo systemctl restart gdm3
|
||||
```
|
||||
|
||||
### Switch to Desktop Mode
|
||||
```bash
|
||||
cd /home/trav/Documents/custodisco-kiosk
|
||||
sudo ./desktop-mode.sh
|
||||
sudo systemctl restart gdm3
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
# SSB server logs
|
||||
journalctl --user -u ssb-server.service -f
|
||||
|
||||
# Kiosk script logs
|
||||
journalctl --user -u org.gnome.Kiosk.Script.service -f
|
||||
```
|
||||
|
||||
### View kiosk.py Output
|
||||
Since kiosk.py runs under `org.gnome.Kiosk.Script.service`, its stdout/stderr goes to the systemd journal:
|
||||
```bash
|
||||
# Follow kiosk.py output in real-time
|
||||
journalctl --user -u org.gnome.Kiosk.Script.service -f
|
||||
|
||||
# See last 100 lines of kiosk.py output
|
||||
journalctl --user -u org.gnome.Kiosk.Script.service -n 100
|
||||
|
||||
# See all output since last boot (no pager)
|
||||
journalctl --user -u org.gnome.Kiosk.Script.service -b --no-pager
|
||||
```
|
||||
|
||||
## How Boot Works
|
||||
|
||||
1. System boots, starts `gdm.service`
|
||||
2. GDM reads `/var/lib/AccountsService/users/trav` to get session type
|
||||
3. If `Session=gnome-kiosk-script-wayland`, GDM starts kiosk session
|
||||
4. Kiosk session starts `org.gnome.Kiosk.Script.service`
|
||||
5. Service runs `/usr/bin/gnome-kiosk-script` which calls `~/.local/bin/gnome-kiosk-script`
|
||||
6. That script activates venv and runs `python kiosk.py`
|
||||
|
||||
## File Locations
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `~/.local/bin/gnome-kiosk-script` | Kiosk startup script |
|
||||
| `~/Documents/custodisco-kiosk/kiosk-mode.sh` | Switch to kiosk mode |
|
||||
| `~/Documents/custodisco-kiosk/desktop-mode.sh` | Switch to desktop mode |
|
||||
| `~/.config/systemd/user/ssb-server.service` | SSB server service file |
|
||||
| `/var/lib/AccountsService/users/trav` | AccountsService config (root owned) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Kiosk not starting at boot
|
||||
1. Check session type: `cat /var/lib/AccountsService/users/trav`
|
||||
2. Should show `Session=gnome-kiosk-script-wayland`
|
||||
3. If not, run: `sudo ./kiosk-mode.sh && sudo systemctl restart gdm3`
|
||||
|
||||
### SSB server not running
|
||||
```bash
|
||||
systemctl --user status ssb-server.service
|
||||
systemctl --user restart ssb-server.service
|
||||
journalctl --user -u ssb-server.service --no-pager -n 50
|
||||
```
|
||||
|
||||
### Check if kiosk.py is running
|
||||
```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
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Switch to GNOME kiosk mode
|
||||
# Run with: sudo ./kiosk-mode.sh
|
||||
|
||||
ACCOUNTS_FILE="/var/lib/AccountsService/users/trav"
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run with sudo: sudo $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unmask the kiosk script service
|
||||
sudo -u trav XDG_RUNTIME_DIR=/run/user/$(id -u trav) systemctl --user unmask org.gnome.Kiosk.Script.service 2>/dev/null
|
||||
|
||||
# Update AccountsService to use kiosk session
|
||||
cat > "$ACCOUNTS_FILE" << EOF
|
||||
[User]
|
||||
Session=gnome-kiosk-script-wayland
|
||||
SystemAccount=false
|
||||
EOF
|
||||
|
||||
echo "Switched to kiosk mode."
|
||||
echo "Run 'sudo systemctl restart gdm3' to apply, or log out and back in."
|
||||
@@ -20,6 +20,81 @@ import re # Import re for regular expression matching
|
||||
import os
|
||||
import shutil
|
||||
import hashlib
|
||||
<<<<<<< HEAD
|
||||
import glob
|
||||
import traceback
|
||||
|
||||
|
||||
def get_preferred_camera_index(preferred_name_fragment=None):
|
||||
"""
|
||||
Try to find a stable camera index for an external webcam.
|
||||
|
||||
Strategy (Linux):
|
||||
- Look in /dev/v4l/by-id, which has stable, human‑readable symlinks.
|
||||
- If `preferred_name_fragment` is provided, pick the first device whose
|
||||
name contains that fragment.
|
||||
- Otherwise, prefer devices whose name looks like an external USB webcam.
|
||||
- Fall back to the lowest working index (usually the built‑in camera).
|
||||
|
||||
You can customize behaviour by passing a string like "Logitech" or by
|
||||
changing the heuristics below.
|
||||
"""
|
||||
by_id_dir = "/dev/v4l/by-id"
|
||||
|
||||
try:
|
||||
if os.path.isdir(by_id_dir):
|
||||
paths = sorted(glob.glob(os.path.join(by_id_dir, "*")))
|
||||
if paths:
|
||||
# Optionally filter by a user‑specified name fragment
|
||||
if preferred_name_fragment:
|
||||
name_fragment = preferred_name_fragment.lower()
|
||||
candidate_paths = [
|
||||
p for p in paths if name_fragment in os.path.basename(p).lower()
|
||||
]
|
||||
if not candidate_paths:
|
||||
candidate_paths = paths
|
||||
else:
|
||||
candidate_paths = paths
|
||||
|
||||
# Heuristic: prefer names that look like external USB webcams
|
||||
# (often contain "usb-" but are not clearly an integrated camera)
|
||||
usb_like = [
|
||||
p
|
||||
for p in candidate_paths
|
||||
if "usb-" in os.path.basename(p).lower()
|
||||
]
|
||||
# If that filter gives us something, use it, otherwise use all
|
||||
candidate_paths = usb_like or candidate_paths
|
||||
|
||||
for path in candidate_paths:
|
||||
target = os.path.realpath(path) # e.g. /dev/video2
|
||||
m = re.search(r"video(\d+)", target)
|
||||
if not m:
|
||||
continue
|
||||
idx = int(m.group(1))
|
||||
cap = cv2.VideoCapture(idx)
|
||||
if cap.isOpened():
|
||||
cap.release()
|
||||
print(f"Selected camera index {idx} from {path}")
|
||||
return idx
|
||||
cap.release()
|
||||
except Exception as e:
|
||||
print(f"Error while trying to select preferred camera: {e}")
|
||||
|
||||
# Fallback: probe a small range of indices and pick the first that works
|
||||
for idx in range(0, 6):
|
||||
cap = cv2.VideoCapture(idx)
|
||||
if cap.isOpened():
|
||||
cap.release()
|
||||
print(f"Falling back to first working camera index: {idx}")
|
||||
return idx
|
||||
cap.release()
|
||||
|
||||
# Absolute fallback if nothing opens
|
||||
print("No working camera found; defaulting to index 0")
|
||||
return 0
|
||||
=======
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +121,11 @@ class Kiosk(tk.Tk):
|
||||
self.config(cursor="crosshair")
|
||||
self.QRX = None
|
||||
self.QRY = None
|
||||
<<<<<<< HEAD
|
||||
self.QRscale = 3
|
||||
=======
|
||||
self.QRscale = 1
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
|
||||
# Initialize fonts
|
||||
GlobalVars.BUTTON_FONT = tkfont.Font(size=24, family='Helvetica')
|
||||
@@ -79,6 +158,7 @@ class Kiosk(tk.Tk):
|
||||
global MIGRATION_ITEMS_DIR
|
||||
|
||||
# Check if migration_ticket is defined
|
||||
<<<<<<< HEAD
|
||||
#if 'migration_ticket' in globals():
|
||||
# Delete any existing migration files
|
||||
# if migration_ticket:
|
||||
@@ -92,6 +172,21 @@ class Kiosk(tk.Tk):
|
||||
|
||||
# Reset migration_ticket
|
||||
# migration_ticket = ""
|
||||
=======
|
||||
if 'migration_ticket' in globals():
|
||||
# Delete any existing migration files
|
||||
if migration_ticket:
|
||||
text_file = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.txt")
|
||||
image_file = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.jpg")
|
||||
|
||||
if os.path.exists(text_file):
|
||||
os.remove(text_file)
|
||||
if os.path.exists(image_file):
|
||||
os.remove(image_file)
|
||||
|
||||
# Reset migration_ticket
|
||||
migration_ticket = ""
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
|
||||
# Reset other global variables
|
||||
GlobalVars.qr_code_value = None
|
||||
@@ -127,11 +222,18 @@ class Screen0(tk.Frame):
|
||||
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
tk.Button(right_frame, text="Create Item", command=lambda: master.switch_frame(Screen1), height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
# Button for Migration mode
|
||||
#tk.Button(right_frame, text="Create Item", command=lambda: master.switch_frame(Screen15), height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
=======
|
||||
# tk.Button(right_frame, text="Create Item", command=lambda: master.switch_frame(Screen1), height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
# Button for Migration mode
|
||||
tk.Button(right_frame, text="Create Item", command=lambda: master.switch_frame(Screen15), height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
tk.Button(right_frame, text="Lookup Item", command=lambda: master.switch_frame(Screen14), height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
# Create the quit button
|
||||
@@ -427,7 +529,12 @@ class Screen3(tk.Frame):
|
||||
home_button = tk.Button(text="Start Over", command=self.show_warning_dialog, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
home_button.place(x=0, y=0) # top-left corner
|
||||
|
||||
<<<<<<< HEAD
|
||||
# Prefer the external NexiGo USB webcam when available
|
||||
self.vid = cv2.VideoCapture(get_preferred_camera_index("NexiGo"))
|
||||
=======
|
||||
self.vid = cv2.VideoCapture(2)
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
self.is_capturing = True
|
||||
self.freeze_frame = None
|
||||
|
||||
@@ -456,7 +563,12 @@ class Screen3(tk.Frame):
|
||||
self.__del__()
|
||||
self.master.switch_frame(Screen0)
|
||||
|
||||
self.vid = cv2.VideoCapture(0)
|
||||
<<<<<<< HEAD
|
||||
# Prefer the external NexiGo USB webcam when available
|
||||
self.vid = cv2.VideoCapture(get_preferred_camera_index("NexiGo"))
|
||||
=======
|
||||
self.vid = cv2.VideoCapture(2)
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
self.is_capturing = True
|
||||
self.freeze_frame = None
|
||||
|
||||
@@ -489,7 +601,12 @@ class Screen3(tk.Frame):
|
||||
home_button = tk.Button(text="Start Over", command=self.show_warning_dialog, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
home_button.place(x=0, y=0) # top-left corner
|
||||
|
||||
<<<<<<< HEAD
|
||||
# Prefer the external NexiGo USB webcam when available
|
||||
self.vid = cv2.VideoCapture(get_preferred_camera_index("NexiGo"))
|
||||
=======
|
||||
self.vid = cv2.VideoCapture(2)
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
self.is_capturing = True
|
||||
self.freeze_frame = None
|
||||
self.countdown_text = None
|
||||
@@ -576,6 +693,7 @@ class Screen3(tk.Frame):
|
||||
global MIGRATION_ITEMS_DIR
|
||||
|
||||
# If migration_ticket is set, copy the photo to MIGRATION_ITEMS_DIR
|
||||
<<<<<<< HEAD
|
||||
#if migration_ticket:
|
||||
# source_path = 'freeze_frame.jpg'
|
||||
# destination_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.jpg")
|
||||
@@ -584,6 +702,16 @@ class Screen3(tk.Frame):
|
||||
# print(f"Photo saved as {destination_path}")
|
||||
#except Exception as e:
|
||||
# print(f"Error saving photo: {e}")
|
||||
=======
|
||||
if migration_ticket:
|
||||
source_path = 'freeze_frame.jpg'
|
||||
destination_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.jpg")
|
||||
try:
|
||||
shutil.copy2(source_path, destination_path)
|
||||
print(f"Photo saved as {destination_path}")
|
||||
except Exception as e:
|
||||
print(f"Error saving photo: {e}")
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
|
||||
self.release_resources()
|
||||
self.master.switch_frame(Screen5)
|
||||
@@ -633,7 +761,11 @@ class Screen4(tk.Frame):
|
||||
|
||||
# Set initial drawing color to black and size to 1
|
||||
self.draw_color = 'black'
|
||||
<<<<<<< HEAD
|
||||
self.draw_size = 3
|
||||
=======
|
||||
self.draw_size = 1
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
|
||||
# Creating the Canvas for drawing
|
||||
self.canvas = Canvas(self.left_frame, width=650, height=360, bg='white')
|
||||
@@ -752,7 +884,11 @@ class Screen4(tk.Frame):
|
||||
self.draw = ImageDraw.Draw(self.drawing)
|
||||
self.add_qr_box()
|
||||
|
||||
<<<<<<< HEAD
|
||||
def add_qr_box(self, x=506, y=217, size=3):
|
||||
=======
|
||||
def add_qr_box(self, x=506, y=217, size=1):
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
box_size = 37 * size
|
||||
self.canvas.create_rectangle(x, y, x + box_size, y + box_size, outline='black', fill='white')
|
||||
self.canvas.create_text(x + box_size/2, y + box_size/2, text="QR", fill="black")
|
||||
@@ -832,6 +968,7 @@ class Screen5(tk.Frame):
|
||||
info_text = info_text.replace('\n', '\\n')
|
||||
|
||||
# If migration_ticket is set (not empty), save the text and image to MIGRATION_ITEMS_DIR
|
||||
<<<<<<< HEAD
|
||||
#if migration_ticket:
|
||||
# Save text
|
||||
# text_file_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.txt")
|
||||
@@ -850,6 +987,26 @@ class Screen5(tk.Frame):
|
||||
# print(f"Photo saved as {destination_image_path}")
|
||||
# except Exception as e:
|
||||
# print(f"Error saving photo: {e}")
|
||||
=======
|
||||
if migration_ticket:
|
||||
# Save text
|
||||
text_file_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.txt")
|
||||
try:
|
||||
with open(text_file_path, 'w') as f:
|
||||
f.write(info_text)
|
||||
print(f"Text saved as {text_file_path}")
|
||||
except Exception as e:
|
||||
print(f"Error saving text: {e}")
|
||||
|
||||
# Save image
|
||||
source_image_path = 'freeze_frame.jpg'
|
||||
destination_image_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.jpg")
|
||||
try:
|
||||
shutil.copy2(source_image_path, destination_image_path)
|
||||
print(f"Photo saved as {destination_image_path}")
|
||||
except Exception as e:
|
||||
print(f"Error saving photo: {e}")
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
|
||||
self.master.switch_frame(Screen11)
|
||||
|
||||
@@ -1272,6 +1429,7 @@ class Screen13(tk.Frame):
|
||||
key = addtoDB.addToSSB(path_to_image, info_text, 1)
|
||||
|
||||
# If we have a migration ticket, append the message ID to the description file
|
||||
<<<<<<< HEAD
|
||||
#if migration_ticket:
|
||||
# description_file_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.txt")
|
||||
# try:
|
||||
@@ -1280,6 +1438,16 @@ class Screen13(tk.Frame):
|
||||
# print(f"Appended message ID to description file: {description_file_path}")
|
||||
# except Exception as e:
|
||||
# print(f"Error appending message ID to description file: {e}")
|
||||
=======
|
||||
if migration_ticket:
|
||||
description_file_path = os.path.join(MIGRATION_ITEMS_DIR, f"{migration_ticket}.txt")
|
||||
try:
|
||||
with open(description_file_path, 'a') as f:
|
||||
f.write(f"\n\n\n{key}")
|
||||
print(f"Appended message ID to description file: {description_file_path}")
|
||||
except Exception as e:
|
||||
print(f"Error appending message ID to description file: {e}")
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
|
||||
# ssb give! (make sure we have a UID to give to first)
|
||||
if GlobalVars.selected_user and GlobalVars.selected_user.strip() != "":
|
||||
@@ -1405,7 +1573,12 @@ class Screen14(tk.Frame):
|
||||
self.cancel_button.pack(pady=20)
|
||||
|
||||
self.qreader = QReader()
|
||||
<<<<<<< HEAD
|
||||
# Prefer the external NexiGo USB webcam when available
|
||||
self.cap = cv2.VideoCapture(get_preferred_camera_index("NexiGo"))
|
||||
=======
|
||||
self.cap = cv2.VideoCapture(2)
|
||||
>>>>>>> 3c6876420f54a89e24d2463323777d9d3342131b
|
||||
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
||||
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Custodisco Kiosk - A touchscreen kiosk for item tagging."""
|
||||
from kiosk.app import Kiosk
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
|
||||
__all__ = ['Kiosk', 'CONFIG', 'BG_COLOR', 'GlobalVars']
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Main Kiosk application controller."""
|
||||
import subprocess
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont, messagebox
|
||||
|
||||
from kiosk.config import CONFIG
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.screens.home import Screen0
|
||||
|
||||
|
||||
class Kiosk(tk.Tk):
|
||||
"""Main kiosk application controller."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
tk.Tk.__init__(self, *args, **kwargs)
|
||||
self.frame = None
|
||||
self.frames_history = []
|
||||
self.geometry('1366x768')
|
||||
self.attributes('-fullscreen', True)
|
||||
self.config(cursor="crosshair")
|
||||
self.QRX = None
|
||||
self.QRY = None
|
||||
self.QRscale = 3
|
||||
|
||||
# Initialize fonts
|
||||
GlobalVars.BUTTON_FONT = tkfont.Font(size=24, family='Helvetica')
|
||||
GlobalVars.TEXT_FONT = tkfont.Font(size=30, family='Georgia')
|
||||
|
||||
# Key blocking for kiosk mode (unless developer_mode is enabled)
|
||||
self.keys_blocked = False
|
||||
if not CONFIG.get("developer_mode", False):
|
||||
self._check_network_and_block_keys()
|
||||
|
||||
self.switch_frame(Screen0)
|
||||
|
||||
def switch_frame(self, frame_class, keep_history=True, **kwargs):
|
||||
if keep_history and self.frame:
|
||||
self.frames_history.append(type(self.frame))
|
||||
new_frame = frame_class(self, **kwargs)
|
||||
if self.frame is not None:
|
||||
self.frame.destroy()
|
||||
self.frame = new_frame
|
||||
self.frame.pack(fill="both", expand=True)
|
||||
|
||||
def add_home_button(self, frame):
|
||||
# Create the "Start Over" button
|
||||
home_button = tk.Button(frame, text="Start over from the beginning", command=self.show_warning_dialog, bg='peach puff', width=24, font=GlobalVars.BUTTON_FONT)
|
||||
home_button.place(x=0, y=0) # top-left corner
|
||||
|
||||
def show_warning_dialog(self):
|
||||
result = messagebox.askokcancel("Warning", "If you start over, you will lose all information entered so far. Are you sure you want to start over?", icon='warning')
|
||||
if result:
|
||||
self.start_over()
|
||||
|
||||
def _has_network(self):
|
||||
"""Check if we have network connectivity."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["ping", "-c", "1", "-W", "2", "8.8.8.8"],
|
||||
capture_output=True, check=True
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def _block_keys(self):
|
||||
"""Block Alt/Super keys to prevent exiting kiosk."""
|
||||
if self.keys_blocked:
|
||||
return
|
||||
self.bind_all("<Alt_L>", lambda e: "break")
|
||||
self.bind_all("<Alt_R>", lambda e: "break")
|
||||
self.bind_all("<Super_L>", lambda e: "break")
|
||||
self.bind_all("<Super_R>", lambda e: "break")
|
||||
self.bind_all("<Alt-Tab>", lambda e: "break")
|
||||
self.bind_all("<Alt-F4>", lambda e: "break")
|
||||
self.keys_blocked = True
|
||||
|
||||
def _check_network_and_block_keys(self):
|
||||
"""Check network; block keys if connected, otherwise retry."""
|
||||
if self._has_network():
|
||||
self._block_keys()
|
||||
else:
|
||||
self.after(5000, self._check_network_and_block_keys)
|
||||
|
||||
def start_over(self):
|
||||
# Reset global variables
|
||||
GlobalVars.qr_code_value = None
|
||||
GlobalVars.print_type = "neither"
|
||||
GlobalVars.selected_user = None
|
||||
GlobalVars.ribbon_size = None
|
||||
GlobalVars.participation_type = None
|
||||
|
||||
# Switch to the home screen
|
||||
self.switch_frame(Screen0)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Configuration loading and global settings."""
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load configuration from config.json."""
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config.json')
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print("Warning: config.json not found. Copy config.example.json to config.json and customize it.")
|
||||
return {
|
||||
"printers": {"sticker": "sticker_printer", "ribbon": "tag-printer"},
|
||||
"camera": {"preferred_name": "NexiGo"},
|
||||
"ribbon": {"width": 450, "margin": 50},
|
||||
"background_color": "#bcfef9"
|
||||
}
|
||||
|
||||
|
||||
CONFIG = load_config()
|
||||
BG_COLOR = CONFIG.get("background_color", "#bcfef9")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Screen components for the kiosk application."""
|
||||
from .home import Screen0
|
||||
from .intro import HaveYouGeneratedTagScreen, InfoPage1, InfoPage2, InfoPage3
|
||||
from .ssb_selection import Screen1, ScreenWhyScuttlebutt, Screen2
|
||||
from .camera import Screen3, Screen14
|
||||
from .drawing import Screen4, Screen8
|
||||
from .description import Screen5
|
||||
from .confirmation import Screen6, Screen11, ScreenRibbonSize
|
||||
from .print_flow import Screen13, PrintFailedScreen
|
||||
from .completion import Screen10
|
||||
from .lookup import Screen12
|
||||
from .placeholder import Screen7, Screen9
|
||||
from .no_qr_flow import NoQRRibbonSizeScreen, NoQRDrawingScreen, NoQRImportImageScreen, NoQRPrintScreen
|
||||
|
||||
__all__ = [
|
||||
'Screen0',
|
||||
'HaveYouGeneratedTagScreen', 'InfoPage1', 'InfoPage2', 'InfoPage3',
|
||||
'Screen1', 'ScreenWhyScuttlebutt', 'Screen2',
|
||||
'Screen3', 'Screen14',
|
||||
'Screen4', 'Screen8',
|
||||
'Screen5',
|
||||
'Screen6', 'Screen11', 'ScreenRibbonSize',
|
||||
'Screen13', 'PrintFailedScreen',
|
||||
'Screen10',
|
||||
'Screen12',
|
||||
'Screen7', 'Screen9',
|
||||
'NoQRRibbonSizeScreen', 'NoQRDrawingScreen', 'NoQRImportImageScreen', 'NoQRPrintScreen',
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Add User screen - scan QR code of SSB public key."""
|
||||
import tkinter as tk
|
||||
from PIL import Image, ImageTk
|
||||
import cv2
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from pyzbar.pyzbar import decode as pyzbar_decode
|
||||
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import get_preferred_camera_index
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
SSB_KEY_REGEX = re.compile(r'^@[A-Za-z0-9+/]{43}=\.ed25519$')
|
||||
|
||||
|
||||
class ScreenAddUser(tk.Frame):
|
||||
"""Scan a QR code containing an SSB public key to add a new user."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
self.scanned_key = None
|
||||
|
||||
# Main container
|
||||
self.main_container = tk.Frame(self, bg=BG_COLOR)
|
||||
self.main_container.pack(fill='both', expand=True, pady=(60, 0))
|
||||
|
||||
# Left side: instructions
|
||||
self.instruction_frame = tk.Frame(self.main_container, bg=BG_COLOR)
|
||||
self.instruction_frame.pack(side='left', fill='both', expand=True, padx=20, pady=20)
|
||||
|
||||
self.instruction_label = RoundedLabel(
|
||||
self.instruction_frame,
|
||||
text="You can input your SSB public key into the kiosk via QR code. First, copy your public key from your Scuttlebutt client (in Manyverse you'll want to tap your key and then tap 'Copy cypherlink'.\n\nNext, navigate to www.cust.ooo/ssb and paste your key into the text box on that page and click Generate QR. This will generate a QR code for you. Hold this code in front of the kiosk to scan the code and load your public key into this kiosk.",
|
||||
font=("Georgia", 16),
|
||||
bg='white',
|
||||
wraplength=400,
|
||||
justify='left'
|
||||
)
|
||||
self.instruction_label.pack(expand=True)
|
||||
|
||||
# Right side container
|
||||
self.right_frame = tk.Frame(self.main_container, bg=BG_COLOR)
|
||||
self.right_frame.pack(side='right', fill='both', expand=True, padx=20, pady=20)
|
||||
|
||||
# Video feed
|
||||
self.video = tk.Label(self.right_frame, bg='black')
|
||||
self.video.pack(pady=(0, 10))
|
||||
|
||||
# Status/detected key text
|
||||
self.status_label = tk.Label(
|
||||
self.right_frame,
|
||||
text="Waiting for QR code...",
|
||||
font=("Georgia", 14),
|
||||
bg=BG_COLOR, fg='black',
|
||||
wraplength=500,
|
||||
justify='center'
|
||||
)
|
||||
self.status_label.pack(pady=(0, 10))
|
||||
|
||||
# Button row
|
||||
self.button_frame = tk.Frame(self.right_frame, bg=BG_COLOR)
|
||||
self.button_frame.pack(pady=10)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_button = tk.Button(
|
||||
self.button_frame,
|
||||
text="Cancel",
|
||||
command=self._go_back,
|
||||
height=2, width=15,
|
||||
bg='peach puff',
|
||||
font=GlobalVars.BUTTON_FONT
|
||||
)
|
||||
self.cancel_button.pack(side='left', padx=10)
|
||||
|
||||
# Done button (hidden until valid key scanned)
|
||||
self.done_button = tk.Button(
|
||||
self.button_frame,
|
||||
text="Yes!",
|
||||
command=self._done,
|
||||
height=2, width=15,
|
||||
bg='peach puff',
|
||||
font=GlobalVars.BUTTON_FONT
|
||||
)
|
||||
# Don't pack yet - shown after valid scan
|
||||
|
||||
# Start camera
|
||||
self.cap = cv2.VideoCapture(get_preferred_camera_index(CONFIG["camera"]["preferred_name"]))
|
||||
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
||||
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
||||
|
||||
self.frame_count = 0
|
||||
self.TIMEOUT_SECONDS = 120
|
||||
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
|
||||
|
||||
if ret:
|
||||
# Process every 3rd frame for QR detection
|
||||
if self.frame_count % 3 == 0 and self.scanned_key is None:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
results = pyzbar_decode(gray)
|
||||
|
||||
if results:
|
||||
qr_value = results[0].data.decode('utf-8')
|
||||
if qr_value:
|
||||
self.start_time = time.time() # Reset idle timer
|
||||
if SSB_KEY_REGEX.match(qr_value):
|
||||
self.scanned_key = qr_value
|
||||
self.status_label.configure(
|
||||
text=f"Is this your key?\n{qr_value}",
|
||||
fg='dark green'
|
||||
)
|
||||
self.done_button.pack(side='left', padx=10)
|
||||
else:
|
||||
self.status_label.configure(
|
||||
text="Sorry, we need a QR code of a valid Scuttlebutt public key",
|
||||
fg='red'
|
||||
)
|
||||
|
||||
# Display frame
|
||||
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(rgb_frame)
|
||||
imgtk = ImageTk.PhotoImage(image=image)
|
||||
self.video.imgtk = imgtk
|
||||
self.video.configure(image=imgtk)
|
||||
|
||||
self.after(10, self.update_frame)
|
||||
|
||||
def _go_back(self):
|
||||
self.cap.release()
|
||||
from kiosk.screens.ssb_selection import Screen2
|
||||
self.master.switch_frame(Screen2)
|
||||
|
||||
def _done(self):
|
||||
if self.scanned_key is None:
|
||||
return
|
||||
|
||||
GlobalVars.selected_user = self.scanned_key
|
||||
self._save_user(self.scanned_key)
|
||||
self.cap.release()
|
||||
|
||||
from kiosk.screens.camera import Screen3
|
||||
self.master.switch_frame(Screen3)
|
||||
|
||||
def _save_user(self, key):
|
||||
"""Append user to users.json if not already present."""
|
||||
try:
|
||||
with open('users.json') as f:
|
||||
users = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
users = []
|
||||
|
||||
# Check if already exists
|
||||
if any(u['id'] == key for u in users):
|
||||
return
|
||||
|
||||
users.append({"id": key, "alias": ""})
|
||||
|
||||
with open('users.json', 'w') as f:
|
||||
json.dump(users, f)
|
||||
|
||||
def destroy(self):
|
||||
if self.cap.isOpened():
|
||||
self.cap.release()
|
||||
super().destroy()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Camera screens (Screen3 for photo, Screen14 for QR lookup)."""
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
from PIL import Image, ImageTk
|
||||
import cv2
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from pyzbar.pyzbar import decode as pyzbar_decode
|
||||
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import get_preferred_camera_index, MEDIA_DIR
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
|
||||
class Screen3(tk.Frame):
|
||||
"""Take photo of item."""
|
||||
def __init__(self, master=None):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
# Create the "Start Over" button
|
||||
home_button = tk.Button(text="Start over from the beginning", command=self.show_warning_dialog, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
home_button.place(x=0, y=0) # top-left corner
|
||||
|
||||
# Prefer the external NexiGo USB webcam when available
|
||||
self.vid = cv2.VideoCapture(get_preferred_camera_index(CONFIG["camera"]["preferred_name"]))
|
||||
self.is_capturing = True
|
||||
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))
|
||||
self.canvas.pack(side="left")
|
||||
|
||||
# Info and button on the right
|
||||
self.text_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
self.text_frame.pack(side="right", fill="both", expand=True)
|
||||
self.button = tk.Button(self.text_frame, text="Take Photo", command=self.take_photo, height=3, width=37, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
self.button.pack(pady=10)
|
||||
|
||||
# Instructional text for photo
|
||||
instruction_text = ("Now take a photo of your object. If you don't have it with you "
|
||||
"you can draw a detailed drawing of it and take a picture of that. "
|
||||
"Please be mindful of who/what you include in the photo: "
|
||||
"THIS PHOTO IS POSTED ONLINE AND CANNOT BE DELETED")
|
||||
self.instruction_label = RoundedLabel(self.text_frame, text=instruction_text, bg='white',
|
||||
font=('Georgia', 18), wraplength=500, justify="center")
|
||||
self.instruction_label.pack(pady=10)
|
||||
|
||||
self.done_button = tk.Button(self, text="Done", command=self.done, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
# Done button starts hidden; revealed after a photo is taken
|
||||
|
||||
self.update_image()
|
||||
|
||||
# Bind the destroy event to release resources
|
||||
self.bind("<Destroy>", self.on_destroy)
|
||||
|
||||
def show_warning_dialog(self):
|
||||
result = messagebox.askokcancel("Warning", "If you start over, you will lose all information entered so far. Are you sure you want to start over?", icon='warning')
|
||||
if result:
|
||||
self.start_over()
|
||||
|
||||
def start_over(self):
|
||||
self.release_resources()
|
||||
from kiosk.screens.home import Screen0
|
||||
self.master.switch_frame(Screen0)
|
||||
|
||||
def update_image(self):
|
||||
if self.is_capturing and self.vid.isOpened():
|
||||
ret, frame = self.vid.read()
|
||||
if ret:
|
||||
self.cv2image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA)
|
||||
self.img = Image.fromarray(self.cv2image)
|
||||
self.imgtk = ImageTk.PhotoImage(image=self.img)
|
||||
self.canvas.delete("all") # Clear previous image
|
||||
self.canvas.create_image(0, 0, image=self.imgtk, anchor='nw')
|
||||
elif self.last_photo:
|
||||
self.canvas.delete("all")
|
||||
self.canvas.create_image(0, 0, image=self.last_photo, anchor='nw')
|
||||
|
||||
if self.countdown_text:
|
||||
self.canvas.create_text(self.canvas.winfo_width() // 2, self.canvas.winfo_height() // 2,
|
||||
text=self.countdown_text, fill="white", font=("Georgia", 120))
|
||||
|
||||
self.after(10, self.update_image)
|
||||
|
||||
def take_photo(self):
|
||||
self.is_capturing = True
|
||||
self.countdown_text = None
|
||||
countdown_thread = threading.Thread(target=self.countdown)
|
||||
countdown_thread.start()
|
||||
|
||||
def countdown(self):
|
||||
for i in range(5, 0, -1):
|
||||
self.countdown_text = str(i)
|
||||
time.sleep(1)
|
||||
|
||||
self.countdown_text = None
|
||||
|
||||
# Capture the current frame
|
||||
ret, frame = self.vid.read()
|
||||
if ret:
|
||||
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
pil_image = Image.fromarray(rgb_image)
|
||||
pil_image.save(os.path.join(MEDIA_DIR, 'freeze_frame.jpg'))
|
||||
self.photo_taken = True
|
||||
self.display_taken_photo()
|
||||
|
||||
self.is_capturing = False
|
||||
|
||||
def display_taken_photo(self):
|
||||
image = Image.open(os.path.join(MEDIA_DIR, 'freeze_frame.jpg'))
|
||||
self.last_photo = ImageTk.PhotoImage(image)
|
||||
self.canvas.delete("all") # Clear the canvas
|
||||
self.canvas.create_image(0, 0, image=self.last_photo, anchor='nw')
|
||||
self.done_button.place(relx=0.9, rely=0.9, anchor='se')
|
||||
self.button.config(text="Re-Take Photo")
|
||||
|
||||
def done(self):
|
||||
self.release_resources()
|
||||
from kiosk.screens.description import Screen5
|
||||
self.master.switch_frame(Screen5)
|
||||
|
||||
def release_resources(self):
|
||||
self.is_capturing = False
|
||||
if self.vid.isOpened():
|
||||
self.vid.release()
|
||||
|
||||
def on_destroy(self, event):
|
||||
self.release_resources()
|
||||
|
||||
def __del__(self):
|
||||
self.release_resources()
|
||||
|
||||
|
||||
class Screen14(tk.Frame):
|
||||
"""Lookup item - QR scanner."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg='white')
|
||||
|
||||
# Main container
|
||||
self.main_container = tk.Frame(self, bg='white')
|
||||
self.main_container.pack(fill='both', expand=True)
|
||||
|
||||
# Video feed frame (left side)
|
||||
self.video_frame = tk.Frame(self.main_container, bg='white')
|
||||
self.video_frame.pack(side='left', fill='both', expand=True, padx=20, pady=20)
|
||||
|
||||
# Instruction frame (right side)
|
||||
self.instruction_frame = tk.Frame(self.main_container, bg='white')
|
||||
self.instruction_frame.pack(side='right', fill='both', expand=True, padx=20, pady=20)
|
||||
|
||||
# Setup the video feed
|
||||
self.video = tk.Label(self.video_frame, bg='white')
|
||||
self.video.pack(fill='both', expand=True)
|
||||
|
||||
# Setup the instruction
|
||||
self.instruction = tk.Label(self.instruction_frame,
|
||||
text="Hold the QR code up to the camera",
|
||||
font=("Georgia", 36),
|
||||
bg='white', fg='black',
|
||||
wraplength=500,
|
||||
justify='center')
|
||||
self.instruction.pack(fill='both', expand=True)
|
||||
|
||||
# Deferred import for navigation
|
||||
def go_home():
|
||||
from kiosk.screens.home import Screen0
|
||||
self.master.switch_frame(Screen0)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_button = tk.Button(self.instruction_frame,
|
||||
text="Cancel",
|
||||
command=go_home,
|
||||
height=3,
|
||||
width=30,
|
||||
bg='white', fg='black',
|
||||
font=GlobalVars.BUTTON_FONT)
|
||||
self.cancel_button.pack(pady=20)
|
||||
|
||||
# Prefer the external NexiGo USB webcam when available
|
||||
self.cap = cv2.VideoCapture(get_preferred_camera_index(CONFIG["camera"]["preferred_name"]))
|
||||
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
||||
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
|
||||
|
||||
if ret:
|
||||
# Process every 3rd frame for QR detection
|
||||
if self.frame_count % 3 == 0:
|
||||
# Convert to grayscale for QR detection
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
results = pyzbar_decode(gray)
|
||||
|
||||
if results:
|
||||
qr_value = results[0].data.decode('utf-8')
|
||||
if qr_value:
|
||||
GlobalVars.qr_code_value = qr_value
|
||||
print(f"QR code scanned: {GlobalVars.qr_code_value}")
|
||||
self.cap.release()
|
||||
from kiosk.screens.lookup import Screen12
|
||||
self.master.switch_frame(Screen12)
|
||||
return
|
||||
|
||||
# Display the original frame
|
||||
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(rgb_frame)
|
||||
imgtk = ImageTk.PhotoImage(image=image)
|
||||
self.video.imgtk = imgtk
|
||||
self.video.configure(image=imgtk)
|
||||
|
||||
self.after(10, self.update_frame)
|
||||
|
||||
def destroy(self):
|
||||
if self.cap.isOpened():
|
||||
self.cap.release()
|
||||
super().destroy()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Completion screen (Screen10 - Thank you)."""
|
||||
import tkinter as tk
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import MEDIA_DIR
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
|
||||
class Screen10(tk.Frame):
|
||||
"""Thank you / completion screen."""
|
||||
def __init__(self, master):
|
||||
GlobalVars.selected_user = None # Reset the selected user
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
# Center content vertically and horizontally
|
||||
container = tk.Frame(self, bg=BG_COLOR)
|
||||
container.pack(expand=True)
|
||||
|
||||
RoundedLabel(container, text="Thank you!", bg='white', font=('Georgia', 48)).pack(pady=(0, 20))
|
||||
|
||||
# Video player frame
|
||||
self.video_frame = tk.Frame(container, width=640, height=400, bg='black')
|
||||
self.video_frame.pack(pady=20)
|
||||
self.video_frame.pack_propagate(False)
|
||||
|
||||
# Video label for displaying frames
|
||||
self.video_label = tk.Label(self.video_frame, bg='black')
|
||||
self.video_label.pack(fill='both', expand=True)
|
||||
|
||||
# Determine which video to play
|
||||
self.video_file = self._get_video_file()
|
||||
self.cap = None
|
||||
self.running = False
|
||||
self.photo = None # Keep reference to prevent garbage collection
|
||||
|
||||
if self.video_file and os.path.exists(self.video_file):
|
||||
self.cap = cv2.VideoCapture(self.video_file)
|
||||
self.running = True
|
||||
self._update_frame()
|
||||
|
||||
# Sticker tip - only show when a sticker was printed
|
||||
if GlobalVars.print_type == "sticker":
|
||||
RoundedLabel(container,
|
||||
text="You might like to add some packing tape on top\nof your sticker to make it more permanent",
|
||||
bg='white', font=('Georgia', 16)).pack(pady=(10, 0))
|
||||
|
||||
# Button container for side-by-side layout
|
||||
button_frame = tk.Frame(container, bg=BG_COLOR)
|
||||
button_frame.pack(pady=10)
|
||||
|
||||
tk.Button(button_frame, text="Done", command=self._cleanup_and_done,
|
||||
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=10)
|
||||
tk.Button(button_frame, text="printer problems?", command=self._cleanup_and_failed,
|
||||
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=10)
|
||||
|
||||
def _get_video_file(self):
|
||||
"""Determine which video to play based on print type and config."""
|
||||
if GlobalVars.print_type == "sticker":
|
||||
return os.path.join(MEDIA_DIR, "sticker.mp4")
|
||||
elif GlobalVars.print_type == "ribbon":
|
||||
has_cutter = CONFIG.get("ribbon", {}).get("has_cutter", False)
|
||||
if has_cutter:
|
||||
return os.path.join(MEDIA_DIR, "ribbon-no-cut.mp4")
|
||||
else:
|
||||
return os.path.join(MEDIA_DIR, "ribbon-cut.mp4")
|
||||
return None
|
||||
|
||||
def _update_frame(self):
|
||||
"""Update video frame using OpenCV."""
|
||||
if not self.running or self.cap is None:
|
||||
return
|
||||
|
||||
ret, frame = self.cap.read()
|
||||
if not ret:
|
||||
# Loop: reset to beginning
|
||||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
ret, frame = self.cap.read()
|
||||
if not ret:
|
||||
return
|
||||
|
||||
# Convert BGR to RGB
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
|
||||
# Get original dimensions
|
||||
h, w = frame.shape[:2]
|
||||
target_w, target_h = 640, 400 # Larger video area
|
||||
|
||||
# Calculate scale to fit while preserving aspect ratio
|
||||
scale = min(target_w / w, target_h / h)
|
||||
new_w, new_h = int(w * scale), int(h * scale)
|
||||
|
||||
# Resize preserving aspect ratio
|
||||
frame = cv2.resize(frame, (new_w, new_h))
|
||||
|
||||
# Create black background and center the frame (letterboxing)
|
||||
canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8)
|
||||
x_offset = (target_w - new_w) // 2
|
||||
y_offset = (target_h - new_h) // 2
|
||||
canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = frame
|
||||
frame = canvas
|
||||
|
||||
# Convert to PIL Image then to PhotoImage
|
||||
img = Image.fromarray(frame)
|
||||
self.photo = ImageTk.PhotoImage(image=img)
|
||||
self.video_label.config(image=self.photo)
|
||||
|
||||
# Schedule next frame update (~30fps = 33ms)
|
||||
if self.running:
|
||||
self.after(33, self._update_frame)
|
||||
|
||||
def _cleanup_and_done(self):
|
||||
"""Stop video and go to home screen."""
|
||||
self.running = False
|
||||
if self.cap:
|
||||
self.cap.release()
|
||||
from kiosk.screens.home import Screen0
|
||||
self.master.switch_frame(Screen0)
|
||||
|
||||
def _cleanup_and_failed(self):
|
||||
"""Stop video and go to print failed screen."""
|
||||
self.running = False
|
||||
if self.cap:
|
||||
self.cap.release()
|
||||
from kiosk.screens.print_flow import PrintFailedScreen
|
||||
self.master.switch_frame(PrintFailedScreen)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Confirmation screens (Screen6, Screen11, ScreenRibbonSize)."""
|
||||
import tkinter as tk
|
||||
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
|
||||
class Screen6(tk.Frame):
|
||||
"""I understand screen."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
master.add_home_button(self)
|
||||
|
||||
def go_to_screen3():
|
||||
from kiosk.screens.camera import Screen3
|
||||
master.switch_frame(Screen3)
|
||||
|
||||
tk.Button(self, text="I Understand", command=go_to_screen3, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=10)
|
||||
|
||||
|
||||
class Screen11(tk.Frame):
|
||||
"""Sticker or tag selection screen."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Instructions
|
||||
self.label = RoundedLabel(self, text="Which type of tag would you like to design?",
|
||||
wraplength=400,
|
||||
font=GlobalVars.TEXT_FONT,
|
||||
bg='white')
|
||||
self.label.pack(pady=50)
|
||||
|
||||
# Button functions
|
||||
def select_ribbon():
|
||||
GlobalVars.print_type = 'ribbon'
|
||||
master.switch_frame(ScreenRibbonSize)
|
||||
|
||||
def select_sticker():
|
||||
GlobalVars.print_type = 'sticker'
|
||||
from kiosk.screens.drawing import Screen4
|
||||
master.switch_frame(Screen4)
|
||||
|
||||
# Buttons
|
||||
tk.Button(self, text="Sticker", command=select_sticker, height=4, width=39, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
tk.Button(self, text="Ribbon tag", command=select_ribbon, height=4, width=39, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
|
||||
class ScreenRibbonSize(tk.Frame):
|
||||
"""Select ribbon size screen."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
# Get ribbon config
|
||||
self.ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
|
||||
self.margin = CONFIG.get("ribbon", {}).get("margin", 50)
|
||||
self.printable_width = self.ribbon_width - 50 # 25px margin each side
|
||||
|
||||
# Spacer to push content toward the bottom
|
||||
spacer = tk.Frame(self, bg=BG_COLOR)
|
||||
spacer.pack(fill='both', expand=True)
|
||||
|
||||
# Add home button after spacer so it isn't covered
|
||||
master.add_home_button(self)
|
||||
|
||||
# Title
|
||||
title_label = RoundedLabel(self, text="Select your ribbon size:",
|
||||
font=GlobalVars.TEXT_FONT, bg='white', padx=40, wraplength=0)
|
||||
title_label.pack(pady=30)
|
||||
|
||||
# Container for the three size options
|
||||
options_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
options_frame.pack(pady=20)
|
||||
|
||||
# Size options with multipliers
|
||||
sizes = [
|
||||
('Small', 0.5, 'Half-height drawing'),
|
||||
('Medium', 1.0, 'Square drawing'),
|
||||
('Large', 1.5, 'Tall drawing')
|
||||
]
|
||||
|
||||
for size_name, multiplier, description in sizes:
|
||||
self.create_size_option(options_frame, size_name, multiplier, description, master)
|
||||
|
||||
def create_size_option(self, parent, size_name, multiplier, description, master):
|
||||
# Frame for each option
|
||||
option_frame = tk.Frame(parent, bg=BG_COLOR, padx=20)
|
||||
option_frame.pack(side='left', padx=20)
|
||||
|
||||
# Calculate dimensions for this size (based on printable width)
|
||||
drawing_height = int(self.printable_width * multiplier)
|
||||
qr_scale = self.printable_width // 21
|
||||
qr_size = 21 * qr_scale
|
||||
# Total height includes top/bottom margins
|
||||
total_height = self.margin + drawing_height + qr_size + self.margin
|
||||
|
||||
# Create preview canvas (scaled down for display)
|
||||
preview_scale = 0.3
|
||||
preview_width = int(self.ribbon_width * preview_scale)
|
||||
preview_height = int(total_height * preview_scale)
|
||||
margin_preview = int(self.margin * preview_scale)
|
||||
drawing_preview_height = int(drawing_height * preview_scale)
|
||||
qr_preview_size = int(qr_size * preview_scale)
|
||||
side_margin_preview = int(25 * preview_scale) # 25px side margins
|
||||
|
||||
canvas = tk.Canvas(option_frame, width=preview_width, height=preview_height,
|
||||
bg='white', highlightthickness=2, highlightbackground='gray')
|
||||
canvas.pack(pady=10)
|
||||
|
||||
# Draw top margin area
|
||||
canvas.create_rectangle(0, 0, preview_width, margin_preview,
|
||||
fill='#f5f5f5', outline='')
|
||||
|
||||
# Draw drawing area (with side margins indicated)
|
||||
canvas.create_rectangle(side_margin_preview, margin_preview,
|
||||
preview_width - side_margin_preview, margin_preview + drawing_preview_height,
|
||||
fill='#e0e0e0', outline='black')
|
||||
canvas.create_text(preview_width // 2, margin_preview + drawing_preview_height // 2,
|
||||
text="Drawing", font=('Georgia', 10))
|
||||
|
||||
# Draw QR indicator (centered)
|
||||
qr_x = (preview_width - qr_preview_size) // 2
|
||||
qr_y = margin_preview + drawing_preview_height
|
||||
canvas.create_rectangle(qr_x, qr_y,
|
||||
qr_x + qr_preview_size, qr_y + qr_preview_size,
|
||||
fill='#808080', outline='black')
|
||||
canvas.create_text(preview_width // 2, qr_y + qr_preview_size // 2,
|
||||
text="QR", font=('Georgia', 8), fill='white')
|
||||
|
||||
# Draw bottom margin area
|
||||
canvas.create_rectangle(0, qr_y + qr_preview_size, preview_width, preview_height,
|
||||
fill='#f5f5f5', outline='')
|
||||
|
||||
# Size name and dimensions button (show printable dimensions)
|
||||
button_text = f"{size_name}\n{self.printable_width}×{drawing_height}px"
|
||||
btn = tk.Button(option_frame, text=button_text,
|
||||
command=lambda s=size_name.lower(): self.select_size(s, master),
|
||||
height=3, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
btn.pack(pady=10)
|
||||
|
||||
# Description label (hidden)
|
||||
# desc_label = tk.Label(option_frame, text=description,
|
||||
# font=('Georgia', 12), bg='white')
|
||||
# desc_label.pack()
|
||||
|
||||
def select_size(self, size, master):
|
||||
GlobalVars.ribbon_size = size
|
||||
from kiosk.screens.drawing import Screen8
|
||||
master.switch_frame(Screen8)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Description entry screen (Screen5)."""
|
||||
import tkinter as tk
|
||||
|
||||
from kiosk.config import BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import sanitize_for_ssb
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
|
||||
class Screen5(tk.Frame):
|
||||
"""Typed description entry screen."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
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 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
|
||||
self.info_entry = tk.Text(self, height=10, width=50, font=("Georgia", 16))
|
||||
self.info_entry.pack(pady=10)
|
||||
|
||||
# Adding the done button
|
||||
self.done_button = tk.Button(self, text="Done", command=self.save_info_and_switch, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
self.done_button.pack(pady=10)
|
||||
|
||||
# Setting the focus to the text entry field (deferred until widget is visible)
|
||||
self.info_entry.after(100, self.info_entry.focus_set)
|
||||
|
||||
def save_info_and_switch(self):
|
||||
GlobalVars.info_text = self.info_entry.get("1.0", "end-1c")
|
||||
GlobalVars.info_text = sanitize_for_ssb(GlobalVars.info_text)
|
||||
|
||||
from kiosk.screens.confirmation import Screen11
|
||||
self.master.switch_frame(Screen11)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Drawing screens (Screen4 for sticker, Screen8 for ribbon)."""
|
||||
import tkinter as tk
|
||||
from tkinter import Canvas, filedialog
|
||||
from PIL import Image, ImageTk
|
||||
import os
|
||||
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import MEDIA_DIR
|
||||
from kiosk.widgets import RoundedLabel, DrawingMixin, PEN_ICON_SIZES, make_pen_icon
|
||||
|
||||
|
||||
class Screen4(tk.Frame, DrawingMixin):
|
||||
"""Draw a sticker."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
# Configure column minsizes
|
||||
self.grid_columnconfigure(0, minsize=622)
|
||||
self.grid_columnconfigure(1, minsize=100)
|
||||
self.grid_columnconfigure(2, minsize=100)
|
||||
|
||||
# Creating a frame for the left side of the screen for drawing and instructions
|
||||
self.left_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
self.left_frame.grid(row=0, column=0, padx=2, pady=(10, 30))
|
||||
|
||||
# Frame for the tools
|
||||
self.right_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
self.right_frame.grid(row=0, column=2, padx=70)
|
||||
|
||||
# Add instructions
|
||||
self.label = RoundedLabel(self.left_frame, text="You may now draw your sticker! Anything you draw inside the QR box will get covered by a QR code", wraplength=597, font=GlobalVars.TEXT_FONT, bg='white')
|
||||
self.label.pack(pady=(20, 10))
|
||||
|
||||
# QR Size selection buttons
|
||||
self.qr_size_frame = tk.Frame(self.left_frame, bg=BG_COLOR)
|
||||
self.qr_size_frame.pack(pady=(5, 15))
|
||||
|
||||
tk.Label(self.qr_size_frame, text="QR Size:", font=GlobalVars.BUTTON_FONT, bg=BG_COLOR).pack(side='left', padx=5)
|
||||
|
||||
for name, label in [('small', 'Small'), ('medium', 'Medium'), ('large', 'Large')]:
|
||||
tk.Button(self.qr_size_frame, text=label,
|
||||
command=lambda n=name: self.set_qr_size(n),
|
||||
height=1, width=8, bg='peach puff',
|
||||
font=GlobalVars.BUTTON_FONT).pack(side='left', padx=5)
|
||||
|
||||
# Creating the Canvas for drawing
|
||||
self.canvas = Canvas(self.left_frame, width=597, height=360, bg='white')
|
||||
self.canvas.bind("<B1-Motion>", self.draw_line)
|
||||
self.image_on_canvas = None
|
||||
self.canvas.pack(pady=(20, 40))
|
||||
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
|
||||
|
||||
# Initialize drawing via mixin (grid_size=1 for stickers)
|
||||
self.init_drawing(597, 360, grid_size=1)
|
||||
self.draw_size = 4 # Override default size for stickers
|
||||
self.imported_img = None
|
||||
|
||||
# QR size positions and defaults
|
||||
# SSB message IDs = 52 bytes → Version 6 QR → 41 modules
|
||||
# QR pixel size = 41 * scale. Positions calculated with ~20px margins.
|
||||
# X positions shifted left by 53 to account for narrower canvas (scan-tag on right edge at merge)
|
||||
self.qr_positions = {
|
||||
'small': (454, 217, 3), # 123px QR
|
||||
'medium': (331, 94, 6), # 246px QR
|
||||
'large': (249, 12, 8) # 328px QR
|
||||
}
|
||||
self.qr_x, self.qr_y, self.qr_scale = self.qr_positions['small']
|
||||
self.master.QRX = self.qr_x
|
||||
self.master.QRY = self.qr_y
|
||||
self.master.QRscale = self.qr_scale
|
||||
self.add_qr_box()
|
||||
|
||||
# Create frames for pen size and color tools
|
||||
self.pen_size_frame = tk.Frame(self.right_frame, bg=BG_COLOR)
|
||||
self.pen_size_frame.pack(pady=(0, 20))
|
||||
self.pen_color_frame = tk.Frame(self.right_frame, bg=BG_COLOR)
|
||||
self.pen_color_frame.pack(pady=(0, 20))
|
||||
|
||||
# Pen size label
|
||||
tk.Label(self.pen_size_frame, text="Pen Size", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).pack()
|
||||
|
||||
# Add Draw Size buttons with square icons of increasing size
|
||||
self._pen_icons = [make_pen_icon(s) for s in PEN_ICON_SIZES]
|
||||
for i, icon in enumerate(self._pen_icons):
|
||||
tk.Button(self.pen_size_frame, image=icon, command=lambda s=i+1: self.set_draw_size(s),
|
||||
bg='peach puff', relief='raised', bd=2).pack(pady=2)
|
||||
|
||||
# Pen color label
|
||||
tk.Label(self.pen_color_frame, text="Pen Color", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).pack()
|
||||
|
||||
# Creating color buttons
|
||||
colors = ['black', 'gray', 'white']
|
||||
for color in colors:
|
||||
tk.Button(self.pen_color_frame, height=2, width=5, bg=color,
|
||||
command=lambda c=color: self.set_draw_color(c)).pack(pady=2)
|
||||
|
||||
# Add Clear Drawing Button
|
||||
tk.Button(self.right_frame, text="Clear Drawing", command=self.clear_drawing,
|
||||
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=10)
|
||||
|
||||
# Frame for Done and Import Image buttons (horizontal row)
|
||||
self.action_buttons_frame = tk.Frame(self.right_frame, bg=BG_COLOR)
|
||||
self.action_buttons_frame.pack(pady=10)
|
||||
|
||||
# Done button (left)
|
||||
tk.Button(self.action_buttons_frame, text="Done", command=self.next,
|
||||
height=2, width=8, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=5)
|
||||
|
||||
# Import Image Button (right)
|
||||
tk.Button(self.action_buttons_frame, text="Import", command=self.import_image,
|
||||
height=2, width=8, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=5)
|
||||
|
||||
# Adding a home button
|
||||
master.add_home_button(self)
|
||||
|
||||
# Define the info_label to display QRX, QRY, and QRscale values
|
||||
self.info_label = tk.Label(self.right_frame, text="", bg=BG_COLOR, font=GlobalVars.TEXT_FONT)
|
||||
self.info_label.pack(pady=5)
|
||||
|
||||
def clear_drawing(self):
|
||||
# Override to add QR box after clearing
|
||||
DrawingMixin.clear_drawing(self)
|
||||
self.imported_img = None
|
||||
self.add_qr_box()
|
||||
|
||||
def add_qr_box(self):
|
||||
"""Draw QR placeholder box using current size settings."""
|
||||
qr_size = 41 * self.qr_scale # 41 modules for Version 6 QR (52-byte SSB message ID)
|
||||
|
||||
# Draw QR placeholder
|
||||
self.canvas.create_rectangle(self.qr_x, self.qr_y,
|
||||
self.qr_x + qr_size, self.qr_y + qr_size,
|
||||
outline='black', fill='white', tags='qr_box')
|
||||
self.canvas.create_text(self.qr_x + qr_size/2, self.qr_y + qr_size/2,
|
||||
text="QR", fill="black", tags='qr_box')
|
||||
|
||||
def set_qr_size(self, size_name):
|
||||
"""Update QR size and redraw preview."""
|
||||
self.qr_x, self.qr_y, self.qr_scale = self.qr_positions[size_name]
|
||||
self.master.QRX = self.qr_x
|
||||
self.master.QRY = self.qr_y
|
||||
self.master.QRscale = self.qr_scale
|
||||
# Just delete and redraw the QR box, not the whole canvas
|
||||
self.canvas.delete('qr_box')
|
||||
self.add_qr_box()
|
||||
|
||||
def next(self):
|
||||
self.drawing.save(os.path.join(MEDIA_DIR, "drawing.png"))
|
||||
from kiosk.screens.print_flow import Screen13
|
||||
self.master.switch_frame(Screen13)
|
||||
|
||||
def import_image(self):
|
||||
file_path = filedialog.askopenfilename(filetypes=[("Image files", "*.jpg *.jpeg *.png")])
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
self.canvas.delete("all")
|
||||
img = Image.open(file_path)
|
||||
img = img.convert('1')
|
||||
if img.size[0] > 597 or img.size[1] > 360:
|
||||
img = img.crop((0, 0, 597, 360))
|
||||
|
||||
self.drawing.paste(img)
|
||||
self.imported_img = ImageTk.PhotoImage(img)
|
||||
self.canvas.create_image(0, 0, image=self.imported_img, anchor='nw')
|
||||
self.add_qr_box()
|
||||
self.info_label.config(text="") # Clear any old info
|
||||
|
||||
|
||||
class Screen8(tk.Frame, DrawingMixin):
|
||||
"""Draw a ribbon tag."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
# Get ribbon width from config and calculate dimensions based on selected size
|
||||
self.ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
|
||||
self.printable_width = self.ribbon_width - 50 # 25px margin each side
|
||||
multiplier = {'small': 0.5, 'medium': 1.0, 'large': 1.5}.get(GlobalVars.ribbon_size, 1.0)
|
||||
width = self.printable_width
|
||||
height = int(self.printable_width * multiplier)
|
||||
|
||||
# Adaptive display scaling based on size
|
||||
# Target max display height ~700px to fit comfortably on screen
|
||||
max_display_height = 700
|
||||
ideal_scale = max_display_height / height
|
||||
scale_factor = min(2, max(1, int(ideal_scale))) # Clamp between 1x and 2x
|
||||
|
||||
# Display dimensions
|
||||
display_width = width * scale_factor
|
||||
display_height = height * scale_factor
|
||||
|
||||
# Main container to hold all columns
|
||||
main_container = tk.Frame(self, bg=BG_COLOR)
|
||||
main_container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Column 1: Start Over button and instruction text
|
||||
col1_frame = tk.Frame(main_container, bg=BG_COLOR)
|
||||
col1_frame.pack(side='left', padx=(10, 10), anchor='n', pady=5)
|
||||
|
||||
# Start Over button (replaces home button) - Screen8
|
||||
tk.Button(col1_frame, text="Start Over", command=master.show_warning_dialog,
|
||||
height=1, width=12, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=(0, 15))
|
||||
|
||||
# Instruction label
|
||||
self.label = RoundedLabel(col1_frame, text="Draw your\nribbon :)",
|
||||
wraplength=150, font=GlobalVars.TEXT_FONT, bg='white')
|
||||
self.label.pack(pady=5)
|
||||
|
||||
# Column 2: Drawing canvas
|
||||
col2_frame = tk.Frame(main_container, bg=BG_COLOR)
|
||||
col2_frame.pack(side='left', padx=(5, 15))
|
||||
|
||||
# Canvas for drawing
|
||||
self.canvas = Canvas(col2_frame, width=display_width, height=display_height, bg='white')
|
||||
self.canvas.bind("<B1-Motion>", self.draw_line)
|
||||
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
|
||||
self.canvas.pack()
|
||||
|
||||
# Initialize drawing via mixin
|
||||
self.init_drawing(width, height, grid_size=scale_factor)
|
||||
self.draw_size = 4 # Second-from-largest pen size
|
||||
|
||||
# Right panel: Pen tools (grid layout) + action buttons
|
||||
right_panel = tk.Frame(main_container, bg=BG_COLOR)
|
||||
right_panel.pack(side='left', padx=15)
|
||||
|
||||
# Pen tools grid (labels align with buttons)
|
||||
pen_grid = tk.Frame(right_panel, bg=BG_COLOR)
|
||||
pen_grid.pack()
|
||||
|
||||
# Pen Size label - spans vertically alongside pen size buttons
|
||||
tk.Label(pen_grid, text="Pen\nSize", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
|
||||
row=0, column=0, rowspan=5, padx=(0, 10), sticky='n', pady=(5, 0))
|
||||
|
||||
# Pen size buttons with square icons of increasing size
|
||||
self._pen_icons = [make_pen_icon(s) for s in PEN_ICON_SIZES]
|
||||
for i, icon in enumerate(self._pen_icons):
|
||||
tk.Button(pen_grid, image=icon, command=lambda s=i+1: self.set_draw_size(s),
|
||||
bg='peach puff', relief='raised', bd=2).grid(row=i, column=1, pady=2)
|
||||
|
||||
# Pen Color label - spans vertically alongside color buttons
|
||||
tk.Label(pen_grid, text="Pen\nColor", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
|
||||
row=6, column=0, rowspan=3, padx=(0, 10), sticky='n', pady=(15, 0))
|
||||
|
||||
# Pen color buttons (3 buttons in grid column 1)
|
||||
colors = ['black', 'gray', 'white']
|
||||
for i, color in enumerate(colors):
|
||||
tk.Button(pen_grid, height=2, width=5, bg=color,
|
||||
command=lambda c=color: self.set_draw_color(c)).grid(row=6+i, column=1, pady=2)
|
||||
|
||||
# Action buttons below the grid
|
||||
action_frame = tk.Frame(right_panel, bg=BG_COLOR)
|
||||
action_frame.pack(pady=(20, 0))
|
||||
|
||||
# Import Image Button
|
||||
tk.Button(action_frame, text="Import Image", command=self.import_image,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
# Clear Drawing Button
|
||||
tk.Button(action_frame, text="Clear Drawing", command=self.clear_drawing,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
# Done button
|
||||
tk.Button(action_frame, text="Done", command=self.next,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
def import_image(self):
|
||||
self.import_image_base()
|
||||
|
||||
def next(self):
|
||||
self.drawing.save(os.path.join(MEDIA_DIR, "drawing.png"))
|
||||
from kiosk.screens.print_flow import Screen13
|
||||
self.master.switch_frame(Screen13)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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, MEDIA_DIR
|
||||
|
||||
|
||||
class Screen0(tk.Frame):
|
||||
"""Home screen with main navigation options."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
title_font = tkfont.Font(size=42, family='Helvetica') # 30% bigger
|
||||
|
||||
# Split the screen into two frames
|
||||
left_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
right_frame = tk.Frame(self, bg=BG_COLOR, padx=40) # 40px padding on the right side
|
||||
left_frame.grid(row=0, column=0, sticky='nsew')
|
||||
right_frame.grid(row=0, column=1, sticky='nsew')
|
||||
|
||||
self.grid_columnconfigure(0, weight=1, minsize=800) # For left frame
|
||||
self.grid_columnconfigure(1, weight=1) # For right frame
|
||||
|
||||
# Title and buttons on the left side - using custo.png image (scaled to 1/4)
|
||||
logo_path = os.path.join(MEDIA_DIR, 'custo.png')
|
||||
logo_image = Image.open(logo_path)
|
||||
new_size = (logo_image.width // 4, logo_image.height // 4)
|
||||
logo_image = logo_image.resize(new_size, Image.LANCZOS)
|
||||
self.logo_photo = ImageTk.PhotoImage(logo_image)
|
||||
title_label = tk.Label(left_frame, image=self.logo_photo, bg=BG_COLOR)
|
||||
title_label.pack(side='top', pady=50)
|
||||
|
||||
# Info text below logo
|
||||
info_label = RoundedLabel(left_frame, text="this is a touchscreen kiosk\n\n more information on the project at\n\nwww.cust.ooo",
|
||||
bg='white', font=GlobalVars.TEXT_FONT, wraplength=650, justify='center')
|
||||
info_label.pack(side='top', pady=20)
|
||||
|
||||
# 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():
|
||||
from kiosk.screens.no_qr_flow import NoQRRibbonSizeScreen
|
||||
master.switch_frame(NoQRRibbonSizeScreen)
|
||||
|
||||
tk.Button(right_frame, text="Generate Tag", command=go_to_generate_tag, height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
tk.Button(right_frame, text="Lookup Item", command=go_to_lookup, height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
# No QR Ribbon button (only show if enabled in config)
|
||||
if CONFIG.get("show_no_qr_ribbon", 0) == 1:
|
||||
tk.Button(right_frame, text="No QR Ribbon", command=go_to_no_qr, height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
def go_to_about():
|
||||
from kiosk.screens.intro import InfoPage1
|
||||
master.switch_frame(InfoPage1, entry='about')
|
||||
|
||||
tk.Button(right_frame, text="About", command=go_to_about, height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
# Create the quit button (only show in developer mode)
|
||||
if CONFIG.get("developer_mode", False):
|
||||
tk.Button(right_frame, text="Quit", command=self.quit_program, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=10)
|
||||
|
||||
def quit_program(self):
|
||||
self.master.destroy()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Introduction screens (HaveYouGeneratedTagScreen, InfoPage1-3)."""
|
||||
import tkinter as tk
|
||||
from PIL import Image, ImageTk
|
||||
import os
|
||||
|
||||
from kiosk.config import BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import MEDIA_DIR
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
|
||||
class HaveYouGeneratedTagScreen(tk.Frame):
|
||||
"""Have you generated a tag before?"""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
# Center container
|
||||
container = tk.Frame(self, bg=BG_COLOR)
|
||||
container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Question label
|
||||
label = RoundedLabel(container, text="have you made a tag on this kiosk before?",
|
||||
font=GlobalVars.TEXT_FONT, bg='white', padx=20, pady=10, wraplength=0)
|
||||
label.pack(pady=50)
|
||||
|
||||
# Deferred imports
|
||||
def go_to_participation():
|
||||
from kiosk.screens.participation import ParticipationScreen
|
||||
master.switch_frame(ParticipationScreen)
|
||||
|
||||
def go_to_info1():
|
||||
master.switch_frame(InfoPage1, entry='generate_tag')
|
||||
|
||||
# Buttons
|
||||
tk.Button(container, text="yea", command=go_to_participation,
|
||||
height=3, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
tk.Button(container, text="not yet!", command=go_to_info1,
|
||||
height=3, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
|
||||
|
||||
class InfoPage1(tk.Frame):
|
||||
"""Info Page 1 - Custo introduction with tags.jpg"""
|
||||
def __init__(self, master, entry='about'):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
self.entry = entry
|
||||
|
||||
# Split the screen into two frames
|
||||
left_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
right_frame = tk.Frame(self, bg=BG_COLOR, padx=40)
|
||||
left_frame.grid(row=0, column=0, sticky='nsew')
|
||||
right_frame.grid(row=0, column=1, sticky='nsew')
|
||||
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
|
||||
# Left side: tags.jpg image
|
||||
image_path = os.path.join(MEDIA_DIR, 'tags.jpg')
|
||||
img = Image.open(image_path)
|
||||
# Scale to fit nicely (max 300px height)
|
||||
max_height = 400
|
||||
if img.height > max_height:
|
||||
ratio = max_height / img.height
|
||||
new_size = (int(img.width * ratio), max_height)
|
||||
img = img.resize(new_size, Image.LANCZOS)
|
||||
self.photo = ImageTk.PhotoImage(img)
|
||||
img_label = tk.Label(left_frame, image=self.photo, bg=BG_COLOR)
|
||||
img_label.pack(expand=True)
|
||||
|
||||
# Right side: Custo introduction text
|
||||
intro_text = """This kiosk is called Custo (pronounced KOOS-toh)
|
||||
|
||||
It is here to foster and document the relationships between people and things
|
||||
|
||||
It can print stickers and ribbons to affix to an item you care about."""
|
||||
|
||||
text_label = RoundedLabel(right_frame, text=intro_text,
|
||||
font=GlobalVars.TEXT_FONT, bg='white',
|
||||
wraplength=500, justify='left', padx=20, pady=20)
|
||||
text_label.pack(expand=True)
|
||||
|
||||
# Bottom: Forward button only (delayed)
|
||||
nav_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
nav_frame.grid(row=1, column=0, columnspan=2, sticky='ew', pady=20)
|
||||
|
||||
# Countdown number indicator
|
||||
self.countdown_label = tk.Label(nav_frame, text="8", font=("Helvetica", 60), bg=BG_COLOR, fg='white')
|
||||
self.countdown_label.pack(side='left', expand=True)
|
||||
self.countdown = 8
|
||||
|
||||
self.forward_btn = tk.Button(nav_frame, text="Forward →",
|
||||
command=lambda: master.switch_frame(InfoPage2, entry=self.entry),
|
||||
height=2, width=15, bg='light gray', font=GlobalVars.BUTTON_FONT, state='disabled')
|
||||
self.forward_btn.pack(side='right', padx=40)
|
||||
|
||||
def update_countdown():
|
||||
self.countdown -= 1
|
||||
if self.countdown > 0:
|
||||
self.countdown_label.config(text=str(self.countdown))
|
||||
self.after(1000, update_countdown)
|
||||
else:
|
||||
self.countdown_label.pack_forget()
|
||||
self.forward_btn.config(state='normal', bg='peach puff')
|
||||
|
||||
self.after(1000, update_countdown)
|
||||
|
||||
|
||||
class InfoPage2(tk.Frame):
|
||||
"""Info Page 2 - Take care of each other with hard.jpg"""
|
||||
def __init__(self, master, entry='about'):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
self.entry = entry
|
||||
|
||||
# Split the screen into two frames
|
||||
left_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
right_frame = tk.Frame(self, bg=BG_COLOR, padx=40)
|
||||
left_frame.grid(row=0, column=0, sticky='nsew')
|
||||
right_frame.grid(row=0, column=1, sticky='nsew')
|
||||
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
|
||||
# Left side: hard.jpg image
|
||||
image_path = os.path.join(MEDIA_DIR, 'hard.jpg')
|
||||
img = Image.open(image_path)
|
||||
# Scale to fit nicely (max 300px height)
|
||||
max_height = 500
|
||||
if img.height > max_height:
|
||||
ratio = max_height / img.height
|
||||
new_size = (int(img.width * ratio), max_height)
|
||||
img = img.resize(new_size, Image.LANCZOS)
|
||||
self.photo = ImageTk.PhotoImage(img)
|
||||
img_label = tk.Label(left_frame, image=self.photo, bg=BG_COLOR)
|
||||
img_label.pack(expand=True)
|
||||
|
||||
# Right side: Take care of each other text
|
||||
care_text = """Items can live long lives! Let's take care of each other while we're alive.
|
||||
|
||||
by tagging an item you are saying to it, 'you matter, I will take care of you'"""
|
||||
|
||||
text_label = RoundedLabel(right_frame, text=care_text,
|
||||
font=GlobalVars.TEXT_FONT, bg='white',
|
||||
wraplength=500, justify='left', padx=20, pady=20)
|
||||
text_label.pack(expand=True)
|
||||
|
||||
# Bottom: Back and Forward buttons (forward delayed)
|
||||
nav_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
nav_frame.grid(row=1, column=0, columnspan=2, sticky='ew', pady=20)
|
||||
|
||||
tk.Button(nav_frame, text="← Back", command=lambda: master.switch_frame(InfoPage1, entry=self.entry),
|
||||
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=40)
|
||||
|
||||
# Countdown number indicator
|
||||
self.countdown_label = tk.Label(nav_frame, text="8", font=("Helvetica", 60), bg=BG_COLOR, fg='white')
|
||||
self.countdown_label.pack(side='left', expand=True)
|
||||
self.countdown = 8
|
||||
|
||||
self.forward_btn = tk.Button(nav_frame, text="Forward →",
|
||||
command=lambda: master.switch_frame(InfoPage3, entry=self.entry),
|
||||
height=2, width=15, bg='light gray', font=GlobalVars.BUTTON_FONT, state='disabled')
|
||||
self.forward_btn.pack(side='right', padx=40)
|
||||
|
||||
def update_countdown():
|
||||
self.countdown -= 1
|
||||
if self.countdown > 0:
|
||||
self.countdown_label.config(text=str(self.countdown))
|
||||
self.after(1000, update_countdown)
|
||||
else:
|
||||
self.countdown_label.pack_forget()
|
||||
self.forward_btn.config(state='normal', bg='peach puff')
|
||||
|
||||
self.after(1000, update_countdown)
|
||||
|
||||
|
||||
class InfoPage3(tk.Frame):
|
||||
"""Info Page 3 - Mindful use, permanent storage, no shitposting"""
|
||||
def __init__(self, master, entry='about'):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
self.entry = entry
|
||||
|
||||
# Center container for text
|
||||
container = tk.Frame(self, bg=BG_COLOR)
|
||||
container.place(relx=0.5, rely=0.4, anchor='center')
|
||||
|
||||
# Mindful use text
|
||||
mindful_text = """Please be mindful while operating the kiosk.
|
||||
|
||||
All generated items are stored permanently and replicated across the Scuttlebutt network (the data is stored on real people's laptops and phones, forever). Once published, it cannot be deleted.
|
||||
|
||||
Please post with care and with positive and constructive intentions.
|
||||
|
||||
The QR code contains a Scuttlebutt messageID, scanning it works best at www.cust.ooo or copy the ID into any Scuttlebutt client.
|
||||
"""
|
||||
|
||||
text_label = RoundedLabel(container, text=mindful_text,
|
||||
font=("Helvetica", 20), bg='white',
|
||||
wraplength=900, justify='center', padx=30, pady=30)
|
||||
text_label.pack()
|
||||
|
||||
# Deferred imports
|
||||
def go_forward():
|
||||
if self.entry == 'about':
|
||||
from kiosk.screens.home import Screen0
|
||||
master.switch_frame(Screen0)
|
||||
else:
|
||||
from kiosk.screens.participation import ParticipationScreen
|
||||
master.switch_frame(ParticipationScreen)
|
||||
|
||||
# Bottom: Back and Forward buttons (forward delayed)
|
||||
nav_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
nav_frame.place(relx=0.5, rely=0.9, anchor='center')
|
||||
|
||||
tk.Button(nav_frame, text="← Back", command=lambda: master.switch_frame(InfoPage2, entry=self.entry),
|
||||
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=40)
|
||||
|
||||
# Countdown number indicator
|
||||
self.countdown_label = tk.Label(nav_frame, text="8", font=("Helvetica", 60), bg=BG_COLOR, fg='white')
|
||||
self.countdown_label.pack(side='left', expand=True)
|
||||
self.countdown = 8
|
||||
|
||||
self.forward_btn = tk.Button(nav_frame, text="Forward →", command=go_forward,
|
||||
height=2, width=15, bg='light gray', font=GlobalVars.BUTTON_FONT, state='disabled')
|
||||
self.forward_btn.pack(side='right', padx=40)
|
||||
|
||||
def update_countdown():
|
||||
self.countdown -= 1
|
||||
if self.countdown > 0:
|
||||
self.countdown_label.config(text=str(self.countdown))
|
||||
self.after(1000, update_countdown)
|
||||
else:
|
||||
self.countdown_label.pack_forget()
|
||||
self.forward_btn.config(state='normal', bg='peach puff')
|
||||
|
||||
self.after(1000, update_countdown)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Lookup screen (Screen12 - after QR scanned)."""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
import addtoDB
|
||||
|
||||
from kiosk.config import BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
|
||||
class Screen12(tk.Frame):
|
||||
"""Display content after QR scanned for lookup."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
print(f"Initializing Screen12 with QR code value: {GlobalVars.qr_code_value}")
|
||||
|
||||
if GlobalVars.qr_code_value is None:
|
||||
self.display_error("Error: QR code value is None")
|
||||
return
|
||||
|
||||
try:
|
||||
qr_code_str = GlobalVars.qr_code_value if isinstance(GlobalVars.qr_code_value, str) else GlobalVars.qr_code_value.decode('utf-8')
|
||||
print(f"Attempting to get message content for: {qr_code_str}")
|
||||
message_content, image_path, replies = addtoDB.get_message_content(qr_code_str)
|
||||
print(f"Received message content: {message_content}")
|
||||
print(f"Received image path: {image_path}")
|
||||
print(f"Received replies: {replies}")
|
||||
|
||||
if message_content is None:
|
||||
self.display_error(f"Error: Failed to retrieve message content for {qr_code_str}")
|
||||
else:
|
||||
self.display_content(message_content, image_path, replies)
|
||||
except Exception as e:
|
||||
self.display_error(f"An error occurred while processing message: {str(e)}")
|
||||
|
||||
def display_error(self, message):
|
||||
print(f"Displaying error: {message}")
|
||||
error_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
error_frame.pack(expand=True, fill='both', padx=20, pady=20)
|
||||
|
||||
RoundedLabel(error_frame, text=message, bg='white', font=GlobalVars.TEXT_FONT, wraplength=500).pack(pady=50)
|
||||
|
||||
# Add "Go Back" button for error cases
|
||||
def go_home():
|
||||
from kiosk.screens.home import Screen0
|
||||
self.master.switch_frame(Screen0)
|
||||
|
||||
tk.Button(error_frame, text="Go Back", command=go_home,
|
||||
height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
|
||||
def get_alias(self, user_id):
|
||||
try:
|
||||
with open('users.json') as f:
|
||||
users = json.load(f)
|
||||
for user in users:
|
||||
if user['id'] == user_id:
|
||||
return user.get('alias', '')
|
||||
except FileNotFoundError:
|
||||
return ''
|
||||
return ''
|
||||
|
||||
def display_content(self, message_content, image_path, replies):
|
||||
# Main content frame
|
||||
content_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
content_frame.pack(expand=True, fill='both', padx=20, pady=10)
|
||||
|
||||
# Left column: Image
|
||||
left_column = tk.Frame(content_frame, bg=BG_COLOR)
|
||||
left_column.pack(side='left', fill='both', expand=False)
|
||||
|
||||
if image_path and os.path.exists(image_path):
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
img.thumbnail((550, 550)) # Slightly smaller than before
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
img_label = tk.Label(left_column, image=photo, bg=BG_COLOR)
|
||||
img_label.image = photo # Keep a reference
|
||||
img_label.pack(expand=True, fill='both')
|
||||
print(f"Displayed image: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"Error displaying image {image_path}: {e}")
|
||||
elif image_path:
|
||||
print(f"Image file not found: {image_path}")
|
||||
else:
|
||||
print("No image path provided")
|
||||
|
||||
# Right column: Scrollable text
|
||||
right_column = tk.Frame(content_frame, bg=BG_COLOR)
|
||||
right_column.pack(side='right', fill='both', padx=10, expand=True)
|
||||
|
||||
canvas = tk.Canvas(right_column, bg=BG_COLOR)
|
||||
scrollbar = ttk.Scrollbar(right_column, orient="vertical", command=canvas.yview)
|
||||
scrollable_frame = tk.Frame(canvas, bg=BG_COLOR)
|
||||
|
||||
scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
)
|
||||
|
||||
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Display original message
|
||||
text_content = message_content.get('content', {}).get('text', '')
|
||||
# Remove markdown image syntax
|
||||
text_content = re.sub(r'!\[.*?\]\(.*?\)', '', text_content).strip()
|
||||
tk.Label(scrollable_frame, text=text_content, wraplength=650, justify='left', bg='white', font=("Georgia", 28)).pack(pady=5, padx=(5, 0))
|
||||
|
||||
# Display replies
|
||||
if replies:
|
||||
tk.Label(scrollable_frame, text="Replies:", wraplength=650, justify='left', bg='white', font=("Georgia", 24, "bold")).pack(pady=(20, 5), padx=(5, 0))
|
||||
for reply in replies:
|
||||
author_id = reply.get('value', {}).get('author', 'Unknown')
|
||||
author_alias = self.get_alias(author_id)
|
||||
author_display = f"{author_alias} ({author_id})" if author_alias else author_id
|
||||
reply_text = reply.get('value', {}).get('content', {}).get('text', '')
|
||||
tk.Label(scrollable_frame, text=f"{author_display}:", wraplength=650, justify='left', bg='white', font=("Georgia", 20, "bold")).pack(pady=(10, 0), padx=(5, 0))
|
||||
tk.Label(scrollable_frame, text=reply_text, wraplength=650, justify='left', bg='white', font=("Georgia", 18)).pack(pady=(0, 10), padx=(5, 0))
|
||||
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
# Configure the scrollbar style to make it larger
|
||||
style = ttk.Style()
|
||||
style.configure("Vertical.TScrollbar", arrowsize=48, width=48)
|
||||
|
||||
# Add "Done" button for successful content display
|
||||
def go_home():
|
||||
from kiosk.screens.home import Screen0
|
||||
self.master.switch_frame(Screen0)
|
||||
|
||||
tk.Button(self, text="Done", command=go_home,
|
||||
height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side="bottom", pady=20)
|
||||
@@ -0,0 +1,431 @@
|
||||
"""No QR ribbon flow screens."""
|
||||
import tkinter as tk
|
||||
from tkinter import Canvas, filedialog
|
||||
import os
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
import tozpl
|
||||
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import MEDIA_DIR
|
||||
from kiosk.widgets import RoundedLabel, DrawingMixin, PEN_ICON_SIZES, make_pen_icon
|
||||
|
||||
|
||||
class NoQRRibbonSizeScreen(tk.Frame):
|
||||
"""No QR Ribbon - Size Selection."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Get ribbon config
|
||||
self.ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
|
||||
self.margin = CONFIG.get("ribbon", {}).get("margin", 50)
|
||||
self.printable_width = self.ribbon_width - 50 # 25px margin each side
|
||||
|
||||
# Title
|
||||
title_label = RoundedLabel(self, text="Select your ribbon size:",
|
||||
font=GlobalVars.TEXT_FONT, bg='white')
|
||||
title_label.pack(pady=30)
|
||||
|
||||
# Container for size options
|
||||
options_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
options_frame.pack(pady=20)
|
||||
|
||||
# Size options with multipliers
|
||||
sizes = [
|
||||
('Small', 0.5, 'Half-height drawing'),
|
||||
('Medium', 1.0, 'Square drawing'),
|
||||
('Large', 1.5, 'Tall drawing')
|
||||
]
|
||||
|
||||
for size_name, multiplier, description in sizes:
|
||||
self.create_size_option(options_frame, size_name, multiplier, description, master)
|
||||
|
||||
# Add Import Image option
|
||||
self.create_import_option(options_frame, master)
|
||||
|
||||
def create_import_option(self, parent, master):
|
||||
"""Create the Import Image option column"""
|
||||
option_frame = tk.Frame(parent, bg=BG_COLOR, padx=20)
|
||||
option_frame.pack(side='left', padx=20)
|
||||
|
||||
# Create preview canvas (show a simplified "variable height" preview)
|
||||
preview_scale = 0.3
|
||||
preview_width = int(self.ribbon_width * preview_scale)
|
||||
# Use medium size for base preview height
|
||||
base_height = int((self.margin + self.printable_width + self.margin) * preview_scale)
|
||||
margin_preview = int(self.margin * preview_scale)
|
||||
side_margin_preview = int(25 * preview_scale)
|
||||
|
||||
canvas = tk.Canvas(option_frame, width=preview_width, height=base_height,
|
||||
bg='white', highlightthickness=2, highlightbackground='gray')
|
||||
canvas.pack(pady=10)
|
||||
|
||||
# Draw top margin
|
||||
canvas.create_rectangle(0, 0, preview_width, margin_preview,
|
||||
fill='#f5f5f5', outline='')
|
||||
|
||||
# Draw image area (dashed to indicate variable)
|
||||
drawing_height = base_height - 2 * margin_preview
|
||||
canvas.create_rectangle(side_margin_preview, margin_preview,
|
||||
preview_width - side_margin_preview, margin_preview + drawing_height,
|
||||
fill='#d0e8d0', outline='black', dash=(4, 2))
|
||||
canvas.create_text(preview_width // 2, margin_preview + drawing_height // 2,
|
||||
text="Your\nImage", font=('Georgia', 10))
|
||||
|
||||
# Draw bottom margin
|
||||
canvas.create_rectangle(0, margin_preview + drawing_height, preview_width, base_height,
|
||||
fill='#f5f5f5', outline='')
|
||||
|
||||
# Import button
|
||||
button_text = f"Import Image\n{self.printable_width}px wide"
|
||||
btn = tk.Button(option_frame, text=button_text,
|
||||
command=lambda: master.switch_frame(NoQRImportImageScreen),
|
||||
height=3, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
btn.pack(pady=10)
|
||||
|
||||
# Description
|
||||
desc_label = tk.Label(option_frame, text="Variable height",
|
||||
font=('Georgia', 12), bg='white')
|
||||
desc_label.pack()
|
||||
|
||||
def create_size_option(self, parent, size_name, multiplier, description, master):
|
||||
# Frame for each option
|
||||
option_frame = tk.Frame(parent, bg=BG_COLOR, padx=20)
|
||||
option_frame.pack(side='left', padx=20)
|
||||
|
||||
# Calculate dimensions for this size (based on printable width)
|
||||
drawing_height = int(self.printable_width * multiplier)
|
||||
|
||||
# Total height includes top/bottom margins (no QR)
|
||||
total_height = self.margin + drawing_height + self.margin
|
||||
|
||||
# Create preview canvas (scaled down for display)
|
||||
preview_scale = 0.3
|
||||
preview_width = int(self.ribbon_width * preview_scale)
|
||||
preview_height = int(total_height * preview_scale)
|
||||
margin_preview = int(self.margin * preview_scale)
|
||||
drawing_preview_height = int(drawing_height * preview_scale)
|
||||
side_margin_preview = int(25 * preview_scale) # 25px side margins
|
||||
|
||||
canvas = tk.Canvas(option_frame, width=preview_width, height=preview_height,
|
||||
bg='white', highlightthickness=2, highlightbackground='gray')
|
||||
canvas.pack(pady=10)
|
||||
|
||||
# Draw top margin area
|
||||
canvas.create_rectangle(0, 0, preview_width, margin_preview,
|
||||
fill='#f5f5f5', outline='')
|
||||
|
||||
# Draw drawing area (with side margins indicated)
|
||||
canvas.create_rectangle(side_margin_preview, margin_preview,
|
||||
preview_width - side_margin_preview, margin_preview + drawing_preview_height,
|
||||
fill='#e0e0e0', outline='black')
|
||||
canvas.create_text(preview_width // 2, margin_preview + drawing_preview_height // 2,
|
||||
text="Drawing", font=('Georgia', 10))
|
||||
|
||||
# Draw bottom margin area
|
||||
canvas.create_rectangle(0, margin_preview + drawing_preview_height, preview_width, preview_height,
|
||||
fill='#f5f5f5', outline='')
|
||||
|
||||
# Size name and dimensions button (show printable dimensions)
|
||||
button_text = f"{size_name}\n{self.printable_width}×{drawing_height}px"
|
||||
btn = tk.Button(option_frame, text=button_text,
|
||||
command=lambda s=size_name.lower(): self.select_size(s, master),
|
||||
height=3, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
btn.pack(pady=10)
|
||||
|
||||
# Description label
|
||||
desc_label = tk.Label(option_frame, text=description,
|
||||
font=('Georgia', 12), bg='white')
|
||||
desc_label.pack()
|
||||
|
||||
def select_size(self, size, master):
|
||||
GlobalVars.ribbon_size = size
|
||||
master.switch_frame(NoQRDrawingScreen)
|
||||
|
||||
|
||||
class NoQRDrawingScreen(tk.Frame, DrawingMixin):
|
||||
"""No QR Ribbon - Drawing Screen."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
# Get ribbon width from config and calculate dimensions based on selected size
|
||||
ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
|
||||
printable_width = ribbon_width - 50 # 25px margin each side
|
||||
multiplier = {'small': 0.5, 'medium': 1.0, 'large': 1.5}.get(GlobalVars.ribbon_size, 1.0)
|
||||
width = printable_width
|
||||
height = int(printable_width * multiplier)
|
||||
|
||||
# Adaptive display scaling based on size
|
||||
max_display_height = 700
|
||||
ideal_scale = max_display_height / height
|
||||
scale_factor = min(2, max(1, int(ideal_scale)))
|
||||
|
||||
# Display dimensions
|
||||
display_width = width * scale_factor
|
||||
display_height = height * scale_factor
|
||||
|
||||
# Main container to hold all columns
|
||||
main_container = tk.Frame(self, bg=BG_COLOR)
|
||||
main_container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Column 1: Start Over button and instruction text
|
||||
col1_frame = tk.Frame(main_container, bg=BG_COLOR)
|
||||
col1_frame.pack(side='left', padx=(15, 25), anchor='n', pady=20)
|
||||
|
||||
# Start Over button - NoQRDrawingScreen
|
||||
tk.Button(col1_frame, text="Start over from the beginning", command=master.show_warning_dialog,
|
||||
height=2, width=24, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=(0, 20))
|
||||
|
||||
# Instruction label
|
||||
self.label = RoundedLabel(col1_frame, text="Draw your\nribbon :)",
|
||||
wraplength=150, font=GlobalVars.TEXT_FONT, bg='white')
|
||||
self.label.pack(pady=5)
|
||||
|
||||
# Column 2: Drawing canvas
|
||||
col2_frame = tk.Frame(main_container, bg=BG_COLOR)
|
||||
col2_frame.pack(side='left', padx=(10, 30))
|
||||
|
||||
# Canvas for drawing
|
||||
self.canvas = Canvas(col2_frame, width=display_width, height=display_height, bg='white')
|
||||
self.canvas.bind("<B1-Motion>", self.draw_line)
|
||||
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
|
||||
self.canvas.pack()
|
||||
|
||||
# Initialize drawing via mixin
|
||||
self.init_drawing(width, height, grid_size=scale_factor)
|
||||
|
||||
# Right panel: Pen tools + action buttons
|
||||
right_panel = tk.Frame(main_container, bg=BG_COLOR)
|
||||
right_panel.pack(side='left', padx=15)
|
||||
|
||||
# Pen tools grid
|
||||
pen_grid = tk.Frame(right_panel, bg=BG_COLOR)
|
||||
pen_grid.pack()
|
||||
|
||||
# Pen Size label
|
||||
tk.Label(pen_grid, text="Pen\nSize", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
|
||||
row=0, column=0, rowspan=5, padx=(0, 10), sticky='n', pady=(5, 0))
|
||||
|
||||
# Pen size buttons
|
||||
self._pen_icons = [make_pen_icon(s) for s in PEN_ICON_SIZES]
|
||||
for i, icon in enumerate(self._pen_icons):
|
||||
tk.Button(pen_grid, image=icon, command=lambda s=i+1: self.set_draw_size(s),
|
||||
bg='peach puff', relief='raised', bd=2).grid(row=i, column=1, pady=2)
|
||||
|
||||
# Pen Color label
|
||||
tk.Label(pen_grid, text="Pen\nColor", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
|
||||
row=6, column=0, rowspan=3, padx=(0, 10), sticky='n', pady=(15, 0))
|
||||
|
||||
# Pen color buttons
|
||||
colors = ['black', 'gray', 'white']
|
||||
for i, color in enumerate(colors):
|
||||
tk.Button(pen_grid, height=2, width=5, bg=color,
|
||||
command=lambda c=color: self.set_draw_color(c)).grid(row=6+i, column=1, pady=2)
|
||||
|
||||
# Action buttons
|
||||
action_frame = tk.Frame(right_panel, bg=BG_COLOR)
|
||||
action_frame.pack(pady=(20, 0))
|
||||
|
||||
# Import Image Button
|
||||
tk.Button(action_frame, text="Import Image", command=self.import_image,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
# Clear Drawing Button
|
||||
tk.Button(action_frame, text="Clear Drawing", command=self.clear_drawing,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
# Print button (goes directly to print)
|
||||
tk.Button(action_frame, text="Print", command=self.go_to_print,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
def import_image(self):
|
||||
self.import_image_base()
|
||||
|
||||
def go_to_print(self):
|
||||
self.drawing.save(os.path.join(MEDIA_DIR, "drawing.png"))
|
||||
self.master.switch_frame(NoQRPrintScreen)
|
||||
|
||||
|
||||
class NoQRImportImageScreen(tk.Frame):
|
||||
"""No QR Ribbon - Import Image Screen."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Get ribbon config
|
||||
self.ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
|
||||
self.margin = CONFIG.get("ribbon", {}).get("margin", 50)
|
||||
self.printable_width = self.ribbon_width - 50 # 25px margin each side
|
||||
|
||||
# Main container
|
||||
self.container = tk.Frame(self, bg=BG_COLOR)
|
||||
self.container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Title
|
||||
self.title_label = tk.Label(self.container, text="Import an image for your ribbon",
|
||||
font=GlobalVars.TEXT_FONT, bg=BG_COLOR)
|
||||
self.title_label.pack(pady=20)
|
||||
|
||||
# Preview area (will show scaled image)
|
||||
self.preview_frame = tk.Frame(self.container, bg=BG_COLOR)
|
||||
self.preview_frame.pack(pady=20)
|
||||
|
||||
self.preview_label = tk.Label(self.preview_frame, text="No image selected",
|
||||
font=('Georgia', 14), bg='white', width=40, height=15)
|
||||
self.preview_label.pack()
|
||||
|
||||
# Info label for dimensions
|
||||
self.info_label = tk.Label(self.container, text="",
|
||||
font=('Georgia', 12), bg=BG_COLOR)
|
||||
self.info_label.pack(pady=10)
|
||||
|
||||
# Buttons frame
|
||||
buttons_frame = tk.Frame(self.container, bg=BG_COLOR)
|
||||
buttons_frame.pack(pady=20)
|
||||
|
||||
# Select Image button
|
||||
self.select_btn = tk.Button(buttons_frame, text="Select Image",
|
||||
command=self.select_image,
|
||||
height=2, width=15, bg='peach puff',
|
||||
font=GlobalVars.BUTTON_FONT)
|
||||
self.select_btn.pack(side='left', padx=10)
|
||||
|
||||
# Print button (disabled until image selected)
|
||||
self.print_btn = tk.Button(buttons_frame, text="Print",
|
||||
command=self.go_to_print,
|
||||
height=2, width=15, bg='peach puff',
|
||||
font=GlobalVars.BUTTON_FONT, state='disabled')
|
||||
self.print_btn.pack(side='left', padx=10)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_btn = tk.Button(buttons_frame, text="Cancel",
|
||||
command=lambda: master.switch_frame(NoQRRibbonSizeScreen),
|
||||
height=2, width=15, bg='peach puff',
|
||||
font=GlobalVars.BUTTON_FONT)
|
||||
self.cancel_btn.pack(side='left', padx=10)
|
||||
|
||||
# Store the processed image
|
||||
self.processed_image = None
|
||||
self.preview_photo = None
|
||||
|
||||
# Auto-open file dialog
|
||||
self.after(100, self.select_image)
|
||||
|
||||
def select_image(self):
|
||||
file_path = filedialog.askopenfilename(
|
||||
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
# Open and process the image
|
||||
img = Image.open(file_path)
|
||||
|
||||
# Calculate new dimensions maintaining aspect ratio
|
||||
original_width, original_height = img.size
|
||||
aspect_ratio = original_height / original_width
|
||||
new_width = self.printable_width
|
||||
new_height = int(self.printable_width * aspect_ratio)
|
||||
|
||||
# Resize maintaining aspect ratio
|
||||
img = img.resize((new_width, new_height), Image.LANCZOS)
|
||||
|
||||
# Convert to 1-bit black/white
|
||||
img = img.convert('1')
|
||||
|
||||
# Store the processed image
|
||||
self.processed_image = img
|
||||
|
||||
# Update info label
|
||||
self.info_label.config(text=f"Size: {new_width} x {new_height} pixels")
|
||||
|
||||
# Create preview (scaled for display)
|
||||
max_preview_height = 400
|
||||
max_preview_width = 500
|
||||
preview_scale = min(max_preview_width / new_width, max_preview_height / new_height, 1.0)
|
||||
preview_width = int(new_width * preview_scale)
|
||||
preview_height = int(new_height * preview_scale)
|
||||
|
||||
preview_img = img.resize((preview_width, preview_height), Image.NEAREST)
|
||||
self.preview_photo = ImageTk.PhotoImage(preview_img)
|
||||
|
||||
# Update preview label to show image
|
||||
self.preview_label.config(image=self.preview_photo, text="", width=preview_width, height=preview_height)
|
||||
|
||||
# Enable print button
|
||||
self.print_btn.config(state='normal')
|
||||
|
||||
def go_to_print(self):
|
||||
if self.processed_image:
|
||||
self.processed_image.save(os.path.join(MEDIA_DIR, "drawing.png"))
|
||||
# Set ribbon_size to 'import' to signal variable height
|
||||
GlobalVars.ribbon_size = 'import'
|
||||
self.master.switch_frame(NoQRPrintScreen)
|
||||
|
||||
|
||||
class NoQRPrintScreen(tk.Frame):
|
||||
"""No QR Ribbon - Print Screen (no QR, no SSB, no photo, no description)."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Create a container to hold the widgets
|
||||
container = tk.Frame(self)
|
||||
container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Instructions
|
||||
RoundedLabel(container, text="Ready to print your ribbon!", wraplength=600, font=GlobalVars.TEXT_FONT, bg='white').grid(row=0, column=0, columnspan=2)
|
||||
|
||||
# Print button
|
||||
tk.Button(container, text="Print", command=self.print_ribbon, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).grid(row=2, column=0, pady=20)
|
||||
|
||||
def print_ribbon(self):
|
||||
# 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
|
||||
drawing = Image.open(os.path.join(MEDIA_DIR, "drawing.png"))
|
||||
drawing_width, drawing_height = drawing.size
|
||||
|
||||
# For fixed sizes, use multiplier; for imports, use actual image dimensions
|
||||
if GlobalVars.ribbon_size != 'import':
|
||||
multiplier = {'small': 0.5, 'medium': 1.0, 'large': 1.5}.get(GlobalVars.ribbon_size, 1.0)
|
||||
drawing_height = int(printable_width * multiplier)
|
||||
|
||||
# Total label height: just margins + drawing (no QR, no scan-tag)
|
||||
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, top_margin down from top
|
||||
merged_image.paste(drawing, (25, top_margin))
|
||||
merged_image.save(os.path.join(MEDIA_DIR, "merged_image.png"))
|
||||
|
||||
# Get the ZPL code for the image
|
||||
zpl_code = tozpl.print_to_zpl(os.path.join(MEDIA_DIR, "merged_image.png"),
|
||||
print_width=ribbon_width,
|
||||
label_length=total_height,
|
||||
darkness=CONFIG.get("darkness"))
|
||||
|
||||
# Save the ZPL
|
||||
with open(os.path.join(MEDIA_DIR, "to_print.zpl"), "w") as file:
|
||||
file.write(zpl_code)
|
||||
|
||||
# Print to ribbon printer
|
||||
GlobalVars.last_print_printer = "ribbon"
|
||||
try:
|
||||
result = subprocess.Popen(f'lp -d {CONFIG["printers"]["ribbon"]} -o raw {os.path.join(MEDIA_DIR, "to_print.zpl")}', shell=True, stdout=subprocess.PIPE)
|
||||
except:
|
||||
print('traceback.format_exc():\n%s' % traceback.format_exc())
|
||||
|
||||
from kiosk.screens.completion import Screen10
|
||||
self.master.switch_frame(Screen10)
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Participation trophy flow screens."""
|
||||
import tkinter as tk
|
||||
from tkinter import Canvas
|
||||
import os
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
import tozpl
|
||||
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import MEDIA_DIR
|
||||
from kiosk.widgets import RoundedLabel, DrawingMixin, PEN_ICON_SIZES, make_pen_icon
|
||||
|
||||
|
||||
class ParticipationScreen(tk.Frame):
|
||||
"""Do you have time and an item to archive?"""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Center container
|
||||
container = tk.Frame(self, bg=BG_COLOR)
|
||||
container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Question label
|
||||
label = RoundedLabel(container,
|
||||
text="Do you have an item currently in-hand that you are prepared to forever immortalize on many people's computers? It can take up to 10 minutes to complete.",
|
||||
font=GlobalVars.TEXT_FONT, bg='white', padx=20, pady=10, wraplength=700)
|
||||
label.pack(pady=50)
|
||||
|
||||
# Deferred imports
|
||||
def go_to_screen1():
|
||||
from kiosk.screens.ssb_selection import Screen1
|
||||
master.switch_frame(Screen1)
|
||||
|
||||
def go_to_sticker_or_ribbon():
|
||||
master.switch_frame(StickerOrRibbonScreen)
|
||||
|
||||
# Buttons
|
||||
tk.Button(container, text="not right now, let me play", command=go_to_sticker_or_ribbon,
|
||||
height=3, width=40, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
tk.Button(container, text="I have the item with me and I am prepared to archive",
|
||||
command=go_to_screen1,
|
||||
height=3, width=60, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
|
||||
|
||||
class StickerOrRibbonScreen(tk.Frame):
|
||||
"""Would you like a sticker or a ribbon?"""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Center container
|
||||
container = tk.Frame(self, bg=BG_COLOR)
|
||||
container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Question label
|
||||
label = RoundedLabel(container,
|
||||
text="Would you like to design a sticker or a ribbon?",
|
||||
font=GlobalVars.TEXT_FONT, bg='white', padx=20, pady=10, wraplength=600)
|
||||
label.pack(pady=50)
|
||||
|
||||
def go_sticker():
|
||||
GlobalVars.participation_type = "sticker"
|
||||
master.switch_frame(ParticipationStickerDrawingScreen)
|
||||
|
||||
def go_ribbon():
|
||||
GlobalVars.participation_type = "ribbon"
|
||||
master.switch_frame(ParticipationRibbonDrawingScreen)
|
||||
|
||||
# Buttons
|
||||
tk.Button(container, text="sticker", command=go_sticker,
|
||||
height=3, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
tk.Button(container, text="ribbon", command=go_ribbon,
|
||||
height=3, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
|
||||
|
||||
class ParticipationStickerDrawingScreen(tk.Frame, DrawingMixin):
|
||||
"""Draw a participation sticker (422x343 canvas)."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
width = 422
|
||||
height = 343 # 375 - 32 = 343, leaves 32px of participation visible at bottom
|
||||
|
||||
# Main container to hold all columns
|
||||
main_container = tk.Frame(self, bg=BG_COLOR)
|
||||
main_container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Column 1: Start Over button and instruction text
|
||||
col1_frame = tk.Frame(main_container, bg=BG_COLOR)
|
||||
col1_frame.pack(side='left', padx=(15, 25), anchor='n', pady=20)
|
||||
|
||||
tk.Button(col1_frame, text="Start over from the beginning", command=master.show_warning_dialog,
|
||||
height=2, width=24, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=(0, 20))
|
||||
|
||||
self.label = RoundedLabel(col1_frame, text="Draw your\nsticker :)",
|
||||
wraplength=150, font=GlobalVars.TEXT_FONT, bg='white')
|
||||
self.label.pack(pady=5)
|
||||
|
||||
# Column 2: Drawing canvas
|
||||
col2_frame = tk.Frame(main_container, bg=BG_COLOR)
|
||||
col2_frame.pack(side='left', padx=(10, 30))
|
||||
|
||||
self.canvas = Canvas(col2_frame, width=width, height=height, bg='white')
|
||||
self.canvas.bind("<B1-Motion>", self.draw_line)
|
||||
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
|
||||
self.canvas.pack()
|
||||
|
||||
# Initialize drawing via mixin (grid_size=1 for stickers)
|
||||
self.init_drawing(width, height, grid_size=1)
|
||||
self.draw_size = 4
|
||||
|
||||
# Right panel: Pen tools + action buttons
|
||||
right_panel = tk.Frame(main_container, bg=BG_COLOR)
|
||||
right_panel.pack(side='left', padx=15)
|
||||
|
||||
# Pen tools grid
|
||||
pen_grid = tk.Frame(right_panel, bg=BG_COLOR)
|
||||
pen_grid.pack()
|
||||
|
||||
tk.Label(pen_grid, text="Pen\nSize", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
|
||||
row=0, column=0, rowspan=5, padx=(0, 10), sticky='n', pady=(5, 0))
|
||||
|
||||
self._pen_icons = [make_pen_icon(s) for s in PEN_ICON_SIZES]
|
||||
for i, icon in enumerate(self._pen_icons):
|
||||
tk.Button(pen_grid, image=icon, command=lambda s=i+1: self.set_draw_size(s),
|
||||
bg='peach puff', relief='raised', bd=2).grid(row=i, column=1, pady=2)
|
||||
|
||||
tk.Label(pen_grid, text="Pen\nColor", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
|
||||
row=6, column=0, rowspan=3, padx=(0, 10), sticky='n', pady=(15, 0))
|
||||
|
||||
colors = ['black', 'gray', 'white']
|
||||
for i, color in enumerate(colors):
|
||||
tk.Button(pen_grid, height=2, width=5, bg=color,
|
||||
command=lambda c=color: self.set_draw_color(c)).grid(row=6+i, column=1, pady=2)
|
||||
|
||||
# Action buttons
|
||||
action_frame = tk.Frame(right_panel, bg=BG_COLOR)
|
||||
action_frame.pack(pady=(20, 0))
|
||||
|
||||
tk.Button(action_frame, text="Clear Drawing", command=self.clear_drawing,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
tk.Button(action_frame, text="Done", command=self.go_to_print,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
def go_to_print(self):
|
||||
self.drawing.save(os.path.join(MEDIA_DIR, "drawing.png"))
|
||||
self.master.switch_frame(ParticipationPrintScreen)
|
||||
|
||||
|
||||
class ParticipationRibbonDrawingScreen(tk.Frame, DrawingMixin):
|
||||
"""Draw a participation ribbon (422 x (usable_width-32) canvas)."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
# Calculate dimensions
|
||||
ribbon_width_config = CONFIG.get("ribbon", {}).get("width", 450)
|
||||
printable_width = ribbon_width_config - 50 # 25px margin each side
|
||||
usable_width = printable_width # e.g. 400
|
||||
|
||||
width = 422
|
||||
height = usable_width - 32 # e.g. 368
|
||||
|
||||
# Adaptive display scaling
|
||||
max_display_height = 700
|
||||
ideal_scale = max_display_height / height
|
||||
scale_factor = min(2, max(1, int(ideal_scale)))
|
||||
|
||||
display_width = width * scale_factor
|
||||
display_height = height * scale_factor
|
||||
|
||||
# Wrapper frame to hold the start-over button above the main columns
|
||||
wrapper = tk.Frame(self, bg=BG_COLOR)
|
||||
wrapper.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
tk.Button(wrapper, text="Start over from the beginning", command=master.show_warning_dialog,
|
||||
height=2, width=24, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=(0, 10))
|
||||
|
||||
# Main container to hold all columns
|
||||
main_container = tk.Frame(wrapper, bg=BG_COLOR)
|
||||
main_container.pack()
|
||||
|
||||
# Column 1: Instruction text
|
||||
col1_frame = tk.Frame(main_container, bg=BG_COLOR)
|
||||
col1_frame.pack(side='left', padx=(5, 10), anchor='n', pady=20)
|
||||
|
||||
self.label = RoundedLabel(col1_frame, text="Draw your\nribbon :)",
|
||||
wraplength=150, font=GlobalVars.TEXT_FONT, bg='white')
|
||||
self.label.pack(pady=5)
|
||||
|
||||
# Column 2: Drawing canvas
|
||||
col2_frame = tk.Frame(main_container, bg=BG_COLOR)
|
||||
col2_frame.pack(side='left', padx=(5, 10))
|
||||
|
||||
self.canvas = Canvas(col2_frame, width=display_width, height=display_height, bg='white')
|
||||
self.canvas.bind("<B1-Motion>", self.draw_line)
|
||||
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
|
||||
self.canvas.pack()
|
||||
|
||||
# Initialize drawing via mixin
|
||||
self.init_drawing(width, height, grid_size=scale_factor)
|
||||
self.draw_size = 4
|
||||
|
||||
# Right panel: Pen tools + action buttons
|
||||
right_panel = tk.Frame(main_container, bg=BG_COLOR)
|
||||
right_panel.pack(side='left', padx=5)
|
||||
|
||||
# Pen tools grid
|
||||
pen_grid = tk.Frame(right_panel, bg=BG_COLOR)
|
||||
pen_grid.pack()
|
||||
|
||||
tk.Label(pen_grid, text="Pen\nSize", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
|
||||
row=0, column=0, rowspan=5, padx=(0, 10), sticky='n', pady=(5, 0))
|
||||
|
||||
self._pen_icons = [make_pen_icon(s) for s in PEN_ICON_SIZES]
|
||||
for i, icon in enumerate(self._pen_icons):
|
||||
tk.Button(pen_grid, image=icon, command=lambda s=i+1: self.set_draw_size(s),
|
||||
bg='peach puff', relief='raised', bd=2).grid(row=i, column=1, pady=2)
|
||||
|
||||
tk.Label(pen_grid, text="Pen\nColor", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
|
||||
row=6, column=0, rowspan=3, padx=(0, 10), sticky='n', pady=(15, 0))
|
||||
|
||||
colors = ['black', 'gray', 'white']
|
||||
for i, color in enumerate(colors):
|
||||
tk.Button(pen_grid, height=2, width=5, bg=color,
|
||||
command=lambda c=color: self.set_draw_color(c)).grid(row=6+i, column=1, pady=2)
|
||||
|
||||
# Action buttons
|
||||
action_frame = tk.Frame(right_panel, bg=BG_COLOR)
|
||||
action_frame.pack(pady=(20, 0))
|
||||
|
||||
tk.Button(action_frame, text="Clear Drawing", command=self.clear_drawing,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
tk.Button(action_frame, text="Done", command=self.go_to_print,
|
||||
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
|
||||
|
||||
def go_to_print(self):
|
||||
self.drawing.save(os.path.join(MEDIA_DIR, "drawing.png"))
|
||||
self.master.switch_frame(ParticipationPrintScreen)
|
||||
|
||||
|
||||
class ParticipationPrintScreen(tk.Frame):
|
||||
"""Print screen for participation sticker/ribbon."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Create a container to hold the widgets
|
||||
container = tk.Frame(self)
|
||||
container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# Instructions
|
||||
RoundedLabel(container, text="Ready to print!", wraplength=600,
|
||||
font=GlobalVars.TEXT_FONT, bg='white').grid(row=0, column=0, columnspan=2)
|
||||
|
||||
# Print button
|
||||
tk.Button(container, text="Print", command=self.do_print, height=3, width=30,
|
||||
bg='peach puff', font=GlobalVars.BUTTON_FONT).grid(row=2, column=0, pady=20)
|
||||
|
||||
def do_print(self):
|
||||
participation_path = os.path.join(MEDIA_DIR, 'participation.png')
|
||||
|
||||
if GlobalVars.participation_type == "sticker":
|
||||
self._print_sticker(participation_path)
|
||||
else:
|
||||
self._print_ribbon(participation_path)
|
||||
|
||||
from kiosk.screens.completion import Screen10
|
||||
self.master.switch_frame(Screen10)
|
||||
|
||||
def _print_sticker(self, participation_path):
|
||||
"""Composite and print a participation sticker."""
|
||||
# Load participation.png as grayscale (600x268)
|
||||
participation = Image.open(participation_path).convert('L')
|
||||
|
||||
# Load user's drawing (422x343) as grayscale
|
||||
drawing = Image.open(os.path.join(MEDIA_DIR, "drawing.png")).convert('L')
|
||||
|
||||
# Full sticker: 600x375 (2"x1.25" at 300 DPI)
|
||||
merged = Image.new('L', (600, 375), "white")
|
||||
# Paste participation at bottom (bottom-aligned)
|
||||
merged.paste(participation, (0, 375 - participation.size[1]))
|
||||
# Paste drawing at top-left
|
||||
merged.paste(drawing, (0, 0))
|
||||
|
||||
merged.save(os.path.join(MEDIA_DIR, "merged_image.png"))
|
||||
|
||||
# Generate ZPL
|
||||
zpl_code = tozpl.print_to_zpl(os.path.join(MEDIA_DIR, "merged_image.png"),
|
||||
print_width=600, label_length=375,
|
||||
darkness=CONFIG.get("darkness"),
|
||||
media_type="gap")
|
||||
with open(os.path.join(MEDIA_DIR, "to_print.zpl"), "w") as f:
|
||||
f.write(zpl_code)
|
||||
|
||||
# Print to sticker printer
|
||||
GlobalVars.print_type = "sticker"
|
||||
GlobalVars.last_print_printer = "sticker"
|
||||
try:
|
||||
subprocess.Popen(
|
||||
f'lp -d {CONFIG["printers"]["sticker"]} -o raw {os.path.join(MEDIA_DIR, "to_print.zpl")}',
|
||||
shell=True, stdout=subprocess.PIPE)
|
||||
except:
|
||||
print('traceback.format_exc():\n%s' % traceback.format_exc())
|
||||
|
||||
def _print_ribbon(self, participation_path):
|
||||
"""Composite and print a participation ribbon."""
|
||||
# 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 margin each side
|
||||
usable_width = printable_width # e.g. 400
|
||||
|
||||
# Load participation.png (600x268)
|
||||
participation = Image.open(participation_path).convert('L')
|
||||
|
||||
# Load user's drawing (422 x (usable_width-32))
|
||||
drawing = Image.open(os.path.join(MEDIA_DIR, "drawing.png")).convert('L')
|
||||
|
||||
# Create 600 x usable_width white image
|
||||
composite = Image.new('L', (600, usable_width), "white")
|
||||
|
||||
# Paste participation.png aligned to bottom (use actual image height)
|
||||
composite.paste(participation, (0, usable_width - participation.size[1]))
|
||||
|
||||
# Paste user's drawing at (0, 0) on top
|
||||
composite.paste(drawing, (0, 0))
|
||||
|
||||
# Rotate 90 degrees → becomes usable_width x 600
|
||||
rotated = composite.rotate(90, expand=True)
|
||||
|
||||
# Create ribbon template
|
||||
total_height = top_margin + 600 + margin
|
||||
merged_image = Image.new('L', (ribbon_width, total_height), "white")
|
||||
|
||||
# Paste rotated image at (25, top_margin)
|
||||
merged_image.paste(rotated, (25, top_margin))
|
||||
merged_image.save(os.path.join(MEDIA_DIR, "merged_image.png"))
|
||||
|
||||
# Generate ZPL
|
||||
zpl_code = tozpl.print_to_zpl(os.path.join(MEDIA_DIR, "merged_image.png"),
|
||||
print_width=ribbon_width,
|
||||
label_length=total_height,
|
||||
darkness=CONFIG.get("darkness"))
|
||||
with open(os.path.join(MEDIA_DIR, "to_print.zpl"), "w") as f:
|
||||
f.write(zpl_code)
|
||||
|
||||
# Print to ribbon printer
|
||||
GlobalVars.print_type = "ribbon"
|
||||
GlobalVars.last_print_printer = "ribbon"
|
||||
try:
|
||||
subprocess.Popen(
|
||||
f'lp -d {CONFIG["printers"]["ribbon"]} -o raw {os.path.join(MEDIA_DIR, "to_print.zpl")}',
|
||||
shell=True, stdout=subprocess.PIPE)
|
||||
except:
|
||||
print('traceback.format_exc():\n%s' % traceback.format_exc())
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Placeholder screens (Screen7, Screen9) - not implemented."""
|
||||
import tkinter as tk
|
||||
|
||||
from kiosk.config import BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
|
||||
|
||||
class Screen7(tk.Frame):
|
||||
"""Create user - not implemented."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
master.add_home_button(self)
|
||||
# Assume there's a method to manage the text entry
|
||||
self.info_label = tk.Label(self, text="Hiii sorry this hasn't been implemented yet!", font=("Georgia", 16), wraplength=500, bg='white')
|
||||
self.info_label.pack()
|
||||
|
||||
|
||||
class Screen9(tk.Frame):
|
||||
"""Text update - placeholder."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
def go_to_screen10():
|
||||
from kiosk.screens.completion import Screen10
|
||||
master.switch_frame(Screen10)
|
||||
|
||||
tk.Button(self, text="Done", command=go_to_screen10, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=10)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Print flow screens (Screen13, PrintFailedScreen)."""
|
||||
import tkinter as tk
|
||||
import subprocess
|
||||
import traceback
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
import qrcode
|
||||
import tozpl
|
||||
import addtoDB
|
||||
|
||||
from kiosk.config import CONFIG, BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.utils import MEDIA_DIR
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
|
||||
class Screen13(tk.Frame):
|
||||
"""Time to print screen."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Create a container to hold the widgets
|
||||
container = tk.Frame(self)
|
||||
container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# instructions
|
||||
RoundedLabel(container, text="Wonderful! It is now time to post your item to Scuttlebutt and to print your tag. You can still cancel by hitting Start over from the beginning if you like.", wraplength=600, font=GlobalVars.TEXT_FONT, bg='white').grid(row=0, column=0, columnspan=2)
|
||||
|
||||
# buttons
|
||||
master.add_home_button(self)
|
||||
tk.Button(container, text="Print", command=self.printy, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).grid(row=2, column=0, pady=20)
|
||||
|
||||
def printy(self):
|
||||
"""Go ahead and print the thing."""
|
||||
# Specify the path to your image file
|
||||
path_to_image = os.path.join(MEDIA_DIR, 'freeze_frame.jpg')
|
||||
|
||||
# Get QR data from the main application
|
||||
QRX = self.master.QRX
|
||||
QRY = self.master.QRY
|
||||
QRscale = self.master.QRscale
|
||||
|
||||
# make ssb post
|
||||
key = addtoDB.addToSSB(path_to_image, GlobalVars.info_text, 1)
|
||||
|
||||
# ssb give! (make sure we have a UID to give to first)
|
||||
if GlobalVars.selected_user and GlobalVars.selected_user.strip() != "":
|
||||
nothing = addtoDB.addToSSB(GlobalVars.selected_user, key, 2)
|
||||
|
||||
# Calculate QR scale based on print type
|
||||
if GlobalVars.print_type == "ribbon":
|
||||
ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
|
||||
printable_width = ribbon_width - 50 # 25px margin each side
|
||||
|
||||
# First pass: determine actual QR version needed for this data
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
box_size=1, # Minimal size just to determine version
|
||||
border=0,
|
||||
)
|
||||
qr.add_data(key)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Calculate module count: 4 * version + 17
|
||||
module_count = 4 * qr.version + 17
|
||||
|
||||
# Calculate pixel-perfect box_size to fit printable width
|
||||
QRscale = printable_width // module_count
|
||||
|
||||
# Second pass: generate QR at correct size
|
||||
qr = qrcode.QRCode(
|
||||
version=qr.version, # Use determined version
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
box_size=QRscale,
|
||||
border=0,
|
||||
)
|
||||
qr.add_data(key)
|
||||
qr.make(fit=False) # Don't allow further upgrades
|
||||
|
||||
img = qr.make_image()
|
||||
else:
|
||||
# Sticker path - use existing QRscale from master
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
box_size=QRscale,
|
||||
border=0,
|
||||
)
|
||||
qr.add_data(key)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image()
|
||||
|
||||
whereToSaveQR = os.path.join(MEDIA_DIR, 'qr.png')
|
||||
img.convert('1').save(whereToSaveQR)
|
||||
|
||||
# compose image for tag
|
||||
drawing = Image.open(os.path.join(MEDIA_DIR, "drawing.png")) # drawing
|
||||
qr_img = Image.open(os.path.join(MEDIA_DIR, "qr.png")).convert("L") # qr
|
||||
|
||||
#### merge em
|
||||
|
||||
## if sticker
|
||||
if GlobalVars.print_type == "sticker":
|
||||
## if we didn't custom set X/Y, set to defaults
|
||||
if QRX is None and QRY is None:
|
||||
QRX = 454
|
||||
QRY = 217
|
||||
|
||||
# Load and rotate scan-tag 90 degrees clockwise
|
||||
scan_tag = Image.open(os.path.join(MEDIA_DIR, "scan-tag.png")).convert("L")
|
||||
scan_tag_rotated = scan_tag.rotate(-90, expand=True) # -90 = clockwise, 53×357
|
||||
|
||||
# 300 DPI printer, 2.25" label = 675 dots
|
||||
# Drawing: 597px, scan-tag rotated: 53px = 650px total (no gap)
|
||||
sticker_width = 650
|
||||
sticker_height = 375
|
||||
|
||||
merged_image = Image.new('L', (sticker_width, sticker_height), "white")
|
||||
merged_image.paste(drawing, (0, 8))
|
||||
merged_image.paste(qr_img, (QRX, QRY + 8))
|
||||
|
||||
# Paste rotated scan-tag right after drawing, vertically centered
|
||||
scan_tag_x = 597 # Right edge of drawing
|
||||
scan_tag_y = (sticker_height - scan_tag_rotated.height) // 2 # Center vertically
|
||||
merged_image.paste(scan_tag_rotated, (scan_tag_x, scan_tag_y))
|
||||
|
||||
merged_image.save(os.path.join(MEDIA_DIR, "merged_image.png"))
|
||||
|
||||
# if ribbon
|
||||
if GlobalVars.print_type == "ribbon":
|
||||
# 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)
|
||||
drawing_height = int(printable_width * multiplier)
|
||||
|
||||
# Load scan-tag image
|
||||
scan_tag = Image.open(os.path.join(MEDIA_DIR, "scan-tag.png")).convert("L")
|
||||
scan_tag_height = scan_tag.height
|
||||
|
||||
# Use actual QR dimensions (already correctly sized from generation)
|
||||
qr_size = qr_img.width
|
||||
|
||||
# Total label height includes top/bottom margins and scan-tag
|
||||
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, 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, 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, top_margin + drawing_height + scan_tag_height))
|
||||
merged_image.save(os.path.join(MEDIA_DIR, "merged_image.png"))
|
||||
|
||||
# Get the ZPL code for the image
|
||||
if GlobalVars.print_type == "ribbon":
|
||||
zpl_code = tozpl.print_to_zpl(os.path.join(MEDIA_DIR, "merged_image.png"),
|
||||
print_width=ribbon_width,
|
||||
label_length=total_height,
|
||||
darkness=CONFIG.get("darkness"))
|
||||
else:
|
||||
# Sticker: specify dimensions for proper positioning on 2.25" (675 dot @ 300 DPI) label
|
||||
zpl_code = tozpl.print_to_zpl(os.path.join(MEDIA_DIR, "merged_image.png"),
|
||||
print_width=sticker_width,
|
||||
label_length=sticker_height,
|
||||
darkness=CONFIG.get("darkness"),
|
||||
media_type="gap")
|
||||
|
||||
# save the zpl
|
||||
# Open the file in write mode
|
||||
with open(os.path.join(MEDIA_DIR, "to_print.zpl"), "w") as file:
|
||||
# Write the string to the file
|
||||
file.write(zpl_code)
|
||||
|
||||
# print to sticker printer
|
||||
if GlobalVars.print_type == "sticker":
|
||||
GlobalVars.last_print_printer = "sticker"
|
||||
try:
|
||||
result = subprocess.Popen(f'lp -d {CONFIG["printers"]["sticker"]} -o raw {os.path.join(MEDIA_DIR, "to_print.zpl")}', shell=True, stdout=subprocess.PIPE, )
|
||||
except:
|
||||
print('traceback.format_exc():\n%s' % traceback.format_exc())
|
||||
exit()
|
||||
# or print to tag printer:
|
||||
if GlobalVars.print_type == "ribbon":
|
||||
GlobalVars.last_print_printer = "ribbon"
|
||||
try:
|
||||
result = subprocess.Popen(f'lp -d {CONFIG["printers"]["ribbon"]} -o raw {os.path.join(MEDIA_DIR, "to_print.zpl")}', shell=True, stdout=subprocess.PIPE, )
|
||||
except:
|
||||
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
|
||||
|
||||
|
||||
class PrintFailedScreen(tk.Frame):
|
||||
"""Print failed screen with re-print option."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
|
||||
# Content positioned higher
|
||||
container = tk.Frame(self, bg=BG_COLOR)
|
||||
container.pack(pady=(50, 0))
|
||||
|
||||
# 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
|
||||
4. make sure the resin is not torn. If it is it may need to be re-attached to the uptake spool
|
||||
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
|
||||
please email maintenance@cust.ooo
|
||||
|
||||
thanks!"""
|
||||
RoundedLabel(container, text=troubleshoot_text, bg='white', font=('Georgia', 18),
|
||||
wraplength=900, justify='left').pack(pady=10)
|
||||
|
||||
# Button container for side-by-side layout
|
||||
def go_home():
|
||||
from kiosk.screens.home import Screen0
|
||||
master.switch_frame(Screen0)
|
||||
|
||||
button_frame = tk.Frame(container, bg=BG_COLOR)
|
||||
button_frame.pack(pady=10)
|
||||
|
||||
tk.Button(button_frame, text="Re-print", command=self._reprint,
|
||||
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=10)
|
||||
tk.Button(button_frame, text="Done", command=go_home,
|
||||
height=2, width=20, bg='light gray', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=10)
|
||||
|
||||
def _reprint(self):
|
||||
"""Re-send the last print job."""
|
||||
if GlobalVars.last_print_printer and os.path.exists(os.path.join(MEDIA_DIR, "to_print.zpl")):
|
||||
try:
|
||||
printer_name = CONFIG["printers"][GlobalVars.last_print_printer]
|
||||
subprocess.Popen(f'lp -d {printer_name} -o raw {os.path.join(MEDIA_DIR, "to_print.zpl")}',
|
||||
shell=True, stdout=subprocess.PIPE)
|
||||
except:
|
||||
print('traceback.format_exc():\n%s' % traceback.format_exc())
|
||||
# Go back to thank you screen
|
||||
from kiosk.screens.completion import Screen10
|
||||
self.master.switch_frame(Screen10)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Scuttlebutt selection screens (Screen1, ScreenWhyScuttlebutt, Screen2)."""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
import subprocess
|
||||
import json
|
||||
import traceback
|
||||
|
||||
from kiosk.config import BG_COLOR
|
||||
from kiosk.state import GlobalVars
|
||||
from kiosk.widgets import RoundedLabel
|
||||
|
||||
|
||||
class Screen1(tk.Frame):
|
||||
"""Do you have SSB?"""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
# Create the label widget with the text
|
||||
label = RoundedLabel(self, text="Would you like to associate your item with a Scuttlebutt account?", font=GlobalVars.TEXT_FONT, bg='white', wraplength=600)
|
||||
label.pack(pady=90)
|
||||
|
||||
# Deferred imports
|
||||
def go_to_screen2():
|
||||
master.switch_frame(Screen2)
|
||||
|
||||
def go_to_screen3():
|
||||
from kiosk.screens.camera import Screen3
|
||||
master.switch_frame(Screen3)
|
||||
|
||||
def go_to_why():
|
||||
master.switch_frame(ScreenWhyScuttlebutt)
|
||||
|
||||
tk.Button(self, text="Yes, and I already have an account", command=go_to_screen2, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
tk.Button(self, text="No thanks", command=go_to_screen3, height=3, width=50, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
tk.Button(self, text="Why would I want to do that?", command=go_to_why, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
|
||||
|
||||
class ScreenWhyScuttlebutt(tk.Frame):
|
||||
"""Why Scuttlebutt explanation screen."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
master.add_home_button(self)
|
||||
|
||||
label = RoundedLabel(self, text="Items associated with a Scuttlebutt account can have their ownership cryptographically verified. This effectively makes the item an NFT. For more info on how to make a Scuttlebutt account and have it visible on the kiosk visit www.cust.ooo/ssb", font=GlobalVars.TEXT_FONT, bg='white', wraplength=600)
|
||||
label.pack(pady=90)
|
||||
|
||||
tk.Button(self, text="OK", command=lambda: master.switch_frame(Screen1), height=3, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
|
||||
|
||||
|
||||
class Screen2(tk.Frame):
|
||||
"""Find yourself in list of SSB users."""
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg=BG_COLOR)
|
||||
self.selected_label = None
|
||||
self.filtered_users = []
|
||||
self._search_after_id = None # For debouncing search input
|
||||
|
||||
# Create a new frame at the top for the label and text box
|
||||
self.top_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
self.top_frame.pack(side="top", fill="x", pady=(60, 10))
|
||||
|
||||
# Add a label with text wrapping
|
||||
self.label = RoundedLabel(self.top_frame,
|
||||
text="Type your public key or alias to find yourself in the list, then click on your key to select it.",
|
||||
font=GlobalVars.TEXT_FONT,
|
||||
wraplength=800,
|
||||
bg='white')
|
||||
self.label.pack(side="top", pady=(0, 10))
|
||||
|
||||
# Add text box to the top frame
|
||||
self.entry = tk.Entry(self.top_frame, font=GlobalVars.TEXT_FONT)
|
||||
self.entry.bind('<KeyRelease>', self._debounced_search)
|
||||
self.entry.pack(side="top", fill="x", padx=20)
|
||||
|
||||
# Focus on the entry box
|
||||
self.entry.focus_set()
|
||||
|
||||
# Create container for user list
|
||||
self.container = tk.Frame(self, bg=BG_COLOR)
|
||||
self.container.pack(fill='both', expand=True, padx=20, pady=10)
|
||||
|
||||
# Create a canvas for the user list
|
||||
self.canvas = tk.Canvas(self.container, bg=BG_COLOR)
|
||||
self.scrollbar = ttk.Scrollbar(self.container, orient="vertical", command=self.canvas.yview)
|
||||
self.scrollable_frame = tk.Frame(self.canvas, bg=BG_COLOR)
|
||||
|
||||
self.scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))
|
||||
)
|
||||
|
||||
self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
|
||||
self.canvas.configure(yscrollcommand=self.scrollbar.set)
|
||||
|
||||
self.canvas.pack(side="left", fill="both", expand=True)
|
||||
self.scrollbar.pack(side="right", fill="y")
|
||||
|
||||
# Configure the scrollbar style to make it larger
|
||||
style = ttk.Style()
|
||||
style.configure("Vertical.TScrollbar", arrowsize=48, width=48)
|
||||
|
||||
# Create a frame for action buttons at the bottom
|
||||
self.button_frame = tk.Frame(self, bg=BG_COLOR)
|
||||
self.button_frame.pack(side="bottom", fill="x", pady=10)
|
||||
|
||||
# The 'Refresh List' button
|
||||
self.refresh_button = tk.Button(self.button_frame, text="Refresh List", command=self.refresh_users,
|
||||
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
self.refresh_button.pack(side="left", padx=(20, 10))
|
||||
|
||||
# Deferred import for navigation
|
||||
def go_to_screen3():
|
||||
from kiosk.screens.camera import Screen3
|
||||
master.switch_frame(Screen3)
|
||||
|
||||
def cancel_selection():
|
||||
GlobalVars.selected_user = None
|
||||
go_to_screen3()
|
||||
|
||||
# The 'Add User' button to scan a QR code
|
||||
def go_to_add_user():
|
||||
from kiosk.screens.add_user import ScreenAddUser
|
||||
master.switch_frame(ScreenAddUser)
|
||||
|
||||
self.add_user_button = tk.Button(self.button_frame, text="Add User", command=go_to_add_user,
|
||||
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
self.add_user_button.pack(side="left", padx=(10, 10))
|
||||
|
||||
# The 'Cancel' button to skip without selecting a user
|
||||
self.cancel_button = tk.Button(self.button_frame, text="Cancel", command=cancel_selection,
|
||||
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
self.cancel_button.pack(side="left", padx=(10, 10))
|
||||
|
||||
# The 'Done' button to navigate to next screen
|
||||
self.done_button = tk.Button(self.button_frame, text="Done", command=go_to_screen3,
|
||||
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT)
|
||||
self.done_button.pack(side="right", padx=(10, 20))
|
||||
|
||||
# Loading label (shown during initial load)
|
||||
self.loading_label = tk.Label(self.scrollable_frame, text="Loading...",
|
||||
font=GlobalVars.TEXT_FONT, bg=BG_COLOR)
|
||||
self.loading_label.pack(pady=20)
|
||||
|
||||
# Defer loading to allow UI to render first
|
||||
self.users = []
|
||||
self.after(10, self._load_users)
|
||||
|
||||
master.add_home_button(self)
|
||||
|
||||
def _debounced_search(self, event=None):
|
||||
"""Debounce search input - wait 250ms after last keystroke before searching."""
|
||||
if self._search_after_id:
|
||||
self.after_cancel(self._search_after_id)
|
||||
self._search_after_id = self.after(250, self.update_users_list)
|
||||
|
||||
def _load_users(self):
|
||||
"""Load users from file and display them."""
|
||||
self.users = self.get_users_from_file()
|
||||
self.loading_label.destroy()
|
||||
self.update_users_list()
|
||||
|
||||
def update_users_list(self):
|
||||
search_text = self.entry.get().lower()
|
||||
# Use cached _search_alias and _search_id for fast filtering
|
||||
self.filtered_users = [
|
||||
user for user in self.users
|
||||
if search_text in user.get('_search_id', user['id'].lower())
|
||||
or search_text in user.get('_search_alias', '')
|
||||
]
|
||||
self.display_users()
|
||||
|
||||
# Preserve selection after filtering
|
||||
if GlobalVars.selected_user:
|
||||
for widget in self.scrollable_frame.winfo_children():
|
||||
if isinstance(widget, tk.Frame):
|
||||
id_label = widget.winfo_children()[-1]
|
||||
if id_label['text'] == GlobalVars.selected_user:
|
||||
self.highlight_frame(widget)
|
||||
self.selected_label = widget
|
||||
break
|
||||
|
||||
def display_users(self):
|
||||
for widget in self.scrollable_frame.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
for index, user in enumerate(self.filtered_users):
|
||||
try:
|
||||
frame = tk.Frame(self.scrollable_frame, bg=BG_COLOR)
|
||||
frame.pack(fill='x', expand=True, pady=2)
|
||||
|
||||
# Use cached _display_alias if available, otherwise compute it
|
||||
alias = user.get('_display_alias', self.unescape_unicode(user.get('alias', '')))
|
||||
id = user.get('id', '')
|
||||
|
||||
alias_label = tk.Label(frame, text=alias, font=('Georgia', 14, 'bold'), width=20, anchor='w', bg='white')
|
||||
alias_label.pack(side='left', padx=(0, 10))
|
||||
|
||||
id_label = tk.Label(frame, text=id, font=('Georgia', 14), anchor='w', bg='white')
|
||||
id_label.pack(side='left', expand=True, fill='x')
|
||||
|
||||
frame.bind('<Button-1>', lambda e, u=user, f=frame: self.on_user_clicked(f, u))
|
||||
alias_label.bind('<Button-1>', lambda e, u=user, f=frame: self.on_user_clicked(f, u))
|
||||
id_label.bind('<Button-1>', lambda e, u=user, f=frame: self.on_user_clicked(f, u))
|
||||
|
||||
if GlobalVars.selected_user == id:
|
||||
self.highlight_frame(frame)
|
||||
self.selected_label = frame
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error displaying user {index + 1}: {str(e)}")
|
||||
print(f"User data: {user}")
|
||||
print(traceback.format_exc())
|
||||
|
||||
self.scrollable_frame.update_idletasks()
|
||||
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
|
||||
|
||||
def on_user_clicked(self, frame, user):
|
||||
# Unhighlight the previously selected user
|
||||
if self.selected_label is not None and self.widget_exists(self.selected_label):
|
||||
self.unhighlight_frame(self.selected_label)
|
||||
|
||||
# Update the selected user
|
||||
GlobalVars.selected_user = user['id']
|
||||
self.selected_label = frame
|
||||
|
||||
# Highlight the newly selected user
|
||||
self.highlight_frame(frame)
|
||||
self.update_done_button_state()
|
||||
|
||||
# Print debug information
|
||||
print(f"User clicked: {user['id']}")
|
||||
print(f"Selected label: {self.selected_label}")
|
||||
print(f"Global selected user: {GlobalVars.selected_user}")
|
||||
|
||||
def highlight_frame(self, frame):
|
||||
frame.configure(bg="light blue")
|
||||
for child in frame.winfo_children():
|
||||
child.configure(bg="light blue")
|
||||
print(f"Highlighted frame: {frame}")
|
||||
|
||||
def unhighlight_frame(self, frame):
|
||||
frame.configure(bg=BG_COLOR)
|
||||
for child in frame.winfo_children():
|
||||
child.configure(bg=BG_COLOR)
|
||||
print(f"Unhighlighted frame: {frame}")
|
||||
|
||||
def scroll_to_widget(self, widget):
|
||||
self.canvas.update_idletasks()
|
||||
self.canvas.yview_moveto(widget.winfo_y() / self.scrollable_frame.winfo_height())
|
||||
|
||||
def update_done_button_state(self):
|
||||
if GlobalVars.selected_user:
|
||||
self.done_button.configure(state="normal")
|
||||
else:
|
||||
self.done_button.configure(state="disabled")
|
||||
|
||||
def widget_exists(self, widget):
|
||||
try:
|
||||
widget.winfo_exists()
|
||||
return True
|
||||
except tk.TclError:
|
||||
return False
|
||||
|
||||
def refresh_users(self):
|
||||
# Show loading state
|
||||
self.refresh_button.configure(state="disabled", text="Loading...")
|
||||
self.update_idletasks()
|
||||
|
||||
try:
|
||||
users = self.get_scuttlebutt_users()
|
||||
# Pre-process aliases for the new users
|
||||
for user in users:
|
||||
display_alias = self.unescape_unicode(user.get('alias', ''))
|
||||
user['_display_alias'] = display_alias
|
||||
user['_search_alias'] = display_alias.lower()
|
||||
user['_search_id'] = user['id'].lower()
|
||||
self.users = users
|
||||
self.update_users_list()
|
||||
self.save_users_to_file(users)
|
||||
messagebox.showinfo("Success", "User list has been refreshed and updated with aliases.")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while refreshing the user list: {e}")
|
||||
messagebox.showerror("Error", "An error occurred while refreshing the user list. Please try again later.")
|
||||
finally:
|
||||
# Restore button state
|
||||
self.refresh_button.configure(state="normal", text="Refresh List")
|
||||
|
||||
def get_scuttlebutt_users(self):
|
||||
try:
|
||||
result = subprocess.run(['node', 'scuttlebot.js'], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode == 0:
|
||||
users = json.loads(result.stdout)
|
||||
return users
|
||||
else:
|
||||
print(f"Command failed with error: {result.stderr}")
|
||||
return []
|
||||
except subprocess.TimeoutExpired:
|
||||
print("Command timed out after 60 seconds")
|
||||
return []
|
||||
except json.JSONDecodeError:
|
||||
print("Failed to parse JSON output")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_users_from_file(self):
|
||||
try:
|
||||
with open('users.json') as f:
|
||||
users = json.load(f)
|
||||
# Pre-process and cache unescaped aliases for fast search and display
|
||||
for user in users:
|
||||
display_alias = self.unescape_unicode(user.get('alias', ''))
|
||||
user['_display_alias'] = display_alias
|
||||
user['_search_alias'] = display_alias.lower()
|
||||
user['_search_id'] = user['id'].lower()
|
||||
return users
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
def save_users_to_file(self, users):
|
||||
with open('users.json', 'w') as f:
|
||||
json.dump(users, f)
|
||||
|
||||
@staticmethod
|
||||
def unescape_unicode(s):
|
||||
return s.encode('utf-8').decode('unicode_escape')
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Global state container for the kiosk application."""
|
||||
|
||||
|
||||
class GlobalVars:
|
||||
"""Shared state container for kiosk data."""
|
||||
qr_code_value = None
|
||||
print_type = "neither"
|
||||
selected_user = None
|
||||
ribbon_size = None # 'small', 'medium', or 'large'
|
||||
info_text = None
|
||||
BUTTON_FONT = None
|
||||
TEXT_FONT = None
|
||||
last_print_printer = None # "sticker" or "ribbon" - for re-print functionality
|
||||
participation_type = None # "sticker" or "ribbon" - for participation trophy flow
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Utility functions for the kiosk application."""
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Optional, Tuple
|
||||
import cv2
|
||||
|
||||
MEDIA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "media-assets")
|
||||
|
||||
|
||||
def get_preferred_camera_index(preferred_name_fragment=None):
|
||||
"""
|
||||
Try to find a stable camera index for an external webcam.
|
||||
|
||||
Strategy (Linux):
|
||||
- Look in /dev/v4l/by-id, which has stable, human-readable symlinks.
|
||||
- If `preferred_name_fragment` is provided, pick the first device whose
|
||||
name contains that fragment.
|
||||
- Otherwise, prefer devices whose name looks like an external USB webcam.
|
||||
- Fall back to the lowest working index (usually the built-in camera).
|
||||
|
||||
You can customize behaviour by passing a string like "Logitech" or by
|
||||
changing the heuristics below.
|
||||
"""
|
||||
by_id_dir = "/dev/v4l/by-id"
|
||||
|
||||
try:
|
||||
if os.path.isdir(by_id_dir):
|
||||
paths = sorted(glob.glob(os.path.join(by_id_dir, "*")))
|
||||
if paths:
|
||||
# Optionally filter by a user-specified name fragment
|
||||
if preferred_name_fragment:
|
||||
name_fragment = preferred_name_fragment.lower()
|
||||
candidate_paths = [
|
||||
p for p in paths if name_fragment in os.path.basename(p).lower()
|
||||
]
|
||||
if not candidate_paths:
|
||||
candidate_paths = paths
|
||||
else:
|
||||
candidate_paths = paths
|
||||
|
||||
# Heuristic: prefer names that look like external USB webcams
|
||||
# (often contain "usb-" but are not clearly an integrated camera)
|
||||
usb_like = [
|
||||
p
|
||||
for p in candidate_paths
|
||||
if "usb-" in os.path.basename(p).lower()
|
||||
]
|
||||
# If that filter gives us something, use it, otherwise use all
|
||||
candidate_paths = usb_like or candidate_paths
|
||||
|
||||
for path in candidate_paths:
|
||||
target = os.path.realpath(path) # e.g. /dev/video2
|
||||
m = re.search(r"video(\d+)", target)
|
||||
if not m:
|
||||
continue
|
||||
idx = int(m.group(1))
|
||||
cap = cv2.VideoCapture(idx)
|
||||
if cap.isOpened():
|
||||
cap.release()
|
||||
print(f"Selected camera index {idx} from {path}")
|
||||
return idx
|
||||
cap.release()
|
||||
except Exception as e:
|
||||
print(f"Error while trying to select preferred camera: {e}")
|
||||
|
||||
# Fallback: probe a small range of indices and pick the first that works
|
||||
for idx in range(0, 6):
|
||||
cap = cv2.VideoCapture(idx)
|
||||
if cap.isOpened():
|
||||
cap.release()
|
||||
print(f"Falling back to first working camera index: {idx}")
|
||||
return idx
|
||||
cap.release()
|
||||
|
||||
# Absolute fallback if nothing opens
|
||||
print("No working camera found; defaulting to index 0")
|
||||
return 0
|
||||
|
||||
|
||||
def check_ssb_health() -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
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, Optional[str]]:
|
||||
"""
|
||||
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
|
||||
text = text.replace('\\', '\\\\')
|
||||
# Escape double quotes for JSON and shell
|
||||
text = text.replace('"', '\\"')
|
||||
# Escape dollar signs to prevent shell variable expansion
|
||||
text = text.replace('$', '\\$')
|
||||
# Escape backticks to prevent shell command substitution
|
||||
text = text.replace('`', '\\`')
|
||||
# Escape carriage returns
|
||||
text = text.replace('\r', '')
|
||||
# Escape tabs
|
||||
text = text.replace('\t', ' ')
|
||||
return text
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Widget components for the kiosk application."""
|
||||
from .rounded_label import RoundedLabel
|
||||
from .scrolled_frame import VirtualScrolledFrame
|
||||
from .drawing_mixin import DrawingMixin, PEN_ICON_SIZES, make_pen_icon
|
||||
|
||||
__all__ = ['RoundedLabel', 'VirtualScrolledFrame', 'DrawingMixin', 'PEN_ICON_SIZES', 'make_pen_icon']
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Drawing functionality mixin for kiosk screens."""
|
||||
import tkinter as tk
|
||||
from PIL import Image, ImageTk, ImageDraw
|
||||
from tkinter import filedialog
|
||||
|
||||
# Pen icon sizes: black square side length in pixels for each pen size (1-5)
|
||||
PEN_ICON_SIZES = [2, 6, 10, 16, 22]
|
||||
PEN_ICON_CANVAS = 40 # Button icon canvas size in pixels
|
||||
|
||||
|
||||
def make_pen_icon(square_size, canvas_size=PEN_ICON_CANVAS):
|
||||
"""Create a PhotoImage with a centered black square of the given size."""
|
||||
img = tk.PhotoImage(width=canvas_size, height=canvas_size)
|
||||
# Fill background with peach puff (#FFDAB9)
|
||||
img.put('#FFDAB9', to=(0, 0, canvas_size, canvas_size))
|
||||
# Center the black square
|
||||
offset = (canvas_size - square_size) // 2
|
||||
if square_size > 0:
|
||||
img.put('black', to=(offset, offset, offset + square_size, offset + square_size))
|
||||
return img
|
||||
|
||||
|
||||
class DrawingMixin:
|
||||
"""Mixin providing drawing canvas functionality for kiosk screens."""
|
||||
|
||||
def init_drawing(self, width, height, grid_size=1):
|
||||
"""Initialize drawing state. Call this in __init__ after creating self.canvas."""
|
||||
self.original_width = width
|
||||
self.original_height = height
|
||||
self.grid_size = grid_size
|
||||
|
||||
self.drawing = Image.new('1', (width, height), 1)
|
||||
self.draw = ImageDraw.Draw(self.drawing)
|
||||
self.last_draw = None
|
||||
self.draw_color = 'black'
|
||||
self.draw_size = 1
|
||||
|
||||
def draw_line(self, event):
|
||||
"""Handle mouse drag to draw lines."""
|
||||
x, y = event.x // self.grid_size, event.y // self.grid_size
|
||||
if self.last_draw:
|
||||
points = self._get_points_on_line(*self.last_draw, x, y)
|
||||
for px, py in points:
|
||||
if self.draw_color == 'gray':
|
||||
self._draw_dithered_point(px, py)
|
||||
else:
|
||||
self._draw_point(px, py)
|
||||
self.last_draw = (x, y)
|
||||
|
||||
def _get_points_on_line(self, x0, y0, x1, y1):
|
||||
"""Bresenham's line algorithm for smooth drawing."""
|
||||
points = []
|
||||
dx = abs(x1 - x0)
|
||||
dy = abs(y1 - y0)
|
||||
sx = 1 if x0 < x1 else -1
|
||||
sy = 1 if y0 < y1 else -1
|
||||
err = dx - dy
|
||||
|
||||
while True:
|
||||
points.append((x0, y0))
|
||||
if x0 == x1 and y0 == y1:
|
||||
break
|
||||
e2 = 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
x0 += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
return points
|
||||
|
||||
def _draw_point(self, x, y):
|
||||
"""Draw a solid point at coordinates."""
|
||||
color = 0 if self.draw_color == 'black' else 1
|
||||
for dx in range(self.draw_size):
|
||||
for dy in range(self.draw_size):
|
||||
self.canvas.create_rectangle(
|
||||
(x+dx)*self.grid_size, (y+dy)*self.grid_size,
|
||||
(x+dx+1)*self.grid_size, (y+dy+1)*self.grid_size,
|
||||
fill=self.draw_color, outline=''
|
||||
)
|
||||
self.draw.point((x+dx, y+dy), fill=color)
|
||||
|
||||
def _draw_dithered_point(self, x, y):
|
||||
"""Draw a dithered (gray) point using checkerboard pattern."""
|
||||
for dx in range(self.draw_size):
|
||||
for dy in range(self.draw_size):
|
||||
if (x+dx+y+dy) % 2 == 0:
|
||||
self.canvas.create_rectangle(
|
||||
(x+dx)*self.grid_size, (y+dy)*self.grid_size,
|
||||
(x+dx+1)*self.grid_size, (y+dy+1)*self.grid_size,
|
||||
fill='black', outline=''
|
||||
)
|
||||
self.draw.point((x+dx, y+dy), fill=0)
|
||||
else:
|
||||
self.canvas.create_rectangle(
|
||||
(x+dx)*self.grid_size, (y+dy)*self.grid_size,
|
||||
(x+dx+1)*self.grid_size, (y+dy+1)*self.grid_size,
|
||||
fill='white', outline=''
|
||||
)
|
||||
self.draw.point((x+dx, y+dy), fill=1)
|
||||
|
||||
def reset_last_draw(self, event):
|
||||
self.last_draw = None
|
||||
|
||||
def set_draw_color(self, color):
|
||||
self.draw_color = color
|
||||
|
||||
def set_draw_size(self, size):
|
||||
self.draw_size = size
|
||||
|
||||
def clear_drawing(self):
|
||||
self.canvas.delete("all")
|
||||
self.drawing = Image.new('1', (self.original_width, self.original_height), 1)
|
||||
self.draw = ImageDraw.Draw(self.drawing)
|
||||
|
||||
def import_image_base(self):
|
||||
"""Base import image functionality. Returns file path or None."""
|
||||
file_path = filedialog.askopenfilename(filetypes=[("Image files", "*.jpg *.jpeg *.png")])
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
img = Image.open(file_path)
|
||||
img = img.convert('1')
|
||||
|
||||
# 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
|
||||
display_width = self.original_width * self.grid_size
|
||||
display_height = self.original_height * self.grid_size
|
||||
display_img = img.resize((display_width, display_height), Image.NEAREST)
|
||||
self.imported_img = ImageTk.PhotoImage(display_img)
|
||||
|
||||
self.canvas.delete("all")
|
||||
self.canvas.create_image(0, 0, image=self.imported_img, anchor='nw')
|
||||
|
||||
return file_path
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Rounded label widget using Canvas."""
|
||||
import tkinter as tk
|
||||
from kiosk.config import BG_COLOR
|
||||
|
||||
|
||||
class RoundedLabel(tk.Canvas):
|
||||
"""A label with rounded corners using Canvas."""
|
||||
def __init__(self, parent, text="", font=None, bg='white', fg='black',
|
||||
wraplength=400, justify='center', padx=15, pady=15, radius=20, **kwargs):
|
||||
# Use a temporary label to calculate text dimensions
|
||||
temp_label = tk.Label(parent, text=text, font=font, wraplength=wraplength, justify=justify)
|
||||
temp_label.update_idletasks()
|
||||
text_width = temp_label.winfo_reqwidth()
|
||||
text_height = temp_label.winfo_reqheight()
|
||||
temp_label.destroy()
|
||||
|
||||
# Calculate canvas dimensions
|
||||
width = text_width + 2 * padx
|
||||
height = text_height + 2 * pady
|
||||
|
||||
# Initialize canvas with background matching the app background
|
||||
super().__init__(parent, width=width, height=height,
|
||||
highlightthickness=0, bg=BG_COLOR, **kwargs)
|
||||
|
||||
# Draw rounded rectangle
|
||||
self._draw_rounded_rect(0, 0, width, height, radius, bg)
|
||||
|
||||
# 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."""
|
||||
# Ensure radius isn't larger than half the smallest dimension
|
||||
radius = min(radius, (x2 - x1) // 2, (y2 - y1) // 2)
|
||||
|
||||
# Draw the rounded rectangle using arcs and rectangles
|
||||
# Top-left corner
|
||||
self.create_arc(x1, y1, x1 + 2*radius, y1 + 2*radius,
|
||||
start=90, extent=90, fill=fill, outline=fill)
|
||||
# Top-right corner
|
||||
self.create_arc(x2 - 2*radius, y1, x2, y1 + 2*radius,
|
||||
start=0, extent=90, fill=fill, outline=fill)
|
||||
# Bottom-left corner
|
||||
self.create_arc(x1, y2 - 2*radius, x1 + 2*radius, y2,
|
||||
start=180, extent=90, fill=fill, outline=fill)
|
||||
# Bottom-right corner
|
||||
self.create_arc(x2 - 2*radius, y2 - 2*radius, x2, y2,
|
||||
start=270, extent=90, fill=fill, outline=fill)
|
||||
|
||||
# Fill the center rectangles
|
||||
# Center horizontal
|
||||
self.create_rectangle(x1 + radius, y1, x2 - radius, y2,
|
||||
fill=fill, outline=fill)
|
||||
# Left vertical
|
||||
self.create_rectangle(x1, y1 + radius, x1 + radius, y2 - radius,
|
||||
fill=fill, outline=fill)
|
||||
# Right vertical
|
||||
self.create_rectangle(x2 - radius, y1 + radius, x2, y2 - radius,
|
||||
fill=fill, outline=fill)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Virtual scrolled frame widget."""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from kiosk.config import BG_COLOR
|
||||
|
||||
|
||||
class VirtualScrolledFrame(ttk.Frame):
|
||||
"""A scrollable frame widget using canvas."""
|
||||
def __init__(self, parent, *args, **kw):
|
||||
ttk.Frame.__init__(self, parent, *args, **kw)
|
||||
|
||||
# Create a canvas object and a vertical scrollbar for scrolling it
|
||||
self.vscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL)
|
||||
self.canvas = tk.Canvas(self, yscrollcommand=self.vscrollbar.set, bg=BG_COLOR)
|
||||
self.vscrollbar.config(command=self.canvas.yview)
|
||||
|
||||
# Reset the view
|
||||
self.canvas.xview_moveto(0)
|
||||
self.canvas.yview_moveto(0)
|
||||
|
||||
# Create a frame inside the canvas which will be scrolled with it
|
||||
self.interior = tk.Frame(self.canvas, bg=BG_COLOR)
|
||||
self.interior_id = self.canvas.create_window(0, 0, window=self.interior, anchor=tk.NW)
|
||||
|
||||
# Pack the widgets
|
||||
self.vscrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# Bind events to the Canvas
|
||||
self.canvas.bind('<Configure>', self._configure_canvas)
|
||||
self.interior.bind('<Configure>', self._configure_interior)
|
||||
|
||||
def _configure_interior(self, event):
|
||||
# Update the scrollbars to match the size of the inner frame
|
||||
size = (self.interior.winfo_reqwidth(), self.interior.winfo_reqheight())
|
||||
self.canvas.config(scrollregion="0 0 %s %s" % size)
|
||||
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
|
||||
# Update the canvas's width to fit the inner frame
|
||||
self.canvas.config(width=self.interior.winfo_reqwidth())
|
||||
|
||||
def _configure_canvas(self, event):
|
||||
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
|
||||
# Update the inner frame's width to fill the canvas
|
||||
self.canvas.itemconfigure(self.interior_id, width=self.canvas.winfo_width())
|
||||
@@ -1,961 +0,0 @@
|
||||
## this is the custodisco kiosk code for dweb camp 2023
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
from tkinter import Canvas, ttk, Text, filedialog, messagebox
|
||||
from PIL import Image, ImageTk, ImageDraw
|
||||
import tozpl
|
||||
import subprocess
|
||||
import threading
|
||||
import json
|
||||
import os
|
||||
import cv2
|
||||
import time
|
||||
import qrcode
|
||||
import addtoDB
|
||||
from pyzbar.pyzbar import decode
|
||||
|
||||
qr_code_value = None # global variable to store QR code value
|
||||
print_type = "neither" # print type when printing
|
||||
|
||||
|
||||
class GlobalVars:
|
||||
selected_user = None
|
||||
|
||||
|
||||
|
||||
class Kiosk(tk.Tk):
|
||||
def __init__(self, *args, **kwargs):
|
||||
tk.Tk.__init__(self, *args, **kwargs)
|
||||
self.frame = None
|
||||
self.frames_history = []
|
||||
self.geometry('1366x768')
|
||||
self.attributes('-fullscreen', True)
|
||||
self.config(cursor="crosshair")
|
||||
self.QRX = None
|
||||
self.QRY = None
|
||||
self.QRscale = 1
|
||||
globals()['BUTTON_FONT'] = tkfont.Font(size=24, family='Helvetica')
|
||||
globals()['TEXT_FONT'] = tkfont.Font(size=20, family='Helvetica')
|
||||
self.switch_frame(Screen0)
|
||||
|
||||
|
||||
def switch_frame(self, frame_class, keep_history=True):
|
||||
if keep_history and self.frame:
|
||||
self.frames_history.append(type(self.frame))
|
||||
new_frame = frame_class(self)
|
||||
if self.frame is not None:
|
||||
self.frame.destroy()
|
||||
self.frame = new_frame
|
||||
self.frame.pack(fill="both", expand=True)
|
||||
|
||||
def back_frame(self):
|
||||
if self.frames_history:
|
||||
self.switch_frame(self.frames_history.pop(), keep_history=False)
|
||||
|
||||
def add_home_button(self, frame):
|
||||
# Create the "Start Over" button
|
||||
home_button = tk.Button(frame, text="Start Over from the beginning", command=lambda: self.switch_frame(Screen0), bg='peach puff', width=24, font=BUTTON_FONT)
|
||||
home_button.place(x=0, y=0) # top-left corner
|
||||
|
||||
# home screen
|
||||
class Screen0(tk.Frame):
|
||||
def __init__(self, master):
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
title_font = tkfont.Font(size=42, family='Helvetica') # 30% bigger
|
||||
|
||||
# Split the screen into two frames
|
||||
left_frame = tk.Frame(self, bg='#bcfef9')
|
||||
right_frame = tk.Frame(self, bg='#bcfef9', padx=40) # 40px padding on the right side
|
||||
left_frame.grid(row=0, column=0, sticky='nsew')
|
||||
right_frame.grid(row=0, column=1, sticky='nsew')
|
||||
|
||||
self.grid_columnconfigure(0, weight=1, minsize=800) # For left frame
|
||||
self.grid_columnconfigure(1, weight=1) # For right frame
|
||||
|
||||
# Title and buttons on the left side
|
||||
title_label = tk.Label(left_frame, text="Custodisco", bg='#bcfef9', fg='#800080', font=('Helvetica', 48)) # dark purple color
|
||||
title_label.pack(side='top', pady=20) # adjust to your needs
|
||||
|
||||
# Welcome message on the left side
|
||||
welcome_text = """If you have an item that is special to you, this kiosk allows you to create a tag for your item.
|
||||
|
||||
Build your relationship with your item by committing to mend and care for it.
|
||||
|
||||
If you put your contact info on the tag the item may be returned to you if lost.
|
||||
|
||||
You can use your fingers to interact with the kiosk but also there is a stylus that can help with drawing.
|
||||
|
||||
More info at https://teafry.me/custodisco"""
|
||||
welcome_label = tk.Label(left_frame, text=welcome_text, bg='#bcfef9', font=TEXT_FONT, justify='left', wraplength=650)
|
||||
welcome_label.pack(side='top', padx=20, pady=20)
|
||||
|
||||
|
||||
|
||||
tk.Button(right_frame, text="Create Item", command=lambda: master.switch_frame(Screen1), height=4, width=75, bg='peach puff', font=BUTTON_FONT).pack(side='top', pady=30)
|
||||
tk.Button(right_frame, text="Lookup Item", command=lambda: master.switch_frame(Screen14), height=4, width=75, bg='peach puff', font=BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
# Create the quit button
|
||||
tk.Button(right_frame, text="Quit", command=self.quit_program, height=3, width=30, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
def quit_program(self):
|
||||
self.master.destroy()
|
||||
|
||||
|
||||
# do you have ssb?
|
||||
class Screen1(tk.Frame):
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
master.add_home_button(self)
|
||||
|
||||
# Create the label widget with the text
|
||||
label = tk.Label(self, text="Do you have a Scuttlebutt account? If no just tap Yes and then Done on the next screen, it's fine.", font=TEXT_FONT)
|
||||
label.pack(pady=60)
|
||||
|
||||
tk.Button(self, text="Yes", command=lambda: master.switch_frame(Screen2), height=3, width=30, bg='peach puff', font=BUTTON_FONT).pack(pady=50)
|
||||
tk.Button(self, text="No, I Want to Make One Now (not implemented yet)", command=lambda: master.switch_frame(Screen7), height=3, width=50, bg='peach puff', font=BUTTON_FONT).pack(pady=50)
|
||||
|
||||
|
||||
|
||||
# find yourself in list of ssb users
|
||||
class Screen2(tk.Frame):
|
||||
selected_user = None # This is the global variable to store selected user
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
|
||||
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
self.selected_label = None # This is the global variable to store selected label
|
||||
|
||||
# Create a new frame at the top for the label and text box
|
||||
self.top_frame = tk.Frame(self)
|
||||
self.top_frame.pack(side="top", fill="x", pady=60)
|
||||
|
||||
# Add a label and text box to the top frame
|
||||
self.label = tk.Label(self.top_frame, text="Start typing your public key to find yourself in the list then click on your key to select it.\n\nIf you can't find your name it might help to follow the Custodisco Scuttlebutt account.", font=TEXT_FONT)
|
||||
self.label.pack(side="left")
|
||||
self.entry = tk.Entry(self.top_frame, font=TEXT_FONT)
|
||||
self.entry.bind('<KeyRelease>', lambda e: self.update_users_list())
|
||||
self.entry.pack(side="left")
|
||||
|
||||
# Focus on the entry box
|
||||
self.entry.focus_set()
|
||||
|
||||
# Create container for user list
|
||||
self.container = ttk.Frame(self, height=500) # Define a height here
|
||||
self.container.pack(fill='both', expand=True, padx=20, pady=20)
|
||||
|
||||
# Initialize users list from users.json
|
||||
self.users = self.get_users_from_file()
|
||||
self.update_users_list()
|
||||
|
||||
# Highlight selected user if one exists
|
||||
if GlobalVars.selected_user is not None:
|
||||
for widget in self.container.winfo_children():
|
||||
if isinstance(widget, tk.Button) and widget['text'] == GlobalVars.selected_user:
|
||||
widget.configure(relief=tk.SUNKEN, bg="light blue")
|
||||
self.selected_label = widget
|
||||
|
||||
|
||||
# The 'Done' button to navigate to next screen
|
||||
self.done_button = tk.Button(self, text="Done", command=lambda: master.switch_frame(Screen3), height=3, width=30, bg='peach puff', font=BUTTON_FONT)
|
||||
self.done_button.pack(side="bottom", padx=20, pady=10)
|
||||
|
||||
# The 'Refresh List' button
|
||||
self.refresh_button = tk.Button(self, text="Refresh List (takes 30 seconds or so)", command=self.refresh_users, height=3, width=30, bg='peach puff', font=BUTTON_FONT)
|
||||
self.refresh_button.pack(side="bottom", padx=20, pady=10)
|
||||
|
||||
master.add_home_button(self)
|
||||
|
||||
def refresh_users(self):
|
||||
try:
|
||||
# Update users list from Scuttlebutt and display it
|
||||
self.users = self.get_scuttlebutt_users()
|
||||
self.update_users_list()
|
||||
except Exception as e:
|
||||
print(f"An error occurred while refreshing the user list: {e}")
|
||||
messagebox.showerror("Error", "An error occurred while refreshing the user list. Please try again later.")
|
||||
|
||||
def get_scuttlebutt_users(self):
|
||||
result = subprocess.run(['node', 'scuttlebot.js'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
users = json.loads(result.stdout)
|
||||
return users
|
||||
else:
|
||||
raise Exception("Command failed: " + result.stderr)
|
||||
|
||||
def get_users_from_file(self):
|
||||
with open('users.json') as f:
|
||||
users = json.load(f)
|
||||
return users
|
||||
|
||||
def update_users_list(self):
|
||||
# Remove all widgets from container
|
||||
for widget in self.container.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
# Filter users based on search text
|
||||
search_text = self.entry.get().lower()
|
||||
filtered_users = [user for user in self.users if search_text in user['id'].lower()]
|
||||
|
||||
# Display filtered users
|
||||
self.display_users(filtered_users)
|
||||
|
||||
def display_users(self, users):
|
||||
# Scrollable list of users
|
||||
canvas = tk.Canvas(self.container, width=460) # Decrease the width by scrollbar's width
|
||||
style = ttk.Style()
|
||||
style.configure("Vertical.TScrollbar", gripcount=0,
|
||||
arrowsize=15, width=40) # Adjust width here
|
||||
scrollbar = ttk.Scrollbar(self.container, orient='vertical', command=canvas.yview, style="Vertical.TScrollbar")
|
||||
scrollable_frame = ttk.Frame(canvas)
|
||||
|
||||
scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.configure(
|
||||
scrollregion=canvas.bbox("all")
|
||||
)
|
||||
)
|
||||
|
||||
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Add user buttons to scrollable frame
|
||||
for user in users:
|
||||
button = tk.Button(scrollable_frame, text=user['id'], font=('Helvetica', 24), bd=0) # Adjust font size here
|
||||
button.pack(anchor="w")
|
||||
button.config(command=lambda button=button, user=user: self.on_user_clicked(button, user))
|
||||
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
def widget_exists(self, widget):
|
||||
try:
|
||||
widget.winfo_exists()
|
||||
return True
|
||||
except tk.TclError:
|
||||
return False
|
||||
|
||||
def on_user_clicked(self, button, user):
|
||||
# Remove highlight from previously selected user
|
||||
if self.selected_label is not None and self.widget_exists(self.selected_label):
|
||||
self.selected_label.configure(relief=tk.FLAT, bg='SystemButtonFace') # default color
|
||||
|
||||
# Store selected user and button
|
||||
GlobalVars.selected_user = user['id']
|
||||
self.selected_label = button
|
||||
|
||||
# Highlight clicked label
|
||||
self.selected_label.configure(relief=tk.SUNKEN, bg="light blue")
|
||||
|
||||
|
||||
|
||||
|
||||
#take photo of item
|
||||
class Screen3(tk.Frame):
|
||||
def __init__(self, master=None):
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
|
||||
# Create the "Start Over" button
|
||||
home_button = tk.Button(text="Start Over", command=self.homer, bg='peach puff', font=BUTTON_FONT)
|
||||
home_button.place(x=0, y=0) # top-left corner
|
||||
|
||||
self.vid = cv2.VideoCapture(0)
|
||||
self.is_capturing = True
|
||||
self.freeze_frame = None
|
||||
|
||||
# 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))
|
||||
self.canvas.pack(side="left")
|
||||
|
||||
# Info and button on the right
|
||||
self.text_frame = tk.Frame(self, bg='#bcfef9')
|
||||
self.text_frame.pack(side="right", fill="both", expand=True)
|
||||
tk.Label(self.text_frame, text="Now we will take a picture of your item to show up on Scuttlebutt.\n\nIf you tap Take Photo a second time it will re-take the photo\nbut wont show you a preview during the countdown (this is a bug)", font=("Helvetica", 16), bg='#bcfef9').pack(pady=10)
|
||||
self.button = tk.Button(self.text_frame, text="Take Photo", command=self.take_photo, height=3, width=37, bg='peach puff', font=BUTTON_FONT)
|
||||
self.button.pack(pady=10)
|
||||
|
||||
self.done_button = tk.Button(self, text="Done", command=self.done, height=3, width=30, bg='peach puff', font=BUTTON_FONT)
|
||||
self.done_button.place(relx=0.9, rely=0.9, anchor='se')
|
||||
|
||||
self.update_image()
|
||||
|
||||
def homer(self):
|
||||
self.__del__()
|
||||
self.master.switch_frame(Screen0)
|
||||
|
||||
def update_image(self):
|
||||
if self.is_capturing:
|
||||
ret, frame = self.vid.read()
|
||||
if ret:
|
||||
self.cv2image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA)
|
||||
self.img = Image.fromarray(self.cv2image)
|
||||
self.imgtk = ImageTk.PhotoImage(image=self.img)
|
||||
self.canvas.create_image(0, 0, image=self.imgtk, anchor='nw')
|
||||
|
||||
self.after(10, self.update_image)
|
||||
|
||||
def take_photo(self):
|
||||
if self.freeze_frame is not None:
|
||||
self.canvas.delete(self.freeze_frame)
|
||||
self.freeze_frame = None
|
||||
if not self.is_capturing:
|
||||
self.is_capturing = True
|
||||
self.update_image()
|
||||
countdown_thread = threading.Thread(target=self.countdown)
|
||||
countdown_thread.start()
|
||||
|
||||
def countdown(self):
|
||||
for i in range(3, 0, -1):
|
||||
self.button.config(text=str(i))
|
||||
time.sleep(1)
|
||||
|
||||
self.button.config(text="Take Photo")
|
||||
self.is_capturing = False
|
||||
rgb_image = cv2.cvtColor(self.cv2image, cv2.COLOR_RGBA2RGB)
|
||||
pil_image = Image.fromarray(rgb_image)
|
||||
pil_image.save('freeze_frame.jpg')
|
||||
self.display_taken_photo()
|
||||
|
||||
def display_taken_photo(self):
|
||||
image = Image.open('freeze_frame.jpg')
|
||||
photo = ImageTk.PhotoImage(image)
|
||||
self.freeze_frame = self.canvas.create_image(0, 0, image=photo, anchor='nw')
|
||||
|
||||
def done(self):
|
||||
self.__del__()
|
||||
self.master.switch_frame(Screen5)
|
||||
|
||||
def __del__(self):
|
||||
if self.vid.isOpened():
|
||||
self.vid.release()
|
||||
|
||||
# draw a sticker
|
||||
class Screen4(tk.Frame):
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
|
||||
# Configure column minsizes
|
||||
self.grid_columnconfigure(0, minsize=675) # considering 675 width + 50 padding on each side
|
||||
self.grid_columnconfigure(1, minsize=100)
|
||||
self.grid_columnconfigure(2, minsize=100)
|
||||
|
||||
# Creating a frame for the left side of the screen for drawing and instructions
|
||||
self.left_frame = tk.Frame(self, bg='#bcfef9')
|
||||
self.left_frame.grid(row=0, column=0, padx=2)
|
||||
|
||||
# Frame for the tools
|
||||
self.right_frame = tk.Frame(self, bg='#bcfef9')
|
||||
self.right_frame.grid(row=0, column=2, padx=70)
|
||||
|
||||
# Add Import Image Button
|
||||
tk.Button(self.left_frame, text="Import Image", command=self.import_image, height=2, width=15, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
# Add instructions
|
||||
self.label = tk.Label(self.left_frame, text="You may now draw your sticker :) This will be printed ~ 1.25 by 2.25 inches. You might have better results using thicker lines (see drawing tools on the right). The image you are drawing now will not be posted to Scuttlebutt.",
|
||||
wraplength=650, # adjust to suit needs
|
||||
font=TEXT_FONT)
|
||||
self.label.pack(pady=2)
|
||||
|
||||
# is this the drawing area
|
||||
self.drawing = Image.new('1', (650, 360), 1)
|
||||
self.draw = ImageDraw.Draw(self.drawing)
|
||||
self.last_draw = None
|
||||
|
||||
# Set initial drawing color to black and size to 1
|
||||
self.draw_color = 'black'
|
||||
self.draw_size = 1
|
||||
|
||||
# Creating the Canvas for drawing
|
||||
self.canvas = Canvas(self.left_frame, width=650, height=360, bg='white')
|
||||
self.canvas.bind("<B1-Motion>", self.draw_line)
|
||||
self.image_on_canvas = None # To store imported image's reference
|
||||
self.canvas.pack(pady=20)
|
||||
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
|
||||
self.add_qr_box() # Add QR box to the canvas
|
||||
|
||||
#Create a frame for the buttons grid
|
||||
self.buttons_frame = tk.Frame(self.right_frame, bg='#bcfef9')
|
||||
self.buttons_frame.pack(pady=50)
|
||||
|
||||
# Define the info_label to display QRX, QRY, and QRscale values
|
||||
self.info_label = tk.Label(self.right_frame, text="", bg='#bcfef9', font=TEXT_FONT)
|
||||
self.info_label.pack(pady=5) # Use pack instead of grid
|
||||
|
||||
|
||||
# Add Draw Size buttons
|
||||
tk.Button(self.buttons_frame, text=".", command=lambda: self.set_draw_size(1), height=3, width=10, bg='peach puff').grid(row=0, column=0, padx=5, pady=5)
|
||||
tk.Button(self.buttons_frame, text="*", command=lambda: self.set_draw_size(2), height=3, width=10, bg='peach puff').grid(row=1, column=0, padx=5, pady=5)
|
||||
tk.Button(self.buttons_frame, text="⚬", command=lambda: self.set_draw_size(3), height=3, width=10, bg='peach puff').grid(row=2, column=0, padx=5, pady=5)
|
||||
tk.Button(self.buttons_frame, text="⬤", command=lambda: self.set_draw_size(4), height=3, width=10, bg='peach puff').grid(row=3, column=0, padx=5, pady=5)
|
||||
|
||||
# Creating color buttons
|
||||
tk.Button(self.buttons_frame, height=5, width=10, bg='black', command=lambda: self.set_draw_color('black')).grid(row=0, column=1, padx=5, pady=5)
|
||||
tk.Button(self.buttons_frame, height=5, width=10, bg='white', command=lambda: self.set_draw_color('white')).grid(row=1, column=1, padx=5, pady=5)
|
||||
|
||||
# Add label for pen color buttons
|
||||
self.color_label = tk.Label(self.right_frame, text="^ drawing tools ^", font=TEXT_FONT)
|
||||
self.color_label.pack(pady=5)
|
||||
|
||||
# Add Clear Drawing Button
|
||||
tk.Button(self.right_frame, text="clear drawing", command=self.clear_drawing, height=2, width=10, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
# Done button
|
||||
tk.Button(self.right_frame, text="Done", command=self.next, height=3, width=10, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
# Adding a home button
|
||||
master.add_home_button(self)
|
||||
|
||||
def draw_line(self, event):
|
||||
x, y = event.x, event.y
|
||||
if self.last_draw:
|
||||
self.canvas.create_line(*self.last_draw, x, y, fill=self.draw_color, width=self.draw_size)
|
||||
self.draw.line([*self.last_draw, x, y], fill=0 if self.draw_color == 'black' else 1, width=self.draw_size)
|
||||
self.last_draw = (x, y)
|
||||
|
||||
def next(self):
|
||||
# Save the drawing as a .png file
|
||||
self.drawing.save("drawing.png")
|
||||
|
||||
# next screen
|
||||
self.master.switch_frame(Screen13)
|
||||
|
||||
def reset_last_draw(self, event):
|
||||
self.last_draw = None
|
||||
|
||||
def set_draw_color(self, color):
|
||||
self.draw_color = color
|
||||
|
||||
def set_draw_size(self, size):
|
||||
self.draw_size = size
|
||||
|
||||
def clear_drawing(self):
|
||||
self.canvas.delete("all") # Clear canvas
|
||||
self.drawing = Image.new('1', (650, 360), 1) # Create new blank drawing image
|
||||
self.draw = ImageDraw.Draw(self.drawing) # Prepare to draw on the new blank image
|
||||
self.add_qr_box() # Add QR box to the canvas
|
||||
|
||||
# add_qr_box to accept coordinates and size
|
||||
def add_qr_box(self, x=506, y=217, size=1):
|
||||
# Adjust the size based on QRscale
|
||||
box_size = 37 * size
|
||||
self.canvas.create_rectangle(x, y, x + box_size, y + box_size, outline='black', fill='white')
|
||||
self.canvas.create_text(x + box_size/2, y + box_size/2, text="QR", fill="black")
|
||||
|
||||
|
||||
def import_image(self):
|
||||
|
||||
# Open file dialog to select an image file
|
||||
file_path = filedialog.askopenfilename(filetypes=[("Image files", "*.jpg *.jpeg *.png")])
|
||||
|
||||
if not file_path: # Exit the method if no file is selected
|
||||
return
|
||||
|
||||
# Split the file path at hyphens and take the last three parts
|
||||
file_parts = file_path.split('-')[-4:]
|
||||
|
||||
if len(file_parts) < 4:
|
||||
messagebox.showerror("Filename Error", "The filename does not follow the expected pattern.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and convert the numeric parts
|
||||
QRX = int(file_parts[1])
|
||||
QRY = int(file_parts[2])
|
||||
QRscale = int(file_parts[3].split('.')[0]) # Split at the dot and take the first part
|
||||
|
||||
# Update the main application's QR data
|
||||
self.master.QRX = QRX
|
||||
self.master.QRY = QRY
|
||||
self.master.QRscale = QRscale
|
||||
|
||||
# Display QRX, QRY, and QRscale values
|
||||
self.info_label.config(text=f"QRX: {QRX}, QRY: {QRY}, QRscale: {QRscale}")
|
||||
except ValueError as e:
|
||||
messagebox.showerror("Filename Error", "The filename does not follow the expected pattern.")
|
||||
return
|
||||
|
||||
# Clear the canvas
|
||||
self.canvas.delete("all")
|
||||
# Load the image
|
||||
img = Image.open(file_path)
|
||||
# Convert to 1-bit black and white
|
||||
img = img.convert('1')
|
||||
# Resize or crop the image to fit the canvas
|
||||
if img.size[0] > 675 or img.size[1] > 375:
|
||||
img = img.crop((0, 0, 650, 360))
|
||||
self.add_qr_box(QRX, QRY, QRscale)
|
||||
|
||||
# Paste the imported image onto the drawing image
|
||||
self.drawing.paste(img)
|
||||
|
||||
# Save image reference to avoid garbage collection
|
||||
self.imported_img = ImageTk.PhotoImage(img)
|
||||
|
||||
# Add image to the canvas
|
||||
self.image_on_canvas = self.canvas.create_image(0, 0, image=self.imported_img, anchor='nw')
|
||||
|
||||
# add the QR box at the new position
|
||||
self.add_qr_box(QRX, QRY, QRscale)
|
||||
|
||||
|
||||
# typed description
|
||||
class Screen5(tk.Frame):
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
master.add_home_button(self)
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
|
||||
# Adding the information label
|
||||
self.info_label = tk.Label(self, text="Please enter any information you'd like about your item. This can be make/model of the item, a name for the item, diagnostic information, provenance, historical context, contact info, stories, whatever. Along with the photo, this will be posted on Scuttlebutt forever and cannot be deleted. You can always add more information later via any Scuttlebutt client.", font=TEXT_FONT, wraplength=500)
|
||||
self.info_label.pack(pady=10)
|
||||
|
||||
# Adding the text entry field
|
||||
self.info_entry = tk.Text(self, height=10, width=50, font=("Helvetica", 16))
|
||||
self.info_entry.pack(pady=10)
|
||||
|
||||
# Adding the done button
|
||||
self.done_button = tk.Button(self, text="Done", command=self.save_info_and_switch, height=3, width=30, bg='peach puff', font=BUTTON_FONT)
|
||||
self.done_button.pack(pady=10)
|
||||
|
||||
# Setting the focus to the text entry field
|
||||
self.info_entry.focus_set()
|
||||
|
||||
def save_info_and_switch(self):
|
||||
# Saving the text from the entry field to a global variable
|
||||
global info_text
|
||||
info_text = self.info_entry.get("1.0", "end-1c")
|
||||
# test
|
||||
# print(info_text)
|
||||
# escape the newlines!:
|
||||
info_text = info_text.replace('\n', '\\n')
|
||||
# print(info_text)
|
||||
# escape quotes as well:
|
||||
# info_text = info_text.replace('"', '\\"')
|
||||
# print(info_text)
|
||||
self.master.switch_frame(Screen11)
|
||||
|
||||
# I understand
|
||||
class Screen6(tk.Frame):
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
master.add_home_button(self)
|
||||
tk.Button(self, text="I Understand", command=lambda: master.switch_frame(Screen3), height=3, width=30, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
# create user not implemented lol
|
||||
class Screen7(tk.Frame):
|
||||
def __init__(self, master):
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
master.add_home_button(self)
|
||||
# Assume there's a method to manage the text entry
|
||||
self.info_label = tk.Label(self, text="Hiii sorry this hasn't been implemented yet!", font=("Helvetica", 16), wraplength=500)
|
||||
self.info_label.pack()
|
||||
#tk.Button(self, text="Done", command=lambda: master.switch_frame(Screen6), height=3, width=30, bg='peach puff').pack(pady=10)
|
||||
|
||||
|
||||
# draw a ribbon tag
|
||||
class Screen8(tk.Frame):
|
||||
def __init__(self, master):
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
|
||||
# default set QRscale to 8
|
||||
QRscale = 8
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
|
||||
# Creating a frame for the left side of the screen for drawing
|
||||
self.left_frame = tk.Frame(self, bg='#bcfef9')
|
||||
self.left_frame.pack(side='left', padx=50)
|
||||
|
||||
# Frame for the instructions
|
||||
self.center_frame = tk.Frame(self, bg='#bcfef9')
|
||||
self.center_frame.pack(side='left', padx=50)
|
||||
|
||||
# Frame for the tools
|
||||
self.right_frame = tk.Frame(self, bg='#bcfef9')
|
||||
self.right_frame.pack(side='left', padx=50)
|
||||
|
||||
# Add instructions
|
||||
self.label = tk.Label(self.center_frame, text="You may now draw your tag! You might like to include contact info for yourself, the name of the item, a drawing, or whatever you want, it's your artistic expression. Thin lines might not show up well (try different drawing tools on the right).", wraplength=400, font=TEXT_FONT)
|
||||
self.label.pack(pady=30)
|
||||
|
||||
# the drawing area
|
||||
self.drawing = Image.new('1', (325, 179), 1)
|
||||
self.draw = ImageDraw.Draw(self.drawing)
|
||||
self.last_draw = None
|
||||
|
||||
# Set initial drawing color to black and size to 1
|
||||
self.draw_color = 'black'
|
||||
self.draw_size = 1
|
||||
|
||||
# Creating the Canvas for drawing
|
||||
self.canvas = Canvas(self.left_frame, width=325, height=179, bg='white')
|
||||
self.canvas.bind("<B1-Motion>", self.draw_line)
|
||||
self.canvas.pack(pady=20)
|
||||
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
|
||||
# self.add_qr_box() # Add QR box to the canvas
|
||||
|
||||
#Create a frame for the buttons grid
|
||||
self.buttons_frame = tk.Frame(self.right_frame, bg='#bcfef9')
|
||||
self.buttons_frame.pack(pady=10)
|
||||
|
||||
# Add Import Image Button
|
||||
tk.Button(self.buttons_frame, text="Import Image", command=self.import_image, height=2, width=15, bg='peach puff', font=BUTTON_FONT).grid(row=4, column=0, columnspan=2, pady=10, sticky='ew')
|
||||
|
||||
|
||||
# Add Draw Size buttons
|
||||
tk.Button(self.buttons_frame, text=".", command=lambda: self.set_draw_size(1), height=3, width=10, bg='peach puff').grid(row=0, column=0, padx=5, pady=5)
|
||||
tk.Button(self.buttons_frame, text="*", command=lambda: self.set_draw_size(2), height=3, width=10, bg='peach puff').grid(row=1, column=0, padx=5, pady=5)
|
||||
tk.Button(self.buttons_frame, text="⚬", command=lambda: self.set_draw_size(3), height=3, width=10, bg='peach puff').grid(row=2, column=0, padx=5, pady=5)
|
||||
tk.Button(self.buttons_frame, text="⬤", command=lambda: self.set_draw_size(4), height=3, width=10, bg='peach puff').grid(row=3, column=0, padx=5, pady=5)
|
||||
|
||||
# Creating color buttons
|
||||
tk.Button(self.buttons_frame, height=5, width=10, bg='black', command=lambda: self.set_draw_color('black')).grid(row=0, column=1, padx=5, pady=5)
|
||||
tk.Button(self.buttons_frame, height=5, width=10, bg='white', command=lambda: self.set_draw_color('white')).grid(row=1, column=1, padx=5, pady=5)
|
||||
|
||||
# Add label for pen color buttons
|
||||
self.color_label = tk.Label(self.right_frame, text="^ drawing tools ^", font=("Helvetica", 16))
|
||||
self.color_label.pack(pady=5)
|
||||
|
||||
# Add Clear Drawing Button
|
||||
tk.Button(self.right_frame, text="clear drawing", command=self.clear_drawing, height=3, width=20, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
# Done button
|
||||
tk.Button(self.right_frame, text="Done", command=self.next, height=3, width=30, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
# Adding a home button
|
||||
master.add_home_button(self.center_frame)
|
||||
|
||||
def draw_line(self, event):
|
||||
x, y = event.x, event.y
|
||||
if self.last_draw:
|
||||
self.canvas.create_line(*self.last_draw, x, y, fill=self.draw_color, width=self.draw_size)
|
||||
self.draw.line([*self.last_draw, x, y], fill=0 if self.draw_color == 'black' else 1, width=self.draw_size)
|
||||
self.last_draw = (x, y)
|
||||
|
||||
def next(self):
|
||||
# Save the drawing as a .png file
|
||||
self.drawing.save("drawing.png")
|
||||
|
||||
# next screen
|
||||
self.master.switch_frame(Screen13)
|
||||
|
||||
def reset_last_draw(self, event):
|
||||
self.last_draw = None
|
||||
|
||||
def set_draw_color(self, color):
|
||||
self.draw_color = color
|
||||
|
||||
def set_draw_size(self, size):
|
||||
self.draw_size = size
|
||||
|
||||
def clear_drawing(self):
|
||||
self.canvas.delete("all") # Clear canvas
|
||||
self.drawing = Image.new('1', (475, 375), 1) # Create new blank drawing image
|
||||
self.draw = ImageDraw.Draw(self.drawing) # Prepare to draw on the new blank image
|
||||
self.add_qr_box() # Add QR box to the canvas
|
||||
|
||||
def add_qr_box(self):
|
||||
self.canvas.create_rectangle(346, 229, 475, 358, outline='black', fill='white')
|
||||
self.canvas.create_text(355, 260, text="QR", fill="black")
|
||||
|
||||
|
||||
|
||||
def import_image(self):
|
||||
# Open file dialog to select an image file
|
||||
file_path = filedialog.askopenfilename(filetypes=[("Image files", "*.jpg *.png")])
|
||||
|
||||
if file_path:
|
||||
# Check if the selected file has a correct extension
|
||||
if not (file_path.lower().endswith('.jpg') or file_path.lower().endswith('.png')):
|
||||
tk.messagebox.showerror("Invalid file", "Please select a .jpg or .png file.")
|
||||
return
|
||||
|
||||
# Load the image
|
||||
img = Image.open(file_path)
|
||||
|
||||
# Convert to 1-bit black and white
|
||||
img = img.convert('1')
|
||||
|
||||
# Resize or crop the image to fit the canvas
|
||||
if img.size[0] > 325 or img.size[1] > 179:
|
||||
img = img.crop((0, 0, 325, 179))
|
||||
|
||||
# Paste the imported image onto the drawing image
|
||||
self.drawing.paste(img)
|
||||
|
||||
# Save image reference to avoid garbage collection
|
||||
self.imported_img = ImageTk.PhotoImage(img)
|
||||
|
||||
# Clear the canvas
|
||||
self.canvas.delete("all")
|
||||
|
||||
# Add image to the canvas
|
||||
self.image_on_canvas = self.canvas.create_image(0, 0, image=self.imported_img, anchor='nw')
|
||||
|
||||
# Re-add QR box overlay
|
||||
# self.add_qr_box()
|
||||
|
||||
|
||||
# txt update
|
||||
class Screen9(tk.Frame):
|
||||
def __init__(self, master):
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
master.add_home_button(self)
|
||||
# Assume there's a method to manage the text entry
|
||||
tk.Button(self, text="Done", command=lambda: master.switch_frame(Screen10), height=3, width=30, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
#THX BYE
|
||||
class Screen10(tk.Frame):
|
||||
def __init__(self, master):
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
GlobalVars.selected_user = None # Reset the selected user
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
tk.Label(self, text="Thank you!", bg='#bcfef9', font=('Helvetica', 48)).pack()
|
||||
tk.Button(self, text="Done", command=lambda: master.switch_frame(Screen0), height=3, width=30, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
# Sticker or tag?
|
||||
class Screen11(tk.Frame):
|
||||
def __init__(self, master):
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
master.add_home_button(self)
|
||||
# Instructions
|
||||
self.label = tk.Label(self, text="Do you want to design a sticker or a ribbon? The sticker is suitable for hard surfaces and the ribbon is a tag suitable for sewing into textiles.",
|
||||
wraplength=400, # adjust to suit needs
|
||||
font=("Helvetica", 16))
|
||||
self.label.pack(pady=50)
|
||||
|
||||
# Button functions
|
||||
def select_ribbon():
|
||||
global print_type
|
||||
print_type = 'ribbon'
|
||||
master.switch_frame(Screen8)
|
||||
|
||||
def select_sticker():
|
||||
global print_type
|
||||
print_type = 'sticker'
|
||||
master.switch_frame(Screen4)
|
||||
|
||||
|
||||
# Buttons
|
||||
tk.Button(self, text="Sticker", command=select_sticker, height=4, width=39, bg='peach puff', font=BUTTON_FONT).pack(side='top', pady=30)
|
||||
tk.Button(self, text="Ribbon tag", command=select_ribbon, height=4, width=39, bg='peach puff', font=BUTTON_FONT).pack(side='top', pady=30)
|
||||
|
||||
|
||||
# after QR scanned for lookup
|
||||
class Screen12(tk.Frame):
|
||||
def __init__(self, master):
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
master.add_home_button(self)
|
||||
tk.Button(self, text="not implemented yet", command=lambda: master.switch_frame(Screen8), height=3, width=30, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
tk.Button(self, text="Post Update (also not implemented)", command=lambda: master.switch_frame(Screen9), height=3, width=30, bg='peach puff', font=BUTTON_FONT).pack(pady=10)
|
||||
|
||||
#time to print
|
||||
class Screen13(tk.Frame):
|
||||
def __init__(self, master):
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
master.add_home_button(self)
|
||||
|
||||
# Create a container to hold the widgets
|
||||
container = tk.Frame(self)
|
||||
container.place(relx=0.5, rely=0.5, anchor='center')
|
||||
|
||||
# instructions
|
||||
tk.Label(container, text="Wonderful! It is now time to post your item to Scuttlebutt and to print your tag. You can still cancel by hitting Start Over if you like.", wraplength=600, font=TEXT_FONT).grid(row=0, column=0, columnspan=2)
|
||||
|
||||
# buttons
|
||||
master.add_home_button(self)
|
||||
tk.Button(container, text="Print", command=self.printy, height=3, width=30, bg='peach puff', font=BUTTON_FONT).grid(row=2, column=0, pady=20)
|
||||
|
||||
|
||||
# go ahead and print the thing
|
||||
def printy(self):
|
||||
global print_type
|
||||
|
||||
# Specify the path to your image file
|
||||
path_to_image = "/home/trav/Documents/custodiosk/freeze_frame.jpg"
|
||||
|
||||
# Get QR data from the main application
|
||||
QRX = self.master.QRX
|
||||
QRY = self.master.QRY
|
||||
QRscale = self.master.QRscale
|
||||
|
||||
# make ssb post
|
||||
key = addtoDB.addToSSB(path_to_image,info_text,1)
|
||||
|
||||
# ssb give! (make sure we have a UID to give to first)
|
||||
if GlobalVars.selected_user and GlobalVars.selected_user.strip() != "":
|
||||
nothing = addtoDB.addToSSB(GlobalVars.selected_user,key,2)
|
||||
|
||||
# gonna need to revise this later but for now the textile tags are always full-size QR:
|
||||
if print_type == "ribbon":
|
||||
QRscale = 7
|
||||
|
||||
# Create qr code
|
||||
#from https://ourcodeworld.com/articles/read/554/how-to-create-a-qr-code-image-or-svg-in-python
|
||||
qr = qrcode.QRCode(
|
||||
version = 1,
|
||||
error_correction = qrcode.constants.ERROR_CORRECT_H,
|
||||
box_size = QRscale,
|
||||
border = 0,
|
||||
)
|
||||
# Add data
|
||||
qr.add_data(key)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Create an image from the QR Code instance
|
||||
img = qr.make_image()
|
||||
whereToSaveQR = 'qr.jpg'
|
||||
img.save(whereToSaveQR)
|
||||
|
||||
# compose image for tag
|
||||
drawing = Image.open("drawing.png") # drawing
|
||||
qr = Image.open("qr.jpg") # qr
|
||||
|
||||
#### merge em
|
||||
|
||||
## if sticker
|
||||
if print_type == "sticker":
|
||||
## if we didn't custom set X/Y, set to defaults
|
||||
if QRX is None and QRY is None:
|
||||
QRX=506
|
||||
QRY=217
|
||||
merged_image = Image.new('L', (675, 375), "white")
|
||||
merged_image.paste(drawing, (0, 8))
|
||||
merged_image.paste(qr, (QRX, QRY+8)) # we add 8 because this is slightly off from when we drew it
|
||||
merged_image.save("merged_image.png")
|
||||
|
||||
# if ribbon
|
||||
if print_type == "ribbon":
|
||||
merged_image = Image.new('L', (375, 675), "white")
|
||||
merged_image.paste(drawing, (25, 100)) # set it 25 in because that's the border
|
||||
merged_image.paste(qr, (42, 279)) # paste without mask
|
||||
merged_image.save("merged_image.png")
|
||||
image = Image.open("merged_image.png")
|
||||
# rotated_image = image.transpose(Image.ROTATE_270) # Transpose and rotate 90 degrees, old version when we weren't doing ribbon vertical
|
||||
# rotated_image.save("merged_image.png")
|
||||
|
||||
# Get the ZPL code for the image
|
||||
zpl_code = tozpl.print_to_zpl("merged_image.png")
|
||||
|
||||
#save the zpl
|
||||
# Open the file in write mode
|
||||
with open("to_print.zpl", "w") as file:
|
||||
# Write the string to the file
|
||||
file.write(zpl_code)
|
||||
|
||||
#print(zpl_code) #only needed for testing
|
||||
#print (print_type)
|
||||
|
||||
|
||||
# print to sticker printer
|
||||
if print_type == "sticker":
|
||||
try:
|
||||
result = subprocess.Popen('lpr -P sticker_printer -o raw to_print.zpl', shell=True, stdout=subprocess.PIPE, )
|
||||
# print('no print')
|
||||
except:
|
||||
print('traceback.format_exc():\n%s' % traceback.format_exc())
|
||||
exit()
|
||||
# or print to tag printer:
|
||||
if print_type == "ribbon":
|
||||
try:
|
||||
result = subprocess.Popen('lpr -P tag-printer -o raw to_print.zpl', shell=True, stdout=subprocess.PIPE, )
|
||||
except:
|
||||
print('traceback.format_exc():\n%s' % traceback.format_exc())
|
||||
exit()
|
||||
|
||||
self.master.switch_frame(Screen10) # Switching to Screen10 after Done
|
||||
|
||||
# lookup item
|
||||
class Screen14(tk.Frame):
|
||||
def __init__(self, master):
|
||||
global BUTTON_FONT
|
||||
global TEXT_FONT
|
||||
tk.Frame.__init__(self, master, bg='#bcfef9')
|
||||
|
||||
# divide the screen into two halves
|
||||
left_frame = tk.Frame(self, bg='#bcfef9') # updated background color
|
||||
right_frame = tk.Frame(self, bg='#bcfef9') # updated background color
|
||||
left_frame.pack(side='left', fill='both', expand=True)
|
||||
right_frame.pack(side='right', fill='both', expand=True)
|
||||
|
||||
# add the home button to the right frame
|
||||
master.add_home_button(right_frame)
|
||||
|
||||
# setup the instruction on the right side, with wraplength to avoid running off the screen
|
||||
instruction = tk.Label(right_frame, text="Please hold the QR code of a Custodisco tag up to the camera to be scanned. This isn't currently working! Sorry! If you download a Scuttlebutt client and follow the Custodisco Kiosk account you can look up Custodisco items on your own device. Ask someone who knows about Scuttlebutt for help ;)", font=("Helvetica", 16), bg='#bcfef9', wraplength=300) # updated wraplength
|
||||
instruction.pack(pady=100) # increased padding to avoid overlap with the button
|
||||
|
||||
# setup the video feed on the left side
|
||||
self.video = tk.Label(left_frame, bg='#bcfef9') # updated background color
|
||||
self.video.pack(pady=10)
|
||||
|
||||
# Open the camera for video capture
|
||||
self.cap = cv2.VideoCapture(0)
|
||||
self.master.after(10, self.update_frame)
|
||||
|
||||
def update_frame(self):
|
||||
global qr_code_value
|
||||
|
||||
# Capture frame-by-frame
|
||||
ret, frame = self.cap.read()
|
||||
|
||||
# Check if the frame is not None
|
||||
if not ret or frame is None:
|
||||
print("Failed to capture frame")
|
||||
return
|
||||
|
||||
# Our operations on the frame come here
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Look for QR codes in the frame
|
||||
codes = decode(gray)
|
||||
|
||||
for code in codes:
|
||||
# If a QR code is detected, store its value and switch to Screen12
|
||||
qr_code_value = code.data.decode('utf-8')
|
||||
self.cap.release() # Close the video capture
|
||||
self.master.switch_frame(Screen12)
|
||||
return
|
||||
|
||||
# Display the resulting frame
|
||||
cv2image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA)
|
||||
img = Image.fromarray(cv2image)
|
||||
imgtk = ImageTk.PhotoImage(image=img)
|
||||
self.video.imgtk = imgtk
|
||||
self.video.configure(image=imgtk)
|
||||
self.master.after(10, self.update_frame) # Call the update_frame function after 10 milliseconds
|
||||
|
||||
def destroy(self):
|
||||
self.cap.release() # Close the video capture when the frame is destroyed
|
||||
super().destroy() # Call the parent class's destroy method
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = Kiosk()
|
||||
BUTTON_FONT = tkfont.Font(size=24, family='Helvetica')
|
||||
TEXT_FONT = tkfont.Font(size=20, family='Helvetica')
|
||||
app.mainloop()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Camera Listing Utility for Custodisco Kiosk
|
||||
|
||||
This script scans for available cameras and displays information about them.
|
||||
Use this to find the correct camera name to set in config.json.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
import cv2
|
||||
|
||||
|
||||
def get_camera_info():
|
||||
"""
|
||||
Scan for available cameras and return information about each.
|
||||
|
||||
Returns a list of dicts with keys: index, device_name, status
|
||||
"""
|
||||
cameras = []
|
||||
by_id_dir = "/dev/v4l/by-id"
|
||||
found_indices = set()
|
||||
|
||||
# First, scan /dev/v4l/by-id for symlinks to cameras
|
||||
if os.path.isdir(by_id_dir):
|
||||
paths = sorted(glob.glob(os.path.join(by_id_dir, "*")))
|
||||
for path in paths:
|
||||
device_name = os.path.basename(path)
|
||||
target = os.path.realpath(path)
|
||||
match = re.search(r"video(\d+)", target)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
idx = int(match.group(1))
|
||||
if idx in found_indices:
|
||||
continue
|
||||
|
||||
# Test if camera works
|
||||
status = test_camera(idx)
|
||||
cameras.append({
|
||||
"index": idx,
|
||||
"device_name": device_name,
|
||||
"status": status
|
||||
})
|
||||
found_indices.add(idx)
|
||||
|
||||
# Fallback: probe indices 0-5 for cameras not in /dev/v4l/by-id
|
||||
for idx in range(6):
|
||||
if idx in found_indices:
|
||||
continue
|
||||
|
||||
status = test_camera(idx)
|
||||
if status == "Working":
|
||||
cameras.append({
|
||||
"index": idx,
|
||||
"device_name": "(built-in or unknown)",
|
||||
"status": status
|
||||
})
|
||||
found_indices.add(idx)
|
||||
|
||||
# Sort by index
|
||||
cameras.sort(key=lambda x: x["index"])
|
||||
return cameras
|
||||
|
||||
|
||||
def test_camera(index):
|
||||
"""Test if a camera index can be opened."""
|
||||
try:
|
||||
cap = cv2.VideoCapture(index)
|
||||
if cap.isOpened():
|
||||
cap.release()
|
||||
return "Working"
|
||||
cap.release()
|
||||
return "Not available"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def print_camera_table(cameras):
|
||||
"""Print a formatted table of cameras."""
|
||||
if not cameras:
|
||||
print("No cameras found.")
|
||||
print("\nTroubleshooting tips:")
|
||||
print(" - Check that your camera is connected")
|
||||
print(" - Try unplugging and replugging the camera")
|
||||
print(" - Check dmesg for USB device errors: dmesg | tail -20")
|
||||
return
|
||||
|
||||
print("\nAvailable Cameras:")
|
||||
print(" {:<6} {:<45} {}".format("Index", "Device Name", "Status"))
|
||||
print(" {:<6} {:<45} {}".format("-----", "-----------", "------"))
|
||||
|
||||
for cam in cameras:
|
||||
print(" {:<6} {:<45} {}".format(
|
||||
cam["index"],
|
||||
cam["device_name"][:45],
|
||||
cam["status"]
|
||||
))
|
||||
|
||||
print("\nTo use a camera, set \"preferred_name\" in config.json to part of the device name.")
|
||||
print("Example: \"preferred_name\": \"NexiGo\"")
|
||||
|
||||
|
||||
def main():
|
||||
print("Scanning for cameras...")
|
||||
cameras = get_camera_info()
|
||||
print_camera_table(cameras)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "custodisco-kiosk",
|
||||
"version": "1.0.0",
|
||||
"description": "Kiosk application for Custodisco - creates item tags with QR codes and posts to Scuttlebutt",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"ssb-client": "^4.9.0",
|
||||
"ssb-keys": "^8.5.0",
|
||||
"pull-stream": "^3.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sha.js": "2.4.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
^XA
|
||||
^PW675
|
||||
^LL375
|
||||
^LH15,15
|
||||
^LT0
|
||||
^LS0
|
||||
^JUS
|
||||
^XZ
|
||||
@@ -0,0 +1,129 @@
|
||||
# Zebra Printer Darkness Troubleshooting
|
||||
|
||||
## Understanding Darkness Settings
|
||||
|
||||
The Zebra GX430t has **two darkness settings that add together**:
|
||||
|
||||
| Command | Type | Range | Persists? |
|
||||
|---------|------|-------|-----------|
|
||||
| `~SD##` | Absolute base darkness | 0-30 | Yes (with `^JUS`) |
|
||||
| `^MD##` | Relative offset | -30 to +30 | Yes (with `^JUS`) |
|
||||
|
||||
**Effective darkness = ~SD value + ^MD offset**
|
||||
|
||||
### Critical Warning: ^MD Stacks!
|
||||
|
||||
The `^MD` command is **relative** - it adds/subtracts from the current saved offset. If you run:
|
||||
```bash
|
||||
echo "^XA^MD-5^JUS^XZ" | lp -d printer # offset now -5
|
||||
echo "^XA^MD-2^JUS^XZ" | lp -d printer # offset now -7 (not -2!)
|
||||
```
|
||||
|
||||
This can quickly result in an extremely low effective darkness, causing blank labels even at high `~SD` values.
|
||||
|
||||
### How to Reset
|
||||
|
||||
To fix stacked ^MD issues:
|
||||
```bash
|
||||
# Option 1: Factory reset (WARNING: loses all calibration)
|
||||
echo "^XA^JUF^XZ" | lp -d printer
|
||||
|
||||
# Option 2: Try setting high ~SD to compensate
|
||||
# If prints are blank at ~SD15, try ~SD25 or ~SD30
|
||||
echo "^XA~SD25^JUS^XZ" | lp -d printer
|
||||
```
|
||||
|
||||
## Symptoms and Solutions
|
||||
|
||||
### Checkerboard/dithered areas print DARKER than solid black
|
||||
**Cause:** Too much heat - solid areas need sustained heat, but you're overheating
|
||||
**Solution:** DECREASE darkness (counterintuitive!)
|
||||
```bash
|
||||
echo "^XA~SD10^JUS^XZ" | lp -d printer # try lower values
|
||||
```
|
||||
|
||||
### Ribbon tears or sticks to labels
|
||||
**Cause:** Way too much heat
|
||||
**Solution:** Significantly decrease darkness
|
||||
```bash
|
||||
echo "^XA~SD5^JUS^XZ" | lp -d printer
|
||||
```
|
||||
|
||||
### Text edges dark but centers are gray/muddled
|
||||
**Cause:** Slightly too much heat
|
||||
**Solution:** Decrease darkness incrementally
|
||||
|
||||
### Prints are faint/gray overall
|
||||
**Cause:** Not enough heat (or extreme overheat causing poor transfer)
|
||||
**Solution:** Increase darkness, but if you've been adjusting ^MD, the offset may be very negative
|
||||
|
||||
### Completely blank labels
|
||||
**Cause:**
|
||||
1. Darkness too low (check for stacked ^MD issue)
|
||||
2. Wrong print mode (direct thermal vs thermal transfer)
|
||||
3. Ribbon not making contact
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Ensure thermal transfer mode
|
||||
echo "^XA^MTT^JUS^XZ" | lp -d printer
|
||||
|
||||
# Try high darkness to rule out ^MD stacking
|
||||
echo "^XA~SD25^JUS^XZ" | lp -d printer
|
||||
```
|
||||
|
||||
## Finding the Right Darkness
|
||||
|
||||
Start low and work up - it's easier to recover from too-light than too-dark:
|
||||
|
||||
1. Set a known baseline:
|
||||
```bash
|
||||
echo "^XA~SD5^JUS^XZ" | lp -d printer
|
||||
```
|
||||
|
||||
2. Print test pattern (see test-prints/ directory)
|
||||
|
||||
3. If too light, increment by 2-3:
|
||||
```bash
|
||||
echo "^XA~SD8^JUS^XZ" | lp -d printer
|
||||
```
|
||||
|
||||
4. Stop when black is solid black and gray is distinguishable
|
||||
|
||||
**For full resin ribbon on this setup, ~SD25 worked well (after ^MD offset issues).**
|
||||
**Originally, ~SD2-6 was optimal before ^MD commands corrupted the offset.**
|
||||
|
||||
## Printing ZPL Files
|
||||
|
||||
Always use `-o raw` flag:
|
||||
```bash
|
||||
lp -d printer -o raw /path/to/file.zpl
|
||||
```
|
||||
|
||||
Without `-o raw`, large ZPL files may not print.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Set absolute darkness (0-30)
|
||||
echo "^XA~SD15^JUS^XZ" | lp -d printer
|
||||
|
||||
# Check thermal transfer mode is enabled
|
||||
echo "^XA^MTT^JUS^XZ" | lp -d printer
|
||||
|
||||
# Set print speed (1=slowest, higher=faster)
|
||||
echo "^XA^PR2^JUS^XZ" | lp -d printer
|
||||
|
||||
# Factory reset (last resort - loses calibration!)
|
||||
echo "^XA^JUF^XZ" | lp -d printer
|
||||
```
|
||||
|
||||
## Session Log: 2026-01-28
|
||||
|
||||
Started with checkerboard-darker-than-solid symptom. Discovered:
|
||||
- Original darkness was way too high
|
||||
- Optimal was around ~SD2-6
|
||||
- Accidentally used ^MD-5 and ^MD-2 which stacked to create large negative offset
|
||||
- Even after power cycle, blank labels persisted (saved to EEPROM)
|
||||
- Had to use ~SD25 to compensate for the negative ^MD offset
|
||||
- Lesson: Avoid ^MD commands, stick to ~SD for absolute control
|
||||
@@ -0,0 +1,334 @@
|
||||
# Nylon Printer Setup Guide
|
||||
## Zebra GX430t - Thermal Transfer - 1.5" Continuous Nylon with Cutter
|
||||
|
||||
> **Correction (2026-07-30):** This guide originally claimed `^MNN,Y` sets thermal transfer mode. It doesn't — `^MN` only controls *media tracking* (continuous vs. gap/mark sensing). The actual print-method command is `^MT` (`T` = thermal transfer, `D` = direct thermal), and this guide's setup script never called it. That, combined with using the *relative* `^MD` darkness command (which stacks across repeated runs instead of setting an absolute value), is what caused the printer to start printing completely blank labels after working fine for months — the print method silently reverted/was never explicitly set, and accumulated `^MD` offsets pushed effective darkness near zero. Fixed below by adding an explicit `^MT` step and switching all darkness commands to the absolute `~SD` form. The original (incorrect) version of this guide is preserved at the bottom for reference.
|
||||
|
||||
### Hardware Requirements
|
||||
- Zebra GX430t printer with cutter module (300dpi)
|
||||
- 1.5" continuous nylon ribbon roll
|
||||
- Full resin thermal transfer ribbon
|
||||
- DIY uptake spool for ribbon
|
||||
- USB connection to Debian machine
|
||||
|
||||
### Step 1: Factory Reset Printer
|
||||
|
||||
Before starting, wipe any previous owner settings:
|
||||
|
||||
```bash
|
||||
echo "^XA^JUF^XZ" | lp -d YOUR_PRINTER_NAME
|
||||
```
|
||||
|
||||
Wait 5-10 seconds for the printer to reset.
|
||||
|
||||
### Step 2: Add Printer to CUPS
|
||||
|
||||
1. Install CUPS if not already installed:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install cups cups-client
|
||||
sudo usermod -aG lpadmin $USER
|
||||
```
|
||||
Log out and back in after adding to lpadmin group.
|
||||
|
||||
2. Connect printer via USB and verify detection:
|
||||
```bash
|
||||
lsusb | grep -i zebra
|
||||
```
|
||||
|
||||
3. Access CUPS web interface at `http://localhost:631`
|
||||
|
||||
4. Go to **Administration → Add Printer**
|
||||
|
||||
5. Select the Zebra printer from USB devices
|
||||
|
||||
6. Name it something memorable (e.g., `nylon`)
|
||||
|
||||
7. **CRITICAL:** For the driver/model, select **"Raw Queue"**. Do NOT use the Zebra CUPS driver — it will intercept ZPL commands instead of passing them through, causing config commands to print blank labels instead of changing settings. Use command line:
|
||||
```bash
|
||||
sudo lpadmin -p nylon -E -v usb://Zebra%20Technologies/ZTC%20GX430t?serial=YOUR_SERIAL -m raw
|
||||
```
|
||||
If the printer was previously set up with a driver, fix it with:
|
||||
```bash
|
||||
sudo lpadmin -p nylon -m raw
|
||||
```
|
||||
|
||||
8. Restart CUPS:
|
||||
```bash
|
||||
sudo systemctl restart cups
|
||||
```
|
||||
|
||||
9. Verify printer exists:
|
||||
```bash
|
||||
lpstat -p
|
||||
```
|
||||
|
||||
### Step 3: Configure Print Method (Thermal Transfer)
|
||||
|
||||
**This is the step that actually enables ribbon/thermal transfer printing.** Use `^MT` — `T` for thermal transfer, `D` for direct thermal:
|
||||
|
||||
```bash
|
||||
echo "^XA^MTT^JUS^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
If a label ever comes out completely blank despite good darkness and correctly-loaded ribbon, re-run this command — the print method may have reverted to direct thermal (e.g. after a factory reset).
|
||||
|
||||
### Step 4: Configure Media Tracking + Ribbon Uptake
|
||||
|
||||
```bash
|
||||
# Set continuous media tracking with uptake enabled
|
||||
echo "^XA^MNN,Y^JUS^XZ" | lp -d nylon -o raw
|
||||
|
||||
# Verify settings saved
|
||||
echo "^XA^JUA^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
The `^MNN,Y` command:
|
||||
- First `N` = Continuous media tracking (no gap/mark sensing needed) — **not** thermal transfer, see Step 3 for that
|
||||
- `Y` = Enable ribbon uptake motor
|
||||
|
||||
### Step 5: Set Media Width
|
||||
|
||||
For 1.5" wide continuous nylon at 300dpi:
|
||||
|
||||
```bash
|
||||
# Width: 1.5" × 300dpi = 450 dots
|
||||
echo "^XA^PW450^JUS^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
**Note:** Label length (`^LL`) will be set per-print based on content height.
|
||||
|
||||
### Step 6: Configure Cutter
|
||||
|
||||
Enable cutter to cut after each print:
|
||||
|
||||
```bash
|
||||
# MMC = Cutter mode
|
||||
# ^MMC,Y = Enable cutter at end of print
|
||||
echo "^XA^MMC,Y^JUS^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
Alternative cutter commands if needed:
|
||||
- `^MMC,N` - Disable cutter
|
||||
- `^CN##` - Cut immediately, advance N dots after cut
|
||||
|
||||
### Step 7: Configure Darkness
|
||||
|
||||
**Use `~SD` (absolute, 0-30) instead of `^MD` (relative, -30 to +30).** `^MD` adds/subtracts from whatever offset is already saved — running a setup script more than once silently stacks it and can walk darkness down to near-zero (this is exactly what caused the 2026-07 blank-label incident). `~SD` always sets an absolute value, so it's safe to re-run.
|
||||
|
||||
```bash
|
||||
echo "^XA~SD20^JUS^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
Test and adjust:
|
||||
- Too light/faint? Increase (`~SD24`, `~SD28`, etc.)
|
||||
- Resin sticking to nylon? Decrease
|
||||
|
||||
**For full resin ribbon on this printer, `~SD28` (near the max of 30) was needed to get solid, legible text — `~SD20` printed extremely faint.** Your combination of ribbon/media may need a different value; start around 20 and work up.
|
||||
|
||||
**Note:** Nylon may require different darkness than paper labels. Test thoroughly. See `darkness-troubleshooting.md` for more detail.
|
||||
|
||||
### Step 8: Test Print
|
||||
|
||||
Simple text test (creates ~0.5" tall tag, small to avoid wasting ribbon while dialing in darkness):
|
||||
|
||||
```bash
|
||||
echo "^XA^MTT~SD20^PW450^LL150^MNN^FO40,40^ADN,36,20^FDTest^FS^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
Longer test with border:
|
||||
|
||||
```bash
|
||||
echo "^XA^MTT~SD20^PW450^LL600^MNN^FO10,10^GB430,580,5^FS^FO50,50^ADN,36,20^FDLonger Tag^FS^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
This creates a 1.5" wide x 2" tall tag with border.
|
||||
|
||||
### Step 9: Print from ZPL File
|
||||
|
||||
```bash
|
||||
lp -d nylon your_file.zpl
|
||||
```
|
||||
|
||||
**Important:** Your ZPL files MUST include `^LL` (label length) for each print, since continuous media has no fixed length.
|
||||
|
||||
Example ZPL structure:
|
||||
```zpl
|
||||
^XA
|
||||
^MTT # Print method: thermal transfer
|
||||
^PW450 # Width: 1.5"
|
||||
^LL450 # Length: 1.5" (creates square tag)
|
||||
~SD28 # Darkness (absolute)
|
||||
^MNN # Continuous media tracking
|
||||
^FO50,50^ADN,36,20^FDContent Here^FS
|
||||
^XZ
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Problem: Labels print completely blank**
|
||||
- Cause 1: Print method reverted to direct thermal — re-run Step 3 (`^MTT`)
|
||||
- Cause 2: Darkness offset corrupted by repeated `^MD` commands — set an absolute value with `~SD` (Step 7), starting around 20 and increasing
|
||||
- Cause 3: Ribbon loaded backwards (dull/matte side should face the media, shiny/coated side should face the print head)
|
||||
- See `darkness-troubleshooting.md` for the full diagnostic writeup
|
||||
|
||||
**Problem: Ribbon pools/doesn't wind onto uptake spool**
|
||||
- Solution: Make sure you ran `^MNN,Y` (uptake enabled)
|
||||
- Check DIY uptake spool is properly engaged with drive gear
|
||||
- Factory reset and reconfigure if needed
|
||||
|
||||
**Problem: Cutter doesn't activate**
|
||||
- Solution: Verify cutter is enabled: `echo "^XA^MMC,Y^JUS^XZ" | lp -d nylon -o raw`
|
||||
- Check if cutter module is physically installed
|
||||
- Try immediate cut command: `echo "^XA^CN5^XZ" | lp -d nylon -o raw`
|
||||
|
||||
**Problem: Prints are fuzzy or gray**
|
||||
- Solution: Increase darkness with `~SD` (not `^MD`)
|
||||
- Nylon may require different heat than paper
|
||||
- Check print head cleanliness (wipe with isopropyl alcohol)
|
||||
|
||||
**Problem: Resin sticks to nylon when handling**
|
||||
- Solution: Decrease darkness (`~SD`)
|
||||
- Slow down print speed: `echo "^XA^PR3^JUS^XZ" | lp -d nylon -o raw`
|
||||
|
||||
**Problem: Tags are wrong length**
|
||||
- Solution: Check your ZPL includes correct `^LL` value
|
||||
- Remember: dots = inches × 300
|
||||
- 1" = 300 dots, 2" = 600 dots, etc.
|
||||
|
||||
**Problem: ZPL commands print as literal text**
|
||||
- Solution: Printer isn't configured as raw queue
|
||||
- Reconfigure: `sudo lpadmin -p nylon -m raw`
|
||||
|
||||
### Complete Configuration Script
|
||||
|
||||
Save this as a shell script for easy re-setup. Every command here uses idempotent/absolute forms (`^MT`, `~SD`) so it's safe to re-run without corrupting saved state:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
PRINTER_NAME="nylon"
|
||||
|
||||
echo "Factory resetting printer..."
|
||||
echo "^XA^JUF^XZ" | lp -d $PRINTER_NAME -o raw
|
||||
sleep 5
|
||||
|
||||
echo "Setting thermal transfer print method..."
|
||||
echo "^XA^MTT^JUS^XZ" | lp -d $PRINTER_NAME -o raw
|
||||
sleep 2
|
||||
|
||||
echo "Configuring continuous media tracking with uptake..."
|
||||
echo "^XA^MNN,Y^JUS^XZ" | lp -d $PRINTER_NAME -o raw
|
||||
sleep 2
|
||||
|
||||
echo "Setting media width (1.5\")..."
|
||||
echo "^XA^PW450^JUS^XZ" | lp -d $PRINTER_NAME -o raw
|
||||
sleep 2
|
||||
|
||||
echo "Enabling cutter..."
|
||||
echo "^XA^MMC,Y^JUS^XZ" | lp -d $PRINTER_NAME -o raw
|
||||
sleep 2
|
||||
|
||||
echo "Setting darkness to 28 (adjust as needed)..."
|
||||
echo "^XA~SD28^JUS^XZ" | lp -d $PRINTER_NAME -o raw
|
||||
sleep 2
|
||||
|
||||
echo "Configuration complete."
|
||||
echo "Test with: echo \"^XA^MTT~SD28^PW450^LL150^MNN^FO40,40^ADN,36,20^FDTest^FS^XZ\" | lp -d $PRINTER_NAME -o raw"
|
||||
```
|
||||
|
||||
### Quick Reference: Continuous Media vs Gap Media
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
| Feature | Gap Media (Sticker Printer) | Continuous Media (Nylon Printer) |
|
||||
|---------|----------------------------|----------------------------------|
|
||||
| Media Type | Pre-cut labels with gaps | Continuous roll |
|
||||
| Calibration | Required (2-blink method) | Not needed |
|
||||
| Label Length | Fixed per media type | Variable per print |
|
||||
| `^LL` Command | Same for all prints | Must specify each time |
|
||||
| Sensor | Gap/notch detection | None (cuts at specified length) |
|
||||
| Cutter | Optional | Required |
|
||||
|
||||
### Calculating Tag Length in ZPL
|
||||
|
||||
For 300dpi printer:
|
||||
- 1 inch = 300 dots
|
||||
- 2 inches = 600 dots
|
||||
- 0.5 inches = 150 dots
|
||||
|
||||
Formula: `dots = inches × 300`
|
||||
|
||||
Example for 2.5" tall tag:
|
||||
```bash
|
||||
echo "^XA^MTT~SD28^PW450^LL750^MNN^FO50,50^ADN,36,20^FDContent^FS^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
### Common ZPL Commands for Continuous Media
|
||||
|
||||
- `^XA` / `^XZ` - Start/end ZPL command block
|
||||
- `^MT` - **Print method**: `T` = thermal transfer, `D` = direct thermal
|
||||
- `^MN` - **Media tracking**: `N` = continuous, `Y` = non-continuous (gap/web sensing), `M` = non-continuous (mark sensing) — not the same as print method
|
||||
- `^PW###` - Print width in dots (450 for 1.5")
|
||||
- `^LL###` - Label length in dots (REQUIRED for each print)
|
||||
- `~SD##` - Darkness, absolute (0-30) — USE THIS
|
||||
- `^MD##` - Darkness, relative (-30 to +30) — AVOID, stacks across repeated commands
|
||||
- `^MMC,Y` - Enable cutter
|
||||
- `^CN##` - Cut now, advance ## dots
|
||||
- `^FO###,###` - Field origin (X,Y position in dots)
|
||||
- `^ADN,H,W` - Font: N=default, H=height, W=width
|
||||
- `^FD` - Field data (the actual text/content)
|
||||
- `^FS` - Field separator (end of field)
|
||||
- `^GB` - Graphic box
|
||||
- `^JUS` - Save configuration
|
||||
- `^JUF` - Factory reset
|
||||
|
||||
### Testing Different Tag Lengths
|
||||
|
||||
```bash
|
||||
# 1" tall tag
|
||||
echo "^XA^MTT~SD28^PW450^LL300^MNN^FO50,50^ADN,36,20^FD1 inch^FS^XZ" | lp -d nylon -o raw
|
||||
|
||||
# 2" tall tag
|
||||
echo "^XA^MTT~SD28^PW450^LL600^MNN^FO50,50^ADN,36,20^FD2 inches^FS^XZ" | lp -d nylon -o raw
|
||||
|
||||
# 3" tall tag
|
||||
echo "^XA^MTT~SD28^PW450^LL900^MNN^FO50,50^ADN,36,20^FD3 inches^FS^XZ" | lp -d nylon -o raw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Original guide (pre-2026-07-30 correction, kept for reference)
|
||||
|
||||
This is what Steps 3 and 6 (renumbered above) used to say, before we discovered `^MN` doesn't control print method and that `^MD` was stacking. Kept here in case anything above is confusing and you want to see what changed.
|
||||
|
||||
> ### Step 3 (old): Configure Thermal Transfer Mode
|
||||
>
|
||||
> **CRITICAL:** Enable ribbon uptake, otherwise used ribbon will pool inside printer.
|
||||
>
|
||||
> ```bash
|
||||
> # Set thermal transfer mode with uptake enabled
|
||||
> echo "^XA^MNN,Y^JUS^XZ" | lp -d nylon
|
||||
>
|
||||
> # Verify settings saved
|
||||
> echo "^XA^JUA^XZ" | lp -d nylon
|
||||
> ```
|
||||
>
|
||||
> The `^MNN,Y` command:
|
||||
> - First `N` = Thermal transfer (not direct thermal) *(WRONG — this is media tracking, not print method; see corrected Step 3/4 above)*
|
||||
> - Second `N` = Continuous media handling (no gap detection needed)
|
||||
> - `Y` = Enable ribbon uptake motor
|
||||
>
|
||||
> ### Step 6 (old): Configure Darkness
|
||||
>
|
||||
> Full resin on nylon typically needs similar darkness to labels. Start with MD18:
|
||||
>
|
||||
> ```bash
|
||||
> echo "^XA^MD18^JUS^XZ" | lp -d nylon
|
||||
> ```
|
||||
>
|
||||
> Test and adjust:
|
||||
> - Too light/gray? Increase to MD20 or MD22
|
||||
> - Resin sticking to nylon? Decrease to MD15
|
||||
>
|
||||
> **Note:** Nylon may require different darkness than paper labels. Test thoroughly.
|
||||
>
|
||||
> *(WRONG-ish — `^MD` is a relative offset, not absolute. Running this more than once stacks the offset and silently drifts darkness, eventually producing blank labels. Use `~SD` instead, see corrected Step 7 above.)*
|
||||
@@ -0,0 +1,256 @@
|
||||
# Sticker Printer Setup Guide
|
||||
## Zebra GX430t - Thermal Transfer - 2.25" x 1.25" Tear-Off Labels
|
||||
|
||||
### Hardware Requirements
|
||||
- Zebra GX430t printer (300dpi)
|
||||
- 2.25" x 1.25" tear-off labels (gap-separated)
|
||||
- Full resin thermal transfer ribbon
|
||||
- Uptake spool (or DIY replacement)
|
||||
- USB connection to Debian machine
|
||||
|
||||
### Step 1: Factory Reset Printer
|
||||
|
||||
Before starting, wipe any previous owner settings:
|
||||
|
||||
```bash
|
||||
echo "^XA^JUF^XZ" | lp -d YOUR_PRINTER_NAME
|
||||
```
|
||||
|
||||
Wait 5-10 seconds for the printer to reset.
|
||||
|
||||
### Step 2: Add Printer to CUPS
|
||||
|
||||
1. Install CUPS if not already installed:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install cups cups-client
|
||||
sudo usermod -aG lpadmin $USER
|
||||
```
|
||||
Log out and back in after adding to lpadmin group.
|
||||
|
||||
2. Connect printer via USB and verify detection:
|
||||
```bash
|
||||
lsusb | grep -i zebra
|
||||
```
|
||||
|
||||
3. Access CUPS web interface at `http://localhost:631`
|
||||
|
||||
4. Go to **Administration → Add Printer**
|
||||
|
||||
5. Select the Zebra printer from USB devices
|
||||
|
||||
6. Name it something memorable (e.g., `sticker`)
|
||||
|
||||
7. **CRITICAL:** For the driver/model, select **"Raw Queue"**. Do NOT use the Zebra CUPS driver — it will intercept ZPL commands instead of passing them through, causing every `^XA...^XZ` command to print a blank label instead of configuring the printer. Use command line:
|
||||
```bash
|
||||
sudo lpadmin -p sticker -E -v usb://Zebra%20Technologies/ZTC%20GX430t?serial=YOUR_SERIAL -m raw
|
||||
```
|
||||
If the printer was previously set up with a driver, fix it with:
|
||||
```bash
|
||||
sudo lpadmin -p sticker -m raw
|
||||
```
|
||||
|
||||
8. Restart CUPS:
|
||||
```bash
|
||||
sudo systemctl restart cups
|
||||
```
|
||||
|
||||
9. Verify printer exists:
|
||||
```bash
|
||||
lpstat -p
|
||||
```
|
||||
|
||||
### Step 3: Configure Thermal Transfer Mode
|
||||
|
||||
**CRITICAL:** Enable ribbon uptake, otherwise used ribbon will pool inside printer.
|
||||
|
||||
```bash
|
||||
# Set thermal transfer mode with uptake enabled
|
||||
echo "^XA^MNY,Y^JUS^XZ" | lp -d sticker
|
||||
|
||||
# Verify settings saved
|
||||
echo "^XA^JUA^XZ" | lp -d sticker
|
||||
```
|
||||
|
||||
The `^MNY,Y` command:
|
||||
- First `N` = Thermal transfer (not direct thermal)
|
||||
- `Y` = Non-continuous media, web/gap sensing (detects gaps between labels)
|
||||
- `,Y` = Enable ribbon uptake motor
|
||||
|
||||
### Step 4: Set Label Dimensions
|
||||
|
||||
For 2.25" wide x 1.25" tall labels at 300dpi:
|
||||
|
||||
```bash
|
||||
# Width: 2.25" × 300dpi = 675 dots
|
||||
# Height: 1.25" × 300dpi = 375 dots
|
||||
echo "^XA^PW675^LL375^JUS^XZ" | lp -d sticker
|
||||
```
|
||||
|
||||
### Step 5: Configure Darkness
|
||||
|
||||
**Use `~SD` (absolute) instead of `^MD` (relative) to avoid stacking issues.**
|
||||
|
||||
Full resin ribbon heat requirements vary. Start LOW and work up:
|
||||
|
||||
```bash
|
||||
# Start with low darkness
|
||||
echo "^XA~SD5^JUS^XZ" | lp -d sticker
|
||||
```
|
||||
|
||||
Test print and adjust:
|
||||
- If too light/gray: increase (`~SD8`, `~SD10`, etc.)
|
||||
- If resin sticks or ribbon tears: decrease
|
||||
- **If checkerboard prints darker than solid black: DECREASE darkness (counterintuitive!)**
|
||||
|
||||
See `darkness-troubleshooting.md` for detailed guidance.
|
||||
|
||||
**WARNING:** Avoid `^MD` commands - they stack and can corrupt saved settings. Stick to `~SD` for absolute control.
|
||||
|
||||
### Step 6: Physical Calibration
|
||||
|
||||
The printer needs to detect gaps between labels:
|
||||
|
||||
1. **Turn printer OFF**
|
||||
2. **Hold down the feed button**
|
||||
3. **Turn printer ON while still holding button**
|
||||
4. **Keep holding until printer blinks twice** (usually ~5 seconds)
|
||||
5. **Release button**
|
||||
|
||||
The printer will feed several labels as it calibrates the gap sensor.
|
||||
|
||||
### Step 7: Disable Head-Close Auto-Feed
|
||||
|
||||
By default, the printer feeds a label every time the printhead is opened and closed. Disable this to avoid wasting labels:
|
||||
|
||||
```bash
|
||||
# Disable auto-feed on both power-up and head close
|
||||
echo "^XA^MFN,N^JUS^XZ" | lp -d sticker
|
||||
```
|
||||
|
||||
The `^MFN,N` command:
|
||||
- `^MF` = Media Feed action control
|
||||
- First `N` = Power Up action: No Motion
|
||||
- Second `N` = Head Close action: No Motion
|
||||
- `^JUS` = Save to EEPROM (persists across power cycles)
|
||||
|
||||
Other `^MF` options: `F` = feed to first web, `C` = calibrate, `L` = feed to label length.
|
||||
|
||||
### Step 8: Test Print
|
||||
|
||||
Simple text test:
|
||||
|
||||
```bash
|
||||
echo "^XA^PW675^LL375~SD10^FO50,50^ADN,36,20^FDTest Print^FS^XZ" | lp -d sticker
|
||||
```
|
||||
|
||||
Border alignment test:
|
||||
|
||||
```bash
|
||||
echo "^XA^PW675^LL375~SD10^FO10,10^GB655,355,5^FS^XZ" | lp -d sticker
|
||||
```
|
||||
|
||||
This prints a 5-dot border around the label edge. If misaligned, adjust with:
|
||||
|
||||
```bash
|
||||
# Shift print area right by N dots
|
||||
echo "^XA^LS20^JUS^XZ" | lp -d sticker
|
||||
```
|
||||
|
||||
### Step 9: Print from ZPL File
|
||||
|
||||
```bash
|
||||
lp -d sticker your_file.zpl
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
See `darkness-troubleshooting.md` and `test-*.zpl` files in this directory for detailed guides and test prints.
|
||||
|
||||
**Problem: Printer feeds labels when printhead is opened/closed**
|
||||
- Solution: Disable head-close auto-feed: `echo "^XA^MFN,N^JUS^XZ" | lp -d sticker`
|
||||
- This saves to EEPROM so it persists across power cycles
|
||||
|
||||
**Problem: Ribbon pools/doesn't wind onto uptake spool**
|
||||
- Solution: Make sure you ran `^MNY,Y` (uptake enabled)
|
||||
- Factory reset and reconfigure if needed
|
||||
|
||||
**Problem: Checkerboard/dithered areas darker than solid black**
|
||||
- Solution: DECREASE darkness (too much heat!)
|
||||
- See `darkness-troubleshooting.md`
|
||||
|
||||
**Problem: Prints are fuzzy or gray**
|
||||
- Solution: Increase darkness with `~SD` command (not `^MD`)
|
||||
- Check print head cleanliness (wipe with isopropyl alcohol)
|
||||
|
||||
**Problem: Printing across multiple labels**
|
||||
- Solution: Re-run physical calibration (2-blink method)
|
||||
- Verify label length matches actual label+gap size
|
||||
|
||||
**Problem: Resin sticks to label or ribbon tears**
|
||||
- Solution: Decrease darkness significantly
|
||||
- Ensure print speed isn't too slow: `echo "^XA^PR4^JUS^XZ" | lp -d sticker`
|
||||
|
||||
**Problem: ZPL file doesn't print (but simple commands work)**
|
||||
- Solution: Use `-o raw` flag: `lp -d sticker -o raw file.zpl`
|
||||
|
||||
**Problem: ZPL commands print as literal text**
|
||||
- Solution: Printer isn't configured as raw queue
|
||||
- Reconfigure: `sudo lpadmin -p sticker -m raw`
|
||||
|
||||
**Problem: ZPL config commands (^MF, ~SD, etc.) print blank labels instead of changing settings**
|
||||
- Cause: Printer is using the Zebra CUPS driver instead of a raw queue. The driver interprets each `^XA...^XZ` block as a print job rather than passing ZPL commands through to the printer.
|
||||
- Symptoms: Every command you send prints a blank label; config label (hold feed button 3s) is also blank; `lpoptions -p sticker -l` shows Zebra-specific options like `zeMediaTracking`, `zePrintMode`, etc.
|
||||
- Solution: Reconfigure as raw queue: `sudo lpadmin -p sticker -m raw`
|
||||
|
||||
**Problem: Blank labels after adjusting darkness**
|
||||
- Solution: May have corrupted ^MD offset - see darkness troubleshooting guide
|
||||
|
||||
### Complete Configuration Script
|
||||
|
||||
Save this as a shell script for easy re-setup:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
PRINTER_NAME="sticker"
|
||||
|
||||
echo "Factory resetting printer..."
|
||||
echo "^XA^JUF^XZ" | lp -d $PRINTER_NAME
|
||||
sleep 5
|
||||
|
||||
echo "Configuring thermal transfer with uptake..."
|
||||
echo "^XA^MNY,Y^JUS^XZ" | lp -d $PRINTER_NAME
|
||||
sleep 2
|
||||
|
||||
echo "Setting label dimensions (2.25\" x 1.25\")..."
|
||||
echo "^XA^PW675^LL375^JUS^XZ" | lp -d $PRINTER_NAME
|
||||
sleep 2
|
||||
|
||||
echo "Setting darkness to 10 (adjust as needed)..."
|
||||
echo "^XA~SD10^JUS^XZ" | lp -d $PRINTER_NAME
|
||||
sleep 2
|
||||
|
||||
echo "Disabling head-close auto-feed..."
|
||||
echo "^XA^MFN,N^JUS^XZ" | lp -d $PRINTER_NAME
|
||||
sleep 2
|
||||
|
||||
echo "Configuration complete. Run physical calibration (2-blink method) now."
|
||||
echo "Test with: echo \"^XA^PW675^LL375~SD10^FO50,50^ADN,36,20^FDTest^FS^XZ\" | lp -d $PRINTER_NAME"
|
||||
```
|
||||
|
||||
### Quick Reference: Common ZPL Commands
|
||||
|
||||
- `^XA` / `^XZ` - Start/end ZPL command block
|
||||
- `^PW###` - Print width in dots
|
||||
- `^LL###` - Label length in dots
|
||||
- `~SD##` - Darkness absolute (0-30) - USE THIS
|
||||
- `^MD##` - Darkness relative (-30 to +30) - AVOID (stacks!)
|
||||
- `^FO###,###` - Field origin (X,Y position in dots)
|
||||
- `^ADN,H,W` - Font: N=default, H=height, W=width
|
||||
- `^FD` - Field data (the actual text/content)
|
||||
- `^FS` - Field separator (end of field)
|
||||
- `^GB` - Graphic box
|
||||
- `^MFN,N` - Disable auto-feed on power up and head close
|
||||
- `^JUS` - Save configuration
|
||||
- `^JUF` - Factory reset
|
||||
- `~JC` - Auto-calibration (doesn't always work)
|
||||
@@ -0,0 +1,7 @@
|
||||
^XA
|
||||
^FO0,0^GB50,50,50^FS
|
||||
^FO625,0^GB50,50,50^FS
|
||||
^FO0,325^GB50,50,50^FS
|
||||
^FO625,325^GB50,50,50^FS
|
||||
^FO280,160^A0N,40,40^FDCENTER^FS
|
||||
^XZ
|
||||
@@ -0,0 +1,12 @@
|
||||
^XA
|
||||
^FO30,20^A0N,30,30^FDSolid Black:^FS
|
||||
^FO30,55^GB120,60,60^FS
|
||||
^FO200,20^A0N,30,30^FDCheckerboard:^FS
|
||||
^FO200,55^GFA,480,480,8,AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555AAAA5555^FS
|
||||
^FO400,20^A0N,30,30^FD50% Gray:^FS
|
||||
^FO400,55^GFA,480,480,8,F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0^FS
|
||||
^FO30,140^A0N,40,40^FDTest Text Here^FS
|
||||
^FO30,200^A0N,24,24^FDIf checkerboard is darker than solid = too hot^FS
|
||||
^FO30,230^A0N,24,24^FDIf everything is gray = too cold^FS
|
||||
^FO30,260^A0N,24,24^FDIdeal: solid=black, checker=medium gray^FS
|
||||
^XZ
|
||||
@@ -0,0 +1,3 @@
|
||||
^XA
|
||||
^FO50,50^A0N,50,50^FDTest^FS
|
||||
^XZ
|
||||
@@ -0,0 +1,5 @@
|
||||
Pillow
|
||||
opencv-python
|
||||
numpy
|
||||
qrcode
|
||||
pyzbar
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Run the Custodisco Kiosk
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Virtual environment not found. Run ./install.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source venv/bin/activate
|
||||
|
||||
if [ "$1" = "--list-cameras" ]; then
|
||||
python list_cameras.py
|
||||
else
|
||||
python kiosk.py
|
||||
fi
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,39 +0,0 @@
|
||||
// scuttlebot.js
|
||||
|
||||
const ssbClient = require('ssb-client');
|
||||
const ssbKeys = require('ssb-keys');
|
||||
const pull = require('pull-stream');
|
||||
|
||||
const keys = ssbKeys.loadOrCreateSync('~/.ssb/secret');
|
||||
|
||||
ssbClient(keys, (err, sbot) => {
|
||||
if (err) {
|
||||
console.error(JSON.stringify({ "error": "Failed to connect to the Scuttlebot server. Is it running?" }));
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const authors = new Set();
|
||||
|
||||
pull(
|
||||
sbot.messagesByType("contact"),
|
||||
pull.drain((msg) => {
|
||||
authors.add(msg.value.author);
|
||||
}, (err) => {
|
||||
if (err) {
|
||||
console.error(JSON.stringify({ "error": "Failed to retrieve messages." }));
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const feeds = Array.from(authors).map(author => { return { id: author } });
|
||||
console.log(JSON.stringify(feeds));
|
||||
try {
|
||||
sbot.close(() => {});
|
||||
} catch(err) {
|
||||
console.error("Error closing SSB server connection: ", err);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,22 +26,33 @@ then
|
||||
#add blob
|
||||
blobID=$(cat $itemImagePath | ssb-server blobs.add) || exit 1
|
||||
|
||||
# publish the item
|
||||
ssb-server publish . <<BLAAB
|
||||
{
|
||||
"type":"post",
|
||||
"text":"\\n\\n$itemDescription\\n\\n a #custodisco item ",
|
||||
"custodisco":"true",
|
||||
"nft": "mint",
|
||||
"mentions": [
|
||||
{
|
||||
"name": "photo.jpg",
|
||||
"type": "image/jpeg",
|
||||
"link": "$blobID"
|
||||
}
|
||||
]
|
||||
}
|
||||
BLAAB
|
||||
# Build the full text and escape for JSON using jq
|
||||
fullText="
|
||||
|
||||
$itemDescription
|
||||
|
||||
a #custodisco item "
|
||||
|
||||
# publish the item using jq to safely build JSON
|
||||
jq -n \
|
||||
--arg text "$fullText" \
|
||||
--arg blobID "$blobID" \
|
||||
'{
|
||||
type: "post",
|
||||
text: $text,
|
||||
custodisco: "true",
|
||||
nft: "mint",
|
||||
mentions: [
|
||||
{
|
||||
name: "photo.jpg",
|
||||
type: "image/jpeg",
|
||||
link: $blobID
|
||||
},
|
||||
{
|
||||
link: "#custodisco"
|
||||
}
|
||||
]
|
||||
}' | ssb-server publish .
|
||||
fi
|
||||
|
||||
|
||||
@@ -51,17 +62,24 @@ then
|
||||
account=$2
|
||||
messageID=$3
|
||||
|
||||
ssb-server publish . <<BLAAB
|
||||
{
|
||||
"type":"post",
|
||||
"text":"This item is stewarded by $account",
|
||||
"custodisco":"true",
|
||||
"nft": "give",
|
||||
"target": "$account",
|
||||
"root": "$messageID",
|
||||
"branch": "$messageID"
|
||||
}
|
||||
BLAAB
|
||||
# Use jq to safely build JSON with proper escaping
|
||||
jq -n \
|
||||
--arg account "$account" \
|
||||
--arg messageID "$messageID" \
|
||||
'{
|
||||
type: "post",
|
||||
text: ("This item is stewarded by " + $account + "\n\n a #custodisco item "),
|
||||
custodisco: "true",
|
||||
nft: "give",
|
||||
target: $account,
|
||||
root: $messageID,
|
||||
branch: $messageID,
|
||||
mentions: [
|
||||
{
|
||||
link: "#custodisco"
|
||||
}
|
||||
]
|
||||
}' | ssb-server publish .
|
||||
fi
|
||||
|
||||
|
||||