CGM glucose readings —> HTTP API -> Nightscout -> MQTT -> ...
Features • Quick start • Configuration • HTTP API • MQTT • Container • Documentation • Troubleshooting
Warning
Beta / WIP — pre-1.0; APIs, config schema, and MQTT wire format may break between releases at any time.
gluco-hub-rs is a small, self-hosted relay between a CGM (currently LibreLink Up) and the rest of your stack. It exposes a local HTTP API by default and ships optional Nightscout and MQTT sinks.
Note
For: technical LibreLink Up users moving CGM data between systems. Not for: therapy, dosing, diagnosis, or replacing approved tools — see DISCLAIMER.md. Vibe: a practice ground for the Rust ecosystem and agentic coding.
SOURCES CORE SINKS CONSUMERS
─────── ────── ───── ─────────
LibreLink Up ─► ─► HTTP API ─► Dashboards, scripts
+ your src ┄► gluco-hub ┄► Nightscout ─► web / apps
(poll + ┄► MQTT broker ─► Smart pixel clocks,
fan-out) Home Assistant
┄► + your sink
- Multiple destinations — Nightscout, MQTT for smart displays (smart pixel clocks, Home Assistant, …), HTTP API
- Lightweight — self-contained Rust binary with a small footprint; runs on Raspberry Pi, VPS, or home server
- Modular design — add sources or sinks with a single file plus a feature flag (how-to)
- Resilient sinks — per-sink watermark drops already-pushed readings each cycle and replays missed ones automatically when a sink recovers, within LLU's 24 h history
- Persistent DLQ — failed pushes are written to a per-sink JSONL queue on disk and replayed on the next successful push, surviving process restarts and arbitrary outage windows beyond LLU's history
- mTLS for MQTT — present a client certificate during the TLS handshake with
client_cert_file+client_key_filein[sink.mqtt] - JWT-as-password — supply a pre-obtained JWT in the
passwordfield instead of the raw LibreLink Up password; the bridge detects the format and skips the login call - Tailscale-ready — set
tailscale_hostnamein[sink.mqtt]to resolve MQTT broker addresses through a localtailscaleddaemon at startup - Operable — Prometheus metrics, structured JSON logs, graceful shutdown on
SIGINT/SIGTERM
- A FreeStyle Libre sensor (Libre 2 / 3) linked to a LibreLink Up account — same email/password as the LibreLinkUp mobile app. No other CGMs supported today.
- One of:
- Container — Docker or Podman; no Rust toolchain needed (see Container)
- Native — Rust ≥ 1.95 to build from source
A Taskfile.yml wraps all common commands — run task to list targets (requires go-task).
Tip
No Rust on your machine? Skip ahead to Container.
Before you touch any credentials, this starts the service with an in-memory mock source to verify the API:
bash scripts/smoke.shOne-shot probe against the real LibreLink Up API. Prints a JSON summary and exits — no server is started, nothing is written:
export LLU_EMAIL='you@example.com' LLU_PASSWORD='…' LLU_REGION='EU'
bash scripts/llu-dryrun.shLLU_REGION matches the server your LibreLinkUp app talks to: AE, AP, AU, CA, CN, DE, EU, EU2, FR, JP, LA, RU, US.
Read-only probe that fetches the last entry date and exits — never POSTs anything:
export NS_BASE_URL='https://nightscout.example.com' NS_API_SECRET='…'
bash scripts/ns-dryrun.shTip
bash scripts/full-dryrun.sh chains all three probes automatically and skips stages whose credentials are not set.
Two flavours produce the same running service — pick whichever fits.
Env-only (recommended, zero files):
cargo build --release --features "source-llu sink-nightscout"
export GLUCO_HUB__SOURCE__LLU__EMAIL='you@example.com'
export GLUCO_HUB__SOURCE__LLU__PASSWORD='…'
export GLUCO_HUB__SOURCE__LLU__REGION='EU'
export GLUCO_HUB__SINK__NIGHTSCOUT__BASE_URL='https://nightscout.example.com'
export GLUCO_HUB__SINK__NIGHTSCOUT__API_SECRET='…'
./target/release/gluco-hub check-config # validate and exit
./target/release/gluco-hub run # no -c flag → no config.toml neededFile-based (when you prefer a checked-in TOML):
cp config.example.toml config.toml # edit [source.llu] and [sink.nightscout] — email & region live here
# Secrets still go in env vars, never in the TOML file:
export GLUCO_HUB__SOURCE__LLU__PASSWORD='…'
export GLUCO_HUB__SINK__NIGHTSCOUT__API_SECRET='…'
./target/release/gluco-hub -c config.toml check-config
./target/release/gluco-hub -c config.toml runsource-llu, sink-nightscout (default), plus optional mock-source and sink-mqtt — see docs/ARCHITECTURE.md for details. Published GHCR images bundle every stable Source/Sink (source-llu sink-nightscout sink-mqtt); only choose a narrower feature set when building locally.
cargo build --release --features "source-llu sink-nightscout sink-mqtt"config.toml is optional — GLUCO_HUB__* environment variables alone can configure everything (useful for containers / Compose / Kubernetes). When a config.toml is present, env vars override its values key by key. Keep secrets in env vars; the TOML file holds none.
For a file-based setup, copy config.example.toml and uncomment the sections you need:
[http]
# enabled = false # MQTT-only deployments: skip TCP listener entirely
bind = "0.0.0.0:8080"
# Bearer auth for /glucose/*: set GLUCO_HUB__HTTP__BEARER_TOKEN=<token>
[poller]
interval_secs = 60 # min 30, max 600; LLU updates every ~60 s
[source.llu]
email = "you@example.com"
region = "EU" # AE AP AU CA CN DE EU EU2 FR JP LA RU US
# Password via env: export GLUCO_HUB__SOURCE__LLU__PASSWORD=…
# Password via file: password_file = "/run/secrets/llu_password"
[sink.nightscout]
base_url = "https://nightscout.example.com"
# Secret via env: export GLUCO_HUB__SINK__NIGHTSCOUT__API_SECRET=…
[sink.mqtt]
broker_host = "mqtt.example.com"
broker_port = 8883
client_id = "gluco-hub-1"
topic_prefix = "gluco-hub/gluco-hub-1"
# mTLS (optional): both fields needed for mutual TLS
# client_cert_file = "/path/to/client.crt"
# client_key_file = "/path/to/client.key"
# Tailscale (optional): resolve broker via tailscaled
# tailscale_hostname = "mqtt-broker"
# Password via env: export GLUCO_HUB__SINK__MQTT__PASSWORD=…
Any TOML key can also be overridden at runtime via `GLUCO_HUB__SECTION__KEY` (double-underscore as separator):
```bash
GLUCO_HUB__HTTP__BIND=0.0.0.0:9090 GLUCO_HUB__POLLER__INTERVAL_SECS=30 ./gluco-hub runNote
Run ./gluco-hub -c config.toml check-config after editing the config — it validates every field before the service tries to start.
| Path | Auth | Response |
|---|---|---|
GET /healthz |
public | {"status":"ok","version":"…"} |
GET /metrics |
public | Prometheus text exposition (v0.0.4) |
GET /glucose/latest |
optional Bearer | Latest cached reading, or 503 + API001 |
Setting GLUCO_HUB__HTTP__BEARER_TOKEN puts /glucose/* behind Bearer auth. Every response also carries the header X-Disclaimer: not-for-medical-use — that header is the canonical machine-readable disclaimer signal; long-form text lives in DISCLAIMER.md. Example reading response:
{
"patient_id": "00000000-0000-0000-0000-000000000000",
"source_id": "llu",
"timestamp": "2025-01-01T12:00:00Z",
"glucose_mgdl": 112,
"trend": "Flat"
}Requires --features sink-mqtt and a [sink.mqtt] config block.
| Topic | Retained | Payload |
|---|---|---|
<prefix>/glucose |
No | {"v":1,"ts":<unix-ms>,"mgdl":<float>,"mmol":<float>,"trend":"Flat","source":"llu","patient":"…"} |
<prefix>/_health |
Yes | {"online":true,"v":1} · LWT: {"online":false,"v":1} |
<prefix>/_stats |
Yes | {"v":1,"uptime_secs":…,"publishes_total":…,"connects_total":…, …} — refreshed every stats_interval_secs |
<prefix>/_patients |
Yes | [{"id":"<uuid>","display_name":"Anna M.","is_active":true}, …] — refreshed after each successful LLU list_connections() |
<prefix> is the topic_prefix value from [sink.mqtt]. Set include_patient_id = false to drop the patient field on shared brokers. The schema is versioned via the v field — see docs/ARCHITECTURE.md for the full payload contracts.
Home Assistant auto-discovery. Set discovery_enabled = true in [sink.mqtt]. The sink then publishes two retained config messages on <discovery_prefix>/sensor/gluco_hub_<client_id>_glucose/config and <discovery_prefix>/sensor/gluco_hub_<client_id>_trend/config (default discovery_prefix = "homeassistant") after each MQTT ConnAck. Home Assistant picks both entities up automatically and groups them under one device:
- Glucose entity — state reads
mgdl(ormmol) from<prefix>/glucose, availability tracks<prefix>/_healthvia theonlineflag, and the full JSON body is exposed as entity attributes (trend, source, patient, ts). - Trend entity — state reads
value_json.trendfrom the same topic and declaresdevice_class: "enum"with everyTrendvariant (Flat,SingleUp,FortyFiveUp, …) inoptions. Use atemplate/mushroomcard with a state→icon mapping to render directional arrows on a dashboard — HA's MQTT discovery does not support templated icons, so arrow rendering is a dashboard concern.
Multi-arch images (linux/amd64, linux/arm64) are published to GHCR on every release tag and every push to main. Versions use CalVer-on-SemVer (YYYY.MMDD.PATCH, e.g. 2026.510.0). Tags split into these stability tiers:
| Tag | Mover | Stability | Use case |
|---|---|---|---|
:main |
every push to main |
stabilising — main is release-cut source | dev tracking after V3 lands |
:testing |
latest pre-release tag | RC / beta / alpha — pre-validation only | beta channel |
:sha-<short> |
immutable | snapshot of one commit | reproducible pinning |
:YYYY.MMDD.PATCH-rc.N |
immutable | release candidate | pre-release tests |
:YYYY.MMDD.PATCH |
immutable | a specific final release | production pin |
:YYYY.MMDD |
rolls forward to latest PATCH of that day |
day-level rolling | auto-patch within a day |
:YYYY |
rolls forward to latest release in year | year-level rolling | "current year" tracking |
:latest |
rolls forward, finals only | highest final release (excludes -rc) |
"always current" convenience |
:stable |
rolls forward, finals only | semantic alias of :latest |
"always stable" convenience |
Warning
This project is in beta. Every release is a dated snapshot — breaking changes can land on any release while the config schema, HTTP API, and MQTT wire format stabilise. For predictable upgrades, pin to an immutable tag (:YYYY.MMDD.PATCH or :sha-<short>). :latest / :stable track the most recent final release but their underlying digests move.
No config file required — everything via env vars:
docker run --rm -p 127.0.0.1:8080:8080 \
-e GLUCO_HUB__HTTP__BIND=0.0.0.0:8080 \
-e GLUCO_HUB__SOURCE__LLU__EMAIL='you@example.com' \
-e GLUCO_HUB__SOURCE__LLU__PASSWORD='…' \
-e GLUCO_HUB__SOURCE__LLU__REGION='EU' \
-e GLUCO_HUB__SINK__NIGHTSCOUT__BASE_URL='https://nightscout.example.com' \
-e GLUCO_HUB__SINK__NIGHTSCOUT__API_SECRET='…' \
ghcr.io/micschr0/gluco-hub:latestFor a persistent setup, use the provided Compose file — no config.toml needed:
cp compose.example.yml compose.yml # tweak if you like the defaults
cp .env.example .env # fill in LLU + sink credentials
docker compose up -d
docker compose logs -f gluco-hubThe Compose file reads .env automatically and ignores config.toml. If you prefer a file-based config (e.g. a checked-in deployment repo), uncomment the volumes: / command: block at the bottom of compose.example.yml and bind-mount config.toml read-only.
Each published image is keyless-signed with Sigstore cosign and ships a SLSA build-provenance attestation. Verify either:
gh attestation verify oci://ghcr.io/micschr0/gluco-hub:latest --owner micschr0docker build -t gluco-hub:dev \
--build-arg GLUCO_HUB_GIT_SHA=$(git rev-parse HEAD) \
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) .Tip
SIGINT and SIGTERM both trigger a graceful shutdown. For Kubernetes, use the exec-form ENTRYPOINT so PID 1 receives the signal directly.
docs/ARCHITECTURE.md— data flow, error codes, module map, Cargo features, config referencedocs/OPERATIONS.md— CLI, endpoints, MQTT topics, metrics, env vars, troubleshootingdocs/EXTENDING.md— how to add new sources or sinksconfig.example.toml·compose.example.yml— config and Compose templatesDISCLAIMER.md·SECURITY.md·CHANGELOG.md·LICENSE
For common error codes and their fixes, see the troubleshooting table in the operations runbook.
Found a bug or have a question? Open an issue — include the error code (e.g. LLU003) and the output of check-config if the service fails to start.
Please file bug reports and feature requests via GitHub issues. Before opening a PR, read DISCLAIMER.md to ensure the change fits the project's intent. For adding new sources or sinks, see docs/EXTENDING.md. For security-sensitive reports, see SECURITY.md.