Skip to content

Fix/decoder substring matching - #1057

Draft
jona159 wants to merge 7 commits into
devfrom
fix/decoder-substring-matching
Draft

Fix/decoder substring matching#1057
jona159 wants to merge 7 commits into
devfrom
fix/decoder-substring-matching

Conversation

@jona159

@jona159 jona159 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Type of Change

  • Dependency upgrade
  • Bug fix (non-breaking change)
  • Breaking change
    • e.g. a fixed bug or new feature that may break something else
  • New feature
  • Code quality improvements
    • e.g. refactoring, documentation, tests, tooling, ...

Implementation

Checklist

  • I gave this pull request a meaningful title
  • My pull request is targeting the dev branch
  • I have added documentation to my code
  • I have deleted code that I have commented out

Additional Information

  • This PR closes #

Summary by CodeRabbit

  • New Features

    • Added support for five SPS30 particle-number concentration measurements in Luftdaten devices.
    • Sensor metadata and catalog associations are now preserved when devices are created.
    • Added configurable value conversions for supported sensor readings.
  • Bug Fixes

    • Improved sensor matching across Luftdaten and hackAIR data, including reliable fallback matching.
    • Ambiguous or conflicting sensor mappings are now reported instead of selecting an incorrect sensor.
    • Prevented duplicate destination mappings and rejected incompatible sensor definitions.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b15712b9-478f-40a6-887c-b42e50b19399

📥 Commits

Reviewing files that changed from the base of the PR and between 5635799 and 563147c.

📒 Files selected for processing (7)
  • app/db/models/device.server.ts
  • app/lib/model-definitions.ts
  • app/lib/sensor-definitions.ts
  • app/services/decoding-service.server.ts
  • tests/db/models/device.server.spec.ts
  • tests/services/decoding-service.server.spec.ts
  • tests/services/hackair-decoding-service.server.spec.ts
 _____________________________
< Reviewing code like a boss. >
 -----------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/decoder-substring-matching

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68.1% 2345 / 3443
🔵 Statements 66.68% 2426 / 3638
🔵 Functions 65.13% 454 / 697
🔵 Branches 53.57% 1160 / 2165
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
app/db/models/device.server.ts 62.17% 66.52% 61.22% 63.21% 91-96, 104, 211, 223-224, 289-346, 386, 420, 449, 478, 508, 512, 520-522, 528-530, 548-550, 554-556, 598-600, 615, 626-798, 849-853, 881-887, 892-908, 992-994, 1005, 1025-1032, 1036-1047, 1056, 1084, 1208-1228, 1247
app/lib/model-definitions.ts 100% 100% 100% 100%
app/lib/sensor-definitions.ts 100% 100% 100% 100%
app/services/decoding-service.server.ts 80.39% 65.8% 100% 83.84% 57, 70-72, 81-82, 84-85, 88, 92, 93, 116-118, 134, 143, 155-157, 181, 186, 208, 225-226, 235, 244-245, 255-273, 326-330, 346, 347, 349, 359, 392, 393, 395, 401, 404, 416, 430, 435-437, 466, 471-473, 477, 532-534, 583-585
Generated in workflow #2855 for commit 563147c by the Vitest Coverage Report Action

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/db/models/device.server.ts (1)

1095-1112: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Validate a caller-supplied sensorDefinitionId on custom devices.

The last branch stores sensorData.data unchanged. A client that creates a custom device can therefore set data.sensorDefinitionId to any catalog key. findLuftdatenSensorMapping accepts that key and applies its mapping, including the 0.01 pressure multiplier, so stored measurement values change silently.

Either strip unknown sensorDefinitionId values in this branch, or validate the key against sensorDefinitions before persisting it.

♻️ Proposed validation
+					const requestedDefinitionId = existingSensorData.sensorDefinitionId
+					const isKnownDefinitionId =
+						typeof requestedDefinitionId === 'string' &&
+						requestedDefinitionId in sensorDefinitions
 					const sensorMetadata = storedDeviceSchemaVersion
 						? {
 								...existingSensorData,
 								deviceSchemaSensorId: sensorData.id,
 							}
 						: usesSensorDefinitions
 							? {
 									...existingSensorData,
 									sensorDefinitionId: sensorData.id,
 								}
-							: sensorData.data
+							: requestedDefinitionId !== undefined && !isKnownDefinitionId
+								? { ...existingSensorData, sensorDefinitionId: undefined }
+								: sensorData.data
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/db/models/device.server.ts` around lines 1095 - 1112, Validate the
caller-supplied sensorDefinitionId in the final branch of the sensorMetadata
construction before persisting sensorData.data. Accept it only when it matches a
key in sensorDefinitions; otherwise remove or ignore that field while preserving
the rest of the custom sensor data. Keep the storedDeviceSchemaVersion and
usesSensorDefinitions branches unchanged.
tests/services/decoding-service.server.spec.ts (1)

105-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for mixed sensor sets and duplicate catalog value types.

Two paths stay untested. First, a device that has both catalog sensors and legacy title-only sensors in one request. Second, a device that has two sensors whose definitions claim the same Luftdaten value type, for example pms5003_pm01 and pms7003_pm01. The second case is the failure described in app/lib/sensor-definitions.ts. A test would pin the intended behaviour.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/services/decoding-service.server.spec.ts` around lines 105 - 159,
Extend the decodeMeasurements test suite with coverage for a request containing
both catalog-defined sensors and legacy title-only sensors, asserting each maps
correctly. Add a separate test with sensor definitions such as pms5003_pm01 and
pms7003_pm01 that resolve to the same Luftdaten value type, and assert the
intended duplicate-mapping behavior described by sensor-definitions.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/lib/sensor-definitions.ts`:
- Around line 417-437: Update the Luftdaten entries in sensorDefinitions so
findLuftdatenSensorMapping can resolve each affected valueType—PMS_P0, PMS_P1,
PMS_P2, BME280_pressure, BMP180_pressure, temperature, and humidity—without
ambiguity when all model sensors are added. Ensure each value type has exactly
one mapping, or add deterministic discriminator metadata such as sensor type or
unit, while preserving the intended sensor phenomena.

In `@app/services/decoding-service.server.ts`:
- Around line 90-122: Change the ambiguity handling across
app/services/decoding-service.server.ts:90-122, :188-211, and :352-383 so one
unresolved value is skipped without aborting the full upload. In the Luftdaten
resolver at :90-122 and findHackairSensorId at :188-211, return undefined or
record the conflict instead of throwing; in the duplicate
destination-measurement handling at :352-383, drop the later duplicate after
this non-throwing policy is applied, preserving other measurements in the
request.

---

Nitpick comments:
In `@app/db/models/device.server.ts`:
- Around line 1095-1112: Validate the caller-supplied sensorDefinitionId in the
final branch of the sensorMetadata construction before persisting
sensorData.data. Accept it only when it matches a key in sensorDefinitions;
otherwise remove or ignore that field while preserving the rest of the custom
sensor data. Keep the storedDeviceSchemaVersion and usesSensorDefinitions
branches unchanged.

In `@tests/services/decoding-service.server.spec.ts`:
- Around line 105-159: Extend the decodeMeasurements test suite with coverage
for a request containing both catalog-defined sensors and legacy title-only
sensors, asserting each maps correctly. Add a separate test with sensor
definitions such as pms5003_pm01 and pms7003_pm01 that resolve to the same
Luftdaten value type, and assert the intended duplicate-mapping behavior
described by sensor-definitions.ts.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b15712b9-478f-40a6-887c-b42e50b19399

📥 Commits

Reviewing files that changed from the base of the PR and between 5635799 and 563147c.

📒 Files selected for processing (7)
  • app/db/models/device.server.ts
  • app/lib/model-definitions.ts
  • app/lib/sensor-definitions.ts
  • app/services/decoding-service.server.ts
  • tests/db/models/device.server.spec.ts
  • tests/services/decoding-service.server.spec.ts
  • tests/services/hackair-decoding-service.server.spec.ts

Comment on lines +417 to +437
pms5003_pm01: {
phenomenon: 'particulate-matter-mass-concentration-1um',
decoderMappings: { luftdaten: [{ valueType: 'PMS_P0' }] },
},
pms5003_pm25: {
phenomenon: 'particulate-matter-mass-concentration-2.5um',
decoderMappings: { luftdaten: [{ valueType: 'PMS_P2' }] },
},
bme280_pressure_pa: {
phenomenon: 'atmospheric-pressure',
decoderMappings: { luftdaten: [{ valueType: 'BME280_pressure' }] },
},
bme680_humidity: { phenomenon: 'relative-humidity' },
bme280_humidity: {
phenomenon: 'relative-humidity',
decoderMappings: { luftdaten: [{ valueType: 'BME280_humidity' }] },
},
pms5003_pm10: {
phenomenon: 'particulate-matter-mass-concentration-10um',
decoderMappings: { luftdaten: [{ valueType: 'PMS_P1' }] },
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Detect duplicate Luftdaten value types within each model definition.
set -euo pipefail

fd -t f 'sensor-definitions.ts' app/lib
fd -t f 'model-definitions.ts' app/lib

python3 - <<'PY'
import re, pathlib
sd = pathlib.Path('app/lib/sensor-definitions.ts').read_text()
md = pathlib.Path('app/lib/model-definitions.ts').read_text()

# metadata block only
meta = sd.split('const sensorDefinitionMetadata', 1)[1]
mapping = {}
for m in re.finditer(r"(\w+):\s*\{(.*?)\n\t\},", meta, re.S):
    key, body = m.group(1), m.group(2)
    vts = re.findall(r"valueType:\s*'([^']+)'", body)
    if vts:
        mapping[key] = vts

for model in re.finditer(r"'?([\w.:]+)'?:\s*\[(.*?)\]\s*as const", md, re.S):
    name, body = model.group(1), model.group(2)
    keys = re.findall(r"'([\w]+)'", body)
    seen = {}
    for k in keys:
        for vt in mapping.get(k, []):
            seen.setdefault(vt, []).append(k)
    dups = {vt: ks for vt, ks in seen.items() if len(ks) > 1}
    if dups:
        print(f"MODEL {name}")
        for vt, ks in dups.items():
            print(f"  {vt}: {ks}")
PY

Repository: openSenseMap/frontend

Length of output: 589


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- relevant model and sensor definitions ---'
rg -n -C 8 "luftdaten\.info|pms5003_pm01|pms7003_pm01|pms1003_pm01|pms3003_pm01|pms5003_pm10|pms3003_pm10|pms1003_pm10|pms3003_pm25|pms5003_pm25|bmp180_pressure_(pa|hpa)|bme280_pressure_(pa|hpa)|dht(11|22)" app/lib/sensor-definitions.ts app/lib/model-definitions.ts

printf '%s\n' '--- decoder mapping implementation and call sites ---'
rg -n -C 12 "findLuftdatenSensorMapping|Ambiguous Luftdaten sensor definition mapping|decoderMappings" app/services app/lib

Repository: openSenseMap/frontend

Length of output: 45228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- sensor creation and definition-ID resolution ---'
rg -n -C 10 "sensorDefinitionId|sensorTemplates|modelDefinitions|model-definitions|sensorDefinitions" app --glob '!lib/sensor-definitions.ts' --glob '!lib/model-definitions.ts'

printf '%s\n' '--- decoder tests and upload error handling ---'
rg -n -C 12 "findLuftdatenSensorMapping|Ambiguous Luftdaten|decodeMessage|luftdaten" app --glob '*test*' --glob '*spec*' --glob '*.ts' --glob '*.tsx'

printf '%s\n' '--- exact model block ---'
sed -n '55,102p' app/lib/model-definitions.ts

printf '%s\n' '--- exact decoder implementation ---'
sed -n '100,175p' app/services/decoding-service.server.ts
sed -n '335,385p' app/services/decoding-service.server.ts

Repository: openSenseMap/frontend

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re
from collections import defaultdict
from pathlib import Path

sensor_text = Path("app/lib/sensor-definitions.ts").read_text()
model_text = Path("app/lib/model-definitions.ts").read_text()
decoder_text = Path("app/services/decoding-service.server.ts").read_text()

metadata = sensor_text.split("const sensorDefinitionMetadata = {", 1)[1].split(
    "} as const satisfies", 1
)[0]

definitions = {}
starts = list(re.finditer(r"(?m)^\t([A-Za-z0-9_]+):\s*\{", metadata))
for index, match in enumerate(starts):
    end = starts[index + 1].start() if index + 1 < len(starts) else len(metadata)
    body = metadata[match.start():end]
    definitions[match.group(1)] = re.findall(
        r"valueType:\s*'([^']+)'", body
    )

model_match = re.search(
    r"'luftdaten\.info':\s*\[(.*?)\]\s*as const", model_text, re.S
)
assert model_match, "luftdaten.info model was not found"
model_keys = re.findall(r"'([A-Za-z0-9_]+)'", model_match.group(1))

claims = defaultdict(list)
for key in model_keys:
    for value_type in definitions.get(key, []):
        claims[value_type.lower()].append(key)

duplicates = {
    value_type: keys for value_type, keys in claims.items() if len(keys) > 1
}

print("Duplicate definition mappings in luftdaten.info:")
for value_type, keys in sorted(duplicates.items()):
    print(f"  {value_type}: {keys}")

assert all(
    key in duplicates
    for key in (
        "pms_p0",
        "pms_p1",
        "pms_p2",
        "bme280_pressure",
        "bmp180_pressure",
        "temperature",
        "humidity",
    )
)

assert "if (definitionMatches.length > 1)" in decoder_text
print("\nThe decoder throws when more than one sensor matches a value type.")

# Emulate the definition-matching branch for a default model device.
for value_type, keys in sorted(duplicates.items()):
    matches = [
        {"id": key, "data": {"sensorDefinitionId": key}}
        for key in model_keys
        if key in keys
    ]
    assert len(matches) > 1
    print(f"  {value_type}: {len(matches)} matches")
PY

Repository: openSenseMap/frontend

Length of output: 903


Prevent ambiguous Luftdaten mappings

luftdaten.info contains multiple definitions for PMS_P0, PMS_P1, PMS_P2, BME280_pressure, BMP180_pressure, temperature, and humidity. When sensorTemplates is omitted, device creation adds all model sensors. findLuftdatenSensorMapping then throws Ambiguous Luftdaten sensor definition mapping ..., so an upload containing any affected value type fails. Enforce one mapping per value type or add a deterministic discriminator such as sensor type or unit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/sensor-definitions.ts` around lines 417 - 437, Update the Luftdaten
entries in sensorDefinitions so findLuftdatenSensorMapping can resolve each
affected valueType—PMS_P0, PMS_P1, PMS_P2, BME280_pressure, BMP180_pressure,
temperature, and humidity—without ambiguity when all model sensors are added.
Ensure each value type has exactly one mapping, or add deterministic
discriminator metadata such as sensor type or unit, while preserving the
intended sensor phenomena.

Comment on lines +90 to +122
const aliases = luftdatenMatchings[vt_phenomenon]
const compatibleSensors = sensors.filter((sensor) => {
if (!sensor?.id || !sensor.title) return false
if (!sensor.sensorType) return true

const title = sensor.title.toLowerCase()
return sensor.sensorType.toLowerCase().startsWith(vt_sensortype)
})

if (sensor.sensorType) {
const type = sensor.sensorType.toLowerCase()
if (!type.startsWith(vt_sensortype)) continue
}
const exactMatches = compatibleSensors.filter((sensor) => {
const title = sensor.title!.toLowerCase()
return title === vt_phenomenon || aliases.includes(title)
})

const aliases = luftdatenMatchings[vt_phenomenon]
const titleMatches =
title === vt_phenomenon ||
aliases.includes(title) ||
aliases.some((alias) => title.includes(alias))
if (exactMatches.length > 1) {
throw new Error(
`Ambiguous Luftdaten sensor mapping for value type ${value_type}`,
)
}
if (exactMatches.length === 1) return exactMatches[0].id

if (titleMatches) return sensor.id
const substringMatches = compatibleSensors.filter((sensor) => {
const title = sensor.title!.toLowerCase()
return aliases.some((alias) => title.includes(alias))
})

if (substringMatches.length > 1) {
throw new Error(
`Ambiguous Luftdaten sensor mapping for value type ${value_type}`,
)
}

return undefined
return substringMatches[0]?.id
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Ambiguous or duplicated mappings reject the complete upload. All three sites throw instead of skipping the affected value, so one unresolvable reading discards every other measurement in the same request. Existing devices with duplicate sensor titles, and new model devices with duplicate catalog value types, lose all data of each upload.

  • app/services/decoding-service.server.ts#L90-L122: return undefined for an ambiguous Luftdaten title match, or record the conflict, instead of throwing out of decodeMessage.
  • app/services/decoding-service.server.ts#L188-L211: apply the same non-throwing resolution in findHackairSensorId.
  • app/services/decoding-service.server.ts#L375-L383: drop the later duplicate destination measurement, or keep the throw only after the ambiguity policy above is settled.
📍 Affects 1 file
  • app/services/decoding-service.server.ts#L90-L122 (this comment)
  • app/services/decoding-service.server.ts#L188-L211
  • app/services/decoding-service.server.ts#L352-L383
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/decoding-service.server.ts` around lines 90 - 122, Change the
ambiguity handling across app/services/decoding-service.server.ts:90-122,
:188-211, and :352-383 so one unresolved value is skipped without aborting the
full upload. In the Luftdaten resolver at :90-122 and findHackairSensorId at
:188-211, return undefined or record the conflict instead of throwing; in the
duplicate destination-measurement handling at :352-383, drop the later duplicate
after this non-throwing policy is applied, preserving other measurements in the
request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant