Synapse — Sensor Ingest/Connect Your Own Device

Connect Your Own Device

You have a board that is already on Wi-Fi and running its own firmware — MicroPython, CircuitPython, Tasmota, a Node-RED flow, a Python script on a Raspberry Pi. It just needs to be tied to your Tissue account so its readings land in your Cell.

Provisioning is three steps, none of which touch the device's firmware choice:

  1. Deploy a Cell with a sensor() handler — the code that receives readings.
  2. Register a device under that Cell — this mints the credentials.
  3. Point the device's MQTT client at ingest.tissue.dev:8883 with those credentials.

Anything that speaks MQTT 3.1.1 over TLS can be a Tissue sensor. No claim codes, no special firmware — the credentials are the provisioning.


Step 1 — deploy a Cell to receive readings

Readings dispatch to the sensor(event, env) export of the Cell the device is registered under. A minimal receiving Cell:

# ribo.toml
[cell]
name = "my-sensors"
js   = "./cell.js"

[[bindings]]
type     = "c3"
binding  = "DB"
database = "readings"
// cell.js
export default {
  async sensor(event, env) {
    const { value, unit } = await event.json();
    await env.DB.exec(
      "CREATE TABLE IF NOT EXISTS readings (device TEXT, metric TEXT, value REAL, unit TEXT, ts TEXT DEFAULT (datetime('now')))"
    );
    await env.DB.exec(
      "INSERT INTO readings (device, metric, value, unit) VALUES (?, ?, ?, ?)",
      event.device, event.metric, value, unit ?? null
    );
    return new Response("ok");
  },

  async fetch(req, env) {
    const rows = await env.DB.query(
      "SELECT * FROM readings ORDER BY ts DESC LIMIT 20"
    );
    return Response.json(rows);
  }
};
ribo deploy

See the Synapse Overview for a fuller example with rollups, pruning, and a dashboard.


Step 2 — register the device

ribo sensor add my-sensors
# ✓ Registered device for my-sensors
#
#   device_id  dev_a1b2c3d4
#   token      key_<64 hex chars>
#   topic      tissue/acct_9c3f21ab/dev_a1b2c3d4/<metric>
#
#   Save the token now — it is not shown again.

Three values come out of this, and they are everything the device needs:

Value Used as
device_id MQTT username (and a fine client id)
token (key_…) MQTT password — shown once, store it on the device
topic prefix Where to publish: tissue/<account>/<device>/<metric>

You can also register from the dashboard (Synapse section → register device) or, for fleet automation, the REST API:

curl -X POST https://api.tissue.systems/v1/sensors/my-sensors/devices \
  -H "Authorization: Bearer tok_<your api token>"

One device = one credential. Register each physical board separately — a leaked token then compromises only that board's stream, and you can rotate or disable it without touching the others.


Step 3 — connect from the device

First: installing MicroPython on a Wemos D1 mini (if you haven't)

If your board doesn't run MicroPython yet, it's a two-command flash. You need esptool (pipx install esptool) and a firmware image — for the D1 mini (ESP8266, 4 MB flash) download the latest ESP8266_GENERIC .bin from micropython.org/download/ESP8266_GENERIC.

# find your serial port (macOS: /dev/cu.usbserial-*, Linux: /dev/ttyUSB0, Windows: COMx)
esptool.py --port /dev/cu.usbserial-210 erase_flash
esptool.py --port /dev/cu.usbserial-210 --baud 460800 \
  write_flash --flash_size=detect 0 ESP8266_GENERIC-*.bin

Then talk to the REPL with mpremote (pipx install mpremote):

mpremote repl                    # Ctrl-] to exit

Join Wi-Fi from the REPL (MicroPython remembers it across reboots):

import network
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect("home-net", "wifi-password")
wlan.ifconfig()                  # prints the IP once connected

Copy your script to the board so it runs at boot:

mpremote cp main.py :main.py
mpremote reset

Troubleshooting: if the board boot-loops after flashing, re-flash with -fm dio added to the write_flash command (some D1 mini clones need it); if write_flash fails mid-transfer, drop --baud to 115200 and avoid USB hubs. Flashing MicroPython replaces whatever firmware was on the board — hold-FLASH-on-boot tricks from other firmware no longer apply; to go back, just flash the other firmware image the same way.

MicroPython client (ESP32 / ESP8266)

Works with the built-in dht and machine modules and umqtt.simple (bundled in most builds; otherwise import mip; mip.install("umqtt.simple")).

import json, ssl, time
import dht, machine
from umqtt.simple import MQTTClient

DEVICE_ID = "dev_a1b2c3d4"                 # from `ribo sensor add`
TOKEN     = "key_..."                      # shown once at registration
ACCOUNT   = "acct_9c3f21ab"
PREFIX    = "tissue/{}/{}".format(ACCOUNT, DEVICE_ID)

sensor = dht.DHT22(machine.Pin(4))         # DATA on GPIO4 (D2 on a D1 mini)

# TLS: encrypted but the server cert is not validated — see the note below.
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.verify_mode = ssl.CERT_NONE

def connect():
    c = MQTTClient(DEVICE_ID, "ingest.tissue.dev", port=8883,
                   user=DEVICE_ID, password=TOKEN, keepalive=60, ssl=ctx)
    c.connect()
    return c

client = connect()
while True:
    try:
        sensor.measure()
        client.publish((PREFIX + "/temperature").encode(),
                       json.dumps({"value": sensor.temperature(), "unit": "C"}))
        client.publish((PREFIX + "/humidity").encode(),
                       json.dumps({"value": sensor.humidity(), "unit": "%RH"}))
    except OSError:                        # Wi-Fi blip / broker disconnect
        time.sleep(5)
        client = connect()
    time.sleep(30)                         # DHT22 minimum interval is 2 s

Notes for MicroPython:

  • Older firmware (pre-1.20) uses ssl=True, ssl_params={...} instead of an SSLContext — check your build's umqtt signature.
  • ESP8266 is tight on RAM for TLS. It works, but connect early (before allocating big buffers) and keep payloads small. An ESP32 is the comfortable choice.
  • Battery devices: if you deep-sleep between readings, connect → publish → machine.deepsleep(); the TLS handshake is paid on every wake, so batch several metrics per wake.

Arduino / C++ client (ESP8266 / ESP32)

The same flow in C++ with PubSubClient and ArduinoJson — this is the pattern the tissue Sense demo firmware uses (full source):

#include <ESP8266WiFi.h>            // ESP32: #include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>

const char* DEVICE_ID = "dev_a1b2c3d4";   // from `ribo sensor add`
const char* TOKEN     = "key_...";        // shown once at registration
const char* ACCOUNT   = "acct_9c3f21ab";
String prefix = String("tissue/") + ACCOUNT + "/" + DEVICE_ID + "/";

WiFiClientSecure net;
PubSubClient mqtt(net);

void connectMqtt() {
  // Encrypted but the server cert is not validated — fine for bring-up.
  // For production, pin the Let's Encrypt root instead (see the TLS note below).
  net.setInsecure();
  mqtt.setServer("ingest.tissue.dev", 8883);
  mqtt.setBufferSize(512);
  while (!mqtt.connected()) {
    if (!mqtt.connect(DEVICE_ID, DEVICE_ID, TOKEN)) delay(5000);
  }
}

void publishMetric(const char* metric, float value, const char* unit) {
  JsonDocument doc;
  doc["value"] = value;
  doc["unit"]  = unit;
  char buf[64];
  size_t n = serializeJson(doc, buf, sizeof(buf));
  mqtt.publish((prefix + "env/" + metric).c_str(), (const uint8_t*)buf, n, false);
}

unsigned long lastReport = 0;

void setup() {
  WiFi.begin("home-net", "wifi-password");
  while (WiFi.status() != WL_CONNECTED) delay(250);
  connectMqtt();
}

void loop() {
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();
  if (millis() - lastReport >= 60000) {   // report once a minute
    publishMetric("temperature", 21.4, "C");
    lastReport = millis();
  }
}

Notes for C++:

  • ESP8266 TLS needs the buffer trimnet.setBufferSizes(512, 512) (BearSSL) keeps the handshake inside the heap; on ESP32 this isn't needed.
  • Cert validation: setInsecure() encrypts but doesn't validate. To validate, sync the clock (configTime) and pin ISRG Root X1 — ESP8266: net.setTrustAnchors(&trust) with a BearSSL::X509List, ESP32: net.setCACert(pem).
  • PubSubClient publishes at QoS 0 with a default 256-byte packet limit — mqtt.setBufferSize(512) above gives JSON payloads headroom.

Anything else

Any MQTT 3.1.1 client works the same way — host ingest.tissue.dev, port 8883, TLS, username = device_id, password = token, publish inside your topic prefix. A Raspberry Pi / laptop smoke test with the Mosquitto client tools (macOS: brew install mosquitto; Debian/Ubuntu: sudo apt install mosquitto-clients; Windows: winget install EclipseFoundation.Mosquitto):

mosquitto_pub -h ingest.tissue.dev -p 8883 --tls-use-os-certs \
  -u dev_a1b2c3d4 -P key_<64 hex chars> \
  -t 'tissue/acct_9c3f21ab/dev_a1b2c3d4/temperature' \
  -m '{"value":21.4,"unit":"C"}'

(--tls-use-os-certs validates against your system's trusted CAs and works the same on macOS, Linux, and Windows. On mosquitto older than 2.0.11, pass --cafile /etc/ssl/cert.pem (macOS) or --cafile /etc/ssl/certs/ca-certificates.crt (Linux) instead.)

For Tasmota-style firmware, set the MQTT host/port/user/password in its UI and configure the telemetry topic to sit inside the device's prefix.

TLS validation. The MicroPython example (like most microcontroller setups) encrypts the connection but skips certificate validation, because shipping a CA bundle on-device is fiddly. That protects credentials from passive listeners but not from an active man-in-the-middle. For production devices, pin the Let's Encrypt root (ISRG Root X1) and use ssl.CERT_REQUIRED — on desktop-class clients (mosquitto_pub, Python + paho) always validate; it's one flag.


Step 4 — verify

Publish once, then check the Cell received it:

curl https://my-sensors.<your-subdomain>.tissue.dev/
# → your fetch() handler's latest-readings JSON

# or straight into the database:
ribo db exec readings "SELECT * FROM readings ORDER BY ts DESC LIMIT 5"

Cloud to device: subscribing and triggering actions

Everything above is uplink — device to Cell. The same MQTT connection works the other way: the device subscribes to a command topic inside its own namespace, and your Cell publishes to it. That's how a Cell blinks an LED, starts a motor, or moves a servo on real hardware.

By convention, commands live under the device's cmd/ prefix — tissue/<account>/<device>/cmd/<action>. A device may only subscribe within its own namespace (broker-enforced, same as publishing), and cmd/* messages are never dispatched into your Cell's sensor() handler — a command can't echo back and masquerade as a reading.

Device side — subscribe (MicroPython)

Extend the reporting example: set a callback, subscribe once after connect, and poll check_msg() in the loop. The callback decodes the action from the last topic segment:

import json
from machine import Pin, PWM

led = Pin(2, Pin.OUT)            # onboard LED (active-low on ESP8266)
servo = PWM(Pin(14), freq=50)    # servo signal on GPIO14 (D5); external 5V supply!

def on_cmd(topic, msg):
    action = topic.decode().rsplit("/", 1)[-1]
    body = json.loads(msg) if msg else {}
    if action == "led":
        led.value(0 if body.get("on") else 1)          # active-low
    elif action == "servo":
        angle = min(180, max(0, int(body.get("angle", 90))))
        servo.duty(40 + angle * 75 // 180)             # ~0.5–2.4 ms pulse; tune per servo

client.set_callback(on_cmd)
client.subscribe((PREFIX + "/cmd/#").encode())

while True:
    client.check_msg()           # non-blocking: handles at most one pending command
    # ... read sensor, publish, sleep ...

Device side — subscribe (Arduino / C++)

Same three moves with PubSubClient: set a callback, subscribe after (every) connect, and the mqtt.loop() you already call delivers messages. Extending the C++ reporting client above:

#include <Servo.h>

Servo servo;                          // signal on D5 (GPIO14); external 5V supply!

void onCmd(char* topic, byte* payload, unsigned int length) {
  String t(topic);
  String action = t.substring(t.lastIndexOf('/') + 1);
  JsonDocument body;
  if (deserializeJson(body, payload, length) != DeserializationError::Ok) return;

  if (action == "led") {
    // Onboard LED is active-low on the ESP8266
    digitalWrite(LED_BUILTIN, body["on"] ? LOW : HIGH);
  } else if (action == "servo") {
    int angle = constrain((int)(body["angle"] | 90), 0, 180);
    servo.write(angle);
  }
}

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  servo.attach(14);                   // D5 on a D1 mini
  // ... Wi-Fi + connectMqtt() as in the reporting client ...
  mqtt.setCallback(onCmd);
  mqtt.subscribe((prefix + "cmd/#").c_str());
}

One subtlety: subscriptions don't survive a reconnect — re-issue mqtt.subscribe(...) inside connectMqtt() (right after a successful mqtt.connect) so a Wi-Fi blip doesn't silently stop command delivery.

Two hardware notes: drive motors and servos from their own power supply (a servo can pull an amp — the board's 3V3 regulator can't), sharing only ground with the board; and treat commands as best-effort (QoS 0, no queueing) — if the device is offline when the command is published, it's gone. Design commands to be safe to repeat.

Cell side — publish a command

Cells can't hold an MQTT connection, so they publish through the downlink HTTP endpoint using the device's own credentials. The topic is relative to the device's namespace — a credential can never publish outside it:

// in your Cell — e.g. from fetch() on a button press, or from pulse() on a schedule
await fetch("https://ingest.tissue.dev/publish", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    device_id: "dev_a1b2c3d4",
    token: env.DEVICE_TOKEN,     // store as a vault binding — never in code
    topic: "cmd/led",            // → tissue/<account>/dev_a1b2c3d4/cmd/led
    payload: { on: true },
  }),
});

payload is published verbatim as JSON (a JSON string publishes its raw bytes). The response is {"ok":true,"topic":"<full topic>"}. Keep the device token in a vault binding (ribo vault set <cell> DEVICE_TOKEN) so it never appears in your source.

A satisfying first test needs no firmware at all — subscribe from your laptop as the device, then publish from anywhere:

mosquitto_sub -h ingest.tissue.dev -p 8883 --tls-use-os-certs \
  -u dev_a1b2c3d4 -P key_... \
  -t 'tissue/acct_9c3f21ab/dev_a1b2c3d4/cmd/#' -v &

curl -X POST https://ingest.tissue.dev/publish \
  -H 'content-type: application/json' \
  -d '{"device_id":"dev_a1b2c3d4","token":"key_...","topic":"cmd/led","payload":{"on":true}}'
# the subscriber prints: tissue/.../cmd/led {"on":true}

Troubleshooting

Symptom Likely cause
CONNECT refused / auth failure Wrong device_id/token pair, or the device was disabled. Rotate the token if it's lost — it can't be recovered.
TLS handshake fails on-device RAM pressure (ESP8266), or an old firmware needing the ssl_params form. Try the mosquitto_pub smoke test from a laptop to confirm the credentials are good.
Publishes succeed, nothing reaches the Cell Topic outside your tissue/<account>/<device>/ prefix (synapse silently rejects), or you're over the rate limits (over-rate messages are dropped, not queued).
Readings arrive but the table is empty Your sensor() handler threw — check that the payload you publish matches what the handler parses.

See also