Assignment
individual assignment:
• design, build, and connect wired or wireless node(s) with network or bus addresses and local input &/or output device(s)
group assignment:
• send a message between two projects
Individual Assignment
For “Networking Week” I’m working on my Final Project. The goal is to signal to a server that a new image should be shown on the e-ink screen (this signal will come from an XR device) and for an ESP32 to then update an attached screen. So, the nodes involved in this setup are:
- A device that can send image updates to a server (a simple
curlin my PoC below). - A server that exposes HTTP
POSTendpoints ashttps://<base-url>/<app-name>/<endpoint>for these updates. - An ESP32 that tries to fetch a new image from that server via a
GETendpoint. - An e-ink screen attached to the ESP32 via SPI.
I had planned to set up infrastructure supporting the architecture outlined in below graph.
The plan was to have an MQTT queue that the ESP32 listens to. When a new message is dropped into the queue, the ESP32 was to assume that a new image was ready to be picked up and make an HTTP request for said image. I have decided against this setup for multiple reasons:
- I think overcomplicating my server setup by hosting my own MQTT broker isn’t helpful. I will instead keep it a bit simpler and not have too many moving parts. I think that will serve me and the project better in the long run.
- I am not entirely sure about power management consequences and whether listening to an MQTT queue increases my power consumption. With the new setup below, I can have the ESP32 sleep for a while and then wake up every X seconds to connect to the wifi and make an HTTP request to figure out whether an update is required. The same should generally work with an MQTT queue, but my thinking is that the benefits of the queue (getting notified immediately when a new image is available) are eliminated by doing the sleep cycling as described above.
The new architecture is simpler:
So, let’s describe the individual parts! Setting this up was pretty straight-forward. The only complications happened when trying to get Sun’s project connected for the group assignment.
Server Setup
In Week 8 I made a device that can talk to the Spotify API through a custom server setup (to keep Spotify credentials hidden). I used that exact server setup and added an additional flask app to it. The code is linked on the right. Note that a lot of the code was written using AI-autocomplete in Visual Studio Code. Code generation was closely supervised by me at all times.
Notes on how the server is set up and how to update the code:
- I log into the server via SSH:
ssh root@<Server IP>
- The flask app is added to a
docker-compose.ymlfile that I was using to administer the processes on this server already. The respective entry in the file looks like this:
htmaa-final:
build: ./htmaa-final-app
container_name: htmaa-final
restart: unless-stopped
expose:
- "8042"
environment:
- HTMAA_API_KEY=${HTMAA_API_KEY}
networks: [webnet]
- That .yml file lives in a repository that I’m also committing the flask app to. While ssh’d into the server, I do the following to fetch the new code and update the docker process.
If the docker container is already running:
docker compose down htmaa-final
Then rebuild the container:
docker compose build htmaa-final
This will create a new container and make sure that all the dependencies are installed correctly. The build process is managed by a Dockerfile (see below). Note that I’m still using poetry for dependency management. It’s an old habit that I need to kick. I recommend using uv for all dependency management needs in Python instead.
FROM python:3.12-slim
# System basics
ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
POETRY_VERSION=1.8.3 \
POETRY_VIRTUALENVS_CREATE=false \
PYTHONUNBUFFERED=1
WORKDIR /app
# Install Poetry
RUN pip install --upgrade pip && pip install "poetry==${POETRY_VERSION}"
# Copy only dependency files first for better cache
COPY pyproject.toml poetry.lock* ./
# Install deps (no project venv, installs into system site-packages)
RUN poetry install --no-interaction --no-ansi --no-root
# Copy app source
COPY . ./
# Gunicorn config & port
EXPOSE 8042
CMD ["gunicorn", "-c", "gunicorn.conf.py", "app:app"]
And finally:
docker compose up -d htmaa-final
At this point, the flask app is running on the server, but it’s not accessible from the outside. As described in
Week 8, I use Caddy for the routing. This new flask app has the following entry in my Caddyfile:
# HTMAA Final App under /htmaa-final (prefix stripped inside container)
handle_path /htmaa-final* {
reverse_proxy htmaa-final:8042
}
and is then available using the HTTPS routing that Caddy handles automatically.
Features
There are multiple endpoints in this flask app that are worth noting. I’ll leave it to the reader to dig through the code in order to fully understand the setup. In short, a new image can be sent to the server as a .png, for example. The image is sent alongside a name that can be used to address the image later. The server maintains some local files that it stores information in (as opposed to global variables - see my comment in Week 8 about the number of gunicorn workers). It converts the image into a bit array that is suitable to be used on the e-ink screen directly (see below). It also keeps a record of what the “current” image to be shown is. This image can be retrieved via a GET endpoint. Alongside the image, the server sends a hash that uniquely describes the image contents. In future requests to the same GET endpoint, the server will NOT send the data again if the request contains a hash that is identical to the hash of the “current” image. So, the logic of understanding whether the screen needs refreshing is offloaded onto this server (as opposed to handling it on the ESP32). The ESP32 will just have to send the hash it got previously in the HTTP request and then update the screen if it received image data - or not if the response contained no data.
The plan for the final project is to now have an XR device send commands to the flask app that can either upload images first or directly switch to a new “current” image by picking from a list of images. Doing updates like these will lead to a new hash being stored as “current” on the server side. When the ESP32 sends its old hash, it gets sent a new image. Easy-Peasy!
This hashing mechanism is essentially replacing the MQTT setup I had dreamed up originally. It’s a different method of preventing continuous refreshing of the screen.
The flask app can provide a list of stored images and their data which should make it possible to show a preview on some other device. You can read up on what I’ve done with it in Week 13) if I was successful.
What’s an API Key?
In case this is a new concept to some of the readers: An API Key is a simple mechanism to protect my HTTP endpoints from fraudulent use. I issued an API Key that is stored as an env var on the server. When the docker container is built, the key is moved into the container so that flask can pick it up (see the docker-compose.yml blurb above). Any request going to this app is immediately rejected if this key wasn’t sent in a X-API-Key header in the request. This behaviour is implemented in the require_api_key() decorator function in the flask code. Having a decorator like this is a very common pattern for this use case.
The ESP32 code knows this key and puts it into the requests it sends. Similarly, when Sun connected to my projected, I had to give him the key so that his requests were accepted.
ESP32 Setup
I had some driver code for the e-ink display that I’ve mentioned on this website before. It is reproduced in the final microcontroller code that you can find on the right, but I’ve also written about it on my Final Project page.
It uses SPI to send images to the e-ink display and all I had to do for this week was to add the code that fetches images from the server setup described above. The code is pretty straight forward. The ESP32 makes the request every 4 seconds and sends the hash of the currently shown image along, if it has one. If the server responds with an image, it’ll show it, otherwise it’ll ignore the response and wait for another 4 seconds.
The code can be found in the list on the right. Please note that I have removed the API Key that my server requires, as well as the WIFI credentials. You will have to add those back in if you want to run this with your own server setup.
I run the code on the ESP32 using mpremote:
mpremote connect auto run micro.py
The video at the top of this page shows the whole thing in action! I’ll add it again below for convenience. In the video, I execute a curl commands to switch what image should be active and shown on the screen. I don’t explicitly show the image upload command. For completeness, here is how to upload an image to the flask app:
curl -X POST -H "X-API-Key: $HTMAA_API_KEY" \\n -F "image=@qr-htmaa.png" \\n -F "name=htmaa-url" \\n https://api.mistermatti.com/htmaa-final/image
And for switching the images:
curl -X POST -H "X-API-Key: $HTMAA_API_KEY" \\n -H "Content-Type: application/json" \\n -d '{"name":"htmaa-url"}' \\n https://api.mistermatti.com/htmaa-final/current
Group Assignment
For the group assignment, I teamed up with Sun to send a message between his project and mine. There is additional documentation about the group assignment on the group page.
To make the group assignment work, I had to add an endpoint to my existing server setup that allows other people to send over text (as long as they have the right API Key). Since the API Key is sent over in a header, and since Sun does so via JS on a browser page, this header needs to be allowed by the CORS setup of my server. See below for some additional documentation about CORS problems.
Text-To-Image
My server fundamentally likes using images. As designed, it accepts images as imputs from POST requests, it stores images as bit sequences and it also sends out images for screen updates. So, in ordere to maintain this somewhat strict focus on images, we need to convert incoming text to an image that can be stored. Here is the implementation of this endpoint:
Text Endpoint
@app.post("/text")
@require_api_key
def text_to_image():
"""
Convert text to an image and set it as current.
Expected JSON: {'text': 'your text here', 'x': 10, 'y': 10} (x, y are optional)
Automatically generates a timestamp-based name and sets as current.
Text will be wrapped to fit within the 200px width.
"""
data = request.get_json()
if not data or 'text' not in data:
return jsonify(ok=False, error="No text provided"), 400
text = data['text']
x = data.get('x', 10)
y = data.get('y', 10)
font_size = data.get('font_size', 10)
try:
# Create a white 200x200 image
im = Image.new('1', (200, 200), 1) # 1 = white in mode '1'
draw = ImageDraw.Draw(im)
# Try to load a font with the specified size
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size)
except:
print("Warning: Could not load TTF font, using default font")
font = ImageFont.load_default()
# Word wrap text to fit within width
max_width = 200 - x - 10 # Leave margin on right
lines = []
words = text.split()
current_line = []
for word in words:
test_line = ' '.join(current_line + [word])
bbox = draw.textbbox((0, 0), test_line, font=font)
line_width = bbox[2] - bbox[0]
if line_width <= max_width:
current_line.append(word)
else:
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
if current_line:
lines.append(' '.join(current_line))
# Draw wrapped text (0 = black in mode '1')
wrapped_text = '\n'.join(lines)
draw.multiline_text((x, y), wrapped_text, fill=0, font=font)
# Pack to 1bpp format
buf, w, h = pack_1bpp(im, invert=False, msb_first=True)
# Generate timestamp-based name
image_name = f"text_{int(time.time())}"
# Save to disk
output_path = os.path.join(IMAGES_DIR, f"{image_name}.bin")
with open(output_path, "wb") as f:
f.write(buf)
# Set as current image
set_current_image_name(image_name)
return jsonify(ok=True, name=image_name, width=w, height=h, size=len(buf), current=True)
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
A lot of this endpoint was written with AI-based auto-complete in Visual Studio Code.
Note that with this endpoint, we don’t do the 2-step process of (1) uploading data and (2) setting it as “current”. Since this is meant to just expose a quick way for others to send text to my screen, the endpoint immediately sets the newly created image as the “current” image to show. The screen refreshes accordingly on the next cycle.
CORS
Sun was sending this over via JavaScript on a browser page. Hence, CORS needs to be considered. Two errors occured and needed to be fixed.
X-API-Key Header not allowed
My Caddyfile already had the following entry:
@preflight method OPTIONS
handle @preflight {
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
header Access-Control-Allow-Headers "Content-Type, Authorization"
header Access-Control-Max-Age "86400"
respond "" 204
}
Note that X-API-Key isn’t explicitely allowed. I forgot that Caddy already handles a lot of this for me and I thus added the following code to my flask app to allow for Sun’s requests:
from flask_cors import CORS
[...]
CORS(app, allow_headers='*')
This explicitly allows the X-API-Key header, but it caused another problem:
This and the above screenshot were taken by Sun.
Multiple CORS Entries
To resolve this problem, I removed the code I had added to the flask app. Instead, I let Caddy handle everything related to CORS. The (very simple) final CORS section in the Caddyfile looks like this:
@preflight method OPTIONS
handle @preflight {
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
header Access-Control-Allow-Headers "*"
header Access-Control-Max-Age "86400"
respond "" 204
}
header Access-Control-Allow-Origin "*"
Now, Sun can send over messages without any trouble and the screen refreshes accordingly. He sends scores (a number that is incremented by one) and my screen reacts: