Compare commits

...

10 Commits

Author SHA1 Message Date
Dejvino
4575584b9d Division cipher: make the cipher configurable 2025-12-23 23:54:24 +01:00
Dejvino
510ea220cc New job: Word search (osmismerka) 2025-12-23 23:17:27 +01:00
Dejvino
0754f82ab6 New job: multi target maze + flush of printer queue 2025-12-23 22:49:21 +01:00
Dejvino
b9715a8032 New job: Random joke from remote source 2025-12-23 16:32:16 +01:00
Dejvino
61ec23b3a4 New job: decimal division 2025-12-23 14:40:57 +01:00
Dejvino
4c5e89aff7 New job: divison cipher 2025-12-23 14:16:53 +01:00
Dejvino
4d1399ec9b Chess fix 2025-12-23 14:03:21 +01:00
Dejvino
085f5dab57 Chess: randomly generated situations using Chess library 2025-12-23 09:43:49 +01:00
Dejvino
05f06c8bf9 More docs 2025-12-23 09:09:26 +01:00
Dejvino
4ee3d12933 Configurable jobs. Maze has difficulties and fixed S and E symbols. 2025-12-23 01:10:09 +01:00
13 changed files with 792 additions and 38 deletions

View File

@ -8,6 +8,19 @@ Simple homework printer using a thermal printer. Practice makes perfect!
pip install python-escpos[all] --user
```
### Chess module
```
pip install chess
```
## Config
Consult python-escpos docs:
- [Usage](https://python-escpos.readthedocs.io/en/latest/user/usage.html)
- [Available profiles](https://python-escpos.readthedocs.io/en/latest/printer_profiles/available-profiles.html)
## Run
```shell

View File

@ -4,6 +4,9 @@ class Job:
def get_name(self):
raise NotImplementedError
def configure(self):
pass
def print_body(self, p):
raise NotImplementedError

View File

@ -1,4 +1,8 @@
import random
try:
import chess
except ImportError:
chess = None
from .base import Job
class ChessPuzzleJob(Job):
@ -6,32 +10,12 @@ class ChessPuzzleJob(Job):
return "SACHOVE ULOHY"
def print_body(self, p):
# (FEN, Instruction, Solution)
puzzles = [
("r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4", "Bily na tahu. Mat 1. tahem.", "Qxf7# (Scholar's Mate)"),
("rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR b KQkq - 1 3", "Cerny na tahu. Mat 1. tahem.", "Qh4# (Fool's Mate)"),
("6k1/5ppp/8/8/8/8/5PPP/4R1K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Re8# (Back Rank Mate)"),
("3r2k1/p4ppp/1p6/8/8/8/P4PPP/3R2K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Rxd8#"),
("6rk/5p1p/3N4/8/8/8/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Nf7# (Smothered Mate)"),
("8/4N1pk/8/7R/8/8/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Rh5# (Anastasia's Mate)"),
("7k/R7/5N2/8/8/8/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Rh7# (Arabian Mate)"),
("2kr4/1pp5/B7/8/8/8/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Ba6# (Boden's Mate)"),
("7k/6pp/6P1/7Q/8/8/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Qh7#"),
("6k1/5p1p/5PpQ/8/8/8/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Qg7# (Lolli's Mate)"),
("3k4/5ppp/8/6B1/8/8/5PPP/3R2K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Rd8# (Opera Mate)"),
("6k1/5P2/8/8/8/8/5PPP/6KR w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Rh8# (Anderssen's Mate)"),
("7k/5ppp/8/7Q/8/2B5/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Qxh7# (Damiano's Bishop Mate)"),
("4k3/8/4K3/8/8/8/8/5Q2 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Qf7# (Dovetail Mate)"),
("2rkr3/8/2Q5/8/8/8/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Qd7# (Swallow's Tail Mate)"),
("3rkr2/8/4Q3/8/8/8/5PPP/6K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Qe6# (Epaulette Mate)"),
("7k/6p1/8/8/2B5/8/5PPP/R5K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Rh1# (Greco's Mate)"),
("6k1/5pP1/5N2/8/8/8/5PPP/3R2K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Rd8# (Hook Mate)"),
("8/8/8/8/8/5K2/6Q1/7k w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Qg2#"),
("k7/1R6/8/8/8/8/8/R5K1 w - - 0 1", "Bily na tahu. Mat 1. tahem.", "Ra8# (Ladder Mate)")
]
if chess is None:
p.text("CHYBA: Neni nainstalovana knihovna 'chess'.\n")
p.text("Spust: pip install chess\n\n")
return
puzzle = random.choice(puzzles)
fen, instruction, solution = puzzle
fen, instruction, solution = self.generate_puzzle()
p.text(f"{instruction}\n\n")
self.print_board(p, fen)
@ -44,6 +28,37 @@ class ChessPuzzleJob(Job):
p.text("Reseni (naskenuj):\n")
p.qr(solution, size=6, native=True)
def generate_puzzle(self):
"""
Simulates a random game until a 'Mate in 1' situation arises.
"""
while True:
board = chess.Board()
# Play up to 120 ply (60 moves)
for _ in range(120):
if board.is_game_over():
break
legal_moves = list(board.legal_moves)
random.shuffle(legal_moves)
# Check if any move leads to immediate checkmate
for move in legal_moves:
board.push(move)
if board.is_checkmate():
# Found a puzzle!
board.pop() # Revert to state before mate
solution = board.san(move)
turn = "Bily" if board.turn == chess.WHITE else "Cerny"
return board.fen(), f"{turn} na tahu. Mat jednim tahem.", solution
board.pop()
# No mate found, play a random move to progress the game
if legal_moves:
board.push(random.choice(legal_moves))
else:
break
def print_board(self, p, fen):
board_str = fen.split(' ')[0]
rows = board_str.split('/')

33
jobs/czech_words.py Normal file
View File

@ -0,0 +1,33 @@
CZECH_WORDS = [
"AUTO", "BABIČKA", "BANÁN", "BARVA", "BÁSNIČKA", "BATOH", "BAVLNA", "BAZÉN",
"BEDNA", "BERAN", "BLÁTO", "BLESK", "BLOUDIT", "BOBŘÍK", "BODLÁK", "BOTY",
"BOUDA", "BOUŘKA", "BRÁNA", "BRATR", "BROUK", "BRUSLE", "BUBEN", "BUNDA",
"CESTA", "CIHLA", "CIRKUS", "CITRON", "CUKR", "ČEPICE", "ČERVENÁ", "ČESNEK",
"ČÍSLO", "ČLOVĚK", "DÁREK", "DATEL", "DCERA", "DĚDEČEK", "DEŠTNÍK", "DIVADLO",
"DOKTOR", "DOMOV", "DOPIS", "DORT", "DRAK", "DUBEN", "DŮM", "DVEŘE",
"DŽBÁN", "FARA", "FIALA", "FILM", "FLÉTNA", "FOTBAL", "GUMA", "HAVRAN",
"HLAVA", "HLÍNA", "HNÍZDO", "HODINY", "HOLKA", "HOLUB", "HORA", "HOUSKA",
"HRAD", "HRÁBĚ", "HRANA", "HRAČKA", "HRNEK", "HRUŠKA", "HUDBA", "CHATA",
"CHLÉB", "CHLAP", "CHYBA", "JABLKO", "JAHODA", "JARO", "JAZYK", "JEHLA",
"JEZERO", "JÍDLO", "JITRO", "KABÁT", "KAČNA", "KAKAO", "KÁMEN", "KAPSÁŘ",
"KARTA", "KÁVA", "KILO", "KLADIVO", "KLÍČ", "KLOBOUK", "KLUK", "KNIHA",
"KOČKA", "KOLO", "KOMÁR", "KONEC", "KOPEC", "KOŘEN", "KOST", "KOŠILE",
"KOZA", "KRÁL", "KRÁVA", "KRESLIT", "KREV", "KRK", "KRUH", "KUCHYNĚ",
"KVĚTINA", "LÁSKA", "LAVICE", "LEDEN", "LES", "LÉTO", "LEV", "LÍSTEK",
"LOĎ", "LOUKA", "LŽÍCE", "MÁMA", "MAPA", "MASO", "MĚSTO", "METR",
"MLÉKO", "MLÝN", "MODRÁ", "MOŘE", "MOST", "MOTÝL", "MRAK", "MRKEV",
"MUŽ", "MYŠ", "NÁDRAŽÍ", "NOHA", "NOC", "NOS", "NOVINY", "NŮŽKY",
"OBCHOD", "OBĚD", "OBRAZ", "OCAS", "OKNO", "OKO", "OLEJ", "OPICE",
"OREL", "OVOCE", "PÁTEK", "PES", "PÍSEŇ", "PIVO", "PLÁČ", "PLOT",
"POLE", "POLÉVKA", "POMOC", "POSTEL", "POTOK", "PRÁCE", "PRAHA", "PRASE",
"PRST", "PTÁK", "RÁDIO", "RADOST", "RAKETA", "RÁNO", "RUKA", "RYBA",
"ŘEKA", "ŘEPA", "SÁŇKY", "SEDLO", "SESTRA", "SEŠIT", "SKLO", "SLON",
"SLUNCE", "SNÍH", "SOVA", "SRDCE", "STROM", "STŮL", "SVĚTLO", "ŠKOLA",
"ŠATY", "ŠNEK", "TÁTA", "TELEVIZE", "TETA", "TMA", "TRAVÁ", "TRH",
"TUŽKA", "UCHO", "ULICE", "ÚNOR", "ÚSTA", "VAJÍČKO", "VÁNOCE", "VČELA",
"VEČEŘE", "VEJCE", "VELRYBA", "VESNICE", "VĚTR", "VODA", "VOJÁK", "VOLANT",
"VRÁNA", "VRATA", "VLASY", "VLAK", "VLK", "ZÁHADA", "ZAHRADA", "ZÁMEK",
"ZIMA", "ZLATO", "ZUB", "ZVON", "ŽÁBA", "ŽÁROVKA", "ŽENA", "ŽIDLE", "ŽIVOT",
# special letters to make sure we have them covered:
"WEB", "PIXEL", "TEQUILA", "BRAWL", "BOX", "QÍK"
]

31
jobs/decimal_division.py Normal file
View File

@ -0,0 +1,31 @@
import random
from .base import Job
class DecimalDivisionJob(Job):
def get_name(self):
return "DELENI (2 DES. MISTA)"
def print_body(self, p):
p.text("Vypocitej na 2 desetinna mista:\n\n")
results = []
exercises = 2;
for i in range(1, exercises + 1):
# Ensure it's not too easy (avoid 1)
divisor = random.randint(2, 39)
# Dividend between 10 and 100
dividend = random.randint(10, 1000)
p.text(f"{i}) {dividend} : {divisor} = ______\n\n")
# Calculate result rounded to 2 decimal places
res = dividend / divisor
results.append(f"{i}) {res:.2f}")
p.text("\n\n\n\n") # add space for calculations
p.text("Reseni (naskenuj):\n")
# Join results for the QR code
qr_data = "\n".join(results)
p.qr(qr_data, size=6, native=True)

62
jobs/division_cipher.py Normal file
View File

@ -0,0 +1,62 @@
import random
import unicodedata
from .base import Job
from jobs.czech_words import CZECH_WORDS
class DivisionCipherJob(Job):
def __init__(self):
self.secret = "TAJENKA"
def get_name(self):
return "TAJENKA DELENIM"
def configure(self):
print("\n--- Configure Division Cipher ---")
phrase = input("Enter secret phrase (default: Random): ").strip().upper()
raw_secret = phrase if phrase else random.choice(CZECH_WORDS)
# Remove accents to ensure mapping to A-Z works
nfkd_form = unicodedata.normalize('NFKD', raw_secret)
only_ascii = "".join([c for c in nfkd_form if not unicodedata.combining(c)])
# Keep only A-Z
self.secret = "".join([c for c in only_ascii.upper() if 'A' <= c <= 'Z'])
if not self.secret:
self.secret = "TAJENKA"
def print_body(self, p):
secret = self.secret
p.text("Vylusti tajenku!\n")
p.text("Vysledek deleni je poradi pismena\n")
p.text("v abecede (A=1, B=2, C=3...).\n\n")
problems = []
for char in secret:
# A=1, B=2...
target = ord(char) - ord('A') + 1
# Generate Dividend / Divisor = target
# Ensure divisor is small enough for mental math
divisor = random.randint(2, 9)
dividend = target * divisor
problems.append((dividend, divisor))
# Print problems
for i, (dividend, divisor) in enumerate(problems):
p.text(f"{i+1}) {dividend} : {divisor} = ___\n")
p.text("\n")
# Print slots for solution
p.text("Tajenka: " + " ".join(["___"] * len(secret)) + "\n\n")
# Print helper key
p.text("Napoveda:\n")
p.text("1=A 2=B 3=C 4=D 5=E 6=F 7=G 8=H\n")
p.text("9=I 10=J 11=K 12=L 13=M 14=N 15=O\n")
p.text("16=P 17=Q 18=R 19=S 20=T 21=U 22=V\n")
p.text("23=W 24=X 25=Y 26=Z\n")

12
jobs/flush.py Normal file
View File

@ -0,0 +1,12 @@
class FlushJob:
def get_name(self):
return "Flush Printer Queue"
def configure(self):
pass
def run(self, printer):
# Send NUL bytes to push any buffered data without printing visible characters
printer._raw(b'\x00\x00')
# Send a newline to ensure any line-buffered data is processed
printer.text("\n")

47
jobs/joke.py Normal file
View File

@ -0,0 +1,47 @@
import textwrap
from jobs.joke_sources import JednorozecJokeSource, BestPageJokeSource
class JokeJob:
def __init__(self):
self.sources = [
JednorozecJokeSource(),
BestPageJokeSource()
]
self.selected_source = self.sources[0]
def get_name(self):
return "Random Joke"
def configure(self):
print("\nSelect Joke Source:")
for i, source in enumerate(self.sources):
print(f" [{i + 1}] {source.get_name()}")
choice = input(f"Choice [{self.sources.index(self.selected_source) + 1}]: ").strip()
if choice:
try:
idx = int(choice) - 1
if 0 <= idx < len(self.sources):
self.selected_source = self.sources[idx]
except ValueError:
pass
def run(self, printer):
try:
joke = self.selected_source.fetch_joke()
if joke:
# Wrap text to avoid word splitting (assuming ~42 chars for 80mm paper)
wrapped_joke = "\n".join([textwrap.fill(line, width=42) for line in joke.splitlines()])
printer.text(f"Joke from {self.selected_source.get_name()}:\n")
printer.text("--------------------------------\n\n")
printer.text(wrapped_joke)
printer.text("\n\n")
else:
printer.text("Sorry, could not extract any jokes from the website.\n")
except Exception as e:
printer.text(f"Error fetching joke: {e}\n")
printer.cut()

97
jobs/joke_sources.py Normal file
View File

@ -0,0 +1,97 @@
import requests
from bs4 import BeautifulSoup
import random
class JokeSource:
def get_name(self):
return "Generic Source"
def fetch_joke(self):
"""Returns a single joke string or None."""
raise NotImplementedError
class JednorozecJokeSource(JokeSource):
def get_name(self):
return "vtipy.jednorozec.cz"
def fetch_joke(self):
url = "https://vtipy.jednorozec.cz/"
try:
# Add a User-Agent to be polite and avoid basic blocking
headers = {'User-Agent': 'Mozilla/5.0 (compatible; PrintServer/1.0)'}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
jokes = []
# Strategy 1: Look for specific classes often used in blogs/joke sites
# We look for divs that might contain the joke text
potential_classes = ['post', 'entry', 'hentry', 'joke', 'vtip']
for class_name in potential_classes:
elements = soup.find_all(class_=lambda x: x and class_name in x.split())
if elements:
for el in elements:
for br in el.find_all("br"):
br.replace_with("\n")
text = el.get_text()
lines = [line.strip() for line in text.splitlines() if line.strip()]
text = "\n".join(lines)
# Filter out very short texts (titles, metadata) and ensure safety limit
if len(text) > 20 and len(lines) <= 20:
jokes.append(text)
if jokes:
break
# Strategy 2: Fallback to all paragraphs if no specific container found
if not jokes:
for p in soup.find_all('p'):
for br in p.find_all("br"):
br.replace_with("\n")
text = p.get_text()
lines = [line.strip() for line in text.splitlines() if line.strip()]
text = "\n".join(lines)
if len(text) > 50 and len(lines) <= 20: # Assume jokes are somewhat long paragraphs
jokes.append(text)
if jokes:
return random.choice(jokes)
return None
except Exception as e:
raise e
class BestPageJokeSource(JokeSource):
def get_name(self):
return "bestpage.cz"
def fetch_joke(self):
url = "https://bestpage.cz/vtipy/"
try:
headers = {'User-Agent': 'Mozilla/5.0 (compatible; PrintServer/1.0)'}
response = requests.get(url, headers=headers, timeout=10)
# Older sites often use windows-1250 or iso-8859-2
response.encoding = response.apparent_encoding
soup = BeautifulSoup(response.content, 'html.parser')
jokes = []
# Bestpage is an older site, often using tables or simple paragraphs
for el in soup.find_all(['p', 'div', 'td']):
for br in el.find_all("br"):
br.replace_with("\n")
text = el.get_text()
lines = [line.strip() for line in text.splitlines() if line.strip()]
text = "\n".join(lines)
if 50 < len(text) < 1000 and len(lines) <= 20:
jokes.append(text)
if jokes:
return random.choice(jokes)
return None
except Exception as e:
raise e

View File

@ -3,15 +3,33 @@ from PIL import Image, ImageDraw
from .base import Job
class MazeJob(Job):
def __init__(self):
self.width = 14
self.height = 32
def get_name(self):
return "BLUDISTE"
def configure(self):
print("\nSelect Difficulty:")
print(" [1] Easy")
print(" [2] Medium")
print(" [3] Hard")
choice = input("Choice [2]: ").strip()
if choice == '1':
self.height = 8
elif choice == '3':
self.height = 32
else:
self.height = 18
def print_body(self, p):
# Width and Height in cells.
# Total width in chars = 2 * w + 1.
# w=15 -> 31 chars (Fits comfortably on 80mm printers, tight on 58mm)
w = 14
h = 32
w = self.width
h = self.height
maze = self.generate_maze(w, h)
@ -38,12 +56,17 @@ class MazeJob(Job):
for r in range(rows):
for c in range(cols):
x = c * cell_size
y = r * cell_size
# Draw walls as black rectangles
if maze[r][c] == '#':
x = c * cell_size
y = r * cell_size
# fill=0 means Black in '1' mode
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=0)
elif maze[r][c] == 'S':
draw.text((x + 5, y + 2), "S", fill=0)
elif maze[r][c] == 'E':
draw.text((x + 5, y + 2), "E", fill=0)
return img

239
jobs/maze_multitarget.py Normal file
View File

@ -0,0 +1,239 @@
import random
from collections import deque
import time
class MazeMultitargetJob:
def __init__(self):
self.options = []
self.correct_index = 0
self.width = 18 * 2 + 1
self.height = 20 * 2 + 1
def get_name(self):
return "Maze with Multiple Endings"
def configure(self):
print("\n--- Configure Maze Options ---")
self.options = []
print("Enter labels for the endings (empty line to finish):")
while True:
label = input(f"Option {chr(65 + len(self.options))}: ").strip()
if not label:
if len(self.options) < 2:
print("Please enter at least 2 options.")
continue
break
self.options.append(label)
if len(self.options) >= 26:
break
print("\nWhich option is the correct one?")
for i, opt in enumerate(self.options):
print(f" [{chr(65 + i)}] {opt}")
while True:
choice = input("Correct option (letter): ").strip().upper()
if len(choice) == 1:
idx = ord(choice) - 65
if 0 <= idx < len(self.options):
self.correct_index = idx
break
print("Invalid selection.")
def run(self, printer):
# 1. Generate Perfect Maze (DFS)
# Grid: 1 = Wall, 0 = Path
grid = [[1 for _ in range(self.width)] for _ in range(self.height)]
def get_neighbors(r, c, dist=2):
ns = []
for dr, dc in [(-dist, 0), (dist, 0), (0, -dist), (0, dist)]:
nr, nc = r + dr, c + dc
if 0 < nr < self.height and 0 < nc < self.width:
ns.append((nr, nc))
return ns
# Start carving from (1, 1)
start_pos = (1, 1)
grid[start_pos[0]][start_pos[1]] = 0
stack = [start_pos]
while stack:
current = stack[-1]
r, c = current
neighbors = get_neighbors(r, c)
unvisited = []
for nr, nc in neighbors:
if grid[nr][nc] == 1:
unvisited.append((nr, nc))
if unvisited:
nr, nc = random.choice(unvisited)
# Remove wall between
wr, wc = (r + nr) // 2, (c + nc) // 2
grid[wr][wc] = 0
grid[nr][nc] = 0
stack.append((nr, nc))
else:
stack.pop()
def find_path(start, end, current_grid):
q = deque([start])
came_from = {start: None}
while q:
curr = q.popleft()
if curr == end:
break
r, c = curr
# Check neighbors (dist 1)
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nr, nc = r + dr, c + dc
if 0 <= nr < self.height and 0 <= nc < self.width:
if current_grid[nr][nc] == 0 and (nr, nc) not in came_from:
came_from[(nr, nc)] = curr
q.append((nr, nc))
if end not in came_from:
return None
# Reconstruct path
path = []
curr = end
while curr:
path.append(curr)
curr = came_from[curr]
return path[::-1]
# 2. Place Endpoints
# We need len(self.options) endpoints.
endpoints = [None] * len(self.options)
# First, place the correct endpoint
attempts = 0
while endpoints[self.correct_index] is None and attempts < 1000:
r = random.randrange(1, self.height, 2)
c = random.randrange(1, self.width, 2)
if (r, c) != start_pos and grid[r][c] == 0:
endpoints[self.correct_index] = (r, c)
attempts += 1
correct_endpoint = endpoints[self.correct_index]
if not correct_endpoint:
printer.text("Error: Could not place correct endpoint.\n")
return
# Calculate true path to ensure we don't place fakes on it
true_path = find_path(start_pos, correct_endpoint, grid)
if not true_path:
printer.text("Error: No path to correct endpoint.\n")
return
true_path_set = set(true_path)
# Place fake endpoints
attempts = 0
while None in endpoints and attempts < 2000:
r = random.randrange(1, self.height, 2)
c = random.randrange(1, self.width, 2)
pt = (r, c)
if pt != start_pos and pt not in endpoints and grid[r][c] == 0:
if pt not in true_path_set:
# Fill first empty slot
for i in range(len(endpoints)):
if endpoints[i] is None:
endpoints[i] = pt
break
attempts += 1
if None in endpoints:
printer.text("Error: Could not place enough endpoints.\n")
return
# 4. Block Incorrect Paths
# Robust Multi-target isolation
fakes = [pt for i, pt in enumerate(endpoints) if i != self.correct_index]
def get_degree(r, c, current_grid):
deg = 0
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nr, nc = r + dr, c + dc
if 0 <= nr < self.height and 0 <= nc < self.width:
if current_grid[nr][nc] == 0:
deg += 1
return deg
for fake in fakes:
# Repeat until fake is isolated from all other targets
while True:
connected_target = None
path_to_target = None
# Prioritize connection to Start/Correct (Main), then other fakes
check_order = [start_pos, correct_endpoint] + [f for f in fakes if f != fake]
for other in check_order:
path = find_path(other, fake, grid)
if path:
connected_target = other
path_to_target = path
break
if not connected_target:
break # Isolated from everyone
# Identify segment NOT on true_path
valid_segment_start = 0
for k in range(len(path_to_target)):
if path_to_target[k] not in true_path_set:
valid_segment_start = k
break
if valid_segment_start == len(path_to_target):
break # Should not happen unless fake is ON true path
# Find last junction on the path to maximize false path length
best_cut_u_index = -1
start_search = max(0, valid_segment_start - 1)
for k in range(start_search, len(path_to_target) - 1):
u = path_to_target[k]
if get_degree(u[0], u[1], grid) > 2:
best_cut_u_index = k
if best_cut_u_index != -1:
block_index = best_cut_u_index + 1
else:
block_index = len(path_to_target) - 2
# Ensure we block a valid node
block_index = max(block_index, valid_segment_start)
to_block = path_to_target[block_index]
grid[to_block[0]][to_block[1]] = 1
# 5. Print Maze
printer.text("Najdi spravny cil!\n\n")
# Map endpoints to letters
endpoint_map = {pt: chr(65 + i) for i, pt in enumerate(endpoints)}
for r in range(self.height):
line = ""
for c in range(self.width):
if (r, c) == start_pos:
line += "S"
elif (r, c) in endpoint_map:
line += endpoint_map[(r, c)]
elif grid[r][c] == 1:
line += "" # Full block
else:
line += " "
printer.text(line + "\n")
time.sleep(0.01)
printer.text("\nMoznosti:\n")
for i, opt in enumerate(self.options):
printer.text(f"{chr(65 + i)}: {opt}\n")
printer.text("\n\n")
printer.cut()

147
jobs/word_search.py Normal file
View File

@ -0,0 +1,147 @@
import random
import time
import textwrap
from jobs.czech_words import CZECH_WORDS
class WordSearchJob:
def __init__(self):
self.width = 14
self.height = 14
self.hidden_phrase = "TAJENKA"
self.words_to_find = []
self.grid = []
def get_name(self):
return "Word Search (Osmismerka)"
def configure(self):
print("\n--- Configure Word Search ---")
phrase = input("Enter hidden phrase (default: Random): ").strip().upper()
if phrase:
self.hidden_phrase = "".join(c for c in phrase if c.isalnum())
else:
self.hidden_phrase = random.choice(CZECH_WORDS)
self.width = len(self.hidden_phrase) + 2
self.height = len(self.hidden_phrase) + 2
size_str = input(f"Enter size (default: {self.width}): ").strip()
if size_str:
try:
size = int(size_str)
self.width = size
self.height = size
except ValueError:
pass
if self.width < 5:
self.width = 5
self.height = 5
if self.width > 40:
self.width = 40
self.height = 40
def run(self, printer):
# Directions: Right, Down, Diag-Down-Right
directions = [(0, 1), (1, 0), (1, 1)]
target_empty = len(self.hidden_phrase)
# Retry loop to ensure perfect fill (no leftover letters)
for attempt in range(20):
# Initialize grid
self.grid = [['' for _ in range(self.width)] for _ in range(self.height)]
used_mask = [[False for _ in range(self.width)] for _ in range(self.height)]
# Filter words that fit in the grid
available_words = [w for w in CZECH_WORDS if len(w) <= self.width and len(w) <= self.height]
random.shuffle(available_words)
self.words_to_find = []
total_cells = self.width * self.height
current_empty = total_cells
# Try to place words until we reach target_empty
for word in available_words:
if current_empty == target_empty:
break
placed = False
attempts = 0
while not placed and attempts < 50:
attempts += 1
direction = random.choice(directions)
dr, dc = direction
# Determine bounds
if dr == 0: # Horizontal
r = random.randint(0, self.height - 1)
c = random.randint(0, self.width - len(word))
elif dc == 0: # Vertical
r = random.randint(0, self.height - len(word))
c = random.randint(0, self.width - 1)
else: # Diagonal
r = random.randint(0, self.height - len(word))
c = random.randint(0, self.width - len(word))
# Check collision
fits = True
temp_new_cells = 0
for i, char in enumerate(word):
nr, nc = r + i*dr, c + i*dc
# Cell must be empty OR contain the same letter
if self.grid[nr][nc] != '' and self.grid[nr][nc] != char:
fits = False
break
if self.grid[nr][nc] == '':
temp_new_cells += 1
if fits:
# Check if this overfills
if current_empty - temp_new_cells < target_empty:
continue
# Place it
for i, char in enumerate(word):
nr, nc = r + i*dr, c + i*dc
if self.grid[nr][nc] == '':
current_empty -= 1
self.grid[nr][nc] = char
used_mask[nr][nc] = True
self.words_to_find.append(word)
placed = True
if current_empty == target_empty:
break
# Fill remaining spots with hidden phrase
empty_slots = []
for r in range(self.height):
for c in range(self.width):
if not used_mask[r][c]:
empty_slots.append((r, c))
phrase_idx = 0
for r, c in empty_slots:
if phrase_idx < len(self.hidden_phrase):
self.grid[r][c] = self.hidden_phrase[phrase_idx]
phrase_idx += 1
else:
# Fill with random letters if phrase is done (extra filler)
self.grid[r][c] = random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
# Print Header
printer.text("OSMISMĚRKA\n")
printer.text("Najdi slova a přečti tajenku!\n")
if current_empty != target_empty:
printer.text(f"Tajenka má {len(self.hidden_phrase)} písmen.\n")
printer.text("\n")
# Print Grid
for r in range(self.height):
line = " ".join(self.grid[r])
printer.text(line + "\n")
time.sleep(0.05)
printer.text("\nSlova k hledání:\n")
words_str = ", ".join(sorted(self.words_to_find))
printer.text(textwrap.fill(words_str, width=42) + "\n")
printer.text("\n\n")
printer.cut()

View File

@ -1,9 +1,16 @@
import time
from escpos.printer import Usb, Dummy
from escpos.exceptions import USBNotFoundError
from jobs.math_homework import MathHomeworkJob
from jobs.unit_conversion import UnitConversionJob
from jobs.chess_puzzle import ChessPuzzleJob
from jobs.maze import MazeJob
from jobs.division_cipher import DivisionCipherJob
from jobs.decimal_division import DecimalDivisionJob
from jobs.joke import JokeJob
from jobs.maze_multitarget import MazeMultitargetJob
from jobs.flush import FlushJob
from jobs.word_search import WordSearchJob
# ==========================================
# CONFIGURATION
@ -12,7 +19,13 @@ from jobs.maze import MazeJob
# Example: 0x04b8 is Epson.
USB_VENDOR_ID = 0x0525
USB_PRODUCT_ID = 0xa700
# Input interface:
# `lsusb -vvv -d xxxx:xxxx | grep iInterface`
# iInterface 0
INPUT_ENDPOINT = 0x00
# Output interface:
# `lsusb -vvv -d xxxx:xxxx | grep bEndpointAddress | grep OUT`
# bEndpointAddress 0x01 EP 1 OUT
OUTPUT_ENDPOINT = 0x01
# Set to True to print to console instead of physical printer (for testing)
@ -28,12 +41,12 @@ def get_printer():
try:
# Initialize USB printer
# profile="TM-T88V" is a generic profile, works for many ESC/POS printers
# Generic printer with custom settings:
p = Usb(USB_VENDOR_ID, USB_PRODUCT_ID, 0, INPUT_ENDPOINT, OUTPUT_ENDPOINT, profile="default")
p.profile.profile_data['media']['width']['mm'] = 80
p.profile.profile_data['media']['width']['pixels'] = 512
# Specific printer based on a profile. See https://python-escpos.readthedocs.io/en/latest/printer_profiles/available-profiles.html
# p = Usb(USB_VENDOR_ID, USB_PRODUCT_ID, 0, INPUT_ENDPOINT, OUTPUT_ENDPOINT, profile="TM-T88V")
return p
except USBNotFoundError:
@ -47,7 +60,14 @@ JOBS = [
MathHomeworkJob(),
UnitConversionJob(),
ChessPuzzleJob(),
MazeJob()
MazeJob(),
DivisionCipherJob(),
DecimalDivisionJob(),
JokeJob(),
MazeMultitargetJob(),
WordSearchJob(),
# keep this last:
FlushJob()
]
def run_tui():
@ -79,21 +99,33 @@ def run_tui():
continue
if job:
job.configure()
copies_str = input("\nNumber of copies [1]: ").strip()
try:
copies = max(1, int(copies_str)) if copies_str else 1
except ValueError:
copies = 1
p = get_printer()
if p:
print(f"Printing {job.get_name()}...")
print(f"Printing {job.get_name()} ({copies} copies)...")
try:
job.run(p)
for i in range(copies):
if copies > 1:
print(f" Printing copy {i + 1}...")
job.run(p)
# If using Dummy, print the output to console to verify
if isinstance(p, Dummy):
print(p.output.decode('utf-8', errors='ignore'))
# If using Dummy, print the output to console to verify
if isinstance(p, Dummy):
print(p.output.decode('utf-8', errors='ignore'))
print("Success! Job sent to printer.")
except Exception as e:
print(f"Print Error: {e}")
finally:
if not isinstance(p, Dummy):
time.sleep(0.5)
p.close()
if __name__ == '__main__':