Summary
On the new backend, a device uploading Luftdaten-format SPS30 data can have its
SPS30_N1 (NC1.0) values silently delivered into the NC10 sensor, starving
the NC1.0 sensor entirely. Two independent defects compound to cause this:
findLuftdatenSensorId() uses greedy substring alias matching with
first-match-wins over the device's sensor list — and "nc10".includes("nc1")
is true, so a sensor titled NC10 can capture the n1 phenomenon.- The sensor list used for decoding is loaded without any
ORDER BY, so
Postgres returns heap order. Whether bug 1 bites therefore depends on
physical row order, which silently changes when rows are rewritten
(migration, autovacuum, any UPDATE).
Either fix alone resolves the symptom; both are worth fixing.
Environment
- Host:
upload.staging.opensensemap.org (new backend, openSenseMap/frontend) - Device: ID redacted, please PM me for details. SPS30 + BME280
- DNMS + WiFi sensors, uploading Luftdaten JSON every 3 min via
POST /boxes/:deviceId/data?luftdaten=1
- Payload includes (among others):
{"value_type":"SPS30_N1","value":"12.01"} and
{"value_type":"SPS30_N10","value":"12.10"}
Observed behaviour (field data)
- Until 2026-07-16 ~04:00 UTC all 14 sensors updated every cycle.
- From one cycle to the next — with no device-side or box-config change —
the NC1.0 sensor stopped receiving values and has been stale since
(22+ days). All other sensors continue to update normally. - The NC10 sensor now receives two writes per upload: the mis-routed
SPS30_N1 value followed by the correct SPS30_N10 value (body order is
preserved by the transform), so its measurement history is polluted with
interleaved NC1.0 values. - The device's current sensor order, as returned by the API, is:
NC0.5, NC4.0, rel. Luftfeuchte, PM10, NC10, NC1.0, Luftdruck, Temperatur, PM1 (P0), Schallpegel, NC2.5, Signal, PM2.5, PM4 — note NC10 precedes
NC1.0. The clean break at a specific timestamp, with unchanged
configuration, indicates the physical row order flipped server-side at that
moment (deploy/migration/vacuum window).
Root cause 1 — greedy substring alias matching
app/services/decoding-service.server.ts, findLuftdatenSensorId():
constaliases=luftdatenMatchings[vt_phenomenon]// n1 → ['nc1.0','nc1','n1.0','n1']consttitleMatches=title===vt_phenomenon||aliases.includes(title)||aliases.some((alias)=>title.includes(alias))// ← "nc10".includes("nc1") === trueif(titleMatches)returnsensor.id// ← first match winsFor value_type = "SPS30_N1" (phenomenon n1), a sensor titled NC10
matches via the substring rule before the correctly-titled NC1.0 sensor is
ever considered — whenever NC10 happens to come first in the list.
Note the collision is not renameable-around: the n10 aliases are
['nc10','n10'], and every string containing either necessarily contains an
n1-matching substring. Only ordering (or a matcher fix) disambiguates.
The classic API (sensebox/openSenseMap-API,
packages/models/src/measurement/decoding/luftdatenHandler.js) has the same
matcher logic, but is shielded in practice: sensors there are an embedded
MongoDB array whose order is structurally stable (creation order). The new
Postgres backend removed that accidental protection.
Root cause 2 — sensor relation loaded without ORDER BY
app/db/models/device.server.ts, getDeviceForMeasurementWrite():
with: {sensors: {columns: {id: true,title: true,sensorType: true},// no orderBy → Postgres heap order, not deterministic over time},},Without ORDER BY, Postgres returns rows in physical order; any tuple rewrite
(UPDATE, migration, VACUUM FULL, etc.) can permanently reorder them. Decode
results should not depend on this.
Suggested fixes
- Matcher specificity (
decoding-service.server.ts): resolve matches in
priority tiers across all sensors before accepting a weaker tier —
(a) title === phenomenon, then (b) aliases.includes(title) (exact alias
equality), and only then (c) substring containment, preferring the longest
matching alias. Under that rule NC1.0 (exact alias of n1) always beats
NC10 (substring hit) regardless of order. - Deterministic order (
device.server.ts): add
orderBy: (sensor, { asc }) => asc(sensor.createdAt) (or asc(sensor.id))
to the sensors relation in getDeviceForMeasurementWrite() — and any other
sensor list consumed by decoders.
Minimal repro
- Create a device with two phenomenon-colliding sensors so that
NC10
precedes NC1.0 in the relation result (on a fresh box: create NC10 first). POST /boxes/:id/data?luftdaten=1 with body
{"sensordatavalues":[{"value_type":"SPS30_N1","value":"1.0"},{"value_type":"SPS30_N10","value":"2.0"}]}- Observe: NC1.0 sensor receives nothing; NC10 receives both values.
Summary
On the new backend, a device uploading Luftdaten-format SPS30 data can have its
SPS30_N1(NC1.0) values silently delivered into the NC10 sensor, starvingthe NC1.0 sensor entirely. Two independent defects compound to cause this:
findLuftdatenSensorId()uses greedy substring alias matching withfirst-match-wins over the device's sensor list — and
"nc10".includes("nc1")is true, so a sensor titled
NC10can capture then1phenomenon.ORDER BY, soPostgres returns heap order. Whether bug 1 bites therefore depends on
physical row order, which silently changes when rows are rewritten
(migration, autovacuum, any UPDATE).
Either fix alone resolves the symptom; both are worth fixing.
Environment
upload.staging.opensensemap.org(new backend,openSenseMap/frontend)POST /boxes/:deviceId/data?luftdaten=1{"value_type":"SPS30_N1","value":"12.01"}and{"value_type":"SPS30_N10","value":"12.10"}Observed behaviour (field data)
the NC1.0 sensor stopped receiving values and has been stale since
(22+ days). All other sensors continue to update normally.
SPS30_N1value followed by the correctSPS30_N10value (body order ispreserved by the transform), so its measurement history is polluted with
interleaved NC1.0 values.
NC0.5, NC4.0, rel. Luftfeuchte, PM10, NC10, NC1.0, Luftdruck, Temperatur, PM1 (P0), Schallpegel, NC2.5, Signal, PM2.5, PM4— note NC10 precedesNC1.0. The clean break at a specific timestamp, with unchanged
configuration, indicates the physical row order flipped server-side at that
moment (deploy/migration/vacuum window).
Root cause 1 — greedy substring alias matching
app/services/decoding-service.server.ts,findLuftdatenSensorId():For
value_type = "SPS30_N1"(phenomenonn1), a sensor titledNC10matches via the substring rule before the correctly-titled
NC1.0sensor isever considered — whenever NC10 happens to come first in the list.
Note the collision is not renameable-around: the
n10aliases are['nc10','n10'], and every string containing either necessarily contains ann1-matching substring. Only ordering (or a matcher fix) disambiguates.The classic API (
sensebox/openSenseMap-API,packages/models/src/measurement/decoding/luftdatenHandler.js) has the samematcher logic, but is shielded in practice: sensors there are an embedded
MongoDB array whose order is structurally stable (creation order). The new
Postgres backend removed that accidental protection.
Root cause 2 — sensor relation loaded without ORDER BY
app/db/models/device.server.ts,getDeviceForMeasurementWrite():Without
ORDER BY, Postgres returns rows in physical order; any tuple rewrite(UPDATE, migration, VACUUM FULL, etc.) can permanently reorder them. Decode
results should not depend on this.
Suggested fixes
decoding-service.server.ts): resolve matches inpriority tiers across all sensors before accepting a weaker tier —
(a)
title === phenomenon, then (b)aliases.includes(title)(exact aliasequality), and only then (c) substring containment, preferring the longest
matching alias. Under that rule
NC1.0(exact alias ofn1) always beatsNC10(substring hit) regardless of order.device.server.ts): addorderBy: (sensor, { asc }) => asc(sensor.createdAt)(orasc(sensor.id))to the sensors relation in
getDeviceForMeasurementWrite()— and any othersensor list consumed by decoders.
Minimal repro
NC10precedes
NC1.0in the relation result (on a fresh box: create NC10 first).POST /boxes/:id/data?luftdaten=1with body{"sensordatavalues":[{"value_type":"SPS30_N1","value":"1.0"},{"value_type":"SPS30_N10","value":"2.0"}]}