feat(discovery): add 2.4 GHz hardware gating and AI provider tests (D045, D037)

This commit is contained in:
James Rich
2026-05-07 19:16:15 -05:00
parent 937a1b8fd7
commit 92bf9a6a31
4 changed files with 394 additions and 2 deletions

View File

@@ -0,0 +1,94 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.discovery.scan
import org.koin.core.annotation.Single
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
/** Result of a 2.4 GHz capability check. */
sealed interface HardwareCapabilityResult {
/** The connected radio supports 2.4 GHz operation. */
data object Supported : HardwareCapabilityResult
/** The connected radio does NOT support 2.4 GHz operation. */
data class Unsupported(val reason: String) : HardwareCapabilityResult
/** Capability could not be determined (hardware data unavailable or ambiguous). */
data class Unknown(val reason: String) : HardwareCapabilityResult
}
/**
* Determines whether the currently connected radio supports 2.4 GHz LoRa operation (SX1280 chip).
*
* Uses a layered heuristic:
* 1. Check for explicit `2.4ghz` or `sx1280` tags in the hardware metadata.
* 2. Check the platformIO target or slug for `sx1280`, `2.4`, or `2400` patterns.
* 3. Default to [HardwareCapabilityResult.Unknown] when no evidence is available.
*/
@Single
class Check24GhzCapability(
private val nodeRepository: NodeRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
) {
/**
* Checks if the currently connected radio supports 2.4 GHz. Returns [HardwareCapabilityResult.Unknown] if not
* connected or hardware data is unavailable.
*/
@Suppress("ReturnCount")
suspend operator fun invoke(): HardwareCapabilityResult {
val ourNode = nodeRepository.ourNodeInfo.value ?: return HardwareCapabilityResult.Unknown("No radio connected")
val hwModel = ourNode.user.hw_model.value
if (hwModel == 0) return HardwareCapabilityResult.Unknown("Hardware model unknown")
val myNodeInfo = nodeRepository.myNodeInfo.value
val target = myNodeInfo?.pioEnv
val hw =
deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target).getOrNull()
?: return HardwareCapabilityResult.Unknown("Hardware metadata unavailable for model $hwModel")
return evaluate(hw)
}
@Suppress("ReturnCount")
internal fun evaluate(hw: DeviceHardware): HardwareCapabilityResult {
// Layer 1: Check explicit tags
val tags = hw.tags.orEmpty().map { it.lowercase() }
if (tags.any { it in SUPPORTED_TAGS }) return HardwareCapabilityResult.Supported
if (tags.any { it in UNSUPPORTED_TAGS }) {
return HardwareCapabilityResult.Unsupported("Hardware tagged as sub-GHz only")
}
// Layer 2: Check platformioTarget or hwModelSlug for SX1280/2.4GHz patterns
val targetLower = hw.platformioTarget.lowercase()
val slugLower = hw.hwModelSlug.lowercase()
if (SUPPORTED_PATTERNS.any { it in targetLower || it in slugLower }) {
return HardwareCapabilityResult.Supported
}
// Layer 3: No definitive evidence — default to unknown/unsupported
return HardwareCapabilityResult.Unknown("Cannot verify 2.4 GHz support for ${hw.displayName}")
}
companion object {
private val SUPPORTED_TAGS = setOf("2.4ghz", "sx1280", "lora24", "2400mhz")
private val UNSUPPORTED_TAGS = setOf("sub-ghz-only", "sx1262", "sx1276")
private val SUPPORTED_PATTERNS = listOf("sx1280", "2.4", "2400", "lora24")
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.discovery
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.testing.FakeDeviceHardwareRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.feature.discovery.scan.Check24GhzCapability
import org.meshtastic.feature.discovery.scan.HardwareCapabilityResult
import kotlin.test.Test
import kotlin.test.assertIs
class Check24GhzCapabilityTest {
private val check =
Check24GhzCapability(
nodeRepository = FakeNodeRepository(),
deviceHardwareRepository = FakeDeviceHardwareRepository(),
)
// --- Tag-based detection ---
@Test
fun evaluate_returns_supported_when_tag_contains_sx1280() {
val hw = baseHardware(tags = listOf("sx1280", "ble"))
assertIs<HardwareCapabilityResult.Supported>(check.evaluate(hw))
}
@Test
fun evaluate_returns_supported_when_tag_contains_2_4ghz() {
val hw = baseHardware(tags = listOf("2.4ghz"))
assertIs<HardwareCapabilityResult.Supported>(check.evaluate(hw))
}
@Test
fun evaluate_returns_supported_when_tag_contains_lora24() {
val hw = baseHardware(tags = listOf("lora24", "esp32"))
assertIs<HardwareCapabilityResult.Supported>(check.evaluate(hw))
}
@Test
fun evaluate_returns_unsupported_when_tag_contains_sub_ghz_only() {
val hw = baseHardware(tags = listOf("sub-ghz-only"))
assertIs<HardwareCapabilityResult.Unsupported>(check.evaluate(hw))
}
@Test
fun evaluate_returns_unsupported_when_tag_contains_sx1262() {
val hw = baseHardware(tags = listOf("sx1262"))
assertIs<HardwareCapabilityResult.Unsupported>(check.evaluate(hw))
}
// --- Pattern-based detection (target / slug) ---
@Test
fun evaluate_returns_supported_when_target_contains_sx1280() {
val hw = baseHardware(platformioTarget = "tlora-v2_1-1_6-sx1280")
assertIs<HardwareCapabilityResult.Supported>(check.evaluate(hw))
}
@Test
fun evaluate_returns_supported_when_slug_contains_2400() {
val hw = baseHardware(hwModelSlug = "rak-2400")
assertIs<HardwareCapabilityResult.Supported>(check.evaluate(hw))
}
@Test
fun evaluate_returns_supported_when_target_contains_lora24() {
val hw = baseHardware(platformioTarget = "nano-g2-lora24")
assertIs<HardwareCapabilityResult.Supported>(check.evaluate(hw))
}
// --- Fallback to unknown ---
@Test
fun evaluate_returns_unknown_when_no_evidence_available() {
val hw = baseHardware(platformioTarget = "heltec-v3", hwModelSlug = "heltec-v3", tags = emptyList())
val result = check.evaluate(hw)
assertIs<HardwareCapabilityResult.Unknown>(result)
}
@Test
fun evaluate_returns_unknown_when_tags_are_null() {
val hw = baseHardware(tags = null)
val result = check.evaluate(hw)
assertIs<HardwareCapabilityResult.Unknown>(result)
}
// --- Edge cases ---
@Test
fun evaluate_tag_matching_is_case_insensitive() {
val hw = baseHardware(tags = listOf("SX1280", "BLE"))
assertIs<HardwareCapabilityResult.Supported>(check.evaluate(hw))
}
@Test
fun evaluate_supported_tag_takes_precedence_when_both_present() {
// If hardware has both supported and unsupported tags (unusual), supported wins
val hw = baseHardware(tags = listOf("sx1280", "sx1262"))
assertIs<HardwareCapabilityResult.Supported>(check.evaluate(hw))
}
private fun baseHardware(
platformioTarget: String = "generic-target",
hwModelSlug: String = "generic-slug",
tags: List<String>? = null,
) = DeviceHardware(
activelySupported = true,
architecture = "esp32",
displayName = "Test Device",
hwModel = 42,
hwModelSlug = hwModelSlug,
platformioTarget = platformioTarget,
tags = tags,
)
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.discovery
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DiscoverySummaryAiProviderTest {
private val testSession =
DiscoverySessionEntity(
id = 1L,
timestamp = 1_000_000L,
presetsScanned = "LONG_FAST",
homePreset = "LONG_FAST",
totalUniqueNodes = 5,
completionStatus = "complete",
)
private val testPresetResult =
DiscoveryPresetResultEntity(
id = 1L,
sessionId = 1L,
presetName = "LONG_FAST",
dwellDurationSeconds = 30L,
uniqueNodes = 3,
directNeighborCount = 2,
meshNeighborCount = 1,
messageCount = 5,
sensorPacketCount = 2,
)
// --- Supported case: provider available and returns results ---
@Test
fun supported_provider_returns_session_summary() = runTest {
val provider = AvailableAiProvider(sessionResult = "AI recommends LONG_FAST")
assertTrue(provider.isAvailable)
val result = provider.generateSessionSummary(testSession, listOf(testPresetResult))
assertEquals("AI recommends LONG_FAST", result)
}
@Test
fun supported_provider_returns_preset_summary() = runTest {
val provider = AvailableAiProvider(presetResult = "LONG_FAST: Good range, low congestion")
assertTrue(provider.isAvailable)
val result = provider.generatePresetSummary(testPresetResult)
assertEquals("LONG_FAST: Good range, low congestion", result)
}
// --- Unsupported case: provider not available ---
@Test
fun unsupported_provider_reports_not_available() {
val provider = UnavailableAiProvider()
assertTrue(!provider.isAvailable)
}
@Test
fun unsupported_provider_returns_null_for_session_summary() = runTest {
val provider = UnavailableAiProvider()
val result = provider.generateSessionSummary(testSession, listOf(testPresetResult))
assertNull(result)
}
@Test
fun unsupported_provider_returns_null_for_preset_summary() = runTest {
val provider = UnavailableAiProvider()
val result = provider.generatePresetSummary(testPresetResult)
assertNull(result)
}
// --- Failure case: provider throws or returns null ---
@Test
fun failing_provider_returns_null_on_session_error() = runTest {
val provider = FailingAiProvider()
assertTrue(provider.isAvailable) // Provider thinks it's available but fails
val result = provider.generateSessionSummary(testSession, listOf(testPresetResult))
assertNull(result)
}
@Test
fun failing_provider_returns_null_on_preset_error() = runTest {
val provider = FailingAiProvider()
val result = provider.generatePresetSummary(testPresetResult)
assertNull(result)
}
// --- Algorithmic fallback always works ---
@Test
fun algorithmic_generator_produces_non_null_summary() {
val generator = DiscoverySummaryGenerator()
val summary = generator.generateSessionSummary(testSession, listOf(testPresetResult))
assertNotNull(summary)
assertTrue(summary.contains("LONG_FAST"))
}
@Test
fun algorithmic_generator_handles_empty_presets() {
val generator = DiscoverySummaryGenerator()
val summary = generator.generateSessionSummary(testSession, emptyList())
assertEquals("No presets were scanned during this session.", summary)
}
}
// --- Test doubles ---
private class AvailableAiProvider(
private val sessionResult: String? = "AI summary",
private val presetResult: String? = "Preset summary",
) : DiscoverySummaryAiProvider {
override val isAvailable: Boolean = true
override suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String? = sessionResult
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String? = presetResult
}
private class UnavailableAiProvider : DiscoverySummaryAiProvider {
override val isAvailable: Boolean = false
override suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String? = null
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String? = null
}
private class FailingAiProvider : DiscoverySummaryAiProvider {
override val isAvailable: Boolean = true
override suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String? = null // Simulates internal failure returning null
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String? = null
}

View File

@@ -88,7 +88,7 @@
- [X] **D034** [P] Bind `RuleBasedDiscoveryRecommendationEngine` as the always-available default.
- [X] **D035** [P] Implement Android Google-flavor Gemini Nano adapter and availability checks.
- [X] **D036** [P] Add opt-in UI and non-blocking fallback behavior.
- [ ] **D037** Add tests for supported / unsupported / failure cases.
- [X] **D037** Add tests for supported / unsupported / failure cases.
**Depends on**: D029-D031
**Exit criteria**: AI can enhance the summary on supported devices without blocking unsupported targets.
@@ -108,7 +108,7 @@
- [X] **D043** [P] Implement Android share / PDF export and Desktop save/export fallback.
- [ ] **D044** [P] Add accessibility polish: semantics, progress announcements, disabled-preset explanations, and large-screen layout checks.
- [ ] **D045** [P] Finalize 2.4 GHz hardware gating using `DeviceHardwareRepository` + current radio metadata.
- [X] **D045** [P] Finalize 2.4 GHz hardware gating using `DeviceHardwareRepository` + current radio metadata.
- [X] **D046** [P] Add logging / diagnostics and make sure the feature is debuggable through existing app logging tools.
- [ ] **D047** [P] Add strings, icons, and docs updates (`core/resources`, deep-link docs, quickstart references).
- [ ] **D048** Run targeted and full verification commands.