Show web pages and images on Visionect e-ink displays, such as the Joan 6, without the Visionect Software Suite.
inkcast is a small self-hosted server. Your displays connect to it; it renders the page you choose (a MagicMirror² dashboard, a Home Assistant view, any URL) with headless Chromium, or takes an image, and pushes it to the screen. One process, one TOML file: no database, no Redis, no vendor account.
The display protocol is implemented by pyvisionect, a reverse-engineered implementation of Visionect "protocol v3".
- Web pages or images per display: a URL is rendered in Chromium at the panel's
size; a PNG/JPEG (by URL or
file://) is fitted to it. - Built for e-ink: 16 grey levels with Floyd–Steinberg dithering, rotation for portrait or upside-down mounting, and no screen refresh when nothing changed.
- Battery friendly: sends the display to sleep after each update, and never keeps it awake because a page failed to load.
- Touch panel on/off per display.
- Status page and JSON API: battery, signal, temperature, last and next contact, and a preview of what each display shows. Light and dark themes, no external resources.
- Home Assistant sensors through the built-in RESTful integration (examples below).
Displays built on Visionect's platform that speak protocol v3 and have a single panel. Tested with:
| Display | Firmware | Status |
|---|---|---|
| Joan 6 (1024×758) | 7.3.4358 | Works: image push, sleep, status, touch mode |
Other single-panel Visionect displays are likely to work: inkcast takes the panel size from what the display reports. Reports for other models are welcome.
- Python 3.13+ and uv, or Docker/Podman.
- The display on the same local network, set up once as described below.
git clone <this repository> inkcast && cd inkcast
cp config.example.toml config.toml # optionally set default_url
uv sync
uv run playwright install chromium
uv run inkcast config.toml # add -v for debug logsOr in a container, with the published image (ghcr.io/kijart/inkcast or kijart/inkcast on Docker Hub):
cp config.example.toml config.toml
docker compose up -d # or: podman compose up -dlatest is the last commit on master; x.y.z is a release. To build the image
locally instead, add --build.
When a display connects, its UUID appears in the log:
2026-10-18T22:30:00.123Z INFO inkcast.server: 00112233-4455-6677-8899-gghhiijjkkll connected (...)Use it in config.toml to give that display its own settings.
Each display has to point to the machine running inkcast, on port 11113, with outbound encryption disabled. Visionect's link encryption relies on keys held by a Visionect service, so a self-hosted server cannot use it.
-
With the Joan app or the Visionect Configurator, set the server address to the IP of the machine running inkcast and turn encryption off.
-
Or over the USB serial console:
server_tcp_set <inkcast-ip> 11113 encryption_mode_set 0 flash_save
config.toml (see config.example.toml):
default_url = "http://example.com" # for displays not listed below
refresh_seconds = 300
status_port = 8000
[devices."00112233-4455-6677-8899-gghhiijjkkll"]
name = "Kitchen"
url = "http://example.com/kitchen"
refresh_seconds = 3600
rotate = 180
sleep_minutes = 60| Option | Default | Description |
|---|---|---|
default_url |
"about:device" |
What displays without their own url show. about:device is a built-in page with the display's own data. |
host |
"0.0.0.0" |
Address to listen on. |
port |
11113 |
Port displays connect to (the Visionect default). |
refresh_seconds |
300 |
How often connected displays are re-rendered. The image is only sent if it changed. Each display can override it. |
settle_seconds |
2.0 |
Wait after a page loads, so client-side widgets can draw. |
status_port |
off | Port of the status page and JSON API. |
about:device shows the display's name, UUID, battery, signal quality, temperature, humidity,
firmware, wake interval, refresh interval, touch mode, resolution and rotation. It has no clock and rounds the
values, so the display is only redrawn when something actually changes.
Per display, under [devices."<uuid>"]:
| Option | Default | Description |
|---|---|---|
name |
"" |
Shown on the status page and in the API. |
url |
default_url |
Web page, image URL, file:///path/to/image.png, or about:device. |
fit |
"contain" |
Images only: contain (white bands), cover (crop) or stretch. |
refresh_seconds |
global refresh_seconds |
How often this display is re-rendered while connected. With sleep_minutes, it is updated when it wakes up instead. |
rotate |
0 |
0, 90, 180 or 270. 90/270 render the page in portrait. |
sleep_minutes |
0 |
Minutes the display sleeps after each update. 0 lets it follow its own schedule. |
touch |
false |
Turns the touch panel on. Saved on the display. |
The display can only be updated while it is awake, and nothing can wake it over
the network. For a battery display, sleep_minutes is what matters most:
| Use | sleep_minutes |
refresh_seconds |
|---|---|---|
| Plugged in, quick updates | 0 |
30–60 |
| On battery, lasting weeks | 30–120 |
high, e.g. 3600 |
Keep touch = false on battery, and avoid content that changes every minute (such
as a clock with seconds): every change is a screen refresh.
- The display is a TCP client: it connects to inkcast on port 11113 and reports its status (panel size, battery, what it is showing...).
- inkcast loads the display's
url:- a web page is screenshotted with headless Chromium at the panel size;
- an image (
Content-Type: image/*, orfile://) is used as is, fitted withfit. Transparent areas become white; SVG is rendered by the browser.
- The result is rotated, converted to 16 greys and dithered.
- If the display already shows that exact image (it reports a checksum of its
screen), nothing is sent. Otherwise the image is sent, followed by the sleep
command if
sleep_minutesis set. - While a display stays connected, the page is re-rendered every
refresh_seconds(its own, or the global one) and sent only if it changed.
With touch, inkcast reads the display's touch mode on every connection and writes
it, and saves it to the display's flash, only when it differs. Touches are logged as
touch finger N at (x, y); they are not passed to the page.
Logs use UTC timestamps.
With status_port = 8000:
| URL | |
|---|---|
http://<inkcast-host>:8000/ |
One card per display: current image, state, battery, signal, temperature, humidity, last and next contact, last update result and settings. Auto/light/dark theme; refreshes every 30 s. |
http://<inkcast-host>:8000/api/devices |
The same data as JSON. |
http://<inkcast-host>:8000/devices/<uuid>/preview.png |
What the display shows. |
GET /api/devices returns one object per display (configured displays first,
then any other display that has connected):
[
{
"uuid": "00112233-4455-6677-8899-gghhiijjkkll",
"name": "Kitchen",
"state": "asleep",
"connected": false,
"last_seen": "2026-10-18T22:30:00Z",
"next_wake": "2026-10-18T23:30:00Z",
"battery_percent": 97,
"battery_voltage_mv": 4138,
"signal_dbm": -66,
"temperature_c": 26,
"humidity_percent": 54,
"firmware": "7.3.4358",
"display_updates": 15,
"touch_mode": 0,
"source": "http://example.com/kitchen",
"rotate": 180,
"sleep_minutes": 60,
"last_update": {
"at": "2026-10-18T22:30:03Z",
"result": "sent",
"error": null
},
"preview_url": "/devices/00112233-4455-6677-8899-gghhiijjkkll/preview.png"
}
]- Times are UTC in RFC 3339 format (
Zsuffix). stateis the badge on the status page:connected: connected right now;asleep: sent to sleep and not overdue (next_wakeplus 10 minutes);disconnected: not connected and not expected to be asleep (nosleep_minutes, or it missed its wake-up);never_seen: configured but not connected since inkcast started.
- Values come from the display's last status, so they change when it wakes up.
Data is kept in memory: after a restart, fields are
nulluntil each display connects again. last_update.resultissent,unchanged(it already showed that image) orerror.
The built-in RESTful integration
can turn /api/devices into sensors with a single request. Replace
<inkcast-host> and the UUID, add this to configuration.yaml and restart Home
Assistant:
rest:
- resource: http://<inkcast-host>:8000/api/devices
scan_interval: 300
sensor:
- name: "Kitchen display state"
unique_id: kitchen_display_state
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ d.state }}
- name: "Kitchen display battery"
unique_id: kitchen_display_battery
device_class: battery
state_class: measurement
unit_of_measurement: "%"
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ d.battery_percent }}
- name: "Kitchen display signal"
unique_id: kitchen_display_signal
device_class: signal_strength
state_class: measurement
unit_of_measurement: "dBm"
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ d.signal_dbm }}
- name: "Kitchen display temperature"
unique_id: kitchen_display_temperature
device_class: temperature
state_class: measurement
unit_of_measurement: "°C"
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ d.temperature_c }}
- name: "Kitchen display humidity"
unique_id: kitchen_display_humidity
device_class: humidity
state_class: measurement
unit_of_measurement: "%"
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ d.humidity_percent }}
- name: "Kitchen display last seen"
unique_id: kitchen_display_last_seen
device_class: timestamp
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ as_datetime(d.last_seen) if d.last_seen else none }}
- name: "Kitchen display next wake"
unique_id: kitchen_display_next_wake
device_class: timestamp
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ as_datetime(d.next_wake) if d.next_wake else none }}
- name: "Kitchen display last update"
unique_id: kitchen_display_last_update
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ d.last_update.result or 'unknown' }}
binary_sensor:
- name: "Kitchen display connected"
unique_id: kitchen_display_connected
device_class: connectivity
value_template: >-
{% set d = value_json | selectattr('uuid', 'equalto', '00112233-4455-6677-8899-gghhiijjkkll') | first %}
{{ d.connected }}- Repeat the sensors for each display.
scan_intervalis how often Home Assistant asks inkcast, not the display; polling faster thansleep_minutesadds little.- On battery, "state" is more useful than "connected", which is
offwhile the display sleeps.
Current image. Add a Generic Camera with this Still image URL (leave the stream URL empty) and show it with a Picture Entity card:
http://<inkcast-host>:8000/devices/00112233-4455-6677-8899-gghhiijjkkll/preview.pngAlerts, for example:
automation:
- alias: "Kitchen display low battery"
triggers:
- trigger: numeric_state
entity_id: sensor.kitchen_display_battery
below: 20
actions:
- action: notify.notify
data:
message: "Kitchen display battery at {{ states('sensor.kitchen_display_battery') }} %"
- alias: "Kitchen display disconnected"
triggers:
- trigger: state
entity_id: sensor.kitchen_display_state
to: "disconnected"
actions:
- action: notify.notify
data:
message: "Kitchen display is disconnected"- The display protocol has no authentication: anything that can reach port 11113 can claim to be any display. The status page and API are read-only but unauthenticated too.
- The link is unencrypted (see Preparing the display).
Run inkcast on a trusted local network and do not expose its ports to the internet.
- Single-panel displays only; multi-panel signs are not supported.
- Every update redraws the full screen (no partial updates).
- No firmware updates: Visionect firmware images are encrypted with keys only Visionect holds.
- Touches are logged but not forwarded to the page.
- Some displays report a panel type pyvisionect does not list yet (the Joan 6
reports
0x501100EB); inkcast then uses the size the display reports.
uv sync
uv run playwright install chromium # the renderer tests use a real browser
uv run pytestEvery push to master publishes the image as latest, and every x.y.z git tag (no
v prefix) as x.y.z, to Docker Hub and the GitHub Container Registry
(publish-image.yml). The tag must match the
version in pyproject.toml:
# bump version in pyproject.toml, commit, then:
git tag 1.2.3 && git push origin 1.2.3The workflow needs two repository secrets: DOCKERHUB_USERNAME and DOCKERHUB_TOKEN
(a Docker Hub access token with read/write scope). The first push to ghcr.io creates
a private package; make it public under the package settings.
- pyvisionect by schlarpc: the display protocol, imaging and work queue.
- Playwright and Chromium for rendering.
MIT.
inkcast is an independent project, not affiliated with or endorsed by Visionect. Visionect and Joan are trademarks of their respective owners and are used here only to describe compatibility.