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
19 changed files with 457 additions and 37 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,5 @@
# Track desktop_serial_transport_20260317 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View File

@@ -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."
}

View File

@@ -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]

View File

@@ -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`.

View File

@@ -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.
- **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.

View File

@@ -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) }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> = SerialPort.getCommPorts().map { it.systemPortName }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

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())

View File

@@ -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 |

View File

@@ -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., `<video>`, `<iframe>`, or canvas-based maps) directly into the Compose UI tree while binding the root app via `CanvasBasedWindow`.
3. **`core:api` contract split** — separate transport-neutral service contracts from Android AIDL packaging
4. **Native packaging** — ✅ Done: DMG, MSI, DEB distributions for Desktop via release pipeline
5. **Module maturity dashboard** — living inventory of per-module KMP readiness
6. **Shared UI vs Shared Logic split** — If the iOS target utilizes native SwiftUI instead of Compose Multiplatform, evaluate splitting feature modules into pure `sharedLogic` (business rules, ViewModels) and `sharedUI` (Compose Multiplatform) to prevent dragging Compose dependencies into pure native iOS apps.
## Design Principles
1. **Solve in `commonMain` first.** If it doesn't need platform APIs, it belongs in `commonMain`.
2. **Interfaces in `commonMain`, implementations per-target.** The repository pattern is established — extend it.
3. **Stubs are a valid first implementation.** Every target starts with no-op stubs, then graduates to real implementations.
4. **Feature modules stay target-agnostic in `commonMain`.** Platform UI goes in platform source sets.
5. **Transport is a pluggable adapter.** BLE, serial, TCP, MQTT all implement `RadioInterfaceService`.
6. **CI validates every target.** If a module declares `jvm()`, CI compiles it. No exceptions.
7. **Test in `commonTest` first.** ViewModel and business logic tests belong in `commonTest` so every target runs them.
2. **Interfaces in `commonMain`, implementations per-target.** The repository pattern is established — extend it. Prefer dependency injection (Koin) with interfaces over `expect`/`actual` declarations whenever possible to keep architecture decoupled and highly testable.
3. **UI Interop Strategies.** When a Compose Multiplatform equivalent doesn't exist (e.g., Maps, Camera), use standard interop APIs rather than extracting the entire screen to native code. Use `AndroidView` for Android, `UIKitView` for iOS, `SwingPanel` for JVM/Desktop, and `HtmlView` for Web (`wasmJs`). Always wrap these in a shared `commonMain` interface contract (like `LocalBarcodeScannerProvider`).
4. **Stubs are a valid first implementation.** Every target starts with no-op stubs, then graduates to real implementations.
5. **Feature modules stay target-agnostic in `commonMain`.** Platform UI goes in platform source sets. Keep the UI layer dumb and rely on shared ViewModels (Unidirectional Data Flow) to drive state.
6. **Transport is a pluggable adapter.** BLE, serial, TCP, MQTT all implement `RadioInterfaceService`.
7. **CI validates every target.** If a module declares `jvm()`, CI compiles it. No exceptions. Run tests on appropriate host runners (macOS for iOS, Linux for JVM/Android) to catch platform regressions.
8. **Test in `commonTest` first.** ViewModel and business logic tests belong in `commonTest` so every target runs them. Use shared `core:testing` utilities to minimize duplication.
9. **Zero Platform Leaks.** Never import `java.*` or `android.*` inside `commonMain`. Use KMP-native alternatives like `kotlinx-datetime` and `Okio`.

View File

@@ -34,13 +34,15 @@ class CommonGetDiscoveredDevicesUseCase(
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
private val usbScanner: UsbScanner? = null,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
return combine(nodeDb, recentAddressesDataSource.recentAddresses) { db, recentList ->
return combine(nodeDb, recentAddressesDataSource.recentAddresses, usbFlow) { db, recentList, usbList ->
val recentTcpForUi =
recentList
.map { DeviceListEntry.Tcp(it.name, it.address) }
@@ -63,12 +65,14 @@ class CommonGetDiscoveredDevicesUseCase(
DiscoveredDevices(
recentTcpDevices = recentTcpForUi,
usbDevices =
if (showMock) {
val demoModeLabel = runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode")
listOf(DeviceListEntry.Mock(demoModeLabel))
} else {
emptyList()
},
usbList +
if (showMock) {
val demoModeLabel =
runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode")
listOf(DeviceListEntry.Mock(demoModeLabel))
} else {
emptyList()
},
)
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.domain.usecase
import kotlinx.coroutines.flow.Flow
import org.meshtastic.feature.connections.model.DeviceListEntry
/** Platform-specific scanner for USB/Serial devices. */
interface UsbScanner {
fun scanUsbDevices(): Flow<List<DeviceListEntry.Usb>>
}

View File

@@ -0,0 +1,53 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.domain.usecase
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import org.koin.core.annotation.Single
import org.meshtastic.core.network.SerialTransport
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.JvmUsbDeviceData
import kotlin.coroutines.coroutineContext
@Single
class JvmUsbScanner : UsbScanner {
override fun scanUsbDevices(): Flow<List<DeviceListEntry.Usb>> = flow {
while (coroutineContext.isActive) {
val ports =
SerialTransport.getAvailablePorts().map { portName ->
DeviceListEntry.Usb(
usbData = JvmUsbDeviceData(portName),
name = portName,
fullAddress = "s$portName",
bonded = true, // Desktop serial ports don't need Android USB permission bonding
node = null,
)
}
emit(ports)
delay(POLL_INTERVAL_MS)
}
}
.distinctUntilChanged()
companion object {
private const val POLL_INTERVAL_MS = 2000L
}
}

View File

@@ -0,0 +1,20 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.model
/** JVM-specific implementation of [UsbDeviceData] wrapping the serial port name. */
data class JvmUsbDeviceData(val portName: String) : UsbDeviceData

View File

@@ -48,6 +48,7 @@ ktor = "3.4.1"
# Other
aboutlibraries = "13.2.1"
jserialcomm = "2.11.0"
coil = "3.4.0"
datadog-gradle = "1.24.0"
dd-sdk-android = "3.7.1"
@@ -219,6 +220,7 @@ nordic-dfu = { module = "no.nordicsemi.android:dfu", version.ref = "nordic-dfu"
kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" }
org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" }
jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }
osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" }