From f6c4963dd5a4cd67cca86a6aea0f57b645f2ecb7 Mon Sep 17 00:00:00 2001
From: Giorgos Karampitsakis <42468982+ComradeTurtle@users.noreply.github.com>
Date: Sat, 18 Apr 2026 03:08:42 +0300
Subject: [PATCH 1/7] Add Wi-Fi dump transfer mode and Android downloader
---
android/app/src/main/AndroidManifest.xml | 5 +
.../wifinder/android/ble/WiFinderBleClient.kt | 12 +
.../com/wifinder/android/model/WgProtocol.kt | 12 +
.../android/service/ScannerService.kt | 468 +++++++++-
main/CMakeLists.txt | 1 +
main/main.c | 881 +++++++++++++++++-
main/wg_protocol.c | 14 +
main/wg_protocol.h | 6 +
tests/test_wg_protocol.c | 30 +
9 files changed, 1416 insertions(+), 13 deletions(-)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c414cb2..43f3555 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,11 @@
+
+
+
+
+
diff --git a/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt b/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt
index 211fb62..63392a3 100644
--- a/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt
+++ b/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt
@@ -166,6 +166,18 @@ class WiFinderBleClient(
WgProtocol.encodeBacklogBlobChunkReplyPayload(sessionId, chunkOffset, chunkLen, accepted),
)
+ fun setWifiDumpEnabled(enabled: Boolean): Boolean =
+ sendCommand(
+ WgProtocol.Command.SET_WIFI_DUMP,
+ WgProtocol.encodeWifiDumpTogglePayload(enabled),
+ )
+
+ fun commitWifiDump(runId: Long): Boolean =
+ sendCommand(
+ WgProtocol.Command.COMMIT_WIFI_DUMP,
+ WgProtocol.encodeWifiDumpCommitPayload(runId),
+ )
+
fun seedDebugStorage(targetBytes: Int): Boolean =
sendCommand(
WgProtocol.Command.DEBUG_SEED_STORAGE,
diff --git a/android/app/src/main/java/com/wifinder/android/model/WgProtocol.kt b/android/app/src/main/java/com/wifinder/android/model/WgProtocol.kt
index 805f08a..665ab23 100644
--- a/android/app/src/main/java/com/wifinder/android/model/WgProtocol.kt
+++ b/android/app/src/main/java/com/wifinder/android/model/WgProtocol.kt
@@ -45,6 +45,8 @@ object WgProtocol {
const val DEBUG_SEED_STORAGE = 0x0E
const val SET_CHANNEL_PLAN = 0x0F
const val BACKLOG_BLOB_CHUNK_REPLY = 0x10
+ const val SET_WIFI_DUMP = 0x11
+ const val COMMIT_WIFI_DUMP = 0x12
}
const val GPS_FLAG_VALID = 1 shl 0
@@ -360,6 +362,16 @@ object WgProtocol {
fun encodeBacklogBlobTogglePayload(enabled: Boolean): ByteArray =
byteArrayOf(if (enabled) 1 else 0)
+ fun encodeWifiDumpTogglePayload(enabled: Boolean): ByteArray =
+ byteArrayOf(if (enabled) 1 else 0)
+
+ fun encodeWifiDumpCommitPayload(runId: Long): ByteArray {
+ val out = ByteArray(8)
+ val bb = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN)
+ bb.putLong(runId)
+ return out
+ }
+
fun encodeBacklogBlobChunkReplyPayload(
sessionId: Long,
chunkOffset: Long,
diff --git a/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt b/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
index bb2dc68..cf72ad5 100644
--- a/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
+++ b/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
@@ -7,6 +7,11 @@ import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.ServiceInfo
import android.location.Location
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.wifi.WifiNetworkSpecifier
import android.os.Binder
import android.os.Build
import android.os.IBinder
@@ -32,6 +37,8 @@ import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.text.SimpleDateFormat
@@ -41,19 +48,26 @@ import java.util.TreeSet
import java.util.zip.CRC32
import kotlin.math.abs
import kotlin.math.roundToInt
+import kotlin.coroutines.resume
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
+import org.json.JSONObject
/**
* Shared state exposed by the service to the UI layer via binding.
@@ -163,6 +177,14 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
private const val DEBUG_SEED_TARGET_BYTES = 768 * 1024
private const val DEBUG_SEED_MIN_BYTES = 512 * 1024
private const val DEBUG_SEED_MAX_BYTES = 1024 * 1024
+ private const val WIFI_DUMP_AP_SSID = "WIFINDER-DUMP"
+ private const val WIFI_DUMP_AP_HOST = "192.168.4.1"
+ private const val WIFI_DUMP_MANIFEST_PATH = "/wifinder/dump/manifest.json"
+ private const val WIFI_DUMP_FILE_PATH = "/wifinder/dump/file"
+ private const val WIFI_DUMP_HTTP_CONNECT_TIMEOUT_MS = 8_000
+ private const val WIFI_DUMP_HTTP_READ_TIMEOUT_MS = 20_000
+ private const val WIFI_DUMP_NETWORK_WAIT_MS = 25_000L
+ private const val WIFI_DUMP_PROGRESS_UI_INTERVAL_MS = 150L
internal fun normalizeGpsNavMode(mode: Int): Int = when (mode) {
0, 1, 2, 4 -> mode
@@ -212,6 +234,19 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
val badBlocks: Int,
)
+ private data class WifiDumpSession(
+ val sessionId: Long,
+ val datBytes: Long,
+ val gpxBytes: Long,
+ val writtenSeq: Long,
+ val ackedSeq: Long,
+ )
+
+ private data class WifiDumpManifest(
+ val runId: Long,
+ val sessions: List,
+ )
+
inner class LocalBinder : Binder() {
val service: ScannerService get() = this@ScannerService
}
@@ -260,6 +295,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
@Volatile private var backlogBlobRx: BacklogBlobReceiver? = null
@Volatile private var blobProgressUiLastMs: Long = 0L
@Volatile private var blobProgressUiLastBytes: Long = 0L
+ @Volatile private var wifiDumpInProgress: Boolean = false
+ @Volatile private var wifiDumpJob: Job? = null
override fun onCreate() {
super.onCreate()
@@ -334,7 +371,11 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
override fun onDestroy() {
super.onDestroy()
scope.cancel()
+ wifiDumpJob?.cancel()
+ wifiDumpJob = null
+ wifiDumpInProgress = false
try { bleClient.sendGpsClear() } catch (_: Exception) {}
+ try { bleClient.setWifiDumpEnabled(false) } catch (_: Exception) {}
stopLiveGpxSession(reason = null)
resetBacklogBlobReceiver(discardFile = true)
bleClient.disconnect()
@@ -386,10 +427,14 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
}
fun doDisconnect() {
+ wifiDumpJob?.cancel()
+ wifiDumpJob = null
+ wifiDumpInProgress = false
phoneGpsPushActive = false
stopLiveGpxSession(reason = null)
if (controlLinkReadySilent()) {
bleClient.setBacklogBlobEnabled(false)
+ bleClient.setWifiDumpEnabled(false)
}
val shouldAutoStopCsv = downloadBacklogAutoStop
downloadBacklogAutoStop = false
@@ -542,8 +587,12 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
}
}
if (_state.value.downloadBacklogActive) {
- val ok = bleClient.setBacklogBlobEnabled(false)
- if (!ok) {
+ wifiDumpJob?.cancel()
+ wifiDumpJob = null
+ wifiDumpInProgress = false
+ val okWifiDump = bleClient.setWifiDumpEnabled(false)
+ val okBlob = bleClient.setBacklogBlobEnabled(false)
+ if (!okWifiDump && !okBlob) {
appendLog("Backlog stop command failed")
return false
}
@@ -573,8 +622,11 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
synchronized(ackLock) {
sessionAcks.clear()
}
- val okBlob = bleClient.setBacklogBlobEnabled(true)
- if (!okBlob) {
+ if (!bleClient.setBacklogBlobEnabled(false)) {
+ appendLog("Backlog download warning: failed to disable legacy blob stream")
+ }
+ val okWifiDump = bleClient.setWifiDumpEnabled(true)
+ if (!okWifiDump) {
downloadBacklogStartMs = 0L
_state.update {
it.copy(
@@ -592,17 +644,23 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
downloadBacklogAutoStop = false
return false
}
+
downloadBacklogStartMs = System.currentTimeMillis()
+ wifiDumpInProgress = true
_state.update {
it.copy(
downloadBacklogActive = true,
- blobActive = false,
+ blobActive = true,
blobSessionId = 0L,
blobBytesSent = 0L,
blobBytesTotal = 0L,
)
}
- appendLog("Backlog download started")
+ appendLog("Backlog download started (Wi-Fi dump)")
+ wifiDumpJob?.cancel()
+ wifiDumpJob = scope.launch {
+ runWifiDumpTransfer()
+ }
bleClient.requestStatus()
return true
}
@@ -799,6 +857,9 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
override fun onConnectState(connected: Boolean) {
if (!connected) {
+ wifiDumpJob?.cancel()
+ wifiDumpJob = null
+ wifiDumpInProgress = false
lastEspGps = null
lastEspFixMs = -1L
stopLiveGpxSession(reason = null)
@@ -945,10 +1006,10 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
spiffsUsedBytes = status.spiffsUsedBytes,
spiffsFreeBytes = status.spiffsFreeBytes,
dieTempCenti = status.dieTempCenti,
- blobActive = status.blobActive,
- blobSessionId = status.blobSessionId,
- blobBytesSent = status.blobBytesSent,
- blobBytesTotal = status.blobBytesTotal,
+ blobActive = if (wifiDumpInProgress) current.blobActive else status.blobActive,
+ blobSessionId = if (wifiDumpInProgress) current.blobSessionId else status.blobSessionId,
+ blobBytesSent = if (wifiDumpInProgress) current.blobBytesSent else status.blobBytesSent,
+ blobBytesTotal = if (wifiDumpInProgress) current.blobBytesTotal else status.blobBytesTotal,
)
}
@@ -967,6 +1028,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
stopLogging()
}
} else if (_state.value.downloadBacklogActive &&
+ !wifiDumpInProgress &&
status.queuedRecords == 0L &&
!status.replayActive &&
!status.blobActive) {
@@ -1456,6 +1518,392 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
}
}
+ private suspend fun runWifiDumpTransfer() {
+ var success = false
+ var cancelled = false
+ var runId: Long = 0L
+ try {
+ val transferOk = withWifiDumpNetwork { network ->
+ val manifest = fetchWifiDumpManifest(network)
+ if (manifest == null) {
+ appendLog("Backlog download failed: Wi-Fi dump manifest unavailable")
+ return@withWifiDumpNetwork false
+ }
+ runId = manifest.runId
+ if (manifest.sessions.isEmpty()) {
+ appendLog("Backlog download: no pending sessions")
+ return@withWifiDumpNetwork true
+ }
+
+ val dumpDirName = "wifi-dump-${SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())}"
+ val dumpDir = File(getExternalFilesDir(null), dumpDirName)
+ dumpDir.mkdirs()
+
+ appendLog(
+ "Backlog Wi-Fi dump manifest run=0x${toHex64(runId)} sessions=${manifest.sessions.size}",
+ )
+
+ for ((index, session) in manifest.sessions.withIndex()) {
+ if (!currentCoroutineContext().isActive) {
+ cancelled = true
+ return@withWifiDumpNetwork false
+ }
+ val sidHex = toHex64(session.sessionId)
+ appendLog(
+ "Backlog Wi-Fi session ${index + 1}/${manifest.sessions.size} sid=0x$sidHex dat=${session.datBytes}B gpx=${session.gpxBytes}B",
+ )
+
+ val datFile = File(dumpDir, "session-$sidHex.dat")
+ _state.update {
+ it.copy(
+ blobActive = true,
+ blobSessionId = session.sessionId,
+ blobBytesSent = 0L,
+ blobBytesTotal = session.datBytes,
+ )
+ }
+
+ val datOk = downloadWifiDumpFile(
+ network = network,
+ sessionId = session.sessionId,
+ type = "dat",
+ expectedBytes = session.datBytes,
+ outFile = datFile,
+ ) { downloaded ->
+ _state.update { state ->
+ state.copy(
+ blobActive = true,
+ blobSessionId = session.sessionId,
+ blobBytesSent = downloaded.coerceAtLeast(0L),
+ blobBytesTotal = session.datBytes,
+ )
+ }
+ }
+ if (!datOk) {
+ appendLog("Backlog Wi-Fi session failed sid=0x$sidHex (dat download)")
+ return@withWifiDumpNetwork false
+ }
+
+ val result = parseBacklogBlobFile(datFile, session.sessionId)
+ val targetSeq = if (session.writtenSeq > 0L) session.writtenSeq else result.highestSeq
+ val importComplete =
+ targetSeq > 0L &&
+ result.badBlocks == 0 &&
+ !result.gapDetected &&
+ result.highestSeq >= targetSeq
+ appendLog(
+ "Backlog Wi-Fi import sid=0x$sidHex records=${result.records} highest_seq=${result.highestSeq} bad_blocks=${result.badBlocks} gap=${result.gapDetected}",
+ )
+ if (!importComplete) {
+ appendLog("Backlog Wi-Fi import failed integrity check sid=0x$sidHex")
+ return@withWifiDumpNetwork false
+ }
+ try {
+ datFile.delete()
+ } catch (_: Exception) {}
+
+ if (session.gpxBytes > 0L) {
+ val gpxFile = File(dumpDir, "session-$sidHex.gpx")
+ val gpxOk = downloadWifiDumpFile(
+ network = network,
+ sessionId = session.sessionId,
+ type = "gpx",
+ expectedBytes = session.gpxBytes,
+ outFile = gpxFile,
+ )
+ if (!gpxOk) {
+ appendLog("Backlog Wi-Fi session sid=0x$sidHex gpx download failed")
+ return@withWifiDumpNetwork false
+ }
+ try {
+ gpxFile.delete()
+ } catch (_: Exception) {}
+ }
+ }
+ true
+ } ?: false
+ if (!transferOk) {
+ return
+ }
+
+ if (!bleClient.commitWifiDump(runId)) {
+ appendLog("Backlog commit failed (run=0x${toHex64(runId)})")
+ return
+ }
+ bleClient.requestStatus()
+ success = true
+ appendLog("Backlog Wi-Fi download complete")
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ cancelled = true
+ } catch (e: Exception) {
+ appendLog("Backlog Wi-Fi transfer error: ${e.message}")
+ } finally {
+ wifiDumpInProgress = false
+ wifiDumpJob = null
+ downloadBacklogStartMs = 0L
+ resetBacklogBlobReceiver(discardFile = true)
+ if (controlLinkReadySilent()) {
+ bleClient.setWifiDumpEnabled(false)
+ bleClient.setBacklogBlobEnabled(false)
+ bleClient.requestStatus()
+ }
+ _state.update {
+ it.copy(
+ downloadBacklogActive = false,
+ blobActive = false,
+ blobSessionId = 0L,
+ blobBytesSent = 0L,
+ blobBytesTotal = 0L,
+ )
+ }
+ val shouldAutoStopCsv = downloadBacklogAutoStop
+ downloadBacklogAutoStop = false
+ if (shouldAutoStopCsv && csvLogger.isActive) {
+ stopLogging()
+ }
+ if (!success && !cancelled) {
+ appendLog(
+ if (runId != 0L) {
+ "Backlog Wi-Fi download ended with errors (run=0x${toHex64(runId)})"
+ } else {
+ "Backlog Wi-Fi download ended with errors"
+ },
+ )
+ } else if (cancelled) {
+ appendLog("Backlog download stopped")
+ }
+ }
+ }
+
+ private fun toHex64(value: Long): String =
+ java.lang.Long.toUnsignedString(value, 16).uppercase(Locale.US).padStart(16, '0')
+
+ @SuppressLint("MissingPermission")
+ private suspend fun withWifiDumpNetwork(block: suspend (Network) -> T?): T? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ appendLog("Backlog Wi-Fi dump requires Android 10+")
+ return null
+ }
+ val cm = getSystemService(ConnectivityManager::class.java) ?: return null
+ val request = NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .setNetworkSpecifier(
+ WifiNetworkSpecifier.Builder()
+ .setSsid(WIFI_DUMP_AP_SSID)
+ .build(),
+ )
+ .build()
+
+ val acquired = withTimeoutOrNull(WIFI_DUMP_NETWORK_WAIT_MS) {
+ suspendCancellableCoroutine?> { cont ->
+ var completed = false
+ lateinit var callback: ConnectivityManager.NetworkCallback
+ fun finish(value: Pair?) {
+ if (completed) return
+ completed = true
+ cont.resume(value)
+ }
+ callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ finish(network to this)
+ }
+
+ override fun onUnavailable() {
+ try {
+ cm.unregisterNetworkCallback(this)
+ } catch (_: Exception) {}
+ finish(null)
+ }
+
+ override fun onLost(network: Network) {
+ if (!completed) {
+ try {
+ cm.unregisterNetworkCallback(this)
+ } catch (_: Exception) {}
+ finish(null)
+ }
+ }
+ }
+ try {
+ cm.requestNetwork(request, callback)
+ } catch (_: Exception) {
+ finish(null)
+ return@suspendCancellableCoroutine
+ }
+ cont.invokeOnCancellation {
+ try {
+ cm.unregisterNetworkCallback(callback)
+ } catch (_: Exception) {}
+ }
+ }
+ } ?: return null
+
+ val network = acquired.first
+ val callback = acquired.second
+ val previous = cm.boundNetworkForProcess
+ val bound = try {
+ cm.bindProcessToNetwork(network)
+ } catch (_: Exception) {
+ false
+ }
+ if (!bound) {
+ try {
+ cm.unregisterNetworkCallback(callback)
+ } catch (_: Exception) {}
+ return null
+ }
+
+ return try {
+ block(network)
+ } finally {
+ try {
+ cm.bindProcessToNetwork(previous)
+ } catch (_: Exception) {}
+ try {
+ cm.unregisterNetworkCallback(callback)
+ } catch (_: Exception) {}
+ }
+ }
+
+ private suspend fun fetchWifiDumpManifest(network: Network): WifiDumpManifest? =
+ withContext(Dispatchers.IO) {
+ val url = URL("http://$WIFI_DUMP_AP_HOST$WIFI_DUMP_MANIFEST_PATH")
+ val conn = (network.openConnection(url) as? HttpURLConnection) ?: return@withContext null
+ try {
+ conn.requestMethod = "GET"
+ conn.connectTimeout = WIFI_DUMP_HTTP_CONNECT_TIMEOUT_MS
+ conn.readTimeout = WIFI_DUMP_HTTP_READ_TIMEOUT_MS
+ conn.setRequestProperty("Connection", "close")
+ val code = conn.responseCode
+ if (code != HttpURLConnection.HTTP_OK) {
+ return@withContext null
+ }
+ val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
+ val root = JSONObject(body)
+ val runId = root.optString("run_id").trim()
+ if (runId.isEmpty()) {
+ return@withContext null
+ }
+ val runValue = try {
+ runId.toULong(16).toLong()
+ } catch (_: Exception) {
+ return@withContext null
+ }
+ val sessionsJson = root.optJSONArray("sessions") ?: return@withContext WifiDumpManifest(
+ runId = runValue,
+ sessions = emptyList(),
+ )
+ val sessions = mutableListOf()
+ for (i in 0 until sessionsJson.length()) {
+ val item = sessionsJson.optJSONObject(i) ?: continue
+ val sidHex = item.optString("sid").trim()
+ if (sidHex.isEmpty()) {
+ continue
+ }
+ val sid = try {
+ sidHex.toULong(16).toLong()
+ } catch (_: Exception) {
+ continue
+ }
+ val datBytes = item.optLong("dat_bytes", 0L).coerceAtLeast(0L)
+ if (datBytes <= 0L) {
+ continue
+ }
+ sessions += WifiDumpSession(
+ sessionId = sid,
+ datBytes = datBytes,
+ gpxBytes = item.optLong("gpx_bytes", 0L).coerceAtLeast(0L),
+ writtenSeq = item.optLong("written_seq", 0L).coerceAtLeast(0L),
+ ackedSeq = item.optLong("acked_seq", 0L).coerceAtLeast(0L),
+ )
+ }
+ WifiDumpManifest(runId = runValue, sessions = sessions)
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ private suspend fun downloadWifiDumpFile(
+ network: Network,
+ sessionId: Long,
+ type: String,
+ expectedBytes: Long,
+ outFile: File,
+ onProgress: ((Long) -> Unit)? = null,
+ ): Boolean = withContext(Dispatchers.IO) {
+ if (expectedBytes <= 0L) {
+ return@withContext false
+ }
+ outFile.parentFile?.mkdirs()
+ val tmpFile = File(outFile.parentFile, "${outFile.name}.part")
+ if (tmpFile.exists()) {
+ tmpFile.delete()
+ }
+
+ val sidHex = toHex64(sessionId)
+ val url = URL("http://$WIFI_DUMP_AP_HOST$WIFI_DUMP_FILE_PATH?sid=$sidHex&type=$type")
+ val conn = (network.openConnection(url) as? HttpURLConnection) ?: return@withContext false
+ try {
+ conn.requestMethod = "GET"
+ conn.connectTimeout = WIFI_DUMP_HTTP_CONNECT_TIMEOUT_MS
+ conn.readTimeout = WIFI_DUMP_HTTP_READ_TIMEOUT_MS
+ conn.setRequestProperty("Connection", "close")
+ val code = conn.responseCode
+ if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) {
+ return@withContext false
+ }
+ val input = conn.inputStream ?: return@withContext false
+ var written = 0L
+ var lastUiMs = 0L
+ input.use { stream ->
+ BufferedInputStream(stream, 64 * 1024).use { bin ->
+ FileOutputStream(tmpFile, false).use { fout ->
+ BufferedOutputStream(fout, 64 * 1024).use { bout ->
+ val buffer = ByteArray(64 * 1024)
+ while (true) {
+ val read = bin.read(buffer)
+ if (read < 0) {
+ break
+ }
+ if (read == 0) {
+ continue
+ }
+ bout.write(buffer, 0, read)
+ written += read.toLong()
+ if (onProgress != null) {
+ val now = System.currentTimeMillis()
+ if (written == expectedBytes ||
+ lastUiMs == 0L ||
+ now - lastUiMs >= WIFI_DUMP_PROGRESS_UI_INTERVAL_MS) {
+ lastUiMs = now
+ onProgress(written)
+ }
+ }
+ }
+ bout.flush()
+ }
+ }
+ }
+ }
+ if (written != expectedBytes) {
+ tmpFile.delete()
+ return@withContext false
+ }
+ if (outFile.exists()) {
+ outFile.delete()
+ }
+ if (!tmpFile.renameTo(outFile)) {
+ return@withContext false
+ }
+ true
+ } catch (_: Exception) {
+ tmpFile.delete()
+ false
+ } finally {
+ conn.disconnect()
+ }
+ }
+
private fun parseBacklogBlobFile(file: File, expectedSessionId: Long): BacklogImportResult {
if (!file.exists()) {
return BacklogImportResult(records = 0, highestSeq = 0L, gapDetected = false, badBlocks = 1)
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 0c95991..75c841c 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -16,6 +16,7 @@ idf_component_register(
esp_event
esp_netif
esp_timer
+ esp_http_server
spiffs
fatfs
sdmmc
diff --git a/main/main.c b/main/main.c
index e114a0e..9f6ed78 100644
--- a/main/main.c
+++ b/main/main.c
@@ -31,6 +31,7 @@
#include "esp_netif.h"
#include "esp_random.h"
#include "esp_vfs_fat.h"
+#include "esp_http_server.h"
#include "esp_spiffs.h"
#include "esp_system.h"
#include "esp_timer.h"
@@ -174,6 +175,18 @@ _Static_assert(WG_NODE_5GHZ_MASK_BIT_COUNT < 64, "WG_NODE_5GHZ_MASK_BIT_COUNT mu
#define WG_SD_SPI_MOSI_GPIO GPIO_NUM_22
#define WG_SD_SPI_MISO_GPIO GPIO_NUM_23
+#define WG_WIFI_DUMP_AP_SSID "WIFINDER-DUMP"
+#define WG_WIFI_DUMP_AP_CHANNEL 6
+#define WG_WIFI_DUMP_HTTP_PORT 80
+#define WG_WIFI_DUMP_MANIFEST_URI "/wifinder/dump/manifest.json"
+#define WG_WIFI_DUMP_FILE_URI "/wifinder/dump/file"
+#define WG_WIFI_DUMP_COMMIT_URI "/wifinder/dump/commit"
+#define WG_WIFI_DUMP_ABORT_URI "/wifinder/dump/abort"
+#define WG_WIFI_DUMP_JOURNAL_FILE "wq_dump_journal.bin"
+#define WG_WIFI_DUMP_JOURNAL_MAGIC 0x314A4457U
+#define WG_WIFI_DUMP_JOURNAL_VERSION 1
+#define WG_WIFI_DUMP_HTTP_CHUNK 2048
+
#define WG_NODE_UART_NUM LP_UART_NUM_0
#define WG_NODE_UART_TX_GPIO 5
#define WG_NODE_UART_RX_GPIO 4
@@ -352,6 +365,30 @@ typedef struct __attribute__((packed)) {
uint32_t crc32;
} wg_session_manifest_t;
+typedef struct __attribute__((packed)) {
+ uint32_t magic;
+ uint16_t version;
+ uint16_t header_size;
+ uint64_t run_id;
+ uint8_t committed;
+ uint8_t reserved[7];
+ uint32_t session_count;
+ uint32_t crc32;
+} wg_dump_journal_header_t;
+
+typedef struct __attribute__((packed)) {
+ uint64_t session_id;
+ uint32_t ack_seq;
+} wg_dump_journal_entry_t;
+
+typedef struct {
+ uint64_t session_id;
+ uint32_t data_bytes;
+ uint32_t gpx_bytes;
+ uint32_t written_seq;
+ uint32_t acked_seq;
+} wg_dump_session_t;
+
static const char *TAG = "wifinder";
static ap_entry_t s_seen[WG_SEEN_CAPACITY];
@@ -575,6 +612,16 @@ static int64_t s_gpx_last_point_ms = 0;
static int64_t s_gpx_last_flush_ms = 0;
static uint32_t s_gpx_points_written = 0;
+static httpd_handle_t s_wifi_dump_httpd = NULL;
+static bool s_wifi_dump_requested = false;
+static bool s_wifi_dump_active = false;
+static bool s_wifi_dump_ap_running = false;
+static uint64_t s_wifi_dump_run_id = 0;
+static int64_t s_wifi_dump_started_ms = 0;
+static wg_dump_session_t *s_wifi_dump_sessions = NULL;
+static size_t s_wifi_dump_session_count = 0;
+static size_t s_wifi_dump_session_capacity = 0;
+
#if CONFIG_WG_RGB_LED_ENABLE
typedef struct {
uint8_t r;
@@ -678,6 +725,7 @@ static bool storage_has_output_link(void);
static const char *storage_base_path(void);
static bool storage_get_fs_info(uint64_t *total_bytes_out, uint64_t *used_bytes_out);
static bool storage_commit_file(FILE *fp, const char *kind, uint64_t session_id);
+static bool storage_parse_meta_filename(const char *name, uint64_t *session_id_out);
static bool storage_open_session(void);
static bool storage_open_session_locked(void);
static void storage_close_session(wg_session_state_t final_state);
@@ -697,6 +745,14 @@ static bool storage_seed_synthetic(uint32_t target_bytes, uint32_t *records_adde
uint32_t *bytes_added_out);
static bool storage_clear_sessions(void);
static bool storage_patch_sighting_source(uint8_t *payload, uint16_t payload_len, uint8_t source_flags);
+static void storage_refresh_backlog_locked(bool reclaim);
+static bool storage_read_manifest(uint64_t session_id, wg_session_manifest_t *out);
+static bool storage_flush_pending_locked(bool force);
+static void storage_reset_replay_locked(void);
+static void storage_reset_blob_locked(void);
+static bool storage_set_wifi_dump_enabled(bool enabled);
+static bool storage_commit_wifi_dump_run(uint64_t run_id);
+static void storage_recover_wifi_dump_journal(void);
static uint16_t ble_notify_frame_payload_limit(void);
static void wake_replay_task(void);
static void request_fast_ble_conn_params(uint16_t conn_handle);
@@ -713,6 +769,22 @@ static void wr_u32_le(uint8_t *out, uint32_t value);
static void wr_u64_le(uint8_t *out, uint64_t value);
static void scan_state_lock(void);
static void scan_state_unlock(void);
+static void storage_dump_reset_sessions_locked(void);
+static bool storage_dump_ensure_capacity_locked(size_t needed);
+static bool storage_dump_collect_sessions_locked(void);
+static bool storage_dump_journal_write_locked(bool committed);
+static bool storage_dump_journal_load_locked(wg_dump_journal_header_t *header_out,
+ wg_dump_journal_entry_t **entries_out);
+static bool storage_dump_journal_delete_locked(void);
+static bool storage_dump_start_locked(void);
+static void storage_dump_stop_locked(bool keep_journal);
+static bool storage_dump_commit_locked(uint64_t run_id);
+static bool wifi_dump_http_start_locked(void);
+static void wifi_dump_http_stop_locked(void);
+static bool wifi_dump_ap_start_locked(void);
+static void wifi_dump_ap_stop_locked(void);
+static int wifi_dump_manifest_handler(httpd_req_t *req);
+static int wifi_dump_file_handler(httpd_req_t *req);
static const struct ble_gatt_svc_def gatt_services[] = {
{
@@ -1469,6 +1541,128 @@ static void storage_gpx_paths(uint64_t session_id, char *gpx_path, size_t gpx_si
}
}
+static void storage_dump_journal_path(char *path_out, size_t path_size) {
+ if (path_out == NULL || path_size == 0) {
+ return;
+ }
+ snprintf(path_out, path_size, "%s/%s", storage_base_path(), WG_WIFI_DUMP_JOURNAL_FILE);
+}
+
+static void storage_dump_reset_sessions_locked(void) {
+ if (s_wifi_dump_sessions != NULL) {
+ free(s_wifi_dump_sessions);
+ }
+ s_wifi_dump_sessions = NULL;
+ s_wifi_dump_session_count = 0;
+ s_wifi_dump_session_capacity = 0;
+}
+
+static bool storage_dump_ensure_capacity_locked(size_t needed) {
+ if (needed <= s_wifi_dump_session_capacity) {
+ return true;
+ }
+ size_t next_cap = s_wifi_dump_session_capacity > 0 ? s_wifi_dump_session_capacity : 8;
+ while (next_cap < needed) {
+ if (next_cap > (SIZE_MAX / 2U)) {
+ next_cap = needed;
+ break;
+ }
+ next_cap *= 2U;
+ }
+ wg_dump_session_t *grown = (wg_dump_session_t *)realloc(
+ s_wifi_dump_sessions, next_cap * sizeof(wg_dump_session_t));
+ if (grown == NULL) {
+ return false;
+ }
+ s_wifi_dump_sessions = grown;
+ s_wifi_dump_session_capacity = next_cap;
+ return true;
+}
+
+static int storage_dump_session_cmp(const void *a, const void *b) {
+ const wg_dump_session_t *lhs = (const wg_dump_session_t *)a;
+ const wg_dump_session_t *rhs = (const wg_dump_session_t *)b;
+ if (lhs->session_id < rhs->session_id) {
+ return -1;
+ }
+ if (lhs->session_id > rhs->session_id) {
+ return 1;
+ }
+ return 0;
+}
+
+static bool storage_dump_collect_sessions_locked(void) {
+ storage_dump_reset_sessions_locked();
+
+ DIR *dir = opendir(storage_base_path());
+ if (dir == NULL) {
+ return false;
+ }
+
+ struct dirent *ent = NULL;
+ while ((ent = readdir(dir)) != NULL) {
+ uint64_t sid = 0;
+ if (!storage_parse_meta_filename(ent->d_name, &sid)) {
+ continue;
+ }
+
+ wg_session_manifest_t manifest = {0};
+ if (!storage_read_manifest(sid, &manifest)) {
+ continue;
+ }
+ if (manifest.state == WG_SESSION_STATE_OPEN || manifest.records_written == 0 ||
+ manifest.records_acked >= manifest.records_written ||
+ manifest.last_seq_written == 0) {
+ continue;
+ }
+
+ if (!storage_dump_ensure_capacity_locked(s_wifi_dump_session_count + 1U)) {
+ closedir(dir);
+ return false;
+ }
+
+ char data_path[96] = {0};
+ char gpx_path[WG_STORAGE_PATH_BUF_SIZE] = {0};
+ storage_session_paths(sid, NULL, 0, data_path, sizeof(data_path));
+ storage_gpx_paths(sid, gpx_path, sizeof(gpx_path), NULL, 0);
+ struct stat st_data = {0};
+ struct stat st_gpx = {0};
+ uint32_t data_bytes = 0;
+ uint32_t gpx_bytes = 0;
+ if (stat(data_path, &st_data) == 0 && st_data.st_size > 0) {
+ if ((uint64_t)st_data.st_size > UINT32_MAX) {
+ data_bytes = UINT32_MAX;
+ } else {
+ data_bytes = (uint32_t)st_data.st_size;
+ }
+ }
+ if (stat(gpx_path, &st_gpx) == 0 && st_gpx.st_size > 0) {
+ if ((uint64_t)st_gpx.st_size > UINT32_MAX) {
+ gpx_bytes = UINT32_MAX;
+ } else {
+ gpx_bytes = (uint32_t)st_gpx.st_size;
+ }
+ }
+ if (data_bytes == 0) {
+ continue;
+ }
+
+ wg_dump_session_t *slot = &s_wifi_dump_sessions[s_wifi_dump_session_count++];
+ slot->session_id = sid;
+ slot->data_bytes = data_bytes;
+ slot->gpx_bytes = gpx_bytes;
+ slot->written_seq = manifest.last_seq_written;
+ slot->acked_seq = manifest.last_seq_acked;
+ }
+ closedir(dir);
+
+ if (s_wifi_dump_session_count > 1U) {
+ qsort(s_wifi_dump_sessions, s_wifi_dump_session_count, sizeof(wg_dump_session_t),
+ storage_dump_session_cmp);
+ }
+ return true;
+}
+
static double gpx_distance_meters_e7(int32_t lat1_e7, int32_t lon1_e7, int32_t lat2_e7, int32_t lon2_e7) {
static const double k_earth_radius_m = 6371000.0;
const double lat1 = ((double)lat1_e7 / 10000000.0) * (M_PI / 180.0);
@@ -1875,6 +2069,649 @@ static bool storage_write_manifest(const wg_session_manifest_t *manifest_in) {
return storage_write_manifest_path(fallback_path, &manifest);
}
+static bool storage_dump_journal_write_locked(bool committed) {
+ char journal_path[WG_STORAGE_PATH_BUF_SIZE] = {0};
+ storage_dump_journal_path(journal_path, sizeof(journal_path));
+ FILE *fp = fopen(journal_path, "wb");
+ if (fp == NULL) {
+ ESP_LOGW(TAG, "wifi dump journal open failed path=%s errno=%d", journal_path, errno);
+ return false;
+ }
+
+ const uint32_t session_count =
+ (s_wifi_dump_session_count > UINT32_MAX) ? UINT32_MAX : (uint32_t)s_wifi_dump_session_count;
+ const size_t entries_size = (size_t)session_count * sizeof(wg_dump_journal_entry_t);
+ uint8_t *entries_buf = NULL;
+ if (entries_size > 0) {
+ entries_buf = (uint8_t *)calloc(1, entries_size);
+ if (entries_buf == NULL) {
+ fclose(fp);
+ return false;
+ }
+ for (uint32_t i = 0; i < session_count; ++i) {
+ wg_dump_journal_entry_t *entry = (wg_dump_journal_entry_t *)(entries_buf + (i * sizeof(*entry)));
+ entry->session_id = s_wifi_dump_sessions[i].session_id;
+ entry->ack_seq = s_wifi_dump_sessions[i].written_seq;
+ }
+ }
+
+ wg_dump_journal_header_t header = {
+ .magic = WG_WIFI_DUMP_JOURNAL_MAGIC,
+ .version = WG_WIFI_DUMP_JOURNAL_VERSION,
+ .header_size = sizeof(wg_dump_journal_header_t),
+ .run_id = s_wifi_dump_run_id,
+ .committed = committed ? 1 : 0,
+ .reserved = {0},
+ .session_count = session_count,
+ .crc32 = entries_size > 0 ? crc32_ieee(entries_buf, entries_size) : 0,
+ };
+
+ bool ok = (fwrite(&header, 1, sizeof(header), fp) == sizeof(header));
+ if (ok && entries_size > 0) {
+ ok = (fwrite(entries_buf, 1, entries_size, fp) == entries_size);
+ }
+ if (entries_buf != NULL) {
+ free(entries_buf);
+ }
+ if (ok) {
+ ok = storage_commit_file(fp, "wifi dump journal", s_wifi_dump_run_id);
+ }
+ fclose(fp);
+ if (!ok) {
+ ESP_LOGW(TAG, "wifi dump journal write failed run=%016llX errno=%d",
+ (unsigned long long)s_wifi_dump_run_id, errno);
+ }
+ return ok;
+}
+
+static bool storage_dump_journal_load_locked(wg_dump_journal_header_t *header_out,
+ wg_dump_journal_entry_t **entries_out) {
+ if (header_out == NULL || entries_out == NULL) {
+ return false;
+ }
+ *entries_out = NULL;
+ memset(header_out, 0, sizeof(*header_out));
+
+ char journal_path[WG_STORAGE_PATH_BUF_SIZE] = {0};
+ storage_dump_journal_path(journal_path, sizeof(journal_path));
+ FILE *fp = fopen(journal_path, "rb");
+ if (fp == NULL) {
+ return false;
+ }
+
+ wg_dump_journal_header_t header = {0};
+ bool ok = (fread(&header, 1, sizeof(header), fp) == sizeof(header));
+ if (!ok || header.magic != WG_WIFI_DUMP_JOURNAL_MAGIC ||
+ header.version != WG_WIFI_DUMP_JOURNAL_VERSION ||
+ header.header_size != sizeof(wg_dump_journal_header_t)) {
+ fclose(fp);
+ return false;
+ }
+
+ const size_t entries_size = (size_t)header.session_count * sizeof(wg_dump_journal_entry_t);
+ wg_dump_journal_entry_t *entries = NULL;
+ if (entries_size > 0) {
+ entries = (wg_dump_journal_entry_t *)calloc(1, entries_size);
+ if (entries == NULL) {
+ fclose(fp);
+ return false;
+ }
+ ok = (fread(entries, 1, entries_size, fp) == entries_size);
+ if (!ok) {
+ free(entries);
+ fclose(fp);
+ return false;
+ }
+ const uint32_t crc = crc32_ieee((const uint8_t *)entries, entries_size);
+ if (crc != header.crc32) {
+ free(entries);
+ fclose(fp);
+ return false;
+ }
+ } else if (header.crc32 != 0) {
+ fclose(fp);
+ return false;
+ }
+
+ fclose(fp);
+ *header_out = header;
+ *entries_out = entries;
+ return true;
+}
+
+static bool storage_dump_journal_delete_locked(void) {
+ char journal_path[WG_STORAGE_PATH_BUF_SIZE] = {0};
+ storage_dump_journal_path(journal_path, sizeof(journal_path));
+ if (unlink(journal_path) == 0 || errno == ENOENT) {
+ return true;
+ }
+ ESP_LOGW(TAG, "wifi dump journal delete failed path=%s errno=%d", journal_path, errno);
+ return false;
+}
+
+static const wg_dump_session_t *storage_dump_find_session_locked(uint64_t sid) {
+ if (sid == 0 || s_wifi_dump_sessions == NULL) {
+ return NULL;
+ }
+ for (size_t i = 0; i < s_wifi_dump_session_count; ++i) {
+ if (s_wifi_dump_sessions[i].session_id == sid) {
+ return &s_wifi_dump_sessions[i];
+ }
+ }
+ return NULL;
+}
+
+static bool wifi_dump_parse_query_value(httpd_req_t *req, const char *key, char *value_out,
+ size_t value_size) {
+ if (req == NULL || key == NULL || value_out == NULL || value_size == 0) {
+ return false;
+ }
+ int query_len = httpd_req_get_url_query_len(req);
+ if (query_len <= 0 || query_len >= 192) {
+ return false;
+ }
+ char query[192] = {0};
+ if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) {
+ return false;
+ }
+ return httpd_query_key_value(query, key, value_out, value_size) == ESP_OK;
+}
+
+static int wifi_dump_manifest_handler(httpd_req_t *req) {
+ if (req == NULL) {
+ return ESP_FAIL;
+ }
+ if (!storage_lock(pdMS_TO_TICKS(200))) {
+ httpd_resp_set_status(req, "503 Service Unavailable");
+ httpd_resp_sendstr(req, "storage lock failed");
+ return ESP_FAIL;
+ }
+ if (!s_wifi_dump_active) {
+ storage_unlock();
+ httpd_resp_set_status(req, "503 Service Unavailable");
+ httpd_resp_sendstr(req, "wifi dump not active");
+ return ESP_FAIL;
+ }
+
+ uint64_t run_id = s_wifi_dump_run_id;
+ size_t count = s_wifi_dump_session_count;
+ httpd_resp_set_type(req, "application/json");
+ httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+
+ char line[256] = {0};
+ snprintf(line, sizeof(line),
+ "{\"run_id\":\"%016llX\",\"ap_ssid\":\"%s\",\"session_count\":%lu,\"sessions\":[",
+ (unsigned long long)run_id, WG_WIFI_DUMP_AP_SSID, (unsigned long)count);
+ esp_err_t rc = httpd_resp_sendstr_chunk(req, line);
+ if (rc != ESP_OK) {
+ storage_unlock();
+ return rc;
+ }
+
+ for (size_t i = 0; i < count; ++i) {
+ const wg_dump_session_t *s = &s_wifi_dump_sessions[i];
+ snprintf(line, sizeof(line),
+ "%s{\"sid\":\"%016llX\",\"dat_bytes\":%lu,\"gpx_bytes\":%lu,\"written_seq\":%lu,\"acked_seq\":%lu}",
+ (i == 0U) ? "" : ",", (unsigned long long)s->session_id,
+ (unsigned long)s->data_bytes, (unsigned long)s->gpx_bytes,
+ (unsigned long)s->written_seq, (unsigned long)s->acked_seq);
+ rc = httpd_resp_sendstr_chunk(req, line);
+ if (rc != ESP_OK) {
+ storage_unlock();
+ return rc;
+ }
+ }
+ storage_unlock();
+
+ if (httpd_resp_sendstr_chunk(req, "]}") != ESP_OK) {
+ return ESP_FAIL;
+ }
+ return httpd_resp_send_chunk(req, NULL, 0);
+}
+
+static int wifi_dump_file_handler(httpd_req_t *req) {
+ if (req == NULL) {
+ return ESP_FAIL;
+ }
+ char sid_hex[24] = {0};
+ char type[8] = {0};
+ if (!wifi_dump_parse_query_value(req, "sid", sid_hex, sizeof(sid_hex)) ||
+ !wifi_dump_parse_query_value(req, "type", type, sizeof(type))) {
+ httpd_resp_set_status(req, "400 Bad Request");
+ httpd_resp_sendstr(req, "missing sid/type");
+ return ESP_FAIL;
+ }
+
+ errno = 0;
+ char *sid_end = NULL;
+ uint64_t sid = strtoull(sid_hex, &sid_end, 16);
+ if (errno != 0 || sid_end == sid_hex || (sid_end != NULL && *sid_end != '\0')) {
+ httpd_resp_set_status(req, "400 Bad Request");
+ httpd_resp_sendstr(req, "bad sid");
+ return ESP_FAIL;
+ }
+
+ bool want_dat = strcmp(type, "dat") == 0;
+ bool want_gpx = strcmp(type, "gpx") == 0;
+ if (!want_dat && !want_gpx) {
+ httpd_resp_set_status(req, "400 Bad Request");
+ httpd_resp_sendstr(req, "bad type");
+ return ESP_FAIL;
+ }
+
+ char path[WG_STORAGE_PATH_BUF_SIZE] = {0};
+ uint32_t expected_bytes = 0;
+ if (!storage_lock(pdMS_TO_TICKS(200))) {
+ httpd_resp_set_status(req, "503 Service Unavailable");
+ httpd_resp_sendstr(req, "storage lock failed");
+ return ESP_FAIL;
+ }
+ if (!s_wifi_dump_active) {
+ storage_unlock();
+ httpd_resp_set_status(req, "503 Service Unavailable");
+ httpd_resp_sendstr(req, "wifi dump not active");
+ return ESP_FAIL;
+ }
+ const wg_dump_session_t *session = storage_dump_find_session_locked(sid);
+ if (session == NULL) {
+ storage_unlock();
+ httpd_resp_set_status(req, "404 Not Found");
+ httpd_resp_sendstr(req, "unknown session");
+ return ESP_FAIL;
+ }
+ if (want_dat) {
+ storage_session_paths(sid, NULL, 0, path, sizeof(path));
+ expected_bytes = session->data_bytes;
+ } else {
+ storage_gpx_paths(sid, path, sizeof(path), NULL, 0);
+ expected_bytes = session->gpx_bytes;
+ }
+ storage_unlock();
+
+ if (expected_bytes == 0) {
+ httpd_resp_set_status(req, "404 Not Found");
+ httpd_resp_sendstr(req, "file absent");
+ return ESP_FAIL;
+ }
+
+ struct stat st = {0};
+ if (stat(path, &st) != 0 || st.st_size <= 0) {
+ httpd_resp_set_status(req, "404 Not Found");
+ httpd_resp_sendstr(req, "file missing");
+ return ESP_FAIL;
+ }
+ uint32_t file_size = ((uint64_t)st.st_size > UINT32_MAX) ? UINT32_MAX : (uint32_t)st.st_size;
+ if (file_size == 0) {
+ httpd_resp_set_status(req, "404 Not Found");
+ httpd_resp_sendstr(req, "empty file");
+ return ESP_FAIL;
+ }
+
+ uint32_t start = 0;
+ bool partial = false;
+ int range_len = httpd_req_get_hdr_value_len(req, "Range");
+ if (range_len > 0 && range_len < 64) {
+ char range[64] = {0};
+ if (httpd_req_get_hdr_value_str(req, "Range", range, sizeof(range)) == ESP_OK &&
+ strncmp(range, "bytes=", 6) == 0) {
+ errno = 0;
+ char *end = NULL;
+ unsigned long parsed = strtoul(range + 6, &end, 10);
+ if (errno == 0 && end != (range + 6)) {
+ if (parsed > UINT32_MAX) {
+ start = UINT32_MAX;
+ } else {
+ start = (uint32_t)parsed;
+ }
+ partial = true;
+ }
+ }
+ }
+ if (start >= file_size) {
+ httpd_resp_set_status(req, "416 Range Not Satisfiable");
+ httpd_resp_sendstr(req, "range out of bounds");
+ return ESP_FAIL;
+ }
+
+ FILE *fp = fopen(path, "rb");
+ if (fp == NULL) {
+ httpd_resp_set_status(req, "500 Internal Server Error");
+ httpd_resp_sendstr(req, "open failed");
+ return ESP_FAIL;
+ }
+ if (start > 0 && fseek(fp, (long)start, SEEK_SET) != 0) {
+ fclose(fp);
+ httpd_resp_set_status(req, "500 Internal Server Error");
+ httpd_resp_sendstr(req, "seek failed");
+ return ESP_FAIL;
+ }
+
+ uint32_t remain = file_size - start;
+ char hdr[96] = {0};
+ if (want_dat) {
+ httpd_resp_set_type(req, "application/octet-stream");
+ } else {
+ httpd_resp_set_type(req, "application/gpx+xml");
+ }
+ httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+ if (partial) {
+ httpd_resp_set_status(req, "206 Partial Content");
+ snprintf(hdr, sizeof(hdr), "bytes %lu-%lu/%lu", (unsigned long)start,
+ (unsigned long)(file_size - 1U), (unsigned long)file_size);
+ httpd_resp_set_hdr(req, "Content-Range", hdr);
+ }
+ snprintf(hdr, sizeof(hdr), "%lu", (unsigned long)remain);
+ httpd_resp_set_hdr(req, "Content-Length", hdr);
+
+ uint8_t chunk[WG_WIFI_DUMP_HTTP_CHUNK] = {0};
+ while (remain > 0) {
+ size_t want = remain > sizeof(chunk) ? sizeof(chunk) : (size_t)remain;
+ size_t n = fread(chunk, 1, want, fp);
+ if (n == 0) {
+ fclose(fp);
+ httpd_resp_send_chunk(req, NULL, 0);
+ return ESP_FAIL;
+ }
+ if (httpd_resp_send_chunk(req, (const char *)chunk, n) != ESP_OK) {
+ fclose(fp);
+ return ESP_FAIL;
+ }
+ remain -= (uint32_t)n;
+ }
+ fclose(fp);
+ return httpd_resp_send_chunk(req, NULL, 0);
+}
+
+static bool wifi_dump_http_start_locked(void) {
+ if (s_wifi_dump_httpd != NULL) {
+ return true;
+ }
+ httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
+ cfg.server_port = WG_WIFI_DUMP_HTTP_PORT;
+ cfg.max_uri_handlers = 6;
+ cfg.stack_size = 6144;
+ cfg.lru_purge_enable = true;
+ cfg.max_open_sockets = 4;
+
+ if (httpd_start(&s_wifi_dump_httpd, &cfg) != ESP_OK) {
+ s_wifi_dump_httpd = NULL;
+ return false;
+ }
+
+ const httpd_uri_t manifest_uri = {
+ .uri = WG_WIFI_DUMP_MANIFEST_URI,
+ .method = HTTP_GET,
+ .handler = wifi_dump_manifest_handler,
+ .user_ctx = NULL,
+ };
+ const httpd_uri_t file_uri = {
+ .uri = WG_WIFI_DUMP_FILE_URI,
+ .method = HTTP_GET,
+ .handler = wifi_dump_file_handler,
+ .user_ctx = NULL,
+ };
+ if (httpd_register_uri_handler(s_wifi_dump_httpd, &manifest_uri) != ESP_OK ||
+ httpd_register_uri_handler(s_wifi_dump_httpd, &file_uri) != ESP_OK) {
+ httpd_stop(s_wifi_dump_httpd);
+ s_wifi_dump_httpd = NULL;
+ return false;
+ }
+ return true;
+}
+
+static void wifi_dump_http_stop_locked(void) {
+ if (s_wifi_dump_httpd != NULL) {
+ httpd_handle_t handle = s_wifi_dump_httpd;
+ s_wifi_dump_httpd = NULL;
+ httpd_stop(handle);
+ }
+}
+
+static bool wifi_dump_ap_start_locked(void) {
+ if (s_wifi_dump_ap_running) {
+ return true;
+ }
+ wifi_config_t ap_cfg = {0};
+ memcpy(ap_cfg.ap.ssid, WG_WIFI_DUMP_AP_SSID, strlen(WG_WIFI_DUMP_AP_SSID));
+ ap_cfg.ap.ssid_len = strlen(WG_WIFI_DUMP_AP_SSID);
+ ap_cfg.ap.channel = WG_WIFI_DUMP_AP_CHANNEL;
+ ap_cfg.ap.authmode = WIFI_AUTH_OPEN;
+ ap_cfg.ap.max_connection = 2;
+ ap_cfg.ap.beacon_interval = 100;
+
+ esp_err_t rc = esp_wifi_set_mode(WIFI_MODE_APSTA);
+ if (rc != ESP_OK) {
+ ESP_LOGW(TAG, "wifi dump set mode APSTA failed rc=%s", esp_err_to_name(rc));
+ return false;
+ }
+ rc = esp_wifi_set_config(WIFI_IF_AP, &ap_cfg);
+ if (rc != ESP_OK) {
+ ESP_LOGW(TAG, "wifi dump AP config failed rc=%s", esp_err_to_name(rc));
+ (void)esp_wifi_set_mode(WIFI_MODE_STA);
+ return false;
+ }
+ s_wifi_dump_ap_running = true;
+ ESP_LOGI(TAG, "wifi dump AP ready ssid=%s channel=%u", WG_WIFI_DUMP_AP_SSID,
+ (unsigned)WG_WIFI_DUMP_AP_CHANNEL);
+ return true;
+}
+
+static void wifi_dump_ap_stop_locked(void) {
+ if (!s_wifi_dump_ap_running) {
+ return;
+ }
+ (void)esp_wifi_set_mode(WIFI_MODE_STA);
+ s_wifi_dump_ap_running = false;
+}
+
+static bool storage_dump_start_locked(void) {
+ if (!s_storage_ready || s_scanning) {
+ return false;
+ }
+
+ s_replay_requested = false;
+ storage_reset_replay_locked();
+ s_blob_requested = false;
+ storage_reset_blob_locked();
+ (void)storage_flush_pending_locked(true);
+
+ if (s_session_open) {
+ storage_close_session_locked(WG_SESSION_STATE_CLOSED);
+ }
+ if (!storage_dump_collect_sessions_locked()) {
+ return false;
+ }
+ s_wifi_dump_run_id = ((uint64_t)(uint32_t)now_ms() << 32) ^ (uint64_t)esp_random();
+ if (s_wifi_dump_run_id == 0) {
+ s_wifi_dump_run_id = 1;
+ }
+ if (!storage_dump_journal_write_locked(false)) {
+ storage_dump_reset_sessions_locked();
+ s_wifi_dump_run_id = 0;
+ return false;
+ }
+ s_wifi_dump_requested = true;
+ s_wifi_dump_active = true;
+ s_wifi_dump_started_ms = now_ms();
+ ESP_LOGI(TAG, "wifi dump start run=%016llX sessions=%lu", (unsigned long long)s_wifi_dump_run_id,
+ (unsigned long)s_wifi_dump_session_count);
+ return true;
+}
+
+static void storage_dump_stop_locked(bool keep_journal) {
+ s_wifi_dump_requested = false;
+ s_wifi_dump_active = false;
+ s_wifi_dump_run_id = 0;
+ s_wifi_dump_started_ms = 0;
+ storage_dump_reset_sessions_locked();
+ if (!keep_journal) {
+ (void)storage_dump_journal_delete_locked();
+ }
+}
+
+static bool storage_dump_commit_locked(uint64_t run_id) {
+ if (!s_wifi_dump_active || run_id == 0 || run_id != s_wifi_dump_run_id) {
+ return false;
+ }
+ return storage_dump_journal_write_locked(true);
+}
+
+static bool storage_set_wifi_dump_enabled(bool enabled) {
+ bool ok = true;
+ if (!storage_lock(pdMS_TO_TICKS(500))) {
+ return false;
+ }
+
+ if (enabled) {
+ if (s_wifi_dump_active || s_wifi_dump_requested) {
+ storage_unlock();
+ notify_status_frame();
+ return true;
+ }
+ if (!storage_dump_start_locked()) {
+ storage_unlock();
+ return false;
+ }
+ if (!wifi_dump_ap_start_locked() || !wifi_dump_http_start_locked()) {
+ wifi_dump_http_stop_locked();
+ wifi_dump_ap_stop_locked();
+ storage_dump_stop_locked(false);
+ ok = false;
+ }
+ } else {
+ if (!s_wifi_dump_active && !s_wifi_dump_requested) {
+ storage_unlock();
+ notify_status_frame();
+ return true;
+ }
+ wifi_dump_http_stop_locked();
+ wifi_dump_ap_stop_locked();
+ storage_dump_stop_locked(false);
+ }
+
+ storage_unlock();
+ notify_status_frame();
+ return ok;
+}
+
+static bool storage_commit_wifi_dump_run(uint64_t run_id) {
+ if (run_id == 0) {
+ return false;
+ }
+
+ wg_dump_journal_entry_t *entries = NULL;
+ uint32_t session_count = 0;
+ bool prepared = false;
+ if (!storage_lock(pdMS_TO_TICKS(500))) {
+ return false;
+ }
+ prepared = storage_dump_commit_locked(run_id);
+ if (prepared) {
+ session_count = (s_wifi_dump_session_count > UINT32_MAX) ? UINT32_MAX : (uint32_t)s_wifi_dump_session_count;
+ if (session_count > 0) {
+ entries = (wg_dump_journal_entry_t *)calloc(session_count, sizeof(wg_dump_journal_entry_t));
+ if (entries == NULL) {
+ prepared = false;
+ } else {
+ for (uint32_t i = 0; i < session_count; ++i) {
+ entries[i].session_id = s_wifi_dump_sessions[i].session_id;
+ entries[i].ack_seq = s_wifi_dump_sessions[i].written_seq;
+ }
+ }
+ }
+ }
+ storage_unlock();
+ if (!prepared) {
+ if (entries != NULL) {
+ free(entries);
+ }
+ return false;
+ }
+
+ bool ack_ok = true;
+ for (uint32_t i = 0; i < session_count; ++i) {
+ if (entries[i].session_id == 0 || entries[i].ack_seq == 0) {
+ continue;
+ }
+ if (!storage_apply_replay_ack(entries[i].session_id, entries[i].ack_seq)) {
+ ack_ok = false;
+ ESP_LOGW(TAG, "wifi dump commit ack failed sid=%016llX seq=%lu",
+ (unsigned long long)entries[i].session_id, (unsigned long)entries[i].ack_seq);
+ }
+ }
+ if (entries != NULL) {
+ free(entries);
+ }
+
+ if (!storage_lock(pdMS_TO_TICKS(500))) {
+ return false;
+ }
+ if (ack_ok) {
+ storage_refresh_backlog_locked(true);
+ (void)storage_dump_journal_delete_locked();
+ }
+ wifi_dump_http_stop_locked();
+ wifi_dump_ap_stop_locked();
+ storage_dump_stop_locked(true);
+ storage_unlock();
+ notify_status_frame();
+
+ if (ack_ok) {
+ ESP_LOGI(TAG, "wifi dump commit complete run=%016llX", (unsigned long long)run_id);
+ }
+ return ack_ok;
+}
+
+static void storage_recover_wifi_dump_journal(void) {
+ wg_dump_journal_header_t header = {0};
+ wg_dump_journal_entry_t *entries = NULL;
+ bool loaded = false;
+ if (!storage_lock(pdMS_TO_TICKS(300))) {
+ return;
+ }
+ loaded = storage_dump_journal_load_locked(&header, &entries);
+ if (!loaded) {
+ storage_unlock();
+ return;
+ }
+ if (header.committed == 0) {
+ ESP_LOGW(TAG, "wifi dump recovery: uncommitted journal retained run=%016llX sessions=%lu",
+ (unsigned long long)header.run_id, (unsigned long)header.session_count);
+ storage_unlock();
+ if (entries != NULL) {
+ free(entries);
+ }
+ return;
+ }
+ storage_unlock();
+
+ bool ok = true;
+ for (uint32_t i = 0; i < header.session_count; ++i) {
+ if (entries[i].session_id == 0 || entries[i].ack_seq == 0) {
+ continue;
+ }
+ if (!storage_apply_replay_ack(entries[i].session_id, entries[i].ack_seq)) {
+ ok = false;
+ }
+ }
+ if (entries != NULL) {
+ free(entries);
+ }
+
+ if (!storage_lock(pdMS_TO_TICKS(300))) {
+ return;
+ }
+ if (ok) {
+ (void)storage_refresh_backlog_locked(true);
+ (void)storage_dump_journal_delete_locked();
+ ESP_LOGI(TAG, "wifi dump recovery replayed committed journal run=%016llX",
+ (unsigned long long)header.run_id);
+ } else {
+ ESP_LOGW(TAG, "wifi dump recovery incomplete run=%016llX", (unsigned long long)header.run_id);
+ }
+ storage_unlock();
+}
+
static uint64_t storage_generate_session_id(void) {
uint32_t unix_s = s_latest_gps.unix_time_s;
if (unix_s == 0) {
@@ -1890,8 +2727,6 @@ static void storage_reset_pending_block_locked(void) {
s_store_block_started_ms = 0;
}
-static void storage_refresh_backlog_locked(bool reclaim);
-
static uint32_t storage_reclaim_acked_sessions_locked(void) {
DIR *dir = opendir(storage_base_path());
if (dir == NULL) {
@@ -2478,6 +3313,9 @@ static bool storage_replay_allowed_locked(void) {
if (!s_storage_ready || !s_replay_requested || s_blob_requested) {
return false;
}
+ if (s_wifi_dump_requested || s_wifi_dump_active) {
+ return false;
+ }
if (s_scanning) {
return false;
}
@@ -2709,6 +3547,9 @@ static bool storage_blob_allowed_locked(void) {
if (!s_storage_ready || !s_blob_requested) {
return false;
}
+ if (s_wifi_dump_requested || s_wifi_dump_active) {
+ return false;
+ }
if (s_scanning) {
return false;
}
@@ -3582,6 +4423,7 @@ static void init_storage(void) {
s_storage_ready = true;
storage_abort_stale_open_sessions();
+ storage_recover_wifi_dump_journal();
storage_refresh_backlog(true);
uint64_t total = 0;
uint64_t used = 0;
@@ -6142,6 +6984,11 @@ static esp_err_t start_scan(void) {
rc = ESP_ERR_INVALID_STATE;
goto out;
}
+ if (s_wifi_dump_requested || s_wifi_dump_active) {
+ ESP_LOGW(TAG, "Scan start blocked: Wi-Fi dump mode is active");
+ rc = ESP_ERR_INVALID_STATE;
+ goto out;
+ }
if (s_scanning) {
rc = ESP_OK;
goto out;
@@ -6339,6 +7186,9 @@ static uint8_t apply_command(const wg_command_t *cmd) {
if (!s_latest_gps.valid) {
return WG_ERR_BAD_ARG;
}
+ if (s_wifi_dump_requested || s_wifi_dump_active) {
+ return WG_ERR_BUSY;
+ }
wg_scan_policy_on_explicit_start(&s_auto_scan_paused);
return start_scan() == ESP_OK ? WG_ACK_OK : WG_ERR_INTERNAL;
case WG_CMD_STOP:
@@ -6415,6 +7265,9 @@ static uint8_t apply_command(const wg_command_t *cmd) {
if (cmd->replay_enable > 1) {
return WG_ERR_BAD_ARG;
}
+ if (cmd->replay_enable == 1 && (s_wifi_dump_requested || s_wifi_dump_active)) {
+ return WG_ERR_BUSY;
+ }
if (cmd->replay_enable == 1 && s_scanning) {
esp_err_t stop_rc = stop_scan();
if (stop_rc != ESP_OK) {
@@ -6429,6 +7282,9 @@ static uint8_t apply_command(const wg_command_t *cmd) {
if (cmd->backlog_blob_enable > 1) {
return WG_ERR_BAD_ARG;
}
+ if (cmd->backlog_blob_enable == 1 && (s_wifi_dump_requested || s_wifi_dump_active)) {
+ return WG_ERR_BUSY;
+ }
if (cmd->backlog_blob_enable == 1 && s_scanning) {
esp_err_t stop_rc = stop_scan();
if (stop_rc != ESP_OK) {
@@ -6457,6 +7313,25 @@ static uint8_t apply_command(const wg_command_t *cmd) {
cmd->backlog_blob_chunk_reply)
? WG_ACK_OK
: WG_ERR_INTERNAL;
+ case WG_CMD_SET_WIFI_DUMP:
+ if (cmd->wifi_dump_enable > 1) {
+ return WG_ERR_BAD_ARG;
+ }
+ if (cmd->wifi_dump_enable == 1 && s_scanning) {
+ esp_err_t stop_rc = stop_scan();
+ if (stop_rc != ESP_OK) {
+ ESP_LOGW(TAG, "set wifi dump failed: stop scan rc=%s", esp_err_to_name(stop_rc));
+ return WG_ERR_INTERNAL;
+ }
+ }
+ return storage_set_wifi_dump_enabled(cmd->wifi_dump_enable == 1) ? WG_ACK_OK
+ : WG_ERR_INTERNAL;
+ case WG_CMD_COMMIT_WIFI_DUMP:
+ if (cmd->wifi_dump_run_id == 0) {
+ return WG_ERR_BAD_ARG;
+ }
+ return storage_commit_wifi_dump_run(cmd->wifi_dump_run_id) ? WG_ACK_OK
+ : WG_ERR_INTERNAL;
case WG_CMD_DEBUG_SEED_STORAGE: {
if (s_scanning) {
return WG_ERR_BUSY;
@@ -6472,7 +7347,7 @@ static uint8_t apply_command(const wg_command_t *cmd) {
return WG_ACK_OK;
}
case WG_CMD_CLEAR_STORAGE:
- if (s_scanning) {
+ if (s_scanning || s_wifi_dump_requested || s_wifi_dump_active) {
return WG_ERR_BUSY;
}
if (!storage_clear_sessions()) {
diff --git a/main/wg_protocol.c b/main/wg_protocol.c
index 994b33d..6f521a9 100644
--- a/main/wg_protocol.c
+++ b/main/wg_protocol.c
@@ -113,6 +113,8 @@ wg_result_t wg_command_decode(const uint8_t *payload, size_t payload_len, wg_com
out->backlog_blob_chunk_offset = 0;
out->backlog_blob_chunk_len = 0;
out->backlog_blob_chunk_reply = WG_BACKLOG_BLOB_CHUNK_REPLY_NAK;
+ out->wifi_dump_enable = 0;
+ out->wifi_dump_run_id = 0;
switch (out->id) {
case WG_CMD_START:
@@ -203,6 +205,18 @@ wg_result_t wg_command_decode(const uint8_t *payload, size_t payload_len, wg_com
out->backlog_blob_chunk_len = rd_u16(&payload[13]);
out->backlog_blob_chunk_reply = payload[15];
return WG_OK;
+ case WG_CMD_SET_WIFI_DUMP:
+ if (payload_len != (size_t)(1 + WG_WIFI_DUMP_TOGGLE_PAYLOAD_SIZE)) {
+ return WG_ERR_INVALID_FRAME;
+ }
+ out->wifi_dump_enable = payload[1];
+ return WG_OK;
+ case WG_CMD_COMMIT_WIFI_DUMP:
+ if (payload_len != (size_t)(1 + WG_WIFI_DUMP_COMMIT_PAYLOAD_SIZE)) {
+ return WG_ERR_INVALID_FRAME;
+ }
+ out->wifi_dump_run_id = rd_u64(&payload[1]);
+ return WG_OK;
default:
return WG_ERR_UNSUPPORTED;
}
diff --git a/main/wg_protocol.h b/main/wg_protocol.h
index cef3e38..86ad367 100644
--- a/main/wg_protocol.h
+++ b/main/wg_protocol.h
@@ -49,6 +49,8 @@ typedef enum {
WG_CMD_DEBUG_SEED_STORAGE = 0x0E,
WG_CMD_SET_CHANNEL_PLAN = 0x0F,
WG_CMD_BACKLOG_BLOB_CHUNK_REPLY = 0x10,
+ WG_CMD_SET_WIFI_DUMP = 0x11,
+ WG_CMD_COMMIT_WIFI_DUMP = 0x12,
} wg_command_id_t;
typedef enum {
@@ -66,6 +68,8 @@ typedef enum {
#define WG_DEBUG_SEED_STORAGE_PAYLOAD_SIZE 4
#define WG_CHANNEL_PLAN_PAYLOAD_SIZE 12
#define WG_BACKLOG_BLOB_CHUNK_REPLY_PAYLOAD_SIZE 15
+#define WG_WIFI_DUMP_TOGGLE_PAYLOAD_SIZE 1
+#define WG_WIFI_DUMP_COMMIT_PAYLOAD_SIZE 8
#define WG_BACKLOG_BLOB_CHUNK_REPLY_NAK 0
#define WG_BACKLOG_BLOB_CHUNK_REPLY_ACK 1
@@ -104,6 +108,8 @@ typedef struct {
uint32_t backlog_blob_chunk_offset;
uint16_t backlog_blob_chunk_len;
uint8_t backlog_blob_chunk_reply;
+ uint8_t wifi_dump_enable;
+ uint64_t wifi_dump_run_id;
} wg_command_t;
wg_result_t wg_frame_encode(const wg_frame_t *frame, uint8_t *out, size_t out_capacity,
diff --git a/tests/test_wg_protocol.c b/tests/test_wg_protocol.c
index b408075..255721e 100644
--- a/tests/test_wg_protocol.c
+++ b/tests/test_wg_protocol.c
@@ -233,6 +233,34 @@ static void test_backlog_blob_chunk_reply_command_decode(void) {
"backlog blob chunk reply code should decode");
}
+static void test_set_wifi_dump_command_decode(void) {
+ uint8_t payload[] = {
+ WG_CMD_SET_WIFI_DUMP,
+ 0x01,
+ };
+
+ wg_command_t cmd = {0};
+ assert_true(wg_command_decode(payload, sizeof(payload), &cmd) == WG_OK,
+ "set wifi dump command decode should succeed");
+ assert_true(cmd.id == WG_CMD_SET_WIFI_DUMP, "set wifi dump command id should match");
+ assert_true(cmd.wifi_dump_enable == 1, "set wifi dump enable should decode");
+}
+
+static void test_commit_wifi_dump_command_decode(void) {
+ uint8_t payload[] = {
+ WG_CMD_COMMIT_WIFI_DUMP,
+ 0xEF, 0xCD, 0xAB, 0x89, 0x67, 0x45, 0x23, 0x01, // run id (little-endian)
+ };
+
+ wg_command_t cmd = {0};
+ assert_true(wg_command_decode(payload, sizeof(payload), &cmd) == WG_OK,
+ "commit wifi dump command decode should succeed");
+ assert_true(cmd.id == WG_CMD_COMMIT_WIFI_DUMP,
+ "commit wifi dump command id should match");
+ assert_true(cmd.wifi_dump_run_id == 0x0123456789ABCDEFULL,
+ "commit wifi dump run id should decode");
+}
+
int main(void) {
test_frame_roundtrip();
test_command_decode();
@@ -246,6 +274,8 @@ int main(void) {
test_set_backlog_blob_command_decode();
test_debug_seed_storage_command_decode();
test_backlog_blob_chunk_reply_command_decode();
+ test_set_wifi_dump_command_decode();
+ test_commit_wifi_dump_command_decode();
printf("PASS: %d tests\n", tests_run);
return 0;
}
From 2b3c51aea855e91475f9b0e90d4cf4cf9361dd47 Mon Sep 17 00:00:00 2001
From: Giorgos Karampitsakis <42468982+ComradeTurtle@users.noreply.github.com>
Date: Sat, 18 Apr 2026 18:13:46 +0300
Subject: [PATCH 2/7] Fix Wi-Fi dump connection path on Android and firmware
---
android/app/src/main/AndroidManifest.xml | 2 +
.../java/com/wifinder/android/MainActivity.kt | 1 +
.../android/service/ScannerService.kt | 40 ++++++++++++++++---
main/main.c | 1 +
4 files changed, 38 insertions(+), 6 deletions(-)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 43f3555..c74d2af 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
@@ -22,6 +23,7 @@
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
+ android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:theme="@style/Theme.WiFinder">
= Build.VERSION_CODES.TIRAMISU) {
+ permissions += Manifest.permission.NEARBY_WIFI_DEVICES
permissions += Manifest.permission.POST_NOTIFICATIONS
}
return permissions
diff --git a/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt b/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
index cf72ad5..9f4e0c9 100644
--- a/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
+++ b/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
@@ -1,10 +1,12 @@
package com.wifinder.android.service
+import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.SharedPreferences
+import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.location.Location
import android.net.ConnectivityManager
@@ -49,6 +51,7 @@ import java.util.zip.CRC32
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.coroutines.resume
+import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -622,9 +625,6 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
synchronized(ackLock) {
sessionAcks.clear()
}
- if (!bleClient.setBacklogBlobEnabled(false)) {
- appendLog("Backlog download warning: failed to disable legacy blob stream")
- }
val okWifiDump = bleClient.setWifiDumpEnabled(true)
if (!okWifiDump) {
downloadBacklogStartMs = 0L
@@ -1684,7 +1684,26 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
appendLog("Backlog Wi-Fi dump requires Android 10+")
return null
}
- val cm = getSystemService(ConnectivityManager::class.java) ?: return null
+ if (ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ ) != PackageManager.PERMISSION_GRANTED) {
+ appendLog("Backlog Wi-Fi dump blocked: ACCESS_FINE_LOCATION permission missing")
+ return null
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.NEARBY_WIFI_DEVICES,
+ ) != PackageManager.PERMISSION_GRANTED) {
+ appendLog("Backlog Wi-Fi dump blocked: NEARBY_WIFI_DEVICES permission missing")
+ return null
+ }
+ val cm = getSystemService(ConnectivityManager::class.java)
+ if (cm == null) {
+ appendLog("Backlog Wi-Fi dump failed: ConnectivityManager unavailable")
+ return null
+ }
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.setNetworkSpecifier(
@@ -1705,10 +1724,12 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
}
callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
+ appendLog("Backlog Wi-Fi network acquired: $WIFI_DUMP_AP_SSID")
finish(network to this)
}
override fun onUnavailable() {
+ appendLog("Backlog Wi-Fi network unavailable: $WIFI_DUMP_AP_SSID")
try {
cm.unregisterNetworkCallback(this)
} catch (_: Exception) {}
@@ -1717,6 +1738,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
override fun onLost(network: Network) {
if (!completed) {
+ appendLog("Backlog Wi-Fi network lost before transfer")
try {
cm.unregisterNetworkCallback(this)
} catch (_: Exception) {}
@@ -1726,7 +1748,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
}
try {
cm.requestNetwork(request, callback)
- } catch (_: Exception) {
+ } catch (e: Exception) {
+ appendLog("Backlog Wi-Fi request failed: ${e.javaClass.simpleName}${if (e.message.isNullOrBlank()) "" else " (${e.message})"}")
finish(null)
return@suspendCancellableCoroutine
}
@@ -1736,7 +1759,11 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
} catch (_: Exception) {}
}
}
- } ?: return null
+ }
+ if (acquired == null) {
+ appendLog("Backlog Wi-Fi request timed out (${WIFI_DUMP_NETWORK_WAIT_MS}ms)")
+ return null
+ }
val network = acquired.first
val callback = acquired.second
@@ -1747,6 +1774,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
false
}
if (!bound) {
+ appendLog("Backlog Wi-Fi bindProcessToNetwork failed")
try {
cm.unregisterNetworkCallback(callback)
} catch (_: Exception) {}
diff --git a/main/main.c b/main/main.c
index 9f6ed78..008c4a2 100644
--- a/main/main.c
+++ b/main/main.c
@@ -8030,6 +8030,7 @@ static void init_wifi(void) {
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
+ esp_netif_create_default_wifi_ap();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
From 217d45df7a1705ddde99fbe331cb9153a5066e67 Mon Sep 17 00:00:00 2001
From: Giorgos Karampitsakis <42468982+ComradeTurtle@users.noreply.github.com>
Date: Sun, 19 Apr 2026 04:12:56 +0300
Subject: [PATCH 3/7] android: make the dashboard cool again
---
.../wifinder/android/ui/DashboardScreen.kt | 189 +++++++++++++-----
1 file changed, 140 insertions(+), 49 deletions(-)
diff --git a/android/app/src/main/java/com/wifinder/android/ui/DashboardScreen.kt b/android/app/src/main/java/com/wifinder/android/ui/DashboardScreen.kt
index 3ee8161..6e62522 100644
--- a/android/app/src/main/java/com/wifinder/android/ui/DashboardScreen.kt
+++ b/android/app/src/main/java/com/wifinder/android/ui/DashboardScreen.kt
@@ -1,5 +1,8 @@
package com.wifinder.android.ui
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.BatteryManager
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -22,6 +25,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -29,6 +38,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
@@ -40,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wifinder.android.model.WifiBand
+import kotlinx.coroutines.delay
// ---------------------------------------------------------------------------
// Colours
@@ -51,7 +62,7 @@ private val TealDim = Color(0xFF00897B)
private val Amber = Color(0xFFFFC107)
private val Red = Color(0xFFEF5350)
private val Green = Color(0xFF4CAF50)
-private val DimLabel = Color(0xFF757575)
+private val DimLabel = Color(0xFF9E9E9E)
private val BrightLabel = Color(0xFFE0E0E0)
// ---------------------------------------------------------------------------
@@ -85,40 +96,38 @@ private fun DashboardPortrait(state: AppUiState, onExit: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ .padding(horizontal = 16.dp),
) {
- DashTopBar(onExit = onExit, state = state)
-
- Spacer(Modifier.weight(1f))
-
- HeroApCount(count = state.uniqueBssids, modifier = Modifier.fillMaxWidth())
-
- Spacer(Modifier.weight(1f))
-
- RateStrip(state = state, modifier = Modifier.fillMaxWidth())
-
- Spacer(Modifier.weight(1f))
-
- HealthIndicators(state = state, modifier = Modifier.fillMaxWidth())
+ // 5% top margin
+ Spacer(Modifier.weight(0.05f))
- Spacer(Modifier.weight(1f))
-
- GpsDetailRow(state = state, modifier = Modifier.fillMaxWidth())
-
- Spacer(Modifier.weight(1f))
+ // 55% info section
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(0.55f),
+ verticalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ DashTopBar(onExit = onExit, state = state)
+ HeroRow(count = state.uniqueBssids, modifier = Modifier.fillMaxWidth())
+ RateStrip(state = state, modifier = Modifier.fillMaxWidth())
+ HealthIndicators(state = state, modifier = Modifier.fillMaxWidth())
+ GpsDopRow(state = state, modifier = Modifier.fillMaxWidth())
+ GpsStatusRow(state = state, modifier = Modifier.fillMaxWidth())
+ StorageBar(state = state, modifier = Modifier.fillMaxWidth())
+ BacklogRow(state = state, modifier = Modifier.fillMaxWidth())
+ }
+ // 35% channel chart
ChannelChart(
sightings = state.sightings,
modifier = Modifier
.fillMaxWidth()
- .height(150.dp),
+ .weight(0.35f),
)
- Spacer(Modifier.weight(1f))
-
- StorageBar(state = state, modifier = Modifier.fillMaxWidth())
- Spacer(Modifier.height(4.dp))
- BacklogRow(state = state, modifier = Modifier.fillMaxWidth())
+ // 5% bottom margin
+ Spacer(Modifier.weight(0.05f))
}
}
@@ -149,10 +158,11 @@ private fun DashboardLandscape(state: AppUiState, onExit: () -> Unit) {
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceEvenly,
) {
- HeroApCount(count = state.uniqueBssids, modifier = Modifier.fillMaxWidth())
+ HeroRow(count = state.uniqueBssids, modifier = Modifier.fillMaxWidth())
RateStrip(state = state, modifier = Modifier.fillMaxWidth())
HealthIndicators(state = state, modifier = Modifier.fillMaxWidth())
- GpsDetailRow(state = state, modifier = Modifier.fillMaxWidth())
+ GpsDopRow(state = state, modifier = Modifier.fillMaxWidth())
+ GpsStatusRow(state = state, modifier = Modifier.fillMaxWidth())
}
// Right column
@@ -238,26 +248,86 @@ private fun MiniDot(color: Color) {
}
// ---------------------------------------------------------------------------
-// Hero AP count
+// Hero row: AP count + battery side by side
// ---------------------------------------------------------------------------
@Composable
-private fun HeroApCount(count: Int, modifier: Modifier = Modifier) {
+private fun HeroRow(count: Int, modifier: Modifier = Modifier) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // AP count – hero
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = "%,d".format(count),
+ color = Teal,
+ fontSize = 56.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ )
+ Text(
+ text = "UNIQUE APs",
+ color = DimLabel,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 2.sp,
+ )
+ }
+
+ // Battery block
+ BatteryBlock(modifier = Modifier.weight(0.6f))
+ }
+}
+
+@Composable
+private fun BatteryBlock(modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ var pct by remember { mutableIntStateOf(-1) }
+ var charging by remember { mutableStateOf(false) }
+
+ // Poll every 5 seconds via the sticky broadcast (very cheap, no receiver registration)
+ LaunchedEffect(Unit) {
+ while (true) {
+ val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+ val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
+ val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100
+ pct = if (level >= 0 && scale > 0) (level * 100) / scale else -1
+ charging = (intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0) != 0
+ delay(5_000L)
+ }
+ }
+
+ val battColor = when {
+ pct < 0 -> DimLabel
+ pct <= 15 -> Red
+ pct <= 30 -> Amber
+ charging -> Green
+ else -> BrightLabel
+ }
+ val stateText = if (charging) "⚡CHG" else "BAT"
+
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
- text = "%,d".format(count),
- color = Teal,
- fontSize = 56.sp,
+ text = if (pct >= 0) "$pct%" else "—",
+ color = battColor,
+ fontSize = 36.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
maxLines = 1,
)
Text(
- text = "UNIQUE APs",
- color = DimLabel,
+ text = stateText,
+ color = if (charging) Green else DimLabel,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 2.sp,
@@ -274,7 +344,7 @@ private fun RateStrip(state: AppUiState, modifier: Modifier = Modifier) {
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(CardBg)
- .padding(horizontal = 12.dp, vertical = 8.dp),
+ .padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -285,8 +355,8 @@ private fun RateStrip(state: AppUiState, modifier: Modifier = Modifier) {
)
VerticalDivider()
MetricCell(
- value = "%.0f".format(state.phoneSpeedKmh),
- label = "km/h",
+ value = if (state.dieTempCenti != Int.MIN_VALUE) "%.0f°".format(state.dieTempCenti / 100.0) else "—",
+ label = "die temp",
modifier = Modifier.weight(1f),
)
VerticalDivider()
@@ -315,7 +385,8 @@ private fun MetricCell(value: String, label: String, modifier: Modifier = Modifi
Text(
text = label,
color = DimLabel,
- fontSize = 10.sp,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
@@ -340,7 +411,7 @@ private fun HealthIndicators(state: AppUiState, modifier: Modifier = Modifier) {
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(CardBg)
- .padding(horizontal = 12.dp, vertical = 8.dp),
+ .padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -383,7 +454,7 @@ private fun HealthIndicators(state: AppUiState, modifier: Modifier = Modifier) {
private fun HealthDot(label: String, color: Color) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(3.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
@@ -394,7 +465,8 @@ private fun HealthDot(label: String, color: Color) {
Text(
text = label,
color = DimLabel,
- fontSize = 9.sp,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
@@ -426,7 +498,7 @@ private fun gpsNavRateCompact(mode: Int, appliedHz: Int): String {
}
@Composable
-private fun GpsDetailRow(state: AppUiState, modifier: Modifier = Modifier) {
+private fun GpsDopRow(state: AppUiState, modifier: Modifier = Modifier) {
val hdopText = if (state.gpsHdopCenti > 0) "%.2f".format(state.gpsHdopCenti / 100.0) else "—"
val vdopText = if (state.gpsVdopCenti > 0) "%.2f".format(state.gpsVdopCenti / 100.0) else "—"
val pdopText = if (state.gpsPdopCenti > 0) "%.2f".format(state.gpsPdopCenti / 100.0) else "—"
@@ -437,21 +509,34 @@ private fun GpsDetailRow(state: AppUiState, modifier: Modifier = Modifier) {
else -> "—"
}
val accText = if (state.gpsValid) "±${"%.1f".format(state.gpsAccuracyDm / 10.0)}m" else "—"
- val ageText = if (state.gpsValid) "${state.gpsAgeS}s" else "—"
- val rateText = gpsNavRateCompact(state.gpsNavMode, state.gpsNavAppliedHz)
- val dopText = "$hdopText/$vdopText/$pdopText"
Row(
modifier = modifier
- .clip(RoundedCornerShape(8.dp))
+ .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
.background(CardBg)
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
- GpsMini(value = dopText, label = "H/V/P", color = pdopColor(state.gpsPdopCenti))
+ GpsMini(value = "$hdopText/$vdopText/$pdopText", label = "H/V/P", color = pdopColor(state.gpsPdopCenti))
GpsMini(value = satText, label = "Use/View", color = BrightLabel)
GpsMini(value = accText, label = "Acc", color = BrightLabel)
+ }
+}
+
+@Composable
+private fun GpsStatusRow(state: AppUiState, modifier: Modifier = Modifier) {
+ val ageText = if (state.gpsValid) "${state.gpsAgeS}s" else "—"
+ val rateText = gpsNavRateCompact(state.gpsNavMode, state.gpsNavAppliedHz)
+
+ Row(
+ modifier = modifier
+ .clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
+ .background(CardBg)
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
GpsMini(
value = gpsSourceLabel(state.gpsSource),
label = "Src",
@@ -468,6 +553,11 @@ private fun GpsDetailRow(state: AppUiState, modifier: Modifier = Modifier) {
else -> Red
})
GpsMini(value = rateText, label = "Nav", color = DimLabel)
+ GpsMini(
+ value = "%.0f".format(state.phoneSpeedKmh),
+ label = "km/h",
+ color = BrightLabel,
+ )
}
}
@@ -485,7 +575,8 @@ private fun GpsMini(value: String, label: String, color: Color) {
Text(
text = label,
color = DimLabel,
- fontSize = 9.sp,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
From f29f21d393ec4952788b41714dc34e83b268d410 Mon Sep 17 00:00:00 2001
From: Giorgos Karampitsakis <42468982+ComradeTurtle@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:52:42 +0300
Subject: [PATCH 4/7] version: unify firmware/app version source at 0.9
---
CMakeLists.txt | 7 ++++
VERSION | 1 +
android/app/build.gradle.kts | 39 ++++++++++++++++++-
.../android/logging/WigleCsvLogger.kt | 32 +++++++++++----
main/CMakeLists.txt | 1 +
main/main.c | 9 +++--
6 files changed, 77 insertions(+), 12 deletions(-)
create mode 100644 VERSION
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 06677a9..118af62 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,4 +1,11 @@
cmake_minimum_required(VERSION 3.16)
+file(READ "${CMAKE_SOURCE_DIR}/VERSION" WIFINDER_VERSION_RAW)
+string(STRIP "${WIFINDER_VERSION_RAW}" WIFINDER_VERSION)
+if(WIFINDER_VERSION STREQUAL "")
+ message(FATAL_ERROR "VERSION file is empty")
+endif()
+set(PROJECT_VER "${WIFINDER_VERSION}")
+
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(wifinder)
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..b63ba69
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.9
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index c3d7939..929e7ad 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -3,6 +3,41 @@ plugins {
id("org.jetbrains.kotlin.android")
}
+val repoVersion: String by lazy {
+ val file = rootProject.projectDir.parentFile.resolve("VERSION")
+ if (!file.exists()) {
+ throw GradleException("Missing VERSION file at ${file.absolutePath}")
+ }
+ val value = file.readText().trim()
+ if (value.isBlank()) {
+ throw GradleException("VERSION file is empty")
+ }
+ value
+}
+
+fun parseSemVer(version: String): Triple {
+ val parts = version.split('.')
+ if (parts.size !in 2..3) {
+ throw GradleException("VERSION must be MAJOR.MINOR or MAJOR.MINOR.PATCH, got '$version'")
+ }
+ val major = parts[0].toIntOrNull()
+ ?: throw GradleException("Invalid major version in '$version'")
+ val minor = parts[1].toIntOrNull()
+ ?: throw GradleException("Invalid minor version in '$version'")
+ val patch = when (parts.size) {
+ 3 -> parts[2].toIntOrNull()
+ ?: throw GradleException("Invalid patch version in '$version'")
+ else -> 0
+ }
+ if (major < 0 || minor < 0 || patch < 0) {
+ throw GradleException("VERSION components must be non-negative, got '$version'")
+ }
+ return Triple(major, minor, patch)
+}
+
+val (versionMajor, versionMinor, versionPatch) = parseSemVer(repoVersion)
+val computedVersionCode = (versionMajor * 10000) + (versionMinor * 100) + versionPatch
+
android {
namespace = "com.wifinder.android"
compileSdk = 34
@@ -11,8 +46,8 @@ android {
applicationId = "com.wifinder.android"
minSdk = 26
targetSdk = 34
- versionCode = 1
- versionName = "1.0"
+ versionCode = computedVersionCode
+ versionName = repoVersion
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/android/app/src/main/java/com/wifinder/android/logging/WigleCsvLogger.kt b/android/app/src/main/java/com/wifinder/android/logging/WigleCsvLogger.kt
index 8bd283c..f3419d8 100644
--- a/android/app/src/main/java/com/wifinder/android/logging/WigleCsvLogger.kt
+++ b/android/app/src/main/java/com/wifinder/android/logging/WigleCsvLogger.kt
@@ -103,15 +103,16 @@ class WigleCsvLogger(private val context: Context) {
currentDisplayPath = displayPath
}
+ val appVersion = appVersionName()
val header =
WigleCsvFormatter.header(
- appRelease = "wifinder-android-1",
- model = Build.MODEL ?: "unknown",
- release = Build.VERSION.RELEASE ?: "unknown",
- device = Build.DEVICE ?: "unknown",
- display = Build.DISPLAY ?: "unknown",
- board = Build.BOARD ?: "unknown",
- brand = Build.BRAND ?: "unknown",
+ appRelease = "WiFinder-Android-$appVersion",
+ model = "WiFinder",
+ release = appVersion,
+ device = "WiFinder-Android",
+ display = "WiFinder",
+ board = "WiFinder",
+ brand = "WiFinder",
)
for (line in header) {
@@ -164,6 +165,23 @@ class WigleCsvLogger(private val context: Context) {
currentFile = null
currentDisplayPath = null
}
+
+ private fun appVersionName(): String {
+ return try {
+ val pkgInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.packageManager.getPackageInfo(
+ context.packageName,
+ android.content.pm.PackageManager.PackageInfoFlags.of(0),
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ context.packageManager.getPackageInfo(context.packageName, 0)
+ }
+ pkgInfo.versionName?.takeIf { it.isNotBlank() } ?: "unknown"
+ } catch (_: Exception) {
+ "unknown"
+ }
+ }
}
data class CsvSessionInfo(
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 75c841c..262b901 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -17,6 +17,7 @@ idf_component_register(
esp_netif
esp_timer
esp_http_server
+ esp_app_format
spiffs
fatfs
sdmmc
diff --git a/main/main.c b/main/main.c
index 008c4a2..76a4965 100644
--- a/main/main.c
+++ b/main/main.c
@@ -25,6 +25,7 @@
#include "driver/temperature_sensor.h"
#include "driver/uart.h"
#include "driver/usb_serial_jtag.h"
+#include "esp_app_desc.h"
#include "esp_err.h"
#include "esp_event.h"
#include "esp_log.h"
@@ -66,10 +67,8 @@ extern void ble_store_config_init(void);
#if CONFIG_IDF_TARGET_ESP32C6
#define WG_DEVICE_NAME "WIFINDER-C6"
-#define WG_DEVICE_INFO_TEXT "WIFINDER-C6 FW1"
#else
#define WG_DEVICE_NAME "WIFINDER-S3"
-#define WG_DEVICE_INFO_TEXT "WIFINDER-S3 FW1"
#endif
#define WG_DEFAULT_HOP_MS 250
@@ -7479,7 +7478,11 @@ static int handle_gatt_read(uint16_t attr_handle, struct ble_gatt_access_ctxt *c
}
if (attr_handle == s_device_info_handle) {
- return os_mbuf_append(ctxt->om, WG_DEVICE_INFO_TEXT, strlen(WG_DEVICE_INFO_TEXT)) == 0
+ const esp_app_desc_t *app_desc = esp_app_get_description();
+ const char *fw_ver = (app_desc != NULL && app_desc->version[0] != '\0') ? app_desc->version : "unknown";
+ char info[96] = {0};
+ (void)snprintf(info, sizeof(info), "%s FW %s", WG_DEVICE_NAME, fw_ver);
+ return os_mbuf_append(ctxt->om, info, strlen(info)) == 0
? 0
: BLE_ATT_ERR_INSUFFICIENT_RES;
}
From e1dea3020337127c62cfbbb0384f9740ff9205ba Mon Sep 17 00:00:00 2001
From: Giorgos Karampitsakis <42468982+ComradeTurtle@users.noreply.github.com>
Date: Thu, 23 Apr 2026 17:51:53 +0300
Subject: [PATCH 5/7] Refactor backlog flow and BLE targeting; add CI checks
---
.github/workflows/android-unit-tests.yml | 34 ++
.github/workflows/desktop-tests.yml | 24 +
.github/workflows/firmware-host-tests.yml | 19 +
.github/workflows/web-core-tests.yml | 27 ++
README.md | 15 +
.../java/com/wifinder/android/MainActivity.kt | 70 +++
.../android/ble/BleTargetingPolicy.kt | 71 +++
.../wifinder/android/ble/WiFinderBleClient.kt | 202 ++++++--
.../service/BacklogTransferCoordinator.kt | 209 +++++++++
.../android/service/ScannerService.kt | 443 ++++++++++--------
.../com/wifinder/android/ui/AppUiState.kt | 11 +
.../com/wifinder/android/ui/MainViewModel.kt | 14 +
.../android/ble/BleTargetingPolicyTest.kt | 56 +++
.../service/BacklogTransferCoordinatorTest.kt | 65 +++
main/CMakeLists.txt | 3 +
main/main.c | 156 +-----
main/wg_gps_nmea.c | 86 ++++
main/wg_gps_nmea.h | 10 +
main/wg_session_transport.c | 79 ++++
main/wg_session_transport.h | 15 +
main/wg_storage_backlog.c | 63 +++
main/wg_storage_backlog.h | 15 +
22 files changed, 1322 insertions(+), 365 deletions(-)
create mode 100644 .github/workflows/android-unit-tests.yml
create mode 100644 .github/workflows/desktop-tests.yml
create mode 100644 .github/workflows/firmware-host-tests.yml
create mode 100644 .github/workflows/web-core-tests.yml
create mode 100644 android/app/src/main/java/com/wifinder/android/ble/BleTargetingPolicy.kt
create mode 100644 android/app/src/main/java/com/wifinder/android/service/BacklogTransferCoordinator.kt
create mode 100644 android/app/src/test/java/com/wifinder/android/ble/BleTargetingPolicyTest.kt
create mode 100644 android/app/src/test/java/com/wifinder/android/service/BacklogTransferCoordinatorTest.kt
create mode 100644 main/wg_gps_nmea.c
create mode 100644 main/wg_gps_nmea.h
create mode 100644 main/wg_session_transport.c
create mode 100644 main/wg_session_transport.h
create mode 100644 main/wg_storage_backlog.c
create mode 100644 main/wg_storage_backlog.h
diff --git a/.github/workflows/android-unit-tests.yml b/.github/workflows/android-unit-tests.yml
new file mode 100644
index 0000000..46d7936
--- /dev/null
+++ b/.github/workflows/android-unit-tests.yml
@@ -0,0 +1,34 @@
+name: Android Unit Tests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ android-unit-tests:
+ name: android-unit-tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Java 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Write local.properties
+ run: |
+ cat > android/local.properties < Unit,
onSeedDebugBacklog: () -> Unit,
onPushPhoneGpsNow: () -> Unit,
+ onSelectBleDevice: (String) -> Unit,
+ onDismissBlePicker: () -> Unit,
+ onForgetPreferredBleDevice: () -> Unit,
onToggleSection: (String) -> Unit,
onToggleDashboard: () -> Unit,
) {
@@ -311,6 +317,45 @@ private fun WiFinderScreen(
color = MaterialTheme.colorScheme.background,
) {
Column(modifier = Modifier.fillMaxSize()) {
+ if (state.bleDevicePickerVisible) {
+ AlertDialog(
+ onDismissRequest = onDismissBlePicker,
+ title = { Text("Select WiFinder Device") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ state.bleDeviceCandidates.forEach { candidate ->
+ OutlinedButton(
+ onClick = { onSelectBleDevice(candidate.address) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = candidate.name,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = "${candidate.address} • RSSI ${candidate.rssi}",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ if (candidate.remembered) {
+ Text(
+ text = "Remembered target",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = onDismissBlePicker) { Text("Cancel") }
+ },
+ )
+ }
+
// ── Top bar ─────────────────────────────────────────────
TopAppBar(
title = {
@@ -394,6 +439,7 @@ private fun WiFinderScreen(
onDownloadBacklog = onDownloadBacklog,
onSeedDebugBacklog = onSeedDebugBacklog,
onPushPhoneGpsNow = onPushPhoneGpsNow,
+ onForgetPreferredBleDevice = onForgetPreferredBleDevice,
)
}
@@ -663,6 +709,7 @@ private fun QuickActions(
onDownloadBacklog: () -> Unit,
onSeedDebugBacklog: () -> Unit,
onPushPhoneGpsNow: () -> Unit,
+ onForgetPreferredBleDevice: () -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
@@ -672,6 +719,29 @@ private fun QuickActions(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
+ if (state.preferredBleDeviceAddress.isNotBlank()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val label = state.preferredBleDeviceName.takeIf { it.isNotBlank() }
+ ?: state.preferredBleDeviceAddress
+ Text(
+ text = "Preferred: $label",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ CompactButton(
+ label = "Forget",
+ primary = false,
+ modifier = Modifier.weight(0.45f),
+ onClick = onForgetPreferredBleDevice,
+ )
+ }
+ }
// Row 1: connection + scanning
Row(
modifier = Modifier.fillMaxWidth(),
diff --git a/android/app/src/main/java/com/wifinder/android/ble/BleTargetingPolicy.kt b/android/app/src/main/java/com/wifinder/android/ble/BleTargetingPolicy.kt
new file mode 100644
index 0000000..baa5e70
--- /dev/null
+++ b/android/app/src/main/java/com/wifinder/android/ble/BleTargetingPolicy.kt
@@ -0,0 +1,71 @@
+package com.wifinder.android.ble
+
+import java.util.Locale
+
+data class BleDeviceCandidate(
+ val address: String,
+ val name: String,
+ val rssi: Int,
+ val remembered: Boolean = false,
+)
+
+object BleTargetingPolicy {
+ private const val NAME_PREFIX = "WIFINDER"
+
+ fun normalizeAddress(address: String?): String? {
+ if (address.isNullOrBlank()) {
+ return null
+ }
+ return address.trim().uppercase(Locale.US)
+ }
+
+ fun isNameCompatible(advertisedName: String?): Boolean {
+ if (advertisedName.isNullOrBlank()) {
+ return true
+ }
+ return advertisedName.trim().uppercase(Locale.US).startsWith(NAME_PREFIX)
+ }
+
+ fun isEligibleCandidate(hasServiceUuid: Boolean, advertisedName: String?): Boolean {
+ if (!hasServiceUuid) {
+ return false
+ }
+ return isNameCompatible(advertisedName)
+ }
+
+ fun sortCandidates(
+ candidates: Collection,
+ preferredAddress: String?,
+ ): List {
+ val normalizedPreferred = normalizeAddress(preferredAddress)
+ return candidates
+ .mapNotNull { candidate ->
+ val normalizedAddress = normalizeAddress(candidate.address) ?: return@mapNotNull null
+ candidate.copy(
+ address = normalizedAddress,
+ remembered = normalizedPreferred != null && normalizedAddress == normalizedPreferred,
+ )
+ }
+ .distinctBy { it.address }
+ .sortedWith(
+ compareByDescending { it.remembered }
+ .thenByDescending { it.rssi }
+ .thenBy { it.name }
+ .thenBy { it.address },
+ )
+ }
+
+ fun chooseAutoConnect(
+ candidates: List,
+ preferredAddress: String?,
+ ): BleDeviceCandidate? {
+ if (candidates.isEmpty()) {
+ return null
+ }
+ val normalizedPreferred = normalizeAddress(preferredAddress)
+ if (normalizedPreferred != null) {
+ candidates.firstOrNull { normalizeAddress(it.address) == normalizedPreferred }?.let { return it }
+ }
+ return if (candidates.size == 1) candidates.first() else null
+ }
+}
diff --git a/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt b/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt
index 63392a3..d4491dc 100644
--- a/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt
+++ b/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt
@@ -11,12 +11,14 @@ import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
+import android.os.ParcelUuid
import com.wifinder.android.model.GpsFix
import com.wifinder.android.model.WgFrame
import com.wifinder.android.model.WgProtocol
@@ -33,6 +35,10 @@ class WiFinderBleClient(
fun onFrame(frame: WgFrame)
fun onInfo(message: String)
+
+ fun onBleDeviceCandidates(candidates: List) {}
+
+ fun onBleDeviceResolved(address: String, name: String?) {}
}
companion object {
@@ -43,9 +49,9 @@ class WiFinderBleClient(
private val CONFIG_UUID: UUID = UUID.fromString("6ee2e690-d7fd-4a11-94a2-89528da43134")
private val DEVICE_INFO_UUID: UUID = UUID.fromString("6ee2e690-d7fd-4a11-94a2-89528da43135")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
- private const val TARGET_DEVICE_NAME = "WIFINDER-C6"
- private const val TARGET_DEVICE_ADDRESS = "A0:85:E3:DB:04:92"
+ private const val TARGET_NAME_HINT = "WIFINDER"
private const val SCAN_TIMEOUT_MS = 12_000L
+ private const val SCAN_SETTLE_MS = 1_200L
private const val TARGET_MTU = 517
}
@@ -62,12 +68,23 @@ class WiFinderBleClient(
private var configChar: BluetoothGattCharacteristic? = null
private var deviceInfoChar: BluetoothGattCharacteristic? = null
private var scanCallback: ScanCallback? = null
+ private var scanTimeoutRunnable: Runnable? = null
+ private var scanSettleRunnable: Runnable? = null
private var connecting = false
private var ready = false
+ private var preferredAddress: String? = null
+ private var connectedAddress: String? = null
+ private var connectedName: String? = null
+ private val discoveredCandidates = linkedMapOf()
+ private val discoveredDevices = linkedMapOf()
private val notificationQueue = ArrayDeque()
fun isConnected(): Boolean = ready && gatt != null
+ fun setPreferredDeviceAddress(address: String?) {
+ preferredAddress = BleTargetingPolicy.normalizeAddress(address)
+ }
+
fun connect() {
if (connecting || isConnected()) {
return
@@ -79,31 +96,59 @@ class WiFinderBleClient(
return
}
- val known = findKnownDevice(adapter)
+ val remembered = preferredAddress
+ val known = remembered?.let { findBondedDeviceByAddress(adapter, it) }
if (known != null) {
connectToDevice(known)
return
}
- startScan(adapter)
+ startScan(adapter, remembered)
+ }
+
+ fun connectToAddress(address: String): Boolean {
+ if (connecting || isConnected()) {
+ return false
+ }
+ val normalized = BleTargetingPolicy.normalizeAddress(address) ?: return false
+ val adapter = bluetoothManager.adapter
+ if (adapter == null || !adapter.isEnabled) {
+ listener.onInfo("Bluetooth adapter unavailable or disabled")
+ return false
+ }
+ val device = discoveredDevices[normalized] ?: runCatching {
+ adapter.getRemoteDevice(normalized)
+ }.getOrNull()
+ if (device == null) {
+ listener.onInfo("Selected device unavailable: $normalized")
+ return false
+ }
+ connectToDevice(device)
+ return true
}
@SuppressLint("MissingPermission")
fun disconnect() {
stopScan()
+ clearScanScheduling()
connecting = false
ready = false
+ connectedAddress = null
+ connectedName = null
controlChar = null
statusChar = null
sightingChar = null
configChar = null
deviceInfoChar = null
notificationQueue.clear()
+ discoveredCandidates.clear()
+ discoveredDevices.clear()
val current = gatt
gatt = null
current?.disconnect()
current?.close()
+ listener.onBleDeviceCandidates(emptyList())
listener.onConnectState(false)
}
@@ -237,28 +282,66 @@ class WiFinderBleClient(
}
@SuppressLint("MissingPermission")
- private fun startScan(adapter: BluetoothAdapter) {
+ private fun startScan(adapter: BluetoothAdapter, rememberedAddress: String?) {
val scanner = adapter.bluetoothLeScanner
if (scanner == null) {
listener.onInfo("BLE scanner unavailable")
return
}
+ stopScan()
+ clearScanScheduling()
+ discoveredCandidates.clear()
+ discoveredDevices.clear()
connecting = true
- listener.onInfo("Scanning for $TARGET_DEVICE_NAME ($TARGET_DEVICE_ADDRESS)...")
+
+ listener.onInfo(
+ if (rememberedAddress != null) {
+ "Scanning for $TARGET_NAME_HINT service (preferring $rememberedAddress)..."
+ } else {
+ "Scanning for $TARGET_NAME_HINT service..."
+ },
+ )
val settings =
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
+ val filters = listOf(
+ ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build(),
+ )
val callback =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device ?: return
- val matchesAddress = device.address.equals(TARGET_DEVICE_ADDRESS, ignoreCase = true)
- if (!matchesAddress) {
+ val normalizedAddress = BleTargetingPolicy.normalizeAddress(device.address) ?: return
+ val scanRecord = result.scanRecord
+ val hasServiceUuid = scanRecord?.serviceUuids?.any { it.uuid == SERVICE_UUID } ?: true
+ val advertisedName = scanRecord?.deviceName ?: device.name
+ if (!BleTargetingPolicy.isEligibleCandidate(hasServiceUuid, advertisedName)) {
return
}
- connectToDevice(device)
+
+ val previous = discoveredCandidates[normalizedAddress]
+ val resolvedName = when {
+ !advertisedName.isNullOrBlank() -> advertisedName
+ previous != null && previous.name.isNotBlank() -> previous.name
+ else -> normalizedAddress
+ }
+ val strongestRssi = maxOf(result.rssi, previous?.rssi ?: Int.MIN_VALUE)
+ discoveredDevices[normalizedAddress] = device
+ discoveredCandidates[normalizedAddress] =
+ BleDeviceCandidate(
+ address = normalizedAddress,
+ name = resolvedName,
+ rssi = strongestRssi,
+ )
+
+ if (rememberedAddress != null && normalizedAddress == rememberedAddress) {
+ connectToDevice(device)
+ return
+ }
+
+ scheduleScanSettle()
}
override fun onScanFailed(errorCode: Int) {
@@ -269,18 +352,61 @@ class WiFinderBleClient(
}
scanCallback = callback
- scanner.startScan(emptyList(), settings, callback)
+ scanner.startScan(filters, settings, callback)
- mainHandler.postDelayed(
- {
- if (scanCallback != null) {
- listener.onInfo("BLE scan timeout")
- stopScan()
- connecting = false
- }
+ val timeout = Runnable {
+ finalizeScanDecision()
+ }
+ scanTimeoutRunnable = timeout
+ mainHandler.postDelayed(timeout, SCAN_TIMEOUT_MS)
+ }
+
+ private fun scheduleScanSettle() {
+ val existing = scanSettleRunnable
+ if (existing != null) {
+ mainHandler.removeCallbacks(existing)
+ }
+ val settle = Runnable {
+ finalizeScanDecision()
+ }
+ scanSettleRunnable = settle
+ mainHandler.postDelayed(settle, SCAN_SETTLE_MS)
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun finalizeScanDecision() {
+ if (scanCallback == null) {
+ return
+ }
+ stopScan()
+ connecting = false
+
+ val candidates = BleTargetingPolicy.sortCandidates(
+ candidates = discoveredCandidates.values,
+ preferredAddress = preferredAddress,
+ )
+ if (candidates.isEmpty()) {
+ listener.onInfo("BLE scan timeout")
+ return
+ }
+
+ val auto = BleTargetingPolicy.chooseAutoConnect(candidates, preferredAddress)
+ if (auto != null) {
+ val device = discoveredDevices[auto.address]
+ if (device != null) {
+ connectToDevice(device)
+ return
+ }
+ }
+
+ listener.onInfo(
+ if (preferredAddress != null) {
+ "Remembered device unavailable; select a WiFinder device"
+ } else {
+ "Multiple WiFinder devices found; select one"
},
- SCAN_TIMEOUT_MS,
)
+ listener.onBleDeviceCandidates(candidates)
}
@SuppressLint("MissingPermission")
@@ -290,22 +416,36 @@ class WiFinderBleClient(
val callback = scanCallback ?: return
scanner.stopScan(callback)
scanCallback = null
+ clearScanScheduling()
}
- private fun findKnownDevice(adapter: BluetoothAdapter): BluetoothDevice? {
+ private fun clearScanScheduling() {
+ scanTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
+ scanSettleRunnable?.let { mainHandler.removeCallbacks(it) }
+ scanTimeoutRunnable = null
+ scanSettleRunnable = null
+ }
+
+ private fun findBondedDeviceByAddress(adapter: BluetoothAdapter, address: String): BluetoothDevice? {
val bonded = adapter.bondedDevices ?: return null
return bonded.firstOrNull { device ->
- device.address.equals(TARGET_DEVICE_ADDRESS, ignoreCase = true)
+ BleTargetingPolicy.normalizeAddress(device.address) == address &&
+ BleTargetingPolicy.isNameCompatible(device.name)
}
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: BluetoothDevice) {
- if (gatt != null || connecting && scanCallback == null) {
+ if (gatt != null || isConnected()) {
return
}
stopScan()
- listener.onInfo("Connecting to ${device.name ?: device.address}...")
+ clearScanScheduling()
+ val normalizedAddress = BleTargetingPolicy.normalizeAddress(device.address) ?: device.address
+ connectedAddress = normalizedAddress
+ connectedName = device.name
+ listener.onBleDeviceCandidates(emptyList())
+ listener.onInfo("Connecting to ${device.name ?: normalizedAddress}...")
connecting = true
gatt?.close()
@@ -319,20 +459,6 @@ class WiFinderBleClient(
}
}
- @SuppressLint("MissingPermission")
- private fun writeCharacteristicNoResponse(
- targetGatt: BluetoothGatt,
- characteristic: BluetoothGattCharacteristic,
- bytes: ByteArray,
- ): Boolean {
- return writeCharacteristic(
- targetGatt = targetGatt,
- characteristic = characteristic,
- bytes = bytes,
- writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE,
- )
- }
-
@SuppressLint("MissingPermission")
private fun writeCharacteristic(
targetGatt: BluetoothGatt,
@@ -361,6 +487,10 @@ class WiFinderBleClient(
ready = true
connecting = false
listener.onConnectState(true)
+ val address = connectedAddress ?: BleTargetingPolicy.normalizeAddress(targetGatt.device.address)
+ if (address != null) {
+ listener.onBleDeviceResolved(address, connectedName ?: targetGatt.device.name)
+ }
listener.onInfo("BLE ready")
return
}
diff --git a/android/app/src/main/java/com/wifinder/android/service/BacklogTransferCoordinator.kt b/android/app/src/main/java/com/wifinder/android/service/BacklogTransferCoordinator.kt
new file mode 100644
index 0000000..0b2e3ca
--- /dev/null
+++ b/android/app/src/main/java/com/wifinder/android/service/BacklogTransferCoordinator.kt
@@ -0,0 +1,209 @@
+package com.wifinder.android.service
+
+enum class BacklogTransferStage {
+ IDLE,
+ WIFI_ACTIVE,
+ BLE_FALLBACK_ACTIVE,
+ COMPLETE,
+ FAILED,
+}
+
+data class BacklogTransferSnapshot(
+ val stage: BacklogTransferStage,
+ val downloadActive: Boolean,
+ val blobActive: Boolean,
+ val blobSessionId: Long,
+ val blobBytesSent: Long,
+ val blobBytesTotal: Long,
+ val failureReason: String?,
+)
+
+class BacklogTransferCoordinator(
+ private val onSnapshot: (BacklogTransferSnapshot) -> Unit,
+ private val onLog: (String) -> Unit,
+) {
+ private var stage: BacklogTransferStage = BacklogTransferStage.IDLE
+ private var fallbackAttempted: Boolean = false
+ private var blobSessionId: Long = 0L
+ private var blobBytesSent: Long = 0L
+ private var blobBytesTotal: Long = 0L
+ private var failureReason: String? = null
+
+ init {
+ emitSnapshot()
+ }
+
+ @Synchronized
+ fun currentStage(): BacklogTransferStage = stage
+
+ @Synchronized
+ fun isActive(): Boolean =
+ stage == BacklogTransferStage.WIFI_ACTIVE || stage == BacklogTransferStage.BLE_FALLBACK_ACTIVE
+
+ @Synchronized
+ fun isWifiActive(): Boolean = stage == BacklogTransferStage.WIFI_ACTIVE
+
+ @Synchronized
+ fun isBleFallbackActive(): Boolean = stage == BacklogTransferStage.BLE_FALLBACK_ACTIVE
+
+ @Synchronized
+ fun startWifi(): Boolean {
+ if (isActive()) {
+ return false
+ }
+ stage = BacklogTransferStage.WIFI_ACTIVE
+ fallbackAttempted = false
+ failureReason = null
+ blobSessionId = 0L
+ blobBytesSent = 0L
+ blobBytesTotal = 0L
+ onLog("Backlog download started (Wi-Fi primary)")
+ emitSnapshot()
+ return true
+ }
+
+ @Synchronized
+ fun failWifiAndRequestBleFallback(reason: String): Boolean {
+ if (stage != BacklogTransferStage.WIFI_ACTIVE) {
+ return false
+ }
+ onLog("Backlog Wi-Fi failed: $reason")
+ if (fallbackAttempted) {
+ stage = BacklogTransferStage.FAILED
+ failureReason = reason
+ emitSnapshot()
+ onLog("Backlog download failed")
+ return false
+ }
+ fallbackAttempted = true
+ stage = BacklogTransferStage.BLE_FALLBACK_ACTIVE
+ blobSessionId = 0L
+ blobBytesSent = 0L
+ blobBytesTotal = 0L
+ failureReason = null
+ emitSnapshot()
+ onLog("Switching to BLE fallback")
+ return true
+ }
+
+ @Synchronized
+ fun noteBleSessionMeta(sessionId: Long, totalBytes: Long) {
+ if (stage != BacklogTransferStage.BLE_FALLBACK_ACTIVE &&
+ stage != BacklogTransferStage.WIFI_ACTIVE) {
+ return
+ }
+ blobSessionId = sessionId
+ blobBytesSent = 0L
+ blobBytesTotal = totalBytes.coerceAtLeast(0L)
+ emitSnapshot()
+ }
+
+ @Synchronized
+ fun noteBleSessionProgress(sessionId: Long, bytesReceived: Long, totalBytes: Long) {
+ if (stage != BacklogTransferStage.BLE_FALLBACK_ACTIVE &&
+ stage != BacklogTransferStage.WIFI_ACTIVE) {
+ return
+ }
+ blobSessionId = sessionId
+ blobBytesSent = bytesReceived.coerceAtLeast(0L)
+ blobBytesTotal = totalBytes.coerceAtLeast(0L)
+ emitSnapshot()
+ }
+
+ @Synchronized
+ fun completeWifi(message: String = "Backlog Wi-Fi download complete") {
+ if (stage != BacklogTransferStage.WIFI_ACTIVE) {
+ return
+ }
+ stage = BacklogTransferStage.COMPLETE
+ failureReason = null
+ blobSessionId = 0L
+ blobBytesSent = 0L
+ blobBytesTotal = 0L
+ emitSnapshot()
+ onLog(message)
+ }
+
+ @Synchronized
+ fun completeBleFallback(message: String = "Backlog BLE fallback complete") {
+ if (stage != BacklogTransferStage.BLE_FALLBACK_ACTIVE) {
+ return
+ }
+ stage = BacklogTransferStage.COMPLETE
+ failureReason = null
+ blobSessionId = 0L
+ blobBytesSent = 0L
+ blobBytesTotal = 0L
+ emitSnapshot()
+ onLog(message)
+ }
+
+ @Synchronized
+ fun failBleFallback(reason: String) {
+ if (stage != BacklogTransferStage.BLE_FALLBACK_ACTIVE) {
+ return
+ }
+ stage = BacklogTransferStage.FAILED
+ failureReason = reason
+ blobSessionId = 0L
+ blobBytesSent = 0L
+ blobBytesTotal = 0L
+ emitSnapshot()
+ onLog("Backlog fallback failed: $reason")
+ }
+
+ @Synchronized
+ fun failTransfer(reason: String, logMessage: String = "Backlog download failed: $reason") {
+ if (stage == BacklogTransferStage.IDLE) {
+ return
+ }
+ stage = BacklogTransferStage.FAILED
+ failureReason = reason
+ blobSessionId = 0L
+ blobBytesSent = 0L
+ blobBytesTotal = 0L
+ emitSnapshot()
+ onLog(logMessage)
+ }
+
+ @Synchronized
+ fun cancel(message: String) {
+ if (stage == BacklogTransferStage.IDLE) {
+ return
+ }
+ stage = BacklogTransferStage.IDLE
+ failureReason = null
+ blobSessionId = 0L
+ blobBytesSent = 0L
+ blobBytesTotal = 0L
+ emitSnapshot()
+ onLog(message)
+ }
+
+ @Synchronized
+ fun resetIdle() {
+ stage = BacklogTransferStage.IDLE
+ fallbackAttempted = false
+ failureReason = null
+ blobSessionId = 0L
+ blobBytesSent = 0L
+ blobBytesTotal = 0L
+ emitSnapshot()
+ }
+
+ private fun emitSnapshot() {
+ onSnapshot(
+ BacklogTransferSnapshot(
+ stage = stage,
+ downloadActive = stage == BacklogTransferStage.WIFI_ACTIVE ||
+ stage == BacklogTransferStage.BLE_FALLBACK_ACTIVE,
+ blobActive = stage == BacklogTransferStage.WIFI_ACTIVE ||
+ stage == BacklogTransferStage.BLE_FALLBACK_ACTIVE,
+ blobSessionId = blobSessionId,
+ blobBytesSent = blobBytesSent,
+ blobBytesTotal = blobBytesTotal,
+ failureReason = failureReason,
+ ),
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt b/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
index 9f4e0c9..a592627 100644
--- a/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
+++ b/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt
@@ -17,6 +17,7 @@ import android.net.wifi.WifiNetworkSpecifier
import android.os.Binder
import android.os.Build
import android.os.IBinder
+import com.wifinder.android.ble.BleDeviceCandidate
import com.wifinder.android.ble.WiFinderBleClient
import com.wifinder.android.logging.GpxTrackLogger
import com.wifinder.android.gps.GpsTracker
@@ -139,10 +140,21 @@ data class ServiceState(
val gpxPointCount: Long = 0L,
val loggingEnabled: Boolean = false,
val csvPath: String = "",
+ val preferredBleDeviceAddress: String = "",
+ val preferredBleDeviceName: String = "",
+ val bleDevicePickerVisible: Boolean = false,
+ val bleDeviceCandidates: List = emptyList(),
val sightings: List = emptyList(),
val logs: List = emptyList(),
)
+data class ServiceBleDeviceCandidate(
+ val address: String,
+ val name: String,
+ val rssi: Int,
+ val remembered: Boolean,
+)
+
class ScannerService : Service(), WiFinderBleClient.Listener {
companion object {
@@ -168,6 +180,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
private const val PREF_AUTO_HOP_BASE_MS = "auto_hop_base_ms"
private const val PREF_GPS_NAV_MODE = "gps_nav_mode"
private const val PREF_GPX_ENABLED = "gpx_enabled"
+ private const val PREF_PREFERRED_BLE_DEVICE_ADDRESS = "preferred_ble_device_address"
+ private const val PREF_PREFERRED_BLE_DEVICE_NAME = "preferred_ble_device_name"
private const val GPX_MIN_ELAPSED_MS = 1000L
private const val GPX_MIN_DISTANCE_METERS = 5.0
private const val BLOB_BLOCK_SIZE = 1024
@@ -258,6 +272,10 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(ServiceState())
+ private val backlogTransferCoordinator = BacklogTransferCoordinator(
+ onSnapshot = ::applyBacklogTransferSnapshot,
+ onLog = ::appendLog,
+ )
val state: StateFlow = _state.asStateFlow()
private val sightings = linkedMapOf()
@@ -265,7 +283,6 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
private val lastCsvWriteByBssid = hashMapOf()
private val sightingsMutex = Mutex()
private val ackLock = Any()
- private val backlogRecoveryLock = Any()
private val sessionAcks = hashMapOf()
private val logTime = SimpleDateFormat("HH:mm:ss", Locale.US)
@@ -294,12 +311,12 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
@Volatile private var downloadBacklogAutoStop: Boolean = false
@Volatile private var downloadBacklogStartMs: Long = 0L
@Volatile private var visibleTimeoutSec: Int = 25
- @Volatile private var backlogBlobRecoveryInFlight: Boolean = false
@Volatile private var backlogBlobRx: BacklogBlobReceiver? = null
@Volatile private var blobProgressUiLastMs: Long = 0L
@Volatile private var blobProgressUiLastBytes: Long = 0L
- @Volatile private var wifiDumpInProgress: Boolean = false
@Volatile private var wifiDumpJob: Job? = null
+ @Volatile private var preferredBleDeviceAddress: String? = null
+ @Volatile private var preferredBleDeviceName: String = ""
override fun onCreate() {
super.onCreate()
@@ -376,7 +393,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
scope.cancel()
wifiDumpJob?.cancel()
wifiDumpJob = null
- wifiDumpInProgress = false
+ backlogTransferCoordinator.resetIdle()
try { bleClient.sendGpsClear() } catch (_: Exception) {}
try { bleClient.setWifiDumpEnabled(false) } catch (_: Exception) {}
stopLiveGpxSession(reason = null)
@@ -392,6 +409,9 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
val autoHopEnabled = prefs.getBoolean(PREF_AUTO_HOP_ENABLED, false)
gpsNavMode = normalizeGpsNavMode(prefs.getInt(PREF_GPS_NAV_MODE, 0))
gpxEnabled = prefs.getBoolean(PREF_GPX_ENABLED, true)
+ preferredBleDeviceAddress = prefs.getString(PREF_PREFERRED_BLE_DEVICE_ADDRESS, null)
+ preferredBleDeviceName = prefs.getString(PREF_PREFERRED_BLE_DEVICE_NAME, "") ?: ""
+ bleClient.setPreferredDeviceAddress(preferredBleDeviceAddress)
_state.update {
it.copy(
visibleTimeoutSec = visibleTimeoutSec,
@@ -400,6 +420,40 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
autoHopAppliedMs = autoHopBase,
gpsNavMode = gpsNavMode,
gpxEnabled = gpxEnabled,
+ preferredBleDeviceAddress = preferredBleDeviceAddress ?: "",
+ preferredBleDeviceName = preferredBleDeviceName,
+ )
+ }
+ }
+
+ private fun persistPreferredBleDevice(address: String, name: String?) {
+ preferredBleDeviceAddress = address
+ preferredBleDeviceName = name?.trim().orEmpty()
+ prefs.edit()
+ .putString(PREF_PREFERRED_BLE_DEVICE_ADDRESS, preferredBleDeviceAddress)
+ .putString(PREF_PREFERRED_BLE_DEVICE_NAME, preferredBleDeviceName)
+ .apply()
+ bleClient.setPreferredDeviceAddress(preferredBleDeviceAddress)
+ _state.update {
+ it.copy(
+ preferredBleDeviceAddress = preferredBleDeviceAddress ?: "",
+ preferredBleDeviceName = preferredBleDeviceName,
+ )
+ }
+ }
+
+ private fun clearPreferredBleDevice() {
+ preferredBleDeviceAddress = null
+ preferredBleDeviceName = ""
+ prefs.edit()
+ .remove(PREF_PREFERRED_BLE_DEVICE_ADDRESS)
+ .remove(PREF_PREFERRED_BLE_DEVICE_NAME)
+ .apply()
+ bleClient.setPreferredDeviceAddress(null)
+ _state.update {
+ it.copy(
+ preferredBleDeviceAddress = "",
+ preferredBleDeviceName = "",
)
}
}
@@ -423,16 +477,29 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
prefs.edit().putBoolean(PREF_GPX_ENABLED, enabled).apply()
}
+ private fun applyBacklogTransferSnapshot(snapshot: BacklogTransferSnapshot) {
+ _state.update {
+ it.copy(
+ downloadBacklogActive = snapshot.downloadActive,
+ blobActive = snapshot.blobActive,
+ blobSessionId = snapshot.blobSessionId,
+ blobBytesSent = snapshot.blobBytesSent,
+ blobBytesTotal = snapshot.blobBytesTotal,
+ )
+ }
+ }
+
// ── Public API (called by ViewModel via binder) ─────────────
fun doConnect() {
+ _state.update { it.copy(bleDevicePickerVisible = false, bleDeviceCandidates = emptyList()) }
bleClient.connect()
}
fun doDisconnect() {
wifiDumpJob?.cancel()
wifiDumpJob = null
- wifiDumpInProgress = false
+ backlogTransferCoordinator.resetIdle()
phoneGpsPushActive = false
stopLiveGpxSession(reason = null)
if (controlLinkReadySilent()) {
@@ -442,9 +509,6 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
val shouldAutoStopCsv = downloadBacklogAutoStop
downloadBacklogAutoStop = false
downloadBacklogStartMs = 0L
- synchronized(backlogRecoveryLock) {
- backlogBlobRecoveryInFlight = false
- }
bleClient.sendGpsClear()
resetBacklogBlobReceiver(discardFile = true)
bleClient.disconnect()
@@ -455,7 +519,43 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
if (shouldAutoStopCsv && csvLogger.isActive) {
stopLogging()
}
- _state.update { it.copy(phoneGpsPushActive = false, downloadBacklogActive = false) }
+ _state.update {
+ it.copy(
+ phoneGpsPushActive = false,
+ downloadBacklogActive = false,
+ blobActive = false,
+ blobSessionId = 0L,
+ blobBytesSent = 0L,
+ blobBytesTotal = 0L,
+ bleDevicePickerVisible = false,
+ bleDeviceCandidates = emptyList(),
+ )
+ }
+ }
+
+ fun selectBleDevice(address: String): Boolean {
+ val normalized = address.trim().uppercase(Locale.US)
+ val selected = _state.value.bleDeviceCandidates.firstOrNull { it.address == normalized } ?: run {
+ appendLog("BLE picker selection failed: device missing")
+ return false
+ }
+ persistPreferredBleDevice(normalized, selected.name)
+ _state.update { it.copy(bleDevicePickerVisible = false, bleDeviceCandidates = emptyList()) }
+ val started = bleClient.connectToAddress(normalized)
+ if (!started) {
+ appendLog("BLE picker selection failed: connect attempt did not start")
+ }
+ return started
+ }
+
+ fun dismissBleDevicePicker() {
+ _state.update { it.copy(bleDevicePickerVisible = false) }
+ }
+
+ fun forgetPreferredBleDevice() {
+ clearPreferredBleDevice()
+ _state.update { it.copy(bleDevicePickerVisible = false, bleDeviceCandidates = emptyList()) }
+ appendLog("Forgot preferred BLE device")
}
fun disconnectAndStop() {
@@ -592,25 +692,15 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
if (_state.value.downloadBacklogActive) {
wifiDumpJob?.cancel()
wifiDumpJob = null
- wifiDumpInProgress = false
val okWifiDump = bleClient.setWifiDumpEnabled(false)
val okBlob = bleClient.setBacklogBlobEnabled(false)
if (!okWifiDump && !okBlob) {
appendLog("Backlog stop command failed")
return false
}
+ backlogTransferCoordinator.cancel("Backlog download stopped")
downloadBacklogStartMs = 0L
resetBacklogBlobReceiver(discardFile = true)
- _state.update {
- it.copy(
- downloadBacklogActive = false,
- blobActive = false,
- blobSessionId = 0L,
- blobBytesSent = 0L,
- blobBytesTotal = 0L,
- )
- }
- appendLog("Backlog download stopped")
val shouldAutoStopCsv = downloadBacklogAutoStop
downloadBacklogAutoStop = false
if (shouldAutoStopCsv && csvLogger.isActive) {
@@ -625,19 +715,14 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
synchronized(ackLock) {
sessionAcks.clear()
}
+ if (!backlogTransferCoordinator.startWifi()) {
+ appendLog("Backlog download already active")
+ return false
+ }
val okWifiDump = bleClient.setWifiDumpEnabled(true)
if (!okWifiDump) {
+ backlogTransferCoordinator.failTransfer("wifi enable failed", "Backlog download request failed")
downloadBacklogStartMs = 0L
- _state.update {
- it.copy(
- downloadBacklogActive = false,
- blobActive = false,
- blobSessionId = 0L,
- blobBytesSent = 0L,
- blobBytesTotal = 0L,
- )
- }
- appendLog("Backlog download request failed")
if (downloadBacklogAutoStop && csvLogger.isActive) {
stopLogging()
}
@@ -646,17 +731,6 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
}
downloadBacklogStartMs = System.currentTimeMillis()
- wifiDumpInProgress = true
- _state.update {
- it.copy(
- downloadBacklogActive = true,
- blobActive = true,
- blobSessionId = 0L,
- blobBytesSent = 0L,
- blobBytesTotal = 0L,
- )
- }
- appendLog("Backlog download started (Wi-Fi dump)")
wifiDumpJob?.cancel()
wifiDumpJob = scope.launch {
runWifiDumpTransfer()
@@ -849,7 +923,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
downloadBacklogAutoStop = false
downloadBacklogStartMs = 0L
csvLogger.stopSession()
- _state.update { it.copy(loggingEnabled = false, downloadBacklogActive = false) }
+ _state.update { it.copy(loggingEnabled = false) }
appendLog("CSV logging stopped")
}
@@ -859,7 +933,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
if (!connected) {
wifiDumpJob?.cancel()
wifiDumpJob = null
- wifiDumpInProgress = false
+ backlogTransferCoordinator.resetIdle()
lastEspGps = null
lastEspFixMs = -1L
stopLiveGpxSession(reason = null)
@@ -872,9 +946,6 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
val shouldAutoStopCsv = downloadBacklogAutoStop
downloadBacklogAutoStop = false
downloadBacklogStartMs = 0L
- synchronized(backlogRecoveryLock) {
- backlogBlobRecoveryInFlight = false
- }
if (shouldAutoStopCsv && csvLogger.isActive) {
stopLogging()
}
@@ -927,6 +998,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
gpxLogging = if (!connected) false else current.gpxLogging,
gpxPath = current.gpxPath,
gpxPointCount = current.gpxPointCount,
+ bleDevicePickerVisible = if (!connected) false else current.bleDevicePickerVisible,
+ bleDeviceCandidates = if (!connected) emptyList() else current.bleDeviceCandidates,
)
}
appendLog(if (connected) "Connected" else "Disconnected")
@@ -955,11 +1028,38 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
appendLog(message)
}
+ override fun onBleDeviceCandidates(candidates: List) {
+ val mapped = candidates.map {
+ ServiceBleDeviceCandidate(
+ address = it.address,
+ name = it.name,
+ rssi = it.rssi,
+ remembered = it.remembered,
+ )
+ }
+ _state.update {
+ it.copy(
+ bleDevicePickerVisible = mapped.isNotEmpty(),
+ bleDeviceCandidates = mapped,
+ )
+ }
+ if (mapped.isNotEmpty()) {
+ appendLog("BLE picker: ${mapped.size} eligible devices")
+ }
+ }
+
+ override fun onBleDeviceResolved(address: String, name: String?) {
+ persistPreferredBleDevice(address, name)
+ _state.update { it.copy(bleDevicePickerVisible = false, bleDeviceCandidates = emptyList()) }
+ appendLog("BLE target set: ${name?.takeIf { n -> n.isNotBlank() } ?: address}")
+ }
+
// ── Internal ───────────────────────────────────────────────
private fun handleStatusFrame(payload: ByteArray) {
val wasEncrypted = _state.value.bleEncrypted
val wasReplayActive = _state.value.replayActive
+ val transferOverlayActive = backlogTransferCoordinator.isActive()
val status: StatusPayload = try {
WgProtocol.decodeStatusPayload(payload)
} catch (e: Exception) {
@@ -1006,10 +1106,10 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
spiffsUsedBytes = status.spiffsUsedBytes,
spiffsFreeBytes = status.spiffsFreeBytes,
dieTempCenti = status.dieTempCenti,
- blobActive = if (wifiDumpInProgress) current.blobActive else status.blobActive,
- blobSessionId = if (wifiDumpInProgress) current.blobSessionId else status.blobSessionId,
- blobBytesSent = if (wifiDumpInProgress) current.blobBytesSent else status.blobBytesSent,
- blobBytesTotal = if (wifiDumpInProgress) current.blobBytesTotal else status.blobBytesTotal,
+ blobActive = if (transferOverlayActive) current.blobActive else status.blobActive,
+ blobSessionId = if (transferOverlayActive) current.blobSessionId else status.blobSessionId,
+ blobBytesSent = if (transferOverlayActive) current.blobBytesSent else status.blobBytesSent,
+ blobBytesTotal = if (transferOverlayActive) current.blobBytesTotal else status.blobBytesTotal,
)
}
@@ -1019,31 +1119,18 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
val withinStartGrace =
downloadBacklogStartMs > 0L && (nowMs - downloadBacklogStartMs) < 2500L
if (_state.value.downloadBacklogActive && status.scanning && !withinStartGrace) {
- downloadBacklogStartMs = 0L
- _state.update { it.copy(downloadBacklogActive = false) }
- appendLog("Backlog download stopped: scanner is active")
- val shouldAutoStopCsv = downloadBacklogAutoStop
- downloadBacklogAutoStop = false
- if (shouldAutoStopCsv && csvLogger.isActive) {
- stopLogging()
- }
+ backlogTransferCoordinator.failTransfer(
+ reason = "scanner active",
+ logMessage = "Backlog download failed: scanner is active",
+ )
+ finalizeBacklogTransferAfterTerminal()
} else if (_state.value.downloadBacklogActive &&
- !wifiDumpInProgress &&
+ backlogTransferCoordinator.currentStage() == BacklogTransferStage.BLE_FALLBACK_ACTIVE &&
status.queuedRecords == 0L &&
!status.replayActive &&
!status.blobActive) {
- _state.update { it.copy(downloadBacklogActive = false) }
- if (controlLinkReadySilent()) {
- bleClient.setBacklogBlobEnabled(false)
- }
- downloadBacklogStartMs = 0L
- resetBacklogBlobReceiver(discardFile = true)
- appendLog("Backlog download complete")
- val shouldAutoStopCsv = downloadBacklogAutoStop
- downloadBacklogAutoStop = false
- if (shouldAutoStopCsv && csvLogger.isActive) {
- stopLogging()
- }
+ backlogTransferCoordinator.completeBleFallback("Backlog BLE fallback complete")
+ finalizeBacklogTransferAfterTerminal()
}
if (!status.bleEncrypted) return
@@ -1232,14 +1319,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
appendLog(
"Backlog blob session 0x${java.lang.Long.toUnsignedString(meta.sessionId, 16)} size=${meta.totalBytes}B acked=${meta.ackedSeq} written=${meta.writtenSeq}",
)
- _state.update {
- it.copy(
- blobActive = true,
- blobSessionId = meta.sessionId,
- blobBytesSent = 0L,
- blobBytesTotal = meta.totalBytes,
- )
- }
+ backlogTransferCoordinator.noteBleSessionMeta(meta.sessionId, meta.totalBytes)
}
private fun handleBacklogBlobChunkFrame(payload: ByteArray) {
@@ -1255,10 +1335,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
return
}
- var uiUpdateSessionId = 0L
- var uiUpdateBytes = 0L
- var uiUpdateTotal = 0L
- var shouldUpdateUi = false
+ var progressBytes = -1L
+ var progressTotal = 0L
var replyOffset = offset
var replyLen = chunkLen
var replyAccept = false
@@ -1292,7 +1370,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
appendLog("Backlog chunk write failed: ${e.message}")
sendBacklogBlobChunkReply(sessionId, rx.receivedBytes, 0, accepted = false)
resetBacklogBlobReceiver(discardFile = true)
- recoverBacklogBlobStream("chunk write failed")
+ backlogTransferCoordinator.failBleFallback("chunk write failed")
+ finalizeBacklogTransferAfterTerminal()
return
}
rx.receivedBytes += chunkLen.toLong()
@@ -1308,10 +1387,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
if (dueToTime || dueToBytes || done) {
blobProgressUiLastMs = nowMs
blobProgressUiLastBytes = rx.receivedBytes
- shouldUpdateUi = true
- uiUpdateSessionId = rx.sessionId
- uiUpdateBytes = rx.receivedBytes
- uiUpdateTotal = rx.totalBytes
+ progressBytes = rx.receivedBytes
+ progressTotal = rx.totalBytes
}
}
}
@@ -1323,15 +1400,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
accepted = replyAccept,
)
- if (shouldUpdateUi) {
- _state.update {
- it.copy(
- blobActive = true,
- blobSessionId = uiUpdateSessionId,
- blobBytesSent = uiUpdateBytes,
- blobBytesTotal = uiUpdateTotal,
- )
- }
+ if (progressBytes >= 0L) {
+ backlogTransferCoordinator.noteBleSessionProgress(sessionId, progressBytes, progressTotal)
}
}
@@ -1419,14 +1489,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
appendLog(
"Backlog blob received session 0x${java.lang.Long.toUnsignedString(done.sessionId, 16)} (${done.totalBytes}B), importing...",
)
- _state.update {
- it.copy(
- blobActive = false,
- blobSessionId = done.sessionId,
- blobBytesSent = done.totalBytes,
- blobBytesTotal = done.totalBytes,
- )
- }
+ backlogTransferCoordinator.noteBleSessionProgress(done.sessionId, done.totalBytes, done.totalBytes)
importBacklogBlobSessionAsync(
file = completed.file,
sessionId = done.sessionId,
@@ -1452,11 +1515,13 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
)
if (importComplete) {
queueBlobSessionAck(sessionId, targetSeq, force = true)
- } else {
- appendLog(
- "Backlog import incomplete sid=0x${java.lang.Long.toUnsignedString(sessionId, 16)}; withholding ACK and retrying",
+ backlogTransferCoordinator.completeBleFallback(
+ "Backlog BLE fallback complete sid=0x${java.lang.Long.toUnsignedString(sessionId, 16)}",
)
- recoverBacklogBlobStream("import integrity check failed")
+ finalizeBacklogTransferAfterTerminal()
+ } else {
+ backlogTransferCoordinator.failBleFallback("import integrity check failed")
+ finalizeBacklogTransferAfterTerminal()
}
try {
file.delete()
@@ -1480,53 +1545,16 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
}
}
- private fun recoverBacklogBlobStream(reason: String) {
- if (!_state.value.downloadBacklogActive) {
- return
- }
- if (!controlLinkReadySilent()) {
- appendLog("Backlog recovery skipped: control link unavailable")
- return
- }
- synchronized(backlogRecoveryLock) {
- if (backlogBlobRecoveryInFlight) {
- return
- }
- backlogBlobRecoveryInFlight = true
- }
- appendLog("Backlog blob desync ($reason); restarting stream")
- scope.launch {
- try {
- val disableOk = bleClient.setBacklogBlobEnabled(false)
- if (!disableOk) {
- appendLog("Backlog recovery failed: disable command failed")
- return@launch
- }
- delay(120)
- val enableOk = bleClient.setBacklogBlobEnabled(true)
- if (!enableOk) {
- appendLog("Backlog recovery failed: enable command failed")
- return@launch
- }
- delay(80)
- bleClient.requestStatus()
- } finally {
- synchronized(backlogRecoveryLock) {
- backlogBlobRecoveryInFlight = false
- }
- }
- }
- }
-
private suspend fun runWifiDumpTransfer() {
- var success = false
+ var wifiCommitted = false
var cancelled = false
+ var failureReason: String? = null
var runId: Long = 0L
try {
val transferOk = withWifiDumpNetwork { network ->
val manifest = fetchWifiDumpManifest(network)
if (manifest == null) {
- appendLog("Backlog download failed: Wi-Fi dump manifest unavailable")
+ failureReason = "manifest unavailable"
return@withWifiDumpNetwork false
}
runId = manifest.runId
@@ -1554,14 +1582,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
)
val datFile = File(dumpDir, "session-$sidHex.dat")
- _state.update {
- it.copy(
- blobActive = true,
- blobSessionId = session.sessionId,
- blobBytesSent = 0L,
- blobBytesTotal = session.datBytes,
- )
- }
+ backlogTransferCoordinator.noteBleSessionMeta(session.sessionId, session.datBytes)
val datOk = downloadWifiDumpFile(
network = network,
@@ -1570,17 +1591,14 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
expectedBytes = session.datBytes,
outFile = datFile,
) { downloaded ->
- _state.update { state ->
- state.copy(
- blobActive = true,
- blobSessionId = session.sessionId,
- blobBytesSent = downloaded.coerceAtLeast(0L),
- blobBytesTotal = session.datBytes,
- )
- }
+ backlogTransferCoordinator.noteBleSessionProgress(
+ sessionId = session.sessionId,
+ bytesReceived = downloaded.coerceAtLeast(0L),
+ totalBytes = session.datBytes,
+ )
}
if (!datOk) {
- appendLog("Backlog Wi-Fi session failed sid=0x$sidHex (dat download)")
+ failureReason = "session sid=0x$sidHex dat download failed"
return@withWifiDumpNetwork false
}
@@ -1595,7 +1613,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
"Backlog Wi-Fi import sid=0x$sidHex records=${result.records} highest_seq=${result.highestSeq} bad_blocks=${result.badBlocks} gap=${result.gapDetected}",
)
if (!importComplete) {
- appendLog("Backlog Wi-Fi import failed integrity check sid=0x$sidHex")
+ failureReason = "session sid=0x$sidHex integrity check failed"
return@withWifiDumpNetwork false
}
try {
@@ -1612,7 +1630,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
outFile = gpxFile,
)
if (!gpxOk) {
- appendLog("Backlog Wi-Fi session sid=0x$sidHex gpx download failed")
+ failureReason = "session sid=0x$sidHex gpx download failed"
return@withWifiDumpNetwork false
}
try {
@@ -1623,58 +1641,85 @@ class ScannerService : Service(), WiFinderBleClient.Listener {
true
} ?: false
if (!transferOk) {
+ if (failureReason == null) {
+ failureReason = if (runId != 0L) {
+ "run=0x${toHex64(runId)} transfer failed"
+ } else {
+ "Wi-Fi transfer failed"
+ }
+ }
return
}
if (!bleClient.commitWifiDump(runId)) {
- appendLog("Backlog commit failed (run=0x${toHex64(runId)})")
+ failureReason = "commit failed for run=0x${toHex64(runId)}"
return
}
bleClient.requestStatus()
- success = true
- appendLog("Backlog Wi-Fi download complete")
+ backlogTransferCoordinator.completeWifi()
+ finalizeBacklogTransferAfterTerminal()
+ wifiCommitted = true
} catch (e: kotlinx.coroutines.CancellationException) {
cancelled = true
} catch (e: Exception) {
- appendLog("Backlog Wi-Fi transfer error: ${e.message}")
+ failureReason = "transfer error: ${e.message ?: e.javaClass.simpleName}"
} finally {
- wifiDumpInProgress = false
wifiDumpJob = null
downloadBacklogStartMs = 0L
- resetBacklogBlobReceiver(discardFile = true)
- if (controlLinkReadySilent()) {
- bleClient.setWifiDumpEnabled(false)
- bleClient.setBacklogBlobEnabled(false)
- bleClient.requestStatus()
- }
- _state.update {
- it.copy(
- downloadBacklogActive = false,
- blobActive = false,
- blobSessionId = 0L,
- blobBytesSent = 0L,
- blobBytesTotal = 0L,
- )
- }
- val shouldAutoStopCsv = downloadBacklogAutoStop
- downloadBacklogAutoStop = false
- if (shouldAutoStopCsv && csvLogger.isActive) {
- stopLogging()
+ if (cancelled) {
+ backlogTransferCoordinator.cancel("Backlog download stopped")
+ finalizeBacklogTransferAfterTerminal()
+ return
}
- if (!success && !cancelled) {
- appendLog(
- if (runId != 0L) {
- "Backlog Wi-Fi download ended with errors (run=0x${toHex64(runId)})"
+ if (!wifiCommitted && backlogTransferCoordinator.currentStage() == BacklogTransferStage.WIFI_ACTIVE) {
+ startBleFallbackAfterWifiFailure(
+ reason = failureReason ?: if (runId != 0L) {
+ "run=0x${toHex64(runId)} failed"
} else {
- "Backlog Wi-Fi download ended with errors"
+ "Wi-Fi transfer failed"
},
)
- } else if (cancelled) {
- appendLog("Backlog download stopped")
}
}
}
+ private fun startBleFallbackAfterWifiFailure(reason: String) {
+ val fallbackStarted = backlogTransferCoordinator.failWifiAndRequestBleFallback(reason)
+ if (!fallbackStarted) {
+ finalizeBacklogTransferAfterTerminal()
+ return
+ }
+ if (!controlLinkReadySilent()) {
+ backlogTransferCoordinator.failBleFallback("control link unavailable")
+ finalizeBacklogTransferAfterTerminal()
+ return
+ }
+ val disableWifiOk = bleClient.setWifiDumpEnabled(false)
+ val enableBlobOk = bleClient.setBacklogBlobEnabled(true)
+ if (!disableWifiOk || !enableBlobOk) {
+ backlogTransferCoordinator.failBleFallback("BLE fallback toggle failed")
+ finalizeBacklogTransferAfterTerminal()
+ return
+ }
+ resetBacklogBlobReceiver(discardFile = true)
+ bleClient.requestStatus()
+ }
+
+ private fun finalizeBacklogTransferAfterTerminal() {
+ downloadBacklogStartMs = 0L
+ resetBacklogBlobReceiver(discardFile = true)
+ if (controlLinkReadySilent()) {
+ bleClient.setWifiDumpEnabled(false)
+ bleClient.setBacklogBlobEnabled(false)
+ bleClient.requestStatus()
+ }
+ val shouldAutoStopCsv = downloadBacklogAutoStop
+ downloadBacklogAutoStop = false
+ if (shouldAutoStopCsv && csvLogger.isActive) {
+ stopLogging()
+ }
+ }
+
private fun toHex64(value: Long): String =
java.lang.Long.toUnsignedString(value, 16).uppercase(Locale.US).padStart(16, '0')
diff --git a/android/app/src/main/java/com/wifinder/android/ui/AppUiState.kt b/android/app/src/main/java/com/wifinder/android/ui/AppUiState.kt
index d152bda..7f1738e 100644
--- a/android/app/src/main/java/com/wifinder/android/ui/AppUiState.kt
+++ b/android/app/src/main/java/com/wifinder/android/ui/AppUiState.kt
@@ -10,6 +10,13 @@ data class SightingUi(
val seenCount: Int,
)
+data class BleDeviceCandidateUi(
+ val address: String,
+ val name: String,
+ val rssi: Int,
+ val remembered: Boolean,
+)
+
data class AppUiState(
val permissionsGranted: Boolean = false,
val serviceRunning: Boolean = false,
@@ -83,6 +90,10 @@ data class AppUiState(
val dashboardMode: Boolean = false,
val loggingEnabled: Boolean = false,
val csvPath: String = "",
+ val preferredBleDeviceAddress: String = "",
+ val preferredBleDeviceName: String = "",
+ val bleDevicePickerVisible: Boolean = false,
+ val bleDeviceCandidates: List = emptyList(),
val sightings: List = emptyList(),
val logs: List = emptyList(),
val expandedSections: Set = emptySet(),
diff --git a/android/app/src/main/java/com/wifinder/android/ui/MainViewModel.kt b/android/app/src/main/java/com/wifinder/android/ui/MainViewModel.kt
index e4af070..bd8f513 100644
--- a/android/app/src/main/java/com/wifinder/android/ui/MainViewModel.kt
+++ b/android/app/src/main/java/com/wifinder/android/ui/MainViewModel.kt
@@ -285,6 +285,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun downloadBacklog() { service?.downloadBacklogToCsv() }
fun seedDebugBacklog() { service?.seedDebugBacklog() }
fun pushPhoneGpsNow() { service?.pushPhoneGpsNow() }
+ fun selectBleDevice(address: String) { service?.selectBleDevice(address) }
+ fun dismissBleDevicePicker() { service?.dismissBleDevicePicker() }
+ fun forgetPreferredBleDevice() { service?.forgetPreferredBleDevice() }
// ── State merging ─────────────────────────────────────────
@@ -359,6 +362,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
gpxPointCount = svc.gpxPointCount,
loggingEnabled = svc.loggingEnabled,
csvPath = svc.csvPath,
+ preferredBleDeviceAddress = svc.preferredBleDeviceAddress,
+ preferredBleDeviceName = svc.preferredBleDeviceName,
+ bleDevicePickerVisible = svc.bleDevicePickerVisible,
+ bleDeviceCandidates = svc.bleDeviceCandidates.map {
+ BleDeviceCandidateUi(
+ address = it.address,
+ name = it.name,
+ rssi = it.rssi,
+ remembered = it.remembered,
+ )
+ },
sightings = svc.sightings,
logs = svc.logs,
hopInputMs = if (!ui.hopInputDirty) svc.autoHopBaseMs else ui.hopInputMs,
diff --git a/android/app/src/test/java/com/wifinder/android/ble/BleTargetingPolicyTest.kt b/android/app/src/test/java/com/wifinder/android/ble/BleTargetingPolicyTest.kt
new file mode 100644
index 0000000..e565257
--- /dev/null
+++ b/android/app/src/test/java/com/wifinder/android/ble/BleTargetingPolicyTest.kt
@@ -0,0 +1,56 @@
+package com.wifinder.android.ble
+
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.junit.Test
+
+class BleTargetingPolicyTest {
+
+ @Test
+ fun `service uuid is required and name guard is secondary`() {
+ assertFalse(BleTargetingPolicy.isEligibleCandidate(hasServiceUuid = false, advertisedName = "WIFINDER-C6"))
+ assertFalse(BleTargetingPolicy.isEligibleCandidate(hasServiceUuid = true, advertisedName = "OtherDevice"))
+ assertTrue(BleTargetingPolicy.isEligibleCandidate(hasServiceUuid = true, advertisedName = "WIFINDER-S3"))
+ assertTrue(BleTargetingPolicy.isEligibleCandidate(hasServiceUuid = true, advertisedName = null))
+ }
+
+ @Test
+ fun `remembered address is prioritized for auto-connect`() {
+ val candidates = listOf(
+ BleDeviceCandidate(address = "AA:BB:CC:DD:EE:01", name = "WIFINDER-1", rssi = -50),
+ BleDeviceCandidate(address = "AA:BB:CC:DD:EE:02", name = "WIFINDER-2", rssi = -40),
+ )
+
+ val chosen = BleTargetingPolicy.chooseAutoConnect(candidates, preferredAddress = "aa:bb:cc:dd:ee:01")
+
+ assertEquals("AA:BB:CC:DD:EE:01", chosen?.address)
+ }
+
+ @Test
+ fun `no preferred device auto-connects only when single candidate exists`() {
+ val one = listOf(BleDeviceCandidate(address = "AA:BB:CC:DD:EE:01", name = "WIFINDER-1", rssi = -55))
+ val many = listOf(
+ BleDeviceCandidate(address = "AA:BB:CC:DD:EE:01", name = "WIFINDER-1", rssi = -55),
+ BleDeviceCandidate(address = "AA:BB:CC:DD:EE:02", name = "WIFINDER-2", rssi = -40),
+ )
+
+ assertEquals("AA:BB:CC:DD:EE:01", BleTargetingPolicy.chooseAutoConnect(one, preferredAddress = null)?.address)
+ assertNull(BleTargetingPolicy.chooseAutoConnect(many, preferredAddress = null))
+ }
+
+ @Test
+ fun `sorted candidates place remembered first`() {
+ val sorted = BleTargetingPolicy.sortCandidates(
+ candidates = listOf(
+ BleDeviceCandidate(address = "AA:BB:CC:DD:EE:01", name = "WIFINDER-1", rssi = -55),
+ BleDeviceCandidate(address = "AA:BB:CC:DD:EE:02", name = "WIFINDER-2", rssi = -40),
+ ),
+ preferredAddress = "AA:BB:CC:DD:EE:01",
+ )
+
+ assertEquals("AA:BB:CC:DD:EE:01", sorted.first().address)
+ assertTrue(sorted.first().remembered)
+ }
+}
diff --git a/android/app/src/test/java/com/wifinder/android/service/BacklogTransferCoordinatorTest.kt b/android/app/src/test/java/com/wifinder/android/service/BacklogTransferCoordinatorTest.kt
new file mode 100644
index 0000000..fcdf7aa
--- /dev/null
+++ b/android/app/src/test/java/com/wifinder/android/service/BacklogTransferCoordinatorTest.kt
@@ -0,0 +1,65 @@
+package com.wifinder.android.service
+
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+
+class BacklogTransferCoordinatorTest {
+
+ @Test
+ fun `wifi failure transitions to single ble fallback and then complete`() {
+ val snapshots = mutableListOf()
+ val logs = mutableListOf()
+ val coordinator = BacklogTransferCoordinator(
+ onSnapshot = { snapshots += it },
+ onLog = { logs += it },
+ )
+
+ assertTrue(coordinator.startWifi())
+ assertEquals(BacklogTransferStage.WIFI_ACTIVE, coordinator.currentStage())
+
+ val startedFallback = coordinator.failWifiAndRequestBleFallback("manifest unavailable")
+ assertTrue(startedFallback)
+ assertEquals(BacklogTransferStage.BLE_FALLBACK_ACTIVE, coordinator.currentStage())
+
+ coordinator.noteBleSessionMeta(sessionId = 0x1234L, totalBytes = 4096L)
+ coordinator.noteBleSessionProgress(sessionId = 0x1234L, bytesReceived = 4096L, totalBytes = 4096L)
+ coordinator.completeBleFallback("fallback imported")
+
+ assertEquals(BacklogTransferStage.COMPLETE, coordinator.currentStage())
+ val last = snapshots.last()
+ assertFalse(last.downloadActive)
+ assertFalse(last.blobActive)
+ assertTrue(logs.any { it.contains("Switching to BLE fallback") })
+ assertTrue(logs.any { it.contains("fallback imported") })
+ }
+
+ @Test
+ fun `only one fallback attempt is allowed`() {
+ val coordinator = BacklogTransferCoordinator(
+ onSnapshot = {},
+ onLog = {},
+ )
+
+ assertTrue(coordinator.startWifi())
+ assertTrue(coordinator.failWifiAndRequestBleFallback("network timeout"))
+ coordinator.failBleFallback("chunk mismatch")
+
+ assertEquals(BacklogTransferStage.FAILED, coordinator.currentStage())
+ assertFalse(coordinator.failWifiAndRequestBleFallback("second wifi failure"))
+ assertEquals(BacklogTransferStage.FAILED, coordinator.currentStage())
+ }
+
+ @Test
+ fun `cancel returns transfer to idle`() {
+ val coordinator = BacklogTransferCoordinator(
+ onSnapshot = {},
+ onLog = {},
+ )
+
+ assertTrue(coordinator.startWifi())
+ coordinator.cancel("user cancelled")
+ assertEquals(BacklogTransferStage.IDLE, coordinator.currentStage())
+ }
+}
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 262b901..f4184b9 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -7,6 +7,9 @@ idf_component_register(
"wg_payload.c"
"ble_security_policy.c"
"scan_policy.c"
+ "wg_session_transport.c"
+ "wg_storage_backlog.c"
+ "wg_gps_nmea.c"
INCLUDE_DIRS
"."
REQUIRES
diff --git a/main/main.c b/main/main.c
index 76a4965..c96a4f9 100644
--- a/main/main.c
+++ b/main/main.c
@@ -60,8 +60,11 @@
#include "ble_security_policy.h"
#include "scan_policy.h"
#include "sniffer_logic.h"
+#include "wg_gps_nmea.h"
#include "wg_payload.h"
#include "wg_protocol.h"
+#include "wg_session_transport.h"
+#include "wg_storage_backlog.h"
extern void ble_store_config_init(void);
@@ -1807,56 +1810,31 @@ static bool storage_finalize_stale_gpx_session(uint64_t session_id) {
}
static bool storage_parse_meta_filename(const char *name, uint64_t *session_id_out) {
- if (name == NULL || session_id_out == NULL) {
- return false;
- }
- const size_t prefix_len = strlen(WG_STORAGE_FILE_PREFIX);
- const size_t n = strlen(name);
- if (n != (prefix_len + 16 + 5) || strncmp(name, WG_STORAGE_FILE_PREFIX, prefix_len) != 0 ||
- strcmp(name + prefix_len + 16, ".meta") != 0) {
- return false;
- }
- char hex[17] = {0};
- memcpy(hex, name + prefix_len, 16);
- errno = 0;
- char *end = NULL;
- unsigned long long parsed = strtoull(hex, &end, 16);
- if (errno != 0 || end == NULL || *end != '\0') {
- return false;
- }
- *session_id_out = (uint64_t)parsed;
- return true;
+ return wg_storage_parse_meta_filename(name, WG_STORAGE_FILE_PREFIX, session_id_out);
}
static uint32_t storage_manifest_generation_get(const wg_session_manifest_t *manifest,
bool *valid_out) {
- bool valid = (manifest != NULL && manifest->reserved1[2] == WG_STORAGE_MANIFEST_GEN_MAGIC);
- if (valid_out != NULL) {
- *valid_out = valid;
- }
- if (!valid) {
+ if (manifest == NULL) {
+ if (valid_out != NULL) {
+ *valid_out = false;
+ }
return 0;
}
- return (uint32_t)manifest->reserved0 | ((uint32_t)manifest->reserved1[0] << 16) |
- ((uint32_t)manifest->reserved1[1] << 24);
+ return wg_storage_manifest_generation_get(manifest->reserved0, manifest->reserved1,
+ WG_STORAGE_MANIFEST_GEN_MAGIC, valid_out);
}
static void storage_manifest_generation_set(wg_session_manifest_t *manifest, uint32_t generation) {
if (manifest == NULL) {
return;
}
- manifest->reserved0 = (uint16_t)(generation & 0xFFFFU);
- manifest->reserved1[0] = (uint8_t)((generation >> 16) & 0xFFU);
- manifest->reserved1[1] = (uint8_t)((generation >> 24) & 0xFFU);
- manifest->reserved1[2] = WG_STORAGE_MANIFEST_GEN_MAGIC;
+ wg_storage_manifest_generation_set(&manifest->reserved0, manifest->reserved1,
+ WG_STORAGE_MANIFEST_GEN_MAGIC, generation);
}
static int storage_manifest_generation_cmp(uint32_t lhs, uint32_t rhs) {
- if (lhs == rhs) {
- return 0;
- }
- uint32_t delta = lhs - rhs;
- return (delta < 0x80000000U) ? 1 : -1;
+ return wg_storage_manifest_generation_cmp(lhs, rhs);
}
static int storage_manifest_compare_freshness(const wg_session_manifest_t *lhs,
@@ -4856,65 +4834,23 @@ static void recompute_effective_gps_fix(void) {
}
static bool parse_nmea_latlon(const char *value, const char *hemi, int32_t *out_e7) {
- if (value == NULL || hemi == NULL || out_e7 == NULL || value[0] == '\0' || hemi[0] == '\0') {
- return false;
- }
- char *end = NULL;
- double raw = strtod(value, &end);
- if (end == value || raw <= 0.0) {
- return false;
- }
- int degrees = (int)(raw / 100.0);
- double minutes = raw - (double)degrees * 100.0;
- double dec = (double)degrees + minutes / 60.0;
- if (hemi[0] == 'S' || hemi[0] == 'W') {
- dec = -dec;
- } else if (hemi[0] != 'N' && hemi[0] != 'E') {
- return false;
- }
- *out_e7 = (int32_t)llround(dec * 10000000.0);
- return true;
+ return wg_gps_parse_nmea_latlon(value, hemi, out_e7);
}
static uint16_t parse_nmea_dop_centi(const char *token) {
- if (token == NULL || token[0] == '\0') {
- return 0;
- }
- char *end = NULL;
- double dop = strtod(token, &end);
- if (end == token || !isfinite(dop) || dop <= 0.0) {
- return 0;
- }
- if (dop > 999.99) {
- dop = 999.99;
- }
- return clamp_u16_u32((uint32_t)llround(dop * 100.0));
+ return wg_gps_parse_nmea_dop_centi(token);
}
static bool nmea_sentence_has_type(const char *line, const char *type3) {
- if (line == NULL || type3 == NULL) {
- return false;
- }
- size_t len = strnlen(line, 16);
- if (len < 6) {
- return false;
- }
- return line[0] == '$' && strncmp(&line[3], type3, 3) == 0;
+ return wg_gps_nmea_sentence_has_type(line, type3);
}
static uint16_t nmea_sentence_talker_key(const char *line) {
- if (line == NULL) {
- return 0;
- }
- size_t len = strnlen(line, 8);
- if (len < 3 || line[0] != '$') {
- return 0;
- }
- return (((uint16_t)(uint8_t)line[1]) << 8) | (uint16_t)(uint8_t)line[2];
+ return wg_gps_nmea_sentence_talker_key(line);
}
static bool talker_key_is_gn(uint16_t key) {
- return key == ((((uint16_t)'G') << 8) | (uint16_t)'N');
+ return wg_gps_talker_key_is_gn(key);
}
static void gps_update_talker_sat(gps_talker_sat_t *buckets, size_t bucket_count, uint16_t talker_key,
@@ -6129,23 +6065,18 @@ static bool send_snapshot_end(void) {
return sent;
}
-static uint16_t rd_u16_le(const uint8_t *in) { return (uint16_t)in[0] | ((uint16_t)in[1] << 8); }
+static uint16_t rd_u16_le(const uint8_t *in) { return wg_transport_rd_u16_le(in); }
static void wr_u16_le(uint8_t *out, uint16_t value) {
- out[0] = (uint8_t)(value & 0xFF);
- out[1] = (uint8_t)((value >> 8) & 0xFF);
+ wg_transport_wr_u16_le(out, value);
}
static void wr_u32_le(uint8_t *out, uint32_t value) {
- out[0] = (uint8_t)(value & 0xFF);
- out[1] = (uint8_t)((value >> 8) & 0xFF);
- out[2] = (uint8_t)((value >> 16) & 0xFF);
- out[3] = (uint8_t)((value >> 24) & 0xFF);
+ wg_transport_wr_u32_le(out, value);
}
static void wr_u64_le(uint8_t *out, uint64_t value) {
- wr_u32_le(out, (uint32_t)(value & 0xFFFFFFFFULL));
- wr_u32_le(out + 4, (uint32_t)((value >> 32) & 0xFFFFFFFFULL));
+ wg_transport_wr_u64_le(out, value);
}
static uint8_t rsn_cipher_flags_from_suite(const uint8_t *suite) {
@@ -7586,48 +7517,13 @@ static void refresh_link_security_state(uint16_t conn_handle) {
}
static const char *ble_phy_name(uint8_t phy) {
- switch (phy) {
- case BLE_GAP_LE_PHY_1M:
- return "1M";
- case BLE_GAP_LE_PHY_2M:
- return "2M";
- case BLE_GAP_LE_PHY_CODED:
- return "CODED";
- default:
- return "unknown";
- }
+ return wg_transport_ble_phy_name(phy);
}
static uint16_t ble_notify_frame_payload_limit(void) {
- uint16_t mtu = s_ble_att_mtu;
- if (mtu < 23) {
- mtu = 23;
- }
- if (mtu <= (WG_FRAME_HEADER_SIZE + 3)) {
- return 0;
- }
- uint16_t limit = (uint16_t)(mtu - WG_FRAME_HEADER_SIZE - 3);
- if (limit > WG_BLE_NOTIFY_FRAME_MAX_PAYLOAD_MAX) {
- limit = WG_BLE_NOTIFY_FRAME_MAX_PAYLOAD_MAX;
- }
- // Cap payload so the entire BLE notification fits within 2 LL PDU fragments.
- // Some Android BLE stacks silently fail to reassemble L2CAP PDUs spanning 3+
- // LL fragments. With DLE=251 and encryption (4-byte MIC), each fragment
- // carries at most 247 bytes of L2CAP data. Two fragments = 494 bytes L2CAP
- // → 490 ATT PDU → 487 notification value → 475 WG frame payload.
- const uint16_t dle = WG_BLE_DATA_LEN_OCTETS;
- const uint16_t mic = s_ble_encrypted ? 4 : 0;
- const uint16_t l2cap_per_frag = (dle > mic) ? (uint16_t)(dle - mic) : 1;
- const uint16_t safe_l2cap = (uint16_t)(l2cap_per_frag * 2U);
- const uint16_t safe_att = (safe_l2cap > 4) ? (uint16_t)(safe_l2cap - 4) : 0;
- const uint16_t safe_notify = (safe_att > 3) ? (uint16_t)(safe_att - 3) : 0;
- const uint16_t safe_payload = (safe_notify > WG_FRAME_HEADER_SIZE)
- ? (uint16_t)(safe_notify - WG_FRAME_HEADER_SIZE)
- : 0;
- if (limit > safe_payload) {
- limit = safe_payload;
- }
- return limit;
+ return wg_transport_ble_notify_payload_limit(s_ble_att_mtu, WG_FRAME_HEADER_SIZE,
+ WG_BLE_NOTIFY_FRAME_MAX_PAYLOAD_MAX,
+ WG_BLE_DATA_LEN_OCTETS, s_ble_encrypted);
}
static void refresh_ble_link_metrics(uint16_t conn_handle, const char *reason_tag) {
diff --git a/main/wg_gps_nmea.c b/main/wg_gps_nmea.c
new file mode 100644
index 0000000..f3052c2
--- /dev/null
+++ b/main/wg_gps_nmea.c
@@ -0,0 +1,86 @@
+#include "wg_gps_nmea.h"
+
+#include
+#include
+#include
+#include
+
+static uint16_t clamp_u16(uint32_t value) {
+ if (value > UINT16_MAX) {
+ return UINT16_MAX;
+ }
+ return (uint16_t)value;
+}
+
+static size_t bounded_strlen(const char *s, size_t max_len) {
+ if (s == NULL) {
+ return 0;
+ }
+ size_t n = 0;
+ while (n < max_len && s[n] != '\0') {
+ ++n;
+ }
+ return n;
+}
+
+bool wg_gps_parse_nmea_latlon(const char *value, const char *hemi, int32_t *out_e7) {
+ if (value == NULL || hemi == NULL || out_e7 == NULL || value[0] == '\0' || hemi[0] == '\0') {
+ return false;
+ }
+ char *end = NULL;
+ double raw = strtod(value, &end);
+ if (end == value || raw <= 0.0) {
+ return false;
+ }
+ int degrees = (int)(raw / 100.0);
+ double minutes = raw - (double)degrees * 100.0;
+ double dec = (double)degrees + minutes / 60.0;
+ if (hemi[0] == 'S' || hemi[0] == 'W') {
+ dec = -dec;
+ } else if (hemi[0] != 'N' && hemi[0] != 'E') {
+ return false;
+ }
+ *out_e7 = (int32_t)llround(dec * 10000000.0);
+ return true;
+}
+
+uint16_t wg_gps_parse_nmea_dop_centi(const char *token) {
+ if (token == NULL || token[0] == '\0') {
+ return 0;
+ }
+ char *end = NULL;
+ double dop = strtod(token, &end);
+ if (end == token || !isfinite(dop) || dop <= 0.0) {
+ return 0;
+ }
+ if (dop > 999.99) {
+ dop = 999.99;
+ }
+ return clamp_u16((uint32_t)llround(dop * 100.0));
+}
+
+bool wg_gps_nmea_sentence_has_type(const char *line, const char *type3) {
+ if (line == NULL || type3 == NULL) {
+ return false;
+ }
+ size_t len = bounded_strlen(line, 16);
+ if (len < 6) {
+ return false;
+ }
+ return line[0] == '$' && strncmp(&line[3], type3, 3) == 0;
+}
+
+uint16_t wg_gps_nmea_sentence_talker_key(const char *line) {
+ if (line == NULL) {
+ return 0;
+ }
+ size_t len = bounded_strlen(line, 8);
+ if (len < 3 || line[0] != '$') {
+ return 0;
+ }
+ return (((uint16_t)(uint8_t)line[1]) << 8) | (uint16_t)(uint8_t)line[2];
+}
+
+bool wg_gps_talker_key_is_gn(uint16_t key) {
+ return key == ((((uint16_t)'G') << 8) | (uint16_t)'N');
+}
diff --git a/main/wg_gps_nmea.h b/main/wg_gps_nmea.h
new file mode 100644
index 0000000..f6618aa
--- /dev/null
+++ b/main/wg_gps_nmea.h
@@ -0,0 +1,10 @@
+#pragma once
+
+#include
+#include
+
+bool wg_gps_parse_nmea_latlon(const char *value, const char *hemi, int32_t *out_e7);
+uint16_t wg_gps_parse_nmea_dop_centi(const char *token);
+bool wg_gps_nmea_sentence_has_type(const char *line, const char *type3);
+uint16_t wg_gps_nmea_sentence_talker_key(const char *line);
+bool wg_gps_talker_key_is_gn(uint16_t key);
diff --git a/main/wg_session_transport.c b/main/wg_session_transport.c
new file mode 100644
index 0000000..b198cd9
--- /dev/null
+++ b/main/wg_session_transport.c
@@ -0,0 +1,79 @@
+#include "wg_session_transport.h"
+
+#include
+
+uint16_t wg_transport_rd_u16_le(const uint8_t *in) {
+ if (in == NULL) {
+ return 0;
+ }
+ return (uint16_t)in[0] | ((uint16_t)in[1] << 8);
+}
+
+void wg_transport_wr_u16_le(uint8_t *out, uint16_t value) {
+ if (out == NULL) {
+ return;
+ }
+ out[0] = (uint8_t)(value & 0xFFU);
+ out[1] = (uint8_t)((value >> 8) & 0xFFU);
+}
+
+void wg_transport_wr_u32_le(uint8_t *out, uint32_t value) {
+ if (out == NULL) {
+ return;
+ }
+ out[0] = (uint8_t)(value & 0xFFU);
+ out[1] = (uint8_t)((value >> 8) & 0xFFU);
+ out[2] = (uint8_t)((value >> 16) & 0xFFU);
+ out[3] = (uint8_t)((value >> 24) & 0xFFU);
+}
+
+void wg_transport_wr_u64_le(uint8_t *out, uint64_t value) {
+ if (out == NULL) {
+ return;
+ }
+ wg_transport_wr_u32_le(out, (uint32_t)(value & 0xFFFFFFFFULL));
+ wg_transport_wr_u32_le(out + 4, (uint32_t)((value >> 32) & 0xFFFFFFFFULL));
+}
+
+const char *wg_transport_ble_phy_name(uint8_t phy) {
+ switch (phy) {
+ case 1:
+ return "1M";
+ case 2:
+ return "2M";
+ case 3:
+ return "CODED";
+ default:
+ return "unknown";
+ }
+}
+
+uint16_t wg_transport_ble_notify_payload_limit(uint16_t att_mtu, uint16_t frame_header_size,
+ uint16_t payload_max, uint16_t data_len_octets,
+ bool encrypted) {
+ uint16_t mtu = att_mtu;
+ if (mtu < 23U) {
+ mtu = 23U;
+ }
+ if (mtu <= (uint16_t)(frame_header_size + 3U)) {
+ return 0U;
+ }
+
+ uint16_t limit = (uint16_t)(mtu - frame_header_size - 3U);
+ if (limit > payload_max) {
+ limit = payload_max;
+ }
+
+ const uint16_t mic = encrypted ? 4U : 0U;
+ const uint16_t l2cap_per_frag = (data_len_octets > mic) ? (uint16_t)(data_len_octets - mic) : 1U;
+ const uint16_t safe_l2cap = (uint16_t)(l2cap_per_frag * 2U);
+ const uint16_t safe_att = (safe_l2cap > 4U) ? (uint16_t)(safe_l2cap - 4U) : 0U;
+ const uint16_t safe_notify = (safe_att > 3U) ? (uint16_t)(safe_att - 3U) : 0U;
+ const uint16_t safe_payload =
+ (safe_notify > frame_header_size) ? (uint16_t)(safe_notify - frame_header_size) : 0U;
+ if (limit > safe_payload) {
+ limit = safe_payload;
+ }
+
+ return limit;
+}
diff --git a/main/wg_session_transport.h b/main/wg_session_transport.h
new file mode 100644
index 0000000..ba43e22
--- /dev/null
+++ b/main/wg_session_transport.h
@@ -0,0 +1,15 @@
+#pragma once
+
+#include
+#include
+
+uint16_t wg_transport_rd_u16_le(const uint8_t *in);
+void wg_transport_wr_u16_le(uint8_t *out, uint16_t value);
+void wg_transport_wr_u32_le(uint8_t *out, uint32_t value);
+void wg_transport_wr_u64_le(uint8_t *out, uint64_t value);
+
+const char *wg_transport_ble_phy_name(uint8_t phy);
+
+uint16_t wg_transport_ble_notify_payload_limit(uint16_t att_mtu, uint16_t frame_header_size,
+ uint16_t payload_max, uint16_t data_len_octets,
+ bool encrypted);
diff --git a/main/wg_storage_backlog.c b/main/wg_storage_backlog.c
new file mode 100644
index 0000000..544da26
--- /dev/null
+++ b/main/wg_storage_backlog.c
@@ -0,0 +1,63 @@
+#include "wg_storage_backlog.h"
+
+#include
+#include
+#include
+#include
+
+bool wg_storage_parse_meta_filename(const char *name, const char *prefix,
+ uint64_t *session_id_out) {
+ if (name == NULL || prefix == NULL || session_id_out == NULL) {
+ return false;
+ }
+
+ const size_t prefix_len = strlen(prefix);
+ const size_t n = strlen(name);
+ if (n != (prefix_len + 16U + 5U) || strncmp(name, prefix, prefix_len) != 0 ||
+ strcmp(name + prefix_len + 16U, ".meta") != 0) {
+ return false;
+ }
+
+ char hex[17] = {0};
+ memcpy(hex, name + prefix_len, 16U);
+ errno = 0;
+ char *end = NULL;
+ unsigned long long parsed = strtoull(hex, &end, 16);
+ if (errno != 0 || end == NULL || *end != '\0') {
+ return false;
+ }
+ *session_id_out = (uint64_t)parsed;
+ return true;
+}
+
+uint32_t wg_storage_manifest_generation_get(uint16_t reserved0, const uint8_t reserved1[3],
+ uint8_t generation_magic, bool *valid_out) {
+ bool valid = (reserved1 != NULL && reserved1[2] == generation_magic);
+ if (valid_out != NULL) {
+ *valid_out = valid;
+ }
+ if (!valid) {
+ return 0;
+ }
+
+ return (uint32_t)reserved0 | ((uint32_t)reserved1[0] << 16) | ((uint32_t)reserved1[1] << 24);
+}
+
+void wg_storage_manifest_generation_set(uint16_t *reserved0, uint8_t reserved1[3],
+ uint8_t generation_magic, uint32_t generation) {
+ if (reserved0 == NULL || reserved1 == NULL) {
+ return;
+ }
+ *reserved0 = (uint16_t)(generation & 0xFFFFU);
+ reserved1[0] = (uint8_t)((generation >> 16) & 0xFFU);
+ reserved1[1] = (uint8_t)((generation >> 24) & 0xFFU);
+ reserved1[2] = generation_magic;
+}
+
+int wg_storage_manifest_generation_cmp(uint32_t lhs, uint32_t rhs) {
+ if (lhs == rhs) {
+ return 0;
+ }
+ uint32_t delta = lhs - rhs;
+ return (delta < 0x80000000U) ? 1 : -1;
+}
diff --git a/main/wg_storage_backlog.h b/main/wg_storage_backlog.h
new file mode 100644
index 0000000..17151a3
--- /dev/null
+++ b/main/wg_storage_backlog.h
@@ -0,0 +1,15 @@
+#pragma once
+
+#include
+#include
+
+bool wg_storage_parse_meta_filename(const char *name, const char *prefix,
+ uint64_t *session_id_out);
+
+uint32_t wg_storage_manifest_generation_get(uint16_t reserved0, const uint8_t reserved1[3],
+ uint8_t generation_magic, bool *valid_out);
+
+void wg_storage_manifest_generation_set(uint16_t *reserved0, uint8_t reserved1[3],
+ uint8_t generation_magic, uint32_t generation);
+
+int wg_storage_manifest_generation_cmp(uint32_t lhs, uint32_t rhs);
From 2d221d322b16f7aa99365060e809aa3f1ef2849b Mon Sep 17 00:00:00 2001
From: Giorgos Karampitsakis <42468982+ComradeTurtle@users.noreply.github.com>
Date: Fri, 24 Apr 2026 02:42:30 +0300
Subject: [PATCH 6/7] storage: fix packed manifest generation write alignment
---
main/main.c | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/main/main.c b/main/main.c
index c96a4f9..8b26f61 100644
--- a/main/main.c
+++ b/main/main.c
@@ -1829,8 +1829,15 @@ static void storage_manifest_generation_set(wg_session_manifest_t *manifest, uin
if (manifest == NULL) {
return;
}
- wg_storage_manifest_generation_set(&manifest->reserved0, manifest->reserved1,
- WG_STORAGE_MANIFEST_GEN_MAGIC, generation);
+ uint16_t reserved0 = manifest->reserved0;
+ uint8_t reserved1[sizeof(manifest->reserved1)] = {0};
+ memcpy(reserved1, manifest->reserved1, sizeof(reserved1));
+
+ wg_storage_manifest_generation_set(&reserved0, reserved1, WG_STORAGE_MANIFEST_GEN_MAGIC,
+ generation);
+
+ manifest->reserved0 = reserved0;
+ memcpy(manifest->reserved1, reserved1, sizeof(reserved1));
}
static int storage_manifest_generation_cmp(uint32_t lhs, uint32_t rhs) {
From 024b20d62efb0813688c2b32ac3e6a31e495afcd Mon Sep 17 00:00:00 2001
From: Giorgos Karampitsakis <42468982+ComradeTurtle@users.noreply.github.com>
Date: Fri, 24 Apr 2026 03:00:29 +0300
Subject: [PATCH 7/7] ci: run checks on feature branch pushes
Also mark Wi-Fi dump as an active download for status LED and legacy replay-active status.
---
.github/workflows/android-unit-tests.yml | 2 --
.github/workflows/desktop-tests.yml | 2 --
.github/workflows/firmware-host-tests.yml | 2 --
.github/workflows/web-core-tests.yml | 2 --
main/main.c | 4 ++--
5 files changed, 2 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/android-unit-tests.yml b/.github/workflows/android-unit-tests.yml
index 46d7936..3397f05 100644
--- a/.github/workflows/android-unit-tests.yml
+++ b/.github/workflows/android-unit-tests.yml
@@ -3,8 +3,6 @@ name: Android Unit Tests
on:
pull_request:
push:
- branches:
- - main
jobs:
android-unit-tests:
diff --git a/.github/workflows/desktop-tests.yml b/.github/workflows/desktop-tests.yml
index fe6e8a7..96a1eee 100644
--- a/.github/workflows/desktop-tests.yml
+++ b/.github/workflows/desktop-tests.yml
@@ -3,8 +3,6 @@ name: Desktop Tests
on:
pull_request:
push:
- branches:
- - main
jobs:
desktop-tests:
diff --git a/.github/workflows/firmware-host-tests.yml b/.github/workflows/firmware-host-tests.yml
index 792289a..490cd83 100644
--- a/.github/workflows/firmware-host-tests.yml
+++ b/.github/workflows/firmware-host-tests.yml
@@ -3,8 +3,6 @@ name: Firmware Host Tests
on:
pull_request:
push:
- branches:
- - main
jobs:
firmware-host-tests:
diff --git a/.github/workflows/web-core-tests.yml b/.github/workflows/web-core-tests.yml
index 22f3b7c..9ef1a75 100644
--- a/.github/workflows/web-core-tests.yml
+++ b/.github/workflows/web-core-tests.yml
@@ -3,8 +3,6 @@ name: Web Core Tests
on:
pull_request:
push:
- branches:
- - main
jobs:
web-core-tests:
diff --git a/main/main.c b/main/main.c
index 8b26f61..c73cc24 100644
--- a/main/main.c
+++ b/main/main.c
@@ -1123,7 +1123,7 @@ static void led_sync_runtime_state(void) {
s_led_state.scanning = s_scanning;
s_led_state.gps_valid = s_latest_gps.valid;
s_led_state.gps_source = s_latest_gps.valid ? (uint8_t)s_gps_source : 0;
- s_led_state.replay_active = s_replay_active || s_blob_active;
+ s_led_state.replay_active = s_replay_active || s_blob_active || s_wifi_dump_active;
s_led_state.backlog_present = s_queue_backlog_records > 0;
}
@@ -4740,7 +4740,7 @@ static void build_status_payload(wg_status_payload_t *payload) {
.session_id = s_session_open ? s_session_id : s_last_session_id,
.queued_records = s_queue_backlog_records,
.queued_bytes = s_queue_backlog_bytes,
- .replay_active = s_replay_active || s_blob_active,
+ .replay_active = s_replay_active || s_blob_active || s_wifi_dump_active,
.replay_cursor = s_blob_active ? s_blob_bytes_acked : s_replay_cursor_seq,
.queue_full = s_queue_full,
.dropped_flash_full = s_dropped_flash_full,