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.
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 (Python)
- 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
}| 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. |
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 wake. Positive = clockwise, negative = counter-clockwise. Usually non-zero only when wakeReason is rotate. |
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 a full-screen image, no header. Good for static image generators that can't easily prepend a header. See section 5.7.
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 | Image width in pixels, 1 … 800. |
| 4 | height | 2 | Image height in pixels, 1 … 480. |
| 6 | x | 2 | Where to place the image: left edge on the screen. |
| 8 | y | 2 | Where to place the image: top edge on the screen. |
| 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 the header. 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. |
For a full-screen image use x = 0, y = 0, width = 800 (or 720 if the
sidebar is on), height = 480. You can also send a smaller image placed at
x, y to update just part of the screen (see flags below).
5.2 flags
| Bit | Value | Name | Meaning |
|---|---|---|---|
| 0 | 0x0001 | FULL | Do a full refresh of the whole screen — 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) | Partial refresh — update only the width × height rectangle at x, y. Fast, but can leave faint ghosting over many updates (the device heals this periodically; 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 uses
ceil(width / 8)bytes. If the width isn't a multiple of 8, the spare bits at the end of each row are ignored. - Bit
1= white, bit0= black. payloadLenmust equalceil(width / 8) × height.payloadLenmust be at most48000(a full 800 × 480 screen).
So a full-screen image is ceil(800/8) × 480 = 100 × 480 = 48000 bytes
(or 90 × 480 = 43200 bytes when the sidebar is on and the width is 720).
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 validates the framed header and, if anything is wrong, shows an error banner and keeps sleeping on the last known interval:
magicis0x5A46width > 0andheight > 0x + width ≤ 800(or≤ 720when the sidebar is on)y + height ≤ 480payloadLen == ceil(width / 8) × heightpayloadLen ≤ 48000
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)Update only a 240×240 area at (240,120), sleep 60 s:
46 5A magic
F0 00 width = 240
F0 00 height = 240
F0 00 x = 240
78 00 y = 120
00 00 flags = 0 (partial)
3C 00 00 00 nextWake = 60
20 1C 00 00 payloadLen = 30 × 240 = 7200
C0 00 00 00 00 localTime = none
<7200 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 a full-screen image of exactly the right size: 48000 bytes (800 × 480) with the sidebar off, or 43200 bytes (720 × 480) with the sidebar on. Any other size is rejected.
- 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. You cannot send a partial update, a "no change", or a clock without the header — use the framed format for those. - It's still a
POST; a static server can simply ignore the request body and return the current image.
Set the Content-Length response header so the device can size-check before it
reads the image.
6. Sidebar, refresh, and sleep timing
Full vs. partial refresh. Set the FULL flag for a clean, ghost-free update
(the screen flashes briefly). Leave the flags empty for a fast partial update of
just your rectangle. 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 images must then fit the left
720 × 480 area — keep x + width ≤ 720. With "sidebar": false, you own the
full 800 × 480 screen and the device draws nothing of its own.
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. Either way the user can wake the device at any
time with the button or knob, regardless of the timer. All intervals are clamped to
5 … 86400 seconds.
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 (Python)
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, partial on button/rotate; 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. |
| "Bad frame / Invalid size or bounds" | payloadLen didn't equal ceil(width/8) × height, the image doesn't fit the screen, or x + width > 720 while the sidebar is on. |
| "Bad response / Frame truncated" | You sent fewer image bytes than payloadLen promised. |
| "Bad image / Send a 720x480 image" (or 800x480) | A raw (headerless) reply wasn't exactly the full-screen size for the current sidebar setting. |
| "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" }. - Each wake the device sends you:
{ "wakeReason", "delta", "telemetry":{ "battery","charging","units", temp, "humidity", pressure }, "mac" }. - You reply
200with either:- a raw full-screen image — 48000 bytes (800×480) or 43200 bytes (720×480 with
the sidebar on); bit
1= white, or - a framed reply — 25-byte header + image:
[magic 0x5A46 · u16][width · u16][height · u16][x · u16][y · u16][flags · u16][nextWake · u32][payloadLen · u32][localTime · 5 bytes]then the image. All numbers little-endian.
- a raw full-screen image — 48000 bytes (800×480) or 43200 bytes (720×480 with
the sidebar on); bit
- flags:
0x01= full refresh,0x02= no change; empty = partial. - payload:
payloadLen == ceil(width/8) × height,≤ 48000. Sidebar on ⇒ keepx + width ≤ 720. - localTime: 5-byte packed clock for the sidebar (section 5.4);
C0 00 00 00 00= leave the clock unchanged.