Our open schema · for builders

The whole schema, in one place.

What this is
The complete oat-ods spec, for developers
Everything here
Published, versioned, and downloadable
For
Anyone writing a device, an endpoint, or an integration

The Data schema page explains oat-ods for growers. This one is for builders: the complete message model, the whole measurement vocabulary, the shared library every device links, and a receiver you can run tonight. Everything on this page is published at a stable address and downloadable — the JSON Schema is the contract; the rest is here so you never have to guess.

01The two messages.

oat-ods has exactly two message types, and a receiver tells them apart by msg_type. The first carries a reading. It holds one measurement — value, what it measured, when, the place, and the gadget:

{
  "schema": "oat-ods/0.3",
  "msg_type": "observation",              // default; may be omitted
  "observed_at": "2026-06-25T14:30:00Z",
  "stream": {
    "id": "gh2-north-air",                // the place — the primary key
    "name": "GH2 North Bench Air",
    "location": "Greenhouse 2 / North Bench"
  },
  "measurement": "temperature",
  "value": 24.31,
  "unit": "Cel",
  "agg": { "window_s": 60, "samples": 28, "method": "mean" },
  "source": {
    "tier": "oat-ble-listener",
    "gateway_id": "oat-greenhouse-2",     // assignable node id, not the MAC
    "physical_id": "AA:BB:CC:DD:EE:01",   // the gadget, swap it any time
    "brand": "Govee", "model": "H5075",
    "battery_pct": 88, "rssi": -78
  }
}

The second is a status message: how a node says it is alive without any reading riding along (§04). Same envelope, no stream/measurement/value:

{
  "schema": "oat-ods/0.3",
  "msg_type": "status",
  "state": "online",                      // "online" | "offline"
  "observed_at": "2026-06-25T14:30:00Z",
  "source": { "tier": "oat-ble-listener", "gateway_id": "oat-greenhouse-2",
              "device_id": "oat-3c8a1fa77d14" },  // the physical board (efuse MAC)
  "health": { "uptime_s": 86400, "boot_count": 3, "reset": "poweron",
              "free_heap": 142000, "rssi": -71, "push_ok": 1440, "push_fail": 2,
              "transport": "https", "tls_ok": true, "chip": "ESP32-C6" }
}

The rule that makes it durable

A reading is filed under stream.id — the place, not the hardware. Replace a dead sensor, keep the same stream.id, and the series continues with no seam. source.physical_id is just swappable provenance. Everything else in the schema follows from that one split.

02Every field.

The complete field set. A consumer must ignore fields it doesn't recognize — that rule is what lets the format grow without breaking anything downstream.

oat-ods/0.3 fields
FieldTypeReqMeaning
schemastringyesoat-ods/{major}.{minor}. Format + version tag.
$schemaurlOptional pointer to this JSON Schema (self-description).
msg_typeenumobservation (default) or status.
stateenumstatusonline / offline. Required on a status message.
observed_atISO-8601yesUTC instant the value was measured or the aggregate closed.
stream.idstringobs.Primary key. Logical slot (place/role). Stable across sensor swaps.
stream.namestringHuman-friendly label.
stream.locationstringFree-text place / Home Assistant area.
measurementstringobs.Quantity kind from the vocabulary (§03). Open string.
valuenumber/string/boolobs.The reading.
unitstringSenML unit symbol. Canonical units only on the wire.
aggobjectwindow_s, samples, method (mean/last/min/max/sum). Absent ⇒ instantaneous.
source.tierstringWhich emitter produced it (e.g. oat-ble-listener, home_assistant).
source.gateway_idstringone ofAssignable node id (not the raw MAC). Required if physical_id absent.
source.physical_idstringone ofThe hardware now filling the slot (MAC/serial). Required if gateway_id absent.
source.device_idstringStatus message only: the node's hardware id (efuse MAC, e.g. oat-3c8a1fa77d14) — the node-level twin of physical_id. Identity still keys on gateway_id; this says which physical board holds the job.
source.brand / modelstringDevice make / model.
source.battery_pct / rssinumberDevice health that rides along with a reading.
healthobjectNode self-report, carried on a status message (§04).

“obs.” = required on an observation; “status” = required on a status message; “one of” = source needs at least one of gateway_id / physical_id.

03The full vocabulary.

This is the part a single sketch never shows you: every measurement the schema knows, 28 of them, each bound to the open standards it profiles — the SenML unit, the Home Assistant device class, the QUDT quantity kind, and the UCUM code. We chose these names; we did not invent them. The table is generated from measurands.json, so it is always exactly what the devices emit.

28 measurements · oat-ods/0.3
measurementunit (SenML)HA device_classQUDTUCUMsource
temperature Cel temperature Temperature Cel sensor
humidity %RH humidity RelativeHumidity % sensor
soil_moisture % moisture % sensor
soil_conductivity uS/cm uS/cm sensor
illuminance lx illuminance Illuminance lx sensor
pressure hPa atmospheric_pressure Pressure hPa sensor
co2 ppm carbon_dioxide [ppm] sensor
pm25 ug/m3 pm25 ug/m3 sensor
pm10 ug/m3 pm10 ug/m3 sensor
pm1 ug/m3 pm1 ug/m3 sensor
tvoc ppb volatile_organic_compounds_parts [ppb] sensor
formaldehyde mg/m3 mg/m3 sensor
battery % battery % sensor
voltage V voltage Voltage V sensor
current A current ElectricCurrent A sensor
power W power Power W sensor
weight kg weight Mass kg sensor
level % % sensor
rssi dBm signal_strength sensor
steps / {steps} sensor
energy Wh energy Energy W.h sensor
contact opening sensor
motion motion sensor
presence occupancy sensor
lock lock sensor
button sensor
dewpoint Cel temperature DewPointTemperature Cel derived
vpd kPa kPa derived

Open by construction. measurement is not a closed list. A device may send a measurement that isn't here yet; the schema asks only for a non-empty string, and a key the dictionary hasn't mapped is forwarded raw (its own name, no unit) rather than dropped — so nothing is ever lost, and new keys surface for adding to the table. source: derived rows (dewpoint, VPD) are computed by the endpoint, never sent by a node.

04Knowing a node is alive.

A node reports liveness with a status message, borrowing the idea from Eclipse Sparkplug. Over a webhook it POSTs state:"online" on boot and on a heartbeat; over MQTT it sets a Last Will of state:"offline" so the broker announces a drop automatically.

The heartbeat interval is a fixed constant of the format: 60 seconds, written here and not carried in the payload — because a packet that never arrives can't declare its own cadence. For an endpoint to detect absence it must know the expected beat in advance. Treat > 3× the cadence (180 s) with no heartbeat as a liveness alarm, not a single missed beat. Monitoring is always the endpoint's job; the node just reports.

A status message may carry a health object — the node's honest self-report:

the health envelope
uptime_s / boot_countSeconds since boot; NVS-persisted boot counter.
resetLast reset reason: poweron / usb (post-flash, native-USB chips) / panic / task_wdt / brownout
free_heap / min_free_heap / largest_blockHeap now / low-water / largest contiguous block (TLS + JSON headroom).
push_ok / push_failDelivery counters since boot.
transport / tls_okhttps or http, and whether TLS is holding.
rssi / wifi_reconnectsUplink signal (dBm); station reconnects since boot.
chip / temp_c / loops_per_secSilicon model; die temperature; main-loop rate (a liveness / overwork signal).
lan_ip / ssid / mdnsWhere to find the node on its own network: station IP, the Wi-Fi it joined, and its mDNS name (oat-gh2.local) — so an endpoint can show “open the node here.”
ble_seen / ble_decoded / ble_matchedRadio workload since boot — adverts heard / decoded / matched to a reportable sensor (BLE-tier nodes only).

05The shared device library.

Every Open Agriculture Technology sketch — the BLE listener, the SDI-12 reader, the analog and Modbus readers — links one shared library, oat_ods, to build these messages. It is header-only (ArduinoJson 7), carries no transport of its own (your sketch owns the HTTP or MQTT send), and holds the native / SenML / flat / status encoders plus the Home Assistant discovery helpers. Write a new device and it pours into the same envelope: only the transducer differs. That is one envelope, every sketch.

You get the library two ways, so a sketch always compiles as-is:

  • Inside every sketch. Each download from the Software Library bundles lib/oat_ods/ alongside the project, and its platformio.ini already points at it (lib_extra_dirs = ../lib). Unzip, build, flash — nothing else to fetch.
  • On its own, here. Grab the library by itself to drop into your own project's lib/ folder, or just to read the source.
the oat_ods library
oat-ods-lib.zipdownloadThe whole library — drop oat_ods/ into your project's lib/.
oat_ods.hsourceThe encoder: encodeNative / Senml / Flat / Status + HA-discovery helpers + the logical MQTT topic.
oat_measurands.hsourceThe measurement lookup — the firmware side of the vocabulary in §03.
README.mddocsHow to link it and emit a reading. CC-BY-4.0.

06How it travels.

The identical message reaches an endpoint two ways, and a device offers whichever suits your setup — the receiver can’t tell them apart. Pick by what you already run:

the two transport bindings
WebhookHTTP POSTOne message per POST, Content-Type: application/json, one node-wide URL, an optional Authorization header. The easy target for a custom or AI-built endpoint.
MQTTpublishOne retained message per stream on a logical topic, plus a liveness topic with a Last Will — spelled out below.

The MQTT binding is worth spelling out. The topic is keyed on the place, not the hardware, so it survives a sensor swap; each value is published retained, so a new subscriber always gets the latest reading immediately; and node liveness rides its own topic with a Last Will the broker publishes on a dropped connection:

# one retained message per stream + measurement
oat/{gateway_id}/{stream.id}/{measurement}

# example
oat/oat-greenhouse-2/gh2-north-air/temperature   → 24.31

# node liveness — Last Will publishes "offline" automatically on a dropped connection
oat/{gateway_id}/status                            → {"state":"online"}

Running Home Assistant? A node can announce itself with MQTT discovery instead, so its readings show up as entities with no hand-configuration — the oat_ods library builds those discovery configs for you. Either way the payload is the same oat-ods message, and there’s a runnable subscriber for it in the next section.

07A receiver you can run.

The other end is just as simple, and there’s a runnable reference for both transports (§06). Copy one, set a couple of environment variables, and start it.

Webhook. Drop-in HTTP endpoints that accept oat-ods over a POST: set one secret and run. Both handle single messages and batches, verify an optional oat1 HMAC signature over the raw body (so the key is never sent, safe even over plain HTTP), and reject replays by sequence number.

webhook receivers
oat_ingest.pyPythonFlask. pip install flask, set OAT_INGEST_KEY, run — serves on :8099.
oat_ingest.jsNodeExpress. npm i express, set OAT_INGEST_KEY, run — same behavior, same port.

MQTT. Subscribers that connect to your broker and print every reading as it lands. Retained messages arrive the moment they subscribe, so a fresh start immediately shows the latest value for every stream, and the status topic is read as liveness. Auth is the broker’s job (username / password + TLS), not a per-message signature.

MQTT subscribers
oat_ingest_mqtt.pyPythonpaho-mqtt. pip install paho-mqtt, set OAT_MQTT_HOST, run — subscribes to oat/#.
oat_ingest_mqtt.jsNodemqtt.js. npm i mqtt, set OAT_MQTT_URL, run — same behavior.

Want to watch messages land without writing anything? Point a device (or curl) at the live test endpoint and see each reading arrive, judged against the schema.

08Conformance & versioning.

A message is oat-ods/0.3 conformant when an observation carries schema, observed_at, stream.id, measurement, value, and a source with at least one of gateway_id / physical_id; any unit is a SenML symbol; and each message holds exactly one measurement. Validate against the JSON Schema with any standard validator.

The version lives in the schema tag, oat-ods/{major}.{minor}. New optional fields or new measurements bump the minor; a breaking change bumps the major. Because consumers ignore unknown fields, minor growth never breaks a receiver already in the field. The highest-leverage promise is that the format is trivially consumable: fetch the schema, validate, generate a receiver in minutes — the bet is on the source being clean, not on any one destination persisting.

09Every artifact.

Everything we publish for oat-ods, in one place:

published & downloadable
oat-ods-0.3.schema.jsonschemaThe machine contract for one message. Everything else here follows it.
measurands.jsondictionaryEvery measurement and its SenML / HA / QUDT / UCUM mappings.
oat-ods-lib.ziplibraryThe shared oat_ods device library (§05).
webhook · MQTT receiversreceiverRunnable drop-ins, Python and Node (§07).
oat-recipeschemaThe sibling schema for the grow plan going out to a device.
Software LibraryfirmwareFlashable devices that emit oat-ods, each bundling the library.

Frequently asked questions.

Is there a JSON Schema for sensor data I can validate against?

Yes. oat-ods publishes a JSON Schema (Draft 2020-12) at a stable URL, oat-ods-0.3.schema.json, describing one observation message: the required fields (schema, observed_at, stream.id, measurement, value, source) and an optional aggregation and health block. You can validate any message with a standard validator in any language, and generate a receiver from it.

Do I need a library to compile an OAT device sketch?

Yes, one: the shared oat_ods library, which builds the message. You do not have to fetch it separately. Every sketch download bundles lib/oat_ods/ next to the project and its platformio.ini already resolves it with lib_extra_dirs, so the project compiles as-is. You can also download the library on its own to drop into another project.

How do I write an endpoint to receive sensor readings over a webhook?

Accept an HTTP POST with a JSON body and store it. Reference receivers in Python (Flask) and Node (Express) are published and runnable: set one secret and start the server. They handle single messages and batches, verify an optional HMAC signature over the raw body so the key is never transmitted, and reject replays by sequence number. Validate the body against the published JSON Schema before trusting it.

Can I publish sensor readings over MQTT instead of a webhook?

Yes. A device can publish each reading to an MQTT broker on a logical topic keyed on the place — oat/{gateway_id}/{stream.id}/{measurement} — with the value retained so a new subscriber gets the latest reading immediately. Node liveness rides a separate status topic with an MQTT Last Will that publishes "offline" when the connection drops. On Home Assistant, MQTT discovery makes the readings appear as entities automatically, and the message body is identical to the webhook form. A runnable subscriber in Python (paho-mqtt) and Node (mqtt.js) is published alongside the webhook receivers.

What measurements does the schema support?

The dictionary defines 28 measurements — temperature, humidity, soil moisture and conductivity, CO2, illuminance, pressure, particulates, VOCs, battery, power, weight, contact, motion, VPD and more — each mapped to its SenML unit, Home Assistant device class, QUDT quantity kind and UCUM code. The list is open: a device may send a measurement that isn't listed, and it is forwarded raw rather than dropped, so nothing is lost.

How does the format report that a device has gone offline?

With a status message. A node sends state "online" on boot and on a fixed 60-second heartbeat; over MQTT it registers a Last Will of state "offline" that the broker publishes automatically when the connection drops. The heartbeat interval is a constant of the format rather than a payload field, because an endpoint must know the expected cadence in advance to detect a missing node. More than three missed beats is treated as an alarm.