From f86cbbf235841264d2fbee5078ee9f0cf51f2d69 Mon Sep 17 00:00:00 2001 From: kgetman Date: Tue, 20 Oct 2026 11:11:59 +0200 Subject: [PATCH 1/3] feat(airbeam): parse sensor readings; keep credentials out of SessionConfig.toString Adds SensorReading and pure parsers for every live-measurement format: the V1 ASCII line (AB2/AB3/Mini V1, ported from legacy ResponseParser) and the Mini V2 binary live + sync frames (names/unit match legacy V2). SessionConfig data classes printed the Wi-Fi password and auth token through toString(), and the session controller logs configs. --- .../pl/llp/aircasting/bluetooth/Connection.kt | 14 ++- .../llp/aircasting/bluetooth/SensorReading.kt | 16 +++ .../bluetooth/protocol/AirBeamProtocol.kt | 57 +++++++++ .../aircasting/bluetooth/SessionConfigTest.kt | 39 +++++++ .../bluetooth/protocol/ReadingParserTest.kt | 109 ++++++++++++++++++ 5 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/SensorReading.kt create mode 100644 shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/SessionConfigTest.kt create mode 100644 shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/protocol/ReadingParserTest.kt diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/Connection.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/Connection.kt index 0bc04ab..980291d 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/Connection.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/Connection.kt @@ -45,10 +45,14 @@ sealed interface ConfigResult { sealed interface SessionConfig { val uuid: Uuid + // Configs end up in logs; each toString() leaves the credentials out. + data class Mobile( override val uuid: Uuid, val authToken: String? = null - ) : SessionConfig + ) : SessionConfig { + override fun toString() = "Mobile(uuid=$uuid)" + } data class FixedWiFi( override val uuid: Uuid, @@ -60,12 +64,16 @@ sealed interface SessionConfig { val zoneOffset: Int, val pm1Index: Int = 0, val pm25Index: Int = 1 - ) : SessionConfig + ) : SessionConfig { + override fun toString() = "FixedWiFi(uuid=$uuid, ssid=$ssid)" + } data class FixedCellular( override val uuid: Uuid, val authToken: String, val latitude: Double, val longitude: Double - ) : SessionConfig + ) : SessionConfig { + override fun toString() = "FixedCellular(uuid=$uuid)" + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/SensorReading.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/SensorReading.kt new file mode 100644 index 0000000..cbb0675 --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/SensorReading.kt @@ -0,0 +1,16 @@ +package pl.llp.aircasting.bluetooth + +import kotlin.time.Instant + +/** + * One value from one sensor, as the device sent it. Not yet a stored `Measurement`: it has no + * stream id and no location — the recording side adds both. + */ +data class SensorReading( + /** Stream identity on the backend, e.g. `AirBeam3-PM2.5`. */ + val sensorName: String, + val unitSymbol: String, + val value: Double, + /** Device clock (Mini V2). `null` when the protocol carries no time (V1) — stamp on receipt. */ + val measuredAt: Instant?, +) diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/protocol/AirBeamProtocol.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/protocol/AirBeamProtocol.kt index d524229..5388ad1 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/protocol/AirBeamProtocol.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/protocol/AirBeamProtocol.kt @@ -8,7 +8,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs import kotlin.math.pow import kotlin.math.roundToLong +import pl.llp.aircasting.bluetooth.SensorReading import kotlin.time.Clock +import kotlin.time.Instant import kotlin.uuid.Uuid sealed class AirBeamProtocol { @@ -67,6 +69,22 @@ sealed class AirBeamProtocol { fun startFixedCellCommand(): ByteArray = byteArrayOf(BEGIN, START_FIXED_CELL, END) fun startSyncCommand(): ByteArray = byteArrayOf(BEGIN, START_SYNC, END) fun clearMemoryCommand(): ByteArray = byteArrayOf(BEGIN, DELETE_MEMORY, END) + + /** + * A measurement line — AB3 / Mini V1 over BLE notifications, AB2 over the serial socket: + * `value;package;sensor name;type;short type;unit name;unit symbol;T1;T2;T3;T4;T5`. + * Anything else (Mini V1's "85%" battery notification, a torn line) is `null`. + * Legacy: `ResponseParser`. + */ + fun parseReading(line: String): SensorReading? { + val parts = line.trim().split(';') + if (parts.size < READING_FIELDS) return null + val value = parts[0].toDoubleOrNull() ?: return null + if (parts.subList(7, READING_FIELDS).any { it.toIntOrNull() == null }) return null + return SensorReading(sensorName = parts[2], unitSymbol = parts[6], value = value, measuredAt = null) + } + + private const val READING_FIELDS = 12 } /** @@ -305,6 +323,39 @@ sealed class AirBeamProtocol { /** 0x16: StartBleSync (transfers stored measurements over sync characteristic) */ fun startBleSyncCommand(): ByteArray = byteArrayOf(CMD_START_BLE_SYNC) + + /** Live `Measurement` indication: `[count_u8=1, timestamp_u32, pm1_u16, pm2_5_u16]`, LE. */ + fun parseMeasurement(bytes: ByteArray): List = + if (bytes.size < 1 + RECORD_SIZE) emptyList() else record(bytes, offset = 1) + + /** + * `Sync` indication — measurements stored while the phone was away, streamed by the + * firmware right after a reconnect: `[count_u8, padding_2B, record × count]`. + */ + fun parseSyncChunk(bytes: ByteArray): List { + if (bytes.isEmpty()) return emptyList() + val count = bytes[0].toInt() and 0xFF + val whole = minOf(count, (bytes.size - SYNC_HEADER_SIZE).coerceAtLeast(0) / RECORD_SIZE) + return (0 until whole).flatMap { i -> record(bytes, SYNC_HEADER_SIZE + i * RECORD_SIZE) } + } + + /** + * V2 frames carry values only; the names and unit match what legacy V2 uploads + * (`AirBeamMiniV2Configurator.postLiveMeasurementEvents`), so streams line up on the backend. + */ + private fun record(bytes: ByteArray, offset: Int): List { + val at = Instant.fromEpochSeconds(bytes.toLittleEndianUInt(offset)) + return listOf( + SensorReading(PM1_SENSOR, PM_UNIT, bytes.toLittleEndianUShort(offset + 4).toDouble(), at), + SensorReading(PM25_SENSOR, PM_UNIT, bytes.toLittleEndianUShort(offset + 6).toDouble(), at), + ) + } + + private const val RECORD_SIZE = 8 + private const val SYNC_HEADER_SIZE = 3 + private const val PM1_SENSOR = "AirBeamMini-PM1" + private const val PM25_SENSOR = "AirBeamMini-PM2.5" + private const val PM_UNIT = "µg/m³" } /** @@ -367,6 +418,12 @@ private fun ByteArray.toLittleEndianLong(offset: Int): Long { return result } +private fun ByteArray.toLittleEndianUInt(offset: Int): Long = + (0 until 4).fold(0L) { acc, i -> acc or ((this[offset + i].toLong() and 0xFFL) shl (i * 8)) } + +private fun ByteArray.toLittleEndianUShort(offset: Int): Int = + (this[offset].toInt() and 0xFF) or ((this[offset + 1].toInt() and 0xFF) shl 8) + private fun uuidStringTo16BytesLittleEndian(uuidStr: String): ByteArray { val hex = uuidStr.replace("-", "") require(hex.length == 32) { "Invalid UUID string length: $uuidStr" } diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/SessionConfigTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/SessionConfigTest.kt new file mode 100644 index 0000000..e233cf3 --- /dev/null +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/SessionConfigTest.kt @@ -0,0 +1,39 @@ +package pl.llp.aircasting.bluetooth + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.uuid.Uuid + +/** Configs get logged; a data class `toString()` would print the credentials into Logcat. */ +class SessionConfigTest { + + @Test + fun fixed_wifi_config_does_not_print_its_credentials() { + val text = SessionConfig.FixedWiFi( + uuid = Uuid.random(), + authToken = "secret-token", + latitude = 1.0, + longitude = 2.0, + ssid = "home", + password = "hunter2", + zoneOffset = 0, + ).toString() + + assertFalse("hunter2" in text, text) + assertFalse("secret-token" in text, text) + } + + @Test + fun cellular_config_does_not_print_its_token() { + val text = SessionConfig.FixedCellular(Uuid.random(), "secret-token", 1.0, 2.0).toString() + + assertFalse("secret-token" in text, text) + } + + @Test + fun mobile_config_does_not_print_its_token() { + val text = SessionConfig.Mobile(Uuid.random(), authToken = "secret-token").toString() + + assertFalse("secret-token" in text, text) + } +} diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/protocol/ReadingParserTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/protocol/ReadingParserTest.kt new file mode 100644 index 0000000..68946a0 --- /dev/null +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/protocol/ReadingParserTest.kt @@ -0,0 +1,109 @@ +package pl.llp.aircasting.bluetooth.protocol + +import pl.llp.aircasting.bluetooth.SensorReading +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class ReadingParserTest { + + // --- AB2 / AB3 / Mini V1: one ASCII line per reading (legacy `ResponseParser`) --- + + @Test + fun standard_line_maps_to_sensor_name_unit_and_value() { + val line = "12.5;AirBeam3:00189610719F;AirBeam3-PM2.5;Particulate Matter;PM;" + + "micrograms per cubic meter;µg/m³;0;12;35;55;150" + + assertEquals( + SensorReading("AirBeam3-PM2.5", "µg/m³", 12.5, measuredAt = null), + AirBeamProtocol.Standard.parseReading(line), + ) + } + + /** AB2 lines come off a socket with the line terminator still attached. */ + @Test + fun standard_line_ignores_surrounding_whitespace() { + val line = "71;AirBeam2:0018961070D6;AirBeam2-F;Temperature;F;degrees Fahrenheit;F;15;45;75;105;135\r\n" + + assertEquals(71.0, AirBeamProtocol.Standard.parseReading(line)?.value) + } + + /** Mini V1 sends its battery level ("85%") on a measurement characteristic. */ + @Test + fun standard_battery_notification_is_not_a_reading() { + assertNull(AirBeamProtocol.Standard.parseReading("85%")) + } + + @Test + fun standard_line_with_missing_fields_is_dropped() { + assertNull(AirBeamProtocol.Standard.parseReading("12.5;AirBeam3:00189610719F;AirBeam3-PM2.5")) + } + + @Test + fun standard_line_with_a_non_numeric_value_is_dropped() { + val line = "nan?;AirBeam3:00189610719F;AirBeam3-PM2.5;Particulate Matter;PM;" + + "micrograms per cubic meter;µg/m³;0;12;35;55;150" + + assertNull(AirBeamProtocol.Standard.parseReading(line)) + } + + // --- Mini V2: binary, little-endian, device-timestamped --- + + /** `[count_u8=1, timestamp_u32_LE, pm1_u16_LE, pm2_5_u16_LE]` → one reading per sensor. */ + @Test + fun v2_live_measurement_yields_pm1_and_pm25_at_the_device_time() { + val bytes = byteArrayOf(1) + u32(1_700_000_000) + u16(7) + u16(300) + val at = Instant.fromEpochSeconds(1_700_000_000) + + assertEquals( + listOf( + SensorReading("AirBeamMini-PM1", "µg/m³", 7.0, at), + SensorReading("AirBeamMini-PM2.5", "µg/m³", 300.0, at), + ), + AirBeamProtocol.MiniV2.parseMeasurement(bytes), + ) + } + + /** Unsigned on the wire: 0xFFFF is 65535, not -1. */ + @Test + fun v2_values_are_unsigned() { + val bytes = byteArrayOf(1) + u32(1_700_000_000) + u16(0xFFFF) + u16(0) + + assertEquals(65535.0, AirBeamProtocol.MiniV2.parseMeasurement(bytes).first().value) + } + + @Test + fun v2_short_measurement_is_dropped() { + assertEquals(emptyList(), AirBeamProtocol.MiniV2.parseMeasurement(byteArrayOf(1, 2, 3))) + } + + /** `[count_u8, padding_2B, (timestamp_u32, pm1_u16, pm2_5_u16) × count]`. */ + @Test + fun v2_sync_chunk_yields_every_record_in_order() { + val bytes = byteArrayOf(2, 0, 0) + + u32(100) + u16(1) + u16(2) + + u32(101) + u16(3) + u16(4) + + assertEquals( + listOf( + SensorReading("AirBeamMini-PM1", "µg/m³", 1.0, Instant.fromEpochSeconds(100)), + SensorReading("AirBeamMini-PM2.5", "µg/m³", 2.0, Instant.fromEpochSeconds(100)), + SensorReading("AirBeamMini-PM1", "µg/m³", 3.0, Instant.fromEpochSeconds(101)), + SensorReading("AirBeamMini-PM2.5", "µg/m³", 4.0, Instant.fromEpochSeconds(101)), + ), + AirBeamProtocol.MiniV2.parseSyncChunk(bytes), + ) + } + + /** A chunk cut short keeps the records that arrived whole. */ + @Test + fun v2_sync_chunk_shorter_than_its_count_keeps_whole_records_only() { + val bytes = byteArrayOf(3, 0, 0) + u32(100) + u16(1) + u16(2) + u32(101) + + assertEquals(2, AirBeamProtocol.MiniV2.parseSyncChunk(bytes).size) + } + + private fun u32(v: Long) = ByteArray(4) { i -> (v shr (8 * i)).toByte() } + private fun u16(v: Int) = byteArrayOf(v.toByte(), (v shr 8).toByte()) +} From 26365a480f538269dfe687b9fcf418322556dc79 Mon Sep 17 00:00:00 2001 From: kgetman Date: Tue, 20 Oct 2026 11:20:30 +0200 Subject: [PATCH 2/3] refactor(airbeam): replace AirBeamSessionController with AirBeamLink The controller was the legacy shape in Flow clothing: fire-and-forget dispatch(action) like EventBus.post, seven mutable fields written from several threads, and a hand-rolled reconnect loop with rules nobody had written down. AirBeamLink is the connection only: - suspend connect/configure/disconnect that return their outcome; configure hands back the device's ConfigResult instead of flattening it to HandshakeFailed - one job per link, launched in a process scope, so the connection outlives the screen that started it; connect/disconnect serialized - reconnect per legacy AirBeamReconnector: mobile sessions only, every 2 s, no cap, resume (not reconfigure) on success. Fixed sessions no longer resend Wi-Fi credentials on a drop - LinkState holds no connection handle and no session data - readings: Flow that carries on across reconnects Connector seam: - connect returns ConnectResult instead of a fake already-failed connection; ConnectionStatus is the life of a live link only - AirBeamConnection gains device, readings, resumeMobileSession - BLE: per-link work runs in the scope Kable's connect() returns, Peripheral is close()d, MTU requested on Android (517 V1 / 247 V2), V2 replies are awaited before the write so none is missed, observe failures are logged instead of escaping - Classic: lines parsed into readings; the 30 s timeout now actually aborts the blocking socket connect - Composite: an unsupported transport is a Failed result, not a crash AirBeamForegroundService follows LinkState and is START_NOT_STICKY: a restarted process has nothing to restore the link from. --- .../service/AirBeamForegroundService.kt | 69 ++-- .../bluetooth/transport/ble/Mtu.android.kt | 16 + .../classic/ClassicAirBeamConnector.kt | 72 ++-- .../llp/aircasting/bluetooth/AirBeamLink.kt | 170 +++++++++ .../bluetooth/AirBeamSessionController.kt | 275 -------------- .../pl/llp/aircasting/bluetooth/Connection.kt | 47 ++- .../transport/CompositeAirBeamConnector.kt | 10 +- .../transport/ble/BleAirBeamConnector.kt | 340 ++++++++++-------- .../aircasting/bluetooth/transport/ble/Mtu.kt | 9 + .../kotlin/pl/llp/aircasting/di/AppModule.kt | 11 +- .../pl/llp/aircasting/scan/ScanViewModel.kt | 29 +- .../aircasting/bluetooth/AirBeamLinkTest.kt | 289 +++++++++++++++ .../bluetooth/AirBeamSessionControllerTest.kt | 129 ------- .../CompositeAirBeamConnectorTest.kt | 17 +- .../bluetooth/transport/FakeConnector.kt | 84 +++-- .../bluetooth/ui/scan/ScanViewModelTest.kt | 36 +- .../bluetooth/transport/ble/Mtu.ios.kt | 5 + 17 files changed, 891 insertions(+), 717 deletions(-) create mode 100644 shared/src/androidMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.android.kt create mode 100644 shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeamLink.kt delete mode 100644 shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeamSessionController.kt create mode 100644 shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.kt create mode 100644 shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/AirBeamLinkTest.kt delete mode 100644 shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/AirBeamSessionControllerTest.kt create mode 100644 shared/src/iosMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.ios.kt diff --git a/androidApp/src/main/kotlin/pl/llp/aircasting/service/AirBeamForegroundService.kt b/androidApp/src/main/kotlin/pl/llp/aircasting/service/AirBeamForegroundService.kt index a84433d..d151c9a 100644 --- a/androidApp/src/main/kotlin/pl/llp/aircasting/service/AirBeamForegroundService.kt +++ b/androidApp/src/main/kotlin/pl/llp/aircasting/service/AirBeamForegroundService.kt @@ -8,9 +8,9 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo -import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -20,12 +20,16 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import pl.llp.aircasting.bluetooth.AirBeamSessionController -import pl.llp.aircasting.bluetooth.SessionState +import pl.llp.aircasting.bluetooth.AirBeamLink +import pl.llp.aircasting.bluetooth.LinkState +/** + * Keeps the process alive while the AirBeam link is up. It holds no connection itself — the + * shared [AirBeamLink] does; this only mirrors its state and stops when the link ends. + */ class AirBeamForegroundService : Service(), KoinComponent { - private val sessionController: AirBeamSessionController by inject() + private val link: AirBeamLink by inject() private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) companion object { @@ -34,12 +38,7 @@ class AirBeamForegroundService : Service(), KoinComponent { private const val NOTIFICATION_ID = 1001 fun start(context: Context) { - val intent = Intent(context, AirBeamForegroundService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } + context.startForegroundService(Intent(context, AirBeamForegroundService::class.java)) } fun stop(context: Context) { @@ -51,13 +50,17 @@ class AirBeamForegroundService : Service(), KoinComponent { override fun onCreate() { super.onCreate() createNotificationChannel() - observeSessionState() + observeLink() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val initialNotification = buildNotification("AirBeam service active") - startForegroundWithCompat(initialNotification) - return START_STICKY + // ServiceCompat drops the service type below API 29, where the overload doesn't exist. + ServiceCompat.startForeground( + this, NOTIFICATION_ID, initialNotification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, + ) + // A restarted process has an idle link and nothing to restore it from — don't come back. + return START_NOT_STICKY } override fun onBind(intent: Intent?): IBinder? = null @@ -67,35 +70,27 @@ class AirBeamForegroundService : Service(), KoinComponent { super.onDestroy() } - private fun observeSessionState() { - sessionController.state - .dropWhile { it is SessionState.Idle } + private fun observeLink() { + link.state + .dropWhile { it is LinkState.Idle } .onEach { state -> when (state) { - SessionState.Idle, is SessionState.Failed -> { + LinkState.Idle, is LinkState.Failed -> { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } - is SessionState.Connecting -> updateNotification("Connecting to ${state.target.name}...") - is SessionState.Connected -> updateNotification("Connected to AirBeam") - is SessionState.Configuring -> updateNotification("Configuring session...") - is SessionState.Recording -> updateNotification("Recording active session") - is SessionState.Reconnecting -> updateNotification("Reconnecting to ${state.target.name} (Attempt ${state.attempt})...") + is LinkState.Connecting -> updateNotification("Connecting to ${state.target.name}...") + is LinkState.Connected -> updateNotification("Connected to ${state.target.name}") + is LinkState.Reconnecting -> updateNotification("Reconnecting to ${state.target.name} (Attempt ${state.attempt})...") } } .launchIn(serviceScope) } private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW - ) - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } + val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW) + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) } private fun buildNotification(contentText: String): Notification { @@ -114,16 +109,4 @@ class AirBeamForegroundService : Service(), KoinComponent { val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager manager.notify(NOTIFICATION_ID, notification) } - - private fun startForegroundWithCompat(notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } } diff --git a/shared/src/androidMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.android.kt b/shared/src/androidMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.android.kt new file mode 100644 index 0000000..0bdd0b5 --- /dev/null +++ b/shared/src/androidMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.android.kt @@ -0,0 +1,16 @@ +package pl.llp.aircasting.bluetooth.transport.ble + +import co.touchlab.kermit.Logger +import com.juul.kable.ServicesDiscoveredPeripheral +import kotlinx.coroutines.CancellationException + +internal actual suspend fun ServicesDiscoveredPeripheral.requestMtuIfSupported(mtu: Int) { + try { + val negotiated = requestMtu(mtu) + Logger.withTag("BleMtu").d { "MTU negotiated: $negotiated" } + } catch (cancel: CancellationException) { + throw cancel + } catch (e: Exception) { + Logger.withTag("BleMtu").w(e) { "MTU request for $mtu refused" } + } +} diff --git a/shared/src/androidMain/kotlin/pl/llp/aircasting/bluetooth/transport/classic/ClassicAirBeamConnector.kt b/shared/src/androidMain/kotlin/pl/llp/aircasting/bluetooth/transport/classic/ClassicAirBeamConnector.kt index 0ce6e88..576dabb 100644 --- a/shared/src/androidMain/kotlin/pl/llp/aircasting/bluetooth/transport/classic/ClassicAirBeamConnector.kt +++ b/shared/src/androidMain/kotlin/pl/llp/aircasting/bluetooth/transport/classic/ClassicAirBeamConnector.kt @@ -15,29 +15,34 @@ import pl.llp.aircasting.bluetooth.AirBeamConnection import pl.llp.aircasting.bluetooth.AirBeamConnector import pl.llp.aircasting.bluetooth.AirBeamDevice import pl.llp.aircasting.bluetooth.ConfigResult +import pl.llp.aircasting.bluetooth.ConnectResult import pl.llp.aircasting.bluetooth.ConnectionStatus import pl.llp.aircasting.bluetooth.DeviceId import pl.llp.aircasting.bluetooth.DiscoveredAirBeam import pl.llp.aircasting.bluetooth.FailureReason +import pl.llp.aircasting.bluetooth.SensorReading import pl.llp.aircasting.bluetooth.SessionConfig import pl.llp.aircasting.bluetooth.Transport import pl.llp.aircasting.bluetooth.protocol.AirBeamProtocol import pl.llp.aircasting.bluetooth.transport.accumulateDistinct +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import java.io.IOException import java.util.UUID import kotlin.let @@ -91,26 +96,32 @@ class ClassicAirBeamConnector( }.accumulateDistinct() @SuppressLint("MissingPermission") - override suspend fun connect(target: DiscoveredAirBeam): AirBeamConnection { - val adapter = adapter ?: return failed(FailureReason.RadioOrPermissionMissing) + override suspend fun connect(target: DiscoveredAirBeam): ConnectResult { + val adapter = adapter ?: return ConnectResult.Failed(FailureReason.RadioOrPermissionMissing) adapter.cancelDiscovery() // inquiry saturates the radio — always stop before connecting val socket = adapter.getRemoteDevice(target.id.value) .createRfcommSocketToServiceRecord(SPP_UUID) - return withContext(Dispatchers.IO) { + return coroutineScope { + // socket.connect() blocks and ignores cancellation; closing the socket is what aborts it. + val watchdog = launch { + delay(CONNECT_TIMEOUT_MS.milliseconds) + socket.safeClose() + } try { - withTimeout(CONNECT_TIMEOUT_MS.milliseconds) { - socket.connect() // blocking until linked or IOException - } + runInterruptible(Dispatchers.IO) { socket.connect() } + watchdog.cancel() delay(ESTIMATED_CONNECTING_TIME_MS.milliseconds) - ClassicConnection(socket, target.device) - } catch (e: TimeoutCancellationException) { - socket.safeClose() - failed(FailureReason.LinkTimeout) + ConnectResult.Connected(ClassicConnection(socket, target.device)) } catch (_: IOException) { + val timedOut = watchdog.isCompleted // ran to the end: it closed the socket on us + watchdog.cancel() socket.safeClose() - failed(FailureReason.HandshakeFailed) + ConnectResult.Failed(if (timedOut) FailureReason.LinkTimeout else FailureReason.HandshakeFailed) + } catch (cancel: CancellationException) { + socket.safeClose() + throw cancel } } } @@ -118,36 +129,38 @@ class ClassicAirBeamConnector( private class ClassicConnection( private val socket: BluetoothSocket, - val device: AirBeamDevice, + override val device: AirBeamDevice, ) : AirBeamConnection { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val _status = MutableStateFlow(ConnectionStatus.Ready(device)) + private val _status = MutableStateFlow(ConnectionStatus.Ready) override val status: StateFlow = _status.asStateFlow() override val deviceState = null // AB2 does not report its own state private val log = Logger.withTag("ClassicConnection") - private var isDisconnectingExplicitly = false + private val received = Channel(Channel.UNLIMITED) + override val readings = received.receiveAsFlow() init { + // AB2 streams one ASCII measurement line at a time (legacy AirBeam2Reader). scope.launch { - val buffer = ByteArray(1024) try { - while (!isDisconnectingExplicitly && socket.isConnected) { - val bytesRead = socket.inputStream.read(buffer) - if (bytesRead == -1) break + val lines = socket.inputStream.bufferedReader() + while (true) { + val line = runInterruptible { lines.readLine() } ?: break + AirBeamProtocol.Standard.parseReading(line)?.let { received.trySend(it) } } } catch (e: IOException) { log.d { "Classic socket read loop ended: ${e.message}" } } finally { - if (!isDisconnectingExplicitly && _status.value !is ConnectionStatus.Disconnected) { + received.close() + if (_status.compareAndSet(ConnectionStatus.Ready, ConnectionStatus.DisconnectedUnexpectedly)) { log.w { "Classic connection dropped unexpectedly" } - _status.value = ConnectionStatus.DisconnectedUnexpectedly } } } } override suspend fun configure(config: SessionConfig): ConfigResult { - log.i { "Configuring AirBeam2 ($device) with config: $config" } + log.i { "Configuring AirBeam2 ($device) with $config" } return try { when (config) { is SessionConfig.Mobile -> configureMobile() @@ -160,6 +173,9 @@ private class ClassicConnection( } } + /** Legacy `AirBeam2Configurator.reconnectMobileSession`: mobile mode again. */ + override suspend fun resumeMobileSession(config: SessionConfig.Mobile): ConfigResult = configure(config) + private suspend fun configureMobile(): ConfigResult { // Mobile Session (Bluetooth): Send [0x01, 0xFF] sendCommand(AirBeamProtocol.AirBeam2.startMobileCommand()) @@ -210,20 +226,12 @@ private class ClassicConnection( } override suspend fun disconnect() { - isDisconnectingExplicitly = true + _status.compareAndSet(ConnectionStatus.Ready, ConnectionStatus.Disconnected) scope.cancel() withContext(Dispatchers.IO) { socket.safeClose() } - _status.value = ConnectionStatus.Disconnected } } -private fun failed(reason: FailureReason): AirBeamConnection = object : AirBeamConnection { - override val status = MutableStateFlow(ConnectionStatus.Failed(reason)) - override val deviceState = null - override suspend fun configure(config: SessionConfig) = ConfigResult.UnknownFailure - override suspend fun disconnect() {} -} - private fun BluetoothSocket.safeClose() { try { close() diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeamLink.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeamLink.kt new file mode 100644 index 0000000..528772a --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeamLink.kt @@ -0,0 +1,170 @@ +package pl.llp.aircasting.bluetooth + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** The app's view of its AirBeam link. Holds no platform handles, so it is safe to hand to UI. */ +sealed interface LinkState { + data object Idle : LinkState + data class Connecting(val target: DiscoveredAirBeam) : LinkState + data class Connected(val target: DiscoveredAirBeam) : LinkState + /** A mobile session's link dropped; [attempt] counts from 1 and has no cap. */ + data class Reconnecting(val target: DiscoveredAirBeam, val attempt: Int) : LinkState + data class Failed(val reason: FailureReason) : LinkState +} + +/** + * The one AirBeam connection of the app, owned for the life of the process — not by a screen. + * Connection-level only: it knows whether a mobile session was started over the link (that is + * what it reconnects for), but sessions, their status and their data belong to the recording side. + */ +interface AirBeamLink { + val state: StateFlow + + /** Readings of whichever connection is current; carries on across reconnects. One collector. */ + val readings: Flow + + /** Suspends until the attempt settles: [LinkState.Connected] or [LinkState.Failed]. */ + suspend fun connect(target: DiscoveredAirBeam): LinkState + + /** A successful [SessionConfig.Mobile] arms reconnecting; anything else disarms it. */ + suspend fun configure(config: SessionConfig): ConfigResult + + /** Ends the link and any reconnecting. Stop and standalone mode both land here. */ + suspend fun disconnect() +} + +class DefaultAirBeamLink( + private val connector: AirBeamConnector, + /** Process-lifetime; the link's work runs here so it outlives the screens that start it. */ + private val scope: CoroutineScope, + /** Legacy `AirBeamReconnector.RECONNECTION_TRIES_INTERVAL`. */ + private val reconnectInterval: Duration = 2.seconds, +) : AirBeamLink { + private val log = Logger.withTag("AirBeamLink") + + private val _state = MutableStateFlow(LinkState.Idle) + override val state: StateFlow = _state.asStateFlow() + + /** + * The newest connection. Kept after it drops until a new one replaces it, so its buffered + * readings still drain; only `state` says whether it is usable. + */ + private val current = MutableStateFlow(null) + + /** What a dropped link reconnects for. Set by a successful mobile [configure]. */ + private val mobileSession = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + override val readings: Flow = current.flatMapLatest { it?.readings ?: emptyFlow() } + + /** Serializes [connect] / [disconnect]: each one cancels and awaits the previous [link]. */ + private val commands = Mutex() + private var link: Job? = null + + override suspend fun connect(target: DiscoveredAirBeam): LinkState { + val settled = CompletableDeferred() + commands.withLock { + stop() + link = scope.launch { run(target, settled) } + } + return settled.await() + } + + override suspend fun configure(config: SessionConfig): ConfigResult { + val connection = current.value?.takeIf { _state.value is LinkState.Connected } + ?: return ConfigResult.NotConnected + val result = connection.configure(config) + if (result == ConfigResult.Success) mobileSession.value = config as? SessionConfig.Mobile + return result + } + + override suspend fun disconnect() = commands.withLock { + stop() + _state.value = LinkState.Idle + } + + private suspend fun stop() { + link?.cancelAndJoin() + link = null + mobileSession.value = null + } + + /** One link's whole life: connect, then hold it — and win it back — until cancelled. */ + private suspend fun run(target: DiscoveredAirBeam, settled: CompletableDeferred) { + try { + _state.value = LinkState.Connecting(target) + var connection = when (val result = connectOnce(target)) { + is ConnectResult.Failed -> return settle(settled, LinkState.Failed(result.reason)) + is ConnectResult.Connected -> result.connection + } + while (true) { + current.value = connection + settle(settled, LinkState.Connected(target)) + + connection.status.first { it != ConnectionStatus.Ready } + connection.disconnect() + val session = mobileSession.value + ?: return settle(settled, LinkState.Failed(FailureReason.ConnectionLost)) + log.w { "${target.name} dropped mid-session, reconnecting" } + connection = reconnect(target, session) + } + } finally { + settled.complete(LinkState.Idle) // cancelled before settling: someone disconnected + // A connection that is no longer Ready was already released by the loop above. + current.getAndUpdate { null } + ?.takeIf { it.status.value == ConnectionStatus.Ready } + ?.let { withContext(NonCancellable) { it.disconnect() } } + } + } + + private fun settle(settled: CompletableDeferred, state: LinkState) { + _state.value = state + settled.complete(state) + } + + /** Legacy `AirBeamReconnector`: every [reconnectInterval], no cap; only [disconnect] stops it. */ + private suspend fun reconnect(target: DiscoveredAirBeam, session: SessionConfig.Mobile): AirBeamConnection { + var attempt = 1 + while (true) { + _state.value = LinkState.Reconnecting(target, attempt++) + delay(reconnectInterval) + val connection = (connectOnce(target) as? ConnectResult.Connected)?.connection ?: continue + val resumed = connection.resumeMobileSession(session) + if (resumed == ConfigResult.Success) return connection + log.w { "${target.name} reconnected but did not resume: $resumed" } + connection.disconnect() + } + } + + /** The connector reports failures as values; this keeps a stray platform throw from crashing [scope]. */ + private suspend fun connectOnce(target: DiscoveredAirBeam): ConnectResult = try { + connector.connect(target) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.e(e) { "Connecting to ${target.name} threw" } + ConnectResult.Failed(FailureReason.HandshakeFailed) + } +} diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeamSessionController.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeamSessionController.kt deleted file mode 100644 index ab93642..0000000 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeamSessionController.kt +++ /dev/null @@ -1,275 +0,0 @@ -package pl.llp.aircasting.bluetooth - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlin.time.Clock -import kotlin.time.Duration.Companion.seconds - -sealed interface SessionAction { - data class Connect(val target: DiscoveredAirBeam) : SessionAction - data class ConfigureAndStart(val config: SessionConfig) : SessionAction - data object StopSession : SessionAction - data object Disconnect : SessionAction - data object UserCancelled : SessionAction -} - -sealed interface SessionState { - data object Idle : SessionState - - data class Connecting( - val target: DiscoveredAirBeam, - val attempt: Int = 1 - ) : SessionState - - data class Connected( - val device: AirBeamDevice, - val connection: AirBeamConnection, - val target: DiscoveredAirBeam - ) : SessionState - - data class Configuring( - val device: AirBeamDevice, - val connection: AirBeamConnection, - val config: SessionConfig, - val target: DiscoveredAirBeam - ) : SessionState - - data class Recording( - val device: AirBeamDevice, - val connection: AirBeamConnection, - val config: SessionConfig, - val target: DiscoveredAirBeam, - val startTimeMs: Long - ) : SessionState - - data class Reconnecting( - val target: DiscoveredAirBeam, - val attempt: Int, - val previousConfig: SessionConfig? = null - ) : SessionState - - data class Failed(val reason: FailureReason) : SessionState -} - -interface AirBeamSessionController { - val state: StateFlow - fun dispatch(action: SessionAction) -} - -class DefaultAirBeamSessionController( - private val connector: AirBeamConnector, - private val clock: Clock = Clock.System, - private val externalScope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) -) : AirBeamSessionController { - - private val _state = MutableStateFlow(SessionState.Idle) - override val state: StateFlow = _state.asStateFlow() - - private val log = Logger.withTag("AirBeamSessionController") - - private var activeConnection: AirBeamConnection? = null - private var activeTarget: DiscoveredAirBeam? = null - private var activeConfig: SessionConfig? = null - private var connectJob: Job? = null - private var configJob: Job? = null - private var statusJob: Job? = null - private var reconnectJob: Job? = null - - override fun dispatch(action: SessionAction) { - log.i { "Dispatched action: $action (current state: ${_state.value})" } - when (action) { - is SessionAction.Connect -> handleConnect(action.target) - is SessionAction.ConfigureAndStart -> handleConfigureAndStart(action.config) - SessionAction.StopSession -> handleDisconnect() - SessionAction.Disconnect -> handleDisconnect() - SessionAction.UserCancelled -> handleDisconnect() - } - } - - private fun handleConnect(target: DiscoveredAirBeam) { - cancelActiveSession() - activeTarget = target - _state.value = SessionState.Connecting(target, attempt = 1) - - connectJob = externalScope.launch { - connectInternal(target) - } - } - - private suspend fun connectInternal(target: DiscoveredAirBeam) { - try { - val conn = connector.connect(target) - val outcome = conn.status.first { - it is ConnectionStatus.Ready || it is ConnectionStatus.Failed - } - - when (outcome) { - is ConnectionStatus.Ready -> { - activeConnection = conn - activeTarget = target - _state.value = SessionState.Connected(outcome.device, conn, target) - observeConnectionStatus(conn, target) - } - is ConnectionStatus.Failed -> { - _state.value = SessionState.Failed(outcome.reason) - } - else -> { - _state.value = SessionState.Failed(FailureReason.HandshakeFailed) - } - } - } catch (cancel: CancellationException) { - throw cancel - } catch (e: Exception) { - log.e(e) { "Error connecting to ${target.name}" } - _state.value = SessionState.Failed(FailureReason.HandshakeFailed) - } - } - - private fun handleConfigureAndStart(config: SessionConfig) { - val currentState = _state.value - if (currentState !is SessionState.Connected) { - log.w { "Cannot configure and start session when not in Connected state. Current: $currentState" } - return - } - - val conn = currentState.connection - val device = currentState.device - val target = currentState.target - activeConfig = config - - configJob?.cancel() - _state.value = SessionState.Configuring(device, conn, config, target) - - configJob = externalScope.launch { - val result = conn.configure(config) - if (result is ConfigResult.Success) { - val now = clock.now().toEpochMilliseconds() - _state.value = SessionState.Recording(device, conn, config, target, startTimeMs = now) - log.i { "Session recording started successfully for device: $device" } - } else { - log.e { "Configuration failed with result: $result" } - _state.value = SessionState.Failed(FailureReason.HandshakeFailed) - } - } - } - - private fun observeConnectionStatus(connection: AirBeamConnection, target: DiscoveredAirBeam) { - statusJob?.cancel() - statusJob = connection.status.onEach { status -> - log.d { "Observed connection status change: $status" } - if (status is ConnectionStatus.DisconnectedUnexpectedly) { - val currentState = _state.value - if (currentState is SessionState.Recording || currentState is SessionState.Connected) { - triggerAutoReconnect(target, currentState) - } else { - _state.value = SessionState.Idle - } - } - }.launchIn(externalScope) - } - - private fun triggerAutoReconnect(target: DiscoveredAirBeam, lastKnownState: SessionState) { - statusJob?.cancel() - reconnectJob?.cancel() - - val prevConfig = (lastKnownState as? SessionState.Recording)?.config ?: activeConfig - _state.value = SessionState.Reconnecting(target, attempt = 1, previousConfig = prevConfig) - - reconnectJob = externalScope.launch { - var attempt = 1 - val maxAttempts = 5 - var reconnected = false - - while (attempt <= maxAttempts && !reconnected) { - log.i { "Auto-reconnect attempt $attempt for ${target.name}" } - _state.value = SessionState.Reconnecting(target, attempt, previousConfig = prevConfig) - - delay((2 * attempt).seconds) - - try { - val conn = connector.connect(target) - val outcome = conn.status.first { - it is ConnectionStatus.Ready || it is ConnectionStatus.Failed - } - - if (outcome is ConnectionStatus.Ready) { - activeConnection = conn - reconnected = true - log.i { "Successfully reconnected to ${target.name} on attempt $attempt" } - - if (prevConfig != null) { - _state.value = SessionState.Configuring(outcome.device, conn, prevConfig, target) - val configResult = conn.configure(prevConfig) - if (configResult is ConfigResult.Success) { - _state.value = SessionState.Recording( - outcome.device, - conn, - prevConfig, - target, - startTimeMs = clock.now().toEpochMilliseconds() - ) - } else { - _state.value = SessionState.Connected(outcome.device, conn, target) - } - } else { - _state.value = SessionState.Connected(outcome.device, conn, target) - } - observeConnectionStatus(conn, target) - } - } catch (_: Exception) { - log.w { "Reconnect attempt $attempt failed" } - } - - attempt++ - } - - if (!reconnected) { - log.e { "Auto-reconnect failed after $maxAttempts attempts" } - _state.value = SessionState.Failed(FailureReason.LinkTimeout) - } - } - } - - private fun handleDisconnect() { - cancelActiveSession() - _state.value = SessionState.Idle - } - - private fun cancelActiveSession() { - connectJob?.cancel() - connectJob = null - configJob?.cancel() - configJob = null - statusJob?.cancel() - statusJob = null - reconnectJob?.cancel() - reconnectJob = null - - val conn = activeConnection - activeConnection = null - activeTarget = null - activeConfig = null - - if (conn != null) { - externalScope.launch { - try { - conn.disconnect() - } catch (e: Exception) { - log.w(e) { "Error disconnecting connection" } - } - } - } - } -} diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/Connection.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/Connection.kt index 980291d..4addda6 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/Connection.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/Connection.kt @@ -8,22 +8,51 @@ import kotlin.uuid.Uuid interface AirBeamConnector { val supportedTransports: Set fun scan(): Flow> - suspend fun connect(target: DiscoveredAirBeam): AirBeamConnection + + /** Link + handshake. Device and radio trouble is a [ConnectResult.Failed], never a throw. */ + suspend fun connect(target: DiscoveredAirBeam): ConnectResult +} + +sealed interface ConnectResult { + data class Connected(val connection: AirBeamConnection) : ConnectResult + data class Failed(val reason: FailureReason) : ConnectResult } +/** + * One live, handshaken link. Holds platform resources (GATT client, socket) until + * [disconnect] — call it even after the link dropped by itself. + */ interface AirBeamConnection { + val device: AirBeamDevice + + /** Starts [ConnectionStatus.Ready] and leaves it once; a connection never comes back. */ val status: StateFlow val deviceState: StateFlow? + + /** + * Everything the device measures — live, and for Mini V2 the stored backlog it streams after + * a reconnect. Buffered from connect on, so nothing is lost before the first collector; meant + * for exactly one collector. + */ + val readings: Flow + suspend fun configure(config: SessionConfig): ConfigResult + + /** + * Picks up a mobile session that was running before the link dropped. Not [configure] + * again: Mini V2 still runs its session and would reject a new config. + */ + suspend fun resumeMobileSession(config: SessionConfig.Mobile): ConfigResult + suspend fun disconnect() } -sealed interface ConnectionStatus { - data object Disconnected : ConnectionStatus - data object Connecting : ConnectionStatus - data class Ready(val device: AirBeamDevice) : ConnectionStatus - data class Failed(val reason: FailureReason) : ConnectionStatus - data object DisconnectedUnexpectedly : ConnectionStatus +enum class ConnectionStatus { + Ready, + /** We called [AirBeamConnection.disconnect]. */ + Disconnected, + /** The link dropped on its own: out of range, battery, radio off. */ + DisconnectedUnexpectedly, } sealed interface FailureReason { @@ -32,6 +61,8 @@ sealed interface FailureReason { data object WrongCommunicationSurface: FailureReason data object HandshakeFailed: FailureReason data object RadioOrPermissionMissing: FailureReason + /** A ready link dropped with no session to reconnect for. */ + data object ConnectionLost: FailureReason } sealed interface ConfigResult { @@ -40,6 +71,8 @@ sealed interface ConfigResult { data object WifiPassFailure: ConfigResult data object BadConfigFailure: ConfigResult data object NotSyncedFailure: ConfigResult + /** Nothing to configure: no ready link at the time of the call. */ + data object NotConnected: ConfigResult } sealed interface SessionConfig { diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/CompositeAirBeamConnector.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/CompositeAirBeamConnector.kt index d2c36e6..efcf3c8 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/CompositeAirBeamConnector.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/CompositeAirBeamConnector.kt @@ -1,8 +1,9 @@ package pl.llp.aircasting.bluetooth.transport -import pl.llp.aircasting.bluetooth.AirBeamConnection import pl.llp.aircasting.bluetooth.AirBeamConnector +import pl.llp.aircasting.bluetooth.ConnectResult import pl.llp.aircasting.bluetooth.DiscoveredAirBeam +import pl.llp.aircasting.bluetooth.FailureReason import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -19,8 +20,9 @@ class CompositeAirBeamConnector( .flatten() } - override suspend fun connect(target: DiscoveredAirBeam): AirBeamConnection = + /** No delegate for the transport — e.g. an AirBeam2 on iOS — is a failure, not a crash. */ + override suspend fun connect(target: DiscoveredAirBeam): ConnectResult = delegates.firstOrNull { target.device.transport in it.supportedTransports } ?.connect(target) - ?: error("No connector for transport ${target.device.transport}") -} \ No newline at end of file + ?: ConnectResult.Failed(FailureReason.WrongCommunicationSurface) +} diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/BleAirBeamConnector.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/BleAirBeamConnector.kt index f98cefb..9ec0e15 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/BleAirBeamConnector.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/BleAirBeamConnector.kt @@ -1,56 +1,66 @@ package pl.llp.aircasting.bluetooth.transport.ble import co.touchlab.kermit.Logger +import com.juul.kable.Characteristic import com.juul.kable.Peripheral import com.juul.kable.Scanner import com.juul.kable.State import com.juul.kable.WriteType import com.juul.kable.characteristicOf -import pl.llp.aircasting.bluetooth.AirBeamConnection -import pl.llp.aircasting.bluetooth.AirBeamConnector -import pl.llp.aircasting.bluetooth.AirBeamDevice -import pl.llp.aircasting.bluetooth.ConnectionStatus -import pl.llp.aircasting.bluetooth.DeviceId -import pl.llp.aircasting.bluetooth.DiscoveredAirBeam -import pl.llp.aircasting.bluetooth.FailureReason -import pl.llp.aircasting.bluetooth.Transport -import pl.llp.aircasting.bluetooth.detection.airBeamFrom -import pl.llp.aircasting.bluetooth.transport.accumulateDistinct -import pl.llp.aircasting.bluetooth.v2_firmware_specific.DeviceReportedState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.cancel +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.datetime.toLocalDateTime +import pl.llp.aircasting.bluetooth.AirBeamConnection +import pl.llp.aircasting.bluetooth.AirBeamConnector +import pl.llp.aircasting.bluetooth.AirBeamDevice import pl.llp.aircasting.bluetooth.ConfigResult +import pl.llp.aircasting.bluetooth.ConnectResult +import pl.llp.aircasting.bluetooth.ConnectionStatus +import pl.llp.aircasting.bluetooth.DeviceId +import pl.llp.aircasting.bluetooth.DiscoveredAirBeam +import pl.llp.aircasting.bluetooth.FailureReason +import pl.llp.aircasting.bluetooth.SensorReading import pl.llp.aircasting.bluetooth.SessionConfig +import pl.llp.aircasting.bluetooth.Transport +import pl.llp.aircasting.bluetooth.detection.airBeamFrom import pl.llp.aircasting.bluetooth.protocol.AirBeamProtocol -import kotlin.math.abs -import kotlin.math.pow -import kotlin.math.roundToLong -import kotlin.time.Clock +import pl.llp.aircasting.bluetooth.protocol.AirBeamProtocol.MiniV2.CommandResponse +import pl.llp.aircasting.bluetooth.transport.accumulateDistinct +import pl.llp.aircasting.bluetooth.v2_firmware_specific.DeviceReportedState +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid private val SCAN_TIMEOUT_DURATION = 10.seconds private val CONNECT_TIMEOUT_DURATION = 30.seconds -private val HANDSHAKE_SETTLE_DURATION = 500.milliseconds private val STATUS_SETTLE_TIMEOUT = 5.seconds +/** Legacy `SyncableAirBeamConfigurator.MAX_MTU`: one ASCII measurement line per notification. */ +private const val V1_MTU = 517 +/** Legacy `AirBeamMiniV2Configurator`: fits a 244-byte sync chunk. */ +private const val V2_MTU = 247 + class BleAirBeamConnector() : AirBeamConnector { override val supportedTransports = setOf(Transport.BLE) @@ -66,118 +76,155 @@ class BleAirBeamConnector() : AirBeamConnector { } .accumulateDistinct() - override suspend fun connect(target: DiscoveredAirBeam): AirBeamConnection { - val advertisement = withTimeoutOrNull(SCAN_TIMEOUT_DURATION) { - Scanner().advertisements.first { it.identifier.toString() == target.id.value } - } ?: return failedConnection(FailureReason.NoDeviceFound) + override suspend fun connect(target: DiscoveredAirBeam): ConnectResult { + val advertisement = try { + withTimeoutOrNull(SCAN_TIMEOUT_DURATION) { + Scanner().advertisements.first { it.identifier.toString() == target.id.value } + } ?: return ConnectResult.Failed(FailureReason.NoDeviceFound) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Exception) { + return ConnectResult.Failed(FailureReason.RadioOrPermissionMissing) + } - val peripheral = Peripheral(advertisement) - try { + val mtu = if (target.device is AirBeamDevice.Mini.V2) V2_MTU else V1_MTU + val peripheral = Peripheral(advertisement) { + onServicesDiscovered { requestMtuIfSupported(mtu) } + } + // Cancelled by Kable the moment this link ends — everything per-link runs in it. + val linkScope = try { withTimeout(CONNECT_TIMEOUT_DURATION) { peripheral.connect() } - + } catch (_: TimeoutCancellationException) { + peripheral.release() + return ConnectResult.Failed(FailureReason.LinkTimeout) } catch (cancel: CancellationException) { - peripheral.disconnectQuietly() - if (cancel is TimeoutCancellationException) - return failedConnection(FailureReason.LinkTimeout) + peripheral.release() throw cancel - } catch (_: Exception) { - peripheral.disconnectQuietly() - return failedConnection(FailureReason.LinkTimeout) + peripheral.release() + return ConnectResult.Failed(FailureReason.LinkTimeout) } return try { - if (target.device is AirBeamDevice.Mini.V2) { - connectV2(peripheral, target.device) - } else { - BleConnection(peripheral, target.device) - } - - } catch (_: TimeoutCancellationException) { - peripheral.disconnectQuietly() - failedConnection(FailureReason.HandshakeFailed) + val readings = peripheral.readings(target.device, linkScope) + val deviceState = if (target.device is AirBeamDevice.Mini.V2) settleV2(peripheral, linkScope) else null + ConnectResult.Connected(BleConnection(peripheral, target.device, readings, deviceState)) } catch (cancel: CancellationException) { - peripheral.disconnectQuietly() + peripheral.release() throw cancel } catch (_: Exception) { - peripheral.disconnectQuietly() - failedConnection(FailureReason.HandshakeFailed) + peripheral.release() + ConnectResult.Failed(FailureReason.HandshakeFailed) } } - private suspend fun connectV2(peripheral: Peripheral, device: AirBeamDevice): AirBeamConnection { - // Scope outlives connect(): keeps observing Status for later transitions (Idle -> Running, etc.). - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - listOf(AirBeamGatt.MiniV2.response, AirBeamGatt.MiniV2.measurement, - AirBeamGatt.MiniV2.sync).forEach { ch -> - peripheral.observe(ch).launchIn(scope) - } - val states = peripheral.observe(AirBeamGatt.MiniV2.status,) - .mapNotNull { DeviceReportedState.from(it) } + /** V2 reports its state on connect; it decides what the app does next (new / continue / sync). */ + private suspend fun settleV2(peripheral: Peripheral, linkScope: CoroutineScope): StateFlow? { + // Kept subscribed for the life of the link, so a command's reply is never missed. + peripheral.observe(AirBeamGatt.MiniV2.response).logFailures().launchIn(linkScope) + val states = peripheral.observe(AirBeamGatt.MiniV2.status).mapNotNull { DeviceReportedState.from(it) } // stateIn (suspend overload) subscribes, then suspends until the first frame — that IS the settle. - val deviceState = withTimeoutOrNull(STATUS_SETTLE_TIMEOUT) { states.stateIn(scope) } - - if (deviceState == null && peripheral.state.value !is State.Connected) { - scope.cancel() - peripheral.disconnectQuietly() - return failedConnection(FailureReason.LinkTimeout) - } + val deviceState = withTimeoutOrNull(STATUS_SETTLE_TIMEOUT) { states.stateIn(linkScope) } + if (deviceState == null && peripheral.state.value !is State.Connected) error("V2 dropped while settling") + return deviceState + } +} - return BleConnection(peripheral, device, deviceState, scope) +/** + * Subscribes every measurement characteristic of [device] and buffers what arrives until the + * reader collects it. Closes when the link ends. + */ +private fun Peripheral.readings(device: AirBeamDevice, linkScope: CoroutineScope): Flow { + val received = Channel(Channel.UNLIMITED) + linkScope.coroutineContext.job.invokeOnCompletion { received.close() } + readingSources(device).forEach { (characteristic, parse) -> + observe(characteristic) + .onEach { bytes -> parse(bytes).forEach { received.trySend(it) } } + .logFailures() + .launchIn(linkScope) } + return received.receiveAsFlow() } +private fun readingSources(device: AirBeamDevice): List List>> { + val line = { bytes: ByteArray -> listOfNotNull(AirBeamProtocol.Standard.parseReading(bytes.decodeToString())) } + return when (device) { + // Legacy AirBeam3Configurator.measurementsCharacteristicUUIDs + AirBeamDevice.AirBeam3 -> with(AirBeamGatt.Standard) { listOf(temperatureF, humidity, PM1, PM2_5, PM10) } + .map { it to line } + // Legacy AirBeamMiniConfigurator (its battery characteristic carries no readings) + AirBeamDevice.Mini.V1 -> with(AirBeamGatt.Standard) { listOf(PM1, PM2_5) }.map { it to line } + AirBeamDevice.Mini.V2 -> listOf( + AirBeamGatt.MiniV2.measurement to AirBeamProtocol.MiniV2::parseMeasurement, + AirBeamGatt.MiniV2.sync to AirBeamProtocol.MiniV2::parseSyncChunk, + ) + AirBeamDevice.AirBeam2 -> emptyList() + } +} private class BleConnection( private val peripheral: Peripheral, - val device: AirBeamDevice, - override val deviceState: StateFlow? = null, - passedScope: CoroutineScope? = null, + override val device: AirBeamDevice, + override val readings: Flow, + override val deviceState: StateFlow?, ) : AirBeamConnection { - private val scope: CoroutineScope = passedScope ?: CoroutineScope(Dispatchers.Default + SupervisorJob()) - private val _status = MutableStateFlow(ConnectionStatus.Ready(device)) + private val _status = MutableStateFlow(ConnectionStatus.Ready) override val status: StateFlow = _status.asStateFlow() private val log = Logger.withTag("BleConnection") - private var isDisconnectingExplicitly = false init { - peripheral.state - .onEach { state -> - log.d { "Peripheral state changed: $state" } - if (state is State.Disconnected && !isDisconnectingExplicitly && _status.value !is ConnectionStatus.Disconnected) { - log.w { "Peripheral disconnected unexpectedly" } - _status.value = ConnectionStatus.DisconnectedUnexpectedly - } + // peripheral.scope, not the link scope: Kable cancels the latter on the very drop we watch for. + peripheral.scope.launch { + peripheral.state.first { it is State.Disconnected } + if (_status.compareAndSet(ConnectionStatus.Ready, ConnectionStatus.DisconnectedUnexpectedly)) { + log.w { "$device disconnected unexpectedly" } } - .launchIn(scope) + } } override suspend fun configure(config: SessionConfig): ConfigResult { - log.i { "Configuring device: $device with config: $config" } - return try { + log.i { "Configuring $device with $config" } + return guarded { when (device) { - AirBeamDevice.AirBeam3, AirBeamDevice.Mini.V1 -> { - oldSetup(config) - } - AirBeamDevice.Mini.V2 -> { - miniV2Setup(config) - } - AirBeamDevice.AirBeam2 -> { - log.e { "BLE connection configure method attempted to configure AirBeam2" } - ConfigResult.UnknownFailure + AirBeamDevice.AirBeam3, AirBeamDevice.Mini.V1 -> oldSetup(config) + AirBeamDevice.Mini.V2 -> miniV2Setup(config) + AirBeamDevice.AirBeam2 -> ConfigResult.UnknownFailure // classic serial, never over BLE + } + } + } + + /** + * Legacy `reconnectMobileSession()`: V1 devices get mobile mode again; a V2 keeps running its + * session through the drop, so it gets the time and — if it paused — `ContinueSession`. + */ + override suspend fun resumeMobileSession(config: SessionConfig.Mobile): ConfigResult = guarded { + when (device) { + AirBeamDevice.AirBeam3, AirBeamDevice.Mini.V1 -> oldSetup(config) + AirBeamDevice.Mini.V2 -> { + sendV2Command(AirBeamProtocol.MiniV2.timeSyncCommand()) + when (deviceState?.value) { + DeviceReportedState.HasSavedSession -> + sendV2AndAwait(AirBeamProtocol.MiniV2.continueSessionCommand(), 10.seconds) + // Running: sync + live data already flow. Idle / unknown: legacy logs and carries on. + else -> ConfigResult.Success } } - } catch (e: Exception) { - log.e(e) { "Configuration failed for $device" } - ConfigResult.UnknownFailure + AirBeamDevice.AirBeam2 -> ConfigResult.UnknownFailure } } override suspend fun disconnect() { - isDisconnectingExplicitly = true - scope.cancel() - peripheral.disconnect() - _status.value = ConnectionStatus.Disconnected + _status.compareAndSet(ConnectionStatus.Ready, ConnectionStatus.Disconnected) + peripheral.release() + } + + private suspend fun guarded(block: suspend () -> ConfigResult): ConfigResult = try { + block() + } catch (cancel: CancellationException) { + throw cancel + } catch (e: Exception) { + log.e(e) { "Configuration failed for $device" } + ConfigResult.UnknownFailure } private suspend fun oldSetup(config: SessionConfig): ConfigResult { @@ -206,65 +253,49 @@ private class BleConnection( sendV2Command(AirBeamProtocol.MiniV2.timeSyncCommand()) // 2. Configure Session - when (config) { - is SessionConfig.Mobile -> { - sendV2Command(AirBeamProtocol.MiniV2.mobileSessionConfigCommand(config.uuid)) + return when (config) { + is SessionConfig.Mobile -> + sendV2AndAwait(AirBeamProtocol.MiniV2.mobileSessionConfigCommand(config.uuid), 10.seconds) + + // Device attempts to connect to WiFi: WiFi OK -> session starts; WiFi Failed -> Nack(0x05 InvalidWifiCredentials) + is SessionConfig.FixedWiFi -> sendV2AndAwait( + AirBeamProtocol.MiniV2.fixedWifiSessionConfigCommand( + uuid = config.uuid, + authToken = config.authToken, + ssid = config.ssid, + password = config.password, + pm1Index = config.pm1Index, + pm25Index = config.pm25Index, + ), + 15.seconds, + ) - val response = withTimeoutOrNull(10.seconds) { - peripheral.observe(AirBeamGatt.MiniV2.response) - .mapNotNull { AirBeamProtocol.MiniV2.CommandResponse.parse(it) } - .first { it !is AirBeamProtocol.MiniV2.CommandResponse.Ack } - } - - return when (response) { - is AirBeamProtocol.MiniV2.CommandResponse.Ready -> ConfigResult.Success - is AirBeamProtocol.MiniV2.CommandResponse.Nack -> { - log.e { "Mini V2 mobile setup failed with Nack: ${response.errorCode}" } - mapNackToConfigResult(response.errorCode) - } - else -> { - log.i { "Mini V2 mobile setup completed (no Nack received)" } - ConfigResult.Success - } - } + is SessionConfig.FixedCellular -> { + log.e { "Mini V2 does not support FixedCellular configuration" } + ConfigResult.BadConfigFailure } + } + } - is SessionConfig.FixedWiFi -> { - sendV2Command( - AirBeamProtocol.MiniV2.fixedWifiSessionConfigCommand( - uuid = config.uuid, - authToken = config.authToken, - ssid = config.ssid, - password = config.password, - pm1Index = config.pm1Index, - pm25Index = config.pm25Index, - ) - ) - - // Device attempts to connect to WiFi: WiFi OK -> session starts; WiFi Failed -> Nack(0x05 InvalidWifiCredentials) - val response = withTimeoutOrNull(15.seconds) { - peripheral.observe(AirBeamGatt.MiniV2.response) - .mapNotNull { AirBeamProtocol.MiniV2.CommandResponse.parse(it) } - .first { it !is AirBeamProtocol.MiniV2.CommandResponse.Ack } - } - - return when (response) { - is AirBeamProtocol.MiniV2.CommandResponse.Ready -> ConfigResult.Success - is AirBeamProtocol.MiniV2.CommandResponse.Nack -> { - log.e { "Mini V2 fixed WiFi setup failed with Nack: ${response.errorCode}" } - mapNackToConfigResult(response.errorCode) - } - else -> { - log.i { "Mini V2 fixed WiFi setup completed (no Nack received)" } - ConfigResult.Success - } - } + /** + * Sends [command] and waits for the device's verdict. Listening starts before the write, so a + * fast reply can't slip past. No verdict within [timeout] counts as success, as before. + */ + private suspend fun sendV2AndAwait(command: ByteArray, timeout: Duration): ConfigResult = coroutineScope { + val verdict = async(start = CoroutineStart.UNDISPATCHED) { + withTimeoutOrNull(timeout) { + peripheral.observe(AirBeamGatt.MiniV2.response) + .mapNotNull { CommandResponse.parse(it) } + .first { it !is CommandResponse.Ack } } - - is SessionConfig.FixedCellular -> { - log.e { "Mini V2 does not support FixedCellular configuration" } - return ConfigResult.BadConfigFailure + } + sendV2Command(command) + when (val response = verdict.await()) { + is CommandResponse.Nack -> { + log.e { "Mini V2 rejected command 0x${command[0].toString(16)}: ${response.errorCode}" } + mapNackToConfigResult(response.errorCode) } + else -> ConfigResult.Success } } @@ -288,19 +319,16 @@ private class BleConnection( } } -private fun failedConnection(reason: FailureReason): AirBeamConnection = - object : AirBeamConnection { - override val status = MutableStateFlow(ConnectionStatus.Failed(reason)) - override val deviceState = null - override suspend fun configure(config: SessionConfig) = ConfigResult.UnknownFailure - override suspend fun disconnect() {} - } +/** A failed observation (missing characteristic, link dropping) ends that one subscription, not the app. */ +private fun Flow.logFailures() = catch { Logger.withTag("BleConnection").w(it) { "Observation ended" } } -private suspend fun Peripheral.disconnectQuietly() { +/** Disconnects and disposes: a Kable [Peripheral] holds its own scope until [Peripheral.close]. */ +private suspend fun Peripheral.release() { try { disconnect() } catch (_: Exception) { } + close() } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.kt new file mode 100644 index 0000000..51c3e6e --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.kt @@ -0,0 +1,9 @@ +package pl.llp.aircasting.bluetooth.transport.ble + +import com.juul.kable.ServicesDiscoveredPeripheral + +/** + * Asks for a bigger MTU where the app has to (Android starts at 23 bytes, too small for an + * AirBeam frame). iOS negotiates on its own. A refusal is logged, not fatal — as in legacy. + */ +internal expect suspend fun ServicesDiscoveredPeripheral.requestMtuIfSupported(mtu: Int) diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/di/AppModule.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/di/AppModule.kt index 7bf9b4a..382a17a 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/di/AppModule.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/di/AppModule.kt @@ -1,5 +1,8 @@ package pl.llp.aircasting.di +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import org.koin.core.context.startKoin import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.KoinAppDeclaration @@ -8,9 +11,8 @@ import pl.llp.aircasting.auth.AuthRepository import pl.llp.aircasting.auth.AuthViewModel import pl.llp.aircasting.auth.ForgotPasswordViewModel import pl.llp.aircasting.auth.NetworkAuthRepository -import pl.llp.aircasting.bluetooth.AirBeamConnector -import pl.llp.aircasting.bluetooth.AirBeamSessionController -import pl.llp.aircasting.bluetooth.DefaultAirBeamSessionController +import pl.llp.aircasting.bluetooth.AirBeamLink +import pl.llp.aircasting.bluetooth.DefaultAirBeamLink import pl.llp.aircasting.bluetooth.transport.ble.BleAirBeamConnector import pl.llp.aircasting.data.auth.AuthSession import pl.llp.aircasting.data.auth.StoredAuthSession @@ -51,7 +53,8 @@ val onboardingModule = module { val bleModule = module { single { BleAirBeamConnector() } - single { DefaultAirBeamSessionController(get(), get()) } + // Process-lifetime scope: the link outlives every screen, like the upload loop. + single { DefaultAirBeamLink(get(), CoroutineScope(SupervisorJob() + Dispatchers.Default)) } viewModelOf(::ScanViewModel) } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/scan/ScanViewModel.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/scan/ScanViewModel.kt index 3885cd8..893d990 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/scan/ScanViewModel.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/scan/ScanViewModel.kt @@ -3,20 +3,20 @@ package pl.llp.aircasting.scan import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import pl.llp.aircasting.bluetooth.AirBeamConnector -import pl.llp.aircasting.bluetooth.AirBeamSessionController +import pl.llp.aircasting.bluetooth.AirBeamLink import pl.llp.aircasting.bluetooth.DiscoveredAirBeam import pl.llp.aircasting.bluetooth.FailureReason -import pl.llp.aircasting.bluetooth.SessionAction -import pl.llp.aircasting.bluetooth.SessionState +import pl.llp.aircasting.bluetooth.LinkState import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch class ScanViewModel( private val connector: AirBeamConnector, - private val sessionController: AirBeamSessionController, + private val link: AirBeamLink, ) : ViewModel() { val state: StateFlow = connector.scan() @@ -29,15 +29,13 @@ class ScanViewModel( ) val connection: StateFlow = - sessionController.state.map { sessionState -> - when (sessionState) { - SessionState.Idle -> ConnectionUiState.None - is SessionState.Connecting -> ConnectionUiState.Connecting - is SessionState.Connected -> ConnectionUiState.Connected(sessionState.device) - is SessionState.Configuring -> ConnectionUiState.Connected(sessionState.device) - is SessionState.Recording -> ConnectionUiState.Connected(sessionState.device) - is SessionState.Reconnecting -> ConnectionUiState.Connecting - is SessionState.Failed -> ConnectionUiState.Failed(sessionState.reason) + link.state.map { linkState -> + when (linkState) { + LinkState.Idle -> ConnectionUiState.None + is LinkState.Connecting -> ConnectionUiState.Connecting + is LinkState.Connected -> ConnectionUiState.Connected(linkState.target.device) + is LinkState.Reconnecting -> ConnectionUiState.Connecting + is LinkState.Failed -> ConnectionUiState.Failed(linkState.reason) } }.stateIn( scope = viewModelScope, @@ -45,7 +43,8 @@ class ScanViewModel( initialValue = ConnectionUiState.None, ) + /** The link owns the attempt; leaving the screen doesn't cancel it. */ fun onConnectClicked(target: DiscoveredAirBeam) { - sessionController.dispatch(SessionAction.Connect(target)) + viewModelScope.launch { link.connect(target) } } -} \ No newline at end of file +} diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/AirBeamLinkTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/AirBeamLinkTest.kt new file mode 100644 index 0000000..961fbb1 --- /dev/null +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/AirBeamLinkTest.kt @@ -0,0 +1,289 @@ +package pl.llp.aircasting.bluetooth + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import pl.llp.aircasting.bluetooth.transport.FakeConnection +import pl.llp.aircasting.bluetooth.transport.FakeConnector +import pl.llp.aircasting.bluetooth.transport.connected +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.Uuid + +/** + * Virtual time: the link runs in `backgroundScope`, so `advanceTimeBy` drives the reconnect + * interval. Reconnect rules are legacy's (`AirBeamService` + `AirBeamReconnector`): mobile + * sessions only, every 2 s, no attempt cap, resume — don't reconfigure — on success. + */ +class AirBeamLinkTest { + + private val target = DiscoveredAirBeam(DeviceId("00:11:22:33:44:55"), "AirBeam3-1234", AirBeamDevice.AirBeam3) + private val other = DiscoveredAirBeam(DeviceId("66:77:88:99:AA:BB"), "AirBeam3-5678", AirBeamDevice.AirBeam3) + private val mobile = SessionConfig.Mobile(Uuid.random()) + private val fixed = SessionConfig.FixedCellular(Uuid.random(), "token", 1.0, 2.0) + private val connector = FakeConnector(setOf(Transport.BLE)) + + private fun TestScope.link() = DefaultAirBeamLink(connector, backgroundScope, reconnectInterval = 2.seconds) + + @Test + fun starts_idle() = runTest { + assertEquals(LinkState.Idle, link().state.value) + } + + @Test + fun connect_settles_on_Connected() = runTest { + val link = link() + connector.willConnect(FakeConnection().connected()) + + assertEquals(LinkState.Connected(target), link.connect(target)) + assertEquals(LinkState.Connected(target), link.state.value) + } + + @Test + fun connect_failure_keeps_its_reason() = runTest { + val link = link() + connector.willConnect(ConnectResult.Failed(FailureReason.NoDeviceFound)) + + assertEquals(LinkState.Failed(FailureReason.NoDeviceFound), link.connect(target)) + } + + @Test + fun disconnect_releases_the_connection_and_goes_idle() = runTest { + val link = link() + val connection = FakeConnection() + connector.willConnect(connection.connected()) + link.connect(target) + + link.disconnect() + + assertEquals(LinkState.Idle, link.state.value) + assertEquals(1, connection.disconnects) + } + + /** The user backs out while the radio is still trying. */ + @Test + fun disconnect_while_connecting_cancels_the_attempt() = runTest { + val link = link() + var settled: LinkState? = null + launch { settled = link.connect(target) } // the connector never answers + runCurrent() + assertEquals(LinkState.Connecting(target), link.state.value) + + link.disconnect() + runCurrent() + + assertEquals(LinkState.Idle, link.state.value) + assertEquals(LinkState.Idle, settled) + } + + /** The connection outlives the screen that asked for it. */ + @Test + fun a_cancelled_caller_does_not_cancel_the_connect() = runTest { + val link = link() + val caller = launch { link.connect(target) } + runCurrent() + caller.cancel() + + connector.willConnect(FakeConnection().connected()) + runCurrent() + + assertEquals(LinkState.Connected(target), link.state.value) + } + + @Test + fun connecting_to_another_device_releases_the_first() = runTest { + val link = link() + val first = FakeConnection() + connector.willConnect(first.connected(), FakeConnection().connected()) + link.connect(target) + + link.connect(other) + + assertEquals(1, first.disconnects) + assertEquals(LinkState.Connected(other), link.state.value) + } + + @Test + fun configure_needs_a_ready_link() = runTest { + assertEquals(ConfigResult.NotConnected, link().configure(mobile)) + } + + /** The specific failure reaches the caller — the UI can say "wrong Wi-Fi password". */ + @Test + fun configure_hands_back_the_device_result() = runTest { + val link = link() + val connection = FakeConnection(configureResult = ConfigResult.WifiPassFailure) + connector.willConnect(connection.connected()) + link.connect(target) + + assertEquals(ConfigResult.WifiPassFailure, link.configure(mobile)) + assertEquals(listOf(mobile), connection.configured) + } + + @Test + fun a_drop_with_no_session_is_ConnectionLost() = runTest { + val link = link() + val connection = FakeConnection() + connector.willConnect(connection.connected()) + link.connect(target) + + connection.drop() + advance(10.seconds) + + assertEquals(LinkState.Failed(FailureReason.ConnectionLost), link.state.value) + assertEquals(1, connector.targets.size) + assertEquals(1, connection.disconnects) + } + + /** Fixed sessions record on the device itself; the phone has nothing to reconnect for. */ + @Test + fun a_drop_during_a_fixed_session_does_not_reconnect() = runTest { + val link = link() + val connection = FakeConnection() + connector.willConnect(connection.connected()) + link.connect(target) + link.configure(fixed) + + connection.drop() + advance(10.seconds) + + assertEquals(LinkState.Failed(FailureReason.ConnectionLost), link.state.value) + assertEquals(1, connector.targets.size) + } + + @Test + fun a_failed_mobile_configure_arms_no_reconnect() = runTest { + val link = link() + val connection = FakeConnection(configureResult = ConfigResult.UnknownFailure) + connector.willConnect(connection.connected()) + link.connect(target) + link.configure(mobile) + + connection.drop() + advance(10.seconds) + + assertEquals(LinkState.Failed(FailureReason.ConnectionLost), link.state.value) + } + + @Test + fun a_drop_during_a_mobile_session_reconnects_every_interval() = runTest { + val link = link() + val dropped = FakeConnection() + connector.willConnect(dropped.connected()) + link.connect(target) + link.configure(mobile) + + dropped.drop() + runCurrent() + assertEquals(LinkState.Reconnecting(target, attempt = 1), link.state.value) + assertEquals(1, dropped.disconnects) // released before trying again + + advance(1.seconds) + assertEquals(1, connector.targets.size) // still waiting out the interval + + advance(1.seconds) + assertEquals(listOf(target, target), connector.targets) + + connector.willConnect(ConnectResult.Failed(FailureReason.NoDeviceFound)) + runCurrent() + assertEquals(LinkState.Reconnecting(target, attempt = 2), link.state.value) + } + + /** On success the device is told to carry on — not handed a new session config. */ + @Test + fun a_reconnect_resumes_the_mobile_session() = runTest { + val link = link() + val dropped = FakeConnection() + val fresh = FakeConnection() + connector.willConnect(dropped.connected(), fresh.connected()) + link.connect(target) + link.configure(mobile) + + dropped.drop() + advance(2.seconds) + + assertEquals(LinkState.Connected(target), link.state.value) + assertEquals(listOf(mobile), fresh.resumed) + assertEquals(emptyList(), fresh.configured) + } + + @Test + fun reconnect_never_gives_up_on_its_own() = runTest { + val link = link() + val dropped = FakeConnection() + connector.willConnect(dropped.connected()) + link.connect(target) + link.configure(mobile) + repeat(50) { connector.willConnect(ConnectResult.Failed(FailureReason.LinkTimeout)) } + + dropped.drop() + advance(100.seconds) + + assertEquals(LinkState.Reconnecting(target, attempt = 51), link.state.value) + } + + @Test + fun a_failed_resume_counts_as_a_failed_attempt() = runTest { + val link = link() + val dropped = FakeConnection() + val refusing = FakeConnection(resumeResult = ConfigResult.UnknownFailure) + connector.willConnect(dropped.connected(), refusing.connected()) + link.connect(target) + link.configure(mobile) + + dropped.drop() + advance(2.seconds) + + assertEquals(LinkState.Reconnecting(target, attempt = 2), link.state.value) + assertEquals(1, refusing.disconnects) + } + + /** Standalone mode and stop both end the link; the session-level difference is not ours. */ + @Test + fun disconnect_stops_a_reconnect() = runTest { + val link = link() + val dropped = FakeConnection() + connector.willConnect(dropped.connected()) + link.connect(target) + link.configure(mobile) + dropped.drop() + runCurrent() + + link.disconnect() + advance(60.seconds) + + assertEquals(LinkState.Idle, link.state.value) + assertEquals(1, connector.targets.size) + } + + @Test + fun readings_carry_on_across_a_reconnect() = runTest { + val link = link() + val dropped = FakeConnection() + val fresh = FakeConnection() + connector.willConnect(dropped.connected(), fresh.connected()) + val received = mutableListOf() + backgroundScope.launch { link.readings.collect { received += it } } + link.connect(target) + link.configure(mobile) + + dropped.send(reading(1.0)) + dropped.drop() + advance(2.seconds) + fresh.send(reading(2.0)) + runCurrent() + + assertEquals(listOf(reading(1.0), reading(2.0)), received) + } + + private fun reading(value: Double) = SensorReading("AirBeam3-PM2.5", "µg/m³", value, measuredAt = null) + + private fun TestScope.advance(by: Duration) { + advanceTimeBy(by) + runCurrent() + } +} diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/AirBeamSessionControllerTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/AirBeamSessionControllerTest.kt deleted file mode 100644 index daa9c1f..0000000 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/AirBeamSessionControllerTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -package pl.llp.aircasting.bluetooth - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import pl.llp.aircasting.bluetooth.transport.ControllableConnection -import pl.llp.aircasting.bluetooth.transport.FakeConnector -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.uuid.Uuid - -@OptIn(ExperimentalCoroutinesApi::class) -class AirBeamSessionControllerTest { - - private val target = DiscoveredAirBeam( - id = DeviceId("00:11:22:33:44:55"), - name = "AirBeam3-1234", - device = AirBeamDevice.AirBeam3 - ) - - @Test - fun initialStateIsIdle() = runTest { - val statusFlow = MutableStateFlow(ConnectionStatus.Disconnected) - val connection = ControllableConnection(statusFlow) - val connector = FakeConnector(setOf(Transport.BLE), connection = connection) - val testDispatcher = UnconfinedTestDispatcher(testScheduler) - val testScope = TestScope(testDispatcher) - - val controller = DefaultAirBeamSessionController(connector, externalScope = testScope) - - assertEquals(SessionState.Idle, controller.state.value) - } - - @Test - fun connectActionTransitionsToConnectingAndThenConnected() = runTest { - val statusFlow = MutableStateFlow(ConnectionStatus.Ready(AirBeamDevice.AirBeam3)) - val connection = ControllableConnection(statusFlow) - val connector = FakeConnector(setOf(Transport.BLE), connection = connection) - val testDispatcher = UnconfinedTestDispatcher(testScheduler) - val testScope = TestScope(testDispatcher) - - val controller = DefaultAirBeamSessionController(connector, externalScope = testScope) - - controller.state.test { - assertEquals(SessionState.Idle, awaitItem()) - - controller.dispatch(SessionAction.Connect(target)) - - val connectingState = awaitItem() - assertTrue(connectingState is SessionState.Connecting) - - val connectedState = awaitItem() - assertTrue(connectedState is SessionState.Connected) - assertEquals(target, connectedState.target) - } - } - - @Test - fun configureAndStartTransitionsToRecording() = runTest { - val statusFlow = MutableStateFlow(ConnectionStatus.Ready(AirBeamDevice.AirBeam3)) - val connection = ControllableConnection(statusFlow) - val connector = FakeConnector(setOf(Transport.BLE), connection = connection) - val testDispatcher = UnconfinedTestDispatcher(testScheduler) - val testScope = TestScope(testDispatcher) - - val controller = DefaultAirBeamSessionController(connector, externalScope = testScope) - - controller.dispatch(SessionAction.Connect(target)) - - val sessionConfig = SessionConfig.Mobile(uuid = Uuid.random()) - - controller.state.test { - val current = awaitItem() - assertTrue(current is SessionState.Connected) - - controller.dispatch(SessionAction.ConfigureAndStart(sessionConfig)) - - val configuringState = awaitItem() - assertTrue(configuringState is SessionState.Configuring) - - val recordingState = awaitItem() - assertTrue(recordingState is SessionState.Recording) - assertEquals(sessionConfig, (recordingState as SessionState.Recording).config) - } - } - - @Test - fun disconnectActionTransitionsToIdleAndDisconnects() = runTest { - val statusFlow = MutableStateFlow(ConnectionStatus.Ready(AirBeamDevice.AirBeam3)) - val connection = ControllableConnection(statusFlow) - val connector = FakeConnector(setOf(Transport.BLE), connection = connection) - val testDispatcher = UnconfinedTestDispatcher(testScheduler) - val testScope = TestScope(testDispatcher) - - val controller = DefaultAirBeamSessionController(connector, externalScope = testScope) - - controller.dispatch(SessionAction.Connect(target)) - assertTrue(controller.state.value is SessionState.Connected) - - controller.dispatch(SessionAction.Disconnect) - - assertEquals(SessionState.Idle, controller.state.value) - assertTrue(connection.disconnectCalled) - } - - @Test - fun unexpectedDisconnectionTriggersReconnecting() = runTest { - val statusFlow = MutableStateFlow(ConnectionStatus.Ready(AirBeamDevice.AirBeam3)) - val connection = ControllableConnection(statusFlow) - val connector = FakeConnector(setOf(Transport.BLE), connection = connection) - val testDispatcher = UnconfinedTestDispatcher(testScheduler) - val testScope = TestScope(testDispatcher) - - val controller = DefaultAirBeamSessionController(connector, externalScope = testScope) - - controller.dispatch(SessionAction.Connect(target)) - assertTrue(controller.state.value is SessionState.Connected) - - statusFlow.value = ConnectionStatus.DisconnectedUnexpectedly - - val state = controller.state.value - assertTrue(state is SessionState.Reconnecting) - assertEquals(target, state.target) - } -} diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/transport/CompositeAirBeamConnectorTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/transport/CompositeAirBeamConnectorTest.kt index 6405845..0756e7e 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/transport/CompositeAirBeamConnectorTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/transport/CompositeAirBeamConnectorTest.kt @@ -2,6 +2,8 @@ package pl.llp.aircasting.bluetooth.transport import app.cash.turbine.test import pl.llp.aircasting.bluetooth.AirBeamDevice +import pl.llp.aircasting.bluetooth.ConnectResult +import pl.llp.aircasting.bluetooth.FailureReason import pl.llp.aircasting.bluetooth.DeviceId import pl.llp.aircasting.bluetooth.DiscoveredAirBeam import pl.llp.aircasting.bluetooth.Transport @@ -11,7 +13,6 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertSame private val ab3 = DiscoveredAirBeam(DeviceId("ble-1"), "AirBeam3", AirBeamDevice.AirBeam3) @@ -50,22 +51,26 @@ class CompositeAirBeamConnectorTest { val ble = FakeConnector(setOf(Transport.BLE)) val classic = FakeConnector(setOf(Transport.CLASSIC_SERIAL)) val composite = CompositeAirBeamConnector(listOf(ble, classic)) + val connection = FakeConnection(AirBeamDevice.AirBeam2) + classic.willConnect(connection.connected()) - val connection = composite.connect(ab2) // CLASSIC_SERIAL + val result = composite.connect(ab2) // CLASSIC_SERIAL - assertSame(FakeConnection, connection) + assertSame(connection, (result as ConnectResult.Connected).connection) assertEquals(ab2, classic.connectedTarget) // classic was used assertEquals(null, ble.connectedTarget) // ble was not touched } + /** iOS has no classic-serial connector; an AirBeam2 target must fail, not crash. */ @Test fun connect_fails_when_no_delegate_supports_the_transport() = runTest { val composite = CompositeAirBeamConnector( listOf(FakeConnector(setOf(Transport.BLE))) ) - assertFailsWith { - composite.connect(ab2) // CLASSIC_SERIAL — unsupported - } + assertEquals( + ConnectResult.Failed(FailureReason.WrongCommunicationSurface), + composite.connect(ab2), // CLASSIC_SERIAL — unsupported + ) } @Test diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/transport/FakeConnector.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/transport/FakeConnector.kt index ef91597..06582d1 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/transport/FakeConnector.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/transport/FakeConnector.kt @@ -1,49 +1,81 @@ package pl.llp.aircasting.bluetooth.transport -import pl.llp.aircasting.bluetooth.AirBeamConnection -import pl.llp.aircasting.bluetooth.AirBeamConnector -import pl.llp.aircasting.bluetooth.ConnectionStatus -import pl.llp.aircasting.bluetooth.DiscoveredAirBeam -import pl.llp.aircasting.bluetooth.Transport -import pl.llp.aircasting.bluetooth.v2_firmware_specific.DeviceReportedState +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf - +import kotlinx.coroutines.flow.receiveAsFlow +import pl.llp.aircasting.bluetooth.AirBeamConnection +import pl.llp.aircasting.bluetooth.AirBeamConnector +import pl.llp.aircasting.bluetooth.AirBeamDevice import pl.llp.aircasting.bluetooth.ConfigResult +import pl.llp.aircasting.bluetooth.ConnectResult +import pl.llp.aircasting.bluetooth.ConnectionStatus +import pl.llp.aircasting.bluetooth.DiscoveredAirBeam +import pl.llp.aircasting.bluetooth.SensorReading import pl.llp.aircasting.bluetooth.SessionConfig +import pl.llp.aircasting.bluetooth.Transport +/** + * `connect` answers with whatever the test queued via [willConnect], in order. With nothing + * queued it suspends until something is — that is how a test holds a connect attempt open. + */ class FakeConnector( override val supportedTransports: Set, private val scanFlow: Flow> = flowOf(emptyList()), - private val connection: AirBeamConnection = FakeConnection, ) : AirBeamConnector { - var connectedTarget: DiscoveredAirBeam? = null + val targets = mutableListOf() + val connectedTarget get() = targets.lastOrNull() + private val results = Channel(Channel.UNLIMITED) + + fun willConnect(vararg next: ConnectResult) = next.forEach { results.trySend(it) } + override fun scan() = scanFlow - override suspend fun connect(target: DiscoveredAirBeam): AirBeamConnection { - connectedTarget = target - return connection + + override suspend fun connect(target: DiscoveredAirBeam): ConnectResult { + targets += target + return results.receive() } } -object FakeConnection : AirBeamConnection { - override val status = MutableStateFlow(ConnectionStatus.Disconnected) +class FakeConnection( + override val device: AirBeamDevice = AirBeamDevice.AirBeam3, + var configureResult: ConfigResult = ConfigResult.Success, + var resumeResult: ConfigResult = ConfigResult.Success, +) : AirBeamConnection { + override val status = MutableStateFlow(ConnectionStatus.Ready) override val deviceState = null - override suspend fun configure(config: SessionConfig): ConfigResult = ConfigResult.Success - override suspend fun disconnect() {} -} + private val sent = Channel(Channel.UNLIMITED) + override val readings = sent.receiveAsFlow() + + val configured = mutableListOf() + val resumed = mutableListOf() + var disconnects = 0 + private set + + fun drop() { + status.value = ConnectionStatus.DisconnectedUnexpectedly + } + + fun send(reading: SensorReading) { + sent.trySend(reading) + } -class ControllableConnection( - override val status: MutableStateFlow, - override val deviceState: MutableStateFlow? = null, -) : AirBeamConnection { - var disconnectCalled = false - var configuredConfig: SessionConfig? = null override suspend fun configure(config: SessionConfig): ConfigResult { - configuredConfig = config - return ConfigResult.Success + configured += config + return configureResult + } + + override suspend fun resumeMobileSession(config: SessionConfig.Mobile): ConfigResult { + resumed += config + return resumeResult } + override suspend fun disconnect() { - disconnectCalled = true + disconnects++ + status.compareAndSet(ConnectionStatus.Ready, ConnectionStatus.Disconnected) + sent.close() } } + +fun FakeConnection.connected() = ConnectResult.Connected(this) diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/ui/scan/ScanViewModelTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/ui/scan/ScanViewModelTest.kt index 9692346..49e6c82 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/ui/scan/ScanViewModelTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/bluetooth/ui/scan/ScanViewModelTest.kt @@ -2,19 +2,19 @@ package pl.llp.aircasting.bluetooth.ui.scan import app.cash.turbine.test import pl.llp.aircasting.bluetooth.AirBeamDevice -import pl.llp.aircasting.bluetooth.ConnectionStatus -import pl.llp.aircasting.bluetooth.DefaultAirBeamSessionController +import pl.llp.aircasting.bluetooth.ConnectResult +import pl.llp.aircasting.bluetooth.DefaultAirBeamLink import pl.llp.aircasting.bluetooth.DeviceId import pl.llp.aircasting.bluetooth.DiscoveredAirBeam import pl.llp.aircasting.bluetooth.FailureReason import pl.llp.aircasting.bluetooth.Transport -import pl.llp.aircasting.bluetooth.transport.ControllableConnection +import pl.llp.aircasting.bluetooth.transport.FakeConnection import pl.llp.aircasting.bluetooth.transport.FakeConnector +import pl.llp.aircasting.bluetooth.transport.connected import pl.llp.aircasting.scan.ConnectionUiState import pl.llp.aircasting.scan.ScanViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -37,16 +37,16 @@ class ScanViewModelTest { @Test fun connection_state_starts_as_None() = runTest { val connector = FakeConnector(setOf(Transport.BLE)) - val controller = DefaultAirBeamSessionController(connector, externalScope = backgroundScope) - val vm = ScanViewModel(connector, controller) + val link = DefaultAirBeamLink(connector, backgroundScope) + val vm = ScanViewModel(connector, link) assertEquals(ConnectionUiState.None, vm.connection.value) } @Test fun onConnectClicked_routes_the_target_to_the_connector() = runTest { val connector = FakeConnector(setOf(Transport.BLE)) - val controller = DefaultAirBeamSessionController(connector, externalScope = backgroundScope) - val vm = ScanViewModel(connector, controller) + val link = DefaultAirBeamLink(connector, backgroundScope) + val vm = ScanViewModel(connector, link) vm.onConnectClicked(ab3) testScheduler.advanceUntilIdle() @@ -56,18 +56,16 @@ class ScanViewModelTest { @Test fun onConnectClicked_emits_Connecting_then_Connected_on_Ready() = runTest { - val connStatus = MutableStateFlow(ConnectionStatus.Connecting) - val conn = ControllableConnection(status = connStatus) - val connector = FakeConnector(setOf(Transport.BLE), connection = conn) - val controller = DefaultAirBeamSessionController(connector, externalScope = backgroundScope) - val vm = ScanViewModel(connector, controller) + val connector = FakeConnector(setOf(Transport.BLE)) + val link = DefaultAirBeamLink(connector, backgroundScope) + val vm = ScanViewModel(connector, link) vm.connection.test { assertEquals(ConnectionUiState.None, awaitItem()) vm.onConnectClicked(ab3) assertEquals(ConnectionUiState.Connecting, awaitItem()) - connStatus.value = ConnectionStatus.Ready(AirBeamDevice.AirBeam3) + connector.willConnect(FakeConnection().connected()) assertEquals( ConnectionUiState.Connected(AirBeamDevice.AirBeam3), awaitItem(), @@ -78,18 +76,16 @@ class ScanViewModelTest { @Test fun onConnectClicked_emits_Failed_when_connection_reports_Failure() = runTest { - val connStatus = MutableStateFlow(ConnectionStatus.Connecting) - val conn = ControllableConnection(status = connStatus) - val connector = FakeConnector(setOf(Transport.BLE), connection = conn) - val controller = DefaultAirBeamSessionController(connector, externalScope = backgroundScope) - val vm = ScanViewModel(connector, controller) + val connector = FakeConnector(setOf(Transport.BLE)) + val link = DefaultAirBeamLink(connector, backgroundScope) + val vm = ScanViewModel(connector, link) vm.connection.test { assertEquals(ConnectionUiState.None, awaitItem()) vm.onConnectClicked(ab3) assertEquals(ConnectionUiState.Connecting, awaitItem()) - connStatus.value = ConnectionStatus.Failed(FailureReason.HandshakeFailed) + connector.willConnect(ConnectResult.Failed(FailureReason.HandshakeFailed)) assertEquals(ConnectionUiState.Failed(FailureReason.HandshakeFailed), awaitItem()) cancelAndIgnoreRemainingEvents() } diff --git a/shared/src/iosMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.ios.kt b/shared/src/iosMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.ios.kt new file mode 100644 index 0000000..adc1682 --- /dev/null +++ b/shared/src/iosMain/kotlin/pl/llp/aircasting/bluetooth/transport/ble/Mtu.ios.kt @@ -0,0 +1,5 @@ +package pl.llp.aircasting.bluetooth.transport.ble + +import com.juul.kable.ServicesDiscoveredPeripheral + +internal actual suspend fun ServicesDiscoveredPeripheral.requestMtuIfSupported(mtu: Int) = Unit From 7dd400296629fca2185260a7f8ab899e011b6615 Mon Sep 17 00:00:00 2001 From: kgetman Date: Tue, 20 Oct 2026 11:21:05 +0200 Subject: [PATCH 3/3] docs: record AirBeam reconnect rules and live-reading formats from legacy --- docs/DOMAIN_KNOWLEDGE.md | 36 +++++++++++++++++++ .../airbeam/02-legacy-connection-internals.md | 6 ++++ 2 files changed, 42 insertions(+) diff --git a/docs/DOMAIN_KNOWLEDGE.md b/docs/DOMAIN_KNOWLEDGE.md index 7b54ac8..a32af67 100644 --- a/docs/DOMAIN_KNOWLEDGE.md +++ b/docs/DOMAIN_KNOWLEDGE.md @@ -118,6 +118,17 @@ Enums: `DISCONNECTED`; a 2s-interval reconnect loop starts (no hard cap). 6. **Resume** — reconnect success → `RECORDING`. User can also disconnect deliberately (standalone mode) keeping the session. + **Reconnect rules, exactly** (`AirBeamService.onMessageEvent(SensorDisconnectedUnexpectedlyEvent)`, + `AirBeamReconnector`): only for a **mobile** session (fixed sessions record on the device); + not in standalone mode; one loop at a time; every **2 s**, **no cap** — it ends on + success, user stop/disconnect, or sync start. On success the device is **resumed, not + reconfigured** (`reconnectMobileSession()`): AB3/Mini V1 get the mobile-mode config again; + AB2 gets its mobile-mode message; **Mini V2 keeps running its session through the drop** — + it gets a time sync, then `ContinueSession` if it reports `HasSavedSession`, nothing if + `Running`; legacy logs and carries on for any other state. Re-sending a V2 session config + would be wrong (it would be rejected or start a second session). + In the rewrite these live in `AirBeamLink` (connection) — whether a drop means + `DISCONNECTED` or a finished session is the recording side's call. 7. **Finish** — `StopRecordingEvent` → final averaging *(dropped in the rewrite — §7)*, delete active-session rows, status `FINISHED`, backend sync. All services/connectors self-tear-down. @@ -178,6 +189,31 @@ Path A (live V1) is the older variant: it stamps `LocationHelper.lastLocation()` Fixed-session measurements carry no per-measurement location (it lives on the session). +### Live readings off the device — **KEEP** (as implemented in the rewrite, 2026-10-20) + +What `AirBeamConnection.readings` emits (`SensorReading`: sensor name, unit symbol, value, +device time or none). Sensor name + unit are the stream identity the v3 create call sends. + +| Device | Source | Format | Time | +|--------|--------|--------|------| +| AB3 | notify `ffe1` temp F, `ffe3` humidity, `ffe4` PM1, `ffe5` PM2.5, `ffe6` PM10 (`AirBeam3Configurator`) | ASCII line, below | none — stamp on receipt | +| Mini V1 | notify `ffe4`, `ffe5` (`AirBeamMiniConfigurator`; its `ffe7` sends only battery `"85%"`) | ASCII line | none | +| AB2 | RFCOMM socket, one line per reading (`AirBeam2Reader`) | ASCII line | none | +| Mini V2 | indicate `…0005` live, `…0006` sync backlog | binary LE (`ble_mobile_app_guide.md` §6) | device epoch seconds | + +- **ASCII line** (`ResponseParser`): `value;package;sensor name;type;short type;unit name; + unit symbol;T1;T2;T3;T4;T5` — 12 fields, the five thresholds integers. Anything else + (battery text, torn line) is dropped. +- **Mini V2 names** carry no metadata on the wire; legacy uploads them as + `AirBeamMini-PM1` / `AirBeamMini-PM2.5`, unit `µg/m³` + (`AirBeamMiniV2Configurator.postLiveMeasurementEvents`). Keep these exact names so the + backend streams line up with legacy-recorded sessions. +- **MTU** (Android only; iOS negotiates itself): V1 BLE devices request 517 + (`SyncableAirBeamConfigurator.MAX_MTU`) — a line doesn't fit the default 20-byte payload; + Mini V2 requests 247 (fits a 244-byte sync chunk). A refused request is logged, not fatal. +- The V2 sync backlog flows through the same `readings` as live data, so the recording side + has one save path (dedup on `(stream, time)` makes replays harmless). + UI consumes the DB reactively: Room `LiveData` → repository projections → `SessionsObserver` (diff engine) → dashboard cards. DB is the intended single source of truth. diff --git a/docs/airbeam/02-legacy-connection-internals.md b/docs/airbeam/02-legacy-connection-internals.md index cf257b2..238c104 100644 --- a/docs/airbeam/02-legacy-connection-internals.md +++ b/docs/airbeam/02-legacy-connection-internals.md @@ -243,6 +243,12 @@ only** → `tryToReconnectPeriodically` (`AirBeamService.kt:123-140`). Retries e - **KEEP (behavior):** connection outlives the UI; mobile auto-reconnect; distinguish user-disconnect from unexpected drop (your `ConnectionStatus.DisconnectedUnexpectedly`). +- **Rewrite status (2026-10-20):** `AirBeamLink` (`bluetooth/AirBeamLink.kt`) is the owner — + a Koin singleton running in a process-lifetime scope, so the connection outlives screens. + It carries the reconnect rules above (`DOMAIN_KNOWLEDGE.md` §2, step 6) and is unit-tested + against fakes (`AirBeamLinkTest`). `AirBeamForegroundService` only mirrors `LinkState` to keep + the process alive; nothing starts it yet (the recording engine will, behind a + `BackgroundSessionHost`). - **KMP mapping:** ownership is the `01-connecting.md` §9 question. Android → foreground service (`expect`/`actual` + Android `FOREGROUND_SERVICE_CONNECTED_DEVICE`). iOS → CoreBluetooth background mode `bluetooth-central` + state restoration. Reconnect loop → a clean Flow + backoff