{"article":{"slug":"driving-the-gdeh0154d67-e-paper-display-with-rust","title":"Driving the GDEH0154D67 e-paper display with Rust","subtitle":null,"summary":"Hands-on Rust notes for driving a GDEH0154D67 e-paper panel—starting from a Watchy/ESP32 hack project, covering the display protocol, refresh quirks, and a working driver approach.","content_type":"tutorial","language":"en","canonical_url":"https://sgt.hootr.club/blog/driving-gdeh0154d67-with-rust/","author":{"name":"sgt.hootr.club","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"sgt.hootr.club","url":"https://sgt.hootr.club/","listing_slug":null,"listing":null},"topics":[{"name":"Rust","slug":"rust","url":"https://listedarticles.com/topics/rust"},{"name":"Hardware","slug":"hardware","url":"https://listedarticles.com/topics/hardware"},{"name":"Embedded","slug":"embedded","url":"https://listedarticles.com/topics/embedded"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Open Source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2066,"reading_minutes":9,"published_at":"2026-10-01T00:00:00.000Z","added_at":"2026-10-03T03:13:39.905Z","updated_at":"2026-10-03T03:13:39.905Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/driving-the-gdeh0154d67-e-paper-display-with-rust","markdown_url":"https://listedarticles.com/articles/driving-the-gdeh0154d67-e-paper-display-with-rust.md","example":false,"citation":"sgt.hootr.club, sgt.hootr.club. \"Driving the GDEH0154D67 e-paper display with Rust.\" 1 Oct 2026. https://sgt.hootr.club/blog/driving-gdeh0154d67-with-rust/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://sgt.hootr.club/blog/driving-gdeh0154d67-with-rust/"},"body_markdown":"# Driving the GDEH0154D67 e-paper display with Rust\n\nA while ago I bought a SQFMI Watchy. It's an open source e-paper watch driven by an ESP32 that I specifically bought to hack on: the firmware is open source and you can very easily create your own watchface by cloning the firmware and directly modifying the code! And I did create my watchface, which I now recognize looks kinda bad.\n\nI had just bought a coffee table book on arcade videogame typefaces and I ended up picking the typeface for Passing Shot, an arcade top-down tennis game from 1988, for the watch face. It looks a lot better with color, but I had spent a lot of time encoding those glyphs into 1s and 0s and damn if I wasn't gonna use it in my crappy watch face.\n\nI wanted to do a lot of things with this firmware: rewrite the menu, have it receive notifications from my phone via BLE, set configurable alarms... There was only one problem: the firmware is written in C++ and uses Arduino libraries. I found the documentation and quality of the code for those libraries lacking. I didn't understand how the firmware interacted with the display and the sensors. I couldn't use tagged unions. The solution? Say it with me: **Rewrite👏It👏In👏Rust!**\n\n## Rust on the ESP32\n\nAt the time of receiving my Watchy, Rust support for ESP32 microcontrollers was in an experimental stage and developing rapidly. At first I gave up the experiment because the HAL didn't even allow me to put the microcontroller in deep sleep, which is very important for a smartwatch!\n\nOnce that was added, I struggled with getting Espressif's forks of LLVM and rustc running on my NixOS desktop. At some point I had this very janky setup where I aliased gcc and cargo and rustc to tiny shell scripts that brought up Espressif's Docker container, running that one command and stopping right after. Eventually I managed to get this working on Nix by fetching the precompiled binaries; I still haven't figured out how to make an overlay with custom rustc and LLVM using nixpkgs.\n\nThroughout all this the esp-rs crates kept changing the API on each release (they hadn't reached a 1.x release yet) so every time I upgraded I had to take an hour or two to fix all the compilation failures and figure out where the new APIs were and ensure that all the dependencies were on the correct version. Riveting work!\n\nOn top of all that, recently `esp-hal` dropped support for ESP32 chips < v3.0, and my Watchy runs on a revision 1.0 chip. I managed to get around it by setting an environment variable in my `.cargo/config.toml` but it doesn't exactly inspire confidence.\n\nDespite all this I still wanted to get my Rust firmware running on the Watchy. The first step was to implement drivers for the external devices: the accelerometer, RTC, and e-paper display.\n\n## The display driver\n\nImplementing drivers was a much smoother experience: I wanted the firmware to be async through Embassy, so all I had to do in the driver crates was to pull `embedded_hal_async` as a dependency and write my code against its interfaces. There are crates published on crates.io for all of the devices, but they were either incomplete or not async, so I had to write my own versions. I'll focus on the display driver here.\n\nMy revision of the Watchy (2.0) uses the GDEH0154D67 e-paper display, driven by the SSD1681 display driver. I pored over the datasheets and GxEPD2's source code and eventually ended up with a working driver for the panel.\n\nI'd never touched a driver or a microcontroller before this so it took me many bursts of work spread over several years (with many moons passing between each burst) to figure everything out. Turns out embedded development is not so daunting as it looks! If you've worked with sockets and binary protocols before you'll find that communicating with this controller over SPI is basically the same: you write a byte corresponding to a command number, and then some data serialized according to what the datasheet tells you. The driver code itself is boring: it simply maps each SPI command described in the datasheet to a method and models data as type-safely as I could manage.\n\nWhen I started testing the driver I got some very weird results! I more or less followed Watchy's code, but I wanted to experiment on the code that updates the display, because I didn't understand it and I wanted to see if I could get it to run any faster with some tweaks. A full display update would get me an empty display, and partial updates looked corrupted, save for the first one after a full update. What's going on!?\n\n## Corrupted updates\n\nWhen you update the e-paper display you can choose between display mode 1 and 2:\n\n- Mode 1 is a full display update: the whole display flashes black and white and you get a clean and artifact-free image, though it takes a few seconds to update. E-paper devices generally do a full update when you boot them up or every once in a while to clean up the image.\n- Mode 2 is a \"partial\" update: the driver tries to only move the pixels that have changed since the last update, which is much faster than a full update. This works best when the frame hasn't changed much since the last update and usually produces artifacts that kind of look like the ink of the previous page bleeding into the next on real paper.\n\nThe SSD1681 display driver supports both monochrome black/white and 3-color black/white/red e-paper panels, like the ones you see on labels in some supermarkets. To support 3-color displays it exposes two 1-bit framebuffers: one for the black and white pixels and one for red, which I'll refer to here as *b/w RAM* and *red RAM*.\n\nThe GDEH0154D67 panel is monochrome, so it repurposes red RAM as a \"previous frame\" buffer for partial updates, while the b/w RAM contains the current frame. As I understand it, a partial update essentially diffs the current and previous frame buffers to produce the appropriate waveforms to transition the ink from one color to the next, so when you're driving the display you have to make sure that the previous frame buffer actually reflects the pixels that are currently on the display.\n\nUnderstanding that should be enough to drive the display. My `Display::draw` function roughly looked like this:\n\n1. Initialize the display\n2. Write a frame to b/w RAM\n3. Update the display \n  - For a full update, use mode 1\n  - For a partial update, use mode 2\n4. Write the same frame to red RAM for the next partial update\n\nThis... didn't have the effect I thought it would. A full update showed a stale frame from *before I had flashed the new firmare*, the first partial update looked clean, but subsequent updates would corrupt the image like in the photo above. Clearly I had something wrong.\n\n## Ping-pong\n\nWhile writing the driver I came across an option that the datasheet calls \"ping-pong\": basically when the option is on, a partial update will also swap the b/w and red RAM so that b/w ram points to the contents of red RAM and vice-versa. I thought this looked useful and wondered whether I could use it on this firmware; the datasheet mentioned that this option was disabled by default.\n\nIt took me a while to realize that this option is turned on in the OTP memory of the Watchy. It's kind of obvious in hindsight, but I had to draw a diagram and write some pseudocode to understand it correctly. Let me show you how it works in pseudo-C.\n\nSince b/w and red RAM are not stable anymore, let's call the two RAMs RAM0 and RAM1. After a software reset (which is part of the startup sequence of the display, according to the datasheet), b/w RAM points to RAM0 and red RAM points RAM1.\n\n```\ntypedef uint8_t ram[5000];\nstatic ram *bw_ram;\nstatic ram *red_ram;\nvoid software_reset() {\n    bw_ram = &RAM0;\n    red_ram = &RAM1;\n}\n```\nOn a full update I wrote to b/w RAM, but in hindsight this didn't make much sense: partial updates are driven by the previous frame being in red RAM, so when you're doing a full update you'd have to write to both RAMs to leave the red one in place for a partial update. It makes much more sense to drive full updates exclusively from red RAM, and indeed, this turned out to fix the issue with the stale frame I was seeing earlier.\n\n```\nvoid full_update() {\n    draw_on_panel(*red_ram);\n}\n```\nOn a partial update, as I mentioned before, b/w RAM is diffed with red RAM, which is expected to contain the previous frame. Like in subtraction, the order of the operands is important here. If ping-pong is enabled, the RAMs are also swapped.\n\n```\nvoid partial_update() {\n    draw_diff_on_panel(*bw_ram, *red_ram);\n    swap(&bw_ram, &red_ram);\n}\n```\nHence, the partial update sequence kind of looks like this:\n\n```\nuint8_t *F0 = prev_frame;\nuint8_t *F1 = current_frame;\n// We'll assume red RAM already contains the previous frame.\nassert(memcmp(*red_ram, F0, sizeof(ram)) == 0);\nwrite(bw_ram, F1);\npartial_update();\n// The pointers are now swapped, so bw_ram now points\n// to the previous frame and red_ram to the current.\nassert(memcmp(*bw_ram, F0, sizeof(ram)) == 0);\nassert(memcmp(*red_ram, F1, sizeof(ram)) == 0);\n```\nIf you're doing several partial updates in a row, ping-pong is useful because it saves you from writing to red RAM after you've written to b/w.\n\nOn a software reset though, the pointers are reset to their original values, meaning that a partial update will diff against a stale frame (unless we partially updated the display an even number of times before the reset).\n\n```\nsoftware_reset();\nassert(memcmp(*bw_ram, F1, sizeof(ram)) == 0);\nassert(memcmp(*red_ram, F0, sizeof(ram)) == 0);\n```\nThis explains the partial update corruption: after the first partial update, red RAM pointed to RAM0, and after a software reset it points back to RAM1, so I basically kept overwriting RAM0 while RAM1 was only written to during a full update.\n\n```\nwrite(bw_ram, F2);\npartial_update(); // draw_diff_on_panel(F2, F0);\n```\nThe fix turned out to be simple. I split the `Display::draw` function into two, with `Display::draw_full` only writing to red RAM:\n\n1. Initialize the display\n2. Write frame to red RAM\n3. Update the display using mode 1\n\n...and `Display::draw_partial` writing to b/w RAM twice:\n\n1. Initialize the display\n2. Write the frame to b/w RAM\n3. Update the display using mode 2\n4. Write the frame to b/w RAM (which used to be red RAM, and will be red RAM after a reset)\n\nThe second write ensures that both RAMs now contain the current frame, so we always get a valid transition both with and without a software reset. This be optimized away through some internal bookkeeping, or maybe I could avoid the software reset when I know the Watchy isn't coming from an \"unknown\" state. Perhaps I'll explore those options in the coming days.\n\n## Future plans\n\nNow that I finally have everything working I think I'll just implement the basics and enjoy wearing my epaper watch around, after it's been confined to a drawer for so much time. I expect you'll have some questions for me.\n\nMay I see the watchface?\n\n\nHere you go. It uses the Upheaval font, whose bytes I got from this repo, and it's running 100% pure Rust! (Except for the bootloader, I think, but I decided that it doesn't count.)\n\nMay I see the code?\n\n\nIt's yours, my friend, as long as you respect the terms of the license it comes with. It's still kind of messy so don't expect much outside of the drivers!\n\nIs this AI slop?\n\n\nNope. I used it a little bit to understand some things about EPDs but I wrote all the code, and all of this post, with my own hands and using my own brain.\n\nI'll take further questions through email or comment sections, if you find this post while browsing a site that has one of those. Thank you for reading!","body_html":"<h1 id=\"driving-the-gdeh0154d67-e-paper-display-with-rust\">Driving the GDEH0154D67 e-paper display with Rust</h1>\n<p>A while ago I bought a SQFMI Watchy. It&#39;s an open source e-paper watch driven by an ESP32 that I specifically bought to hack on: the firmware is open source and you can very easily create your own watchface by cloning the firmware and directly modifying the code! And I did create my watchface, which I now recognize looks kinda bad.</p>\n<p>I had just bought a coffee table book on arcade videogame typefaces and I ended up picking the typeface for Passing Shot, an arcade top-down tennis game from 1988, for the watch face. It looks a lot better with color, but I had spent a lot of time encoding those glyphs into 1s and 0s and damn if I wasn&#39;t gonna use it in my crappy watch face.</p>\n<p>I wanted to do a lot of things with this firmware: rewrite the menu, have it receive notifications from my phone via BLE, set configurable alarms... There was only one problem: the firmware is written in C++ and uses Arduino libraries. I found the documentation and quality of the code for those libraries lacking. I didn&#39;t understand how the firmware interacted with the display and the sensors. I couldn&#39;t use tagged unions. The solution? Say it with me: <strong>Rewrite👏It👏In👏Rust!</strong></p>\n<h2 id=\"rust-on-the-esp32\">Rust on the ESP32</h2>\n<p>At the time of receiving my Watchy, Rust support for ESP32 microcontrollers was in an experimental stage and developing rapidly. At first I gave up the experiment because the HAL didn&#39;t even allow me to put the microcontroller in deep sleep, which is very important for a smartwatch!</p>\n<p>Once that was added, I struggled with getting Espressif&#39;s forks of LLVM and rustc running on my NixOS desktop. At some point I had this very janky setup where I aliased gcc and cargo and rustc to tiny shell scripts that brought up Espressif&#39;s Docker container, running that one command and stopping right after. Eventually I managed to get this working on Nix by fetching the precompiled binaries; I still haven&#39;t figured out how to make an overlay with custom rustc and LLVM using nixpkgs.</p>\n<p>Throughout all this the esp-rs crates kept changing the API on each release (they hadn&#39;t reached a 1.x release yet) so every time I upgraded I had to take an hour or two to fix all the compilation failures and figure out where the new APIs were and ensure that all the dependencies were on the correct version. Riveting work!</p>\n<p>On top of all that, recently <code>esp-hal</code> dropped support for ESP32 chips &lt; v3.0, and my Watchy runs on a revision 1.0 chip. I managed to get around it by setting an environment variable in my <code>.cargo/config.toml</code> but it doesn&#39;t exactly inspire confidence.</p>\n<p>Despite all this I still wanted to get my Rust firmware running on the Watchy. The first step was to implement drivers for the external devices: the accelerometer, RTC, and e-paper display.</p>\n<h2 id=\"the-display-driver\">The display driver</h2>\n<p>Implementing drivers was a much smoother experience: I wanted the firmware to be async through Embassy, so all I had to do in the driver crates was to pull <code>embedded_hal_async</code> as a dependency and write my code against its interfaces. There are crates published on crates.io for all of the devices, but they were either incomplete or not async, so I had to write my own versions. I&#39;ll focus on the display driver here.</p>\n<p>My revision of the Watchy (2.0) uses the GDEH0154D67 e-paper display, driven by the SSD1681 display driver. I pored over the datasheets and GxEPD2&#39;s source code and eventually ended up with a working driver for the panel.</p>\n<p>I&#39;d never touched a driver or a microcontroller before this so it took me many bursts of work spread over several years (with many moons passing between each burst) to figure everything out. Turns out embedded development is not so daunting as it looks! If you&#39;ve worked with sockets and binary protocols before you&#39;ll find that communicating with this controller over SPI is basically the same: you write a byte corresponding to a command number, and then some data serialized according to what the datasheet tells you. The driver code itself is boring: it simply maps each SPI command described in the datasheet to a method and models data as type-safely as I could manage.</p>\n<p>When I started testing the driver I got some very weird results! I more or less followed Watchy&#39;s code, but I wanted to experiment on the code that updates the display, because I didn&#39;t understand it and I wanted to see if I could get it to run any faster with some tweaks. A full display update would get me an empty display, and partial updates looked corrupted, save for the first one after a full update. What&#39;s going on!?</p>\n<h2 id=\"corrupted-updates\">Corrupted updates</h2>\n<p>When you update the e-paper display you can choose between display mode 1 and 2:</p>\n<ul><li>Mode 1 is a full display update: the whole display flashes black and white and you get a clean and artifact-free image, though it takes a few seconds to update. E-paper devices generally do a full update when you boot them up or every once in a while to clean up the image.</li><li>Mode 2 is a &quot;partial&quot; update: the driver tries to only move the pixels that have changed since the last update, which is much faster than a full update. This works best when the frame hasn&#39;t changed much since the last update and usually produces artifacts that kind of look like the ink of the previous page bleeding into the next on real paper.</li></ul>\n<p>The SSD1681 display driver supports both monochrome black/white and 3-color black/white/red e-paper panels, like the ones you see on labels in some supermarkets. To support 3-color displays it exposes two 1-bit framebuffers: one for the black and white pixels and one for red, which I&#39;ll refer to here as <em>b/w RAM</em> and <em>red RAM</em>.</p>\n<p>The GDEH0154D67 panel is monochrome, so it repurposes red RAM as a &quot;previous frame&quot; buffer for partial updates, while the b/w RAM contains the current frame. As I understand it, a partial update essentially diffs the current and previous frame buffers to produce the appropriate waveforms to transition the ink from one color to the next, so when you&#39;re driving the display you have to make sure that the previous frame buffer actually reflects the pixels that are currently on the display.</p>\n<p>Understanding that should be enough to drive the display. My <code>Display::draw</code> function roughly looked like this:</p>\n<ol><li>Initialize the display</li><li>Write a frame to b/w RAM</li><li>Update the display <ul><li>For a full update, use mode 1</li><li>For a partial update, use mode 2</li></ul></li><li>Write the same frame to red RAM for the next partial update</li></ol>\n<p>This... didn&#39;t have the effect I thought it would. A full update showed a stale frame from <em>before I had flashed the new firmare</em>, the first partial update looked clean, but subsequent updates would corrupt the image like in the photo above. Clearly I had something wrong.</p>\n<h2 id=\"ping-pong\">Ping-pong</h2>\n<p>While writing the driver I came across an option that the datasheet calls &quot;ping-pong&quot;: basically when the option is on, a partial update will also swap the b/w and red RAM so that b/w ram points to the contents of red RAM and vice-versa. I thought this looked useful and wondered whether I could use it on this firmware; the datasheet mentioned that this option was disabled by default.</p>\n<p>It took me a while to realize that this option is turned on in the OTP memory of the Watchy. It&#39;s kind of obvious in hindsight, but I had to draw a diagram and write some pseudocode to understand it correctly. Let me show you how it works in pseudo-C.</p>\n<p>Since b/w and red RAM are not stable anymore, let&#39;s call the two RAMs RAM0 and RAM1. After a software reset (which is part of the startup sequence of the display, according to the datasheet), b/w RAM points to RAM0 and red RAM points RAM1.</p>\n<pre><code>typedef uint8_t ram[5000];\nstatic ram *bw_ram;\nstatic ram *red_ram;\nvoid software_reset() {\n    bw_ram = &amp;RAM0;\n    red_ram = &amp;RAM1;\n}</code></pre>\n<p>On a full update I wrote to b/w RAM, but in hindsight this didn&#39;t make much sense: partial updates are driven by the previous frame being in red RAM, so when you&#39;re doing a full update you&#39;d have to write to both RAMs to leave the red one in place for a partial update. It makes much more sense to drive full updates exclusively from red RAM, and indeed, this turned out to fix the issue with the stale frame I was seeing earlier.</p>\n<pre><code>void full_update() {\n    draw_on_panel(*red_ram);\n}</code></pre>\n<p>On a partial update, as I mentioned before, b/w RAM is diffed with red RAM, which is expected to contain the previous frame. Like in subtraction, the order of the operands is important here. If ping-pong is enabled, the RAMs are also swapped.</p>\n<pre><code>void partial_update() {\n    draw_diff_on_panel(*bw_ram, *red_ram);\n    swap(&amp;bw_ram, &amp;red_ram);\n}</code></pre>\n<p>Hence, the partial update sequence kind of looks like this:</p>\n<pre><code>uint8_t *F0 = prev_frame;\nuint8_t *F1 = current_frame;\n// We&#39;ll assume red RAM already contains the previous frame.\nassert(memcmp(*red_ram, F0, sizeof(ram)) == 0);\nwrite(bw_ram, F1);\npartial_update();\n// The pointers are now swapped, so bw_ram now points\n// to the previous frame and red_ram to the current.\nassert(memcmp(*bw_ram, F0, sizeof(ram)) == 0);\nassert(memcmp(*red_ram, F1, sizeof(ram)) == 0);</code></pre>\n<p>If you&#39;re doing several partial updates in a row, ping-pong is useful because it saves you from writing to red RAM after you&#39;ve written to b/w.</p>\n<p>On a software reset though, the pointers are reset to their original values, meaning that a partial update will diff against a stale frame (unless we partially updated the display an even number of times before the reset).</p>\n<pre><code>software_reset();\nassert(memcmp(*bw_ram, F1, sizeof(ram)) == 0);\nassert(memcmp(*red_ram, F0, sizeof(ram)) == 0);</code></pre>\n<p>This explains the partial update corruption: after the first partial update, red RAM pointed to RAM0, and after a software reset it points back to RAM1, so I basically kept overwriting RAM0 while RAM1 was only written to during a full update.</p>\n<pre><code>write(bw_ram, F2);\npartial_update(); // draw_diff_on_panel(F2, F0);</code></pre>\n<p>The fix turned out to be simple. I split the <code>Display::draw</code> function into two, with <code>Display::draw_full</code> only writing to red RAM:</p>\n<ol><li>Initialize the display</li><li>Write frame to red RAM</li><li>Update the display using mode 1</li></ol>\n<p>...and <code>Display::draw_partial</code> writing to b/w RAM twice:</p>\n<ol><li>Initialize the display</li><li>Write the frame to b/w RAM</li><li>Update the display using mode 2</li><li>Write the frame to b/w RAM (which used to be red RAM, and will be red RAM after a reset)</li></ol>\n<p>The second write ensures that both RAMs now contain the current frame, so we always get a valid transition both with and without a software reset. This be optimized away through some internal bookkeeping, or maybe I could avoid the software reset when I know the Watchy isn&#39;t coming from an &quot;unknown&quot; state. Perhaps I&#39;ll explore those options in the coming days.</p>\n<h2 id=\"future-plans\">Future plans</h2>\n<p>Now that I finally have everything working I think I&#39;ll just implement the basics and enjoy wearing my epaper watch around, after it&#39;s been confined to a drawer for so much time. I expect you&#39;ll have some questions for me.</p>\n<p>May I see the watchface?</p>\n<p>Here you go. It uses the Upheaval font, whose bytes I got from this repo, and it&#39;s running 100% pure Rust! (Except for the bootloader, I think, but I decided that it doesn&#39;t count.)</p>\n<p>May I see the code?</p>\n<p>It&#39;s yours, my friend, as long as you respect the terms of the license it comes with. It&#39;s still kind of messy so don&#39;t expect much outside of the drivers!</p>\n<p>Is this AI slop?</p>\n<p>Nope. I used it a little bit to understand some things about EPDs but I wrote all the code, and all of this post, with my own hands and using my own brain.</p>\n<p>I&#39;ll take further questions through email or comment sections, if you find this post while browsing a site that has one of those. Thank you for reading!</p>","headings":[{"level":1,"text":"Driving the GDEH0154D67 e-paper display with Rust","id":"driving-the-gdeh0154d67-e-paper-display-with-rust"},{"level":2,"text":"Rust on the ESP32","id":"rust-on-the-esp32"},{"level":2,"text":"The display driver","id":"the-display-driver"},{"level":2,"text":"Corrupted updates","id":"corrupted-updates"},{"level":2,"text":"Ping-pong","id":"ping-pong"},{"level":2,"text":"Future plans","id":"future-plans"}]}}