|
17 | 17 | package org.meshtastic.feature.firmware.ota |
18 | 18 |
|
19 | 19 | import co.touchlab.kermit.Logger |
| 20 | +import kotlinx.coroutines.CancellationException |
20 | 21 | import kotlinx.coroutines.CompletableDeferred |
21 | 22 | import kotlinx.coroutines.CoroutineDispatcher |
22 | 23 | import kotlinx.coroutines.CoroutineScope |
@@ -66,79 +67,110 @@ class BleOtaTransport( |
66 | 67 | private suspend fun scanForOtaDevice(): BleDevice? { |
67 | 68 | val otaAddress = calculateMacPlusOne(address) |
68 | 69 | val targetAddresses = setOf(address, otaAddress) |
69 | | - Logger.i { "BLE OTA: Will match addresses: $targetAddresses" } |
| 70 | + Logger.i { "BLE OTA: Will match addresses: ${targetAddresses.joinToString(", ") { maskMac(it) }}" } |
70 | 71 |
|
71 | 72 | return scanForBleDevice(scanner = scanner, tag = "BLE OTA", serviceUuid = OTA_SERVICE_UUID) { |
72 | 73 | it.address in targetAddresses |
73 | 74 | } |
74 | 75 | } |
75 | 76 |
|
76 | 77 | @Suppress("MagicNumber", "ThrowsCount") // distinct exception types for scan-miss, connect-fail, and timeout |
77 | | - override suspend fun connect(): Result<Unit> = safeCatching { |
78 | | - Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." } |
79 | | - delay(REBOOT_DELAY) |
80 | | - |
81 | | - Logger.i { "BLE OTA: Connecting to $address using Kable..." } |
82 | | - |
83 | | - val device = |
84 | | - scanForOtaDevice() |
85 | | - ?: throw OtaProtocolException.ConnectionFailed( |
86 | | - "Device not found at address $address. " + |
87 | | - "Ensure the device has rebooted into OTA mode and is advertising.", |
88 | | - ) |
89 | | - |
90 | | - bleConnection.connectionState |
91 | | - .onEach { state -> |
92 | | - Logger.d { "BLE OTA: Connection state changed to $state" } |
93 | | - isConnected = state is BleConnectionState.Connected |
| 78 | + override suspend fun connect(): Result<Unit> { |
| 79 | + val result = safeCatching { |
| 80 | + Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." } |
| 81 | + delay(REBOOT_DELAY) |
| 82 | + |
| 83 | + Logger.i { "BLE OTA: Connecting to ${maskMac(address)} using Kable..." } |
| 84 | + |
| 85 | + val device = |
| 86 | + scanForOtaDevice() |
| 87 | + ?: throw OtaProtocolException.ConnectionFailed( |
| 88 | + "Device not found at address $address. " + |
| 89 | + "Ensure the device has rebooted into OTA mode and is advertising.", |
| 90 | + ) |
| 91 | + |
| 92 | + bleConnection.connectionState |
| 93 | + .onEach { state -> |
| 94 | + Logger.d { "BLE OTA: Connection state changed to $state" } |
| 95 | + isConnected = state is BleConnectionState.Connected |
| 96 | + } |
| 97 | + .launchIn(transportScope) |
| 98 | + |
| 99 | + try { |
| 100 | + val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) |
| 101 | + if (finalState is BleConnectionState.Disconnected) { |
| 102 | + Logger.w { "BLE OTA: Failed to connect to ${maskMac(device.address)} (state=$finalState)" } |
| 103 | + throw OtaProtocolException.ConnectionFailed( |
| 104 | + "Failed to connect to device at address ${device.address}", |
| 105 | + ) |
| 106 | + } |
| 107 | + } catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) { |
| 108 | + Logger.w { "BLE OTA: Timed out waiting to connect to ${maskMac(device.address)}. Error: ${e.message}" } |
| 109 | + throw OtaProtocolException.Timeout("Timed out connecting to device at address ${device.address}") |
94 | 110 | } |
95 | | - .launchIn(transportScope) |
96 | 111 |
|
97 | | - try { |
98 | | - val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) |
99 | | - if (finalState is BleConnectionState.Disconnected) { |
100 | | - Logger.w { "BLE OTA: Failed to connect to ${device.address} (state=$finalState)" } |
101 | | - throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${device.address}") |
102 | | - } |
103 | | - } catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) { |
104 | | - Logger.w { "BLE OTA: Timed out waiting to connect to ${device.address}. Error: ${e.message}" } |
105 | | - throw OtaProtocolException.Timeout("Timed out connecting to device at address ${device.address}") |
| 112 | + Logger.i { "BLE OTA: Connected to ${maskMac(device.address)}, discovering services..." } |
| 113 | + prepareOtaProfile() |
106 | 114 | } |
| 115 | + if (result.isFailure) { |
| 116 | + closeAfterFailedConnect() |
| 117 | + } |
| 118 | + return result |
| 119 | + } |
107 | 120 |
|
108 | | - Logger.i { "BLE OTA: Connected to ${device.address}, discovering services..." } |
109 | | - |
110 | | - bleConnection.profile(OTA_SERVICE_UUID) { service -> |
111 | | - // Log negotiated MTU for diagnostics |
112 | | - val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) |
113 | | - Logger.i { "BLE OTA: Service ready. Max write value length: $maxLen bytes" } |
| 121 | + private suspend fun closeAfterFailedConnect() { |
| 122 | + try { |
| 123 | + close() |
| 124 | + } catch (e: CancellationException) { |
| 125 | + throw e |
| 126 | + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { |
| 127 | + Logger.w(e) { "BLE OTA: Failed to close after connection failure" } |
| 128 | + } |
| 129 | + } |
114 | 130 |
|
115 | | - // Collect responses. onSubscription fires when the CCCD write completes — a precise readiness |
116 | | - // signal; the settle below is a conservative cushion. |
117 | | - val subscribed = CompletableDeferred<Unit>() |
118 | | - service |
119 | | - .observe(txChar) { |
120 | | - Logger.d { "BLE OTA: TX characteristic subscribed" } |
121 | | - subscribed.complete(Unit) |
122 | | - } |
123 | | - .onEach { notifyBytes -> |
124 | | - try { |
125 | | - val response = notifyBytes.decodeToString() |
126 | | - Logger.d { "BLE OTA: Received response: $response" } |
127 | | - responseChannel.trySend(response) |
128 | | - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { |
129 | | - Logger.e(e) { "BLE OTA: Failed to decode response bytes" } |
| 131 | + private suspend fun prepareOtaProfile() { |
| 132 | + try { |
| 133 | + bleConnection.profile(OTA_SERVICE_UUID) { service -> |
| 134 | + // Log negotiated MTU for diagnostics |
| 135 | + val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) |
| 136 | + Logger.i { "BLE OTA: Service ready. Max write value length: $maxLen bytes" } |
| 137 | + |
| 138 | + // Collect responses. onSubscription fires when the CCCD write completes — a precise readiness |
| 139 | + // signal; the settle below is a conservative cushion. |
| 140 | + val subscribed = CompletableDeferred<Unit>() |
| 141 | + service |
| 142 | + .observe(txChar) { |
| 143 | + Logger.d { "BLE OTA: TX characteristic subscribed" } |
| 144 | + subscribed.complete(Unit) |
130 | 145 | } |
131 | | - } |
132 | | - .catch { e -> |
133 | | - if (!subscribed.isCompleted) subscribed.completeExceptionally(e) |
134 | | - Logger.e(e) { "BLE OTA: Error in TX characteristic subscription" } |
135 | | - } |
136 | | - .launchIn(this) |
| 146 | + .onEach { notifyBytes -> |
| 147 | + try { |
| 148 | + val response = notifyBytes.decodeToString() |
| 149 | + Logger.d { "BLE OTA: Received response: $response" } |
| 150 | + responseChannel.trySend(response) |
| 151 | + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { |
| 152 | + Logger.e(e) { "BLE OTA: Failed to decode response bytes" } |
| 153 | + } |
| 154 | + } |
| 155 | + .catch { e -> |
| 156 | + if (!subscribed.isCompleted) subscribed.completeExceptionally(e) |
| 157 | + Logger.e(e) { "BLE OTA: Error in TX characteristic subscription" } |
| 158 | + } |
| 159 | + .launchIn(this) |
137 | 160 |
|
138 | | - subscribed.await() |
139 | | - // Conservative settle after CCCD confirmation before issuing commands. |
140 | | - delay(SUBSCRIPTION_SETTLE) |
141 | | - Logger.i { "BLE OTA: Service discovered and ready" } |
| 161 | + subscribed.await() |
| 162 | + // Conservative settle after CCCD confirmation before issuing commands. |
| 163 | + delay(SUBSCRIPTION_SETTLE) |
| 164 | + Logger.i { "BLE OTA: Service discovered and ready" } |
| 165 | + } |
| 166 | + } catch (e: CancellationException) { |
| 167 | + throw e |
| 168 | + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { |
| 169 | + Logger.w(e) { "BLE OTA: Connected over GATT but OTA service was not ready" } |
| 170 | + throw OtaProtocolException.ConnectionFailed( |
| 171 | + "Connected over BLE, but the OTA service was not available yet", |
| 172 | + e, |
| 173 | + ) |
142 | 174 | } |
143 | 175 | } |
144 | 176 |
|
@@ -263,10 +295,20 @@ class BleOtaTransport( |
263 | 295 | OtaProtocolException.TransferFailed("Transfer failed: ${error.message}") |
264 | 296 | } |
265 | 297 |
|
| 298 | + /** |
| 299 | + * Masks a BLE MAC address for logging: preserves the OUI (first 3 octets) and last octet for diagnostics while |
| 300 | + * hiding the unique middle portion. Privacy rule: never log raw PII. |
| 301 | + */ |
| 302 | + private fun maskMac(address: String): String = |
| 303 | + address.takeIf { it.length >= 17 }?.let { "${it.substring(0, 8)}:**:**:**:${it.substring(15)}" } ?: address |
| 304 | + |
266 | 305 | override suspend fun close() { |
267 | | - bleConnection.disconnect() |
268 | | - isConnected = false |
269 | | - transportScope.cancel() |
| 306 | + try { |
| 307 | + bleConnection.disconnect() |
| 308 | + } finally { |
| 309 | + isConnected = false |
| 310 | + transportScope.cancel() |
| 311 | + } |
270 | 312 | } |
271 | 313 |
|
272 | 314 | private suspend fun sendCommand(command: OtaCommand): Int { |
|
0 commit comments