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:
- Deploy a Cell with a
sensor()handler — the code that receives readings. - Register a device under that Cell — this mints the credentials.
- Point the device's MQTT client at
ingest.tissue.dev:8883with 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 anSSLContext— check your build'sumqttsignature. - 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 trim —
net.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 aBearSSL::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
- Synapse Overview — transports, topics, payloads, the full example Cell
- Managing Devices — disable, rotate, delete; credential model; rate limits
- Sensor API Reference —
/v1/sensors/*REST endpoints for automation