Skip to content

Repository files navigation

Xteink SugarTV

Xteink SugarTV turns a dedicated Xteink X3 into a direct Home Assistant glucose display. The device runs a patched CrossPoint build, reads Home Assistant state and history itself, renders a native 528×792 portrait frame, and wakes once per minute. No laptop, display server, or Lovelace card is required at runtime.

Synthetic SugarTV frame rendered by the firmware code

The image above contains synthetic data and is generated by the same C++ renderer and bitmap assets compiled into the firmware.

While the SugarTV cycle is active, a short power-button press wakes the X3 and requests an immediate update instead of waiting for the next timer wake. Holding the power button exits the cycle and returns to the normal CrossPoint UI. The screen labels the rightmost physical button as Manage Wi-Fi; pressing it opens the network list without automatically reconnecting to the current network.

The screen follows the behavior of homeassistant-sugartv-card: source and trend resolution for common glucose integrations, measurement timestamps, history-derived delta and cadence, prediction text, threshold states, relative time, missing data, and cadence-based aging. Color semantics are translated to the one-bit panel: low and high readings gain an outline, urgent readings invert the frame, and stale readings use ordered ink density. The upper-right value is the battery percentage measured by the X3. The upper-left status always shows the result and local time of the latest update attempt: Updated at Aug 9, 3:55 PM after success or Update failed at Aug 9, 3:56 PM after failure. The battery is measured on every update attempt, including failures. If Wi-Fi, Home Assistant, or the configured glucose sensor is unavailable, the last successful reading remains visible without changing its value, trend, delta, or displayed age, while the battery percentage remains live.

This is an informational display, not a medical device or a source for treatment decisions.

The project is released under the MIT License. CrossPoint and icon attribution are recorded in THIRD_PARTY_NOTICES.md.

Reproduce it on an X3

Requirements

  • An Xteink X3, a data-capable USB-C cable, and an SD card.
  • Git, a C++17 compiler, Python 3.11 or later, and pioarduino Core installed with the current pioarduino Core installer.
  • A Home Assistant instance reachable from a Wi-Fi network saved on the X3.
  • A sensor.* entity with Home Assistant device_class: blood_glucose_concentration, or an explicit entity ID in the optional settings file.
  • A Home Assistant long-lived access token. Create one under User profile > Security > Long-lived access tokens as described by the official REST API documentation.

The firmware binary embeds the Home Assistant token. Treat the binary as a credential: do not publish it, attach it to an issue, or upload it as a public CI artifact.

1. Verify Home Assistant access

Set the URL without a trailing /, export the token only in the current shell, and verify the same REST endpoint the device will use:

export HA_URL='http://homeassistant.local:8123'
export HA_TOKEN='replace-with-token'
curl --fail --silent --show-error \
  -H "Authorization: Bearer ${HA_TOKEN}" \
  "${HA_URL}/api/" >/dev/null

For repeatable local builds, copy .env.example to the Git-ignored .env, uncomment and fill HA_URL and HA_TOKEN, then load them into the current shell with set -a; source .env; set +a. The firmware does not read .env directly; PlatformIO receives the values through the build flags below.

Home Assistant uses port 8123 by default. An http:// URL sends the bearer token without transport encryption; use it only on a network whose risk you accept. HTTPS is the appropriate production choice once server certificate validation is configured.

2. Check out the pinned sources

The patch is pinned to CrossPoint commit e00f5958dfeea2a3e640c39eb78186fd20996f4b on the develop branch.

git clone https://github.com/wiltodelta/xteink-sugartv.git
git clone --recursive https://github.com/crosspoint-reader/crosspoint-reader.git
cd crosspoint-reader
git checkout e00f5958dfeea2a3e640c39eb78186fd20996f4b
git submodule update --init --recursive
git apply --check ../xteink-sugartv/firmware/patches/crosspoint-sugartv.patch
git apply ../xteink-sugartv/firmware/patches/crosspoint-sugartv.patch

Do not apply the patch to another CrossPoint revision: it includes deliberate changes to sleep, Wi-Fi, HTTP, rendering, fonts, and the home menu, all tested together at the pinned commit.

3. Build the private firmware

Supply the Home Assistant URL, token, and optional POSIX timezone through build flags. Never add real values to the patch or another tracked file:

export PLATFORMIO_BUILD_FLAGS="-DSUGARTV_HA_URL=\\\"${HA_URL}\\\" -DSUGARTV_HA_TOKEN=\\\"${HA_TOKEN}\\\" -DSUGARTV_TZ=\\\"PST8PDT,M3.2.0,M11.1.0\\\""
pio run -e default

The dedicated build defaults to Pacific time (PST8PDT,M3.2.0,M11.1.0) if SUGARTV_TZ is omitted. Set the build flag explicitly when installing the firmware in another timezone.

The result is .pio/build/default/firmware.bin. Building does not alter the X3; flashing is a separate step. Investigate any failed or interrupted build instead of flashing whatever binary happens to remain from an earlier run.

4. Flash the X3

Read CrossPoint's pinned installation and USB-unlocker notes before flashing a locked or stock device. Connect and wake the X3, then find its serial port. Typical values are /dev/cu.usbmodem1101 on macOS and /dev/ttyACM0 on Linux.

Keep the build variables from the previous step in the same shell and run:

export X3_PORT='/dev/cu.usbmodem1101'
pio run -e default --target upload --upload-port "${X3_PORT}"

PlatformIO rebuilds before uploading, which is why HA_URL, HA_TOKEN, and PLATFORMIO_BUILD_FLAGS must still be present. Do not use an old binary merely because it happens to exist under .pio/build/default/.

After a successful upload, remove the secrets from the shell:

unset HA_TOKEN PLATFORMIO_BUILD_FLAGS

5. Complete first launch

  1. Wake the X3 and select SugarTV from the CrossPoint home menu.
  2. Select a Wi-Fi network. Saved networks are reused silently on later timed wakes; the visible list remains manual so it can be used to change networks.
  3. Wait for the first successful Home Assistant update. The upper-left status must show local time, the upper-right value must show the current X3 battery, and the next update should occur on the one-minute cadence.
  4. If automatic sensor discovery does not select the intended entity, copy firmware/sugartv.example.json to /.crosspoint/sugartv.json on the SD card and set glucose_value explicitly.

Physical controls

Control SugarTV behavior
Short power-button press Wake and update immediately.
Long power-button hold Leave the scheduled SugarTV cycle and return to CrossPoint.
Rightmost button, labelled Manage Wi-Fi Open the manual network list without reconnecting automatically.

Automatic timer wakes keep the existing e-ink frame visible. They do not show the boot logo or Wi-Fi selector before every update.

Source and settings

Without settings, the firmware scans /api/states and selects the first valid sensor whose Home Assistant device_class is blood_glucose_concentration. The selected entity is cached, and a failed entity request causes rediscovery. A temporarily unknown or unavailable reading does not discard the cached source.

The latest successful reading is cached in /.crosspoint/sugartv-reading.json. A successful cycle shows its absolute local update time in the upper-left status. A failed automatic cycle renders the cached reading exactly as it appeared on the last successful cycle, replaces that status with Update failed and the failed attempt time on the same line, then retries on the next one-minute wake. If no successful reading has been cached, the main value is N/A.

Synthetic failed-update frame

Every automatic cycle appends a diagnostic JSON object to the daily SD-card log at /.crosspoint/logs/sugartv-YYYY-MM-DD.jsonl. It retains 30 days of cycle, reading, network, system, and error context without tokens, Wi-Fi passwords, or response payloads. Schema version 2 also records the requested sleep interval and whether ESP-IDF accepted the timer wake source. The active cycle is marked by /.crosspoint/sugartv-cycle-armed, so a reset or USB power event resumes SugarTV instead of silently returning to the retained Home screen. See Architecture for the complete logging contract.

On a cold wake, SugarTV first tries the last successful saved Wi-Fi network. If the radio rejects that early attempt, a fresh scan tries visible saved networks and permits one scan-confirmed retry of the original network. The attempt set remains bounded, so an unavailable network cannot trap the device awake. A manually selected open network is saved automatically with an empty password so later headless wakes can reconnect to it.

Each SugarTV update has a shared 20-second HTTP budget across its Home Assistant requests. A Wi-Fi network with a captive portal, a stalled response, or no internet route therefore produces a failed update and returns the device to its normal sleep cadence instead of leaving it awake indefinitely.

The saved-network store is loaded immediately after SD-card initialization, so the device UI and API do not temporarily report an empty list before the first Wi-Fi activity opens it.

An optional /.crosspoint/sugartv.json file overrides discovery and display defaults. The shared fields use the same names as homeassistant-sugartv-card:

{
  "glucose_value": "sensor.jane_glucose_value",
  "glucose_trend": "sensor.jane_glucose_trend",
  "timestamp_attribute": "measurement_timestamp",
  "show_prediction": true,
  "relative_time": true,
  "dim_by_age": false,
  "show_age_states": false,
  "color_thresholds": false,
  "decimal_comma": false,
  "thresholds": {
    "urgent_low": 54,
    "low": 70,
    "high": 180,
    "urgent_high": 250
  }
}

glucose_value, glucose_trend, timestamp_attribute, show_prediction, relative_time, dim_by_age, color_thresholds, and thresholds have the card's meanings. Unlike the Lovelace card, glucose_value is optional because the dedicated device can discover the first blood-glucose sensor itself. Individual threshold values are optional and are merged with the defaults for the sensor unit. A completed set that is not strictly ascending falls back to the unit defaults, as does a complete set containing the other unit's defaults.

show_age_states and decimal_comma are X3-only parameters. The former gates all age-based dithering while preserving the textual reading age; the latter uses a comma as the mmol/L decimal separator. The ready-to-copy example is firmware/sugartv.example.json.

See Troubleshooting for clock, Wi-Fi, sensor, captive-portal, flashing, and diagnostic-log checks.

Local validation

Run these commands from the patched CrossPoint checkout:

c++ -std=c++17 -Isrc tools/test_sugartv_logic.cpp \
  src/activities/network/SugarTvLogic.cpp -o /tmp/test_sugartv_logic
/tmp/test_sugartv_logic
python3 tools/test_sugartv_preview.py
python3 tools/render_sugartv_preview.py \
  --font lib/EpdFont/builtinFonts/source/NotoSans/NotoSans-Regular.ttf \
  --output /tmp/sugartv-feature-matrix.png --matrix

The C++ test exercises the same timestamp, trend, sibling-entity, cadence, aging, event-result, cycle-resume, filename, and retention code compiled into the firmware. The preview is not a second Python implementation: it compiles and executes the same C++ frame renderer, bitmap fonts, layout, and icon assets as the X3. Its tests reject clipped pixels, lock the default framebuffer with a golden digest, and cover prediction placement, threshold states, age density, missing history, unknown trend, the single-line update status, and the complete visual matrix. The final command above writes that matrix to /tmp for local inspection without adding generated device data to the repository.

To reproduce the tracked synthetic screenshots from the same patched checkout, run this command from the Xteink SugarTV repository:

uv run python scripts/generate_firmware_screenshots.py ../crosspoint-reader
git diff -- docs/images

The generator contains only synthetic values and must remain independent of a real Home Assistant response or device framebuffer.

The sleep lifecycle requires a hardware soak test after flashing. Observe at least 15 consecutive one-minute updates with the X3 disconnected from USB, then repeat with USB connected. Both runs must retain timer wake and power-button wake, advance the displayed measurement, and avoid hanging on entry to deep sleep. A single successful wake is not sufficient validation.

Reference server

The Python application under src/xteink_sugartv is retained as a local reference renderer and a compatibility harness for the earlier x3-trmnl prototype. It is not part of the direct-device runtime. It starts in synthetic-data mode with the defaults from .env.example:

uv sync
uv run uvicorn xteink_sugartv.app:app --host 0.0.0.0 --port 8000

Copy .env.example to .env, customize it, and add --env-file .env to the command when Home Assistant-backed rendering is needed. Run the complete local quality gate with ./maintain.sh.

About

Firmware patch and reference renderer that turn an Xteink X3 e-ink reader into a direct Home Assistant CGM display.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages