From 0bd7dd4686ab139226a8e984eed98fd7cdb2a0f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:46:13 +0000 Subject: [PATCH] feat(android): capture UWB RSSI/first-path into signalQuality (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw ranging records had dead quality fields (signalQuality≡1.0, rssi≡0.0), so MeasurementPreFilter's signal-quality gate (raw.signalQuality < 0.5) never fired and the engine was blind to weak cross-room / NLOS links. Capture per-link RSSI (dBm) off the AndroidX UWB RangingPosition in AndroidUwbService.runLink and carry it on UwbRangingResult.rssiDbm (nullable, defaulted — the non-radio emitExternalRange path stays source-compatible). The RangingPosition RSSI accessor is read via reflection (extractRssiDbm) and range-validated: it is not a stable part of the androidx.core.uwb 1.0.0-beta01 surface across vendor stacks, and this Android module cannot be built offline to catch a missing symbol — so capture is best-effort, null when unreported. AndroidUwbRangingServiceAdapter threads the real dBm into RawRangingMeasurement.rssi and DERIVES signalQuality from it using the engine's own existing RSSI map (((rssi+100)/50) clamped to [0,1], per MeasurementPreFilter.computeRawConfidence), so a link at <= -75 dBm crosses the 0.5 gate. Both fall back to the prior dead defaults when no RSSI is reported. First-path / CIR is not on the AndroidX surface, so it is not attempted. No common change — the fields already exist. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../platform/AndroidMofePlatformProvider.kt | 18 ++++++++-- .../aether/mofe/platform/AndroidUwbService.kt | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/androidApp/src/main/java/com/aether/mofe/platform/AndroidMofePlatformProvider.kt b/androidApp/src/main/java/com/aether/mofe/platform/AndroidMofePlatformProvider.kt index 1f35c94..29ae651 100644 --- a/androidApp/src/main/java/com/aether/mofe/platform/AndroidMofePlatformProvider.kt +++ b/androidApp/src/main/java/com/aether/mofe/platform/AndroidMofePlatformProvider.kt @@ -47,8 +47,22 @@ class AndroidUwbRangingServiceAdapter( // every bearing over ~3° as "out of range", so self-yaw never anchored. azimuth = r.azimuthDeg?.toDouble()?.let { Math.toRadians(it) }, elevation = r.elevationDeg?.toDouble()?.let { Math.toRadians(it) }, - signalQuality = 1.0, - rssi = 0.0, + // Per-link signal strength captured from the UWB ranging callback (#62). + // Previously both fields were dead constants (signalQuality≡1.0, rssi≡0.0), + // so MeasurementPreFilter's signal-quality gate (raw.signalQuality < 0.5) + // never fired and weak long-range / NLOS links were treated as pristine. + // + // Android UWB reports RSSI in dBm (when the stack exposes it), not a + // normalized [0,1] quality. Thread the real dBm into `rssi`, and DERIVE + // `signalQuality` from it with the SAME linear map the engine already applies + // to RSSI in MeasurementPreFilter.computeRawConfidence — ((rssi + 100)/50) + // clamped to [0,1] — so a link at ≤ -75 dBm crosses the 0.5 gate. When the + // stack reports no RSSI (r.rssiDbm == null) we fall back to the previous dead + // defaults, leaving behavior unchanged for hardware/versions that expose none. + // NOTE: the -100..-50 dBm scale is the engine's existing assumption; real UWB + // RSSI may need on-device recalibration of that map (see #62 follow-up). + signalQuality = r.rssiDbm?.let { ((it + 100.0) / 50.0).coerceIn(0.0, 1.0) } ?: 1.0, + rssi = r.rssiDbm?.toDouble() ?: 0.0, ) } diff --git a/androidApp/src/main/java/com/aether/mofe/platform/AndroidUwbService.kt b/androidApp/src/main/java/com/aether/mofe/platform/AndroidUwbService.kt index bb3e16b..be4fe3a 100644 --- a/androidApp/src/main/java/com/aether/mofe/platform/AndroidUwbService.kt +++ b/androidApp/src/main/java/com/aether/mofe/platform/AndroidUwbService.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Base64 import android.util.Log import androidx.core.uwb.RangingParameters +import androidx.core.uwb.RangingPosition import androidx.core.uwb.RangingResult import androidx.core.uwb.UwbAddress import androidx.core.uwb.UwbClientSessionScope @@ -241,6 +242,10 @@ class AndroidUwbService(private val context: Context) { azimuthDeg = pos.azimuth?.value, elevationDeg = pos.elevation?.value, timestampMs = System.currentTimeMillis(), + // Capture per-link RSSI off this range sample (#62) so the + // engine can see weak links. Best-effort / null when the + // stack does not report it — see extractRssiDbm. + rssiDbm = extractRssiDbm(pos), ) ) } @@ -349,6 +354,29 @@ class AndroidUwbService(private val context: Context) { private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) private fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } + /** + * Read per-link RSSI (dBm) off a UWB [RangingPosition] DEFENSIVELY (#62). + * + * WHY REFLECTION rather than a direct `position.rssiDbm`: receive-signal strength + * is not a stable part of the AndroidX UWB surface across the versions and vendor + * stacks this app runs against — `RangingPosition.rssiDbm` was added partway along + * the androidx.core.uwb 1.0.0 alpha→beta line, and some OEM builds still back it + * with the framework RSSI_UNKNOWN sentinel. A direct property reference would fail + * to compile/link where it is absent, and this Android module cannot be built in + * the offline dev sandbox to catch that. Reflection keeps the capture best-effort: + * real dBm when the stack reports it, null (→ the engine keeps its prior default) + * when it does not. First-path / CIR is not exposed on the AndroidX surface at all, + * so it is deliberately not attempted here. + * + * @return a plausible negative dBm reading, or null when unavailable / unknown. + */ + private fun extractRssiDbm(position: RangingPosition): Int? = runCatching { + val raw = (position.javaClass.getMethod("getRssiDbm").invoke(position) as? Number)?.toInt() + // Valid UWB RSSI is a negative dBm (~ -30 … -100). Reject the framework + // RSSI_UNKNOWN sentinel (-128) and any non-negative "unset" reading. + if (raw != null && raw in -127..-1) raw else null + }.getOrNull() + companion object { // Fixed UWB channel so both peers agree without an OOB exchange. Channel 9 + BPRF // preambles 9–12 are broadly supported (Pixel + Galaxy). UWB_PREAMBLE is the legacy @@ -397,6 +425,12 @@ data class UwbRangingResult( val azimuthDeg: Float?, val elevationDeg: Float?, val timestampMs: Long, + /** Per-link receive signal strength in dBm from the UWB ranging callback, or + * null when the platform/hardware does not report it (read defensively in + * AndroidUwbService.extractRssiDbm). Threaded to the engine's rssi / + * signalQuality fields in AndroidUwbRangingServiceAdapter. Defaulted so the + * non-radio emitExternalRange path stays source-compatible. (#62) */ + val rssiDbm: Int? = null, ) enum class SessionStatus { -- 2.43.0