diff --git a/AGENTS.md b/AGENTS.md
index b35b8d208..def726573 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,7 +38,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
-| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec` in commonMain, `TcpTransport` in jvmAndroidMain). |
+| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
@@ -47,11 +47,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
| `core:barcode` | Barcode scanning (Android-only). |
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. |
-| `core/ble/` | Bluetooth Low Energy stack using Nordic libraries. |
+| `core/ble/` | Bluetooth Low Energy stack using Kable. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`). All are KMP with `jvm()` target. Use `meshtastic.kmp.feature` convention plugin. |
-| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP transport with `want_config` handshake. |
+| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
## 3. Development Guidelines & Coding Standards
@@ -72,7 +72,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
-- **BLE:** All Bluetooth communication must route through `core:ble` using Nordic Semiconductor's Android Common Libraries.
+- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
diff --git a/GEMINI.md b/GEMINI.md
index b35b8d208..def726573 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -38,7 +38,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
-| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec` in commonMain, `TcpTransport` in jvmAndroidMain). |
+| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
@@ -47,11 +47,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
| `core:barcode` | Barcode scanning (Android-only). |
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. |
-| `core/ble/` | Bluetooth Low Energy stack using Nordic libraries. |
+| `core/ble/` | Bluetooth Low Energy stack using Kable. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`). All are KMP with `jvm()` target. Use `meshtastic.kmp.feature` convention plugin. |
-| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP transport with `want_config` handshake. |
+| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
## 3. Development Guidelines & Coding Standards
@@ -72,7 +72,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
-- **BLE:** All Bluetooth communication must route through `core:ble` using Nordic Semiconductor's Android Common Libraries.
+- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
diff --git a/conductor/archive/desktop_serial_transport_20260317/index.md b/conductor/archive/desktop_serial_transport_20260317/index.md
new file mode 100644
index 000000000..1cbe07406
--- /dev/null
+++ b/conductor/archive/desktop_serial_transport_20260317/index.md
@@ -0,0 +1,5 @@
+# Track desktop_serial_transport_20260317 Context
+
+- [Specification](./spec.md)
+- [Implementation Plan](./plan.md)
+- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/desktop_serial_transport_20260317/metadata.json b/conductor/archive/desktop_serial_transport_20260317/metadata.json
new file mode 100644
index 000000000..3d1257289
--- /dev/null
+++ b/conductor/archive/desktop_serial_transport_20260317/metadata.json
@@ -0,0 +1,8 @@
+{
+ "track_id": "desktop_serial_transport_20260317",
+ "type": "feature",
+ "status": "new",
+ "created_at": "2026-03-17T12:00:00Z",
+ "updated_at": "2026-03-17T12:00:00Z",
+ "description": "Implement Serial/USB transport for the Desktop target using jSerialComm. This fulfills the medium-term priority for direct radio connections on JVM and uses the shared RadioTransport interface."
+}
\ No newline at end of file
diff --git a/conductor/archive/desktop_serial_transport_20260317/plan.md b/conductor/archive/desktop_serial_transport_20260317/plan.md
new file mode 100644
index 000000000..3d55c7380
--- /dev/null
+++ b/conductor/archive/desktop_serial_transport_20260317/plan.md
@@ -0,0 +1,21 @@
+# Implementation Plan: Desktop Serial/USB Transport
+
+## Phase 1: JVM Setup & Dependency Integration [checkpoint: a05916d]
+- [x] Task: Add the `jSerialComm` library to the `jvmMain` dependencies of the networking module. [checkpoint: 8994c66]
+- [x] Task: Create a `jvmMain` stub implementation for a `SerialTransport` class that implements the shared `RadioTransport` interface. [checkpoint: 83668e4]
+
+## Phase 2: Serial Port Scanning & Connection Management [checkpoint: 9cda87d]
+- [x] Task: Implement port discovery using `jSerialComm` to list available serial ports. [checkpoint: c72501d]
+- [x] Task: Implement connect/disconnect logic for a selected serial port, handling port locking and baud rate configuration. [checkpoint: 23ee815]
+- [x] Task: Map the input/output streams of the open serial port to the existing KMP stream framing logic (`StreamFrameCodec`). [checkpoint: 04ba9c2]
+
+## Phase 3: UI Integration
+- [x] Task: Update the `feature:connections` UI or `DesktopScannerViewModel` to poll the new `SerialTransport` for available ports. [checkpoint: 2e85b5a]
+- [x] Task: Wire the user's serial port selection to initiate the connection via the DI graph and active service logic. [checkpoint: 94cb97c]
+
+## Phase 4: Validation [checkpoint: 1055752]
+- [x] Task: Verify end-to-end communication with a physical Meshtastic device over USB on the desktop target. [checkpoint: 1055752]
+- [x] Task: Ensure CI builds cleanly and that no `java.*` dependencies leaked into `commonMain`. [checkpoint: 1055752]
+
+## Phase: Review Fixes
+- [x] Task: Apply review suggestions [checkpoint: d2f7c82]
diff --git a/conductor/archive/desktop_serial_transport_20260317/spec.md b/conductor/archive/desktop_serial_transport_20260317/spec.md
new file mode 100644
index 000000000..04ff68481
--- /dev/null
+++ b/conductor/archive/desktop_serial_transport_20260317/spec.md
@@ -0,0 +1,20 @@
+# Specification: Desktop Serial/USB Transport via jSerialComm
+
+## Objective
+Implement direct radio connection via Serial/USB on the Desktop (JVM) target using the `jSerialComm` library. This fulfills the medium-term priority of bringing physical transport parity to the desktop app and validates the newly extracted `RadioTransport` abstraction in `core:repository`.
+
+## Background
+Currently, the desktop app supports TCP connections via a shared `StreamFrameCodec`. To provide parity with Android's USB serial connection capabilities, we need to implement a JVM-specific serial transport. The `jSerialComm` library is a widely-used, cross-platform Java library that handles native serial port communication without requiring complex JNI setups.
+
+## Requirements
+- Introduce `jSerialComm` dependency to the `jvmMain` source set of the appropriate core module (likely `core:network` or a new `core:serial` module).
+- Implement the `RadioTransport` interface (defined in `core:repository/commonMain`) for the desktop target, wrapping `jSerialComm`'s port scanning and connection logic.
+- Ensure the serial data is encoded/decoded using the same protobuf frame structure utilized by the TCP transport (e.g., leveraging the existing `StreamFrameCodec`).
+- Integrate the new transport into the `feature:connections` UI on the desktop so users can scan for and select connected USB serial devices.
+- Retain platform purity: keep all `jSerialComm` and `java.io.*` imports strictly within the `jvmMain` source set.
+
+## Success Criteria
+- [ ] Desktop application successfully scans for connected Meshtastic devices over USB/Serial.
+- [ ] Users can select a serial port from the `feature:connections` UI and establish a connection.
+- [ ] Two-way protobuf communication is verified (e.g., the app receives node info and can send a message).
+- [ ] The implementation uses the shared `RadioTransport` interface without leaking JVM dependencies into `commonMain`.
diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md
index c6ea7ebbd..eb3244a32 100644
--- a/conductor/tech-stack.md
+++ b/conductor/tech-stack.md
@@ -24,4 +24,12 @@
## Networking & Transport
- **Ktor:** Multiplatform HTTP client for web services and TCP streaming.
- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS).
-- **Coroutines & Flows:** For asynchronous programming and state management.
\ No newline at end of file
+- **jSerialComm:** Cross-platform Java library used for direct Serial/USB communication with Meshtastic devices on the Desktop (JVM) target.
+- **Coroutines & Flows:** For asynchronous programming and state management.
+
+## Testing (KMP)
+- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`.
+- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows.
+- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`.
+- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates.
+- **Property-Based Testing:** Consider evaluating `Kotest` for multiplatform data-driven and property-based testing scenarios if standard `kotlin.test` becomes insufficient.
\ No newline at end of file
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index dde171d11..a499f3644 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -48,7 +48,12 @@ kotlin {
implementation(libs.kermit)
}
- val jvmMain by getting { dependencies { implementation(libs.ktor.client.java) } }
+ val jvmMain by getting {
+ dependencies {
+ implementation(libs.ktor.client.java)
+ implementation(libs.jserialcomm)
+ }
+ }
androidMain.dependencies {
implementation(projects.core.ble)
@@ -61,6 +66,7 @@ kotlin {
implementation(libs.okhttp3.logging.interceptor)
}
+ val jvmTest by getting { dependencies { implementation(libs.mockk) } }
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
}
}
diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
new file mode 100644
index 000000000..7e504f893
--- /dev/null
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.network
+
+import co.touchlab.kermit.Logger
+import com.fazecast.jSerialComm.SerialPort
+import com.fazecast.jSerialComm.SerialPortTimeoutException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.meshtastic.core.network.radio.StreamInterface
+import org.meshtastic.core.repository.RadioInterfaceService
+
+/**
+ * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamInterface] for START1/START2 packet
+ * framing.
+ */
+class SerialTransport(
+ private val portName: String,
+ private val baudRate: Int = DEFAULT_BAUD_RATE,
+ service: RadioInterfaceService,
+) : StreamInterface(service) {
+ private var serialPort: SerialPort? = null
+ private var readJob: Job? = null
+
+ /** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */
+ fun startConnection(): Boolean {
+ return try {
+ val port = SerialPort.getCommPort(portName) ?: return false
+ port.setComPortParameters(baudRate, DATA_BITS, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY)
+ port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, READ_TIMEOUT_MS, 0)
+ if (port.openPort()) {
+ serialPort = port
+ port.setDTR()
+ port.setRTS()
+ super.connect() // Sends WAKE_BYTES and signals service.onConnect()
+ startReadLoop(port)
+ true
+ } else {
+ false
+ }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Serial connection failed" }
+ false
+ }
+ }
+
+ @Suppress("CyclomaticComplexMethod")
+ private fun startReadLoop(port: SerialPort) {
+ readJob =
+ service.serviceScope.launch(Dispatchers.IO) {
+ val input = port.inputStream
+ val buffer = ByteArray(READ_BUFFER_SIZE)
+ try {
+ var reading = true
+ while (isActive && port.isOpen && reading) {
+ try {
+ val numRead = input.read(buffer)
+ if (numRead == -1) {
+ reading = false
+ } else if (numRead > 0) {
+ for (i in 0 until numRead) {
+ readChar(buffer[i])
+ }
+ }
+ } catch (_: SerialPortTimeoutException) {
+ // Expected timeout when no data is available
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ if (isActive) {
+ Logger.e(e) { "Serial read IOException: ${e.message}" }
+ } else {
+ Logger.d { "Serial read interrupted by cancellation: ${e.message}" }
+ }
+ reading = false
+ }
+ }
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ if (isActive) {
+ Logger.e(e) { "Serial read loop outer error: ${e.message}" }
+ } else {
+ Logger.d { "Serial read loop outer interrupted by cancellation: ${e.message}" }
+ }
+ } finally {
+ try {
+ input.close()
+ } catch (_: Exception) {
+ // Ignore errors during input stream close
+ }
+ try {
+ if (port.isOpen) {
+ port.closePort()
+ }
+ } catch (_: Exception) {
+ // Ignore errors during port close
+ }
+ if (isActive) {
+ onDeviceDisconnect(true)
+ }
+ }
+ }
+ }
+
+ override fun sendBytes(p: ByteArray) {
+ serialPort?.takeIf { it.isOpen }?.outputStream?.write(p)
+ }
+
+ override fun flushBytes() {
+ serialPort?.takeIf { it.isOpen }?.outputStream?.flush()
+ }
+
+ override fun keepAlive() {
+ // Not specifically needed for raw serial unless implemented
+ }
+
+ private fun closePortResources() {
+ serialPort?.takeIf { it.isOpen }?.closePort()
+ serialPort = null
+ }
+
+ override fun close() {
+ readJob?.cancel()
+ readJob = null
+ closePortResources()
+ super.close()
+ }
+
+ companion object {
+ private const val DEFAULT_BAUD_RATE = 115200
+ private const val DATA_BITS = 8
+ private const val READ_BUFFER_SIZE = 1024
+ private const val READ_TIMEOUT_MS = 100
+
+ /**
+ * Discovers and returns a list of available serial ports. Returns a list of the system port names (e.g.,
+ * "COM3", "/dev/ttyUSB0").
+ */
+ fun getAvailablePorts(): List = SerialPort.getCommPorts().map { it.systemPortName }
+ }
+}
diff --git a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/SerialTransportTest.kt b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/SerialTransportTest.kt
new file mode 100644
index 000000000..ab1e408ae
--- /dev/null
+++ b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/SerialTransportTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.network
+
+import com.fazecast.jSerialComm.SerialPort
+import io.mockk.mockk
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioTransport
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class SerialTransportTest {
+ private val mockService: RadioInterfaceService = mockk(relaxed = true)
+
+ @Test
+ fun testJSerialCommIsAvailable() {
+ val ports = SerialPort.getCommPorts()
+ assertNotNull(ports, "Serial ports array should not be null")
+ }
+
+ @Test
+ fun testSerialTransportImplementsRadioTransport() {
+ val transport: RadioTransport = SerialTransport("dummyPort", service = mockService)
+ assertTrue(transport is SerialTransport, "Transport should be a SerialTransport")
+ }
+
+ @Test
+ fun testGetAvailablePorts() {
+ val ports = SerialTransport.getAvailablePorts()
+ assertNotNull(ports, "Available ports should not be null")
+ }
+
+ @Test
+ fun testConnectToInvalidPortFailsGracefully() {
+ val transport = SerialTransport("invalid_port_name", 115200, mockService)
+ val connected = transport.startConnection()
+ assertFalse(connected, "Connecting to an invalid port should return false")
+ transport.close()
+ }
+}
diff --git a/desktop/README.md b/desktop/README.md
index 51485da04..14a66457f 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -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
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioInterfaceService.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioInterfaceService.kt
index 22d47e012..c4defd7d1 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioInterfaceService.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioInterfaceService.kt
@@ -56,7 +56,11 @@ class DesktopRadioInterfaceService(
) : RadioInterfaceService {
override val supportedDeviceTypes: List =
- 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.Disconnected)
override val connectionState: StateFlow = _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())
diff --git a/docs/kmp-status.md b/docs/kmp-status.md
index 2f5f2861f..4e9811a3e 100644
--- a/docs/kmp-status.md
+++ b/docs/kmp-status.md
@@ -27,7 +27,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
| `core:database` | ✅ | ✅ | Room KMP |
| `core:domain` | ✅ | ✅ | UseCases |
| `core:prefs` | ✅ | ✅ | Preferences layer |
-| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport` |
+| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport` |
| `core:data` | ✅ | ✅ | Data orchestration |
| `core:ble` | ✅ | ✅ | Kable multiplatform BLE abstractions in commonMain |
| `core:nfc` | ✅ | ✅ | NFC contract in commonMain; hardware in androidMain |
@@ -56,13 +56,14 @@ Modules that share JVM-specific code between Android and desktop now standardize
Working Compose Desktop application with:
- Navigation 3 shell (`NavigationRail` + `NavDisplay`) using shared routes
- Full Koin DI graph (stubs + real implementations)
-- TCP transport with auto-reconnect and full `want_config` handshake
+- TCP, Serial/USB, and BLE transports with auto-reconnect and full `want_config` handshake
- Adaptive list-detail screens for nodes and contacts
-- **Dynamic Connections screen** with automatic discovery of platform-supported transports (TCP)
+- **Dynamic Connections screen** with automatic discovery of platform-supported transports (TCP, Serial/USB, BLE)
- **Desktop language picker** backed by `UiPreferencesDataSource.locale`, with immediate Compose Multiplatform resource updates
- **Navigation-preserving locale switching** via `Main.kt` `staticCompositionLocalOf` recomposition instead of recreating the Nav3 backstack
- Node detail metrics screens (Device, Environment, Signal, Power, Pax) wired with shared KMP + Vico charts
- 7 desktop-specific screens (Settings, Device, Position, Network, Security, ExternalNotification, Debug)
+- **Native notifications and system tray icon** wired via `DesktopNotificationManager`
- **Native release pipeline** generating `.dmg` (macOS), `.msi` (Windows), and `.deb` (Linux) installers in CI
## Scorecard
@@ -107,7 +108,7 @@ Based on the latest codebase investigation, the following steps are proposed to
| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-alpha04` |
| JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime |
| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
-| Transport deduplication | ✅ Done | `StreamFrameCodec` + `TcpTransport` shared in `core:network` |
+| Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` |
| **Transport UI Unification** | ✅ Done | `RadioInterfaceService` provides dynamic transport capability to shared UI |
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 01fb9402e..0dd6adc5e 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -28,7 +28,7 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
- ✅ **Settings:** ~35 screens with real configuration, including theme/about parity and desktop language picker support
- ✅ **Nodes:** Adaptive list-detail with node management
- ✅ **Messaging:** Adaptive contacts with message view + send
-- ✅ **Connections:** Dynamic discovery of platform-supported transports (TCP)
+- ✅ **Connections:** Dynamic discovery of platform-supported transports (TCP, Serial/USB, BLE)
- ❌ **Map:** Placeholder only, needs MapLibre or alternative
- ⚠️ **Firmware:** Placeholder wired into nav graph; native DFU not applicable to desktop
- ⚠️ **Intro:** Onboarding flow (may not apply to desktop)
@@ -41,7 +41,7 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
- Test navigation flows end-to-end
2. **Tier 2: Polish (High Priority)**
- Additional desktop-specific settings polish
- - Keyboard shortcuts
+ - ✅ **MenuBar integration** and Keyboard shortcuts
- Window management
- State persistence
3. **Tier 3: Advanced (Nice-to-have)**
@@ -53,9 +53,10 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
| Transport | Platform | Status |
|---|---|---|
| TCP | Desktop (JVM) | ✅ Done — shared `StreamFrameCodec` + `TcpTransport` in `core:network` |
-| Serial/USB | Desktop (JVM) | ❌ Next — jSerialComm |
+| Serial/USB | Desktop (JVM) | ✅ Done — jSerialComm |
| MQTT | All (KMP) | ❌ Planned — Ktor/MQTT (currently Android-only via Eclipse Paho) |
-| BLE | Desktop | ❌ Future — Kable (JVM) |
+| BLE | Android | ✅ Done — Kable |
+| BLE | Desktop | ✅ Done — Kable (JVM) |
| BLE | iOS | ❌ Future — Kable/CoreBluetooth |
### Desktop Feature Gaps
@@ -70,6 +71,8 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
| Map | ❌ Needs MapLibre or equivalent |
| Charts | ✅ Vico KMP charts wired in commonMain (Device, Environment, Signal, Power, Pax) |
| Debug Panel | ✅ Real screen (mesh log viewer via shared `DebugViewModel`) |
+| Notifications | ✅ Desktop native notifications with system tray icon support |
+| MenuBar | ✅ Done — Native application menu bar with File/View menus |
| About | ✅ Shared `commonMain` screen (AboutLibraries KMP `produceLibraries` + per-platform JSON) |
| Packaging | ✅ Done — Native distribution pipeline in CI (DMG, MSI, DEB) |
@@ -89,9 +92,9 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
- ✅ **Done:** Extracted remaining 5 ViewModels: `SettingsViewModel`, `RadioConfigViewModel`, `DebugViewModel`, `MetricsViewModel`, `UIViewModel` to shared KMP modules.
- ✅ **Done:** Extracted service, worker, and radio files from `app` to `core:service/androidMain` and `core:network/androidMain`.
- **Next:** Extract remaining Android-specific files (e.g., Navigation files, App Widgets, message queues, and root Activity logic) out of `:app` to establish a truly thin app module.
-2. **Serial/USB transport** — direct radio connection on Desktop via jSerialComm
+2. ✅ **Done:** **Serial/USB transport** — direct radio connection on Desktop via jSerialComm
3. **MQTT transport** — cloud relay operation (KMP, benefits all targets)
-4. **Evaluate KMP-native mocking** — Evaluate `mockative` or similar to replace `mockk` in `commonMain` of `core:testing` for iOS readiness.
+4. **Evaluate KMP-native testing tools** — Evaluate `Mokkery` or `Mockative` to replace `mockk` in `commonMain` of `core:testing` for iOS readiness. Integrate `Turbine` for shared `Flow` testing.
5. **Desktop ViewModel auto-wiring** — ✅ Done: ensured Koin K2 Compiler Plugin generates ViewModel modules for JVM target; eliminated manual wiring in `DesktopKoinModule`
5. **KMP charting** — ✅ Done: Vico charts migrated to `feature:node/commonMain` using KMP artifacts; desktop wires them directly
6. **Navigation contract extraction** — ✅ Done: shared `TopLevelDestination` enum in `core:navigation`; icon mapping in `core:ui`; parity tests in place. Both shells derive from the same source of truth.
@@ -100,17 +103,23 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
## Longer-Term (90+ days)
1. **iOS proof target** — declare `iosArm64()`/`iosSimulatorArm64()` in KMP modules; BLE via Kable/CoreBluetooth
-2. **Map on Desktop** — evaluate MapLibre for cross-platform maps
+2. **Platform-Native UI Interop** —
+ - **iOS Maps & Camera:** Implement `MapLibre` or `MKMapView` via Compose Multiplatform's `UIKitView`. Leverage `AVCaptureSession` wrapped in `UIKitView` to fulfill the `LocalBarcodeScannerProvider` contract.
+ - **Desktop Maps:** Implement maps via `SwingPanel` wrapper, utilizing experimental interop blending (`compose.interop.blending=true`) to ensure tooltips and Compose overlays render correctly on top of the native JComponent.
+ - **Web (wasmJs) Integrations:** Leverage `HtmlView` to embed raw DOM elements (e.g., `