Skip to content

Instantly share code, notes, and snippets.

@pedramamini
Last active June 20, 2023 17:28
Show Gist options
  • Save pedramamini/aecb386544ae6e8cc14afead6b5ca9e0 to your computer and use it in GitHub Desktop.
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
#!/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