Skip to main content

My Cloud-free Self-hosted E-Ink Status Sign

·1592 words·8 mins

The Project #

I built a networked office status sign using a Seeed reTerminal E1002 — a 7.3" color e-ink display on an ESP32-S3 — and integrated it into my existing Home Assistant setup. The result is a self-hosted, cloud-free sign that shows whether I’m in the office, busy, remote, or out, all driven through a hardened mutual-TLS MQTT pipeline. Here’s how it works and how I put it together.

The finished sign showing “Greg is IN OFFICE”.


Working with color e-ink #

Color gives you more options than monochrome, but the trade off is that the Spectra 6 panel in this device takes about 20 seconds to do a full color refresh because it cycles the pigments through several passes. There’s no fast partial-refresh mode like monochrome panels offer. Every update is a full wipe.

For a status sign that changes a few times a day, it works well. You’re rarely watching it draw. But this reality was something I took into account when designing the software control. The sign refreshes only when something actually changes, and I never animate anything.

The palette is also fixed: six colors — black, white, red, yellow, blue, green.


Ditching the stock firmware #

The device ships with firmware that pulls a dashboard, but relies on a cloud service. I wanted local control, integrated with the Home Assistant and MQTT (Message Queuing Telemetry Transport) setup that I already run. Because the E1002 runs on ESP32, I reflashed it with ESPHome. ESPHome has a native driver for the E1002. This made controlling the e-ink display easy. I just had to write a C++ “lambda” that draws to the screen with primitives like it.print() and it.filled_rectangle().

To get the layout right, I went through a few iterations. The fiddly part was the status line: I draw an icon next to big status text, and I wanted the pair centered regardless of whether the text is short (“OUT”) or long (“IN OFFICE”). Pinning the text to a fixed x made short statuses look centered and long ones run off the edge. The fix was to measure the rendered text width with get_text_bounds and center the whole icon+text group. For example:

// Auto-centering the icon + status text
int text_width = 0;
int text_height = 0;
it.get_text_bounds(status_text, FONT_SIZE, &text_width, &text_height);

int icon_size = 48;
int spacing = 8;
int group_width = icon_size + spacing + text_width;
int x_start = (it.get_width() - group_width) / 2;

// Draw icon at x_start, then text after it
it.image(x_start, y_center - icon_size/2, &icon);
it.print(x_start + icon_size + spacing, y_center, &fonts, status_text);

Hardened MQTT pipeline #

Home Assistant can control ESP Home devices like this, is on my home network. I made this sign for my office at the firm. It connects to the Internet on an office network that I don’t control. I needed status changes to reach the sign, but I either could not or did not want to poke inbound holes into either network.

The answer: the sign connects outbound to an MQTT broker, and everything flows through that. To make the MQTT broker accessible to he sign over the internet, I had to make sure it was secured:

  • A dedicated edge broker on a Proxmox LXC, exposed to the internet on a single port.
  • Mutual TLS: the broker presents a Let’s Encrypt cert, and every client must present a certificate signed by my own private CA. No client cert, no connection. The broker drops the handshake before authentication even happens.
  • My existing home broker bridges to the edge broker over the private network (using tailscale), so Home Assistant talks to its local broker as usual and the bridge carries the relevant topics back and forth.
graph TD pwa(["iPhone PWA"]) ha["Home Assistant
(source of truth)"] main["Main MQTT broker
(Docker, mosquitto)"] edge["Edge broker
(Mosquitto, Proxmox LXC,
internet-exposed, mutual TLS)"] sign["reTerminal E1002
(ESPHome, office WiFi)"] pwa -->|"set status
(HTTPS via Tailscale)"| ha ha -->|"publish status/* (retained)"| main main -->|"status/* (out)"| edge edge -->|"sensors, button events (in)"| main edge -->|"status/* (deliver)"| sign sign -->|"sensors, button events"| edge subgraph bridge [" bridge: mTLS over private IP "] main edge end

The full path. Everything the sign does is outbound; nothing reaches into the office.

The “rejected at the handshake” property keeps things secure. Internet scanners hit the port constantly, and they all die at the TLS layer without a valid client cert. They never reach the auth stage or any underlying data.

The server cert is Let’s Encrypt, but the client certs come from my own private CA. Those are different jobs. Let’s Encrypt issues server certs for domains. Only I can decide who my legitimate clients are. The private CA exists solely to sign the sign’s cert and the bridge’s cert, and its key never leaves my network.


Home Assistant #

Home Assistant holds the source of truth for the sign. There is a dropdown helper for the status and a text helper for the message. When either changes, an automation publishes it (retained) to MQTT, the bridge carries it to the edge broker, and the sign redraws. Here is an example of the automation YAML.

alias: "Publish office sign status"
triggers:
  - trigger: state
    entity_id: input_select.greg_status
  - trigger: state
    entity_id: input_text.greg_status_message
actions:
  - action: mqtt.publish
    data:
      topic: status/greg
      payload: "{{ states('input_select.greg_status') }}"
      retain: true
  - action: mqtt.publish
    data:
      topic: status/greg/message
      payload: "{{ states('input_text.greg_status_message') }}"
      retain: true

The retained flag is important. Without it, the sign comes up blank every time it reconnects because there would be nothing stored to replay. To avoid that problem, every status publish uses retain: true.


A phone app for setting status #

Digging into Home Assistant every time I want to change status can be a bit much, so I built a tiny web app . I have an Express server that changes the helpers in Home Assistant through the API. The app is exposed on my tailnet, so only I can access it. The app reads the current status on open, lets me pick a new one and type a message, and sets both with one button. Tapping “Busy” auto-fills “In a meeting.” since that’s the usual reason.

It’s reachable only over Tailscale, so there’s no public exposure. The Home Assistant API token lives on the server side. My phone never holds it. “Add to Home Screen” makes it feel like a native app.

The phone remote. One tap to set status.

Installed as a PWA. No app store needed!


Physical buttons, too. #

The app is great, but half the time I’m getting up to close the door anyway, and the sign is right there. So I repurposed the two otherwise unused buttons on the device: one sets me to “Busy / On a call,” the other puts me back to “In Office.”

I wanted to make sure I had consistency for the sign’s status. Having the button just change what’s on screen would let the sign drift from Home Assistant. The next time anything synced, my button press would get silently overwritten. Home Assistant is the source of truth, so the button has to update that, not the display.

The device can’t just write the status topic directly (I deliberately gave it read-only access there, so it can’t corrupt its own status). So instead, a button press publishes an event to a separate topic. Home Assistant sees the event, updates the status helper, and the change flows back out to the sign through the normal path exactly as if I’d used the app. The sign always gets its status the same way. The button feeds into that instead of working against it:

# ESPHome button configuration
binary_sensor:
  - platform: gpio
    pin: GPIO5
    name: "Busy Button"
    on_press:
      - mqtt.publish:
          topic: status/greg/button
          payload: "busy"
          qos: 1
  - platform: gpio
    pin: GPIO4
    name: "In Office Button"
    on_press:
      - mqtt.publish:
          topic: status/greg/button
          payload: "in_office"
          qos: 1
# HA automation reacting to button events
alias: "Office sign button press"
trigger:
  - trigger: mqtt
    topic: status/greg/button
action:
  - choose:
      - conditions: "{{ trigger.payload == 'busy' }}"
        sequence:
          - action: input_select.select_option
            target:
              entity_id: input_select.greg_status
            data:
              option: "Busy"
          - action: input_text.set_value
            target:
              entity_id: input_text.greg_status_message
            data:
              value: "On a call"
      - conditions: "{{ trigger.payload == 'in_office' }}"
        sequence:
          - action: input_select.select_option
            target:
              entity_id: input_select.greg_status
            data:
              option: "In Office"
          - action: input_text.set_value
            target:
              entity_id: input_text.greg_status_message
            data:
              value: ""

Battery, sensors, and sleeping through the weekend #

The reTerminal has an onboard temperature/humidity sensor and a battery, so the sign also reports those back to Home Assistant over the same MQTT pipeline (the sensor data flows the opposite direction across the bridge from the sign to HA). The panel shows battery, temp, and humidity along with my status. Because the sensor data goes back to Home Assistant, I can (and do) monitor the battery status and send myself an alert when it is low.

Always-on, the battery lasts a couple of days. The e-ink display doesn’t require power once it is set, but the WiFi radio staying connected for instant updates takes a lot of power. So I made it time-aware: it deep-sleeps on a schedule: weeknights from 5pm to morning, and straight through the weekend (when I am not in the office). Before it sleeps, it throws up a farewell screen, which e-ink then holds for free with zero power while it’s asleep.

Done for the day. E-ink holds the image at zero power.


Was it worth it? #

Yes, it’s completely worth it. I know I could simply put a paper sign on the door to let people know I’m out or busy, but what fun would that be?


Built on: Seeed reTerminal E1002, ESPHome, Mosquitto, Home Assistant, Proxmox, Tailscale.