Skip to content

fix(ui): give rx_snr real presence semantics end to end - #6523

Merged
jamesarich merged 4 commits into
mainfrom
claude/fervent-faraday-b12dd5
Jul 30, 2026
Merged

fix(ui): give rx_snr real presence semantics end to end#6523
jamesarich merged 4 commits into
mainfrom
claude/fervent-faraday-b12dd5

Conversation

@jamesarich

@jamesarich jamesarich commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Upstream meshtastic/protobufs is converting MeshPacket fields to explicit presence one at a time (rx_rssi in 2.7.26.138, rx_time in .140), and each conversion turns a non-null Wire field into a nullable one that breaks every read site at once. rx_snr is a likely next candidate and the dangerous one, because 0 dB is a genuine, common reading — so the takeIf { it != 0 } trick used for rx_time would be actively wrong. This adds the seam ahead of the bump and, in the process, fixes several live instances of the same presence-vs-sentinel-zero defect class.

🛠️ Refactoring & Architecture

  • Add MeshPacket.snrOrNull() in core/model alongside rxTimeOrNull(), and route all nine read sites through it. When rx_snr goes optional upstream, the bump becomes a one-line body change instead of a cross-module diff.
  • Add Node.snrOrNull / Node.rssiOrNull plus named SNR_UNSET / RSSI_UNSET constants. Node.snr defaults to Float.MAX_VALUE and Node.rssi to Int.MAX_VALUE, but with no canonical resolver five call sites each invented their own check (< 100f in two core/ui gates, an exact comparison in NodeDetailsSection, another in feature/car, and nothing at all in the AI paths). The divergent thresholds MAX_VALID_SNR, MAX_VALID_RSSI and SNR_UNSET_THRESHOLD are deleted — they had no other references.
  • Add SignalQuality.UNKNOWN in feature/car so "no reading" is distinct from NONE, which claims a measured but undemodulable link. The exhaustive whens over the enum forced every render site to be updated.
  • Make the SNR chain nullable end to end, following the template schema 51 already set for rssi: DataPacket.snr, Message.snr, Reaction.snr and MeshBeaconOffer.snr become Float?, and the Room packet.snr / reactions.snr columns go nullable via AutoMigration(51 → 52). MeshDataMapper now passes snrOrNull() straight through with no fallback, so the seam reaches the UI instead of stopping at the module boundary.
  • Route six raw SNR interpolations through MetricFormatter.snr (NeighborInfo, NeighborInfoHandlerImpl, DiscoveryReportFormatter, both discovery map marker snippets, DebugViewModel).

Why the seam must not fold zero

Under proto3 implicit presence, a field at its zero value is never serialized. So once rx_snr becomes optional, a zero written by older firmware already decodes to null for free — there is no old-vs-new firmware ambiguity to defend against. The only zero that can ever reach a nullable accessor was written explicitly by firmware that has explicit presence, i.e. a genuine 0 dB reading: a signal at the noise floor, comfortably demodulable on every preset. Folding zero would discard precisely the case the upstream change exists to preserve.

The general rule this gives, for the conversions still to come: fold zero only when zero is physically impossible as a reading. rx_time qualifies (a 1970 arrival time). rx_snr and rx_rssi do not.

Presence also cannot be inferred from transport instead — TRANSPORT_INTERNAL is 0, so firmware that never sets transport_mechanism would have every reading suppressed.

🐛 Bug Fixes

  • determineSignalQuality(Float.MAX_VALUE, …) returned GOOD. A node with no reading at all rated as an excellent signal. Most UI gates guarded first, but AiFunctionProviderImpl and the App Functions provider leaked the raw sentinel to a model. NodeDetails.snr/rssi and the App Functions response fields are now nullable.
  • SignalInfo hid the entire signal row for a genuine 0 dBm reading, because the gate was node.rssi < MAX_VALID_RSSI where MAX_VALID_RSSI = 0. It now gates on SNR presence and renders an absent RSSI as the unknown marker.
  • buildNodeDescription suppressed the TalkBack signal announcement for the same reason. RSSI is deliberately excluded from the rating (Use modem-preset-relative SNR thresholds for signal quality #5446), so its rssi parameter existed only to feed that broken gate and is removed.
  • NodeItem / NodeItemCompact required RSSI to be present before showing the quality icon, again contradicting the SNR-only rating.
  • ExportDataUseCase used rx_snr != 0f as a row-inclusion predicate, silently dropping any reception at exactly 0 dB from the CSV export.
  • DiscoveryScanEngine used if (rx_snr != 0f) to decide whether to record a reading, one line above an already-correct nullable rx_rssi check.
  • MessageItem rendered "SNR 0.00 dB" for a direct packet that carried no measurement — a reading the radio never took. Message.displayTime hides the equivalent problem for time by falling back to received_time, but snr had no such guard. Fixed by the nullable chain above.
  • MeshBeaconInvitationCard showed "0 dB" as a real reading whenever rssi happened to be present, because its gate was offer.rssi != null || offer.snr != 0f. Now a null check.
  • A null SNR now renders as unknown rather than as a bad reading: MetricFormatter.snr(null) yields the same em dash as rssi(null), Snr() renders nothing (mirroring its Rssi sibling), and LoraSignalIndicator shows "Signal Unknown" in a neutral tint instead of falling through to Quality.NONE.

🧹 Chores

  • The !rx_snr.isNaN() filters in SignalMetrics were dead code — rx_snr is a non-null primitive float today, so hasSnr was unconditionally true.
  • Delete NodeSignalQuality and SnrAndRssi, which had no call sites anywhere in the repo.

Testing Performed

./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests — green. Executed counts confirmed from the JUnit XML rather than a cached task tick.

Addedcore/model/src/commonTest/.../util/SnrExtensionsTest.kt (4 tests): snrOrNull reports negative and positive readings, treats a true zero as a measurement rather than as absent, and does not conflate a zero reading with an unknown one (a regression guard against the rxTimeOrNull pattern being copied over).

Addedcore/database MeshtasticDatabaseMigrationTest.snrColumnsGoNullableWithoutLosingRows: a stored 0 dB survives the 51→52 recreate as 0 rather than becoming NULL, and a NULL is storable afterwards. Mirrors the existing rssiColumnsGoNullableWithoutLosingRows.

Addedfeature/messaging MessageItemTest: directMessageWithoutSnrDoesNotFabricateAZeroReading (the regression guard for the "SNR 0.00 dB" bug) and directMessageWithZeroSnrShowsTheReading (the other half — 0 dB must still render). 10 tests in the class.

Modified

  • core/common/.../MetricFormatterTest.kt — added snrAbsentIsUnknown and snrZeroIsARealReading; extended snr to cover a negative value.
  • core/ui/.../LoraSignalIndicatorTest.kt — added a zero SNR reading is rated rather than treated as missing and absent SNR is not a quality band.
  • core/ui/.../LoraSignalIndicatorUiTest.kt — added snrRendersAZeroReading, snrRendersNothingWhenAbsent, loraSignalIndicatorShowsUnknownWhenSnrIsAbsent.
  • core/ui/.../BuildNodeDescriptionTest.ktsignal_hidden_when_rssi_not_negative and signal_hidden_when_snr_is_max_float encoded the buggy behaviour and are replaced by signal_shown_for_a_zero_snr_reading and signal_hidden_when_snr_is_absent.
  • feature/car/.../CarScreenDataBuilderTest.ktdetermineSignalQuality returns none when snr is max value becomes returns unknown when snr is absent; added rates a zero snr reading, plus buildNodeUi resolves an unset node snr to unknown and buildNodeUi rates a zero node snr reading. The buildNodeUi pair exercises the production path — they fail if the call reverts to reading node.snr, since Float.MAX_VALUE would rate as EXCELLENT. 42 tests in the class.

Notes for reviewers

  • Three test deletions are intentional and are called out above — they asserted the 0 dBm suppression and the sentinel-to-NONE mapping that this PR fixes.
  • Schema 52 is an auto-migration, same shape as the rssi change at schema 51: NOT NULL → nullable with no data transformation. As there, rows written before the migration keep their stored 0, so a legacy 0 dB reading stays indistinguishable from "no reading" in existing history — only new rows carry true presence. The column comments say so, and snrColumnsGoNullableWithoutLosingRows covers it.
  • discovered_node.snr is deliberately left NOT NULL. Its readers aggregate — DiscoveryRankingEngine takes a median over nodes.map { it.snr } and DiscoveryMapViewModel dedups with maxByOrNull { it.snr } — so nullability there is a semantics question (is an unmeasured node excluded from the median, or sorted last?) rather than a mechanical change.
  • DataPacket.time / Message.meshTime still carry a 0 sentinel, narrowed at MeshDataMapper by (rxTimeOrNull() ?: 0). Unlike snr it has a working downstream guard (displayTime falls back to the always-present received_time), so it is a structural wart with no user-visible symptom — not folded in here to keep this migration to one concern.
  • The Node.snr / NodeEntity.snr sentinels also remain; the new accessors make them safe to read, and removing them is a separate migration.
  • The seam generalizes cleanly to via_mqtt (absent ⟺ false; the codebase already writes via_mqtt == true, which is null-safe). hop_start/hop_limit carry a trap for whoever does that bump: isDirectSignal() compares hop_start == hop_limit, and on nullable types null == null is true in Kotlin, so an unstamped packet would be wrongly classified as a direct signal. Same shape at MeshMessageProcessorImpl and MeshDataHandlerImpl. Nothing was built for these speculatively.
  • feature/car strings are not Crowdin-managed, so car_signal_unknown needs no translation round-trip.
  • SnrExtensionsTest does not assert the proto-absent case, by necessity rather than omission: rx_snr is still a non-null float upstream, so snrOrNull() cannot return null for any packet the test could build. Null handling is covered where a null is representable — MetricFormatterTest and LoraSignalIndicatorUiTest. The file's KDoc records this.

🤖 Generated with Claude Code

jamesarich and others added 2 commits July 30, 2026 08:10
Upstream protobufs is converting MeshPacket fields to explicit presence one
at a time (rx_rssi in 2.7.26.138, rx_time in .140). rx_snr is a likely next
candidate, and each conversion currently breaks every read site at once.

Introduce MeshPacket.snrOrNull() and route all nine read sites through it, so
the eventual bump is a one-line body change instead of a cross-module diff.

Unlike rxTimeOrNull(), the seam must NOT fold zero. Under proto3 implicit
presence a field at its zero value is never serialized, so once rx_snr becomes
optional a zero written by older firmware already decodes to null for free.
The only zero that can reach a nullable accessor was written explicitly by
firmware that has explicit presence -- i.e. a genuine 0 dB reading, which is a
signal at the noise floor and demodulable on every preset. Folding it would
discard precisely the case the upstream change exists to preserve.

Presence cannot be inferred from transport instead: TRANSPORT_INTERNAL is 0,
so firmware that never sets transport_mechanism would have every reading
suppressed.

Also fixes two pre-existing wrong-zero folds this seam replaces:

- ExportDataUseCase used `rx_snr != 0f` as a row-inclusion predicate, silently
  dropping any reception at exactly 0 dB from the CSV export.
- DiscoveryScanEngine used `if (rx_snr != 0f)` to decide whether to record a
  reading, one line above an already-correct nullable rx_rssi check.

The `!rx_snr.isNaN()` filters in SignalMetrics were dead code: rx_snr is a
non-null primitive float today, so hasSnr was unconditionally true.

A null SNR now renders as unknown rather than as a bad reading:
MetricFormatter.snr(null) yields the em dash used by rssi(null), and Snr()
renders nothing, mirroring its Rssi sibling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Node.snr defaults to Float.MAX_VALUE and Node.rssi to Int.MAX_VALUE, but there
was no canonical resolver, so five call sites each invented their own check:
`< 100f` in two core/ui gates, an exact `!= Float.MAX_VALUE` in
NodeDetailsSection, an exact check in feature/car, and nothing at all in the AI
paths. determineSignalQuality() has no sentinel handling, so Float.MAX_VALUE
falls into `snr > limit -> GOOD`: a node with no reading at all rated as an
excellent signal.

Add Node.snrOrNull / Node.rssiOrNull plus named SNR_UNSET / RSSI_UNSET
constants, route every read through them, and delete the divergent thresholds
(MAX_VALID_SNR, MAX_VALID_RSSI, SNR_UNSET_THRESHOLD had no other references).

Three latent presence-vs-sentinel-zero bugs fell out of this:

- SignalInfo hid the whole signal row for a genuine 0 dBm reading, because the
  gate was `node.rssi < MAX_VALID_RSSI` where MAX_VALID_RSSI is 0. It now gates
  on SNR presence and renders an absent RSSI as the unknown marker.
- buildNodeDescription suppressed the TalkBack signal announcement for the same
  reason. Since RSSI is deliberately excluded from the rating (#5446), its rssi
  parameter existed only to feed that broken gate, so it is removed.
- NodeItem and NodeItemCompact required RSSI to be present before showing the
  quality icon, again contradicting the SNR-only rating.

feature/car mapped the sentinel to SignalQuality.NONE, which claims a measured
but undemodulable link. Add SignalQuality.UNKNOWN so absence is distinct; the
exhaustive `when`s over the enum forced every render site to be updated.

NodeDetails and the App Functions response now expose snr/rssi as nullable, so
the raw sentinel can no longer be surfaced to a model as a superb signal.

Also route six raw SNR interpolations through MetricFormatter.snr
(NeighborInfo, NeighborInfoHandlerImpl, DiscoveryReportFormatter, both
discovery map marker snippets, DebugViewModel), and delete NodeSignalQuality
and SnrAndRssi, which had no call sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

SNR and RSSI telemetry now use nullable accessors to distinguish unavailable values from valid zero readings. Formatting, packet processing, AI responses, exports, discovery output, standard UI, signal metrics, maps, and car UI were updated accordingly with expanded tests.

Changes

Telemetry contracts and propagation

Layer / File(s) Summary
Metric contracts and nullable accessors
core/common/..., core/model/..., core/data/.../ai/*, androidApp/src/google/.../AppFunctionModels.kt
SNR/RSSI accessors and AI response fields now support absent values, while MetricFormatter.snr preserves and formats zero readings.
Packet propagation and formatted outputs
core/model/..., core/data/..., core/domain/..., feature/discovery/..., feature/settings/..., androidApp/src/*/kotlin/.../map/discovery/*
Packet updates, exports, discovery handling, neighbor output, and map snippets use nullable SNR extraction or centralized formatting.
Nullable signal UI behavior
core/ui/..., feature/node/...
Node descriptions, signal indicators, signal rows, and metrics screens now render signal data based on nullable telemetry; tests cover absent and zero SNR.
Car signal quality states
feature/car/...
Car signal-quality computation and labels now represent absent SNR as UNKNOWN, with a localized string and tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested labels: bugfix

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Tests Prove The Path, Not The End State ⚠️ Warning LoraSignalIndicatorTest only asserts Quality.entries size/order, and several 0-dB tests don’t exercise the new nullable path, so they’d still pass on revert. Replace the enum-size check with an end-to-end absent-SNR assertion, and make zero-dB cases flow through the nullable seam (packet/node/UI) so reverting the production path fails.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Sibling Call Sites And Presence Semantics ✅ Passed All changed SNR/RSSI call sites (NodeItem, NodeItemCompact, SignalInfo, NodeDetailsSection, AI provider) were updated; no new reachable-0 default fields were introduced.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: restoring real presence semantics for rx_snr across the stack.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added the bugfix PR tag label Jul 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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
`@androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt`:
- Around line 129-132: Update the KDoc for nullable fields snr and rssi in the
AppFunction model to state that values may be unavailable/null, and correct
RSSI’s unit from dB to dBm to match the NodeDetails contract.

In
`@core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt`:
- Around line 47-49: Update the DataPacket model so snr is nullable (Float?) and
preserve the null result from snrOrNull() in MeshDataMapper instead of
defaulting to 0f. Adjust all affected consumers, signal logic, serialization,
and tests to handle absent SNR explicitly while retaining genuine 0 dB readings.

In
`@core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt`:
- Around line 60-65: Add an assertion in the test `snrOrNull does not conflate a
zero reading with an unknown one` that verifies `MeshPacket().snrOrNull()`
returns null, while preserving the existing zero-SNR assertion and the separate
rxTimeOrNull null assertion.

In
`@feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt`:
- Line 56: Add a buildNodeUi() test covering a node with the canonical unset SNR
representation, and assert the resulting car model signal quality is UNKNOWN.
Ensure the test exercises the production call through node.snrOrNull rather than
invoking determineSignalQuality() directly, so it fails if buildNodeUi()
regresses to node.snr.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d4fd649-dbec-466b-94e2-0900fb56df7e

📥 Commits

Reviewing files that changed from the base of the PR and between 4846425 and ae69463.

📒 Files selected for processing (36)
  • androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
  • androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt
  • androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
  • core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
  • core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
  • core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
  • core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt
  • core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
  • core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
  • core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
  • core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt
  • core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt
  • core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
  • core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt
  • core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt
  • core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
  • core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt
  • core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt
  • core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt
  • feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt
  • feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt
  • feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
  • feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt
  • feature/car/src/main/res/values/strings.xml
  • feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
  • feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
  • feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt
  • feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
  • feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
  • feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt

Comment thread core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt Outdated
jamesarich and others added 2 commits July 30, 2026 10:00
Address review feedback on #6523.

The car signal-quality tests called determineSignalQuality() directly, so
nothing proved buildNodeUi() routes through Node.snrOrNull rather than reading
node.snr. Add two buildNodeUi() cases: an unset node resolves to UNKNOWN (this
fails if the call reverts to node.snr, which would feed Float.MAX_VALUE into the
bands and rate a node with no reading as EXCELLENT), and a 0 dB node still rates
EXCELLENT.

Also document the nullable snr/rssi fields in the AppFunction schema and correct
RSSI's unit from dB to dBm to match the NodeDetails contract, and record in
SnrExtensionsTest why the proto-absent case is not asserted there: rx_snr is
still a non-null float upstream, so snrOrNull() cannot return null for any
packet the test could construct. Null handling is covered where a null is
representable, in MetricFormatterTest and LoraSignalIndicatorUiTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The snrOrNull() seam stopped at the module boundary: MeshDataMapper narrowed an
absent reading to 0f because DataPacket.snr was not nullable, so MessageItem
rendered "SNR 0.00 dB" for a direct packet that carried no measurement -- a
reading the radio never took. Message.displayTime hides the equivalent problem
for time by falling back to received_time, but snr had no such guard.

Make the chain nullable end to end, following the template schema 51 already
set for rssi:

- DataPacket.snr, Message.snr, Reaction.snr, MeshBeaconOffer.snr -> Float?
- Room packet.snr and reactions.snr -> nullable, via AutoMigration(51 -> 52)
- MeshDataMapper passes snrOrNull() straight through, no fallback

As with the rssi migration, rows written before schema 52 keep their stored 0,
so a legacy 0 dB reading stays indistinguishable from "no reading" for existing
history; only new rows carry true presence. The column comments say so.

MeshBeaconInvitationCard's `offer.snr != 0f` check becomes a null check, which
also fixes its OR-gate showing "0 dB" as a real reading whenever rssi happened
to be present.

Left out deliberately: discovered_node.snr. Its readers aggregate --
DiscoveryRankingEngine takes a median over `nodes.map { it.snr }` and
DiscoveryMapViewModel dedups with `maxByOrNull { it.snr }` -- so nullability
there is a semantics decision (is an unmeasured node excluded from the median,
or sorted last?), not a mechanical change. It stays NOT NULL until that is
decided.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jamesarich jamesarich changed the title fix(ui): add rx_snr presence seam and stop unset signal sentinels rating as real readings fix(ui): give rx_snr real presence semantics end to end Jul 30, 2026
@jamesarich
jamesarich added this pull request to the merge queue Jul 30, 2026
Merged via the queue into main with commit 2d20cd8 Jul 30, 2026
15 checks passed
@jamesarich
jamesarich deleted the claude/fervent-faraday-b12dd5 branch July 30, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant