End-to-end recipes for embedding meshtastic-sdk in an application.
Pairs with api-reference.md (what exists) and
observability.md (how to see what it's doing).
If you target Android, work through this checklist once and you can skip the platform-specific call-outs scattered through later sections.
Minimum API: Android 8.0 Oreo (API 26). All
sdk-*Android artifacts pinminSdk = 26. An app withminSdk < 26will fail to resolve the dependency at build time. There is no runtime fallback for older OS versions.
The block below covers every transport. Trim the permissions you do not use.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- BLE transport (API 31+; for API ≤ 30, also add BLUETOOTH,
BLUETOOTH_ADMIN, and ACCESS_FINE_LOCATION) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="31" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
tools:targetApi="31" />
<!-- Foreground service (required on API 34+ to keep BLE/USB
connections alive in the background). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- USB-serial transport -->
<uses-feature android:name="android.hardware.usb.host" />
<application>
<!-- Foreground service hosting your RadioClient -->
<service
android:name=".RadioConnectionService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<!-- USB attach/detach intents (optional but recommended) -->
<receiver
android:name=".UsbDeviceReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
<action android:name="android.hardware.usb.action.USB_DEVICE_DETACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</receiver>
</application>
</manifest><resources>
<usb-device vendor-id="0x10c4" product-id="0xea60" /> <!-- CP2102 (Silicon Labs) -->
<usb-device vendor-id="0x1a86" product-id="0x7523" /> <!-- CH340 -->
<usb-device vendor-id="0x0403" product-id="0x6001" /> <!-- FTDI -->
</resources>| Concern | Where to look |
|---|---|
| Requesting BLE runtime permissions | §4 BLE |
Starting a foreground service before connect() |
§4 BLE (Android 14+ requirement) |
UsbManager.requestPermission(...) flow |
§4 Serial (USB) |
Wiring AndroidContextHolder for SqlDelight storage |
§5 Storage on Android |
Lifecycle-safe Flow collection in Fragments / Compose |
Reactive lifecycle management guide |
| Deciding what to retry vs surface to the user | Error-handling guide |
// settings.gradle.kts — Maven Central is the only repository needed
dependencyResolutionManagement {
repositories { mavenCentral() }
}
// build.gradle.kts (consumer module)
dependencies {
implementation(platform("org.meshtastic:sdk-bom:<version>"))
implementation("org.meshtastic:sdk-core")
implementation("org.meshtastic:sdk-storage-sqldelight")
// Pick the transports your app supports:
implementation("org.meshtastic:sdk-transport-tcp")
implementation("org.meshtastic:sdk-transport-ble")
implementation("org.meshtastic:sdk-transport-serial") // JVM + Android
}The BOM aligns every sdk-* artifact to one version (see
bom/README.md for full details).
sdk-testing is available as testImplementation.
import org.meshtastic.sdk.storage.sqldelight.SqlDelightStorageProvider
import org.meshtastic.sdk.RadioClient
import org.meshtastic.sdk.transport.tcp.TcpTransport
val client = RadioClient.Builder()
.transport(TcpTransport(host = "meshtastic.local", port = 4403))
.storage(SqlDelightStorageProvider(baseDir = "/var/data")) // "" = in-memory
.build()
client.connect() // throws MeshtasticException on failureRequired Builder calls: transport(...), storage(...). Everything
else has sensible defaults — see api-reference.md §Construction.
Zero platform setup. Works on JVM, Android, iOS, and Linux.
val transport = TcpTransport(host = "192.168.1.50", port = 4403)The Meshtastic firmware listens on TCP/4403 when the WiFi/Ethernet interface is enabled.
import com.juul.kable.Peripheral
import com.juul.kable.Scanner
val ad = Scanner {
filters { match { services = listOf(/* Meshtastic service UUID */) } }
}.advertisements.first()
val transport = BleTransport(
peripheral = Peripheral(ad),
address = ad.identifier.toString(), // storage identity only
)Platform requirements:
-
Android — declare and request the runtime permissions appropriate to your
targetSdk:- SDK ≥ 31:
BLUETOOTH_SCAN,BLUETOOTH_CONNECT. Addandroid:usesPermissionFlags="neverForLocation"toBLUETOOTH_SCANif you do not need location-derived scan results. - SDK ≤ 30:
BLUETOOTH,BLUETOOTH_ADMIN, plusACCESS_FINE_LOCATIONfor scanning. Bonding is handled by the OS once the SDK initiates GATT.
⚠️ Android 14+ Foreground Service requirement: On API 34+, BLE and USB connections die in the background without a foreground service. The SDK does not start one for you. Wrap yourconnect()/disconnect()calls in anActivity/Servicethat declares foreground service permissions and type:<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <application> <service android:name=".RadioConnectionService" android:foregroundServiceType="connectedDevice" /> </application>
Your
Servicemust callstartForeground(...)with a notification beforeconnect()and stop it inonDestroy()or when disconnecting. See Android system documentation for details. - SDK ≥ 31:
-
iOS — set
NSBluetoothAlwaysUsageDescriptioninInfo.plistand requestCBCentralManagerauthorization before scanning. Coordinate background usage viabluetooth-centralbackground mode if needed. -
JVM (desktop) — Linux requires BlueZ; macOS/Windows BLE through Kable is best-effort.
JVM:
import org.meshtastic.sdk.transport.serial.JvmSerialPorts
val ports = JvmSerialPorts.list() // List<String>
val transport = JvmSerialPorts.open(ports.first(), baudRate = 115200)Android:
import org.meshtastic.sdk.transport.serial.AndroidSerialPorts
// Application.onCreate() or DI bootstrap:
AndroidSerialPorts.init(applicationContext)
// After UsbManager.requestPermission(...) succeeds:
val transport = AndroidSerialPorts.open(portName, baudRate = 115200)<!-- Declare USB host hardware feature -->
<uses-feature android:name="android.hardware.usb.host" />
<!-- Declare USB device attachment broadcast receiver (optional but recommended) -->
<application>
<receiver android:name=".UsbDeviceReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
<action android:name="android.hardware.usb.action.USB_DEVICE_DETACHED" />
</intent-filter>
<meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</receiver>
</application>You are responsible for:
-
Filtering devices — match Meshtastic device VID/PID in a
res/xml/device_filter.xml:<!-- res/xml/device_filter.xml --> <resources> <usb-device vendor-id="0x10c4" product-id="0xea60" /> <!-- CP2102 (Silicon Labs) --> <usb-device vendor-id="0x1a86" product-id="0x7523" /> <!-- CH340 --> <usb-device vendor-id="0x0403" product-id="0x6001" /> <!-- FTDI --> </resources>
-
Requesting permission — before calling
AndroidSerialPorts.open(...):val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager usbManager.requestPermission(device, PendingIntent.getBroadcast( context, 0, Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_MUTABLE ))
-
Handling the broadcast — in your
BroadcastReceiver:class UsbDeviceReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == ACTION_USB_PERMISSION) { val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) if (granted) { // User approved; now open the port val transport = AndroidSerialPorts.open(portName) } } } }
See samples/cli for a complete worked example.
iOS is not supported — use TCP or BLE.
SqlDelightStorageProvider needs an Android Context for its
AndroidSqliteDriver. There is no init(context) method — set the
holder once before the first activate(...):
import org.meshtastic.sdk.storage.sqldelight.AndroidContextHolder
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
AndroidContextHolder.context = applicationContext
}
}Then construct as on any other platform:
val storage = SqlDelightStorageProvider(baseDir = filesDir.absolutePath)baseDir = "" selects in-memory mode (handy for tests; lost on close).
Otherwise the provider creates one SQLite file per TransportIdentity
under $baseDir/.
connect()suspends untilconnection.value == ConnectionState.Connectedand throwsMeshtasticExceptionon any failure (seeerror-taxonomy.md).- A second call to
connect()while already connected throwsMeshtasticException.AlreadyConnected— this is not idempotent by design. Guard at your call site (if (client.connection.value !is Connected)). - All public flows (
nodes,packets,events) are cold/Multi-collectorable; collect from anywhere. Backpressure isSUSPENDfornodes/packetsandDROP_OLDESTforevents(useeventsfor soft observability, not for accounting). disconnect()is idempotent and never throws. PendingMessageHandle.await()resolves toSendOutcome.Failure(Disconnected).- The
RadioClientowns aSupervisorJob. To run multiple radios in parallel, build separateRadioClientinstances.
val handle = client.sendText("hello mesh")
when (val outcome = handle.await()) {
SendOutcome.Success -> println("acked / rebroadcast heard")
is SendOutcome.Failure -> println("failed: ${outcome.reason}")
}Want progress instead of the terminal outcome? Collect handle.state —
it transitions Queued → Sent → Acked|Delivered|Failed(reason). See
api-reference.md §SendState.
// Read the device config
when (val result = client.admin.getConfig(AdminMessage.ConfigType.LORA_CONFIG)) {
is AdminResult.Success -> println("LoRa region: ${result.value.lora.region}")
AdminResult.Timeout -> println("timed out")
AdminResult.RateLimited -> println("rate-limited; try again later")
else -> println("failed: $result")
}
// Write config using convenience builders (avoids manual proto construction):
client.admin.setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) }
// Batch multiple writes atomically:
client.admin.editSettings {
setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) }
setMqttConfig { copy(enabled = true) }
}
// Target a remote node:
val remote = client.admin.forNode(NodeId(0x12345678.toInt()))
remote.reboot()See api-reference.md §AdminApi for the full method inventory, api-reference.md §Config Builder Extensions for all 23 builder functions, and error-taxonomy.md for AdminResult variant meanings.
By default the SDK is silent. Wire a LogSink at build time and, for
deep debugging, opt into protocolLogging (with redaction). Full
guide: observability.md.
// testImplementation("org.meshtastic:sdk-testing")
import org.meshtastic.sdk.testing.FakeRadioTransport
import org.meshtastic.sdk.testing.InMemoryStorageProvider
val transport = FakeRadioTransport()
val client = RadioClient.Builder()
.transport(transport)
.storage(InMemoryStorageProvider())
.build()
// Drive the engine by feeding `FromRadio` envelopes through the fake transport.
// See testing/Module.md and core/src/commonTest/ for working patterns.Direct messages between two nodes are encrypted end-to-end with X25519 + AES-CTR keys derived from each peer's User.public_key. The SDK does not perform the crypto itself — the firmware does — but it does surface everything you need to verify peers:
- Inbound DM payloads arrive as
MeshPacketwithdecoded.public_keypopulated whenever the firmware decrypted them with a known peer key. An emptypublic_keyon adecoded.portnum == TEXT_MESSAGE_APPpacket from a non-broadcasttofield means the peer's identity could not be verified. - The first time a peer's
public_keyis observed (or when it changes), the engine emitsMeshEvent.KeyVerificationwith aKeyVerificationPromptdescribing the new fingerprint. Consumers should surface this to the user (TOFU/SAS-style) and persist the accepted key alongside theNodeId. MeshEvent.SecurityWarning.DuplicatedPublicKeyandMeshEvent.SecurityWarning.LowEntropyKeyflagnode_inforecords whosepublic_keyis suspect; treat the affectedNodeIdas untrusted for DMs until a manual re-verification round trips.
Sending DMs is the same as any other text message — call client.sendText(...) with a non-broadcast to — but you should refuse the call if the destination's last-seen public_key does not match what the user previously verified. The engine will not reject the send for you.
Some devices participate in a regional MQTT mesh. When the firmware has MQTT enabled, inbound packets that originated over MQTT arrive with MeshPacket.via_mqtt = true and outbound packets you send may be re-broadcast to the MQTT topic by the device itself. Both sides are transparent to the SDK: there is no transport-mqtt artifact (R-11 in roadmap.md) and you do not need to configure anything beyond the device. See protocol.md §14 for the wire details and the topic naming convention; via_mqtt is the only signal the SDK exposes today.
- API reference — every public symbol
- Error taxonomy — which failures are throws vs. results vs. events
- Protocol notes — wire-level behavior the SDK speaks
- Sample CLI — a complete reference consumer for TCP, BLE, and serial
- ADR-005 — why the surface looks the way it does