Replacing my Kobo's software stack in three days

My Kobo Nia took about 20 seconds to show me a book. It's a small Linux computer with one Cortex-A7 core, 250 MB of RAM and an E Ink panel, and it boots into a Qt app that also contains a store, a sync service and a web browser.

In October I spent three days replacing all of that with my own software, written in Rust: the boot daemon, the EPUB engine, the typesetter, the e-ink rendering, USB and power management. KOReader and NickelMenu already exist, and I didn't want either on the device, because the whole point was to build our own.

An AI coding agent did the implementation, 152 commits of it. I'll get to how that worked, because it went wrong in some interesting ways.

Two Kobo e-readers running Signet: the home screen and a book page with a highlighted passage

It now reaches the home screen in 6 to 7 seconds. Press the button if you want to sit through both.

Stock software~20 s

Signet6–7 s

Seconds from power button to home screen, in real time.

Signet

signet

French, /see-nyeh/

noun

The ribbon sewn into a book to keep your place.

It opens on the book you were reading, and that's most of the interface. The book in these screenshots is Make Time, my test book the whole way through. It's about avoiding distractions, so it seemed like a fair choice.

Signet's home screen: the current book, its progress and a Resume reading button
The home screen. Read straight from the framebuffer over SSH.

Also inside: highlights · a dictionary · bookmarks · search · clickable notes · night mode · two typefaces · exact page numbers.

6–7 s
power on to homewas ~20 s
~70 ms
to resume readingwas ~1.1 s
13 MB
of memory usedout of 250 MB
3 days
first probe to full report152 commits

Getting in

The firmware unpacks any KoboRoot.tgz it finds in a hidden folder on the USB partition, so that's how everything gets onto the device. A udev rule started my daemon at first. Later I added twelve lines to rcS so that Nickel, Kobo's app, doesn't start at all.

Before

Nickelstore, sync, browser, reader
rcSas shipped
Linux 4.1.15as shipped

After

Signet + signetdstatic Rust binaries
rcS+ a 12-line patch
Linux 4.1.15untouched
The kernel stays as it was. Nickel just never gets started.

The first thing I installed only read /proc, /sys and /etc and wrote a report. The specs I'd found online said 1 GHz Cortex-A9. The report said 900 MHz Cortex-A7.

The first real run was less tidy. A long press restarted Nickel, Nickel's startup ran udevadm trigger, that fired my udev rule again, and my daemon killed Nickel eight seconds later. Then it started over. Nickel also owns the USB connection, so I couldn't reach the disk from my Mac to fix it. I got out by plugging the cable in while Signet was running, long-pressing, and tapping Connect inside that eight-second window. The daemon now leaves a marker in /tmp and only runs once per boot.

Waiting for ink

I measured the panel before writing any UI, and I'm glad I did. Signet works out a page in 19 to 88 ms. GC16 then takes 620 ms to move the ink, and the size of the area barely changes that.

Layout
88 ms
GC16
620 ms
GC16: 620 ms, 16 grays, full quality. Benchmarked on the panel and replayed here. Tap the page to replay it.

So there's very little to gain from faster layout code. We tried anyway at one point, and a round of Knuth-Plass optimizations took a full book from 2,399 ms to 2,360. We left it there.

What you actually feel is whether the screen reacts when you touch it. Buttons invert in A2 on just their own rectangle, about 140 ms, before the real redraw. There's also a setting that thresholds text to pure black and white so pages can use DU. The letters get slightly rougher and each page turn is 40% shorter, which is why it's a setting and not the default.

Sleep

Sleep took four wrong theories. /sys/power/state returned an error, so at first it looked like the kernel refused. But dmesg showed a real suspend and resume cycle, which meant the return code couldn't be trusted on this kernel. Then it looked like an instant wakeup, then like an RTC alarm left behind by Nickel.

The actual line was Device mmc1:0001:1 failed to suspend: error -38. That's the Wi-Fi driver. Unload 8189fs first and the device slept 88.4 seconds on a 90-second alarm.

The page

A six-inch column fits about 42 characters per line, and justified text at that width is hard. Browsers fill each line and move on, which leaves holes whenever a long word doesn't fit. TeX has had a better answer since 1981: Knuth-Plass picks all the breaks in a paragraph together. Signet uses it, with French hyphenation and TrueType hinting through skrifa, all in Rust.

French paragraph set greedily: the second line has wide gaps between its words
Real renders from Signet, same text and same measure. Watch the second line.

Widest gap between words

Greedy
2.27 em
Signet
1.13 em

Word gaps wider than half an em

Greedy
18.4 %
Signet
10.4 %

Runs of three hyphenated lines

Greedy
70
Signet
0
Measured on a whole test book, medium text size, at the Kobo's column width.

The numbers were better, and the page still didn't look as clean as Apple Books after an evening of reading on it. Putting the same chapter through Apple's engine next to mine finally explained why, and it had little to do with line breaks. Our titles were 36 px where the book's stylesheet asked for 42. There were 150 px between a title and its subtitle where WebKit had 84, because we were adding vertical margins that CSS collapses.

Apple Books also ships its typographic rules as plain CSS in the app bundle, so I could read them.

p { widows: 2; orphans: 2; }
body { -webkit-hyphenate-limit-lines: 2; }

I took those two. I didn't take its three-letter minimum before a hyphen, because at this width it added 4.7 points of loose lines, and French typography allows two letters anyway. Signet allows them at forty times the normal penalty, so they're rare. I also ignored the book's gray quotes. On a panel with 16 grays, gray text is hard to read.

Twice, it wiped itself

The first factory reset was our fault. An early build kept its log file open on the FAT partition while my Mac had it mounted over USB. Two systems wrote to it, the firmware's disk check failed four times, and it reset itself to an older firmware with no books.

The second one happened the next morning.

Photo of the Kobo showing a warning triangle and a button with a circular arrow icon
  1. A hard power cut while the FAT partition is mounted read-write.
  2. The volume is left dirty.
  3. At boot, rcS runs dosfsck four times. All four fail.
  4. The firmware shows this screen, with one button.
  5. One tap: recovery boot, partition wiped.
The firmware's error screen.

I tapped it. It's the only button on the screen and the icon looks like "retry". I only found out what it does afterwards, in the boot script: pickel wait-for-hit, then write_uboot_env with RECOVERY, then reboot.

The partition now stays mounted read-only. Each write remounts it read-write, renames a temp file into place, syncs and remounts read-only, and the kernel clears the FAT dirty bit on that last step. To test it I ran reboot -n -f, which is about as rude as pulling the power. It came back in 41 seconds with no error screen and the reading position intact, and it hasn't reset since.

Working with the agent

The agent wrote the implementation, ran experiments on the device over SSH and read the logs. I set the architecture and the constraints, tested it by reading on it, and decided when something wasn't good enough. A lot of the work started with me saying something like this (translated from French):

No NickelMenu, no hybrid. We write our own thing.

Static Rust binaries from the boot daemon down to the framebuffer. The only outside pieces are generic ones like rustybuzz, skrifa and image decoders.

Did you push it as far as it goes? Did you measure?

We hadn't. That produced a bench that lays out the whole book. The loosest line went from a Knuth ratio of 43.3 to 9.7, and greedy fallbacks from 65 paragraphs to 0.

When I put it on the charger, it thinks it's plugged into a computer.

It now loads the USB gadget with no medium and watches the UDC state. A computer enumerates it within seconds. A charger stays at “powered”, and you keep reading.

Can't we make the dictionary better?

Looking closer turned up a real bug. Zip filenames were read as CP437, so words starting with é or â had no definition at all, including école and être.

A build took 10 to 25 seconds to cross-compile, 3 to deploy and 150 ms to restart, so these loops were short. One full reboot hit while I was in the middle of a chapter. After that, the agent had to check the logs for recent page turns before touching the device.

It got plenty wrong. It told me the Kobo had frozen when it was only reconnecting to Wi-Fi. It told me to hold the power button for ten seconds, which is a hard power cut and brought the error screen back. One of its remote taps was calculated for the wrong panel layout, hit "switch to reading mode" and turned off the Wi-Fi it was connected through. That button asks for confirmation now.

Separately, a review with no context from the build went through the typesetter and came back with twelve issues. The worst was an infinite loop in pagination when an image was exactly one page tall.

If you want to try it

The code is on GitHub, about 23,000 lines of Rust. It's built for the Nia and I've only tested it on mine. It also patches the boot script, so keep a copy of your books.