# Wairoa / mariko earthgrids GraphQL integration guide

Updated: 16 September 2026. This guide describes the deployed development API.
It is intended for an AI coding assistant or a human building another dashboard.

## Connection and credentials

- Endpoint: `https://dev.mariko.org.nz/api/graphql`
- Method: `POST`
- Headers: `Content-Type: application/json` and `Authorization: Bearer <API_KEY>`
- Body: a JSON object with `query`, optional `variables`, and optional `operationName`.
- Sign in to the website and open **API keys** to create a GraphQL read key. Give it a label and choose an expiry or no expiry. Copy the secret when created; it is shown only once. The operator can revoke each key independently. No key is included here.
- Website account/device sign-in and TG cryptographic keys are separate from the GraphQL Bearer read key. An Admin manages API keys; do not use a TG private key as an API credential.
- This API supports queries only. It cannot change sampling intervals, set clocks, send OTA updates or acknowledge readings on a sensor's behalf.

Put the API key in your application's backend environment or secret store. Make
browser requests through your own backend; the current service does not provide
cross-origin browser CORS support. Do not embed the key in public JavaScript or
commit it. HTTPS certificate verification should remain enabled.

Minimal Python client (standard library only):

```python
import json
import os
import urllib.error
import urllib.request

ENDPOINT = "https://dev.mariko.org.nz/api/graphql"

def graphql(query, variables=None, operation_name=None):
    body = {"query": query, "variables": variables or {}}
    if operation_name:
        body["operationName"] = operation_name
    request = urllib.request.Request(
        ENDPOINT,
        data=json.dumps(body).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "Authorization": "Bearer " + os.environ["WAIROA_GRAPHQL_API_KEY"],
        },
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=20) as response:
        result = json.load(response)
    # GraphQL execution errors can accompany HTTP 200 and partial data.
    if result.get("errors"):
        raise RuntimeError(result["errors"])
    return result["data"]
```

Handle `urllib.error.HTTPError` and connection errors in your application.
A `401` means the Bearer key is missing, invalid, expired or revoked; `400` indicates an invalid
request/query; `413` means the request or response is too large; `415` requires
JSON content type. `GET` is not supported. Retry transient failures with bounded
backoff; do not keep retrying invalid queries or credentials.

## Reading timestamps and freshness

`Reading.indexedAt` and `ReadingStream.latestIndexedAt` are ISO 8601 UTC cloud
indexing timestamps. Index refreshes preserve each existing record's timestamp.
They are separate from `timeMs` / `timeSource`, which describe the device clock.
Queued readings or a database backfill can be indexed long after acquisition;
indexing time does not establish current connectivity or authenticated receipt
of a particular reading. `Station.generatedAt` is the response generation time,
so do not use it as the latest sensor measurement time.

Optional `measurementUtcMs`, `measurementErrorMs` and `measurementTimeBasis`
carry a sensor's retained acquisition-time assertion when available. UTC and
error are decimal-string milliseconds; null error means unknown, never zero.
The basis `sensor_asserted_network_time` identifies provenance, not independent
clock verification. Metadata is joined only to identical source/log/sequence and
raw reading bytes, including when it arrives after the original record. Original
`timeMs`, `timeSource` and `indexedAt` remain unchanged. Missing metadata must not
be filled with arrival time and labelled sensed UTC.

A sensor may receive a valid durable-storage receipt from Alfred before the
Cloud receiver has its copy. This GraphQL endpoint currently reads the cloud
index only: temporary absence here does not establish that a reading was lost
or remains unacknowledged. Stores reconcile when connectivity returns.

## Discover sources and fetch recent readings

```graphql
query RecentSources($after: ID, $sensor: ID) {
  station {
    generatedAt
    receiver
    profile
    fieldRouteVerified
    otaVerified
    store(dataset: FIELD) {
      available
      error
      receiptAuthorityVerified
      streams(first: 16, after: $after, sensor: $sensor) {
        endCursor
        hasNextPage
        nodes {
          sourceLog
          sensorId
          logLifetime
          identityBound
          rows
          partialTailBytes
          latestIndexedAt
          recent(last: 16) {
            seq boot timeMs timeSource indexedAt kind outcome
            mm batteryMv temperatureC100 humidityPct100 pressureDapa
            xMicroG yMicroG zMicroG
            rmsMg peakMg durationMs dominantDhz
          }
        }
      }
    }
  }
}
```

Start with variables `{}`. To select a sensor, supply its returned `sensorId` as
`{"sensor":"<sensorId>"}`. This filters source logs for that sensor; a sensor may
have multiple log lifetimes. For additional sources, send the returned stream
`endCursor` as `after` until `hasNextPage` is false. Stream pages are sorted by
`sourceLog`; `first` accepts 1–32 (default 16). `recent(last:)` accepts 1–32
(default 32), ordered oldest to newest within that tail.

Use `FIELD` for physical readings. `ACCEPTANCE` is a separate historical test
dataset; do not mix it into field graphs. Check `available` before using `streams`:
an unavailable store returns a null stream page, not proof of an empty dataset.
An available empty page means no matching source logs.

A canonical identity-bound `sourceLog` is 48 lowercase hexadecimal characters:
16 for the eight-byte source hive identity followed by 32 for the log lifetime.
Prefer the explicit `sensorId` and `logLifetime` fields. Legacy logs may have
`identityBound: false` and null identity fields. Configured repeater/router cards
on the website are not live API identities and cannot be used as sensor filters.

## Read paginated history

First discover the source logs as above. Then request history for one source log
at a time, using the same sensor filter and selecting the matching `sourceLog`
from the returned stream page. There is no direct `sourceLog` filter in this API.

```graphql
query SourceHistory($sensor: ID!, $streamAfter: ID, $after: ID,
                    $kind: String, $boot: String, $timeSource: String,
                    $from: String, $to: String) {
  station {
    store(dataset: FIELD) {
      available
      error
      streams(first: 1, after: $streamAfter, sensor: $sensor) {
        endCursor
        hasNextPage
        nodes {
          sourceLog sensorId logLifetime
          history(first: 128, after: $after, kind: $kind, boot: $boot,
                  timeSource: $timeSource, fromTimeMs: $from, toTimeMs: $to) {
            endCursor
            hasNextPage
            nodes {
              seq boot timeMs timeSource indexedAt kind outcome
              mm batteryMv temperatureC100 humidityPct100 pressureDapa
              xMicroG yMicroG zMicroG
              rmsMg peakMg durationMs dominantDhz
            }
          }
        }
      }
    }
  }
}
```

Example variables: `{"sensor":"<sensorId>","kind":"acceleration"}`.
History `first` accepts 1–256 (default 32). Rows follow stored arrival order,
which can differ from sensor acquisition order when a retained backlog arrives.
`after` is an opaque **history cursor**, scoped to its dataset and source log;
never construct it or use it for stream pagination. Keep `streamAfter` fixed
while paging that log's history, and advance `after` to the history `endCursor`.
Only after that log is exhausted should you advance `streamAfter` to the stream
`endCursor` and reset history `after` to null. Validate `sourceLog` on every page.

Supported `kind` filters: `level`, `environment`, `acceleration`, `vibration`.
Time bounds are inclusive decimal millisecond strings. They require `timeSource`;
for `ms_since_boot`, they also require a decimal-string `boot` generation.
Keep filters unchanged during a paginated traversal. For example:
`{"sensor":"<sensorId>","timeSource":"ms_since_boot","boot":"17","from":"60000","to":"120000"}`.

## Sensor data format

The API returns JSON in the GraphQL `data` / `errors` envelope. Each stream
identifies a source hive and its log lifetime; each `Reading` is one typed record.
Requested fields that do not apply to that record are `null`. Fields omitted
from the query are absent from the JSON response. Field names are camelCase in
GraphQL; internal storage/diagnostic snake_case names are not additional API fields.
The API has already decoded the radio record: clients do not parse UART frames,
CBOR, raw ADC counts or IMU register values.

| `kind` | Measurement fields that may be present | Meaning |
| --- | --- | --- |
| `level` | `outcome`, `mm`, `batteryMv`, `temperatureC100`, `humidityPct100` | Radar result with optional contemporaneous battery/environment channels. A legacy level record may contain radar only. |
| `environment` | `temperatureC100`, `humidityPct100`, `pressureDapa` | BME280 measurement; each channel may be absent independently. |
| `acceleration` | `xMicroG`, `yMicroG`, `zMicroG` | Current signed acceleration along the IMU axes, including gravity. |
| `vibration` | `rmsMg`, `peakMg`, `durationMs`, `dominantDhz` | A measured vibration-window summary when that producer is enabled. Schema support does not establish that deployed sensors are sending it. |

All records carry `seq`, `boot`, `timeMs`, `timeSource` and `kind`; `indexedAt`
may be null. `boot` is the producer's log/runtime generation, not a calibrated
count of physical power cycles. A read cycle can generate separate acceleration,
environment and level records with different sequences/timestamps. Do not join
records merely by their array position or assume exactly one record per cycle.
Battery is currently a field on `level`, not a separate `battery` kind.

Illustrative `Reading` objects, as returned inside `recent` or `history.nodes`
when these fields are requested (example values, not a live sensor report):

```json
[
  {"seq":"100","boot":"7","timeMs":"31742","timeSource":"ms_since_boot","indexedAt":"2026-09-16T00:00:32+00:00","kind":"level","outcome":"value","mm":2000,"batteryMv":3836,"temperatureC100":1954,"humidityPct100":6102,"pressureDapa":null,"xMicroG":null,"yMicroG":null,"zMicroG":null,"rmsMg":null,"peakMg":null,"durationMs":null,"dominantDhz":null},
  {"seq":"101","boot":"7","timeMs":"33100","timeSource":"ms_since_boot","indexedAt":"2026-09-16T00:00:34+00:00","kind":"environment","outcome":null,"mm":null,"batteryMv":null,"temperatureC100":1954,"humidityPct100":6102,"pressureDapa":10154},
  {"seq":"102","boot":"7","timeMs":"33200","timeSource":"ms_since_boot","indexedAt":"2026-09-16T00:00:34+00:00","kind":"acceleration","xMicroG":-1000000,"yMicroG":0,"zMicroG":10000,"rmsMg":null,"peakMg":null},
  {"seq":"103","boot":"7","timeMs":"34000","timeSource":"ms_since_boot","indexedAt":"2026-09-16T00:00:35+00:00","kind":"level","outcome":"silence","mm":null,"batteryMv":3836}
]
```

The fourth example has no radar distance, but its battery voltage remains usable.
The envelope is `data.station.store.streams.nodes[]`; retain that stream's
`sensorId`, `logLifetime` and `sourceLog` with each extracted reading.

### Measurement ratios and display conversions

These are encoding scales, not sensor accuracy specifications or calibration
coefficients. Apply them once. A null input stays null.

| API field | Stored unit / ratio | Conversion | Example |
| --- | --- | --- | --- |
| `mm` | 1 unit = 1 mm | metres = value / 1000; centimetres = value / 10 | 2000 → 2.000 m |
| `batteryMv` | 1 unit = 1 mV | volts = value / 1000 | 3836 → 3.836 V |
| `temperatureC100` | 100 units = 1 °C | °C = value / 100 | 1954 → 19.54 °C; −125 → −1.25 °C |
| `humidityPct100` | 100 units = 1 percentage point RH | %RH = value / 100; fraction = value / 10000 | 6102 → 61.02% RH → 0.6102 |
| `pressureDapa` | 1 unit = 10 Pa | Pa = value × 10; hPa = value / 10; kPa = value / 100 | 10154 → 101540 Pa → 1015.4 hPa |
| `xMicroG`, `yMicroG`, `zMicroG` | 1 unit = 0.000001 g | g = value / 1000000; m/s² = value × 9.80665 / 1000000 | −1000000 → −1 g → −9.80665 m/s² |
| `rmsMg`, `peakMg` | 1 unit = 0.001 g | g = value / 1000; m/s² = value × 9.80665 / 1000 | 250 → 0.250 g |
| `durationMs` | Decimal-string milliseconds | seconds = exact value / 1000 | "1250" → 1.250 s |
| `dominantDhz` | 1 unit = 0.1 Hz | Hz = value / 10, except zero means no dominant bin | 125 → 12.5 Hz; 0 → unavailable frequency |
| `timeMs` | Decimal-string milliseconds in `timeSource` | seconds = exact value / 1000 in the same clock domain | "31742" → 31.742 s of uptime when `ms_since_boot` |

Do not apply the IMU's register sensitivity or a battery ADC divider again:
these API values are already expressed in the units above. `9.80665` is the
standard-gravity conversion constant, not a measurement of local gravity.
Pressure is the measured sensor pressure; the API does not supply a sea-level
correction. Battery voltage is not state of charge and should not be converted
to a percentage without a separately justified battery model or fuel-gauge value.

Radar reports distance from the sensor toward the reflecting surface. For a
fixed, downward-facing installation with a surveyed sensor height `H` above a
chosen datum, water-surface elevation above that datum is `H - mm / 1000`, with
`H` in metres. This is an application calculation requiring valid geometry and
calibration; `mm` alone is neither pond depth nor water elevation. Moving/tilting
the sensor invalidates that assumed geometry. Do not silently clamp a zero or
short distance into a plausible level: preserve the observation and flag it for
quality review. The encoding's 1 mm increment does not imply 1 mm field accuracy.

Current acceleration includes gravity and uses the module's physical axes.
It is not earth-referenced orientation, velocity or displacement. Plot X/Y/Z
on one shared scale. Sparse current readings cannot reconstruct a vibration
waveform or establish event RMS, peak, duration or dominant frequency.

### Outcomes, missing values and time

The level outcomes are `value`, `silence`, `bad_reply`, `malformed`, `not_ready`
and `access_unavailable`. Only `value` carries a usable `mm`; other outcomes
project `mm: null`. They describe the acquisition result, not a diagnosed cause
such as a flat battery or a broken cable. Unknown future outcomes must not be
silently treated as successful measurements.

Null means absent, unavailable or inapplicable. Preserve numeric zero when it is
a valid measurement, including a zero acceleration axis. The documented
`dominantDhz == 0` sentinel is the exception described above. Do not insert zeros
for missing channels. Skip records of other kinds when plotting a series; break
continuity at an explicitly missing/failed sample or a change in boot/time basis.

`seq`, `boot`, `timeMs`, `rows` and `durationMs` are decimal strings. Preserve
exact values; use `BigInt` or arbitrary-precision integers where required, and
only convert a safely bounded display interval to a floating-point number.
`timeSource` is `ms_since_boot`, `rtc` or `unavailable`. Current DFR producers
use uptime. An `rtc` tag alone establishes neither accuracy nor a UTC epoch;
validate the producer's time contract before treating it as an absolute date.
No acquisition time should be inferred for `unavailable`.

Keep source identity and log lifetime with every record. A sequence alone is not
a globally unique key, and stored physical duplicates can exist. `rows` counts
stored records, not necessarily unique acquisitions. Preserve history cursors
for exact arrival-order continuation. `partialTailBytes` reports an incomplete
trailing file write excluded from the record count.

`indexedAt` is available, but it is cloud indexing time, not an exact network
arrival timestamp or storage-receipt timestamp. `Station.generatedAt` is response
generation time; `receiver` is service status, not proof of sensor reachability.
The development sensors accept authenticated Dev TG exact-record storage claims.
That is distinct from proof of a particular store's identity or independent
replication: do not reinterpret `receiptAuthorityVerified` as true when false or
null. An API read does not acknowledge, delete or release sensor data.

## Sensor and hive details

This inventory describes the commissioned pilot as of the guide date. Associate
it with an explicitly identified hive; do not assign a hardware profile to an
unknown `sensorId` merely because it reports similar fields. Friendly names and
locations can change, while readings remain associated with their source identity.

| Hive / hardware | Fitted parts and data | Availability and interpretation |
| --- | --- | --- |
| Sensor 1 · Garden; Sensor 2 and Sensor 3 · Bench | [DFRobot DFR1195 ESP32-S3 board](https://wiki.dfrobot.com/dfr1195/), SEN0676 radar, BME280 environment sensor, LSM6DSOX IMU, DS3231 RTC, retained storage, battery voltage sensing, display/button; ESP-NOW and LoRa | Radar, battery voltage, environment and current acceleration have been observed over TN. Per-image sleep/OTA qualification differs; a record's presence does not establish current reachability. |
| Radar on each DFR sensor | [SEN0676 liquid-level radar](https://wiki.dfrobot.com/sen0676/), UART/Modbus, reporting distance as `level.mm` | Uses switched radar power and a stabilization interval. This is distance to the reflecting target; supply/aim/settling anomalies need quality review. |
| Environment on each DFR sensor | [Bosch BME280](https://www.bosch-sensortec.com/en/products/environmental-sensors/humidity-sensors-bme280), I²C | Temperature, relative humidity and pressure. The sensor may report individual channels unavailable. Enclosure conditions may differ from ambient conditions. |
| Motion on each DFR sensor | [ST LSM6DSOX](https://www.st.com/en/mems-and-sensors/lsm6dsox.html), I²C IMU | Current acceleration is enabled. Gyroscope values are not exposed by this GraphQL schema. Interrupt-driven vibration wake/event reporting remains to be qualified. |
| GNSS | PA1010D physically fitted only to Sensor 1 | Omitted from current firmware; Sensors 2 and 3 have no GNSS. No GNSS position/fix fields exist in this schema. A saved location/geohash is metadata, not a measured GNSS fix. |
| Clock | DS3231 fitted to the DFR sensors | Current readings use uptime; fitted RTC hardware is not evidence of a synchronized clock. |
| XIAO repeater | ESP32-S3/LoRa, BME280 and MAX17048 battery fuel gauge, battery/solar supply | BME280/MAX17048 are fitted; their periodic telemetry integration remains pending. Do not invent repeater environmental readings, battery percentage or field names. |
| Alfred complex hive | Linux host plus bare DFR1195 radio component | Routes radio traffic and retains delivered TG readings in a local durable store. Local JSON inspection is available. Protected TN synchronization and local exact-record storage receipts are enabled. Live outage recovery and sensor receipt acceptance with the Cloud receiver stopped have passed; historical backfill continues. No sensor/battery fields are implied by its status. |
| Cloud receiver | Cloud-hosted receiver, durable store, query index and web/API service | Stores/indexes observations. It has no battery or physical environmental sensor. |

### Reading cadence and metadata access

Intervals are device settings, not fixed properties of the API. A 16 September
confirmed-state snapshot showed Sensor 1 at 300-second readings with up to
60 seconds asleep and a 30-second awake window; its five-minute reading request
was saved and applied at revision 3. Sensor 2 was confirmed at 300-second readings, up to
180 seconds asleep and a 90-second awake window. Sensor 3 was at 60-second
readings with sleep disabled. These are dated observations, not defaults to
hard-code. Sleep limit, awake window and acquisition period are different
parameters; radio retries and retained backlog also affect arrival cadence.

The current GraphQL `ReadingStream` does **not** expose name, location, geohash,
component inventory, firmware or confirmed schedule fields. The TG portal has
separate authenticated inventory/management surfaces; an API read key does not
grant website access to them. For this integration, maintain an explicit mapping
from returned `sensorId` to agreed display name, location and this hardware
inventory. Do not generate GraphQL queries for undocumented metadata fields.
Future metadata API support should be added to the schema and guide together.

This GraphQL endpoint queries the Cloud receiver. It does not yet combine records from local
stores, so a reading retained only on Alfred will not appear here until it also
reaches the Cloud receiver.

## Limits and integration checklist

- Poll recent data approximately every 10 seconds; the snapshot cache lasts 5 seconds.
- Fetch history in bounded pages rather than repeatedly downloading everything.
- Maximum JSON request body: 16,384 bytes; maximum response: 524,288 bytes.
- Query limits: 1,024 parser tokens, selection depth 12, 128 expanded selections.
- Full-schema introspection may exceed these limits. The deployed schema is reproduced below.
- Handle HTTP errors and GraphQL `errors`, including partial-data responses.
- Keep sources/log lifetimes separate, units explicit and absent values absent.
- Preserve exact decimal strings and treat both cursor types as opaque.
- The current API has no live topology, subscription, management mutation, or exact network-arrival/storage-receipt-time field. Do not generate clients that assume those features exist.

## Schema snapshot

The following is the deployed SDL as of this guide's update. Recheck the guide
when integrating new features; this development API does not yet have a versioned
compatibility guarantee.

```graphql
"""Application readings projected from the receiving hive's durable store."""
type Query {
  station: Station!
}

enum Dataset { FIELD ACCEPTANCE }

type Station {
  generatedAt: String!
  receiver: String!
  profile: String!
  fieldRouteVerified: Boolean!
  otaVerified: Boolean!
  store(dataset: Dataset!): ReadingStore!
}

type ReadingStore {
  available: Boolean!
  error: String
  receiptAuthorityVerified: Boolean
  """Null when inspection is unavailable; an empty page means no matching logs."""
  streams(first: Int! = 16, after: ID, sensor: ID): StreamPage
}

type StreamPage {
  nodes: [ReadingStream!]!
  endCursor: ID
  hasNextPage: Boolean!
}

type ReadingStream {
  sourceLog: ID!
  sensorId: ID
  logLifetime: ID
  identityBound: Boolean!
  """Decimal string, preserving counts beyond JavaScript's exact integer range."""
  rows: String!
  partialTailBytes: Int!
  """Latest cloud indexing timestamp (ISO 8601 UTC), not device acquisition time or proof of current connectivity."""
  latestIndexedAt: String
  """Last 1..32 records, ordered oldest to newest. No inferred missing values."""
  recent(last: Int! = 32): [Reading!]!
  """Stored arrival order, 1..256 records. Uptime ranges require the boot generation."""
  history(first: Int! = 32, after: ID, kind: String, boot: String,
          timeSource: String, fromTimeMs: String, toTimeMs: String): ReadingPage
}

type ReadingPage {
  nodes: [Reading!]!
  endCursor: ID
  hasNextPage: Boolean!
}

type Reading {
  seq: String!
  boot: String!
  timeMs: String!
  timeSource: String!
  """Cloud indexing timestamp (ISO 8601 UTC). Queued or backfilled readings may be older."""
  indexedAt: String
  measurementUtcMs: String
  measurementErrorMs: String
  measurementTimeBasis: String
  kind: String!
  outcome: String
  mm: Int
  batteryMv: Int
  temperatureC100: Int
  humidityPct100: Int
  pressureDapa: Int
  """Current acceleration in signed micro-g, including gravity."""
  xMicroG: Int
  yMicroG: Int
  zMicroG: Int
  rmsMg: Int
  peakMg: Int
  durationMs: String
  dominantDhz: Int
}
```
