feat: Desktop USB serial transport (#4836)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-18 07:42:24 -05:00 committed by GitHub
parent 06c990026f
commit 59408ef46e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 457 additions and 37 deletions

View file

@ -49,7 +49,7 @@ The module depends on the JVM variants of KMP modules:
| `navigation/DesktopSettingsNavigation.kt` | Real settings feature composables wired into nav graph (~35 screens) |
| `navigation/DesktopNodeNavigation.kt` | Real adaptive node list-detail + real metrics screens (logs + charts); map routes remain placeholders |
| `navigation/DesktopMessagingNavigation.kt` | Real adaptive contacts list-detail + real Messages/Share/QuickChat route screens |
| `radio/DesktopRadioInterfaceService.kt` | TCP socket transport with auto-reconnect, heartbeat, and backoff retry |
| `radio/DesktopRadioInterfaceService.kt` | TCP, Serial/USB, and BLE transports with auto-reconnect, heartbeat, and backoff retry |
| `radio/DesktopMeshServiceController.kt` | Mesh service lifecycle — orchestrates `want_config` handshake chain |
| `radio/DesktopMessageQueue.kt` | Message queue for outbound mesh packets |
| `ui/firmware/DesktopFirmwareScreen.kt` | Placeholder firmware screen (native DFU is Android-only) |
@ -91,6 +91,7 @@ The module depends on the JVM variants of KMP modules:
- [x] Add desktop language picker backed by shared `UiPreferencesDataSource.locale` with live translation updates
- [ ] Wire remaining `feature:*` composables (map) into the nav graph
- [ ] Move remaining node detail and message composables from `androidMain` to `commonMain`
- [ ] Add serial/USB transport for direct radio connection on Desktop
- [x] Add serial/USB transport for direct radio connection on Desktop
- [x] Add BLE transport (via Kable) for direct radio connection on Desktop
- [ ] Add MQTT transport for cloud-connected operation
- [x] Package as native distributions (DMG, MSI, DEB) via CI release pipeline

View file

@ -56,7 +56,11 @@ class DesktopRadioInterfaceService(
) : RadioInterfaceService {
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
listOf(org.meshtastic.core.model.DeviceType.TCP, org.meshtastic.core.model.DeviceType.BLE)
listOf(
org.meshtastic.core.model.DeviceType.TCP,
org.meshtastic.core.model.DeviceType.BLE,
org.meshtastic.core.model.DeviceType.USB,
)
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@ -76,6 +80,7 @@ class DesktopRadioInterfaceService(
private var transport: TcpTransport? = null
private var bleTransport: DesktopBleInterface? = null
private var serialTransport: org.meshtastic.core.network.SerialTransport? = null
init {
// Observe radioPrefs to handle asynchronous loads from DataStore
@ -136,6 +141,7 @@ class DesktopRadioInterfaceService(
serviceScope.handledLaunch {
transport?.sendPacket(bytes)
bleTransport?.handleSendToRadio(bytes)
serialTransport?.handleSendToRadio(bytes)
}
}
@ -170,6 +176,8 @@ class DesktopRadioInterfaceService(
private fun startConnection(address: String) {
if (address.startsWith("t")) {
startTcpConnection(address.removePrefix("t"))
} else if (address.startsWith("s")) {
startSerialConnection(address.removePrefix("s"))
} else if (address.startsWith("x")) {
startBleConnection(address.removePrefix("x"))
} else {
@ -179,6 +187,18 @@ class DesktopRadioInterfaceService(
}
}
private fun startSerialConnection(portName: String) {
transport?.stop()
bleTransport?.close()
serialTransport?.close()
val serial = org.meshtastic.core.network.SerialTransport(portName = portName, service = this)
serialTransport = serial
if (!serial.startConnection()) {
onDisconnect(isPermanent = true, errorMessage = "Failed to connect to $portName")
}
}
private fun startBleConnection(address: String) {
transport?.stop()
bleTransport?.close()
@ -228,6 +248,9 @@ class DesktopRadioInterfaceService(
bleTransport?.close()
bleTransport = null
serialTransport?.close()
serialTransport = null
// Recreate the service scope
serviceScope.cancel("stopping interface")
serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())