sdk/ — layer zero as code#
Build-order step 1 from docs/VIRTUAL_FARM.md: the data
model and the log, with no devices at all. Apache-2.0. Two dependencies: cbor2, for the CBOR wire format in wire.py, and cryptography, only in erasure.py; everything
else is the standard library.
cd sdk
python3 -m unittest discover -p 'test_*.py' # 259 tests, ~0.15s
python3 demo.py # see it run: log -> state -> SensorThings
python3 demo.py --interval # phenomenonTime as the §2 uncertainty window
python3 demo.py --v2 # the unpublished 2.0 draft dialect
python3 demo.py --json # the served Observations collection
Modules#
| Module | Clause | What it is |
|---|---|---|
ids.py |
§1 | UUIDv7 per RFC 9562, with a per-millisecond counter so ids are strictly monotonic — which §3.3's precedence tiebreak depends on |
hlc.py |
§7.4 | Hybrid logical clocks. Event ordering a bad clock cannot reorder, which §2 says to expect |
model.py |
§1–§4 | The contract types, frozen, rejecting at construction what the spec forbids |
codec.py |
§7.1, §7.6 | Exact JSON round-trip for every record — the log and the export share one encoding |
log.py |
§7.1, §7.3 | The append-only log. No update, no delete, by construction. One file has several writers on a farm's computer, and refresh takes in what the others appended without writing it again; the first write after a line a power cut left half-written starts a fresh line |
project.py |
§2, §3.2, §3.3, §4.2, §7.1 | Derived state: live rows, quality precedence and quality as known at a moment, calibration by measured_at, staleness, ordering |
fixtures.py |
— | Record builders for tests, and the seed of the virtual farm's own |
wire.py |
§5.2, §5.3 | The wire message: CBOR packed arrays, a CDDL schema, payload-budget-aware packing, transmit-time restamping, and Window, a node's aggregate of a low-urgency series (Q20) |
registry.py |
§1, §8.1, §9.1 | Short wire ids to full identities, plus each node's clock source and uncertainty budget |
gateway.py |
§5.1, §5.4, §8.1 | Ingest: decode, quarantine, dedup, gaps, reboots, raw, derive, assess |
checks.py |
§4.2 | The four health checks, writing assertions and never mutations. One assertion per run carrying the union of flags |
sensorthings.py |
§10, §3.2 | The SensorThings out-adapter. Serves live observations only, which is the compatibility promise v0 would have broken. Validated against FROST-Server |
records.py |
§3, §7.4; Q14 | The farm's records, farmOS-shaped: land and its boundary, animals and groups, inputs applied, animal treatments and their withdrawal, harvest lots. Each declares the fields that never resolve silently and the ones that merge by union |
edits.py |
§7.4; Q7 | Offline edits: each names the versions its writer had seen, so concurrency is decided by knowledge, not clocks. Conflicts keep both values and are noticed into the log; withdrawal and pre-harvest checks read every candidate |
modhost.py |
§8.2, §8.3; Q11 | Modules as separate processes: a manifest that is a ceiling, a farmer's grant per point (a log record, the token's hash only), and a unix-socket host that checks every call, stamps every write with the module's name and lets a module read back only its own. RemoteState and RemoteLog let a module written against the SDK run unchanged in its own process. An owner may grant a module every module's decisions, read-only, by arrival cursor: the notifier's and the board's need. The host reads back every two seconds what other processes write to the farm's log, so a revocation made at the command line counts from the module's next call |
sharing.py |
§7.6; Q16 | Sharing grants: what a grantee may read at a moment. A grant covers what had arrived when it was made unless it asks for more and says when that ends; revoking stops access from then on |
erasure.py |
§7.3; Q17 | Erasure by key destruction: personal text sealed to a per-person key, wrapped by a farm key kept out of backups; erasing destroys the person's key and rotates the farm key |
forecast.py |
§3.1, §3.2, §10; Q26 | Weather forecasts as input data: runs onto a forecast datastream with issued_at, the latest issue live, every run kept and scored against the farm's sensors by lead time. Probabilities as a declared event, scored by Brier; ensembles as one forecast datastream per member, and an event's chance counted on the members' runs |
subscribe.py |
§7.5 | Subscriptions with a durable cursor in the log's arrival order: at-least-once, replayable, and an edit that syncs late is never skipped |
export.py |
§7.6 | The log out in open formats — a JSONL archive, per-type CSV, GeoJSON — with a checksummed manifest, verified by replaying it into an identical log |
csv_import.py |
§10 | A farm's or a station's existing records in from CSV onto declared datastreams, so its history lands in the same log |
mqtt_bridge.py, mqtt_emulator.py |
§10 | MQTT into the log for the device families that already publish there — ESPHome, Tasmota, Shelly, Zigbee, Home Assistant, ChirpStack LoRaWAN, OpenSprinkler's valves opening and shutting, and the weather stations and rain gauges rtl_433 hears on 433/868/915 MHz — and an emulator of what each really sends |
eto.py |
§11 | Reference ET from a farm's own station, FAO-56 Penman–Monteith (reproduces its Example 18), with FAO-56's stand-ins for missing sunshine, humidity and wind; a station's local day read from the log, only readings the checks passed, in whatever unit each series declares |
sun.py |
§4.2 | Where the sun is and what a clear sky gives, from FAO-56 (eqs. 23, 24, 31–33, 37): the clear-sky check's ceiling on a sunshine reading, checked against FAO-56's Example 8 |
siting.py |
§4.1, §4.2 | Is a probe where its installation says? Depth from the damping and delay of its daily temperature wave, read only against peers under the same recorded cover, since mulch and sod read as depth; a two-peer check for a move nobody logged; a GPS node's fix |
airtime.py |
§5.2 | LoRa airtime and the duty-cycle budget, at runtime, so a fleet that reports too often fails here and not in a field |
vocab.py |
§3.4; Q4 | Observed-property terms and UCUM units: CF Standard Names for physical measurands, ODM2 for what CF lacks, AGROVOC for records, and a counted few local terms for real gaps, each with its reason |
i18n.py, packs/ |
§11 | Language packs: codes in the log, sentences in the reader's language; a module ships its own pack under its own prefix |
demo.py |
— | One command that exercises the whole chain, no hardware and no site data |
spool.py |
§5.2 | Gateway-side store-and-forward: retry, auth, spool, drain |
modbus_rtu.py |
§9 | Modbus RTU framing over serial, read function codes only (see PROVENANCE.md) |
station_adapters.py |
§10 | Weather stations on their own local protocols, written by the MQTT bridge's bindings and rules: WeatherFlow Tempest's UDP broadcast, Davis WeatherLink Live's local HTTP API, an Ecowitt gateway's customized upload (plain HTTP, so it takes only bound PASSKEYs and, from the command line, only the gateway's address) |
modbus_collector.py |
§10 | Modbus RTU devices into the log: one burst read a poll from a register map written from the maker's guide (METER's ATMOS 41 Gen 2 first), what the bindings name written to raw datastreams, each reading saying the span it covers |
The four rules worth knowing#
Each was a defect in the v0 draft, and each has a test class named after it in
test_project.py:
- Recalibration appends. Raw and calibrated values are separate datastreams;
a re-derivation carries
supersedes; exactly one live row exists per(datastream, measured_at)andlive_observationschecks that rather than assuming it. Two live rows at one timestamp is precisely what v0 produced, and it doubles every SensorThings client's data. - Calibration is selected by
measured_at. A reading that spent three days in a node's buffer gets the coefficients that were in force when it was taken, not whatever is current at ingest. - Quality is an assertion, not a column. A flag set plus a derived
usablesummary, carrying the check and threshold version it used. A person outranks the foundation and the machine's original stays readable. Retuning a threshold cannot re-flag history. - Ordering is honest. Within one node
(boot, seq)beatsmeasured_at, because the counter is reliable when the clock is not. Across nodes, observations whose uncertainty windows overlap are genuinely unordered —concurrent()is how you ask, andorder_observations()is for display only.
What is deliberately not here#
| Missing | Waiting on |
|---|---|
| A real node (firmware) | Q6, and hardware. The behaviour is specified and simulated in sim/node.py |
| A link driver (Meshtastic) | Q6 |
| The aggregate datastream kind | Q20 |
| The command contract (§6) | Scope: the first release ships observations only, and §6 freezes after a season |
| Sharing grants and erasure (§7.3, §7.6) | Q16, Q17 |
| A GIS client actually reading it (QGIS) | The HTTP layer below — documents being right is necessary, not sufficient |
| An HTTP service ($filter, $expand, $top, paging) | Nothing. sensorthings.py produces the documents; routing and query options are the next layer |
| Harrow Bend assembled — six beds, paddock, research plot, hoop house, well | Nothing. Build-order step 5, and the biggest remaining gap |
| Field-level conflict resolution and conflict events (§7.4) | Q14 — which fields must never resolve silently |
State.validate() returns every violation it can see rather than raising on the
first, so a conformance run can show all of them at once.
The SensorThings mapping#
Four decisions in sensorthings.py that could each reasonably have gone the other
way, so they are stated rather than buried:
- Only live observations are served. A recalibrated series keeps both rows in
the log forever; serving both would put two observations at one
phenomenonTimeand double every client's chart. This is the single most important line in the adapter. - Layer-zero fields are namespaced under
parameters/fieldmesh. 1.1 documentsparametersas environmental conditions during measurement;received_at,bootandseqare not that. Nesting keeps$filterpaths working and reduces the 2.0 migration (parameters→properties) to one key. resultTimeis null. It means "when the result was generated", which layer zero does not record.received_atis a different thing, and claiming it here would be a small lie in a field researchers read. 1.1 explicitly allows null.resultQualityis emitted for a clean assertion too, and omitted only when nothing has been asserted — "checked, no objection" is a judgement, and collapsing it into "not checked" discards what a certifier cares about.
Validated against the reference implementation: FROST-Server accepted a deep
insert of these documents (201), round-tripped resultQuality with nested
evidence and parameters/fieldmesh intact, and after one fix the Datastream and
Observation documents are at full key parity with its own. The findings are pinned
in TestValidatedAgainstFrost so they survive without the container; the method
and what it caught are in frost-validation.md.
Ingest#
wire.py + registry.py + gateway.py are §5.1's step 4 in order: resolve ids,
compute times, dedup on (site, node, boot, seq), write raw, derive by the
calibration in force at measured_at, assess.
Everything it learns that is not a reading, it records — an unparseable frame, an unapproved device, a hole in a sequence, a reboot, an evicted backlog, a channel with no binding, a calibration it cannot apply. The alternative is a dataset with quiet holes and no way to tell a gap from a reading that never existed.
Four behaviours where the easy implementation is wrong, each with a test:
- An unregistered node is quarantined, not dropped (§8.1). Its frames are logged so the device can be approved and the data replayed; nothing of its reaches an observation.
- A sequence going backwards is usually a wrap, not a replay (§1). Getting this wrong discards a node's next 32768 readings.
- A
seqreset after a reboot is not a duplicate — the v0 defect, now a regression test. - Two readings may not land on one instant. Wire ages are whole seconds, so a
channel sampled faster than 1 Hz cannot express distinct instants, and writing
both would break §3.2's uniqueness. The gateway keeps the first and records the
second. This one was found by
State.validate()failing in a test — the invariant earning its keep.
Health checks#
Four checks (§4.2), writing assertions and never touching an observation. The
thing worth knowing is that they produce one assertion per run carrying the
union of flags, not one assertion each. §4.2's four one-flag checks and §3.3's
single live assertion could not both hold as written: two checks firing together
meant one won on precedence and the other's finding silently vanished.
test_separate_assertions_per_check_would_lose_a_flag demonstrates it rather than
asserting it, and the spec now carries the amendment (§17) with evidence
recording which check fired and why.
Three smaller judgements: cross-check flags only suspect, because when two
probes disagree it cannot say which lies; a longer gap permits a larger rate
change, since three hours of absence genuinely allows more movement than three
minutes; and assess_series will never supersede a person's override with a
machine verdict (§3.3).
The version-dependent names live in one Dialect class, which is what makes §10's
"one thin layer" checkable: test_the_model_knows_nothing_about_sensorthings
greps model.py for @iot, phenomenonTime, resultQuality and
unitOfMeasurement and fails if any of them leaked in. SensorThings20Draft
exists to prove the seam, and its supports_quality = False is where the dropped
resultQuality of open-questions Q18 becomes visible: quality falls into the
extras bag, where nothing standards-aware will look.