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 three messages.

oat-ods has three 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, and the sensor that took it, named by its own hardware id:

{
  "schema": "oat-ods/0.3",
  "msg_type": "observation",              // default; may be omitted
  "observed_at": "2026-06-25T14:30:00Z",
  "stream": {
    "id": "A4:C1:38:0D:1E:2F",            // the sensor's own hardware id (here its BLE MAC)
    "name": "GVH5075_1E2F",               // only a name the sensor advertises itself
    "location": "Greenhouse 2"            // optional: typed at setup, describes the node
  },
  "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",     // the node's setup name (its hardware id if none)
    "physical_id": "A4:C1:38:0D:1E:2F",   // the same hardware id, kept as provenance
    "brand": "Govee", "model": "H5075",
    "battery_pct": 88, "rssi": -78
  }
}

The second is a batch, and it is what a real node actually sends. A node with several sensors does not open a request per reading: it wraps them in one signed body. Each entry in messages is a complete observation minus the envelope keys, so if you can parse the message above you can parse these:

{
  "schema": "oat-ods/0.3",
  "msg_type": "batch",
  "sent_at": "2026-07-26T05:26:00Z",      // when the BODY was sent
  "seq": 3000004,                         // monotonic per body; replay detection
  "source": {                             // node-level provenance, stated once
    "tier": "oat-ds18b20-node",
    "gateway_id": "walkin-1",
    "farm_id": "riverbend-farm",          // the operator: one grower, many gateways
    "fw": "OAT-DS18B20-Node/2.0.4"
  },
  "messages": [
    { "observed_at": "2026-07-26T05:26:00Z",   // per MESSAGE, never on the envelope
      "stream": { "id": "ds18b20:28ff641f8c1a3b02" },
      "measurement": "temperature", "value": 3.44, "unit": "Cel",
      "agg": { "window_s": 60, "samples": 6, "method": "mean" },
      "source": { "physical_id": "ds18b20:28ff641f8c1a3b02",
                  "brand": "Analog Devices", "model": "DS18B20" } }
  ]
}

Two timestamps, and why they are not the same

sent_at is when the body left the node. observed_at is when a value was measured, and it lives on each message because it is a property of that reading. They can be far apart and that is not an error: a node keeps sampling through a network outage, so the first body after one legitimately carries hours of readings. File on observed_at. agg.window_s tells you how long each average actually covers, which is the window the node really used rather than the interval someone configured.

The third 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

The node names nothing. stream.id is the sensor's own hardware id (a BLE MAC, a 1-Wire address, a chip serial), and stream.name appears only when the device supplies a label about itself. Mapping that id to a place is the endpoint's job, and the endpoint is where the series is filed. Replace a dead sensor, map the new id to the same place, and the series continues with no seam; the change of hardware stays visible in the record. 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.
$schemaurl—Optional pointer to this JSON Schema (self-description).
msg_typeenum—observation (default), batch, or status.
sent_atISO-8601batchWhen the body was sent. Not a measurement time — see observed_at.
seqint—Monotonic per-body counter from the node. Use it to reject replays.
messagesarraybatchOne or more observations. Required when msg_type is batch.
stateenumstatusonline / offline. Required on a status message.
observed_atISO-8601yesUTC instant the value was measured or the aggregate closed.
stream.idstringobs.Stream key. The sensor's own hardware id (BLE MAC, 1-Wire address, chip serial; a probe with no id of its own uses the board id plus the pin). The node names nothing; your endpoint maps this id to a place, and a replacement sensor arrives under a new id for the endpoint to map to the same place. (The analog, SDI-12 and Modbus reader sketches still take a label you type at setup.)
stream.namestring—Human-friendly label, and only ever one the DEVICE supplied about itself — a BLE sensor’s advertised name, for instance. An OAT node never invents one, so on a wired sensor this field is simply absent. Either way stream.id is the sensor’s own hardware id and your endpoint owns the mapping from that to a place: treat name as a hint for a human, and never key on it.
stream.locationstring—Free-text place / Home Assistant area.
measurementstringobs.Quantity kind from the vocabulary (§03). Open string.
valuenumber/string/boolobs.The reading.
unitstring—SenML unit symbol. Canonical units only on the wire.
aggobject—window_s, samples, method (mean/last/min/max/sum). Absent ⇒ instantaneous.
source.tierstring—Which emitter produced it (e.g. oat-ble-listener, home_assistant).
source.gateway_idstringone ofThe node's name as given at setup, or its own hardware id if none was given. Required if physical_id absent.
source.farm_idstring—The operator. One grower with ten nodes is one farm_id and ten gateway_ids. Group on it; it is what answers “whose data is this?” without guessing from node names.
source.physical_idstringone ofThe sensor's hardware id (MAC/serial), kept as provenance; on most nodes the same value as stream.id. Required if gateway_id absent.
source.device_idstring—Status message only: the node's hardware id (efuse MAC, e.g. oat-3c8a1fa77d14) — the node-level twin of physical_id. MQTT topics use gateway_id; this says which physical board holds the job.
source.brand / modelstring—Device make / model.
source.battery_pct / rssinumber—Device health that rides along with a reading.
healthobject—Node 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, 50 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.

50 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
wind_speed m/s wind_speed LinearVelocity m/s sensor
wind_gust m/s wind_speed LinearVelocity m/s sensor
wind_direction deg — Angle deg sensor
rain_total mm precipitation — mm sensor
rainfall mm precipitation — mm sensor
rain_rate mm/h precipitation_intensity — mm/h sensor
solar_radiation W/m2 irradiance Irradiance W/m2 sensor
par umol/m2/s — — umol/m2/s sensor
uv_index — — — — sensor
leaf_wetness % moisture — % sensor
canopy_temperature Cel temperature Temperature Cel sensor
lightning_count — — — — sensor
lightning_total — — — — sensor
lightning_distance km distance Length km sensor
flow l/min volume_flow_rate VolumeFlowRate L/min sensor
water_level cm distance Length cm sensor
ph pH ph — [pH] sensor
conductivity uS/cm — Conductivity uS/cm sensor
dissolved_oxygen mg/l — MassConcentration mg/L sensor
water_leak — moisture — — sensor
battery_low — battery — — sensor
mains — power — — 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).

When the gateway carries other sensors.

Two kinds of OAT gateway push readings that are not their own: the BLE Listener hears Bluetooth sensors, and the LoRa Gateway hears LoRa Field Nodes, each of which may carry thirty probes. Nothing about the format changes for an endpoint, and that is deliberate. Four rules make it work:

  • One observation per reading, always. A field node with three soil probes and an air sensor arrives as four streams, each under that sensor’s own hardware id (ds18b20:28ff…, sht30:0a1b…). The endpoint never sees the node’s sensor list, only streams, and a probe added tomorrow is simply a new stream on the next batch.
  • Stream ids are hardware ids, so they survive the radio. A probe moved from a Wi-Fi node to a LoRa node keeps its id and therefore its history. The map from a serial number to a place and a depth is the endpoint’s to hold; no node names anything.
  • The carrier’s link quality rides in source. rssi and battery on a carried reading describe the field node or BLE sensor that sent it, not the gateway’s Wi-Fi. A LoRa field node also gets a stream of its own (its 8-hex unit id) carrying rssi, snr, voltage and uptime as gauges.
  • Absence is a stream that stops. The gateway’s own heartbeat says the gateway is alive. A carried sensor has no heartbeat; when its node falls silent for three of its cycles the gateway releases its streams and they stop appearing in batches. Treat a stream that has not appeared for three of its usual intervals as stale — the same rule as §04, applied per stream.

A fifth source of carried readings is a pod: any board that prints readings in the oat-line grammar on a serial port, cabled to a field node or a gateway. Every OAT sketch prints its readings that way by default, so a Radio Sensor Listener cabled to a LoRa node carries an AcuRite’s streams to the house under the station’s own id. To an endpoint, a pod’s streams are indistinguishable from wired ones.

Counters arrive as running totals. A rain gauge, a water meter, a lightning detector and an energy monitor report a cumulative value (rain_total, lightning_total, energy), sent with agg.method: "last". The amount for any period is the endpoint’s subtraction: new total minus the last total it stored for that stream. Three cases: normal, a small positive difference; reset, the new total is smaller (batteries pulled, or the device’s counter wrapped) — take the new value as the new baseline and never report a negative; gap, readings were missed — the difference is still right, which is the whole reason totals are sent rather than increments. A device that reports increments instead (the Tempest’s rain per minute) uses rainfall with agg.method: "sum", and those are simply added. The field says which rule applies; an endpoint never guesses. Home Assistant’s Utility Meter helper implements exactly this.

The over-air frame between a field node and its gateway is not oat-ods and is not an endpoint’s concern; it is documented on the field node page for anyone building a node. The next schema revision adds an optional source.via carrying the node id on each carried reading, so an endpoint can group a node’s streams without parsing ids.

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 Home Assistant discovery helpers that no sketch calls yet. 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 Sketches 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 (not yet sent by any sketch) + the MQTT topic.
oat_measurands.hsourceThe measurement lookup — the firmware side of the vocabulary in §03.
README.mddocsHow to link it and emit a reading. The README is CC BY 4.0; the library code is Apache-2.0 (licence, NOTICE).

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 node's name and the sensor's own hardware id, so a replacement sensor arrives on a new topic and whatever reads it (Home Assistant, your endpoint) maps it to the same place; 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/A4:C1:38:0D:1E:2F/temperature   → {"value": 24.31, …}

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

Running Home Assistant? Declare each topic above as an MQTT sensor in YAML with value_template: "{{ value_json.value }}" and the status topic for availability. The oat_ods library carries helpers that build Home Assistant discovery configs, but no OAT sketch sends them today, so entities are declared, not discovered. 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-line/1grammarThe one-line-per-reading text form any device uses to hand readings to another over a serial wire (pods). Not oat-ods; converted by the reader.
oat-recipeschemaThe sibling schema for the grow plan going out to a device.
The SketchesfirmwareFlashable 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 topic keyed on the node and the sensor's own hardware id, 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, declare each topic as an MQTT sensor in YAML (the sketches send no discovery announcements); 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 50 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.