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.