Making Xiao into a Trmnl

Illustration

A while ago I got myself a Trmnl device. However, I didn’t really want to use it for its Trmnl capabilities (which are admittedly great). What I wanted is to use Trmnl firmware with my server. And support for Trmnl firmware was the reason I got myself XIAO 7.5" ePaper Panel. I mean, it even comes with Trmnl firmware flashing instructions.

So, with Xiao display in my hand I tried following the instructions only to mostly fail. Why I say “mostly”? Well, display didn’t work and it still had picture that came from factory. But, since I was using my own server, I could easily see that it actually did communicate to my server. It did everything I expected it to, except showing the image.

The next step was, of course, contacting support. After following their steps, display did update using Arduino examples. But I didn’t want those - I wanted Trmnl firmware. So, I decided to dig into a Trmnl firmware itself. And one of the first thing I saw was that ESP32-C3 support was just added in 1.5.6. So much for the device supporting Trmnl firmware out of box. :)

Looking into source, it was easy to see that it contained defines for BOARD_TRMNL and BOARD_SEEED_XIAO_ESP32C3. The following was GPIO config for BOARD_TRMNL:

#define EPD_SCK_PIN  7
#define EPD_MOSI_PIN 8
#define EPD_CS_PIN   6
#define EPD_RST_PIN  10
#define EPD_DC_PIN   5
#define EPD_BUSY_PIN 4

And BOARD_SEEED_XIAO_ESP32C3 had these:

#define EPD_SCK_PIN  8
#define EPD_MOSI_PIN 10
#define EPD_CS_PIN   3
#define EPD_RST_PIN  2
#define EPD_DC_PIN   5
#define EPD_BUSY_PIN 4

Yep, e-paper control constants were completely different fitting what I saw happening. Firmware was running due to the same microcontroller. But Trmnl firmware literally couldn’t communicate with my display.

Fix was easy. I simply ignored instructions provided as method 1. Since I didn’t want to use old firmware, I also ignored method 2. This nicely led me to method 3. Building directly from source.

I already had PlatformIO installed from before so I only needed to figure out how to flash the firmware. What worked for me was holding “Boot” button and pressing “Reset”. Then wait for 2-3 seconds and press “Reset” twice in row. Sometimes it would be ok to double-click “Reset” only - but not every time. In PlatformIO one can now select seeed_xiao_esp32c3 as a device and upload working firmware.

With the properly firmware uploaded, my device finally worked. Curiously, the only thing missing was reading battery voltage. Worst case scenario, this will need a bit of hardware modification to work. But that is story for another time.

Epson V600 under Bazzite

Illustration

After I upgraded my family PC from Windows 11 to Bazzite, I found nothing lacking. At least for a few week. It took me a while but I finally noticed that my Epson V600 scanner, connected to that PC was no longer working.

Well, onto Epson site I went and, lo and behold, they had Linux drivers. While Bazzite is an atomic distribution and not supported by drivers directly, you can still install RPMs using rpm-ostree. So, with drivers unpacked, I tried just that:

sudo rpm-ostree install data/iscan-data-1.39.1-2.noarch.rpm
sudo rpm-ostree install core/iscan-2.30.4-2.x86_64.rpm
sudo rpm-ostree install plugins/iscan-plugin-gt-x820-2.2.1-1.x86_64.rpm

While the first two packages installed just fine, the third package was attempting to change stuff installed by the first two. And, due to atomic nature of Bazzite, it ran into a mkdir: cannot create directory ‘/var/lib/iscan’: Read-only file system error. And no, it doesn’t matter if you install all three RPMs together or all at once - the last one always fails.

Well, if we cannot get packages installed on Bazzite, how about we give it a separate system? Enter, Distrobox. Without going into too many details, it’s essentially container for your Linux distribution. To create it, just enter and you will be asked which distribution you want to create. I went with Fedora.

toolbox enter

After it pulls all packages, you have essentially running Fedora system inside your Bazzite. And, since Fedora is supported by Epson drivers, you can simply use the provided ./install.sh script to install it. If you run it manually, software can now start.

iscan

Since everybody in the family needed this application, I really wanted application in the start menu. However, Distrobox for some reason doesn’t provide this functionality. So, you need to do a bit of manual magic.

cp /usr/share/applications/iscan.desktop ~/.local/share/applications/
sed -i 's|^Exec=.*|Exec=distrobox enter -- iscan|' ~/.local/share/applications/iscan.desktop

Illustration

With that, you can finally find Image Scan! for Linux in your start menu.

After all this effort to have it running, I expected something like Epson’s Windows application. Only to be faced with barely functional application. Definitelly not satisfactory.

But, before I went onto creating Widnows dual boot, I decided to check if Flatpak has something to offer. And, wouldn’t you know it, somebody already packed Epson Scan 2. While still not really equivalent to the Windows counterpart, this one was actually good enough for my use case. And it could be installed without trickery.

Lesson learned for a milionth time.

Percentage Based on LiIon Voltage

As part of my LocalPaper project, I wanted to have a battery indicator. However, Trmnl device I use reports only voltage, leaving conversion to percent reading to the software. So, how to convert it?

After charging the Trmnl device, I saw that fully charged its voltage goes to 4.1V. If I check docs, I can see that voltage cutoff for their battery is at 2.75V. So, one could be tempeted to set those as limits. But, there are two issues with that approach.

First, you should not wait until cutoff point as this means you are close to (potentially) damaging the battery. You should probably report 0% before you allow battery’s safety to kick in. Also, I have only 1 device and thus 4.1V measured here might not be what other devices would show.

Fortunately, for more data, we can turn to flashlight crowd. While not conclusive, we can see that 4.1V maximum is probably spot-on. For minium, we could just go with 2.75V safety cut-off, but I would actually recommend 3.1V. Why? Well, it’s higher than safety cut-off and it actually makes math really easy. So, my suggested formula for turning voltage in percentages would be:

Percent = (Vmeasured - 3.1) * 100;

And yes, this could go under 0% or over 100% so we might want to adjust it a bit:

Percent = (int)Math.Min(Math.Max(Math.Ceiling( (Vmeasured - 3.1) * 100 ), 0), 100);

Is this precise? Not really. But thanks to LiIon rather linear voltage curve, it’s actually precise enough. We lie at the top range because we don’t want users to overcharge the battery and/or end with fully charged battery showing 99%. We lie at the bottom range because we don’t want to drain battery fully before user goes to recharge. But overall, we do use most of energy stored in the battery. And, we’re not stopping user from going down to the cut-off point. We’re just not going to encourage it.

But what if you have somee other priorities and you want to select other points? Well, formula above would probably not work since it depends on 1V range. We need something slighlty more general.

Vmin = 3.1;
Vmax = 4.1;
Percentage = (int)Math.Min(Math.Max(Math.Ceiling( 100.0 * (Vmeasured - Vmin) / (Vmax - Vmin) ), 0), 100);

Not precise, but probably good enough for any purpose you might need it for.

LocalPaper

Illustration

Ever since I got my first Be Book reader I was a fan of epaper displays. So, when I saw a decently looking Trmnl dashboard, I was immediatelly drawn to it. It’s a nice looking device with actually great dashboard software. It’s not perfect (e.g. darn display updates take ages), but there is nothing major I mind. It even has option to host your own server and half decent instructions on how to get it running.

However, even with all this goodness, I decided to roll my own software regardless. Reason? Well, my main beef was that I just wanted a simple list of events for today and tomorrow. On left I would have general family events while on right each of my kids would get their own column. I did try to use calendar for this but main issue was that it was setup as calendar and not really as event list I wanted. Also, it was not really possible to filter just based on date and not on time. For example, I didn’t want past events to dissapear until day is over. And I really wanted a separate box for tomorrow. Essentially, I wanted exactly what is depicted above.

To make things more complicated, I also didn’t want to use calendars for this. Main reason was because it was rather annoying to have all events displayed. For example, my kids’ school schedule is not really something they will enter in calendar. That would make calendar overcrowded for them. As for me, my problem was oposite one as I quite often have stuff in calendar that nobody else cares about and that would just make visual mess (e.g. dates for passport renewal are entries in my calendar). And yes, most of these issues could be sorted by a separate calendar for dashboard. But I didn’t really like that workflow and, most importantly, it wouldn’t show what happens the next day. And that is something my wife really wanted.

With all this I figured I would spend less time rolling my own solution than creating plugins.

Thankfully, Trmnl was kind enough to anticipate the need for custom server in their device setup. Just select your custom destination and you’re good. Mind you, as an opensource project, it would be simple enough to change servers on your own but actually having it available in their code does simplify future upgrades.

On server side, you just need three URLs to server.

The initial one is /api/setup. This one gets called only when device is first time pointed toward the new server and its purpose is to setup authentication keys. Because this was limited to my home network and I really didn’t care, I simply just respond 200 with a basic json.

{
  "status": 200,
  "api_key": "12:34:56:78:90:AB",
  "friendly_id": "1234567890AB",
  "image_url": "http://10.20.30.40:8084/hello.bmp",
  "filename": "empty_state"
}

Once device is setup, the next API call it makes will probably be /api/log. This one I ignore because I could. :) While device is sending the request, it doesn’t care about the answer. At the time I wrote this, even Trmnl own API documentation didn’t cover what it does. While they later did update documentation, I didn’t bother updating the code since 404 works here just fine and data provided in this call is actually also available in the next one.

In /api/display device actually asks you at predefined intervals what to do. Response gives two important pieces of information to the device: where is image to draw and when should I ask for the next image. The next image is easy - I decided upon 5 minutes. You really cannot do it more often as every refresh flashes the screen. Probably the only thing I really hate and actualy the one that will be solved eventually since there is no reason to do a full epaper reset on every draw. But, even once that is solved, you don’t want to do it more often because you will drain your battery. With 5 minute interval it will last you a month. I could have used longer intervals but then any update I make wouldn’t be visible for a while. Month is good enoough for me. To keep things simple, for the file name I just gave specially formatted URL that my software will process later.

{
  "status": 0,
  "image_url": "http://10.20.30.40:8084/A085E37A1984_2025-06-01T04-55-00.bmp",
  "filename": "A085E37A1984_2025-06-01T04-55-00.bmp",
  "refresh_rate": 300,
  "reset_firmware": false,
  "update_firmware": false,
  "firmware_url": null,
  "special_function": "identify"
}

And finally, the last part of API was actually providing the bitmap. Based on the file name and time requested, I would simply generate one on the fly. But you cannot just give it any old bitmap - it has to be 1-bit bitmap (aka 1bpp). And none of the C# libraries supports it out of box. Even SkiaSharp that is capable of doing any manipulation you can imagine simply refuses to deal with something that simple. After trying all reasonably popular graphic libraries only to end up with the same issue, I decided to simply go over the bits in for loop and output my own raw bitmap bytes. Ironically, I spent less time on that then what I spent on testing different libraries. In essence, bitmap Trmnl device wanted has 62 byte header that is followed by simple bit-by-bit dump of image data. You can check Get1BPPImageBytes function if you are curious.

And that is all there is to API. Is it perfect? No. But it is easy to implement. The only pet peeve I have with it is not really the API but device behavior in case of missing server. Instead of just keeping the old data, it goes to an error screen. While I can see the logic, in my case where 95% of time nothing changes on display, it seems counter-productive. But again, I can see why some people would prefer fresh error screen to the old data. To each their own, I guess. Second issue I have is that there is no way to order device NOT to update. For example, if my image is exactly the same as the previous one, why flash the screen? But, again, those are minor things.

After all this talk, one might ask - what about data? Well, all my data comes in the form of pseudo-ini files. The main one being the configuration that setups what goes where. The full example, is on GitHub, I will just show interesting parts here.

[Events.All]
Directory=All
Top=0
Bottom=214
Left=0
Right=265

[Events.Thing1]
Directory=Thing1
Top=0
Bottom=214
Left=267
Right=532

[Events.Thing2]
Directory=Thing2
Top=0
Bottom=214
Left=534
Right=799

[Events.All+1d]
Directory=All
Offset=24
Top=265
Bottom=479
Left=0
Right=265

[Events.Thing1+1d]
Directory=Thing1
Offset=24
Top=265
Bottom=479
Left=267
Right=532

[Events.Thing2+1d]
Directory=Thing2
Offset=24
Top=265
Bottom=479
Left=534
Right=799

Then in each of those directories, you would find something like this.

[2025-05-26]
Lunch=Šnicle
Lunch=Krumpir salata
Lunch=Riža

[2025-05-27]
Lunch=Piletina na lovački
Lunch=Pire krumpir
Lunch=Zelena salata

Each date gets its own section and all entries underneath it are displayed in order. Even better, if they have the same key, that is used as a common header. So, the “Lunch” entries above are all combined together.

Since files are only read when updating, I exposed them on a file share so everybody can put anything on “the wall” by simply editing a text file. Setup is definitelly something that is not going to fit many people. I would almost bet that it will fit only me. However, that is a beauty of being a developer. Often you get to scratch the itch only you have.


You can find my code on GitHub. If you want to test it yourself, docker image is probably the easiest way to do so.

Getting SkiaSharp Running Under Alpine Linux

While I am not using Alpine Linux for my desktop environment, I love it in containers. And C# pairs with it like a dream. Just compile it using linux-musl-x64 runtime and you’re golden.

But, ocassionally, I do have a situation where my application is running fine on Kubuntu while it just crashes on Alpiine Linux. This time, crashes were coming from SkiaSharp.

Unhandled exception. System.TypeInitializationException: The type initializer for 'SkiaSharp.SKImageInfo' threw an exception.
 ---> System.DllNotFoundException: Unable to load shared library 'libSkiaSharp' or one of its dependencies. In order to help diagnose loading problems, consider using a tool like strace. If you're using glibc, consider setting the LD_DEBUG environment variable:
Error loading shared library libfontconfig.so.1: No such file or directory (needed by /app/bin/libSkiaSharp.so)
Error loading shared library libSkiaSharp.so: No such file or directory
Error loading shared library /app/bin/liblibSkiaSharp.so: No such file or directory
Error loading shared library liblibSkiaSharp.so: No such file or directory
Error loading shared library /app/bin/libSkiaSharp: No such file or directory
Error loading shared library libSkiaSharp: No such file or directory
Error loading shared library /app/bin/liblibSkiaSharp: No such file or directory
Error loading shared library liblibSkiaSharp: No such file or directory

The first error is obvious: I was missing a fontconfig package. To install it, just do the standard APK stuff:

apk add fontconfig ttf-dejavu

And yes, I am not only installing fontconfig butw also ttf-dejavu. Alpine is so lightweigth that it comes without any fonts. I like DejaVu, so I decided to go with it. You can make your own font choices but don’t forget to install some if your application requires them.

But it took me a while to figure out rest of the issues since now I faced a bit more puzzling exception:

 ---> System.DllNotFoundException: Unable to load shared library 'libSkiaSharp' or one of its dependencies. In order to help diagnose loading problems, consider using a tool like strace. If you're using glibc, consider setting the LD_DEBUG environment variable:

No matter what I did, I kept getting one set of error or another. And issue seemed to stem from SkiaSharp having glibc dependencies. Since Alpine Linux uses completely different musl library, one of rare thing you cannot install is glibc.

At moment of desperation, I was even looking to compile it from source myself since that seemed to be something people had luck with. And then, on NuGet I noticed there is another package available: SkiaSharp.NativeAssets.Linux.NoDependencies. This package is a direct replacement for SkiaSharp.NativeAssets.Linux, the only difference being it includes its dependencies on libpthread, libdl, libm, libc, and ld-linux-x86-64. Essentially it includes all dependencies except for fontconfig that I already added to my docker image.

So, I added this dependency to my project and SkiaSharp happily worked ever after.