tangled
alpha
login
or
join now
dunkirk.sh
/
tinkcil
2
fork
atom
ironOS native ios app
2
fork
atom
overview
issues
pulls
pipelines
feat: redo how multi devices work
dunkirk.sh
1 month ago
23c6c3e4
7a8c7c38
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+172
-34
9 changed files
expand all
collapse all
unified
split
android
app
src
main
java
com
tinkcil
data
ble
BLEManager.kt
ui
components
ScanningOverlay.kt
screens
home
HomeScreen.kt
HomeUiState.kt
HomeViewModel.kt
res
values
strings.xml
ios
Tinkcil
BLEManager.swift
ContentView.swift
Localizable.xcstrings
+28
-8
android/app/src/main/java/com/tinkcil/data/ble/BLEManager.kt
···
10
10
import android.bluetooth.BluetoothManager
11
11
import android.bluetooth.BluetoothProfile
12
12
import android.bluetooth.le.ScanCallback
13
13
+
import android.bluetooth.le.ScanFilter
13
14
import android.bluetooth.le.ScanResult
14
15
import android.bluetooth.le.ScanSettings
16
16
+
import android.os.ParcelUuid
15
17
import android.content.Context
16
18
import com.tinkcil.data.model.CircularBuffer
17
19
import com.tinkcil.data.model.IronOSLiveData
···
42
44
enum class ConnectionState {
43
45
DISCONNECTED, BLUETOOTH_OFF, SCANNING, CONNECTING, CONNECTED
44
46
}
47
47
+
48
48
+
data class DiscoveredDevice(
49
49
+
val device: BluetoothDevice,
50
50
+
val name: String
51
51
+
)
45
52
46
53
@Singleton
47
54
class BLEManager @Inject constructor(
···
83
90
private val _serialNumber = MutableStateFlow<String?>(null)
84
91
val serialNumber: StateFlow<String?> = _serialNumber.asStateFlow()
85
92
93
93
+
private val _discoveredDevices = MutableStateFlow<List<DiscoveredDevice>>(emptyList())
94
94
+
val discoveredDevices: StateFlow<List<DiscoveredDevice>> = _discoveredDevices.asStateFlow()
95
95
+
86
96
private val _lastError = MutableStateFlow<BLEError?>(null)
87
97
val lastError: StateFlow<BLEError?> = _lastError.asStateFlow()
88
98
···
172
182
}
173
183
174
184
_connectionState.value = ConnectionState.SCANNING
185
185
+
_discoveredDevices.value = emptyList()
186
186
+
187
187
+
val scanFilter = ScanFilter.Builder()
188
188
+
.setServiceUuid(ParcelUuid(IronOSUUIDs.BULK_DATA_SERVICE))
189
189
+
.build()
175
190
176
191
val scanSettings = ScanSettings.Builder()
177
192
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
···
179
194
180
195
val scanCallback = object : ScanCallback() {
181
196
override fun onScanResult(callbackType: Int, result: ScanResult) {
182
182
-
val name = result.device.name ?: return
183
183
-
if (name.startsWith("Pinecil-") || name.startsWith("PrattlePin-")) {
184
184
-
scanner.stopScan(this)
185
185
-
scanTimeoutJob?.cancel()
186
186
-
scope.launch { connectToDevice(result.device, name) }
197
197
+
val name = result.device.name ?: "Unknown Iron"
198
198
+
val address = result.device.address
199
199
+
val current = _discoveredDevices.value
200
200
+
if (current.none { it.device.address == address }) {
201
201
+
_discoveredDevices.value = current + DiscoveredDevice(result.device, name)
187
202
}
188
203
}
189
204
···
193
208
}
194
209
}
195
210
196
196
-
scanner.startScan(null, scanSettings, scanCallback)
211
211
+
scanner.startScan(listOf(scanFilter), scanSettings, scanCallback)
197
212
198
213
scanTimeoutJob = scope.launch {
199
214
delay(10_000)
···
201
216
scanner.stopScan(scanCallback)
202
217
} catch (_: Exception) {}
203
218
if (_connectionState.value == ConnectionState.SCANNING) {
204
204
-
_connectionState.value = ConnectionState.DISCONNECTED
219
219
+
val devices = _discoveredDevices.value
220
220
+
if (devices.size == 1) {
221
221
+
connectToDevice(devices[0].device, devices[0].name)
222
222
+
} else {
223
223
+
_connectionState.value = ConnectionState.DISCONNECTED
224
224
+
}
205
225
}
206
226
}
207
227
}
208
228
209
229
@SuppressLint("MissingPermission")
210
210
-
private suspend fun connectToDevice(device: BluetoothDevice, name: String) {
230
230
+
suspend fun connectToDevice(device: BluetoothDevice, name: String) {
211
231
_connectionState.value = ConnectionState.CONNECTING
212
232
_deviceName.value = name
213
233
+39
-13
android/app/src/main/java/com/tinkcil/ui/components/ScanningOverlay.kt
···
22
22
import androidx.compose.ui.unit.dp
23
23
import com.tinkcil.R
24
24
import com.tinkcil.data.ble.ConnectionState
25
25
+
import com.tinkcil.data.ble.DiscoveredDevice
25
26
26
27
@Composable
27
28
fun ScanningOverlay(
28
29
connectionState: ConnectionState,
30
30
+
discoveredDevices: List<DiscoveredDevice>,
31
31
+
onDeviceSelected: (DiscoveredDevice) -> Unit,
29
32
onScanAgain: () -> Unit,
30
33
onTryDemo: () -> Unit,
31
34
modifier: Modifier = Modifier
···
93
96
}
94
97
}
95
98
ConnectionState.DISCONNECTED -> {
96
96
-
Text(
97
97
-
text = stringResource(R.string.connection_no_device_found),
98
98
-
style = MaterialTheme.typography.headlineSmall,
99
99
-
color = MaterialTheme.colorScheme.onSurface,
100
100
-
textAlign = TextAlign.Center
101
101
-
)
102
102
-
Spacer(modifier = Modifier.height(24.dp))
103
103
-
Button(onClick = onScanAgain) {
104
104
-
Text(stringResource(R.string.connection_scan_again))
105
105
-
}
106
106
-
Spacer(modifier = Modifier.height(12.dp))
107
107
-
OutlinedButton(onClick = onTryDemo) {
108
108
-
Text(stringResource(R.string.try_demo))
99
99
+
if (discoveredDevices.size > 1) {
100
100
+
Text(
101
101
+
text = stringResource(R.string.connection_multiple_devices),
102
102
+
style = MaterialTheme.typography.headlineSmall,
103
103
+
color = MaterialTheme.colorScheme.onSurface,
104
104
+
textAlign = TextAlign.Center
105
105
+
)
106
106
+
Spacer(modifier = Modifier.height(16.dp))
107
107
+
discoveredDevices.forEach { device ->
108
108
+
Button(
109
109
+
onClick = { onDeviceSelected(device) },
110
110
+
modifier = Modifier.fillMaxWidth(0.7f)
111
111
+
) {
112
112
+
Text(device.name)
113
113
+
}
114
114
+
Spacer(modifier = Modifier.height(8.dp))
115
115
+
}
116
116
+
Spacer(modifier = Modifier.height(8.dp))
117
117
+
OutlinedButton(onClick = onScanAgain) {
118
118
+
Text(stringResource(R.string.connection_scan_again))
119
119
+
}
120
120
+
} else {
121
121
+
Text(
122
122
+
text = stringResource(R.string.connection_no_device_found),
123
123
+
style = MaterialTheme.typography.headlineSmall,
124
124
+
color = MaterialTheme.colorScheme.onSurface,
125
125
+
textAlign = TextAlign.Center
126
126
+
)
127
127
+
Spacer(modifier = Modifier.height(24.dp))
128
128
+
Button(onClick = onScanAgain) {
129
129
+
Text(stringResource(R.string.connection_scan_again))
130
130
+
}
131
131
+
Spacer(modifier = Modifier.height(12.dp))
132
132
+
OutlinedButton(onClick = onTryDemo) {
133
133
+
Text(stringResource(R.string.try_demo))
134
134
+
}
109
135
}
110
136
}
111
137
else -> {}
+2
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeScreen.kt
···
71
71
} else {
72
72
ScanningOverlay(
73
73
connectionState = uiState.connectionState,
74
74
+
discoveredDevices = uiState.discoveredDevices,
75
75
+
onDeviceSelected = viewModel::connectToDevice,
74
76
onScanAgain = viewModel::startScan,
75
77
onTryDemo = viewModel::startDemo
76
78
)
+2
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeUiState.kt
···
2
2
3
3
import com.tinkcil.data.ble.BLEError
4
4
import com.tinkcil.data.ble.ConnectionState
5
5
+
import com.tinkcil.data.ble.DiscoveredDevice
5
6
import com.tinkcil.data.model.IronOSLiveData
6
7
import com.tinkcil.data.model.TemperaturePoint
7
8
···
14
15
val isDemo: Boolean = false,
15
16
val settingsCache: Map<Int, Int> = emptyMap(),
16
17
val temperatureHistory: List<TemperaturePoint> = emptyList(),
18
18
+
val discoveredDevices: List<DiscoveredDevice> = emptyList(),
17
19
val lastError: BLEError? = null,
18
20
val isTopBarExpanded: Boolean = false,
19
21
val isSettingsSheetVisible: Boolean = false
+10
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeViewModel.kt
···
3
3
import androidx.lifecycle.ViewModel
4
4
import androidx.lifecycle.viewModelScope
5
5
import com.tinkcil.data.ble.BLEManager
6
6
+
import com.tinkcil.data.ble.DiscoveredDevice
6
7
import com.tinkcil.data.repository.SettingsRepository
7
8
import dagger.hilt.android.lifecycle.HiltViewModel
8
9
import kotlinx.coroutines.flow.MutableStateFlow
···
58
59
settingsCache = settings,
59
60
lastError = error,
60
61
temperatureHistory = bleManager.temperatureHistory.toList(),
62
62
+
discoveredDevices = bleManager.discoveredDevices.value,
61
63
isTopBarExpanded = _uiState.value.isTopBarExpanded,
62
64
isSettingsSheetVisible = _uiState.value.isSettingsSheetVisible
63
65
)
66
66
+
}.combine(bleManager.discoveredDevices) { state, devices ->
67
67
+
state.copy(discoveredDevices = devices)
64
68
}.collect { state ->
65
69
_uiState.value = state
66
70
if (state.settingsCache.isNotEmpty()) {
···
81
85
82
86
fun startScan() {
83
87
bleManager.startScan()
88
88
+
}
89
89
+
90
90
+
fun connectToDevice(device: DiscoveredDevice) {
91
91
+
viewModelScope.launch {
92
92
+
bleManager.connectToDevice(device.device, device.name)
93
93
+
}
84
94
}
85
95
86
96
fun startDemo() {
+1
android/app/src/main/res/values/strings.xml
···
8
8
<string name="connection_looking_for_iron">Looking for soldering iron…</string>
9
9
<string name="connection_no_device_found">No device found</string>
10
10
<string name="connection_scan_again">Scan Again</string>
11
11
+
<string name="connection_multiple_devices">Multiple devices found</string>
11
12
<string name="bluetooth_error_title">Bluetooth Error</string>
12
13
<string name="bluetooth_off_title">Bluetooth is Off</string>
13
14
<string name="bluetooth_off_message">Turn on Bluetooth in Settings to connect to your soldering iron.</string>
+6
-13
ios/Tinkcil/BLEManager.swift
···
105
105
centralManager.stopScan()
106
106
isScanning = false
107
107
if connectionState == .scanning {
108
108
-
connectionState = .disconnected
108
108
+
if discoveredDevices.count == 1 {
109
109
+
connect(to: discoveredDevices[0])
110
110
+
} else {
111
111
+
connectionState = .disconnected
112
112
+
}
109
113
}
110
114
}
111
115
···
572
576
rssi RSSI: NSNumber) {
573
577
DispatchQueue.main.async { [weak self] in
574
578
guard let self else { return }
575
575
-
// Auto-connect to first discovered Tinkcil
576
576
-
if self.connectedPeripheral == nil {
577
577
-
// Match either Pinecil-* (legacy) or by the advertised service UUID
578
578
-
if peripheral.name?.hasPrefix("Pinecil-") == true ||
579
579
-
peripheral.name?.hasPrefix("PrattlePin-") == true ||
580
580
-
(advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?.contains(IronOSUUIDs.bulkDataService) == true {
581
581
-
self.connect(to: peripheral)
582
582
-
return
583
583
-
}
584
584
-
}
585
585
-
586
579
if !self.discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) {
587
580
self.discoveredDevices.append(peripheral)
588
581
}
···
593
586
DispatchQueue.main.async { [weak self] in
594
587
guard let self else { return }
595
588
self.connectionState = .connected
596
596
-
self.deviceName = peripheral.name ?? "Tinkcil"
589
589
+
self.deviceName = peripheral.name ?? "Unknown Iron"
597
590
}
598
591
peripheral.discoverServices(nil)
599
592
}
+31
ios/Tinkcil/ContentView.swift
···
437
437
Text(String(localized: "connection_looking_for_iron"))
438
438
.font(.subheadline)
439
439
.foregroundStyle(.secondary)
440
440
+
} else if bleManager.discoveredDevices.count > 1 {
441
441
+
Image(systemName: "antenna.radiowaves.left.and.right")
442
442
+
.font(.system(size: 36))
443
443
+
.foregroundStyle(.secondary)
444
444
+
.padding(.bottom, 4)
445
445
+
.accessibilityHidden(true)
446
446
+
447
447
+
Text(String(localized: "connection_multiple_devices"))
448
448
+
.font(.headline)
449
449
+
.accessibilityAddTraits(.isHeader)
450
450
+
451
451
+
VStack(spacing: 8) {
452
452
+
ForEach(bleManager.discoveredDevices, id: \.identifier) { peripheral in
453
453
+
Button {
454
454
+
Haptics.light()
455
455
+
bleManager.connect(to: peripheral)
456
456
+
} label: {
457
457
+
Text(peripheral.name ?? String(localized: "common_unknown"))
458
458
+
.frame(maxWidth: .infinity)
459
459
+
}
460
460
+
.buttonStyle(.borderedProminent)
461
461
+
}
462
462
+
}
463
463
+
464
464
+
Button(String(localized: "connection_scan_again")) {
465
465
+
Haptics.light()
466
466
+
bleManager.startScanning()
467
467
+
}
468
468
+
.font(.subheadline)
469
469
+
.foregroundStyle(.secondary)
470
470
+
.padding(.top, 4)
440
471
} else {
441
472
Image(systemName: "antenna.radiowaves.left.and.right.slash")
442
473
.font(.system(size: 36))
+53
ios/Tinkcil/Localizable.xcstrings
···
1089
1089
}
1090
1090
}
1091
1091
},
1092
1092
+
"connection_multiple_devices" : {
1093
1093
+
"extractionState" : "manual",
1094
1094
+
"localizations" : {
1095
1095
+
"de" : {
1096
1096
+
"stringUnit" : {
1097
1097
+
"state" : "translated",
1098
1098
+
"value" : "Mehrere Geräte gefunden"
1099
1099
+
}
1100
1100
+
},
1101
1101
+
"en" : {
1102
1102
+
"stringUnit" : {
1103
1103
+
"state" : "translated",
1104
1104
+
"value" : "Multiple Devices Found"
1105
1105
+
}
1106
1106
+
},
1107
1107
+
"es" : {
1108
1108
+
"stringUnit" : {
1109
1109
+
"state" : "translated",
1110
1110
+
"value" : "Múltiples dispositivos encontrados"
1111
1111
+
}
1112
1112
+
},
1113
1113
+
"fr" : {
1114
1114
+
"stringUnit" : {
1115
1115
+
"state" : "translated",
1116
1116
+
"value" : "Plusieurs appareils trouvés"
1117
1117
+
}
1118
1118
+
},
1119
1119
+
"ja" : {
1120
1120
+
"stringUnit" : {
1121
1121
+
"state" : "translated",
1122
1122
+
"value" : "複数のデバイスが見つかりました"
1123
1123
+
}
1124
1124
+
},
1125
1125
+
"ko" : {
1126
1126
+
"stringUnit" : {
1127
1127
+
"state" : "translated",
1128
1128
+
"value" : "여러 장치 발견"
1129
1129
+
}
1130
1130
+
},
1131
1131
+
"ru" : {
1132
1132
+
"stringUnit" : {
1133
1133
+
"state" : "translated",
1134
1134
+
"value" : "Найдено несколько устройств"
1135
1135
+
}
1136
1136
+
},
1137
1137
+
"zh-Hans" : {
1138
1138
+
"stringUnit" : {
1139
1139
+
"state" : "translated",
1140
1140
+
"value" : "发现多个设备"
1141
1141
+
}
1142
1142
+
}
1143
1143
+
}
1144
1144
+
},
1092
1145
"connection_no_device_found" : {
1093
1146
"extractionState" : "manual",
1094
1147
"localizations" : {