diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/scan/Check24GhzCapability.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/scan/Check24GhzCapability.kt new file mode 100644 index 000000000..05fc9bb82 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/scan/Check24GhzCapability.kt @@ -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 . + */ +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") + } +} diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/Check24GhzCapabilityTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/Check24GhzCapabilityTest.kt new file mode 100644 index 000000000..792d2281b --- /dev/null +++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/Check24GhzCapabilityTest.kt @@ -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 . + */ +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(check.evaluate(hw)) + } + + @Test + fun evaluate_returns_supported_when_tag_contains_2_4ghz() { + val hw = baseHardware(tags = listOf("2.4ghz")) + assertIs(check.evaluate(hw)) + } + + @Test + fun evaluate_returns_supported_when_tag_contains_lora24() { + val hw = baseHardware(tags = listOf("lora24", "esp32")) + assertIs(check.evaluate(hw)) + } + + @Test + fun evaluate_returns_unsupported_when_tag_contains_sub_ghz_only() { + val hw = baseHardware(tags = listOf("sub-ghz-only")) + assertIs(check.evaluate(hw)) + } + + @Test + fun evaluate_returns_unsupported_when_tag_contains_sx1262() { + val hw = baseHardware(tags = listOf("sx1262")) + assertIs(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(check.evaluate(hw)) + } + + @Test + fun evaluate_returns_supported_when_slug_contains_2400() { + val hw = baseHardware(hwModelSlug = "rak-2400") + assertIs(check.evaluate(hw)) + } + + @Test + fun evaluate_returns_supported_when_target_contains_lora24() { + val hw = baseHardware(platformioTarget = "nano-g2-lora24") + assertIs(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(result) + } + + @Test + fun evaluate_returns_unknown_when_tags_are_null() { + val hw = baseHardware(tags = null) + val result = check.evaluate(hw) + assertIs(result) + } + + // --- Edge cases --- + + @Test + fun evaluate_tag_matching_is_case_insensitive() { + val hw = baseHardware(tags = listOf("SX1280", "BLE")) + assertIs(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(check.evaluate(hw)) + } + + private fun baseHardware( + platformioTarget: String = "generic-target", + hwModelSlug: String = "generic-slug", + tags: List? = null, + ) = DeviceHardware( + activelySupported = true, + architecture = "esp32", + displayName = "Test Device", + hwModel = 42, + hwModelSlug = hwModelSlug, + platformioTarget = platformioTarget, + tags = tags, + ) +} diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryAiProviderTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryAiProviderTest.kt new file mode 100644 index 000000000..0f7cc86cb --- /dev/null +++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryAiProviderTest.kt @@ -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 . + */ +@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, + ): 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, + ): 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, + ): String? = null // Simulates internal failure returning null + + override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String? = null +} diff --git a/specs/001-local-mesh-discovery/tasks.md b/specs/001-local-mesh-discovery/tasks.md index 419a26110..2cd07b5fe 100644 --- a/specs/001-local-mesh-discovery/tasks.md +++ b/specs/001-local-mesh-discovery/tasks.md @@ -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.