The fully-local, fully-offline "cloud" for the Ring•One stall demo at Zephyr Meetup Hyderabad — InfluxDB, Mosquitto, Telegraf and Grafana behind a self-signed TLS front door, driven by one CLI, with a synthetic-device simulator for rehearsing without hardware.
Everything the Ring•One DK talks to at the stall is this repo. No cloud
accounts, no internet, one docker compose pull at home and it runs
completely offline on a phone hotspot.
The real Ring•One firmware (ringone-firmware) is built to talk to InfluxDB Cloud over HTTPS and HiveMQ Cloud over MQTT/TLS. This repo stands up the same two endpoints, locally: an InfluxDB 2.7 instance behind an nginx TLS front door on port 443 (exactly what the firmware expects), and a Mosquitto broker on 8883. Point the firmware at your laptop's LAN IP instead of the cloud hostnames and it works unmodified — see Firmware build + provisioning.
Branding note: the current company site/org is dotstarsystems.com /
github.com/dotstar-systems. The ringone-firmware and ringone-android
READMEs still say dotstarconsulting.com / dotstar-consulting — that's
stale, from before a rename; don't copy it into new material.
stall-screen.html and presenter.html (see On-screen
surfaces) use the current names.
# 1. On home Wi-Fi, once: pull all images so the stall never needs internet
docker compose pull
# 2. Start the stack — SpO2-only, real hardware only (what you show visitors)
./ringone-cloud start
# 2b. ...or the full dev/rehearsal view (every field, simulated devices too)
./ringone-cloud start --full
# 3. No hardware handy? Fake some devices (only visible in --full)
./ringone-cloud sim -- --count 3 --interval 1start prints the exact firmware rebuild flag and shell command to point a
real DK at this stack, then opens the live Grafana dashboard. See Trust
matrix for why there
are two dashboards.
| Service | Port | Protocol | Purpose |
|---|---|---|---|
| nginx | 443 | HTTPS (TLS1.2/1.3) | Device-facing front door → InfluxDB write API |
| InfluxDB | 8086 | HTTP | Direct API access, simulator writes here directly |
| Mosquitto | 1883 | MQTT (plain) | Telegraf consumer, bench tooling |
| Mosquitto | 8883 | MQTT over TLS | Device-facing MQTT (matches firmware default) |
| Mosquitto | 9001 | MQTT over WebSockets | Browser-based MQTT tooling |
| Grafana | 3000 | HTTP | Dashboard (admin / ringone, anonymous Viewer enabled) |
All ports are bound on 0.0.0.0 so a DK or phone on the hotspot LAN can reach
the laptop directly — see docker-compose.yml. Change any port in .env.
Deterministic credentials (never change, safe to commit — this stack is never reachable from the internet):
INFLUX_ORG=dotstar
INFLUX_BUCKET=ringone
INFLUX_TOKEN=ringone-local-dev-token
Grafana: admin / ringone (anonymous Viewer also enabled)
-
Point the firmware at this stack.
CONFIG_RINGONE_INFLUX_HOST(app/Kconfig.ringone:23-27) defaults to InfluxDB Cloud's hostname. Override it with your laptop's LAN IP at build time:west build -b nrf54lm20dk/nrf54lm20a/cpuapp -- \ -DCONFIG_RINGONE_INFLUX_HOST=\"192.168.1.5\"
./ringone-cloud startprints this line with the correct IP filled in every time (detected viaip route get 1). -
Telemetry interval. The demo wants 1–2 s resolution, but
CONFIG_RINGONE_TELEMETRY_INTERVAL_SEC(app/Kconfig.ringone:14-21) isrange 10 300— the firmware will not build below 10 s, and the MQTTset_intervalcommand (mqtt_client.c:156-157) enforces the same floor at runtime. The fastest achievable demo cadence is-DCONFIG_RINGONE_TELEMETRY_INTERVAL_SEC=10, not 1–2 s as originally hoped. Dashboard refresh is still set to 1 s so any pace increase feels immediate; see Data contract for the other place the prompt and the code disagree. -
Wi-Fi provisioning (SoftAP), from
wifi_prov.c. On first boot with no stored credentials — or after a 5 s long-press on Button 1 (wifi_prov.c:220-237,SOFTAP_LONGPRESS_MS=5000) — the device starts a SoftAP nameddotstar-ringone-setup(CONFIG_RINGONE_SOFTAP_SSID,app/Kconfig.ringone:69-72) and the NCSsoftap_wifi_provisionlibrary serves an HTTP captive portal for entering your hotspot's SSID/password (wifi_prov.c:325-352). It shuts down automatically afterCONFIG_RINGONE_SOFTAP_TIMEOUT_SEC(default 300 s,app/Kconfig.ringone:75-81) if nothing is entered. A BLE-assisted path (writing the WiFi-SSID/WiFi-Password GATT characteristics) exists as a fallback (wifi_prov.c:241-269) — useful if the companion app drives provisioning instead of the captive portal. -
Runtime token, via the shell (
ringone_shell.c:7-14). The InfluxDB token lives in PSA Protected Storage, not Kconfig — set it once per device over the UART shell:uart:~$ ringone_cred set influx_token ringone-local-dev-tokenTakes effect on next boot (
influx_telemetry.c:267-279loads it at init)../ringone-cloud startprints this exact command.
Extracted from app/src/influx_telemetry.c and app/src/mqtt_client.c —
where this differs from the original prompt, the firmware wins.
Only spo2 is a genuine live sensor reading, and only on a real device.
Every other field (hr, temp, steps, battery, rssi_ble) is a
firmware stub value (see the firmware README's "Stub sensors" table) — on
every device, including the physical Ring•One DK. Rather than show that
and label it "simulated," the two dashboards just handle it structurally:
| Dashboard | uid | Started by | Shows |
|---|---|---|---|
Ring•One — Live (ringone.json) |
ringone-live |
./ringone-cloud start (default) |
SpO2 only. Real hardware only — the query itself excludes any device_id matching ^sim-. No other field, no mention of simulation anywhere. This is what runs at the stall. |
Ring•One — Live (full, dev/rehearsal) (ringone-full.json) |
ringone-live-full |
./ringone-cloud start --full |
Every field, every device, sim-* included and labeled (REAL)/(SIMULATED) on the SpO2 panels. For testing the pipeline before hardware arrives, or before a rehearsal, without hardware in hand. Never shown to a visitor. |
Both dashboards are always provisioned (Grafana just loads whatever's in
grafana/provisioning/dashboards/); the flag only changes which one
start opens and sets as the Grafana home dashboard
(GRAFANA_HOME_DASHBOARD_PATH in .env, threaded through to
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH in docker-compose.yml). The
real-only dashboard's sim-* exclusion is a Flux filter(), not an
operational rule to remember — it stays clean even if the simulator is
accidentally left running.
This will need revisiting once real ICs replace the stubs in
app/src/ringone_sensors.c — at that point, promote the corresponding
field out of ringone-full.json's stub styling into the plain dashboard
the same way spo2 was, one field at a time.
HTTP / InfluxDB (PATH B — influx_telemetry.c)
POST https://<host>:443/api/v2/write?org=dotstar&bucket=ringone&precision=s(influx_telemetry.c:145-147, port fixed atinflux_telemetry.c:43)Authorization: Token <token>(note:Token, notBearer—influx_telemetry.c:153)- Transport:
IPPROTO_TLS_1_2socket,TLS_PEER_VERIFY_NONE(influx_telemetry.c:108-122) — any certificate is accepted, which is exactly why the self-signed cert fromcerts/gen-certs.shworks with zero device-side trust setup. - Line protocol (
influx_telemetry.c:237-249):ring_telemetry,device_id=<id> hr=<u8>,spo2=<u8>,temp=<f.2>,steps=<u32>,battery=<u8>,rssi_ble=<i32> <unix_seconds>device_idformat:rng-%02X%02Xfrom the BLE address (influx_telemetry.c:85-86).rssi_bleis currently hardcoded to-60pending aRING_ONE_TODO(influx_telemetry.c:248). - Success is HTTP
204 No Content(influx_telemetry.c:182). - Publishes only fire when
wifi_prov_get_status() == WIFI_STATUS_CONNECTED— SoftAP's own DHCP lease does not count (influx_telemetry.c:220-227).
MQTT (PATH C — mqtt_client.c)
- Broker:
CONFIG_RINGONE_MQTT_BROKER_HOST:CONFIG_RINGONE_MQTT_BROKER_PORT(app/Kconfig.ringone:42-53), default port8883. - Publish topic
ring-one/<device_id>/telemetry(QoS 0,mqtt_client.c:571), JSON body (mqtt_client.c:548-563):Subscribe topic{"device_id":"<id>","ts":<unix>,"hr":<u8>,"spo2":<u8>,"temp":<f.2>,"steps":<u32>,"battery":<u8>,"rssi_ble":<i32>}ring-one/<device_id>/cmd(QoS 1,mqtt_client.c:198); status topicring-one/<device_id>/status(mqtt_client.c:134).
device_id as ring-one-%02X%02X (mqtt_client.c:98-99), while the HTTP
path derives it as rng-%02X%02X (influx_telemetry.c:85-86) — from the
same BLE address. A single physical device therefore shows up as two
different device_id tag values depending on which path a given point
took. telegraf.conf and the Grafana dashboard both group by device_id
as instructed, so on a live device you may see e.g. rng-A1B2 (HTTP path)
and ring-one-A1B2 (MQTT path) as two separate series for one ring. This is
a firmware bug, not a bug in this repo — noted here rather than silently
"fixed" by renaming tags in Telegraf, since that would hide the real
behavior from the dashboard.
mqtt_client.c:327-332 sets:
s_client.transport.tls.config.peer_verify = TLS_PEER_VERIFY_NONE;
s_client.transport.tls.config.sec_tag_list = NULL;
s_client.transport.tls.config.sec_tag_count = 0;Same story as the InfluxDB path: the firmware never validates the broker's
TLS certificate and presents no client certificate. So the 8883 TLS
listener is enabled in mosquitto/config/mosquitto.conf, using the same
self-signed cert as nginx — no CA, no pinning, nothing further needed.
Dead broker never blocks anything else, confirmed from the code:
watchdog.c:14arms a 30 s hardware watchdog;watchdog_feed()is called every 2 s from the main workqueue'snotify_work_handler(main.c:256), completely independent of networking.influx_telemetry_publish()andmqtt_publish_telemetry()— the only two callsmain.cmakes into the networking subsystems (main.c:263-264) — are non-blocking enqueues (k_msgq_put(..., K_NO_WAIT)atinflux_telemetry.c:295andmqtt_client.c:535).- All actual socket I/O, TLS handshakes, DNS lookups and reconnect backoff
(5/10/30/60 s,
mqtt_client.c:51) happen in the dedicatedinflux_pubandmqtt_clientthreads (influx_telemetry.c:281-286,mqtt_client.c:520-524), each with its own stack — never on the main workqueue thread that feeds the watchdog and drives BLE notifications.
So: BLE notifications, sensor reads, and the watchdog feed keep running at a
steady 2 s cadence regardless of whether InfluxDB, Mosquitto, both, or
neither is reachable. mqtt=--/influx=-- just show up in the device's
UART log (main.c status line) and mqtt_client_connected() /
influx_telemetry_connected() read false — nothing else is affected, and the
device never watchdog-resets because of a dead cloud endpoint.
┌─────────────────────────────┐
│ Phone hotspot (the "LAN") │
└──────────────┬───────────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
┌────────┴────────┐ ┌─────────┴─────────┐ ┌─────────┴─────────┐
│ Laptop │ │ Ring•One DK │ │ Phone / tablet │
│ static IP on │ │ (nRF54LM20DK) │ │ running the │
│ hotspot profile │ │ TLS:443 + MQTT:8883│ │ companion app │
│ │ │ → laptop LAN IP │ │ (BLE to the DK) │
│ docker compose: │ └────────────────────┘ └────────────────────┘
│ nginx :443 ─────┼──► influxdb :8086
│ mosquitto :8883/│
│ 1883/9001 │
│ telegraf ───────┼──► influxdb
│ grafana :3000 │
└──────────────────┘
The DK and the phone app are both on the phone's own hotspot network; the
laptop just needs a fixed IP on that same network so the DK's build-time
CONFIG_RINGONE_INFLUX_HOST doesn't change between rehearsal and stall day.
An alternative to the phone-hotspot topology above: the laptop's own Wi-Fi
radio becomes the network, via wifi-ap/start-ap.sh. No phone hotspot, no
venue Wi-Fi, no dependency on anything but this machine — the same "this
laptop is the whole cloud" idea, extended to "this laptop is the whole
Wi-Fi network" too.
./wifi-ap/start-ap.sh # hostapd (wifi-ap/ap_wpa2.conf) + dnsmasq DHCP
./ringone-cloud start # the usual docker compose stack
# ... demo ...
./wifi-ap/stop-ap.sh # hands the interface back to NetworkManager
- hostapd:
wifi-ap/ap_wpa2.conf— WPA2-PSK, SSIDDotstar, channel 6,ap_isolate=1(clients can't see each other, only the gateway). Awifi-ap/ap_sae.conf(WPA3-SAE) variant is also checked in but unused by default — switch back withRINGONE_AP_CONF=wifi-ap/ap_sae.conf. - DHCP: dnsmasq, fixed range
10.42.0.10–10.42.0.50, laptop is10.42.0.1. Point the DK'sCONFIG_RINGONE_INFLUX_HOSTat10.42.0.1in this mode. - NTP: DHCP hands out option 42 (
ntp-server) =10.42.0.1, andstart-ap.shrunswifi-ap/ringone_ntpd.py, a minimal hand-rolled SNTP responder bound to just that IP, replying with the laptop's own system clock. The DK has no RTC battery and no other route to real time in this topology — its firmware's SNTP client can sync against10.42.0.1at boot instead of starting every telemetry timestamp at 1970-01-01.chronyd'slocal ... orphanmode was tried first and abandoned:chronyc trackingreportedStratum 8 / Normalinternally, but the actual wire-level NTP replies stayedStratum 0/ Kiss-of-Death regardless of config file location,manualdirective, or warm-up time — root cause not identified after extensive testing (verified with a raw NTP client, not just chronyc).ringone_ntpd.pyis simple enough (~50 lines) to fully verify: it doesn't sync from anywhere, it just timestamps a reply withtime.time(). - Wi-Fi interface defaults to
wlp0s20f3; override with./wifi-ap/start-ap.sh <iface>orRINGONE_AP_IFACE=<iface>on a different machine (ip -brief link showto find yours). start-ap.shreleases the interface from NetworkManager before binding it (hostapd can't share it with an activewpa_supplicantconnection);stop-ap.shhands it back so normal Wi-Fi returns after the demo.- This disconnects the laptop's current Wi-Fi network for as long as the AP is running — expected, not a bug, since the AP now is the network.
Three different things can be on the monitor, for three different moments:
| Surface | When | What it is |
|---|---|---|
Ring•One — Live (Grafana) |
Default | The dashboard itself — SpO2 number + graph, real hardware only |
presenter.html |
Talking to someone | Static architecture diagram next to a live embedded Grafana panel (d-solo route, GF_SECURITY_ALLOW_EMBEDDING=true) — context and proof in one window, no tab-switching mid-conversation |
stall-screen.html |
Booth unattended | Offline, self-contained, auto-advancing explainer loop (6 slides, ~9s each; arrow keys/click/space to drive manually instead) |
All three, plus both Grafana dashboards, carry the same small Dotstar
branding lockup (new_logo.svg, embedded inline everywhere — Grafana OSS
has no white-labeling, so its lockup is a plain HTML text panel baked into
each dashboard JSON, gated on GF_PANELS_DISABLE_SANITIZE_HTML=true).
- At home, on real Wi-Fi:
docker compose pull(never rely on stall Wi-Fi) - Set a static IP for the laptop in the phone's hotspot profile
- Turn on the hotspot, connect the laptop, then ping the DK from the laptop (and vice versa) to check for client isolation — some phones block device-to-device traffic on hotspot mode; if ping fails, look for a "client isolation" / "AP isolation" toggle in the hotspot settings
-
./ringone-cloud startbefore leaving home — first boot needs to pull nothing, but InfluxDB's setup step and Grafana provisioning still need a few seconds; better to catch failures with internet available - Rehearse once with
./ringone-cloud start --full+./ringone-cloud sim -- --count 2 --interval 2, confirm two series show up, thenCtrl-Cthe simulator and switch back to./ringone-cloud start(plain, real-only) for the actual stall - At the stall, talking to someone:
presenter.html— architecture diagram next to the live SpO2 panel, side by side, in one window - Stepping away from the booth:
stall-screen.htmlinstead — offline, self-contained, auto-advancing explainer loop. Arrow keys / click / spacebar to advance manually if you want to present it instead of leaving it to loop - Either page:
F11in the browser for kiosk mode - Know the recovery path:
./ringone-cloud status,./ringone-cloud logs <service>
./ringone-cloud resetAsks for y/N confirmation, then docker compose down -v — deletes the
InfluxDB, Grafana and Mosquitto volumes. ./ringone-cloud stop (no -v)
stops the stack without touching data.
docker compose config -q— validbash -n ringone-cloud— valid;shellcheck ringone-cloud— cleanpython3 -m json.toolon bothringone.jsonandringone-full.json— valid- Full stack brought up with
./ringone-cloud startand with--full, all healthchecks green both ways; confirmed the Grafana container actually recreates and switches its home dashboard between modes - Replicated the firmware's exact HTTP write (
curl -k https://localhost:443/api/v2/write?...withAuthorization: Token ...) and confirmed the point lands via a Flux query - Replicated the firmware's MQTT publish with
mosquitto_pubover TLS on 8883 and confirmed Telegraf forwards the point into InfluxDB - Ran the simulator concurrently with a real-device write and confirmed the
real-only dashboard's query still returns only the real
device_id— thesim-*exclusion is a Flux filter, not an operational rule - Confirmed both dashboards, their panel structure, and the datasource (
uid ringone-influx) via the Grafana HTTP API; confirmedGF_PANELS_DISABLE_SANITIZE_HTMLso the branding panel's inline SVG actually renders, and screenshotted both dashboards to confirm it does stop→start— prior data persisted (named volumes)- Screenshotted all 6
stall-screen.htmlslides (headless Chrome,--virtual-time-budgetto force its auto-advance timer) and confirmedbash/structural balance presenter.html's live embed couldn't be trusted from a headless--virtual-time-budgetscreenshot alone — cross-origin iframes don't reliably finish loading under virtual time. Verified for real instead: a real-wall-clock screenshot via the Chrome DevTools Protocol (Page.captureScreenshotafter an actualsleep), first showing Grafana's own "No data" state correctly styled, then a fresh device write turning it into a live "99%" — both the diagram alignment and the embed are real, not assumed
(See the session's verification transcript for exact commands and output — not duplicated here since it's a point-in-time log, not a durable fact about this repo.)
wifi-ap/ringone_ntpd.pyis a deliberate workaround, not a "real" NTP server.chronyd'slocal stratum N orphanmode was the first attempt at a fully-offline NTP source for the AP subnet. Its ownchronyc trackingcorrectly reportedStratum 8 / Normalonceorphanwas added, but the actual wire-level NTP replies (checked with a raw NTP client, not justchronyc) stayedStratum 0/ Kiss-of-Death regardless of the config file's location, adding themanualdirective, or waiting longer for it to "warm up." Root cause not identified after extensive testing.ringone_ntpd.pysidesteps the problem entirely with a ~65-line hand-rolled UDP responder that always answers with the laptop's owntime.time()— correct for this use case, but it skips everything a real NTP server does: no leap-second handling, no authentication, no per-client rate limiting, single-threaded blocking I/O, one request handled at a time. Fine for one DK on an isolated AP subnet; not a general-purpose NTP server. TODO: if this ever needs to be more than a stall demo, either properly root-cause thechronydorphan-mode gap (a minimal reproduction outside this project, filed upstream, would help track it down) or hardenringone_ntpd.pyinstead of quietly relying on it indefinitely.- MQTT path reconnects roughly every ~30s rather than holding one
steady connection (
mqtt_client.c— see mosquitto logs:disconnected: exceeded timeout). Telegraf's ingestion-time stopgap (telegraf.conf, nojson_time_key) means this doesn't corrupt data, but it's still a firmware-side keepalive/reconnect issue worth investigating if the MQTT path needs to look more "always connected" for a demo.
Apache 2.0 — see LICENSE.
"Ring•One" and "Dotstar" are trademarks of Dotstar Systems. This license does not grant permission to use these names in any product name, service, or promotional material without prior written permission from Dotstar Systems.