Self-Host Mode
Build your own server to drive a ZerryBit device.
Audience: anyone building their own server to drive a ZerryBit device. Status: current — no prior knowledge of the product internals is assumed. Firmware: v0.0.82.
Self-host mode turns the device into a thin client of your own HTTP server
instead of the standard cloud service. On every wake the device sends an HTTP
POST to your server (with its inputs and sensor readings) and your server replies
with a black-and-white image to show on the screen. Your reply also tells the
device how long to sleep before it wakes and asks again.
That's the whole contract. If you can run an HTTP server that returns an image, you can drive the device. This document is everything you need to build one.
Contents
- How it works
- Putting the device into self-host mode
- Setup: sending the configuration
- The request the device sends you
- The reply you send back (the image)
- Sidebar, refresh, and sleep timing
- HTTPS / TLS
- Complete example server
- Changing the config or leaving self-host mode
- Troubleshooting
- Quick reference
1. How it works
The device has a 800 × 480 pixel, black-and-white e-paper screen (the kind that holds an image with no power and updates with a brief flicker). It is battery-powered and spends almost all of its time asleep, waking on a timer, a button press, a knob turn, or when a charger is plugged in.
There are two phases:
| Phase | Who runs the HTTP server | What happens |
|---|---|---|
| Setup | The device runs a small server | The screen shows the device's own IP address. You send it a one-time JSON configuration (your server's URL, sleep interval, options). |
| Run | Your server | On every wake the device POSTs to your URL; you reply with an image. The device shows it and sleeps. Repeat. |
After setup the device fetches the first image immediately, then loops forever:
wake → POST to your server → receive image → display → sleep → (repeat)Security note
In self-host mode the device only ever downloads a bounded, validated black-and-white image — never firmware or executable code. A malformed reply is rejected and shown as an on-screen error; it cannot harm the device.
2. Putting the device into self-host mode
Self-host mode is selected on the device itself:
- During first-time setup, choose Self-Host when asked how you'll use the device, or
- Later, from Settings — wake the device with a button press, then double-click the button to open Settings, and choose Enter self-host mode.
The device restarts into self-host mode. Because it has no configuration yet, it goes straight into the setup phase (section 3).
Wi-Fi. The device joins your existing Wi-Fi network as a normal client, using the Wi-Fi credentials you entered during setup. Self-host mode does not create its own hotspot. If no Wi-Fi is configured yet, set it up first (first-time setup or Settings → Wi-Fi).
3. Setup: sending the configuration
While the device is in self-host mode but not yet configured, it connects to Wi-Fi and shows a Self-Host Setup screen displaying its IP address. It runs a small HTTP server on port 80 for 10 minutes. If nothing arrives it sleeps and shows "press the button to start setup again" (a button press restarts the window).
You send the configuration once, with any HTTP client (Postman, curl, a script…).
Endpoint
POST http://<device-ip>/config
Content-Type: application/jsonGET http://<device-ip>/config (or /status) returns the currently stored config,
or 404 {"configured":false} if none is saved yet.
Configuration fields
{
"url": "http://192.168.1.50:8080/screen",
"sleepSec": 900,
"sidebar": true,
"fullRefreshFrequency": 10,
"imperialUnitsEnabled": false,
"tlsInsecure": false,
"rotationLatency": 180
}| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
url | string | yes | — | The address the device POSTs to on every wake. Must start with http:// or https://. Up to 255 characters. |
sleepSec | number | no | 900 | Fallback sleep time in seconds between wakes. Used when a reply doesn't specify its own next-wake time, or when a request fails. Clamped to 5 … 86400 (5 s … 24 h). |
sidebar | boolean | no | false | true = the device draws its own 80-pixel status column on the right (clock, battery, temperature, humidity). Your image area then shrinks to 720 wide. See section 6. |
fullRefreshFrequency | number | no | 10 | How often to do a full-screen "clean" refresh to prevent ghosting: a full refresh happens at least every N-th update. Clamped to 1 … 10. 1 = every update is a full refresh. See section 6. |
imperialUnitsEnabled | boolean | no | false | true = the sidebar shows temperature in °F, and the sensor readings in the request (section 4) are sent in imperial units (°F, inHg). false = Celsius / metric. |
tlsInsecure | boolean | no | false | Only for https:// URLs: true skips certificate checking so you can use a self-signed certificate. See section 7. |
rotationLatency | number | no | 180 | When the user turns the knob to page through content (section 6), the device waits this many milliseconds after the last detent before it fetches — so a fast spin becomes one request instead of many. Clamped to 0 … 2000. 0 = fetch on every detent. |
JSON must be strict:
- Every key and every string value in double quotes (
"sidebar": true, notsidebar: true). - Keys are case-sensitive —
sidebar, notsideBar. - No trailing commas. Body up to 1024 bytes.
Responses
| Status | Body | Meaning |
|---|---|---|
200 OK | the stored config + "configured":true | Accepted. The device shows "Configured" and immediately fetches the first image. |
400 Bad Request | {"error":"…"} | Invalid JSON, missing/too-long url, or sleepSec out of range. Nothing was saved. |
500 | {"error":"…"} | The device could not save the config to its storage. |
Example
curl -X POST http://192.168.1.42/config \
-H "Content-Type: application/json" \
-d '{"url":"http://192.168.1.50:8080/screen","sleepSec":900,"sidebar":true}'4. The request the device sends you
On every wake the device sends this to your configured url:
POST <url>
Content-Type: application/jsonMetric (default):
{
"wakeReason": "timer",
"delta": 0,
"telemetry": {
"battery": 0,
"charging": false,
"units": "metric",
"tempC": 21.7,
"humidity": 43.5,
"pressureHpa": 1012.3
},
"mac": "AA:BB:CC:DD:EE:FF"
}Imperial (when you set "imperialUnitsEnabled": true): only the units change —
tempC→tempF, pressureHpa→pressureInhg, and units becomes "imperial":
"telemetry": {
"battery": 0,
"charging": false,
"units": "imperial",
"tempF": 71.1,
"humidity": 43.5,
"pressureInhg": 29.89
}| Field | Type | Description |
|---|---|---|
wakeReason | string | Why the device woke — see the table below. |
delta | integer | How far the knob was turned since the last request. Positive = clockwise, negative = counter-clockwise. Non-zero when wakeReason is rotate — use it to page through content (see section 6). |
telemetry.battery | integer | Battery level band: 0 = full, 1 = medium, 2 = low, 3 = critical. |
telemetry.charging | boolean | true while a charger is plugged in. |
telemetry.units | string | "metric" or "imperial" — tells you which unit fields follow. |
telemetry.tempC / tempF | number | Ambient temperature from the built-in sensor, in °C (metric) or °F (imperial). |
telemetry.humidity | number | Relative humidity, in percent (both unit systems). |
telemetry.pressureHpa / pressureInhg | number | Air pressure, in hPa (metric) or inHg (imperial). |
mac | string | The device's unique hardware address, e.g. "AA:BB:CC:DD:EE:FF". Use it to tell your devices apart. |
wakeReason values
| Value | Trigger |
|---|---|
timer | The sleep interval elapsed — the normal periodic refresh. |
button | The user pressed the knob button. |
rotate | The user turned the knob — see delta. |
charger | A charger was plugged in (or unplugged). |
boot | Cold start / the first fetch right after setup. Treat it like timer. |
Your server can be completely stateless as far as the device is concerned: decide
what to show from this request alone, plus any state you keep on your side keyed by
mac.
5. The reply you send back (the image)
Reply with HTTP 200 and a binary body. There are two accepted formats:
- Framed (recommended) — a small header followed by the image. The header lets you control the next-wake time, the refresh type, and the on-screen clock.
- Raw (simplest) — just the image, no header. Good for static image generators that can't easily prepend a header. See section 5.7.
Full-screen images only
The image must always be full-screen and exactly the right size for your sidebar setting: 800 × 480 (48000 bytes) with the sidebar off, or 720 × 480 (43200 bytes) with the sidebar on. Any other size is rejected with a "Wrong image size" banner — this applies to both formats. Partial / sub-region updates are not supported.
Any non-200 status, or a body that's shorter than promised, is treated as an
error (the device shows a banner and sleeps). Content-Type isn't checked;
application/octet-stream is a fine choice.
5.1 Framed header (25 bytes)
The body is:
[ 25-byte header ][ image data ]All multi-byte numbers are little-endian (least-significant byte first).
| Offset | Field | Size | Description |
|---|---|---|---|
| 0 | magic | 2 | Must be 0x5A46 (on the wire: bytes 0x46 0x5A). Marks a framed reply. |
| 2 | width | 2 | Must be 800 (or 720 when the sidebar is on). |
| 4 | height | 2 | Must be 480. |
| 6 | x | 2 | Must be 0 (full-screen only). |
| 8 | y | 2 | Must be 0. |
| 10 | flags | 2 | See below. |
| 12 | nextWake | 4 | Seconds to sleep before the next timer wake. 0 = use the config's sleepSec. Clamped to 5 … 86400. |
| 16 | payloadLen | 4 | Number of image bytes that follow — must be 48000 (or 43200 with the sidebar on). 0 when using the NOOP flag. |
| 20 | localTime | 5 | The clock to show on the sidebar. See section 5.4. Send C0 00 00 00 00 to leave the clock unchanged. |
The width/height/x/y fields are fixed to the full-screen values above — the device rejects anything else. They exist in the header only so the format is self-describing; you always send the same full-screen geometry.
5.2 flags
| Bit | Value | Name | Meaning |
|---|---|---|---|
| 0 | 0x0001 | FULL | Do a full refresh — clean, no ghosting, but flickers for about a second. |
| 1 | 0x0002 | NOOP | Don't redraw anything. Set payloadLen to 0 and send no image data. Only nextWake and the clock are applied. Use it to just reschedule the next wake. |
| — | 0x0000 | (none) | Fast refresh — quicker and doesn't flicker, but can leave faint ghosting of the previous image. The device auto-promotes to a full refresh every fullRefreshFrequency updates to clear it (see section 6). |
5.3 Image data format
- 1 bit per pixel (black or white), packed most-significant-bit first,
row by row. Each row is
width / 8bytes (100 for 800 px, 90 for 720 px). - Bit
1= white, bit0= black. - The image is always full-screen, so it's exactly:
- 48000 bytes =
100 bytes/row × 480 rows(800 × 480, sidebar off), or - 43200 bytes =
90 bytes/row × 480 rows(720 × 480, sidebar on).
- 48000 bytes =
payloadLen must equal that byte count. Anything else is rejected.
5.4 The sidebar clock (localTime)
The device has no clock or timezone of its own in self-host mode. If you enable
the sidebar (section 6), the time it shows comes entirely from the localTime
field of each framed reply — you decide what time to display, having already
applied the timezone, daylight-saving, and 12h/24h choice on your server.
localTime is 5 bytes holding a 40-bit value, sent most-significant byte
first. The value packs these fields:
| Bits | Field | Range / meaning |
|---|---|---|
| 39–38 | mode | 0 = 24-hour, 1 = AM, 2 = PM, 3 = none (leave the clock unchanged) |
| 37–33 | hour | 0–23 in 24-hour mode, 1–12 in AM/PM mode |
| 32–27 | minute | 0–59 |
| 26–21 | second | 0–59 |
| 20–9 | year | e.g. 2026 |
| 8–5 | month | 1–12 |
| 4–0 | day | 1–31 |
To build it:
value = (mode << 38) | (hour << 33) | (minute << 27) | (second << 21)
| (year << 9) | (month << 5) | day
5 bytes, most-significant first:
byte0 = (value >> 32) & 0xFF … byte4 = value & 0xFF- To show 13:45 on 6 July 2026 in 24-hour mode,
mode=0, hour=13, minute=45. - For 1:45 PM, use
mode=2(PM) andhour=1(12-hour clock). - To not touch the clock (e.g. you don't use the sidebar), send
mode=3— the bytesC0 00 00 00 00.
The clock only appears when the sidebar is on; with the sidebar off, localTime is
ignored. There's a ready-made packer in the example server (section 8).
5.5 Why a reply gets rejected
Before drawing, the device checks the framed header and, if anything is wrong, shows
a "Wrong image size" banner and keeps sleeping on the last known interval. A framed
reply is accepted only when all of these hold (with the sidebar off; use 720 /
43200 instead when it's on):
magic=0x5A46width=800,height=480x=0,y=0payloadLen=48000
(A NOOP frame is exempt — it carries no image.)
5.6 Byte-layout examples
Full-screen, full refresh, sleep 15 minutes, clock = 14:30 on 2026-07-06 (24h):
46 5A magic = 0x5A46
20 03 width = 800
E0 01 height = 480
00 00 x = 0
00 00 y = 0
01 00 flags = FULL
84 03 00 00 nextWake = 900
80 BB 00 00 payloadLen = 48000
1C F0 0F D4 E6 localTime = 2026-07-06 14:30:00, 24-hour
<48000 bytes of image data>"No change, ask again in 5 minutes", clock left untouched:
46 5A magic
00 00 width = 0 (ignored for NOOP)
00 00 height = 0
00 00 x
00 00 y
02 00 flags = NOOP
2C 01 00 00 nextWake = 300
00 00 00 00 payloadLen = 0
C0 00 00 00 00 localTime = none (leave the clock)Fast refresh (no flags), sleep 60 s, clock left untouched:
46 5A magic
20 03 width = 800
E0 01 height = 480
00 00 x = 0
00 00 y = 0
00 00 flags = 0 (fast refresh, no flicker)
3C 00 00 00 nextWake = 60
80 BB 00 00 payloadLen = 48000
C0 00 00 00 00 localTime = none
<48000 bytes of image data>5.7 Raw (headerless) full-screen image
If prepending the header is awkward — for example a static image generator that
just serves a bitmap — you can return a raw image with no header at all. When
the reply does not start with the 0x5A46 magic, the device treats the whole
body as a full-screen image:
- It must be exactly the full-screen size: 48000 bytes (800 × 480) with the sidebar off, or 43200 bytes (720 × 480) with the sidebar on.
- You must send a
Content-Lengthheader, and it must match that exact size. The device size-checks it up front and rejects a wrong length — or a missing one it can't verify — with "Wrong image size". (Chunked responses have noContent-Length, so serve the file with a normal length.) - Same pixel format as section 5.3 (1 bit per pixel, MSB-first, bit
1= white). - The sleep interval is the config's
sleepSec(there's no per-image next-wake), and full-refresh healing followsfullRefreshFrequency. To set the sidebar clock, send a "no change", or otherwise use header features, use the framed format. - It's still a
POST; a static server can simply ignore the request body and return the current image.
6. Sidebar, refresh, and sleep timing
Full vs. fast refresh. Set the FULL flag for a clean, ghost-free update (the
screen flashes briefly). Leave the flags empty for a fast refresh that doesn't
flicker but can leave faint ghosting of the previous image. To keep the screen
crisp, the device also promotes to a full refresh automatically every
fullRefreshFrequency updates — so you don't have to manage ghosting yourself.
Default is 10; set it to 1 if you'd rather every update be a full refresh
(cleanest, but flashes each time). This automatic healing applies to raw images
(section 5.7) too.
Sidebar. With "sidebar": true, the device draws its own 80-pixel status column
on the right side (screen columns 720–799): the clock (from localTime), battery,
last-update time, temperature and humidity. Your image must then be 720 × 480
(it fills the left area next to the sidebar). With "sidebar": false, your image is
the full 800 × 480 and the device draws nothing of its own.
The sidebar also shows a small sleep indicator: a sleep icon appears when the
device drops back to sleep and is absent while it's awake. With the sidebar on, this
is the easiest way to tell at a glance whether the device is awake (accepting
knob and button input) or asleep (waiting for the next timer wake).
Sleep timing. Each framed reply's nextWake sets how many seconds the device
sleeps before the next timer wake; 0 falls back to the configured sleepSec.
Raw replies always use sleepSec. All intervals are clamped to 5 … 86400 seconds.
Waking and browsing. The user can wake the device at any time with the button or a knob turn, regardless of the timer. After a manual wake the device stays awake for about 10 seconds, watching for more input, before it goes back to sleep. During that window:
- Turning the knob or pressing the button sends you a fresh request — with
wakeReasonrotateorbuttonand the knobdelta— and shows your reply right away. So the user can page through content by turning the knob: pick the next screen from thedeltaand reply with it. - A rapid spin is coalesced into one request. The device waits
rotationLatency(default 180 ms) after the last detent before it fetches, so a fast multi-detent turn becomes a single request carrying the summeddelta— not one request per detent. Set it to0to fetch on every detent, or higher (up to 2000 ms) to batch more. - Fast while browsing, clean at rest. Refreshes while browsing honour your flag — use fast (no-flag) replies for snappy paging — and the device does one clean full refresh of the final screen just before it sleeps, so you get quick partial updates while browsing and a crisp, ghost-free image at rest.
Any input resets the 10-second window; when it expires with no input, the device sleeps until the next wake. (With the sidebar on, the sleep icon above shows when it has gone back to sleep.)
7. HTTPS / TLS
url scheme | tlsInsecure | Behaviour |
|---|---|---|
http:// | (ignored) | Plain HTTP. Fine on a trusted home/office network. |
https:// | false (default) | Encrypted, with normal certificate verification. Works with a publicly-trusted certificate; a self-signed one is rejected. |
https:// | true | Encrypted, but certificate verification is off — allows a self-signed certificate. There's no server authentication, so only use it on a network you trust. |
For a typical home network, plain http://, or https:// with
"tlsInsecure": true, is the easy path.
8. Complete example server
A small but complete server using Flask + Pillow. It draws an 800 × 480 image,
converts it to the required format, adds the header (including the sidebar clock),
and returns it. Install with pip install flask pillow.
import struct
from datetime import datetime
from flask import Flask, request, Response
from PIL import Image, ImageDraw
app = Flask(__name__)
MAGIC = 0x5A46
FLAG_FULL = 0x01
FLAG_NOOP = 0x02
def pack_local_time(dt, twelve_hour=False):
"""5-byte sidebar clock. `dt` must already be in the timezone you want shown."""
if twelve_hour:
mode = 1 if dt.hour < 12 else 2 # 1 = AM, 2 = PM
hour = ((dt.hour + 11) % 12) + 1 # 1..12
else:
mode, hour = 0, dt.hour # 24-hour
v = ((mode & 0x3) << 38) | ((hour & 0x1F) << 33) | ((dt.minute & 0x3F) << 27) \
| ((dt.second & 0x3F) << 21) | ((dt.year & 0xFFF) << 9) \
| ((dt.month & 0xF) << 5) | (dt.day & 0x1F)
return v.to_bytes(5, 'big') # most-significant byte first
NO_CLOCK = bytes([0xC0, 0, 0, 0, 0]) # mode 3 = leave the clock alone
def header(width, height, x, y, flags, next_wake, payload_len, local_time):
# little-endian: magic,w,h,x,y,flags (uint16) + nextWake,payloadLen (uint32) + 5-byte clock
return struct.pack('<HHHHHHII', MAGIC, width, height, x, y,
flags, next_wake, payload_len) + local_time
def to_1bpp(img):
"""PIL image -> 1 bit/pixel, MSB-first, row-major. Bit 1 = white, 0 = black."""
img = img.convert('1') # 0 = black, 255 = white
w, h = img.size
row_bytes = (w + 7) // 8
out = bytearray(row_bytes * h)
px = img.load()
for yy in range(h):
base = yy * row_bytes
for xx in range(w):
if px[xx, yy]: # white -> set bit
out[base + (xx >> 3)] |= (0x80 >> (xx & 7))
return bytes(out)
def render(event):
"""Draw the screen. Use 800x480, or 720x480 if you enabled the sidebar."""
img = Image.new('1', (800, 480), 1) # white background
d = ImageDraw.Draw(img)
d.rectangle([10, 10, 789, 469], outline=0)
d.text((40, 40), f"wake: {event['wakeReason']} delta: {event['delta']}", fill=0)
t = event['telemetry']
d.text((40, 80), f"{t['tempC']:.1f} C {t['humidity']:.0f}% batt {t['battery']}", fill=0)
return img
@app.post('/screen')
def screen():
event = request.get_json(force=True)
print("event:", event)
img = render(event)
w, h = img.size
bitmap = to_1bpp(img)
# Full refresh on cold boot / timer, fast refresh otherwise; sleep 15 min.
flags = FLAG_FULL if event['wakeReason'] in ('boot', 'timer') else 0
clock = pack_local_time(datetime.now()) # <-- your local time, in your timezone
body = header(w, h, 0, 0, flags, 900, len(bitmap), clock) + bitmap
return Response(body, mimetype='application/octet-stream')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)"No change, check again in 5 minutes" (leaves the screen and clock as-is):
return Response(header(0, 0, 0, 0, FLAG_NOOP, 300, 0, NO_CLOCK),
mimetype='application/octet-stream')Any HTTP framework works — the only requirements are: read the JSON request, and
reply 200 with header + image (or a bare full-screen image, section 5.7). Build
the header with a little-endian packer equivalent to the struct.pack('<HHHHHHII', …)
above, followed by the 5-byte clock.
9. Changing the config or leaving self-host mode
Open Settings on the device: wake the device with a button press, then double-click the button. (The device stays awake for about 10 seconds after a wake, watching for the double-click.) From Settings you can:
- Reconfigure the server — the device re-opens the setup server (section 3) so
you can
POST /configagain with new values. - Return to normal (cloud) mode.
- Factory-reset — clears Wi-Fi, the mode, and the self-host config, and restarts first-time setup.
If the device is showing an error and won't respond, wait for it to sleep, then wake it and double-click again — the double-click is watched for on every wake.
10. Troubleshooting
| What you see on the device | Likely cause |
|---|---|
POST /config → 400 "invalid JSON" | Unquoted key/value or a trailing comma. Use strict JSON — quote every key ("sidebar", not sidebar). |
| Config accepted but an option is ignored | Wrong key spelling/casing (sideBar vs sidebar). Keys are case-sensitive. |
| "Server unreachable / Check your endpoint" | The device couldn't reach your url (wrong host or port, server down, firewall), or your server returned a status other than 200. |
| "Bad response / No frame header" | The connection opened but the reply was shorter than a full header, or the connection dropped early. |
| "Wrong image size / Send a 720x480 image" (or 800x480) | The image wasn't exactly the full-screen size for the current sidebar setting. For a framed reply, check width/height/x/y/payloadLen (section 5.5). For a raw reply, the Content-Length must exactly match 43200 / 48000 and must be present. |
| "Bad response / Frame truncated" | You sent fewer image bytes than payloadLen (or the Content-Length) promised. |
| "Wi-Fi failed / Check your network" | The device couldn't join Wi-Fi. Re-check the Wi-Fi credentials on the device. |
| The image looks inverted (black↔white) | Bit polarity: on the device bit 1 = white, 0 = black. |
| The image ghosts or smears over time | Send FULL refreshes more often, or lower fullRefreshFrequency (e.g. 1 = every update is a full refresh). |
The sidebar clock is wrong or shows --:-- | The clock comes only from localTime in each framed reply — send the correct local time (section 5.4). A raw reply can't set the clock. |
11. Quick reference
- Configure the device:
POST http://<device-ip>/config(port 80, 10-minute window). Body:{ "url", "sleepSec"(5–86400), "sidebar", "fullRefreshFrequency"(1–10), "imperialUnitsEnabled", "tlsInsecure", "rotationLatency"(0–2000) }. - Browsing: after a button / knob wake the device stays awake ~10 s; turning
the knob or pressing the button sends a fresh request (carrying
delta) so the user can page through content, then it sleeps. With the sidebar on, a sleep icon shows when it's asleep. - Each wake the device sends you:
{ "wakeReason", "delta", "telemetry":{ "battery","charging","units", temp, "humidity", pressure }, "mac" }. - The image is always full-screen — exactly 48000 bytes (800×480) or 43200
bytes (720×480 with the sidebar on); bit
1= white. Any other size is rejected ("Wrong image size"). - You reply
200with either:- a raw image — just those bytes, with a matching
Content-Length, or - a framed reply — 25-byte header + image:
[magic 0x5A46 · u16][width=800/720 · u16][height=480 · u16][x=0 · u16][y=0 · u16][flags · u16][nextWake · u32][payloadLen=48000/43200 · u32][localTime · 5 bytes]then the image. All numbers little-endian.
- a raw image — just those bytes, with a matching
- flags:
0x01= full refresh,0x02= no change; empty = fast refresh. - localTime: 5-byte packed clock for the sidebar (section 5.4);
C0 00 00 00 00= leave the clock unchanged.