Author SHA1 Message Date
trav cbedde6f6b a few fixes 2026-07-30 19:28:01 -07:00
trav f39be6b24b oopsie 2026-06-18 15:04:28 -04:00
trav a48f7a467b fixed package-lock in gitignore 2026-06-18 15:00:55 -04:00
trav 22a290e0ed who knows what 2026-06-01 21:49:49 -04:00
trav 5ca6254ec3 Merge branch 'feature/scan-tag-ribbon' of ssh://git.coopcloud.tech:2222/trav/custodisco-kiosk into feature/scan-tag-ribbon
lol I really don't understand git
2026-02-27 11:41:41 -05:00
trav 40b80dc196 minor text improvements 2026-02-27 11:41:03 -05:00
trav 62f4d267a9 updated video 2026-02-27 00:17:56 -05:00
trav 28bd9bfa13 some fixes 2026-02-26 17:48:15 -05:00
trav b9ac998fb4 checkpoint 2026-02-24 19:29:15 -05:00
trav 77fd097b4a fair bit o changes! Images moved around, new participation trophy, etc 2026-02-23 21:36:36 -05:00
trav 48979b52f6 new fixes, yay 2026-01-29 15:20:01 -08:00
trav cafd4b6084 printer troubleshooting etc 2026-01-28 16:42:11 -08:00
travandClaude Opus 4.5 e1f9417684 Remove __pycache__ files and fix .gitignore
- Update .gitignore to properly ignore __pycache__ in all subdirectories
- Add *.pyc and *.pyo patterns
- Remove accidentally committed pycache files from tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:59:18 -08:00
trav d33717825d it is now split into files and there are many things 2026-01-27 21:57:08 -08:00
trav d4dbd79cc2 backup before major refactor... 2026-01-27 14:24:48 -08:00
trav 9c7adf985c whole buncha stuff 2026-01-26 22:30:26 -08:00
trav cbcef9888b in a good spot 2026-01-23 12:17:24 -08:00
travandClaude Opus 4.5 d78f84d8ff Add scan-tag.png between drawing and QR code on ribbon prints
Inserts the scan-tag image centered horizontally between the user drawing
and QR code in ribbon print output. Total height increases by scan-tag height.

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

Before

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File
Executable
+119
View File
@@ -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
+123
View File
@@ -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
Executable
+23
View File
@@ -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."
+3 -1579
View File
File diff suppressed because it is too large Load Diff
+174 -1
View File
@@ -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)
+6
View File
@@ -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']
+93
View File
@@ -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)
+23
View File
@@ -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")
+28
View File
@@ -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',
]
+179
View File
@@ -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()
+235
View File
@@ -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()
+131
View File
@@ -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)
+150
View File
@@ -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)
+36
View File
@@ -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)
+274
View File
@@ -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)
+118
View File
@@ -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()
+235
View File
@@ -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)
+139
View File
@@ -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)
+431
View File
@@ -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)
+363
View File
@@ -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())
+29
View File
@@ -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)
+262
View File
@@ -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)
+328
View File
@@ -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')
+14
View File
@@ -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
+146
View File
@@ -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
+6
View File
@@ -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']
+146
View File
@@ -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
+64
View File
@@ -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)
+44
View File
@@ -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())
-961
View File
@@ -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()
+112
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+1065
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
^XA
^PW675
^LL375
^LH15,15
^LT0
^LS0
^JUS
^XZ
+129
View File
@@ -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
+334
View File
@@ -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.)*
+256
View File
@@ -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)
+7
View File
@@ -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
+12
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
^XA
^FO50,50^A0N,50,50^FDTest^FS
^XZ
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+5
View File
@@ -0,0 +1,5 @@
Pillow
opencv-python
numpy
qrcode
pyzbar
Executable
+18
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# Udev rule to allow video group write access to backlight brightness
# This enables the screensaver to turn off the display without root privileges
# Install to: /etc/udev/rules.d/90-backlight.rules
# Then reload: sudo udevadm control --reload-rules && sudo udevadm trigger
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chgrp video /sys/class/backlight/%k/brightness"
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chmod g+w /sys/class/backlight/%k/brightness"
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Fullscreen text display for screensaver with animations and screen blanking"""
import gi
gi.require_version('Gtk', '4.0')
from gi.repository import Gtk, Gdk, GLib
import math
import sys
import argparse
import os
TEXT = "custo"
# Backlight control paths
BACKLIGHT_PATH = "/sys/class/backlight/intel_backlight/brightness"
MAX_BRIGHTNESS_PATH = "/sys/class/backlight/intel_backlight/max_brightness"
def get_max_brightness():
"""Read the maximum brightness value"""
try:
with open(MAX_BRIGHTNESS_PATH) as f:
return f.read().strip()
except Exception:
return None
def set_brightness(value):
"""Set the backlight brightness"""
try:
with open(BACKLIGHT_PATH, 'w') as f:
f.write(str(value))
return True
except Exception as e:
print(f"custo-display: Could not set brightness: {e}")
return False
class ScreensaverWindow(Gtk.ApplicationWindow):
def __init__(self, blank_after_minutes=20, *args, **kwargs):
super().__init__(*args, **kwargs)
self.blank_after_minutes = blank_after_minutes
self.is_blanked = False
self.original_brightness = None
self.set_css_classes(['screensaver-window'])
# Create label with large white text
self.label = Gtk.Label(label=TEXT)
self.label.set_css_classes(['screensaver-text'])
# Center the label
self.set_child(self.label)
# Animation state
self.time = 0.0
self.elapsed_seconds = 0
# Key press controller - close on any key
key_controller = Gtk.EventControllerKey()
key_controller.connect('key-pressed', self.on_input)
self.add_controller(key_controller)
# Click/touch controller - close on click or tap
click_controller = Gtk.GestureClick()
click_controller.connect('pressed', self.on_input)
self.add_controller(click_controller)
# Motion controller - close on mouse movement
motion_controller = Gtk.EventControllerMotion()
motion_controller.connect('motion', self.on_motion)
self.add_controller(motion_controller)
# Track initial position to ignore tiny movements
self.initial_x = None
self.initial_y = None
self.motion_threshold = 50 # pixels of movement before closing
# Go fullscreen
self.fullscreen()
# Start animation (100ms tick = 10fps, gentle on CPU)
GLib.timeout_add(100, self.animate)
# Store max brightness for restoration
self.original_brightness = get_max_brightness()
print(f"custo-display: Started. Screen will blank after {blank_after_minutes} minutes.")
def on_input(self, *args):
self.restore_and_close()
return True
def on_motion(self, controller, x, y):
if self.initial_x is None:
self.initial_x = x
self.initial_y = y
return
# Only close if mouse moved significantly
dx = abs(x - self.initial_x)
dy = abs(y - self.initial_y)
if dx > self.motion_threshold or dy > self.motion_threshold:
self.restore_and_close()
def restore_and_close(self):
"""Restore brightness and close the window"""
if self.is_blanked and self.original_brightness:
print("custo-display: Restoring brightness")
set_brightness(self.original_brightness)
self.close()
def blank_screen(self):
"""Turn off the backlight"""
if not self.is_blanked:
print("custo-display: Blanking screen (backlight off)")
if set_brightness(0):
self.is_blanked = True
# Hide the label since screen is blank
self.label.set_visible(False)
def animate(self):
self.time += 0.025 # Slower time progression
self.elapsed_seconds += 0.1 # 100ms per tick
# Check if it's time to blank the screen
elapsed_minutes = self.elapsed_seconds / 60
if elapsed_minutes >= self.blank_after_minutes and not self.is_blanked:
self.blank_screen()
# Skip animation updates if screen is blanked
if self.is_blanked:
return True
# Slow fade: oscillates between 0.3 and 1.0 over ~12 seconds
fade = 0.65 + 0.35 * math.sin(self.time * 0.25)
# Wavy vertical offset using sine (increased amplitude for more visible movement)
wave_offset = 60 * math.sin(self.time * 0.4)
# Apply opacity and transform
self.label.set_opacity(fade)
self.label.set_margin_top(int(100 + wave_offset))
self.label.set_margin_bottom(int(100 - wave_offset))
return True # Continue animation
class ScreensaverApp(Gtk.Application):
def __init__(self, blank_after_minutes=20):
super().__init__(application_id='com.custo.screensaver')
self.blank_after_minutes = blank_after_minutes
def do_activate(self):
# Load CSS
css_provider = Gtk.CssProvider()
css_provider.load_from_string('''
.screensaver-window {
background-color: black;
}
.screensaver-text {
color: white;
font-size: 120px;
font-weight: bold;
}
''')
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
win = ScreensaverWindow(
blank_after_minutes=self.blank_after_minutes,
application=self
)
win.present()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Custo screensaver display')
parser.add_argument('--blank-after', type=int, default=20,
help='Minutes until screen blanks (default: 20)')
args = parser.parse_args()
app = ScreensaverApp(blank_after_minutes=args.blank_after)
app.run(None)
+7
View File
@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Custo Screensaver
Exec=/home/trav/.local/bin/custo-screensaver.sh
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# custo-screensaver.sh: Display "custo" after idle timeout on GNOME/Wayland
# Two-stage behavior:
# Stage 1: After start_after minutes, show "custo" animation
# Stage 2: After blank_after more minutes, tell display to blank screen
start_after=10 # minutes until screensaver appears
blank_after=20 # additional minutes until screen blanks (total: 30 min)
# Kill any existing instances of this script (prevent duplicates)
for pid in $(pgrep -f 'custo-screensaver.sh'); do
if [ "$pid" != "$$" ]; then
echo "custo-screensaver: Killing existing instance (PID $pid)"
kill "$pid" 2>/dev/null
fi
done
# Also kill any orphaned display processes
pkill -f 'custo-display.py' 2>/dev/null
cmd="python3 $HOME/.local/bin/custo-display.py"
get_idle_minutes() {
idle_time=$(dbus-send --print-reply --dest=org.gnome.Mutter.IdleMonitor \
/org/gnome/Mutter/IdleMonitor/Core org.gnome.Mutter.IdleMonitor.GetIdletime 2>/dev/null | grep -Po '(?<=uint64\s)\d+')
if [ -n "$idle_time" ]; then
echo "$idle_time / 60000" | bc
else
echo "0"
fi
}
get_inhibitors() {
for i in $(dbus-send --print-reply --dest=org.gnome.SessionManager \
/org/gnome/SessionManager org.gnome.SessionManager.GetInhibitors 2>/dev/null | grep -Po 'Inhibitor\d+'); do
if dbus-send --print-reply --dest=org.gnome.SessionManager \
/org/gnome/SessionManager/"$i" org.gnome.SessionManager.Inhibitor.GetFlags 2>/dev/null | grep -q 'uint32\s8'; then
true; return
fi
done
false; return
}
echo "custo-screensaver: Started (PID $$). Will activate after $start_after minutes of idle."
echo "custo-screensaver: Screen will blank after $blank_after additional minutes."
screensaver_pid=""
screensaver_start_time=""
while :; do
idle_mins=$(get_idle_minutes)
if [ "$idle_mins" -ge $start_after ] && [ -z "$screensaver_pid" ]; then
if ! get_inhibitors; then
echo "custo-screensaver: Activating (idle: ${idle_mins}m)"
$cmd --blank-after=$blank_after &
screensaver_pid=$!
screensaver_start_time=$(date +%s)
fi
elif [ "$idle_mins" -lt $start_after ] && [ -n "$screensaver_pid" ]; then
echo "custo-screensaver: Deactivating (user activity detected)"
pkill -P $screensaver_pid 2>/dev/null
kill $screensaver_pid 2>/dev/null
screensaver_pid=""
screensaver_start_time=""
fi
sleep 1
done
-39
View File
@@ -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);
}
})
);
});
+45 -27
View File
@@ -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":"![photo.jpg]($blobID)\\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="![photo.jpg]($blobID)
$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