Skip to content

Luftdaten decoder mis-routes SPS30_N1 into the NC10 sensor — greedy substring matching + non-deterministic sensor order #1051

Description

@MMBytes

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:

  1. 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.
  2. 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 wins

For 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

  1. 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.
  2. 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

  1. 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).
  2. POST /boxes/:id/data?luftdaten=1 with body
    {"sensordatavalues":[{"value_type":"SPS30_N1","value":"1.0"},{"value_type":"SPS30_N10","value":"2.0"}]}
  3. Observe: NC1.0 sensor receives nothing; NC10 receives both values.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status
🔨 In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions