Last active
June 20, 2023 17:28
-
-
Save pedramamini/aecb386544ae6e8cc14afead6b5ca9e0 to your computer and use it in GitHub Desktop.
Cowsay, but via Amazon's sticky note printer. See it in action: https://stickynote.pedramamini.com
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/Users/pedram/venv3/bin/python | |
# | |
# The Amazon thermal sticky note printer is pretty useless as the interface is purely verbal. But... | |
# it does expose an IP Printing port (TCP 613). Took some finagling but I'm proud to present... | |
# | |
# Cowsay Sticky Note Edition! | |
# | |
# Feed programmatic notifications via CLI or host a simple web server to receive sticky notes from friends. | |
# | |
# When running a server, if plain-text is detected, it will be passed through cowsay and printed. If ascii-art is | |
# detected then the ascii art is printed directly with truncation at the column boundary. | |
# | |
# Relevant Links: | |
# | |
# Live Interface: https://stickynote.pedramamini.com | |
# Video Demo: https://youtu.be/1e0R9B3MvR4 | |
# Open Printer Launch: https://twitter.com/pedramamini/status/1645888031251996675 | |
# First Day Harvest: https://twitter.com/pedramamini/status/1646350611183484938 | |
# Photo Album: https://www.icloud.com/sharedalbum/#B0qGdIshaGO97cV | |
# | |
# Usage: | |
# | |
# Run a server: | |
# cowsay-sticky --server | |
# | |
# Run a server with an out of service banner: | |
# cowsay-sticky --server | |
# | |
# Print a single note: | |
# cowsay-sticky "i am a sticky note" | |
# | |
import os | |
import sys | |
import html | |
import shlex | |
import string | |
import datetime | |
import subprocess | |
import http.server | |
import urllib.parse | |
import socketserver | |
# batteries not included. | |
from PIL import Image, ImageDraw, ImageFont | |
# constants. | |
PRINTER_IP = "192.168.1.55" | |
FONT = "/Users/pedram/Library/Fonts/SourceCodePro[wght].ttf" | |
FONT_SIZE = 25 | |
COLUMNS = 35 | |
HTTPD_PORT = 80 | |
COWSAY = f"/opt/homebrew/bin/cowsay -W{COLUMNS} " | |
PNG = "/tmp/sticky-note.png" | |
BMP = "/tmp/sticky-note.bmp" | |
# USE_COW can be one of True, False, or "auto". | |
# if we disable the cow, then we'll simply cut-off at COLUMNS. | |
# NOTE: i implemented this as folks were sending ASCII art that was getting butchered through cowsay. | |
USE_COW = "auto" | |
PAGE_SIZE_COW = 600 | |
PAGE_SIZE_NOCOW = 400 | |
# banned IP addresses. | |
BANNED_IP = \ | |
[ | |
"8.8.8.8", # just an example. | |
] | |
######################################################################################################################## | |
### SHOULD NOT NEED TO EDIT BELOW THIS LINE | |
######################################################################################################################## | |
# HTML template. | |
HTML = \ | |
""" | |
<html> | |
<head> | |
<!-- Title it! --> | |
<title>Cowsay Sticky Note Edition!</title> | |
<!-- Script it! --> | |
<script> | |
window.addEventListener("load", () => { | |
document.forms[0].elements[0].focus(); | |
}); | |
</script> | |
<!-- Style it! --> | |
<style> | |
/* Reset */ | |
html, | |
body { | |
margin: 0; | |
padding: 0; | |
} | |
* { | |
font-family: monospace; | |
box-sizing: border-box; | |
} | |
/* Layout */ | |
form { | |
margin: auto; | |
margin-top: 40px; | |
max-width: 95vw; | |
} | |
form > * { | |
display: block; | |
width: 95%%; | |
max-width: 95%%; | |
min-width: 95%%; | |
margin-bottom: 8px; | |
} | |
form > textarea { | |
padding: 8px; | |
font-family: monospace; | |
font-size: calc((100vw - 16px) / 18.5); | |
} | |
form > input { | |
padding: 8px 16px; | |
cursor: pointer; | |
} | |
#cowsayit { | |
height: 100px; | |
font-size:50px; | |
} | |
p { | |
font-size: 20px; | |
} | |
</style> | |
</head> | |
<body> | |
<center> | |
%s | |
<!-- Prompt it! --> | |
<form action="/print" method="post"> | |
<textarea | |
placeholder="Sticky note content..." | |
name="note" | |
rows="5" | |
cols="35" | |
></textarea> | |
<input type="submit" id="cowsayit" value="Cowsay it!" /> | |
</form> | |
<p> | |
<a href="https://www.youtube.com/watch?v=1e0R9B3MvR4">Video Demo</a> | | |
<a href="https://gist.github.com/pedramamini/aecb386544ae6e8cc14afead6b5ca9e0">Source Code</a> | | |
<a href="https://www.icloud.com/sharedalbum/#B0qGdIshaGO97cV">Photo Album</a> | |
</p> | |
</center> | |
</body> | |
</html> | |
""" | |
# this is toggled on via --oos argument. | |
OUT_OF_SERVICE = "" | |
# the following blob of text at a font size of 25 will fill 576x576 | |
# this is how we calculated the column count of 35 and page size of 600. | |
TUNING_NOTE = """ | |
1234567890 1234567890 1234567890 12345 | |
2234567890 1234567890 1234567890 12345 | |
3234567890 1234567890 1234567890 12345 | |
4234567890 1234567890 1234567890 12345 | |
5234567890 1234567890 1234567890 12345 | |
6234567890 1234567890 1234567890 12345 | |
7234567890 1234567890 1234567890 12345 | |
8234567890 1234567890 1234567890 12345 | |
9234567890 1234567890 1234567890 12345 | |
0234567890 1234567890 1234567890 12345 | |
1234567890 1234567890 1234567890 12345 | |
2234567890 1234567890 1234567890 12345 | |
3234567890 1234567890 1234567890 12345 | |
4234567890 1234567890 1234567890 12345 | |
5234567890 1234567890 1234567890 12345 | |
6234567890 1234567890 1234567890 12345 | |
7234567890 1234567890 1234567890 12345 | |
8234567890 1234567890 1234567890 12345 | |
9234567890 1234567890 1234567890 12345 | |
0234567890 1234567890 1234567890 12345 | |
""" | |
######################################################################################################################## | |
class StickyHTTPD (http.server.SimpleHTTPRequestHandler): | |
# ban IPs. | |
def fence (self): | |
x_forwarded_for = self.headers.get('X-Forwarded-For') | |
if x_forwarded_for in BANNED_IP: | |
self.log(f"Request from banned IP: {x_forwarded_for}") | |
self.send_response(403) | |
self.send_header("Content-type", "text/html") | |
self.end_headers() | |
self.wfile.write(f"<b>Your IP is banned.</b>".encode("utf-8")) | |
return True | |
# log requests. | |
def log (self, message): | |
client_ip = self.address_string() | |
print(f"[{datetime.datetime.now().isoformat()}] {client_ip} - {message}") | |
# handle GET's | |
def do_GET (self): | |
if self.fence(): | |
return | |
if self.path == "/": | |
self.log("Form requested.") | |
self.send_response(200) | |
self.send_header("Content-type", "text/html") | |
self.end_headers() | |
self.wfile.write((HTML % OUT_OF_SERVICE).encode("utf-8")) | |
else: | |
self.log(f"Undefined request: {self.path}") | |
super().do_GET() | |
# handle POST's. | |
def do_POST (self): | |
if self.fence(): | |
return | |
if self.path == "/print": | |
content_length = int(self.headers.get("Content-Length")) | |
post_data = self.rfile.read(content_length) | |
parsed_data = urllib.parse.parse_qs(post_data.decode("utf-8")) | |
input_text = parsed_data.get("note", [""])[0] | |
self.log(f"Print requested: {input_text}") | |
self.send_response(200) | |
self.send_header("Content-type", "text/html") | |
self.end_headers() | |
# you may leave up to 1k of sticky note content. | |
print_sticky_note(cowsay(input_text[:1024])) | |
input_text = html.escape(input_text) | |
response = f"Cowsay request received: <pre>{input_text}</pre><p>" | |
response += "Sticky note printed<p><a href='/'>Let's do another one!</a>" | |
self.wfile.write(response.encode("utf-8")) | |
######################################################################################################################## | |
def cowsay (s): | |
# bypass cowsay, raw sticky note, chopping off content after max COLUMNS. | |
if not USE_COW or USE_COW == "auto" and has_ascii_art(s): | |
print("raw mode") | |
return "\n".join([l[:COLUMNS] for l in s.splitlines()]) | |
print("cowsay mode") | |
# NOTE: i didn't originally release this filter in the gist, so as not to discourage folks from injection attempts. | |
for c in "\\;'\"-<>{}$&": | |
s = s.replace(c, "") | |
return subprocess.check_output(COWSAY + shlex.quote(s), shell=True).decode("utf-8", errors="replace") | |
######################################################################################################################## | |
def has_ascii_art (s): | |
if s[0] in string.whitespace and s[1] in string.whitespace: | |
return True | |
punctuation_count = 0 | |
for c in string.punctuation + string.whitespace: | |
punctuation_count += s.count(c) | |
if punctuation_count / len(s) > 0.50: | |
return True | |
return False | |
######################################################################################################################## | |
def print_sticky_note (s): | |
# determine the vertical page size. | |
PAGE_SIZE = PAGE_SIZE_NOCOW | |
if USE_COW == True: | |
PAGE_SIZE = PAGE_SIZE_COW | |
elif USE_COW == "auto" and not has_ascii_art(s): | |
PAGE_SIZE = PAGE_SIZE_COW | |
# yes, the canvas MUST be 576 pixels wide... found this via brute force. | |
canvas = 576, int(576 * max(1, len(s) / PAGE_SIZE)) | |
point = 0, 0 | |
img = Image.new("RGBA", canvas) | |
draw = ImageDraw.Draw(img) | |
font = ImageFont.truetype(FONT, size=FONT_SIZE) | |
# produce and save the PNG to disk. | |
draw.multiline_text(point, s, font=font, fill="white") | |
print(f"wrote PNG to {PNG}") | |
img.save(PNG) | |
# the printer supports only black/white and the image must be flipped and negated. | |
print(f"converted to BMP {BMP}") | |
os.system(f"convert {PNG} -monochrome -colors 2 -flip -negate BMP3:{BMP}") | |
# ipptool man page provided the list of standard files to enumerate and get this piece working. | |
print("issuing print command...") | |
os.system(f"ipptool -tvf {BMP} ipp://{PRINTER_IP}/ipp/print -d fileType=image/reverse-encoding-bmp print-job.test") | |
######################################################################################################################## | |
def usage (msg): | |
if msg: | |
sys.stderr.write(f"{msg}\n") | |
sys.stderr.write("Usage: cowsay-sticky <'your sticky note contents'|--server>\n") | |
sys.exit(1) | |
######################################################################################################################## | |
if __name__ == "__main__": | |
if len(sys.argv) != 2: | |
usage("Invalid argument count") | |
argument = sys.argv.pop() | |
# tuning. | |
if "--tuning" in argument.lower(): | |
print_sticky_note(cowsay(TUNING_NOTE)) | |
# HTTPD. | |
elif "--server" in argument.lower() or "--oos" in argument.lower(): | |
if "--oos" in argument.lower(): | |
OUT_OF_SERVICE = "<p><b>Note:</b> Temporarily Out of Service</p>" | |
with socketserver.TCPServer(("", HTTPD_PORT), StickyHTTPD) as httpd: | |
print(f"Serving on port {HTTPD_PORT}, {len(BANNED_IP)} banned IP addresses.") | |
httpd.serve_forever() | |
# print and go. | |
else: | |
print_sticky_note(cowsay(argument)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment