MatrixML is a side project I’ve been building out off and on for the past few weeks in my spare time that I recently blogged about a few weeks ago.
TL;DR: It’s a display framework for Raspberry Pi LED matrices that ships with its own markup language (hence, matrix
+ ML
).
In the last post I detailed usage of the html.parser
from Python to build a custom parser for the view templates MatrixML lets you define. At the end of that exercise, I was able to render static content onto the matrix via MatrixML. The data might be animated, but it wasn’t exactly dynamic – the <text>
tags required strings only. That’s great and all, but the beauty of a matrix is its unique look coupled with the ability to display dynamic content, which makes it perfect for things like stock/crypto tickers, scoreboards, and pixel graphics.
Today I want to talk a bit about my decision not to roll my own template engine and instead use something off the shelf. But first, I should detail the internals of MatrixML to make understanding this decision a little clearer.
The Guts of MatrixML
Under the hood, MatrixML is actually set up sort of like an MVC (model-view-controller) web framework you might already have familiarity with. The controller layer is represented by the MatrixScreen
class, which can be subclassed to define custom screens for each actual display template you want to use. The view layer is the MatrixML template you define. There’s no strong concept of a model that the framework enforces right now, but you could define a data model if you wanted to and hook it into your screen as you see fit.
On top of all this sits the “router”, which is represented by the ScreenManager
that determines which screen class should render its template at a given time.
Digging into the MatrixScreen
object a little deeper, it knows how to use the MatrixTemplateParser
from the last blog post to take a view template and parse it into a list of MatrixElements
. Each element (such as <text>
, <scroll>
, or <row>
) then knows how to render its static content to the screen via the appropriate driver library call.
Dynamic Content, Attempt #1
In order for dynamic content to get displayed, we need a way to reach out to the controller (i.e. MatrixScreen
) and fetch data, then interpolate it into the template at runtime. Then, at the point at which the parser reads the matrix elements, it’s simple to render this to the display.
A naive approach that I initially took was to use a custom <py />
self-closing tag that took in an attribute and used that to fetch an instance variable on the screen object:
<py data_attribute />
Now, every time you re-render the template, you get a dynamic string based on the data_attribute
of the screen.
The problem with this is the implementation is simple. The parser (which has access to the screen object directly) is simply hydrating itself via the screen performing a getattr
with the name of the data attribute from the element:
import random
class ExampleScreen(MatrixScreen):
def __init__(self):
self.data_attribute = f"Dynamic content = {random.randint()}"
def resolve(self, binding):
return getattr(self, binding)
# <py /> elements resolve to DynamicElement
class DynamicElement(MatrixElement):
def hydrate(self):
binding = self.attributes[0][0]
self.data = self.screen.resolve(binding)
This approach means that everything you want to render needs to be broken down and stored in its own instance variable in the screen. Complex data, such as a large dict or a data class, simply can’t be deserialized via this approach:
<py a.complex.object />
The above code will throw an error 100% of the time.
Dynamic Content, Attempt #2
With this all in mind, I decided to integrate with a template engine in order to simplify development. I chose jinja
.
I really only needed the Template
class. Within the MatrixTemplateParser
, rather than feed the raw MatrixML file to HTMLParser
, there’s an intermediate step where it’s first loaded into a jinja
template:
from jinja2 import Template
class MatrixTemplateParser(HTMLParser):
def __init__(self, template_path, screen):
super(MatrixTemplateParser, self).__init__()
with open(template_path, 'r') as f:
self.__html_raw = f.read()
self.template = Template(self.__html_raw, autoescape=True)
self.screen = screen
Next, parse the new template object, but make sure to pass in the screen:
# OLD
def parse(self):
self.tokens = []
self.feed(self.__html_raw)
# NEW
def parse(self):
self.tokens = []
self.feed(self.template.render(screen=self.screen))
This gives us all the goodness of jinja
in the context of the existing matrix parser, including complete access to the screen object, complex data structures, and control flow for the template:
<text>{{ screen.data.get("coin_name", "MISSING") }}</text>
<text>{{ screen.generate_price() }}</text>
<scroll>
<text>
{{ screen.data.get("uid", "MISSING") }}
</text>
</scroll>
I’ve assigned some complex dict to the data
attribute for the screen, which we can now appropriately reference from the template.
This even gives me conditionals and control flow out of the box:
{% if screen.current_image == 1 %}
<img>{{ screen.image_identifier }}</img>
{% else %}
<img href="{{ screen.REMOTE_IMAGE_URL }}" />
{% endif %}
(Both of these examples are ripped straight from the MatrixML sample files – try them out!)
Here’s an example running, showcasing:
- Dynamic content
- Data fetched from an API
- Image tags
- Image caching for both local and remote images
Wrap Up
I like building things. Sometimes, I forget that I don’t have to build everything on my own – especially true for side projects. It’s good to step back and re-evaluate where your strengths lie, what your goals are, and what the easiest path to success may be.
Sometimes, you might find it’s just not worth reinventing the wheel.
That’s all for now. Thanks for reading!
- ← Previous: Programmatically Modifying Ancient Fonts
- Next: Emulating Raspberry Pi LED Matrices in Your Browser →