mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
feat(discovery): add 2.4 GHz hardware gating and AI provider tests (D045, D037)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user