feat(discovery): Mesh Beacon client with iOS 014-mesh-beacons parity (#6097)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-05 19:15:56 -05:00
committed by GitHub
parent 005ad1a9d4
commit bf929c20f2
27 changed files with 1289 additions and 76 deletions

View File

@@ -206,7 +206,7 @@ class MeshDataHandlerImpl(
}
PortNum.MESH_BEACON_APP -> {
handleMeshBeacon(packet)
handleMeshBeacon(packet, myNodeNum)
}
else -> {}
@@ -224,12 +224,16 @@ class MeshDataHandlerImpl(
* low-priority notification when the invitation is first seen (not on every periodic re-broadcast). Only beacons
* carrying a join offer (a channel) are actionable; message-only beacons are ignored.
*/
private fun handleMeshBeacon(packet: MeshPacket) {
@Suppress("ReturnCount")
private fun handleMeshBeacon(packet: MeshPacket, myNodeNum: Int) {
// Ignore our own beacons (spec FR-001) — once broadcast is enabled a node that also listens would self-notify.
if (packet.from == myNodeNum) return
val payload = packet.decoded?.payload ?: return
val beacon = MeshBeacon.ADAPTER.decodeOrNull(payload, Logger)
// Only actionable beacons (carrying a channel offer) that we haven't already seen warrant a notification.
if (beacon?.offer_channel == null) return
val offer = MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon)
val offer =
MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon, snr = packet.rx_snr, rssi = packet.rx_rssi)
if (meshBeaconRepository.add(offer)) {
scope.launch {
notificationManager.dispatch(

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@@ -40,6 +41,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.MeshBeaconPrefs
import org.meshtastic.core.repository.MeshBeaconRepository
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
@@ -91,7 +93,6 @@ class MeshDataHandlerTest {
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
private val meshBeaconRepository = MeshBeaconRepository()
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@@ -101,6 +102,18 @@ class MeshDataHandlerTest {
// still drives it; cancelled in tearDown.
private val geofenceScope = CoroutineScope(testDispatcher)
// Real repository over an in-memory prefs fake — its persistence write-through is exercised without a DataStore.
private val fakeBeaconPrefs =
object : MeshBeaconPrefs {
private val flow = MutableStateFlow<List<String>>(emptyList())
override val storedBeacons: StateFlow<List<String>> = flow
override fun setStoredBeacons(records: List<String>) {
flow.value = records
}
}
private val meshBeaconRepository = MeshBeaconRepository(fakeBeaconPrefs, geofenceScope)
@AfterTest
fun tearDown() {
geofenceScope.cancel()
@@ -416,6 +429,23 @@ class MeshDataHandlerTest {
assertEquals(0, meshBeaconRepository.offers.value.size)
}
@Test
fun `our own mesh beacon is ignored`() {
// Spec FR-001: ignore beacons from the scanning node itself (else a listen+broadcast node self-notifies).
val beacon = MeshBeacon(message = "Join us", offer_channel = ChannelSettings(name = "PartyNet"))
val packet =
MeshPacket(
from = 123, // == myNodeNum below
decoded = Data(portnum = PortNum.MESH_BEACON_APP, payload = beacon.encode().toByteString()),
)
every { dataMapper.toDataPacket(packet) } returns
DataPacket(from = "!self", bytes = beacon.encode().toByteString(), dataType = PortNum.MESH_BEACON_APP.value)
handler.handleReceivedData(packet, 123)
assertEquals(0, meshBeaconRepository.offers.value.size)
}
// --- Store-and-Forward handling ---
@Test