Two real clients, pointed at the HTTP service#
2026-10-07. spec §10, VIRTUAL_FARM.md step 7's cheap half. The HTTP layer in server/ was written, tested against 69 of its own tests, and then handed to two clients nobody here wrote:
- QGIS 3.44.15-Solothurn (
qgis/qgis:ltrin Docker, headless,QT_QPA_PLATFORM=offscreen) and its native SensorThings provider. Ubuntu 24.04 ships QGIS 3.34, which predates the provider, so the distribution package cannot be used for this. frost-sta-client1.1.53, the Python client from Fraunhofer IOSB — the same group that writes FROST-Server, which this project already uses as a conformance oracle.
Both read the service. Between them they found five defects, four of them ours, and one of our own tests turned out to be asserting the wrong thing. None of these was reachable from inside: every one needed a consumer with its own opinions about what a SensorThings document means.
What worked immediately#
QGIS loaded six entity sets from a simulated day of Harrow Bend:
| entity | features |
|---|---|
| Thing | 9 |
| Datastream | 56 |
| Observation | 5450 |
| Sensor | 28 |
| ObservedProperty | 6 |
| FeatureOfInterest | 10 |
Timestamps parsed into QDateTime without coaxing, resultQuality survived as a field,
the labels from harrow_bend.labels() showed as layer names, and paging worked — the
Fraunhofer client walked the whole collection by following @iot.nextLink on its own.
The five findings#
1. Every QGIS layer had no geometry#
geometryType() came back NullGeometry for all of them, so the farm loaded as six
attribute tables and nothing could go on a map. §7.6 names a GIS client as the
acceptance test for the export story, and it was failing it silently.
Two causes, which is why it took a real client to see:
FeatureOfInterest.featurewasnullfor every feature. The serialiser already read geometry from the label side table; nothing ever put any there.- The QGIS provider needs the geometry type in its data-source URI. With
url='...' entity='FeatureOfInterest'alone it builds a geometryless layer; withtype=Pointadded it builds a point layer. So even after the server was fixed, the first reading was still "no geometry" until the URI was right.
Fixed by giving Harrow Bend's features a layout — a row of six beds about 50 m long, with
the paddock, plot and hoop house offset — delivered through the same side table the names
use. Geometry is deliberately not a log record: a real farm's field boundaries are the
material VIRTUAL_FARM.md keeps in a gitignored .site/ directory,
and §3 has no record type for them. Harrow Bend is fictional and sits on the prime
meridian, so its geometry can live in the repository.
After the fix: 10 of 10 features with geometry, layer extent
-0.00060,-0.00040 : 0.00060,0.00030.
2. Every observed property had a blank name#
QGIS's legend showed six empty strings. The name fell back to "everything after the last
/", and the Q4 vocabulary decision had just given every
URI a trailing slash — which both vocabulary servers want, because the slashless form
redirects and a redirect is a different identifier to a dumb client. Everything after the
last slash of .../air_temperature/ is the empty string.
A defect introduced by a correct decision, invisible to every test we had, and the first
thing a human looking at a legend would notice. humanise_term now strips the trailing
slash and renders volume_fraction_of_condensed_water_in_soil as
"volume fraction of condensed water in soil" and batteryVoltage as "battery voltage" —
while leaving pH as pH rather than p h, which the first attempt got wrong and which
soil pH will certainly exercise.
3. The service emitted self links it answered 404 to#
An observed property's identity is its vocabulary URI (§3.4), so its self link read
ObservedProperties('http://vocab.nerc.ac.uk/standard_name/air_temperature/'). The
unencoded slashes split the path into the wrong segments and our own router returned
404 no such entity set: ObservedProperties('http:.
The id is now percent-encoded, and the router decodes it after splitting the path,
not before — the ordering is the whole fix. ObservedProperties is also now exempt from
UUID parsing, since it is the one entity set whose key is not a UUID.
A second instance of the same class: Thing advertised a Locations@iot.navigationLink,
and we serve no Locations at all. Removed rather than implemented — geometry here belongs
to a feature of interest, not to the Thing that observes it, and 1.1 makes
Thing.Locations 0..*.
Generalised into test_app.py::TestEverySelfLinkResolves, which walks every entity set,
collects every self and navigation link, and fetches each one. A link you cannot answer
is worse than one you never offered, because the client only learns after following it.
4. ObservedProperty had no Datastreams link at all#
1.1 Table 9 gives it one. It was simply missing, and a client cannot work around it: there is no other way to ask "which series measure this property?" than to list every datastream and filter client-side. Added.
5. A raw series was typed as a count, with a unit#
QGIS showed observationType = OM_CountObservation on a datastream whose
unitOfMeasurement was cm3/cm3. A count has no unit, so one document asserted two
incompatible things, and a consumer honouring observationType would be entitled to
refuse to average or interpolate a continuous measurement.
The cause was typing the datastream from the Python type of the first result it could
find — and a raw soil-moisture series carries scaled integers, 280 for 0.280. The type
now follows the declaration: a declared unit means a measured quantity, which is what
a unit is for. Only an undeclared unit lets the values decide, and then they decide
something real.
Two of our own tests asserted the old behaviour and were rewritten, because the behaviour
they pinned was wrong. The same scan was also walking every observation in the log for
every datastream on every /Datastreams request — the quadratic shape §7.2 forbids,
hiding in a serialiser rather than in the projection. It uses the index now.
This fix only moved the real question, which is what unit a raw datastream should declare when it carries scaled integers. That is a data-model decision affecting the canonical farm, so it is recorded as Q21 rather than quietly chosen.
What FROST settled, and one thing it did not#
With the oracle running alongside, three behaviours were checked against it rather than against a reading of the specification.
$top and @iot.nextLink — we match FROST exactly. FROST returns $top=2 as two
rows plus a nextLink with $skip=2; so do we. This mattered because the Fraunhofer
client follows nextLink automatically, so asking for 3 rows yields the whole collection —
which looks exactly like $top being ignored. I had drafted a change to suppress the
nextLink whenever $top was explicit, on a reading of OData where $top bounds the
result set rather than the page. FROST says otherwise, and FROST is the oracle this
project chose, so the change was not made. Recorded because it is precisely the sort of
conformant behaviour a later reader would "fix" into non-conformance.
FROST requires unitOfMeasurement even for a count. Posting a Datastream with
OM_CountObservation and a unit: 201. The same without a unit: 400. So our
count-with-a-unit document was never a protocol violation — it was a semantic one. This
also constrains Q21: a raw datastream declaring a null unit could not be mirrored into
the reference implementation.
FROST requires a FeatureOfInterest, or a Location to generate one from. A deep insert
of a Thing with Datastreams and Observations and no Location is rejected with
No FeatureOfInterest provided, and none can be generated. Our model derives features of
interest from the datastream's declared feature instead, which is a different and
deliberate choice (§3.1) — noted because anyone mirroring layer zero into FROST will meet
it.
Reproducing#
make serve # or: cd server && python3 serve.py --days 1 --port 8099 --open
docker pull qgis/qgis:ltr
docker run --rm --network host -e QT_QPA_PLATFORM=offscreen \
-v "$PWD/probe.py:/probe.py:ro" qgis/qgis:ltr python3 /probe.py
where probe.py builds layers with
QgsVectorLayer("url='http://127.0.0.1:8099/v1.1' entity='FeatureOfInterest' type=Point", "foi", "sensorthings").
QT_QPA_PLATFORM=offscreen is required; without it the container cannot initialise Qt.
For the Fraunhofer client, pip install frost-sta-client into a virtual environment and
point SensorThingsService at the same base URL.
Every finding above is pinned as a test, so none of it depends on the containers:
sdk/test_sensorthings.py (TestVocabularyUriPresentation, TestFeatureGeometry,
TestThingHasNoLocationsLink, TestUnitsAndTypes), server/test_app.py
(TestEverySelfLinkResolves, TestUriKeyedObservedProperties), and
sim/test_harrow_bend.py (TestLabelsAndGeometry).