Skip to content

Repository files navigation

ringone-cloud-local

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.

What this is

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.

Quick start

# 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 1

start 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.

Ports

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)

Firmware build + provisioning

  1. 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 start prints this line with the correct IP filled in every time (detected via ip route get 1).

  2. Telemetry interval. The demo wants 1–2 s resolution, but CONFIG_RINGONE_TELEMETRY_INTERVAL_SEC (app/Kconfig.ringone:14-21) is range 10 300the firmware will not build below 10 s, and the MQTT set_interval command (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.

  3. 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 named dotstar-ringone-setup (CONFIG_RINGONE_SOFTAP_SSID, app/Kconfig.ringone:69-72) and the NCS softap_wifi_provision library serves an HTTP captive portal for entering your hotspot's SSID/password (wifi_prov.c:325-352). It shuts down automatically after CONFIG_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.

  4. 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-token
    

    Takes effect on next boot (influx_telemetry.c:267-279 loads it at init). ./ringone-cloud start prints this exact command.

Data contract (source of truth is the firmware)

Extracted from app/src/influx_telemetry.c and app/src/mqtt_client.c — where this differs from the original prompt, the firmware wins.

Trust matrix — what you show vs. what you debug with

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 at influx_telemetry.c:43)
  • Authorization: Token <token> (note: Token, not Bearerinflux_telemetry.c:153)
  • Transport: IPPROTO_TLS_1_2 socket, TLS_PEER_VERIFY_NONE (influx_telemetry.c:108-122) — any certificate is accepted, which is exactly why the self-signed cert from certs/gen-certs.sh works 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_id format: rng-%02X%02X from the BLE address (influx_telemetry.c:85-86). rssi_ble is currently hardcoded to -60 pending a RING_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 port 8883.
  • Publish topic ring-one/<device_id>/telemetry (QoS 0, mqtt_client.c:571), JSON body (mqtt_client.c:548-563):
    {"device_id":"<id>","ts":<unix>,"hr":<u8>,"spo2":<u8>,"temp":<f.2>,"steps":<u32>,"battery":<u8>,"rssi_ble":<i32>}
    Subscribe topic ring-one/<device_id>/cmd (QoS 1, mqtt_client.c:198); status topic ring-one/<device_id>/status (mqtt_client.c:134).

⚠️ Discrepancy found in the firmware itself: the MQTT path derives 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 findings

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:14 arms a 30 s hardware watchdog; watchdog_feed() is called every 2 s from the main workqueue's notify_work_handler (main.c:256), completely independent of networking.
  • influx_telemetry_publish() and mqtt_publish_telemetry() — the only two calls main.c makes into the networking subsystems (main.c:263-264) — are non-blocking enqueues (k_msgq_put(..., K_NO_WAIT) at influx_telemetry.c:295 and mqtt_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 dedicated influx_pub and mqtt_client threads (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.

Topology (phone-hotspot stall)

                     ┌─────────────────────────────┐
                     │   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.

Topology (laptop as its own Wi-Fi AP)

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, SSID Dotstar, channel 6, ap_isolate=1 (clients can't see each other, only the gateway). A wifi-ap/ap_sae.conf (WPA3-SAE) variant is also checked in but unused by default — switch back with RINGONE_AP_CONF=wifi-ap/ap_sae.conf.
  • DHCP: dnsmasq, fixed range 10.42.0.1010.42.0.50, laptop is 10.42.0.1. Point the DK's CONFIG_RINGONE_INFLUX_HOST at 10.42.0.1 in this mode.
  • NTP: DHCP hands out option 42 (ntp-server) = 10.42.0.1, and start-ap.sh runs wifi-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 against 10.42.0.1 at boot instead of starting every telemetry timestamp at 1970-01-01. chronyd's local ... orphan mode was tried first and abandoned: chronyc tracking reported Stratum 8 / Normal internally, but the actual wire-level NTP replies stayed Stratum 0 / Kiss-of-Death regardless of config file location, manual directive, or warm-up time — root cause not identified after extensive testing (verified with a raw NTP client, not just chronyc). ringone_ntpd.py is simple enough (~50 lines) to fully verify: it doesn't sync from anywhere, it just timestamps a reply with time.time().
  • Wi-Fi interface defaults to wlp0s20f3; override with ./wifi-ap/start-ap.sh <iface> or RINGONE_AP_IFACE=<iface> on a different machine (ip -brief link show to find yours).
  • start-ap.sh releases the interface from NetworkManager before binding it (hostapd can't share it with an active wpa_supplicant connection); stop-ap.sh hands 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.

On-screen surfaces

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).

Stall-day checklist

  • 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 start before 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, then Ctrl-C the 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.html instead — 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: F11 in the browser for kiosk mode
  • Know the recovery path: ./ringone-cloud status, ./ringone-cloud logs <service>

Reset

./ringone-cloud reset

Asks 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.

Verification performed

  • docker compose config -q — valid
  • bash -n ringone-cloud — valid; shellcheck ringone-cloud — clean
  • python3 -m json.tool on both ringone.json and ringone-full.json — valid
  • Full stack brought up with ./ringone-cloud start and 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?... with Authorization: Token ...) and confirmed the point lands via a Flux query
  • Replicated the firmware's MQTT publish with mosquitto_pub over 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 — the sim-* 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; confirmed GF_PANELS_DISABLE_SANITIZE_HTML so the branding panel's inline SVG actually renders, and screenshotted both dashboards to confirm it does
  • stopstart — prior data persisted (named volumes)
  • Screenshotted all 6 stall-screen.html slides (headless Chrome, --virtual-time-budget to force its auto-advance timer) and confirmed bash/structural balance
  • presenter.html's live embed couldn't be trusted from a headless --virtual-time-budget screenshot 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.captureScreenshot after an actual sleep), 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.)

Known issues / TODO

  • wifi-ap/ringone_ntpd.py is a deliberate workaround, not a "real" NTP server. chronyd's local stratum N orphan mode was the first attempt at a fully-offline NTP source for the AP subnet. Its own chronyc tracking correctly reported Stratum 8 / Normal once orphan was added, but the actual wire-level NTP replies (checked with a raw NTP client, not just chronyc) stayed Stratum 0 / Kiss-of-Death regardless of the config file's location, adding the manual directive, or waiting longer for it to "warm up." Root cause not identified after extensive testing. ringone_ntpd.py sidesteps the problem entirely with a ~65-line hand-rolled UDP responder that always answers with the laptop's own time.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 the chronyd orphan-mode gap (a minimal reproduction outside this project, filed upstream, would help track it down) or harden ringone_ntpd.py instead 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, no json_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.

License

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.

About

Fully local/offline InfluxDB + Mosquitto + Telegraf + Grafana stack for the Ring•One smart-ring demo — no venue Wi-Fi or internet needed

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages