From 748298b9647b516d9a7957c3ffeac2b765dbf132 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:36:38 -0500 Subject: [PATCH 01/16] feat(core): remote-admin correctness + inbound coverage for the Android hard cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote admin (correctness — previously rejected by firmware 2.5+): - Route remote ADMIN_APP packets over PKC when both nodes have published keys (pki_encrypted + target public_key, channel 0), falling back to a channel named "admin"; priority defaults to RELIABLE. One choke point (prepareOutboundAdminPacket) shared by the RPC, ACK'd-send, and fire-and-forget paths. - Cache session passkeys per node (each node issues its own in every admin response) instead of one global slot that cross-contaminated concurrent admin sessions and stamped the local passkey onto remote targets. The SessionKeyExpired single-shot retry now re-seeds against the target node, whose passkey is the one the replay needs. Inbound coverage: - Surface MQTT client-proxy, XModem, and FileInfo frames as typed MeshEvents instead of dropping them with a ProtocolWarning (outbound already works via sendRaw). - Buffer mesh packets that arrive mid-handshake (drop-oldest at 64, observable via PacketsDropped) and flush them through the normal packet pipeline at Ready — live traffic interleaved with the config drain was silently lost. - Fast-fail QueueStatus res != 0 as SendFailure.QueueRejected (and fail any pending RPC sharing the wire id) instead of waiting out the full ACK timer for a packet the firmware already rejected. Ergonomics: - sendText(replyId) for threaded replies. - Document that DeviceStorage.loadNodes() is host-facing (the engine reseeds the node DB from the handshake and never calls it). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- core/api/core.klib.api | 54 ++- core/api/jvm/core.api | 50 ++- .../org/meshtastic/sdk/MessageHandleRetry.kt | 1 + .../kotlin/org/meshtastic/sdk/Node.kt | 33 ++ .../kotlin/org/meshtastic/sdk/RadioClient.kt | 4 + .../kotlin/org/meshtastic/sdk/Result.kt | 12 + .../org/meshtastic/sdk/RoutingErrors.kt | 1 + .../kotlin/org/meshtastic/sdk/Storage.kt | 5 + .../meshtastic/sdk/internal/AdminApiImpl.kt | 13 +- .../org/meshtastic/sdk/internal/MeshEngine.kt | 369 ++++++++++++------ .../sdk/AndroidCutoverPrereqsTest.kt | 216 ++++++++++ .../sdk/HandshakeAndReconnectTest.kt | 99 ++++- 12 files changed, 727 insertions(+), 130 deletions(-) create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt diff --git a/core/api/core.klib.api b/core/api/core.klib.api index 0da0300..2a9240b 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -436,6 +436,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.toString|toString(){}[0] } + final class FileInfo : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.FileInfo|null[0] + constructor (org.meshtastic.proto/FileInfo) // org.meshtastic.sdk/MeshEvent.FileInfo.|(org.meshtastic.proto.FileInfo){}[0] + + final val info // org.meshtastic.sdk/MeshEvent.FileInfo.info|{}info[0] + final fun (): org.meshtastic.proto/FileInfo // org.meshtastic.sdk/MeshEvent.FileInfo.info.|(){}[0] + + final fun component1(): org.meshtastic.proto/FileInfo // org.meshtastic.sdk/MeshEvent.FileInfo.component1|component1(){}[0] + final fun copy(org.meshtastic.proto/FileInfo = ...): org.meshtastic.sdk/MeshEvent.FileInfo // org.meshtastic.sdk/MeshEvent.FileInfo.copy|copy(org.meshtastic.proto.FileInfo){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.FileInfo.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.FileInfo.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.FileInfo.toString|toString(){}[0] + } + final class IdentityRebound : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.IdentityRebound|null[0] constructor (org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId, kotlin/String = ...) // org.meshtastic.sdk/MeshEvent.IdentityRebound.|(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.String){}[0] @@ -481,6 +494,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.MqttDisconnected.toString|toString(){}[0] } + final class MqttProxyMessage : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.MqttProxyMessage|null[0] + constructor (org.meshtastic.proto/MqttClientProxyMessage) // org.meshtastic.sdk/MeshEvent.MqttProxyMessage.|(org.meshtastic.proto.MqttClientProxyMessage){}[0] + + final val message // org.meshtastic.sdk/MeshEvent.MqttProxyMessage.message|{}message[0] + final fun (): org.meshtastic.proto/MqttClientProxyMessage // org.meshtastic.sdk/MeshEvent.MqttProxyMessage.message.|(){}[0] + + final fun component1(): org.meshtastic.proto/MqttClientProxyMessage // org.meshtastic.sdk/MeshEvent.MqttProxyMessage.component1|component1(){}[0] + final fun copy(org.meshtastic.proto/MqttClientProxyMessage = ...): org.meshtastic.sdk/MeshEvent.MqttProxyMessage // org.meshtastic.sdk/MeshEvent.MqttProxyMessage.copy|copy(org.meshtastic.proto.MqttClientProxyMessage){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.MqttProxyMessage.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.MqttProxyMessage.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.MqttProxyMessage.toString|toString(){}[0] + } + final class Notification : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.Notification|null[0] constructor (org.meshtastic.proto/ClientNotification) // org.meshtastic.sdk/MeshEvent.Notification.|(org.meshtastic.proto.ClientNotification){}[0] @@ -565,6 +591,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.TransportError.toString|toString(){}[0] } + final class XmodemPacket : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.XmodemPacket|null[0] + constructor (org.meshtastic.proto/XModem) // org.meshtastic.sdk/MeshEvent.XmodemPacket.|(org.meshtastic.proto.XModem){}[0] + + final val packet // org.meshtastic.sdk/MeshEvent.XmodemPacket.packet|{}packet[0] + final fun (): org.meshtastic.proto/XModem // org.meshtastic.sdk/MeshEvent.XmodemPacket.packet.|(){}[0] + + final fun component1(): org.meshtastic.proto/XModem // org.meshtastic.sdk/MeshEvent.XmodemPacket.component1|component1(){}[0] + final fun copy(org.meshtastic.proto/XModem = ...): org.meshtastic.sdk/MeshEvent.XmodemPacket // org.meshtastic.sdk/MeshEvent.XmodemPacket.copy|copy(org.meshtastic.proto.XModem){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.XmodemPacket.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.XmodemPacket.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.XmodemPacket.toString|toString(){}[0] + } + final object MqttConnected : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.MqttConnected|null[0] final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.MqttConnected.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.MqttConnected.hashCode|hashCode(){}[0] @@ -672,6 +711,19 @@ sealed interface org.meshtastic.sdk/SendFailure { // org.meshtastic.sdk/SendFail final fun toString(): kotlin/String // org.meshtastic.sdk/SendFailure.Other.toString|toString(){}[0] } + final class QueueRejected : org.meshtastic.sdk/SendFailure { // org.meshtastic.sdk/SendFailure.QueueRejected|null[0] + constructor (kotlin/Int) // org.meshtastic.sdk/SendFailure.QueueRejected.|(kotlin.Int){}[0] + + final val res // org.meshtastic.sdk/SendFailure.QueueRejected.res|{}res[0] + final fun (): kotlin/Int // org.meshtastic.sdk/SendFailure.QueueRejected.res.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/SendFailure.QueueRejected.component1|component1(){}[0] + final fun copy(kotlin/Int = ...): org.meshtastic.sdk/SendFailure.QueueRejected // org.meshtastic.sdk/SendFailure.QueueRejected.copy|copy(kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/SendFailure.QueueRejected.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/SendFailure.QueueRejected.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/SendFailure.QueueRejected.toString|toString(){}[0] + } + final class Unknown : org.meshtastic.sdk/SendFailure { // org.meshtastic.sdk/SendFailure.Unknown|null[0] constructor (kotlin/String) // org.meshtastic.sdk/SendFailure.Unknown.|(kotlin.String){}[0] @@ -1409,7 +1461,7 @@ final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.mesht final fun send(org.meshtastic.proto/MeshPacket): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.send|send(org.meshtastic.proto.MeshPacket){}[0] final fun sendRaw(org.meshtastic.proto/ToRadio) // org.meshtastic.sdk/RadioClient.sendRaw|sendRaw(org.meshtastic.proto.ToRadio){}[0] final fun sendReaction(kotlin/String, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Int): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendReaction|sendReaction(kotlin.String;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Int){}[0] - final fun sendText(kotlin/String, org.meshtastic.sdk/ChannelIndex = ..., org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendText|sendText(kotlin.String;org.meshtastic.sdk.ChannelIndex;org.meshtastic.sdk.NodeId){}[0] + final fun sendText(kotlin/String, org.meshtastic.sdk/ChannelIndex = ..., org.meshtastic.sdk/NodeId = ..., kotlin/Int = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendText|sendText(kotlin.String;org.meshtastic.sdk.ChannelIndex;org.meshtastic.sdk.NodeId;kotlin.Int){}[0] final suspend fun connect() // org.meshtastic.sdk/RadioClient.connect|connect(){}[0] final suspend fun disconnect() // org.meshtastic.sdk/RadioClient.disconnect|disconnect(){}[0] final suspend fun nodeSnapshot(): kotlin.collections/Map // org.meshtastic.sdk/RadioClient.nodeSnapshot|nodeSnapshot(){}[0] diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index a0df543..5c7e458 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -638,6 +638,17 @@ public final class org/meshtastic/sdk/MeshEvent$ExternalConfigChange : org/mesht public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshEvent$FileInfo : org/meshtastic/sdk/MeshEvent { + public fun (Lorg/meshtastic/proto/FileInfo;)V + public final fun component1 ()Lorg/meshtastic/proto/FileInfo; + public final fun copy (Lorg/meshtastic/proto/FileInfo;)Lorg/meshtastic/sdk/MeshEvent$FileInfo; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$FileInfo;Lorg/meshtastic/proto/FileInfo;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$FileInfo; + public fun equals (Ljava/lang/Object;)Z + public final fun getInfo ()Lorg/meshtastic/proto/FileInfo; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$IdentityRebound : org/meshtastic/sdk/MeshEvent { public synthetic fun (IILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (IILjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -691,6 +702,17 @@ public final class org/meshtastic/sdk/MeshEvent$MqttDisconnected : org/meshtasti public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshEvent$MqttProxyMessage : org/meshtastic/sdk/MeshEvent { + public fun (Lorg/meshtastic/proto/MqttClientProxyMessage;)V + public final fun component1 ()Lorg/meshtastic/proto/MqttClientProxyMessage; + public final fun copy (Lorg/meshtastic/proto/MqttClientProxyMessage;)Lorg/meshtastic/sdk/MeshEvent$MqttProxyMessage; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$MqttProxyMessage;Lorg/meshtastic/proto/MqttClientProxyMessage;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$MqttProxyMessage; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Lorg/meshtastic/proto/MqttClientProxyMessage; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$Notification : org/meshtastic/sdk/MeshEvent { public fun (Lorg/meshtastic/proto/ClientNotification;)V public final fun component1 ()Lorg/meshtastic/proto/ClientNotification; @@ -779,6 +801,17 @@ public final class org/meshtastic/sdk/MeshEvent$TransportError : org/meshtastic/ public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshEvent$XmodemPacket : org/meshtastic/sdk/MeshEvent { + public fun (Lorg/meshtastic/proto/XModem;)V + public final fun component1 ()Lorg/meshtastic/proto/XModem; + public final fun copy (Lorg/meshtastic/proto/XModem;)Lorg/meshtastic/sdk/MeshEvent$XmodemPacket; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$XmodemPacket;Lorg/meshtastic/proto/XModem;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$XmodemPacket; + public fun equals (Ljava/lang/Object;)Z + public final fun getPacket ()Lorg/meshtastic/proto/XModem; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshNode { public fun (Lorg/meshtastic/proto/NodeInfo;ZLorg/meshtastic/sdk/ConnectionQuality;Lorg/meshtastic/sdk/SignalQuality;)V public final fun component1 ()Lorg/meshtastic/proto/NodeInfo; @@ -1250,9 +1283,9 @@ public final class org/meshtastic/sdk/RadioClient : java/lang/AutoCloseable { public final fun sendReaction (Ljava/lang/String;Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/ChannelIndex;I)Lorg/meshtastic/sdk/MessageHandle; public final fun sendReaction-soBxvt8 (Ljava/lang/String;III)Lorg/meshtastic/sdk/MessageHandle; public static synthetic fun sendReaction-soBxvt8$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; - public final fun sendText (Ljava/lang/String;Lorg/meshtastic/sdk/ChannelIndex;Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/MessageHandle; - public final fun sendText-0mzNYbQ (Ljava/lang/String;II)Lorg/meshtastic/sdk/MessageHandle; - public static synthetic fun sendText-0mzNYbQ$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; + public final fun sendText (Ljava/lang/String;Lorg/meshtastic/sdk/ChannelIndex;Lorg/meshtastic/sdk/NodeId;I)Lorg/meshtastic/sdk/MessageHandle; + public final fun sendText-HaIeDj0 (Ljava/lang/String;III)Lorg/meshtastic/sdk/MessageHandle; + public static synthetic fun sendText-HaIeDj0$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; } public final class org/meshtastic/sdk/RadioClient$Builder { @@ -1529,6 +1562,17 @@ public final class org/meshtastic/sdk/SendFailure$Other : org/meshtastic/sdk/Sen public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/SendFailure$QueueRejected : org/meshtastic/sdk/SendFailure { + public fun (I)V + public final fun component1 ()I + public final fun copy (I)Lorg/meshtastic/sdk/SendFailure$QueueRejected; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/SendFailure$QueueRejected;IILjava/lang/Object;)Lorg/meshtastic/sdk/SendFailure$QueueRejected; + public fun equals (Ljava/lang/Object;)Z + public final fun getRes ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/SendFailure$Timeout : org/meshtastic/sdk/SendFailure { public static final field INSTANCE Lorg/meshtastic/sdk/SendFailure$Timeout; public fun equals (Ljava/lang/Object;)Z diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt index 9518edf..90b6295 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt @@ -82,6 +82,7 @@ private fun SendFailure.isRetryable(): Boolean = when (this) { SendFailure.Timeout, SendFailure.DutyCycleLimit, SendFailure.AckTimeout, + is SendFailure.QueueRejected, is SendFailure.Other, is SendFailure.Unknown, -> true diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index d52c012..2c08c0f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -149,6 +149,39 @@ public sealed interface MeshEvent { */ public data class MqttDisconnected(val reason: String? = null) : MeshEvent + /** + * The device asked the host to relay an MQTT message on its behalf (MQTT client proxy). + * + * When `ModuleConfig.mqtt.proxy_to_client_enabled` is set, the firmware tunnels its MQTT + * traffic through the connected phone/host instead of its own network stack. Inbound frames + * surface here; hosts publish them to the broker and feed broker traffic back with + * `RadioClient.sendRaw(ToRadio(mqttClientProxyMessage = …))`. + * + * @property message the wire [MqttClientProxyMessage][org.meshtastic.proto.MqttClientProxyMessage] + * @since 0.2.0 + */ + public data class MqttProxyMessage(val message: org.meshtastic.proto.MqttClientProxyMessage) : MeshEvent + + /** + * An XModem file-transfer frame arrived from the device. + * + * Used by firmware-update and file-transfer flows. Hosts drive the transfer by replying + * with `RadioClient.sendRaw(ToRadio(xmodemPacket = …))`. + * + * @property packet the wire [XModem][org.meshtastic.proto.XModem] frame + * @since 0.2.0 + */ + public data class XmodemPacket(val packet: org.meshtastic.proto.XModem) : MeshEvent + + /** + * The device announced a file in its on-device filesystem (sent during the handshake or + * after file operations). + * + * @property info the wire [FileInfo][org.meshtastic.proto.FileInfo] + * @since 0.2.0 + */ + public data class FileInfo(val info: org.meshtastic.proto.FileInfo) : MeshEvent + /** * A transport-level error occurred. * diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index a8270f1..55ebff0 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -349,6 +349,8 @@ public class RadioClient internal constructor( * @param text the message text (UTF-8 encoded) * @param channel the channel index (default: 0) * @param to the destination [NodeId] (default: [NodeId.BROADCAST]) + * @param replyId the packet ID of the message this text replies to (threaded reply), or `0` + * (default) for a standalone message * @return a handle tracking delivery state * @throws MeshtasticException.NotConnected if not currently connected * @throws MeshtasticException.PayloadTooLarge if the encoded text exceeds the device limit @@ -358,6 +360,7 @@ public class RadioClient internal constructor( text: String, channel: ChannelIndex = ChannelIndex(0), to: NodeId = NodeId.BROADCAST, + replyId: Int = 0, ): MessageHandle { val payload = text.encodeToByteArray() if (payload.size > DATA_PAYLOAD_LEN) { @@ -370,6 +373,7 @@ public class RadioClient internal constructor( decoded = org.meshtastic.proto.Data( portnum = org.meshtastic.proto.PortNum.TEXT_MESSAGE_APP, payload = payload.toByteString(), + reply_id = replyId, ), ) return send(packet) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt index d443904..8c5bde2 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt @@ -115,6 +115,18 @@ public sealed interface SendFailure { */ public data object AckTimeout : SendFailure + /** + * The device rejected the packet at its transmit queue (`QueueStatus.res != 0`), most + * commonly because the firmware's bounded in-flight queue (~16 packets) is full. + * + * Surfaced immediately instead of waiting out the ACK timer. Callers should back off and + * retry; bulk senders should pace submissions against this signal. + * + * @param res the raw `QueueStatus.res` error code reported by the firmware + * @since 0.2.0 + */ + public data class QueueRejected(val res: Int) : SendFailure + /** * Some other device-reported routing error. * diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingErrors.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingErrors.kt index 31aae87..5cf2b6b 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingErrors.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingErrors.kt @@ -105,6 +105,7 @@ public fun SendFailure.humanMessage(): String = when (this) { SendFailure.Cancelled -> "Send was cancelled before transmission." SendFailure.IdCollision -> "A send with this id is already in flight; refusing to overwrite it." SendFailure.AckTimeout -> "No ACK received within the configured send timeout." + is SendFailure.QueueRejected -> "Device transmit queue rejected the packet (res=$res); back off and retry." is SendFailure.Other -> routingError.actionableMessage() is SendFailure.Unknown -> "Unknown send failure: $message" } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt index 335d05a..acc1f87 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt @@ -101,6 +101,11 @@ public interface DeviceStorage : AutoCloseable { /** * Load all stored nodes. * + * **The engine never calls this** — the in-session node DB is reseeded from the Stage 2 + * handshake snapshot on every connect, not hydrated from storage. This accessor exists for + * *hosts*: read previously-persisted nodes while disconnected (offline node list) or verify + * persistence in tests. + * * Suspends while the backend reads from disk; cancellable. If the caller's coroutine is * cancelled mid-read the implementation must rethrow * [kotlin.coroutines.cancellation.CancellationException]. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index d134dfb..8d530e0 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -479,9 +479,11 @@ internal class AdminApiImpl( if (isDeviceManaged()) return AdminResult.Unauthorized val first = block() if (first !is AdminResult.SessionKeyExpired) return first - // Re-seed against the *local* device PhoneAPI, even when this AdminApiImpl is scoped to - // a remote node via forNode(dest). Remote getOwner requires a valid session passkey, so - // retrying against targetNode would just loop the same expiry failure. + // Re-seed against the node this AdminApiImpl is scoped to. Session passkeys are + // per-node (each node issues its own), and admin *read* requests don't require one — + // so a get_owner_request to the target succeeds and its response carries a fresh + // passkey, which the engine latches keyed by the responder. The replayed [block] then + // picks it up via the engine's outbound admin choke point. reseedSessionPasskey() return block() } @@ -489,7 +491,7 @@ internal class AdminApiImpl( private suspend fun reseedSessionPasskey(): AdminResult = submitAdminRpc( adminMsg = AdminMessage(get_owner_request = true), kind = ResponseKind.AdminOwner, - to = NodeId(engine.myNodeNumOrNull() ?: 0), + to = localNode(), ) private fun localNode(): NodeId = targetNode ?: NodeId(engine.myNodeNumOrNull() ?: 0) @@ -582,6 +584,9 @@ private fun mapSendFailureToAdminResult(reason: SendFailure): AdminResult SendFailure.Cancelled, SendFailure.IdCollision -> AdminResult.NodeUnreachable + // Firmware transmit queue rejected the packet (queue full) — back off and retry. + is SendFailure.QueueRejected -> AdminResult.RateLimited + is SendFailure.Other -> when (reason.routingError) { Routing.Error.ADMIN_BAD_SESSION_KEY -> AdminResult.SessionKeyExpired diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index cdcbe0d..8d429bf 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -170,10 +170,16 @@ internal class MeshEngine( // R-9: snapshot of the previously-persisted my_node_num, captured at connect time so // we can synchronously detect identity rebinds on Stage 2 commit (before storage is cleared). private var previousMyNodeNum: Int? = null - private var sessionPasskeyMem: ByteArray? = null - /** epoch-ms expiry for [sessionPasskeyMem]; 0 means "no valid passkey". */ - private var sessionPasskeyExpiresAtMs: Long = 0L + /** + * Per-node admin session passkeys, keyed by the node num that issued them. Each node + * (local device and every remote admin target) hands out its **own** passkey in its admin + * responses; remote admin writes must echo the *target's* passkey back, so a single shared + * slot would cross-contaminate concurrent admin sessions against different nodes. + * Entries expire after [SESSION_PASSKEY_TTL]; only the local node's seed passkey is + * persisted to storage. + */ + private val sessionPasskeys = mutableMapOf() // Node accumulator during Stage 2 draining. private val pendingNodes = mutableMapOf() @@ -226,6 +232,15 @@ internal class MeshEngine( */ private val settleBuffer = ArrayDeque(SETTLE_BUFFER_CAP) + /** + * `FromRadio.packet` frames (text messages, telemetry, waypoints, …) that arrive while the + * handshake is still in flight. Devices forward live mesh traffic interleaved with the + * config/NodeDB drain; dropping it loses user-visible messages. Buffered drop-oldest at + * [HANDSHAKE_PACKET_BUFFER_CAP] (with a [MeshEvent.PacketsDropped] signal) and flushed + * through the normal Ready packet pipeline in [transitionToReady]. + */ + private val handshakePacketBuffer = ArrayDeque(HANDSHAKE_PACKET_BUFFER_CAP) + /** * Per-send ACK timeout jobs. Keyed by the wire packet `id`. Touched only by the * actor — no atomic needed. @@ -546,8 +561,8 @@ internal class MeshEngine( handshakeStage = HandshakeStage.Idle myNodeNum = 0 previousMyNodeNum = null - sessionPasskeyMem = null - sessionPasskeyExpiresAtMs = 0L + sessionPasskeys.clear() + handshakePacketBuffer.clear() pendingNodes.clear() pendingConfigs.clear() pendingModuleConfigs.clear() @@ -621,8 +636,8 @@ internal class MeshEngine( } // Register before sending so a fast device reply can never race the registration. dispatcher.register(msg.requestId, msg.kind, msg.deferred) - // Outbound: re-attach session passkey for remote-admin if needed, then enqueue. - val outboundPacket = augmentForRemoteAdminIfNeeded(msg.packet) + // Outbound: stamp the target's session passkey + PKC routing for remote-admin, then enqueue. + val outboundPacket = prepareOutboundAdminPacket(msg.packet) val encoded = WireCodec.encodeToRadio(ToRadio(packet = outboundPacket)) outbound.trySend(Frame(ByteString(encoded))) // Arm the timeout last so the timer fires even if dispatch path errors above. @@ -639,33 +654,75 @@ internal class MeshEngine( } /** - * Mirror of [sendAdminPacket]'s passkey logic but operating on an already-built [MeshPacket] - * (the RPC path supplies the packet pre-encoded with portnum / payload / want_response). - * Only the encoded admin payload needs the passkey re-stamp when targeting a remote node. + * Prepare an outbound ADMIN_APP packet targeting a **remote** node. One choke point shared + * by the RPC path ([handlePostRpc]), the ACK'd-send path ([dispatchSend]), and the + * fire-and-forget path ([sendAdminPacket]). Three concerns, mirroring the Android + * reference client's remote-admin behaviour: + * + * 1. **Session passkey** — stamp the *target node's* cached passkey (each node issues its + * own; a passkey from node A is rejected by node B). Missing/expired passkey emits a + * ProtocolWarning and sends anyway — the firmware NAKs with ADMIN_BAD_SESSION_KEY, + * which [AdminApiImpl]'s single-shot reseed-and-retry handles. + * 2. **PKC routing** — firmware 2.5+ requires remote admin to be PKI-encrypted when both + * nodes have published keys: `pki_encrypted = true`, `public_key = `, + * channel 0. Without mutual keys, fall back to a channel named "admin" (legacy + * secondary-channel admin). + * 3. **Priority** — remote admin defaults to RELIABLE (matches the reference client). + * + * Local admin (`to == myNodeNum`) is returned untouched: the PhoneAPI accepts local admin + * without a passkey or PKC. */ - private fun augmentForRemoteAdminIfNeeded(packet: MeshPacket): MeshPacket { + private fun prepareOutboundAdminPacket(packet: MeshPacket): MeshPacket { val decoded = packet.decoded ?: return packet if (decoded.portnum != PortNum.ADMIN_APP) return packet - if (packet.to == 0 || packet.to == myNodeNum) return packet - val now = clock.now().toEpochMilliseconds() - val passkey = sessionPasskeyMem - if (passkey == null || passkey.isEmpty() || now >= sessionPasskeyExpiresAtMs) { + if (packet.to == 0 || packet.to == myNodeNum || packet.to == BROADCAST_ADDR) return packet + + // 1. Session passkey re-stamp (skip when the caller already provided one). + var outDecoded = decoded + val passkey = validSessionPasskeyFor(packet.to) + if (passkey != null) { + val originalAdmin = runCatching { AdminMessage.ADAPTER.decode(decoded.payload) }.getOrNull() + if (originalAdmin != null && originalAdmin.session_passkey.size == 0) { + val stamped = originalAdmin.copy(session_passkey = passkey.toByteString()) + outDecoded = decoded.copy(payload = AdminMessage.ADAPTER.encode(stamped).toByteString()) + } + } else { events.tryEmit( MeshEvent.ProtocolWarning( "admin remote without session passkey", details = mapOf("to" to packet.to), ), ) - return packet } - val originalAdmin = try { - AdminMessage.ADAPTER.decode(decoded.payload) - } catch (_: Exception) { - return packet + + val priority = if (packet.priority == MeshPacket.Priority.UNSET) { + MeshPacket.Priority.RELIABLE + } else { + packet.priority + } + + // Caller already routed the packet itself (custom PKC build) — passkey/priority only. + if (packet.pki_encrypted) { + return packet.copy(decoded = outDecoded, priority = priority) + } + + // 2. PKC when both sides have published keys; legacy "admin" channel otherwise. + val targetKey = meshState.nodes[NodeId(packet.to)]?.user?.public_key + val ownKey = meshState.nodes[NodeId(myNodeNum)]?.user?.public_key + return if (targetKey != null && targetKey.size > 0 && ownKey != null && ownKey.size > 0) { + packet.copy( + decoded = outDecoded, + channel = 0, + pki_encrypted = true, + public_key = targetKey, + priority = priority, + ) + } else { + val adminIndex = channelsState.value + ?.indexOfFirst { it.settings?.name?.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) == true } + ?.takeIf { it >= 0 } ?: 0 + packet.copy(decoded = outDecoded, channel = adminIndex, priority = priority) } - val stamped = originalAdmin.copy(session_passkey = passkey.toByteString()) - val newPayload = AdminMessage.ADAPTER.encode(stamped).toByteString() - return packet.copy(decoded = decoded.copy(payload = newPayload)) } private suspend fun handleConnect(msg: EngineMessage.Connect) { @@ -705,7 +762,9 @@ internal class MeshEngine( // restore the persisted session passkey so admin RPCs work immediately, instead // of waiting for the next get_owner_request response. Failures are non-fatal — we can - // always re-seed via the handshake's get_owner round-trip. + // always re-seed via the handshake's get_owner round-trip. The persisted passkey is the + // *local* device's seed passkey, so it is keyed under the previously-persisted identity + // (remote-node passkeys are ephemeral and never persisted). val persisted = try { storage?.loadSessionPasskey() } catch (e: CancellationException) { @@ -714,9 +773,11 @@ internal class MeshEngine( logger.warn(TAG, e) { "Failed to load persisted session passkey" } null } - sessionPasskeyMem = persisted?.bytes?.toByteArray() - // track expiry alongside the bytes so [sendAdminPacket] can skip stale passkeys. - sessionPasskeyExpiresAtMs = persisted?.expiresAtEpochMs ?: 0L + val restoredOwner = previousMyNodeNum + if (persisted != null && restoredOwner != null) { + sessionPasskeys[restoredOwner] = + SessionPasskeyEntry(persisted.bytes.toByteArray(), persisted.expiresAtEpochMs) + } // hydrate in-memory heartbeat map so subscribers don't see every previously-known // node as silent on reconnect. Failures are non-fatal; we just start fresh. @@ -980,6 +1041,10 @@ internal class MeshEngine( pendingNodes[nodeId] = nodeInfo } + // live mesh traffic interleaved with the Stage 1 config drain — buffer for + // delivery through the normal packet pipeline once the session reaches Ready. + fromRadio.packet != null -> bufferHandshakePacket(fromRadio.packet!!) + // route auxiliary FromRadio variants (clientNotification, // mqttClientProxyMessage, xmodemPacket, deviceuiConfig, fileInfo, queueStatus, // log_record) through the shared helper so they're not silently dropped during @@ -1086,6 +1151,9 @@ internal class MeshEngine( pendingNodes[nodeId] = nodeInfo } + // live mesh traffic interleaved with the Stage 2 NodeDB drain (see Stage 1). + fromRadio.packet != null -> bufferHandshakePacket(fromRadio.packet!!) + // shared auxiliary-variant handling for Stage 2 (see Stage 1). handleAuxiliaryFromRadioVariant(fromRadio, HandshakeStage.Stage2Draining) -> Unit @@ -1110,6 +1178,8 @@ internal class MeshEngine( 16, )} new=0x${myInfo.my_node_num.toString(16)}" } + // passkeys issued under the previous identity are meaningless now. + sessionPasskeys.clear() events.tryEmit( MeshEvent.IdentityRebound( previousNodeNum = NodeId(prev), @@ -1214,7 +1284,11 @@ internal class MeshEngine( private fun processSeedingEnvelope(fromRadio: org.meshtastic.proto.FromRadio) { val packet = fromRadio.packet ?: return val decoded = packet.decoded ?: return - if (decoded.portnum != PortNum.ADMIN_APP) return + if (decoded.portnum != PortNum.ADMIN_APP) { + // live mesh traffic during the seeding window — same treatment as Stage 1/2. + bufferHandshakePacket(packet) + return + } val adminMsg = try { AdminMessage.ADAPTER.decode(decoded.payload) @@ -1223,18 +1297,28 @@ internal class MeshEngine( } if (adminMsg.get_owner_response != null) { - latchSessionPasskey(adminMsg) + latchSessionPasskey(packet.from.takeIf { it != 0 } ?: myNodeNum, adminMsg) transitionToReady() + } else { + // unsolicited admin traffic (e.g. external config change) — deliver post-Ready. + bufferHandshakePacket(packet) } } - private fun latchSessionPasskey(adminMsg: AdminMessage) { + /** + * Cache the session passkey carried in [adminMsg], keyed by the node that issued it. + * Firmware refreshes the passkey in **every** admin response, so any admin packet from + * [fromNum] keeps that node's admin session alive. Only the local node's seed passkey is + * persisted — remote passkeys are ephemeral by design (4-minute TTL). + */ + private fun latchSessionPasskey(fromNum: Int, adminMsg: AdminMessage) { if (adminMsg.session_passkey.size <= 0) return + if (fromNum == 0) return val bytes = adminMsg.session_passkey.toByteArray() - sessionPasskeyMem = bytes val expiresAtMs = clock.now().toEpochMilliseconds() + SESSION_PASSKEY_TTL.inWholeMilliseconds - sessionPasskeyExpiresAtMs = expiresAtMs - logger.debug(TAG) { "Session passkey latched (${bytes.size} bytes)" } + sessionPasskeys[fromNum] = SessionPasskeyEntry(bytes, expiresAtMs) + logger.debug(TAG) { "Session passkey latched for 0x${fromNum.toString(16)} (${bytes.size} bytes)" } + if (fromNum != myNodeNum) return engineScope?.launch { if (storageDegraded) return@launch try { @@ -1250,6 +1334,28 @@ internal class MeshEngine( } } + /** Returns the unexpired session passkey for [nodeNum], pruning a stale entry if present. */ + private fun validSessionPasskeyFor(nodeNum: Int): ByteArray? { + val entry = sessionPasskeys[nodeNum] ?: return null + if (clock.now().toEpochMilliseconds() >= entry.expiresAtMs) { + sessionPasskeys.remove(nodeNum) + return null + } + return entry.bytes + } + + /** Buffer a mid-handshake `FromRadio.packet` for delivery once the session reaches Ready. */ + private fun bufferHandshakePacket(packet: MeshPacket) { + if (handshakePacketBuffer.size >= HANDSHAKE_PACKET_BUFFER_CAP) { + handshakePacketBuffer.removeFirst() + logger.warn(TAG) { + "Handshake packet buffer full (cap=$HANDSHAKE_PACKET_BUFFER_CAP) — dropping oldest" + } + events.tryEmit(MeshEvent.PacketsDropped(DroppedFlow.Packets, count = 1)) + } + handshakePacketBuffer.addLast(packet) + } + private fun transitionToReady() { handshakeStage = HandshakeStage.Ready @@ -1303,6 +1409,17 @@ internal class MeshEngine( } } + // Flush mesh packets that arrived mid-handshake through the normal Ready pipeline + // (presence, dedup, packets flow, RPC/ACK correlation) before draining queued sends. + if (handshakePacketBuffer.isNotEmpty()) { + val backlog = handshakePacketBuffer.toList() + handshakePacketBuffer.clear() + logger.info(TAG) { "Flushing ${backlog.size} packets received during handshake" } + for (packet in backlog) { + processInboundMeshPacket(packet) + } + } + // Drain the snapshot: dispatch every send that wasn't cancelled while waiting. for (msg in snapshot) { if (msg.stateFlow.value == SendState.Queued) { @@ -1323,55 +1440,7 @@ internal class MeshEngine( val queueStatus = fromRadio.queueStatus when { - packet != null -> { - logger.verbose(TAG) { - "Rx packet id=${packet.id} from=0x${packet.from.toString(16)} " + - "port=${packet.decoded?.portnum ?: "encrypted"}" - } - // any frame from a node counts as presence activity. - if (packet.from != 0) markNodeHeard(NodeId(packet.from)) - if (shouldDropDuplicateInboundPacket(packet)) return - val decoded = packet.decoded - if (decoded == null) { - maybeWarnEncryptedPacketSkipped() - return - } - if (decoded.portnum == PortNum.UNKNOWN_APP) { - logger.warn(TAG) { - "Dropping packet id=${packet.id} with unknown port from=0x${packet.from.toString(16)}" - } - events.tryEmit( - MeshEvent.ProtocolWarning( - "packet dropped for unknown port", - details = mapOf( - "id" to packet.id, - "from" to packet.from, - "port" to decoded.portnum.name, - ), - ), - ) - return - } - emitPacketOrLog(packet) - if (decoded.portnum == PortNum.ADMIN_APP) { - runCatching { AdminMessage.ADAPTER.decode(decoded.payload) } - .getOrNull() - ?.takeIf { it.get_owner_response != null } - ?.let(::latchSessionPasskey) - } - // RPC dispatch: complete any pending getter / traceRoute / neighborInfo - // request matching this packet's request_id. Must run before processRoutingAck - // so a route_reply doesn't get mistakenly classified as a generic Acked send. - dispatcher.tryComplete(packet) - processRoutingAck(packet) - // Telemetry → node update: merge device_metrics into the node DB so that - // NodeChange subscribers see battery/telemetry changes without calling TelemetryApi. - maybeMergeDeviceMetrics(packet) - maybeEmitCongestionWarning(packet) - // External config change detection: unsolicited admin messages from firmware - // indicating another client modified channels/config on this device. - maybeProcessExternalAdminChange(packet) - } + packet != null -> processInboundMeshPacket(packet) nodeInfo != null -> { val nodeId = NodeId(nodeInfo.num) @@ -1398,6 +1467,27 @@ internal class MeshEngine( val packetId = MessageId(queueStatus.mesh_packet_id) if (queueStatus.res == 0) { pendingSends[packetId]?.value = SendState.Sent + } else { + // the firmware rejected the packet at its transmit queue (typically + // queue-full). Fast-fail the handle / pending RPC instead of letting the + // caller wait out the full ACK timer for a packet that never left the device. + Routing.Error.fromValue(queueStatus.res)?.let { err -> + dispatcher.tryFailFromRouting(packetId.raw, err) + } + val flow = pendingSends.remove(packetId) + ackTimeoutJobs.remove(packetId)?.cancel() + if (flow != null) { + val current = flow.value + val terminal = current is SendState.Failed || + current == SendState.Acked || + current == SendState.Delivered + if (!terminal) { + logger.warn(TAG) { + "Queue rejected id=${packetId.raw} res=${queueStatus.res}" + } + flow.value = SendState.Failed(SendFailure.QueueRejected(queueStatus.res)) + } + } } } @@ -1410,6 +1500,63 @@ internal class MeshEngine( } } + /** + * The Ready-stage pipeline for a single inbound [MeshPacket]: presence, dedup, public + * `packets` emission, session-passkey latching, RPC/ACK correlation, telemetry merge, and + * external-config-change detection. Called for live packets in [routeNormalEnvelope] and + * for the mid-handshake backlog flushed in [transitionToReady]. + */ + private fun processInboundMeshPacket(packet: MeshPacket) { + logger.verbose(TAG) { + "Rx packet id=${packet.id} from=0x${packet.from.toString(16)} " + + "port=${packet.decoded?.portnum ?: "encrypted"}" + } + // any frame from a node counts as presence activity. + if (packet.from != 0) markNodeHeard(NodeId(packet.from)) + if (shouldDropDuplicateInboundPacket(packet)) return + val decoded = packet.decoded + if (decoded == null) { + maybeWarnEncryptedPacketSkipped() + return + } + if (decoded.portnum == PortNum.UNKNOWN_APP) { + logger.warn(TAG) { + "Dropping packet id=${packet.id} with unknown port from=0x${packet.from.toString(16)}" + } + events.tryEmit( + MeshEvent.ProtocolWarning( + "packet dropped for unknown port", + details = mapOf( + "id" to packet.id, + "from" to packet.from, + "port" to decoded.portnum.name, + ), + ), + ) + return + } + emitPacketOrLog(packet) + if (decoded.portnum == PortNum.ADMIN_APP) { + // every admin response refreshes the responder's session passkey — latch them all + // (keyed per node) so remote admin sessions stay alive without re-seeding. + runCatching { AdminMessage.ADAPTER.decode(decoded.payload) } + .getOrNull() + ?.let { latchSessionPasskey(packet.from.takeIf { n -> n != 0 } ?: myNodeNum, it) } + } + // RPC dispatch: complete any pending getter / traceRoute / neighborInfo + // request matching this packet's request_id. Must run before processRoutingAck + // so a route_reply doesn't get mistakenly classified as a generic Acked send. + dispatcher.tryComplete(packet) + processRoutingAck(packet) + // Telemetry → node update: merge device_metrics into the node DB so that + // NodeChange subscribers see battery/telemetry changes without calling TelemetryApi. + maybeMergeDeviceMetrics(packet) + maybeEmitCongestionWarning(packet) + // External config change detection: unsolicited admin messages from firmware + // indicating another client modified channels/config on this device. + maybeProcessExternalAdminChange(packet) + } + /** * Shared dispatch for `FromRadio` variants that are not part of the strict handshake state * machine but can arrive at any time (clientNotification, mqttClientProxyMessage, @@ -1449,12 +1596,16 @@ internal class MeshEngine( } fromRadio.mqttClientProxyMessage != null -> { - warnUnhandledVariant("mqtt_client_proxy_message", stage) + // MQTT client proxy: the device tunnels its MQTT traffic through the host. + // Surfaced as a typed event; the host relays to the broker and answers via + // RadioClient.sendRaw(ToRadio(mqttClientProxyMessage = …)). + events.tryEmit(MeshEvent.MqttProxyMessage(fromRadio.mqttClientProxyMessage!!)) true } fromRadio.xmodemPacket != null -> { - warnUnhandledVariant("xmodem_packet", stage) + // XModem file-transfer frame (firmware update / file transfer flows). + events.tryEmit(MeshEvent.XmodemPacket(fromRadio.xmodemPacket!!)) true } @@ -1465,7 +1616,7 @@ internal class MeshEngine( } fromRadio.fileInfo != null -> { - warnUnhandledVariant("file_info", stage) + events.tryEmit(MeshEvent.FileInfo(fromRadio.fileInfo!!)) true } @@ -1830,6 +1981,7 @@ internal class MeshEngine( pendingMyInfo = null pendingMetadata = null settleBuffer.clear() + handshakePacketBuffer.clear() for ((_, job) in ackTimeoutJobs) job.cancel() ackTimeoutJobs.clear() stage2WatchdogJob?.cancel() @@ -1942,9 +2094,13 @@ internal class MeshEngine( // Ensure the wire packet ID is set to the SDK-level MessageId value so that // QueueStatus / Routing ACK correlation via pendingSends[MessageId(x)] works. val wireId = if (msg.packet.id != 0) msg.packet.id else msg.id.raw - val packet = msg.packet.copy( - from = if (msg.packet.from == 0 && myNodeNum != 0) myNodeNum else msg.packet.from, - id = wireId, + // Remote ADMIN_APP packets pick up the target's session passkey + PKC routing here + // (no-op for everything else). + val packet = prepareOutboundAdminPacket( + msg.packet.copy( + from = if (msg.packet.from == 0 && myNodeNum != 0) myNodeNum else msg.packet.from, + id = wireId, + ), ) // Re-key so both QueueStatus and Routing ACK can look up via MessageId(wireId). pendingSends.remove(msg.id) @@ -2111,29 +2267,7 @@ internal class MeshEngine( private fun sendAdminPacket(adminMsg: AdminMessage, wantResponse: Boolean = false, to: Int = myNodeNum) { if (myNodeNum == 0) return - // attach the cached session passkey when targeting a *remote* node. Local admin - // messages (to == myNodeNum) speak directly to the device's PhoneAPI and do not - // require the session passkey. If we have no valid passkey for a remote target, emit - // a ProtocolWarning and proceed — the firmware will reject the packet, surfacing as a - // SendFailure / AckTimeout to the caller. - val outbound: AdminMessage = if (to != myNodeNum) { - val now = clock.now().toEpochMilliseconds() - val passkey = sessionPasskeyMem - if (passkey != null && passkey.isNotEmpty() && now < sessionPasskeyExpiresAtMs) { - adminMsg.copy(session_passkey = passkey.toByteString()) - } else { - events.tryEmit( - MeshEvent.ProtocolWarning( - "admin remote without session passkey", - details = mapOf("to" to to), - ), - ) - adminMsg - } - } else { - adminMsg - } - val payload = AdminMessage.ADAPTER.encode(outbound).toByteString() + val payload = AdminMessage.ADAPTER.encode(adminMsg).toByteString() val packet = MeshPacket( to = to, from = myNodeNum, @@ -2143,7 +2277,8 @@ internal class MeshEngine( want_response = wantResponse, ), ) - sendToRadio(ToRadio(packet = packet)) + // Remote targets pick up the session passkey + PKC routing in the shared choke point. + sendToRadio(ToRadio(packet = prepareOutboundAdminPacket(packet))) } // ---- C-shared-flow emit helpers ---- @@ -2375,6 +2510,13 @@ internal class MeshEngine( // maximum frames buffered during the inter-stage settle window. const val SETTLE_BUFFER_CAP = 64 + // maximum mesh packets buffered while the handshake is in flight (flushed at Ready). + const val HANDSHAKE_PACKET_BUFFER_CAP = 64 + + // name of the legacy secondary channel used for remote admin when PKC keys are + // unavailable. Matched case-insensitively against ChannelSettings.name. + const val ADMIN_CHANNEL_NAME = "admin" + // broadcast destination is the unsigned 0xFFFFFFFF address (Int.toLong → -1 // after wire round-trip). Used so we don't arm an ACK timer for broadcasts. const val BROADCAST_ADDR: Int = -1 @@ -2392,4 +2534,7 @@ internal class MeshEngine( } private data class InboundPacketKey(val from: Int, val to: Int, val channel: Int, val id: Int) + + /** In-memory per-node session passkey with absolute expiry (epoch ms). */ + private class SessionPasskeyEntry(val bytes: ByteArray, val expiresAtMs: Long) } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt new file mode 100644 index 0000000..379f138 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt @@ -0,0 +1,216 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.bytestring.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.MqttClientProxyMessage +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.QueueStatus +import org.meshtastic.proto.User +import org.meshtastic.proto.XModem +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Behaviour added for the Meshtastic-Android hard cutover (0.2.0): typed auxiliary-frame + * events, QueueStatus fast-fail, threaded-reply sendText, and PKC / admin-channel routing + * for remote admin. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AndroidCutoverPrereqsTest { + + @Test + fun sendTextCarriesReplyIdForThreadedReplies() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + + client.sendText("on my way", to = REMOTE_NODE, replyId = 42) + runCurrent() + + val outbound = transport.outboundPackets().drop(before).last() + val decoded = assertNotNull(outbound.decoded) + assertEquals(42, decoded.reply_id) + assertEquals(0, decoded.emoji, "A threaded reply is not an emoji reaction") + } + } + + @Test + fun queueStatusRejectionFastFailsTheHandle() = runTest { + withConnectedClient { client, transport -> + val handle = client.sendText("burst") + runCurrent() + assertEquals(SendState.Sent, handle.state.value) + + transport.injectFrame( + frameOf(FromRadio(queueStatus = QueueStatus(res = 9, mesh_packet_id = handle.id.raw))), + ) + runCurrent() + + val state = assertIs(handle.state.value) + assertEquals(SendFailure.QueueRejected(res = 9), state.reason) + } + } + + @Test + fun auxiliaryFramesSurfaceAsTypedEvents() = runTest { + withConnectedClient { client, transport -> + client.events.test { + val proxy = MqttClientProxyMessage(topic = "msh/2/e/test", retained = true) + transport.injectFrame(frameOf(FromRadio(mqttClientProxyMessage = proxy))) + runCurrent() + assertEquals(MeshEvent.MqttProxyMessage(proxy), awaitItem()) + + val xmodem = XModem(seq = 7) + transport.injectFrame(frameOf(FromRadio(xmodemPacket = xmodem))) + runCurrent() + assertEquals(MeshEvent.XmodemPacket(xmodem), awaitItem()) + + val fileInfo = org.meshtastic.proto.FileInfo(file_name = "firmware.uf2", size_bytes = 1024) + transport.injectFrame(frameOf(FromRadio(fileInfo = fileInfo))) + runCurrent() + assertEquals(MeshEvent.FileInfo(fileInfo), awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun remoteAdminUsesPkcWhenBothNodesHavePublishedKeys() = runTest { + withConnectedClient { client, transport -> + transport.injectFrame( + frameOf( + FromRadio(node_info = NodeInfo(num = TEST_NODE_NUM, user = User(id = "!1", public_key = MY_KEY))), + ), + ) + transport.injectFrame( + frameOf( + FromRadio( + node_info = NodeInfo(num = REMOTE_NODE.raw, user = User(id = "!2", public_key = REMOTE_KEY)), + ), + ), + ) + runCurrent() + + val before = transport.outboundPackets().size + val deferred = backgroundScope.async { client.admin.forNode(REMOTE_NODE).getDeviceMetadata() } + runCurrent() + + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + assertTrue(outbound.pki_encrypted, "Remote admin must be PKI-encrypted when both nodes have keys") + assertContentEquals(REMOTE_KEY.toByteArray(), outbound.public_key.toByteArray()) + assertEquals(0, outbound.channel, "PKC admin is sent on channel 0") + assertEquals(MeshPacket.Priority.RELIABLE, outbound.priority) + + transport.injectAdminResponse( + requestId = outbound.id, + response = AdminMessage(get_device_metadata_response = DeviceMetadata(firmware_version = "2.7.0")), + fromNode = REMOTE_NODE.raw, + ) + runCurrent() + assertIs>(deferred.await()) + } + } + + @Test + fun remoteAdminFallsBackToNamedAdminChannelWithoutKeys() = runTest { + withConnectedClient { client, transport -> + // Establish a secondary channel named "admin" via the normal setChannel path so + // channelsState carries it at index 1. + val adminChannel = Channel( + index = 1, + role = Channel.Role.SECONDARY, + settings = ChannelSettings(name = "Admin"), + ) + val setDeferred = backgroundScope.async { client.admin.setChannel(adminChannel) } + runCurrent() + val setPacket = transport.outboundPackets().last { + runCatching { AdminMessage.ADAPTER.decode(it.decoded!!.payload) }.getOrNull()?.set_channel != null + } + transport.injectRoutingAck(requestId = setPacket.id) + runCurrent() + assertIs>(setDeferred.await()) + + val before = transport.outboundPackets().size + val deferred = backgroundScope.async { client.admin.forNode(REMOTE_NODE).getDeviceMetadata() } + runCurrent() + + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + assertFalse(outbound.pki_encrypted, "No keys published — PKC must not be used") + assertEquals(1, outbound.channel, "Remote admin without keys uses the channel named 'admin'") + + deferred.cancel() + } + } + + // ── Harness ───────────────────────────────────────────────────────────── + + private fun TestScope.buildClient(transport: FakeRadioTransport): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .build() + + private suspend fun TestScope.withConnectedClient(block: suspend (RadioClient, FakeRadioTransport) -> Unit) { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:cutover"), + autoHandshake = true, + nodeNum = TEST_NODE_NUM, + ) + val client = buildClient(transport) + client.connect() + runCurrent() + assertEquals(ConnectionState.Connected, client.connection.value) + try { + block(client, transport) + } finally { + client.disconnect() + runCurrent() + } + } + + private fun frameOf(fromRadio: FromRadio): Frame { + val proto = FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(ByteString(bytes)) + } + + private companion object { + const val TEST_NODE_NUM: Int = 0x11111111 + val REMOTE_NODE: NodeId = NodeId(0x22222222) + val MY_KEY = byteArrayOf(1, 1, 2, 2).toByteString() + val REMOTE_KEY = byteArrayOf(3, 3, 4, 4).toByteString() + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt index 369860e..6b954a1 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt @@ -273,7 +273,35 @@ class HandshakeAndReconnectTest { } @Test - fun remoteAdminUsesSeededSessionPasskeyBeforeExpiry() = runTest { + fun packetsReceivedMidHandshakeAreDeliveredAfterConnected() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:handshake-packet-buffer"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { client.connect() } + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + + client.packets.test { + // Live mesh traffic interleaved with the Stage 2 drain must not be lost — and + // must not be delivered before the session is Ready. + transport.injectAlivePacket(packetId = 777) + drainCurrent() + expectNoEvents() + + transport.injectFromRadio(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE2)) + drainCurrent() + connectJob.await() + assertEquals(ConnectionState.Connected, client.connection.value) + + assertEquals(777, awaitItem().id, "Buffered handshake packet must flush at Ready") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun remoteAdminUsesPasskeyIssuedByTheTargetNode() = runTest { val transport = ScriptedTransport( identity = TransportIdentity("fake:session-passkey"), nowMs = { currentTime }, @@ -293,14 +321,18 @@ class HandshakeAndReconnectTest { } runCurrent() + // Session passkeys are per-node: the target has never issued us one, and the *local* + // node's seeded passkey must NOT leak onto a remote-bound admin packet. val request = transport.outboundPackets().drop(outboundBefore) .last { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } val admin = assertNotNull(adminOf(request)) - assertContentEquals(SEEDED_PASSKEY, admin.session_passkey.toByteArray()) + assertEquals(0, admin.session_passkey.size, "Local passkey must not be stamped on a remote target") + // The response carries the remote node's own passkey (firmware refreshes it in every + // admin response); the engine latches it keyed by the responder. transport.injectAdminResponse( requestId = request.id, - response = AdminMessage(get_config_response = expected), + response = AdminMessage(get_config_response = expected, session_passkey = REMOTE_PASSKEY.toByteString()), fromNode = remoteNode.raw, ) runCurrent() @@ -308,6 +340,24 @@ class HandshakeAndReconnectTest { val result = deferred.await() assertIs>(result) assertEquals(expected, result.value) + + // A follow-up RPC to the same remote node is stamped with the passkey *it* issued. + val outboundBeforeSecond = transport.outboundPackets().size + val second = backgroundScope.async { + client.admin.forNode(remoteNode).getConfig(AdminMessage.ConfigType.LORA_CONFIG) + } + runCurrent() + val secondRequest = transport.outboundPackets().drop(outboundBeforeSecond) + .last { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + assertContentEquals(REMOTE_PASSKEY, adminOf(secondRequest)?.session_passkey?.toByteArray()) + + transport.injectAdminResponse( + requestId = secondRequest.id, + response = AdminMessage(get_config_response = expected), + fromNode = remoteNode.raw, + ) + runCurrent() + assertIs>(second.await()) } @Test @@ -342,17 +392,27 @@ class HandshakeAndReconnectTest { transport.injectRoutingError(firstRemote.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) drainCurrent() - val outboundAfterRetry = transport.outboundPackets().drop(outboundBefore) - val reseed = outboundAfterRetry.firstOrNull { - it.to == transport.nodeNum && adminOf(it)?.get_owner_request == true + // Re-seed targets the *remote* node — its passkey is the one the retry needs. + val reseed = transport.outboundPackets().drop(outboundBefore).firstOrNull { + it.to == remoteNode.raw && adminOf(it)?.get_owner_request == true } - assertNotNull(reseed, "Session expiry must trigger a local get_owner re-seed") + assertNotNull(reseed, "Session expiry must trigger a get_owner re-seed against the target node") + + transport.injectAdminResponse( + requestId = reseed.id, + response = AdminMessage( + get_owner_response = User(id = "!0000beef", long_name = "Remote", short_name = "RN"), + session_passkey = REMOTE_PASSKEY.toByteString(), + ), + fromNode = remoteNode.raw, + ) + drainCurrent() - val replay = outboundAfterRetry.last { + val replay = transport.outboundPackets().drop(outboundBefore).last { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } assertTrue(replay.id != firstRemote.id, "Replay must use a fresh wire id") - assertContentEquals(SEEDED_PASSKEY, adminOf(replay)?.session_passkey?.toByteArray()) + assertContentEquals(REMOTE_PASSKEY, adminOf(replay)?.session_passkey?.toByteArray()) transport.injectAdminResponse( requestId = replay.id, @@ -391,8 +451,22 @@ class HandshakeAndReconnectTest { transport.injectRoutingError(firstRemote.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) drainCurrent() + // Answer the (remote-targeted) re-seed so the single retry can proceed. + val reseed = transport.outboundPackets().drop(outboundBefore) + .first { it.to == remoteNode.raw && adminOf(it)?.get_owner_request == true } + transport.injectAdminResponse( + requestId = reseed.id, + response = AdminMessage( + get_owner_response = User(id = "!0000d00d", long_name = "Remote", short_name = "RN"), + session_passkey = REMOTE_PASSKEY.toByteString(), + ), + fromNode = remoteNode.raw, + ) + drainCurrent() + val replay = transport.outboundPackets().drop(outboundBefore) .last { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + assertTrue(replay.id != firstRemote.id, "Replay must use a fresh wire id") transport.injectRoutingError(replay.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) drainCurrent() @@ -400,7 +474,7 @@ class HandshakeAndReconnectTest { assertEquals( 1, transport.outboundPackets().drop(outboundBefore).count { - it.to == transport.nodeNum && adminOf(it)?.get_owner_request == true + it.to == remoteNode.raw && adminOf(it)?.get_owner_request == true }, "Re-authentication must be single-shot", ) @@ -752,6 +826,10 @@ class HandshakeAndReconnectTest { inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(node_info = NodeInfo(num = node)))) } + fun injectFromRadio(fromRadio: org.meshtastic.proto.FromRadio) { + inbound.tryEmit(encodeFromRadioFrame(fromRadio)) + } + fun injectAdminResponse(requestId: Int, response: AdminMessage, fromNode: Int = nodeNum) { val payload = AdminMessage.ADAPTER.encode(response).toByteString() val packet = MeshPacket( @@ -867,5 +945,6 @@ class HandshakeAndReconnectTest { const val STAGE2_PROGRESS_TICK_MS = 30_000L const val STAGE2_HARD_CAP_MS = 60_000L val SEEDED_PASSKEY = byteArrayOf(1, 2, 3, 4) + val REMOTE_PASSKEY = byteArrayOf(9, 8, 7, 6) } } From 216e07b313974f7a431d1753435bfd890d1c50c3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:36:49 -0500 Subject: [PATCH 02/16] feat(transport-ble): negotiate ATT MTU and boost connection priority on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android keeps the ATT MTU at the BLE minimum (23) unless the central requests otherwise, capping write-without-response payloads at 20 bytes — nearly every real ToRadio frame (setConfig, sendText) exceeds that, so the transport was effectively broken on Android (conformance had only ever run on JVM/macOS, where CoreBluetooth auto-negotiates). The BleTransport(address) factory now installs a best-effort post-connect hook that requests MTU 517 and CONNECTION_PRIORITY_HIGH for the 30-second handshake window before downgrading to Balanced (mirrors the Android reference client). The hook is internal to the transport and runs once per link establishment, including auto-reconnect cycles. Also bumps Kable 0.42.0 -> 0.43.0 to align with Meshtastic-Android. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- .../sdk/transport/ble/BleTransport.android.kt | 58 +++++++++++++++++-- .../sdk/transport/ble/BleTransport.kt | 18 ++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5b61fc5..6773653 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ meshtasticProtobufs = "2.7.25" sqldelight = "2.3.2" # --- Transports --- -kable = "0.42.0" +kable = "0.43.0" ktor = "3.5.0" # matches Meshtastic-Android jSerialComm = "2.11.4" diff --git a/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt b/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt index 1df2817..27a5b1d 100644 --- a/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt +++ b/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt @@ -7,9 +7,27 @@ */ package org.meshtastic.sdk.transport.ble +import com.juul.kable.AndroidPeripheral import com.juul.kable.Peripheral import com.juul.kable.PeripheralBuilder import com.juul.kable.toIdentifier +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * ATT MTU requested after each link establishment. ToRadio/FromRadio frames are up to 512 bytes + * and Android keeps the ATT MTU at the BLE minimum (23 — i.e. 20-byte writes) unless the central + * explicitly negotiates; 517 is the Android maximum and what Meshtastic firmware expects. + */ +private const val PREFERRED_MTU: Int = 517 + +/** + * How long to hold [AndroidPeripheral.Priority.High] after connecting before downgrading to + * [AndroidPeripheral.Priority.Balanced]. Covers the config handshake window (faster connection + * interval ≈ faster handshake) without holding the radio at high duty cycle forever. + */ +private const val CONNECTION_PRIORITY_BOOST_MS: Long = 30_000L /** * Android-specific factory that creates a [BleTransport] from a persisted MAC address string. @@ -17,6 +35,11 @@ import com.juul.kable.toIdentifier * On Android, Kable's `Identifier` is a type alias for `String`, but [toIdentifier] is the * canonical conversion and remains correct across Kable versions. * + * After each successful link establishment the transport negotiates the ATT MTU + * ([PREFERRED_MTU]) and requests a high connection priority for the handshake window + * (downgraded to Balanced after [CONNECTION_PRIORITY_BOOST_MS]). Both are best-effort: a + * failure leaves the OS defaults in place and never fails the connect. + * * Example — bonded device (no fresh advertisement, must use `autoConnect`): * ```kotlin * val transport = BleTransport(address = "AA:BB:CC:DD:EE:FF") { @@ -25,11 +48,36 @@ import com.juul.kable.toIdentifier * ``` * * @param address Bluetooth MAC address string (e.g. `"AA:BB:CC:DD:EE:FF"`). - * @param builderAction Optional [PeripheralBuilder] action for GATT configuration (MTU, threading, + * @param builderAction Optional [PeripheralBuilder] action for GATT configuration (threading, * `autoConnect`, etc.). For bonded devices without a fresh advertisement, add * `autoConnectIf { true }` to avoid GATT error 133. */ -public fun BleTransport(address: String, builderAction: PeripheralBuilder.() -> Unit = {}): BleTransport = BleTransport( - peripheral = Peripheral(address.toIdentifier(), builderAction), - address = address, -) +public fun BleTransport(address: String, builderAction: PeripheralBuilder.() -> Unit = {}): BleTransport { + val transport = BleTransport( + peripheral = Peripheral(address.toIdentifier(), builderAction), + address = address, + ) + transport.postConnectHook = { connected -> + val android = connected as? AndroidPeripheral + if (android != null) { + tryTune { android.requestMtu(PREFERRED_MTU) } + tryTune { android.requestConnectionPriority(AndroidPeripheral.Priority.High) } + launch { + delay(CONNECTION_PRIORITY_BOOST_MS) + tryTune { android.requestConnectionPriority(AndroidPeripheral.Priority.Balanced) } + } + } + } + return transport +} + +/** Run a best-effort GATT tuning call: cancellation propagates, everything else is ignored. */ +private inline fun tryTune(block: () -> Unit) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // best-effort: leave OS defaults in place. + } +} diff --git a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt index 6814cbd..08867f2 100644 --- a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt +++ b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt @@ -96,6 +96,16 @@ public class BleTransport( // ADR-012: lifecycle idempotency — only one Error transition per connect cycle. private val errorPublished = atomic(false) + /** + * Platform hook invoked once per successful link establishment, after the GATT connection + * (and Kable's service discovery) completes and before the warmup read. Platform factories + * use it for link tuning — e.g. the Android factory negotiates the ATT MTU and requests a + * faster connection interval here. Receives the transport's [CoroutineScope] as receiver + * for scheduling follow-ups (e.g. a delayed priority downgrade). Best-effort: failures are + * swallowed and never fail the connect. + */ + internal var postConnectHook: suspend CoroutineScope.(Peripheral) -> Unit = {} + override suspend fun connect() { check(!shuttingDown.value) { "BleTransport has been shut down; construct a new instance" } // Reset per-connect lifecycle flags so reuse-after-disconnect works. @@ -105,6 +115,14 @@ public class BleTransport( try { connectWithRetry() + try { + postConnectHook(scope, peripheral) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // best-effort platform tuning (MTU / connection priority); never fatal. + } + stateBridgeJob = peripheral.state .onEach { kableState -> bridgeKableState(kableState) } .launchIn(scope) From a48046df9204481c8867c3466b09b0b9140c2fbb Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:36:49 -0500 Subject: [PATCH 03/16] docs: changelog for the 0.2.0 Android-cutover prerequisites Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24786ac..a329413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,20 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **events:** Inbound MQTT client-proxy, XModem, and file-info frames are now surfaced as typed events — `MeshEvent.MqttProxyMessage`, `MeshEvent.XmodemPacket`, `MeshEvent.FileInfo` — instead of being dropped with a `ProtocolWarning`. Outbound counterparts go through `RadioClient.sendRaw(ToRadio(...))`. +- **send:** `SendFailure.QueueRejected(res)` — a firmware `QueueStatus.res != 0` (transmit queue full/rejected) now fast-fails the `MessageHandle` (and any pending admin RPC sharing the wire id) instead of waiting out the full ACK timeout. +- **send:** `RadioClient.sendText(..., replyId)` for threaded replies (`decoded.reply_id` without the emoji flag). +- **engine:** Mesh packets received while the handshake is still in flight (live traffic interleaved with the config/NodeDB drain, including the seeding window) are now buffered (drop-oldest at 64, observable via `PacketsDropped`) and flushed through the normal packet pipeline at Ready — previously they were silently dropped. + ### Changed +- **remote admin (breaking behavior, correctness):** Remote admin packets are now routed the way modern firmware (2.5+) requires — `pki_encrypted = true` + the target's `public_key` on channel 0 when both nodes have published keys, falling back to a channel named `admin` otherwise, with priority `RELIABLE`. Previously remote admin went out on channel 0 in the clear and was rejected by current firmware. +- **remote admin:** Session passkeys are now cached **per node** (each node issues its own in its admin responses; every inbound admin response refreshes the issuer's entry). Previously a single shared slot cross-contaminated concurrent admin sessions against different nodes and stamped the *local* node's passkey onto remote targets. The `SessionKeyExpired` single-shot retry now re-seeds against the *target* node. +- **storage:** Documented that `DeviceStorage.loadNodes()` is never called by the engine (node DB reseeds from the handshake); it exists for hosts (offline node access) and tests. +- **transport-ble (Android):** `BleTransport(address)` now negotiates the ATT MTU (517) after each link establishment and requests `CONNECTION_PRIORITY_HIGH` for the 30-second handshake window before downgrading to Balanced. Without the MTU request Android stays at the BLE minimum (23) and any ToRadio write over 20 bytes fails. +- **build:** Kable 0.42.0 → 0.43.0 (aligns with Meshtastic-Android). - **build:** Aligned the toolchain with Meshtastic-Android — Kotlin 2.3.21 (SKIE 0.10.12), Wire 6.4.0, Ktor 3.5.0, and stable coroutines 1.11.0 (was 1.11.0-rc02). Validated locally: iOS framework link, jvmTest, and Kotlin ABI check all green. - **docs(SPEC.md):** Bumped spec from v2.1 to v2.2 — full post-audit sync aligning spec with shipped implementation. Key areas synchronized: AdminApi expansion (~15 → ~45 methods), `StoreForwardApi`, presence tracking (`WentOffline`/`CameOnline`), `AutoReconnectConfig`, `CongestionWarning`/`ExternalConfigChange`/`StorageDegraded` MeshEvent variants, send DSL, `connectAndAwaitReady()`, `SessionPasskey`, `ConfigBundle.deviceUIConfig`, `SendFailure.IdCollision`/`AckTimeout`/`HandshakeFailed`, `AdminResult` extensions, `ConnectionState` extensions, `MeshtasticException` context fields, convention plugin + version catalog correction (JVM 17→21, Android SDK→36, Kotlin 2.3.20). - **docs:** Synchronized `api-reference.md`, `error-taxonomy.md`, `roadmap.md`, `module-graph.md`, `README.md`, `CONTRIBUTING.md` with spec v2.2 changes. From 5d1b13c31207813d56b20e747ab84d24262d0d78 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:52:32 -0500 Subject: [PATCH 04/16] fix(core): firmware-conformance fixes from PhoneAPI/AdminModule source audit Audited the engine against firmware main (PhoneAPI.cpp, AdminModule.cpp, MeshService.cpp @ 4e7181c79) and protobufs v2.7.25. Two real defects: - Stage 1 accumulators now replace by key (channel index / config section) instead of appending: a want_config_id retry restarts the firmware's config drain from scratch (PhoneAPI handleStartConfig resets its read index), which duplicated channels and config sections in the committed ConfigBundle and channels state. - FromRadio.lockdown_status (protobufs 2.7.25+) fell through every routing arm and was silently dropped, violating the no-silent-loss rule. It now surfaces as a structured ProtocolWarning until typed lockdown support lands alongside AdminMessage.lockdown_auth. Audit confirmations baked into tests/comments: firmware forces from=0 on all phone packets (MeshService.cpp:188), so local admin authorizes via the from==0 branch and never needs a passkey; every phone send gets a per-packet QueueStatus(res, mesh_packet_id) from sendToMesh, so the QueueRejected fast-fail is live; the 69420/69421 special nonces are the firmware-sanctioned config-only/nodes-only drains. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- CHANGELOG.md | 5 ++ .../org/meshtastic/sdk/internal/MeshEngine.kt | 28 ++++++++-- .../sdk/AndroidCutoverPrereqsTest.kt | 7 +++ .../sdk/HandshakeAndReconnectTest.kt | 54 +++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a329413..4e2369c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **send:** `RadioClient.sendText(..., replyId)` for threaded replies (`decoded.reply_id` without the emoji flag). - **engine:** Mesh packets received while the handshake is still in flight (live traffic interleaved with the config/NodeDB drain, including the seeding window) are now buffered (drop-oldest at 64, observable via `PacketsDropped`) and flushed through the normal packet pipeline at Ready — previously they were silently dropped. +### Fixed + +- **engine:** A `want_config_id` retry restarts the firmware's config drain from scratch (PhoneAPI resets its read index), which previously duplicated channels / config sections in the committed `ConfigBundle` and `channels` state. Stage 1 accumulators now replace by key (channel index / config section), latest occurrence wins. +- **engine:** `FromRadio.lockdown_status` (protobufs 2.7.25+) was silently dropped — it now surfaces as a structured `ProtocolWarning` until typed lockdown support lands. + ### Changed - **remote admin (breaking behavior, correctness):** Remote admin packets are now routed the way modern firmware (2.5+) requires — `pki_encrypted = true` + the target's `public_key` on channel 0 when both nodes have published keys, falling back to a channel named `admin` otherwise, with priority `RELIABLE`. Previously remote admin went out on channel 0 in the clear and was rejected by current firmware. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index 8d429bf..d9b985a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -1023,16 +1023,31 @@ internal class MeshEngine( val deviceUIConf = fromRadio.deviceuiConfig val completeId = fromRadio.config_complete_id + // Accumulators must be idempotent: a `want_config_id` retry (Stage1RetryWantConfig) or a + // device-side hiccup restarts the firmware's config drain from scratch (PhoneAPI + // handleStartConfig resets its read index), so the same channels/config sections can + // stream twice. Replace-by-key instead of append so the committed bundle has no dupes. when { myInfo != null -> pendingMyInfo = myInfo metadata != null -> pendingMetadata = metadata - channel != null -> pendingChannels.add(channel) + channel != null -> { + pendingChannels.removeAll { it.index == channel.index } + pendingChannels.add(channel) + } - config != null -> pendingConfigs.add(config) + config != null -> { + val merged = mergeConfigs(pendingConfigs, listOf(config)) + pendingConfigs.clear() + pendingConfigs.addAll(merged) + } - modConfig != null -> pendingModuleConfigs.add(modConfig) + modConfig != null -> { + val merged = mergeModuleConfigs(pendingModuleConfigs, listOf(modConfig)) + pendingModuleConfigs.clear() + pendingModuleConfigs.addAll(merged) + } deviceUIConf != null -> pendingDeviceUIConfig = deviceUIConf @@ -1620,6 +1635,13 @@ internal class MeshEngine( true } + // Lockdown (protobufs 2.7.25+, firmware support in flight): surfaced as a structured + // warning until a typed event lands alongside AdminMessage.lockdown_auth support. + fromRadio.lockdown_status != null -> { + warnUnhandledVariant("lockdown_status", stage) + true + } + // queueStatus arriving mid-handshake is not actionable (we have no // pendingSends yet) — surface it explicitly so it's not silently dropped. stage != HandshakeStage.Ready && fromRadio.queueStatus != null -> { diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt index 379f138..eb1e562 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt @@ -95,6 +95,13 @@ class AndroidCutoverPrereqsTest { runCurrent() assertEquals(MeshEvent.FileInfo(fileInfo), awaitItem()) + // lockdown_status (protobufs 2.7.25+) is not yet typed — but it must not be + // silently dropped either. + transport.injectFrame(frameOf(FromRadio(lockdown_status = org.meshtastic.proto.LockdownStatus()))) + runCurrent() + val warning = assertIs(awaitItem()) + assertEquals("lockdown_status", warning.details["variant"]) + cancelAndIgnoreRemainingEvents() } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt index 6b954a1..0a7ebef 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt @@ -272,6 +272,60 @@ class HandshakeAndReconnectTest { assertEquals(ConnectionState.Connected, retryClient.connection.value) } + @Test + fun stage1RedrainDoesNotDuplicateConfigOrChannels() = runTest { + // A want_config_id retry restarts the firmware's config drain from scratch, so the + // same config sections / channels can stream twice. The committed bundle must hold + // one entry per section/index, with the latest occurrence winning. + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage1-redrain"), + nowMs = { currentTime }, + autoCompleteStage1 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { client.connect() } + runCurrent() + + val channel0 = org.meshtastic.proto.Channel( + index = 0, + role = org.meshtastic.proto.Channel.Role.PRIMARY, + settings = org.meshtastic.proto.ChannelSettings(name = "LongFast"), + ) + transport.injectFromRadio(org.meshtastic.proto.FromRadio(my_info = MyNodeInfo(my_node_num = transport.nodeNum))) + transport.injectFromRadio( + org.meshtastic.proto.FromRadio( + config = org.meshtastic.proto.Config( + lora = org.meshtastic.proto.Config.LoRaConfig(use_preset = true), + ), + ), + ) + transport.injectFromRadio(org.meshtastic.proto.FromRadio(channel = channel0)) + // Re-drain: the same section + channel stream again (latest value wins). + transport.injectFromRadio( + org.meshtastic.proto.FromRadio( + config = org.meshtastic.proto.Config( + lora = org.meshtastic.proto.Config.LoRaConfig(use_preset = false), + ), + ), + ) + transport.injectFromRadio(org.meshtastic.proto.FromRadio(channel = channel0)) + transport.injectFromRadio(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE1)) + drainCurrent() + + // Settle windows (100 ms each) → heartbeat → Stage 2 (auto-completed) → seeding → Ready. + repeat(4) { + advanceTimeBy(150L) + drainCurrent() + } + connectJob.await() + assertEquals(ConnectionState.Connected, client.connection.value) + + val bundle = assertNotNull(client.configBundle.value) + assertEquals(1, bundle.configs.size, "Re-drained config sections must not duplicate") + assertEquals(false, bundle.configs.single().lora?.use_preset, "Latest occurrence must win") + assertEquals(1, client.channels.value?.size, "Re-drained channels must not duplicate") + } + @Test fun packetsReceivedMidHandshakeAreDeliveredAfterConnected() = runTest { val transport = ScriptedTransport( From 60640621697c92c73fc9776a01d10bbd82ba05f0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:22:05 -0500 Subject: [PATCH 05/16] refactor(core)!: suspend-only lifecycle + API-convention hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin library best-practices pass: - Remove RadioClient's AutoCloseable/close() — the runBlocking bridge was an ANR trap on Android main and a deadlock risk on iOS main, and a radio session has no safe non-suspending teardown. Lifecycle is suspend-only: try { ... } finally { client.disconnect() }. Docs (threading-model.md, RadioClient KDoc) updated to match. - Bridge the two byte-string vocabularies in the public surface: okio.ByteString (Wire proto types, per ADR-001) <-> kotlinx.io.bytestring.ByteString (Frame/SessionPasskey) via toKotlinxByteString()/toOkioByteString(), so consumers never round-trip raw arrays. - Codify the API conventions in docs/api-reference.md: proto exposure, byte vocabularies, no blocking bridges, the data-class trade (deliberate, ABI-dump-gated; copy()/destructuring are conveniences not contract), and the Kotlin/Swift-first interop stance. - Track Poko adoption for event/result types in the roadmap: 0.23.1 fails against Kotlin 2.3.21 (NoClassDefFoundError: ExtensionPointDescriptor), so @Poko migration waits on upstream. ABI dumps regenerated (close()/AutoCloseable removal). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- CHANGELOG.md | 15 ++++++++ core/api/core.klib.api | 5 ++- core/api/jvm/core.api | 8 +++- .../kotlin/org/meshtastic/sdk/ByteStrings.kt | 38 +++++++++++++++++++ .../kotlin/org/meshtastic/sdk/RadioClient.kt | 26 +++---------- .../org/meshtastic/sdk/ByteStringsTest.kt | 30 +++++++++++++++ docs/api-reference.md | 26 +++++++++++++ docs/roadmap.md | 8 ++++ docs/threading-model.md | 13 ++++--- 9 files changed, 140 insertions(+), 29 deletions(-) create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/ByteStrings.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ByteStringsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2369c..3f37af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,21 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **engine:** A `want_config_id` retry restarts the firmware's config drain from scratch (PhoneAPI resets its read index), which previously duplicated channels / config sections in the committed `ConfigBundle` and `channels` state. Stage 1 accumulators now replace by key (channel index / config section), latest occurrence wins. - **engine:** `FromRadio.lockdown_status` (protobufs 2.7.25+) was silently dropped — it now surfaces as a structured `ProtocolWarning` until typed lockdown support lands. +### Removed + +- **`RadioClient.close()` / `AutoCloseable` (breaking):** the blocking `close()` bridge + (`runBlocking { disconnect() }`) was an ANR/deadlock trap on Android and iOS main threads. + Lifecycle is suspend-only — use `try { … } finally { client.disconnect() }`. + +### Added (API conventions) + +- `okio.ByteString.toKotlinxByteString()` / `kotlinx.io.bytestring.ByteString.toOkioByteString()` + bridge the two byte-string vocabularies in the API surface (Wire proto types vs SDK framing + types) without raw-array round-trips. +- `docs/api-reference.md` gains an **API conventions** section codifying the proto-exposure, + byte-string, no-blocking-bridge, data-class, and Kotlin/Swift-first policies (Poko migration + for event/result types tracked in the roadmap pending Kotlin 2.3.21 support). + ### Changed - **remote admin (breaking behavior, correctness):** Remote admin packets are now routed the way modern firmware (2.5+) requires — `pki_encrypted = true` + the target's `public_key` on channel 0 when both nodes have published keys, falling back to a channel named `admin` otherwise, with priority `RELIABLE`. Previously remote admin went out on channel 0 in the clear and was rejected by current firmware. diff --git a/core/api/core.klib.api b/core/api/core.klib.api index 2a9240b..d416370 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -1432,7 +1432,7 @@ final class org.meshtastic.sdk/NeighborInfo { // org.meshtastic.sdk/NeighborInfo } } -final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.meshtastic.sdk/RadioClient|null[0] +final class org.meshtastic.sdk/RadioClient { // org.meshtastic.sdk/RadioClient|null[0] final val admin // org.meshtastic.sdk/RadioClient.admin|{}admin[0] final fun (): org.meshtastic.sdk/AdminApi // org.meshtastic.sdk/RadioClient.admin.|(){}[0] final val channels // org.meshtastic.sdk/RadioClient.channels|{}channels[0] @@ -1456,7 +1456,6 @@ final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.mesht final val telemetry // org.meshtastic.sdk/RadioClient.telemetry|{}telemetry[0] final fun (): org.meshtastic.sdk/TelemetryApi // org.meshtastic.sdk/RadioClient.telemetry.|(){}[0] - final fun close() // org.meshtastic.sdk/RadioClient.close|close(){}[0] final fun requestNodeInfo(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.requestNodeInfo|requestNodeInfo(org.meshtastic.sdk.NodeId){}[0] final fun send(org.meshtastic.proto/MeshPacket): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.send|send(org.meshtastic.proto.MeshPacket){}[0] final fun sendRaw(org.meshtastic.proto/ToRadio) // org.meshtastic.sdk/RadioClient.sendRaw|sendRaw(org.meshtastic.proto.ToRadio){}[0] @@ -1982,6 +1981,8 @@ final fun (kotlin/ByteArray).org.meshtastic.sdk/toFromRadio(): org.meshtastic.pr final fun (kotlin/ByteArray).org.meshtastic.sdk/toMeshPacket(): org.meshtastic.proto/MeshPacket? // org.meshtastic.sdk/toMeshPacket|toMeshPacket@kotlin.ByteArray(){}[0] final fun (kotlin/ByteArray).org.meshtastic.sdk/toToRadio(): org.meshtastic.proto/ToRadio? // org.meshtastic.sdk/toToRadio|toToRadio@kotlin.ByteArray(){}[0] final fun (kotlin/Int).org.meshtastic.sdk/firmwareSecondsToInstant(): kotlin.time/Instant? // org.meshtastic.sdk/firmwareSecondsToInstant|firmwareSecondsToInstant@kotlin.Int(){}[0] +final fun (kotlinx.io.bytestring/ByteString).org.meshtastic.sdk/toOkioByteString(): okio/ByteString // org.meshtastic.sdk/toOkioByteString|toOkioByteString@kotlinx.io.bytestring.ByteString(){}[0] +final fun (okio/ByteString).org.meshtastic.sdk/toKotlinxByteString(): kotlinx.io.bytestring/ByteString // org.meshtastic.sdk/toKotlinxByteString|toKotlinxByteString@okio.ByteString(){}[0] final fun (org.meshtastic.proto/Channel.Companion).org.meshtastic.sdk/default(): org.meshtastic.proto/Channel // org.meshtastic.sdk/default|default@org.meshtastic.proto.Channel.Companion(){}[0] final fun (org.meshtastic.proto/ChannelSet).org.meshtastic.sdk/toUrl(): kotlin/String // org.meshtastic.sdk/toUrl|toUrl@org.meshtastic.proto.ChannelSet(){}[0] final fun (org.meshtastic.proto/ChannelSettings.Companion).org.meshtastic.sdk/hash(kotlin/String, kotlin/ByteArray): kotlin/Int // org.meshtastic.sdk/hash|hash@org.meshtastic.proto.ChannelSettings.Companion(kotlin.String;kotlin.ByteArray){}[0] diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index 5c7e458..c1a8e46 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -224,6 +224,11 @@ public final class org/meshtastic/sdk/BatteryStatusKt { public static final fun toBatteryStatus (Lorg/meshtastic/proto/Telemetry;)Lorg/meshtastic/sdk/BatteryStatus; } +public final class org/meshtastic/sdk/ByteStringsKt { + public static final fun toKotlinxByteString (Lokio/ByteString;)Lkotlinx/io/bytestring/ByteString; + public static final fun toOkioByteString (Lkotlinx/io/bytestring/ByteString;)Lokio/ByteString; +} + public final class org/meshtastic/sdk/ChannelHelpers { public static final field INSTANCE Lorg/meshtastic/sdk/ChannelHelpers; public static final field MAX_NAME_LENGTH I @@ -1254,10 +1259,9 @@ public final class org/meshtastic/sdk/ProtoBytesKt { public static final fun toToRadio ([B)Lorg/meshtastic/proto/ToRadio; } -public final class org/meshtastic/sdk/RadioClient : java/lang/AutoCloseable { +public final class org/meshtastic/sdk/RadioClient { public static final field Companion Lorg/meshtastic/sdk/RadioClient$Companion; public fun (Lorg/meshtastic/sdk/internal/MeshEngine;Lkotlin/time/Clock;Lkotlin/time/Duration;ZLkotlin/coroutines/CoroutineContext;)V - public fun close ()V public final fun connect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun disconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getAdmin ()Lorg/meshtastic/sdk/AdminApi; diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ByteStrings.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ByteStrings.kt new file mode 100644 index 0000000..53f5b8e --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ByteStrings.kt @@ -0,0 +1,38 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.io.bytestring.ByteString +import okio.ByteString.Companion.toByteString + +/* + * The SDK's API surface deliberately spans two byte-string vocabularies: + * + * - Wire-generated proto types (packet payloads, public keys, passkeys on the wire) use + * [okio.ByteString] — Wire's runtime type, exposed unwrapped per ADR-001 (no mirror types). + * - SDK-native types ([Frame], [SessionPasskey]) use [kotlinx.io.bytestring.ByteString], + * the multiplatform-first standard library type. + * + * These adapters bridge the two so consumers never have to round-trip through raw arrays. + */ + +/** + * Convert an [okio.ByteString] (Wire proto payloads) to a + * [kotlinx.io.bytestring.ByteString][ByteString] (SDK framing/storage types). + * + * @since 0.2.0 + */ +public fun okio.ByteString.toKotlinxByteString(): ByteString = ByteString(toByteArray()) + +/** + * Convert a [kotlinx.io.bytestring.ByteString][ByteString] (SDK framing/storage types) to an + * [okio.ByteString] (Wire proto payloads). + * + * @since 0.2.0 + */ +public fun ByteString.toOkioByteString(): okio.ByteString = toByteArray().toByteString() diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 55ebff0..19dd30a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.io.readByteArray import okio.ByteString.Companion.toByteString import org.meshtastic.proto.MeshPacket @@ -70,9 +69,11 @@ import kotlin.time.Duration.Companion.seconds * **No host plumbing:** foreground services, permissions, notifications, and WorkManager are the * app's responsibility. The SDK owns only the engine. * - * **Resource management:** implements [AutoCloseable], so `client.use { … }` guarantees cleanup - * even on exception. Prefer `disconnect()` (suspend) over `close()` (blocking) when inside a - * coroutine. + * **Resource management:** call the suspending [disconnect] when done — typically from the same + * structured scope that owns the session, e.g. `try { … } finally { client.disconnect() }`. + * There is deliberately no blocking `close()`/[AutoCloseable]: a blocking bridge invites ANR on + * Android's main thread and deadlock on iOS main, and a radio session has no non-suspending way + * to tear down safely. * * @since 0.1.0 */ @@ -82,7 +83,7 @@ public class RadioClient internal constructor( private val rpcTimeout: Duration, private val autoSyncTimeOnConnect: Boolean, private val parentContext: kotlin.coroutines.CoroutineContext, -) : AutoCloseable { +) { // ── Observable state ──────────────────────────────────────────────────── @@ -276,21 +277,6 @@ public class RadioClient internal constructor( engine.disconnect() } - /** - * Blocking close for [AutoCloseable] conformance. - * - * Delegates to [disconnect] via `runBlocking`. Prefer the suspending [disconnect] when - * already inside a coroutine — this overload exists so `RadioClient` works with Kotlin's - * `use { }` idiom and Java's try-with-resources. - * - * **Warning:** Do not call from the main/UI thread — `runBlocking` will block the caller - * until disconnection completes, which can trigger ANR on Android or deadlock on iOS main. - * Use [disconnect] directly from a coroutine scope instead. - */ - override fun close() { - runBlocking { disconnect() } - } - // ── Outbound ──────────────────────────────────────────────────────────── /** diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ByteStringsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ByteStringsTest.kt new file mode 100644 index 0000000..a2b65f1 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ByteStringsTest.kt @@ -0,0 +1,30 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.io.bytestring.ByteString +import okio.ByteString.Companion.toByteString +import kotlin.test.Test +import kotlin.test.assertEquals + +class ByteStringsTest { + + @Test + fun okioToKotlinxRoundTripsBytes() { + val okio = byteArrayOf(0x01, 0x7F, -0x80, 0x00).toByteString() + val kotlinx = okio.toKotlinxByteString() + assertEquals(ByteString(0x01, 0x7F, -0x80, 0x00), kotlinx) + assertEquals(okio, kotlinx.toOkioByteString()) + } + + @Test + fun emptyByteStringsConvertCleanly() { + assertEquals(ByteString(), okio.ByteString.EMPTY.toKotlinxByteString()) + assertEquals(okio.ByteString.EMPTY, ByteString().toOkioByteString()) + } +} diff --git a/docs/api-reference.md b/docs/api-reference.md index e397c8d..e60af72 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -19,6 +19,32 @@ > All public top-level types in `org.meshtastic.sdk` are considered stable for the 0.1.x series. +## API conventions + +Deliberate, enforced choices about the shape of the public surface: + +- **Proto types are exposed unwrapped** ([ADR-001](./decisions/001-public-api-uses-generated-protobufs.md)). + The SDK's ABI therefore tracks the `org.meshtastic:protobufs` artifact; proto bumps are priced + into the SemVer table in [`versioning.md`](./versioning.md). +- **Two byte-string vocabularies, bridged.** Wire-generated proto fields use `okio.ByteString` + (Wire's runtime type); SDK-native types (`Frame`, `SessionPasskey`) use + `kotlinx.io.bytestring.ByteString`. Use `toKotlinxByteString()` / `toOkioByteString()` + (`ByteStrings.kt`) to cross — never round-trip through raw arrays yourself. +- **No blocking bridges.** Lifecycle is suspend-only (`connect()`/`disconnect()`); there is + deliberately no `AutoCloseable`/`close()` on `RadioClient`. Blocking teardown invites ANR on + Android main and deadlock on iOS main. +- **Data classes in the public API are a deliberate trade.** Event/result types are `data` so + consumers get structural equality in tests and exhaustive `when` matching. The ABI cost + (`copy`/`componentN` lock-in) is mitigated by the pre-1.0 window, the CI-gated Kotlin ABI + dumps (every signature change is reviewed), and the SemVer pricing of sealed-subtype + additions. When the [Poko](https://github.com/drewhamilton/Poko) compiler plugin supports the + current Kotlin version, event/result types migrate to `@Poko` classes (tracked in + [`roadmap.md`](./roadmap.md)) — consumers should treat `copy()`/destructuring of SDK types as + unstable conveniences, not contract. +- **Kotlin/Swift first.** No `@JvmOverloads`/`@JvmName` Java-ergonomics annotations; Java + callers are not a supported audience. Swift interop is first-class via SKIE + `@Throws`. + + ### Coordinates and the BOM Every artifact above is published under the `org.meshtastic` group as diff --git a/docs/roadmap.md b/docs/roadmap.md index d198661..c374f70 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,6 +19,14 @@ ## Inventory +> **Tooling deferral:** migrate public event/result `data class`es to +> [Poko](https://github.com/drewhamilton/Poko) `@Poko` classes (drops `copy`/`componentN` +> ABI lock-in, keeps `equals`/`hashCode`/`toString`) once Poko supports Kotlin 2.3.21+ — +> 0.23.1 fails with `NoClassDefFoundError: org/jetbrains/kotlin/extensions/ExtensionPointDescriptor` +> against the 2.3.x compiler. Until then the data-class policy in +> [`api-reference.md`](./api-reference.md#api-conventions) applies. + + | ID | Item | Surface | Status | Phase | Source | |---|---|---|---|---|---| | R-1 | ~~`RadioClient.admin` (admin RPC)~~ | ~~public~~ | **Done** — Sprint 5 (`AdminApiImpl`, [`AdminApi`](../core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt)). 16 methods + `editSettings` transactional builder; `SessionKeyExpired` triggers single-shot retry. | — | — | diff --git a/docs/threading-model.md b/docs/threading-model.md index 0d4855e..d8d5679 100644 --- a/docs/threading-model.md +++ b/docs/threading-model.md @@ -151,12 +151,15 @@ does not have to match. - `RadioClient.connect()` suspends until the engine reaches `ConnectionState.Connected`. **Cancelling the caller scope after connect returns does not disconnect the radio** — call - `client.close()` for that. `close()` is idempotent and suspends - until cleanup completes. -- A closed `RadioClient` is **not reusable**. Build a new one via the - `Builder`. + `client.disconnect()` for that. `disconnect()` is idempotent and + suspends until cleanup completes. There is deliberately no blocking + `close()`/`AutoCloseable` — a blocking bridge invites ANR on + Android's main thread; tear down from a coroutine, e.g. + `try { … } finally { client.disconnect() }`. +- A disconnected `RadioClient` is **not reusable**. Build a new one via + the `Builder`. - Heartbeats are owned by the engine, not by your collector. Cancelling - a flow collection does not stop heartbeats; only `close()` does. + a flow collection does not stop heartbeats; only `disconnect()` does. - `connect()` itself is cancellable; cancellation during connect is fully unwound (including any in-flight transport handshake). From 53be4e8544435b9dfe88a1a8fd08de8c4447a988 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:36:20 -0500 Subject: [PATCH 06/16] =?UTF-8?q?refactor!:=20standardize=20on=20okio.Byte?= =?UTF-8?q?String=20=E2=80=94=20one=20byte=20vocabulary=20across=20the=20A?= =?UTF-8?q?PI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire-generated proto types force okio.ByteString into every proto-typed call for both this SDK and its consumers (Meshtastic-Android consumes the same protobufs artifact), so the kotlinx-io ByteString on Frame / SessionPasskey was a gratuitous second vocabulary. Standardize: - Frame.bytes and SessionPasskey.bytes are okio.ByteString. - send(portnum, payload: kotlinx.io.Buffer) is replaced by send(portnum, payload: okio.ByteString) — the type proto payloads already arrive in, so payloads pass through without array copies. - Internal frame assembly (WireCodec.FrameDecoder, TcpTransport, SerialFrameAssembler) moves to okio.Buffer. - kotlinx-io is dropped from every published module (and the version catalog); okio is promoted to api in :core since it now appears in core's own public signatures. - The toKotlinxByteString()/toOkioByteString() adapters added earlier this cycle are deleted — nothing left to bridge. Consumers (and the app cutover) now use exactly one byte type end-to-end: proto payloads, frames, and storage. ABI dumps regenerated; docs/api-reference.md API-conventions section updated. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- CHANGELOG.md | 10 +++-- core/api/core.klib.api | 20 +++++----- core/api/jvm/core.api | 29 ++++++-------- core/build.gradle.kts | 4 +- .../kotlin/org/meshtastic/sdk/ByteStrings.kt | 38 ------------------- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 36 ++++++++---------- .../kotlin/org/meshtastic/sdk/Storage.kt | 2 +- .../kotlin/org/meshtastic/sdk/Transport.kt | 2 +- .../kotlin/org/meshtastic/sdk/WireCodec.kt | 5 +-- .../org/meshtastic/sdk/internal/MeshEngine.kt | 14 +++---- .../sdk/AdminApiImplComprehensiveTest.kt | 5 ++- .../sdk/AndroidCutoverPrereqsTest.kt | 4 +- .../org/meshtastic/sdk/ByteStringsTest.kt | 30 --------------- .../meshtastic/sdk/EngineAuditFixesTest.kt | 4 +- .../sdk/HandshakeAndReconnectTest.kt | 6 +-- .../org/meshtastic/sdk/HandshakeFsmTest.kt | 5 ++- .../meshtastic/sdk/MeshEngineEdgeCasesTest.kt | 6 +-- .../org/meshtastic/sdk/P0ReliabilityTest.kt | 3 +- .../meshtastic/sdk/P1EngineHardeningTest.kt | 4 +- .../org/meshtastic/sdk/P2AdminRpcTest.kt | 3 +- .../org/meshtastic/sdk/RadioClientSendTest.kt | 9 ++--- .../meshtastic/sdk/StorageResilienceTest.kt | 3 +- docs/api-reference.md | 8 ++-- gradle/libs.versions.toml | 2 - .../storage/sqldelight/SqlDelightStorage.kt | 3 +- testing/build.gradle.kts | 1 - .../sdk/testing/FakeRadioTransport.kt | 5 ++- .../sdk/transport/ble/BleTransport.kt | 5 ++- transport-serial/build.gradle.kts | 1 - .../serial/internal/SerialFrameAssembler.kt | 11 +++--- .../internal/FrameChannelPublisherTest.kt | 5 ++- .../JSerialCommTransportSendSizeTest.kt | 7 ++-- .../sdk/transport/tcp/TcpTransport.kt | 12 +++--- .../transport/tcp/TcpTransportSendSizeTest.kt | 7 ++-- 34 files changed, 116 insertions(+), 193 deletions(-) delete mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/ByteStrings.kt delete mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ByteStringsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f37af4..3e340a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,11 +27,13 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). (`runBlocking { disconnect() }`) was an ANR/deadlock trap on Android and iOS main threads. Lifecycle is suspend-only — use `try { … } finally { client.disconnect() }`. -### Added (API conventions) +### Changed (API conventions, breaking) -- `okio.ByteString.toKotlinxByteString()` / `kotlinx.io.bytestring.ByteString.toOkioByteString()` - bridge the two byte-string vocabularies in the API surface (Wire proto types vs SDK framing - types) without raw-array round-trips. +- **One byte-string vocabulary.** `Frame`, `SessionPasskey`, and the streaming `send` overload + now use `okio.ByteString` — the type Wire-generated proto fields already force into the + surface — instead of `kotlinx.io.bytestring.ByteString`. The `send(portnum, payload: + kotlinx.io.Buffer)` overload is replaced by `send(portnum, payload: okio.ByteString)`. + `kotlinx-io` is no longer a dependency of any published module. - `docs/api-reference.md` gains an **API conventions** section codifying the proto-exposure, byte-string, no-blocking-bridge, data-class, and Kotlin/Swift-first policies (Poko migration for event/result types tracked in the roadmap pending Kotlin 2.3.21 support). diff --git a/core/api/core.klib.api b/core/api/core.klib.api index d416370..2c550e7 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -1246,13 +1246,13 @@ final class org.meshtastic.sdk/DeviceVersion : kotlin/Comparable(kotlinx.io.bytestring/ByteString) // org.meshtastic.sdk/Frame.|(kotlinx.io.bytestring.ByteString){}[0] + constructor (okio/ByteString) // org.meshtastic.sdk/Frame.|(okio.ByteString){}[0] final val bytes // org.meshtastic.sdk/Frame.bytes|{}bytes[0] - final fun (): kotlinx.io.bytestring/ByteString // org.meshtastic.sdk/Frame.bytes.|(){}[0] + final fun (): okio/ByteString // org.meshtastic.sdk/Frame.bytes.|(){}[0] - final fun component1(): kotlinx.io.bytestring/ByteString // org.meshtastic.sdk/Frame.component1|component1(){}[0] - final fun copy(kotlinx.io.bytestring/ByteString = ...): org.meshtastic.sdk/Frame // org.meshtastic.sdk/Frame.copy|copy(kotlinx.io.bytestring.ByteString){}[0] + final fun component1(): okio/ByteString // org.meshtastic.sdk/Frame.component1|component1(){}[0] + final fun copy(okio/ByteString = ...): org.meshtastic.sdk/Frame // org.meshtastic.sdk/Frame.copy|copy(okio.ByteString){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/Frame.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // org.meshtastic.sdk/Frame.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // org.meshtastic.sdk/Frame.toString|toString(){}[0] @@ -1465,7 +1465,7 @@ final class org.meshtastic.sdk/RadioClient { // org.meshtastic.sdk/RadioClient|n final suspend fun disconnect() // org.meshtastic.sdk/RadioClient.disconnect|disconnect(){}[0] final suspend fun nodeSnapshot(): kotlin.collections/Map // org.meshtastic.sdk/RadioClient.nodeSnapshot|nodeSnapshot(){}[0] final suspend fun send(org.meshtastic.proto/PortNum, kotlin/ByteArray, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Boolean = ..., kotlin/Int? = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.send|send(org.meshtastic.proto.PortNum;kotlin.ByteArray;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Boolean;kotlin.Int?){}[0] - final suspend fun send(org.meshtastic.proto/PortNum, kotlinx.io/Buffer, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Boolean = ..., kotlin/Int? = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.send|send(org.meshtastic.proto.PortNum;kotlinx.io.Buffer;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Boolean;kotlin.Int?){}[0] + final suspend fun send(org.meshtastic.proto/PortNum, okio/ByteString, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Boolean = ..., kotlin/Int? = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.send|send(org.meshtastic.proto.PortNum;okio.ByteString;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Boolean;kotlin.Int?){}[0] final class Builder { // org.meshtastic.sdk/RadioClient.Builder|null[0] constructor () // org.meshtastic.sdk/RadioClient.Builder.|(){}[0] @@ -1555,16 +1555,16 @@ final class org.meshtastic.sdk/SendBuilder { // org.meshtastic.sdk/SendBuilder|n } final class org.meshtastic.sdk/SessionPasskey { // org.meshtastic.sdk/SessionPasskey|null[0] - constructor (kotlinx.io.bytestring/ByteString, kotlin/Long) // org.meshtastic.sdk/SessionPasskey.|(kotlinx.io.bytestring.ByteString;kotlin.Long){}[0] + constructor (okio/ByteString, kotlin/Long) // org.meshtastic.sdk/SessionPasskey.|(okio.ByteString;kotlin.Long){}[0] final val bytes // org.meshtastic.sdk/SessionPasskey.bytes|{}bytes[0] - final fun (): kotlinx.io.bytestring/ByteString // org.meshtastic.sdk/SessionPasskey.bytes.|(){}[0] + final fun (): okio/ByteString // org.meshtastic.sdk/SessionPasskey.bytes.|(){}[0] final val expiresAtEpochMs // org.meshtastic.sdk/SessionPasskey.expiresAtEpochMs|{}expiresAtEpochMs[0] final fun (): kotlin/Long // org.meshtastic.sdk/SessionPasskey.expiresAtEpochMs.|(){}[0] - final fun component1(): kotlinx.io.bytestring/ByteString // org.meshtastic.sdk/SessionPasskey.component1|component1(){}[0] + final fun component1(): okio/ByteString // org.meshtastic.sdk/SessionPasskey.component1|component1(){}[0] final fun component2(): kotlin/Long // org.meshtastic.sdk/SessionPasskey.component2|component2(){}[0] - final fun copy(kotlinx.io.bytestring/ByteString = ..., kotlin/Long = ...): org.meshtastic.sdk/SessionPasskey // org.meshtastic.sdk/SessionPasskey.copy|copy(kotlinx.io.bytestring.ByteString;kotlin.Long){}[0] + final fun copy(okio/ByteString = ..., kotlin/Long = ...): org.meshtastic.sdk/SessionPasskey // org.meshtastic.sdk/SessionPasskey.copy|copy(okio.ByteString;kotlin.Long){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/SessionPasskey.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // org.meshtastic.sdk/SessionPasskey.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // org.meshtastic.sdk/SessionPasskey.toString|toString(){}[0] @@ -1981,8 +1981,6 @@ final fun (kotlin/ByteArray).org.meshtastic.sdk/toFromRadio(): org.meshtastic.pr final fun (kotlin/ByteArray).org.meshtastic.sdk/toMeshPacket(): org.meshtastic.proto/MeshPacket? // org.meshtastic.sdk/toMeshPacket|toMeshPacket@kotlin.ByteArray(){}[0] final fun (kotlin/ByteArray).org.meshtastic.sdk/toToRadio(): org.meshtastic.proto/ToRadio? // org.meshtastic.sdk/toToRadio|toToRadio@kotlin.ByteArray(){}[0] final fun (kotlin/Int).org.meshtastic.sdk/firmwareSecondsToInstant(): kotlin.time/Instant? // org.meshtastic.sdk/firmwareSecondsToInstant|firmwareSecondsToInstant@kotlin.Int(){}[0] -final fun (kotlinx.io.bytestring/ByteString).org.meshtastic.sdk/toOkioByteString(): okio/ByteString // org.meshtastic.sdk/toOkioByteString|toOkioByteString@kotlinx.io.bytestring.ByteString(){}[0] -final fun (okio/ByteString).org.meshtastic.sdk/toKotlinxByteString(): kotlinx.io.bytestring/ByteString // org.meshtastic.sdk/toKotlinxByteString|toKotlinxByteString@okio.ByteString(){}[0] final fun (org.meshtastic.proto/Channel.Companion).org.meshtastic.sdk/default(): org.meshtastic.proto/Channel // org.meshtastic.sdk/default|default@org.meshtastic.proto.Channel.Companion(){}[0] final fun (org.meshtastic.proto/ChannelSet).org.meshtastic.sdk/toUrl(): kotlin/String // org.meshtastic.sdk/toUrl|toUrl@org.meshtastic.proto.ChannelSet(){}[0] final fun (org.meshtastic.proto/ChannelSettings.Companion).org.meshtastic.sdk/hash(kotlin/String, kotlin/ByteArray): kotlin/Int // org.meshtastic.sdk/hash|hash@org.meshtastic.proto.ChannelSettings.Companion(kotlin.String;kotlin.ByteArray){}[0] diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index c1a8e46..73060f6 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -224,11 +224,6 @@ public final class org/meshtastic/sdk/BatteryStatusKt { public static final fun toBatteryStatus (Lorg/meshtastic/proto/Telemetry;)Lorg/meshtastic/sdk/BatteryStatus; } -public final class org/meshtastic/sdk/ByteStringsKt { - public static final fun toKotlinxByteString (Lokio/ByteString;)Lkotlinx/io/bytestring/ByteString; - public static final fun toOkioByteString (Lkotlinx/io/bytestring/ByteString;)Lokio/ByteString; -} - public final class org/meshtastic/sdk/ChannelHelpers { public static final field INSTANCE Lorg/meshtastic/sdk/ChannelHelpers; public static final field MAX_NAME_LENGTH I @@ -534,12 +529,12 @@ public final class org/meshtastic/sdk/FirmwareTimeKt { } public final class org/meshtastic/sdk/Frame { - public fun (Lkotlinx/io/bytestring/ByteString;)V - public final fun component1 ()Lkotlinx/io/bytestring/ByteString; - public final fun copy (Lkotlinx/io/bytestring/ByteString;)Lorg/meshtastic/sdk/Frame; - public static synthetic fun copy$default (Lorg/meshtastic/sdk/Frame;Lkotlinx/io/bytestring/ByteString;ILjava/lang/Object;)Lorg/meshtastic/sdk/Frame; + public fun (Lokio/ByteString;)V + public final fun component1 ()Lokio/ByteString; + public final fun copy (Lokio/ByteString;)Lorg/meshtastic/sdk/Frame; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/Frame;Lokio/ByteString;ILjava/lang/Object;)Lorg/meshtastic/sdk/Frame; public fun equals (Ljava/lang/Object;)Z - public final fun getBytes ()Lkotlinx/io/bytestring/ByteString; + public final fun getBytes ()Lokio/ByteString; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1279,9 +1274,9 @@ public final class org/meshtastic/sdk/RadioClient { public final fun requestNodeInfo (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/MessageHandle; public final fun requestNodeInfo-LO_6HKw (I)Lorg/meshtastic/sdk/MessageHandle; public final fun send (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/sdk/MessageHandle; - public final fun send-IJD_lpo (Lorg/meshtastic/proto/PortNum;Lkotlinx/io/Buffer;IIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun send-IJD_lpo (Lorg/meshtastic/proto/PortNum;Lokio/ByteString;IIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun send-IJD_lpo (Lorg/meshtastic/proto/PortNum;[BIIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun send-IJD_lpo$default (Lorg/meshtastic/sdk/RadioClient;Lorg/meshtastic/proto/PortNum;Lkotlinx/io/Buffer;IIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun send-IJD_lpo$default (Lorg/meshtastic/sdk/RadioClient;Lorg/meshtastic/proto/PortNum;Lokio/ByteString;IIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun send-IJD_lpo$default (Lorg/meshtastic/sdk/RadioClient;Lorg/meshtastic/proto/PortNum;[BIIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun sendRaw (Lorg/meshtastic/proto/ToRadio;)V public final fun sendReaction (Ljava/lang/String;Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/ChannelIndex;I)Lorg/meshtastic/sdk/MessageHandle; @@ -1659,13 +1654,13 @@ public final class org/meshtastic/sdk/SendState$Sent : org/meshtastic/sdk/SendSt } public final class org/meshtastic/sdk/SessionPasskey { - public fun (Lkotlinx/io/bytestring/ByteString;J)V - public final fun component1 ()Lkotlinx/io/bytestring/ByteString; + public fun (Lokio/ByteString;J)V + public final fun component1 ()Lokio/ByteString; public final fun component2 ()J - public final fun copy (Lkotlinx/io/bytestring/ByteString;J)Lorg/meshtastic/sdk/SessionPasskey; - public static synthetic fun copy$default (Lorg/meshtastic/sdk/SessionPasskey;Lkotlinx/io/bytestring/ByteString;JILjava/lang/Object;)Lorg/meshtastic/sdk/SessionPasskey; + public final fun copy (Lokio/ByteString;J)Lorg/meshtastic/sdk/SessionPasskey; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/SessionPasskey;Lokio/ByteString;JILjava/lang/Object;)Lorg/meshtastic/sdk/SessionPasskey; public fun equals (Ljava/lang/Object;)Z - public final fun getBytes ()Lkotlinx/io/bytestring/ByteString; + public final fun getBytes ()Lokio/ByteString; public final fun getExpiresAtEpochMs ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9358021..84dd467 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -24,9 +24,8 @@ kotlin { commonMain.dependencies { api(libs.meshtasticProtobufs) api(libs.coroutinesCore) - api(libs.kotlinxIoCore) implementation(libs.atomicfu) - implementation(libs.okio) + api(libs.okio) } commonTest.dependencies { implementation(libs.kotlinTest) @@ -34,7 +33,6 @@ kotlin { implementation(libs.kotestAssertions) implementation(libs.coroutinesTest) implementation(project(":testing")) - implementation(libs.okio) } androidMain.dependencies { implementation(libs.coroutinesAndroid) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ByteStrings.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ByteStrings.kt deleted file mode 100644 index 53f5b8e..0000000 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/ByteStrings.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Meshtastic — open source mesh radio - * Copyright © 2026 Meshtastic LLC - * - * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package org.meshtastic.sdk - -import kotlinx.io.bytestring.ByteString -import okio.ByteString.Companion.toByteString - -/* - * The SDK's API surface deliberately spans two byte-string vocabularies: - * - * - Wire-generated proto types (packet payloads, public keys, passkeys on the wire) use - * [okio.ByteString] — Wire's runtime type, exposed unwrapped per ADR-001 (no mirror types). - * - SDK-native types ([Frame], [SessionPasskey]) use [kotlinx.io.bytestring.ByteString], - * the multiplatform-first standard library type. - * - * These adapters bridge the two so consumers never have to round-trip through raw arrays. - */ - -/** - * Convert an [okio.ByteString] (Wire proto payloads) to a - * [kotlinx.io.bytestring.ByteString][ByteString] (SDK framing/storage types). - * - * @since 0.2.0 - */ -public fun okio.ByteString.toKotlinxByteString(): ByteString = ByteString(toByteArray()) - -/** - * Convert a [kotlinx.io.bytestring.ByteString][ByteString] (SDK framing/storage types) to an - * [okio.ByteString] (Wire proto payloads). - * - * @since 0.2.0 - */ -public fun ByteString.toOkioByteString(): okio.ByteString = toByteArray().toByteString() diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 19dd30a..b774052 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.io.readByteArray import okio.ByteString.Companion.toByteString import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NodeInfo @@ -468,42 +467,39 @@ public class RadioClient internal constructor( } /** - * Convenience: build a [MeshPacket] for the given [portnum] from a [kotlinx.io.Buffer] payload. + * Convenience: build a [MeshPacket] for the given [portnum] from an [okio.ByteString] payload. * - * Identical to the `ByteArray`-accepting [send] overload but reads from a [kotlinx.io.Buffer], - * allowing callers to compose payloads with the `kotlinx-io` sink/source API instead of raw - * byte arrays. The buffer is consumed (read fully) on invocation. + * Identical to the `ByteArray`-accepting [send] overload but takes the byte-string type that + * Wire-generated proto fields already use — pass payloads (or slices of received payloads) + * straight through without copying into a raw array first. * * @param portnum the application port number - * @param payload a [kotlinx.io.Buffer] containing the wire-encoded payload (≤ [DATA_PAYLOAD_LEN]) + * @param payload the wire-encoded payload (≤ [DATA_PAYLOAD_LEN]) * @param to destination [NodeId]; defaults to [NodeId.BROADCAST] * @param channel [ChannelIndex] to send on; defaults to channel 0 (primary) * @param wantAck request a reliable delivery ACK * @param hopLimit explicit hop limit override; `null` keeps the device default * @return a [MessageHandle] tracking delivery state * @throws MeshtasticException.NotConnected if not currently connected - * @throws MeshtasticException.PayloadTooLarge if the buffer content exceeds the device limit - * @since 0.1.0 + * @throws MeshtasticException.PayloadTooLarge if the payload exceeds the device limit + * @since 0.2.0 */ @Throws(MeshtasticException::class, CancellationException::class) public suspend fun send( portnum: PortNum, - payload: kotlinx.io.Buffer, + payload: okio.ByteString, to: NodeId = NodeId.BROADCAST, channel: ChannelIndex = ChannelIndex(0), wantAck: Boolean = false, hopLimit: Int? = null, - ): MessageHandle { - val bytes = payload.readByteArray() - return send( - portnum = portnum, - payload = bytes, - to = to, - channel = channel, - wantAck = wantAck, - hopLimit = hopLimit, - ) - } + ): MessageHandle = send( + portnum = portnum, + payload = payload.toByteArray(), + to = to, + channel = channel, + wantAck = wantAck, + hopLimit = hopLimit, + ) // ── Sub-APIs ──────────────────────────────────────────────────────────── diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt index acc1f87..469f037 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt @@ -252,4 +252,4 @@ public interface DeviceStorage : AutoCloseable { * * @since 0.1.0 */ -public data class SessionPasskey(public val bytes: kotlinx.io.bytestring.ByteString, public val expiresAtEpochMs: Long) +public data class SessionPasskey(public val bytes: okio.ByteString, public val expiresAtEpochMs: Long) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Transport.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Transport.kt index ca1284d..3f6e52b 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Transport.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Transport.kt @@ -9,7 +9,7 @@ package org.meshtastic.sdk import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import kotlinx.io.bytestring.ByteString +import okio.ByteString /** * A framed packet ready for transmission or received from the device. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/WireCodec.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/WireCodec.kt index 187c3f3..0c4c8a0 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/WireCodec.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/WireCodec.kt @@ -7,8 +7,7 @@ */ package org.meshtastic.sdk -import kotlinx.io.Buffer -import kotlinx.io.readByteArray +import okio.Buffer import org.meshtastic.proto.FromRadio import org.meshtastic.proto.ToRadio @@ -167,7 +166,7 @@ public object WireCodec { } State.READ_PAYLOAD -> { - buffer.writeByte(b) + buffer.writeByte(b.toInt()) bytesRemaining-- if (bytesRemaining == 0) { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index d9b985a..4d92611 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.io.bytestring.ByteString +import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Data @@ -542,7 +542,7 @@ internal class MeshEngine( try { withTimeoutOrNull(GOODBYE_TIMEOUT_MS) { val encoded = WireCodec.encodeToRadio(ToRadio(disconnect = true)) - transport.send(Frame(ByteString(encoded))) + transport.send(Frame(encoded.toByteString())) } } catch (e: CancellationException) { throw e @@ -639,7 +639,7 @@ internal class MeshEngine( // Outbound: stamp the target's session passkey + PKC routing for remote-admin, then enqueue. val outboundPacket = prepareOutboundAdminPacket(msg.packet) val encoded = WireCodec.encodeToRadio(ToRadio(packet = outboundPacket)) - outbound.trySend(Frame(ByteString(encoded))) + outbound.trySend(Frame(encoded.toByteString())) // Arm the timeout last so the timer fires even if dispatch path errors above. val scope = engineScope ?: return val job = scope.launch { @@ -826,7 +826,7 @@ internal class MeshEngine( // BLE doesn't need this since each GATT write is self-framing. if (!transport.identity.raw.startsWith("ble:")) { val wakeBytes = byteArrayOf(0x94.toByte(), 0x94.toByte(), 0x94.toByte(), 0x94.toByte()) - outbound.trySend(Frame(ByteString(wakeBytes))) + outbound.trySend(Frame(wakeBytes.toByteString())) } sendToRadio(ToRadio(want_config_id = NONCE_STAGE1)) @@ -1337,7 +1337,7 @@ internal class MeshEngine( engineScope?.launch { if (storageDegraded) return@launch try { - storage?.saveSessionPasskey(SessionPasskey(ByteString(bytes), expiresAtMs)) + storage?.saveSessionPasskey(SessionPasskey(bytes.toByteString(), expiresAtMs)) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -2129,7 +2129,7 @@ internal class MeshEngine( pendingSends[MessageId(wireId)] = msg.stateFlow val encoded = WireCodec.encodeToRadio(ToRadio(packet = packet)) - outbound.trySend(Frame(ByteString(encoded))) + outbound.trySend(Frame(encoded.toByteString())) // Transition Queued → Sent; the device will confirm with QueueStatus. msg.stateFlow.value = SendState.Sent logger.debug(TAG) { "Send dispatched id=${msg.id}" } @@ -2277,7 +2277,7 @@ internal class MeshEngine( internal fun sendToRadio(msg: ToRadio) { try { val encoded = WireCodec.encodeToRadio(msg) - outbound.trySend(Frame(ByteString(encoded))) + outbound.trySend(Frame(encoded.toByteString())) } catch (e: Exception) { logger.warn(TAG, e) { "Failed to encode outbound ToRadio: ${e.message}" } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt index 386776c..ed8440a 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -1167,7 +1168,7 @@ class AdminApiImplComprehensiveTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(kotlinx.io.bytestring.ByteString(bytes)) + return Frame(bytes.toByteString()) } private suspend fun TestScope.assertAckedOperation( @@ -1265,6 +1266,6 @@ class AdminApiImplComprehensiveTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(kotlinx.io.bytestring.ByteString(bytes)) + return Frame(bytes.toByteString()) } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt index eb1e562..eddc35f 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.io.bytestring.ByteString +import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel @@ -211,7 +211,7 @@ class AndroidCutoverPrereqsTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(ByteString(bytes)) + return Frame(bytes.toByteString()) } private companion object { diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ByteStringsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ByteStringsTest.kt deleted file mode 100644 index a2b65f1..0000000 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ByteStringsTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Meshtastic — open source mesh radio - * Copyright © 2026 Meshtastic LLC - * - * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package org.meshtastic.sdk - -import kotlinx.io.bytestring.ByteString -import okio.ByteString.Companion.toByteString -import kotlin.test.Test -import kotlin.test.assertEquals - -class ByteStringsTest { - - @Test - fun okioToKotlinxRoundTripsBytes() { - val okio = byteArrayOf(0x01, 0x7F, -0x80, 0x00).toByteString() - val kotlinx = okio.toKotlinxByteString() - assertEquals(ByteString(0x01, 0x7F, -0x80, 0x00), kotlinx) - assertEquals(okio, kotlinx.toOkioByteString()) - } - - @Test - fun emptyByteStringsConvertCleanly() { - assertEquals(ByteString(), okio.ByteString.EMPTY.toKotlinxByteString()) - assertEquals(okio.ByteString.EMPTY, ByteString().toOkioByteString()) - } -} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt index 1455389..c046644 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceUIConfig @@ -40,7 +41,6 @@ import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds -import kotlinx.io.bytestring.ByteString as KByteString /** * Coverage for the engine/protocol audit fixes (P1-1 … P1-6, P2-2 … P2-5). @@ -347,7 +347,7 @@ class EngineAuditFixesTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(KByteString(frameBytes)) + return Frame(frameBytes.toByteString()) } /** diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt index 0a7ebef..587cf79 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.io.bytestring.ByteString +import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Data @@ -806,7 +806,7 @@ class HandshakeAndReconnectTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(ByteString(bytes)) + return Frame(bytes.toByteString()) } private sealed interface ConnectOutcome { @@ -983,7 +983,7 @@ class HandshakeAndReconnectTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(ByteString(bytes)) + return Frame(bytes.toByteString()) } private fun decodeAdmin(packet: MeshPacket): AdminMessage? { diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt index 20d96c9..a75996a 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt @@ -16,7 +16,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.io.bytestring.ByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.FromRadio import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.ToRadio @@ -389,7 +390,7 @@ class HandshakeFsmTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(ByteString(bytes)) + return Frame(bytes.toByteString()) } private fun decodeToRadioOrNull(frame: Frame): ToRadio? { diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt index f1e46ae..1f989be 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Data import org.meshtastic.proto.FromRadio import org.meshtastic.proto.MeshPacket @@ -32,7 +33,6 @@ import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -import kotlinx.io.bytestring.ByteString as KByteString @OptIn(ExperimentalCoroutinesApi::class) class MeshEngineEdgeCasesTest { @@ -48,7 +48,7 @@ class MeshEngineEdgeCasesTest { } client.connect() - transport.injectFrame(Frame(KByteString(byteArrayOf(WireFraming.MAGIC_0, WireFraming.MAGIC_1, 0x00)))) + transport.injectFrame(Frame(byteArrayOf(WireFraming.MAGIC_0, WireFraming.MAGIC_1, 0x00).toByteString())) runCurrent() assertEquals(ConnectionState.Connected, client.connection.value) @@ -718,7 +718,7 @@ class MeshEngineEdgeCasesTest { bytes[2] = (declaredLength shr 8).toByte() bytes[3] = (declaredLength and 0xFF).toByte() payload.copyInto(bytes, destinationOffset = WireFraming.HEADER_SIZE) - return Frame(KByteString(bytes)) + return Frame(bytes.toByteString()) } private fun encodedFromRadio(fromRadio: FromRadio): ByteArray = FromRadio.ADAPTER.encode(fromRadio) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt index 2425b4d..63c72b3 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum @@ -157,7 +158,7 @@ class P0ReliabilityTest { assertEquals(null, storage.loadSessionPasskey()) // Save a passkey with a future expiry; load returns it. - val bytes = kotlinx.io.bytestring.ByteString(byteArrayOf(1, 2, 3, 4)) + val bytes = byteArrayOf(1, 2, 3, 4).toByteString() storage.saveSessionPasskey(SessionPasskey(bytes, expiresAtEpochMs = Long.MAX_VALUE)) val loaded = storage.loadSessionPasskey() assertEquals(SessionPasskey(bytes, Long.MAX_VALUE), loaded) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P1EngineHardeningTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P1EngineHardeningTest.kt index 776c9d9..f8e76b9 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P1EngineHardeningTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P1EngineHardeningTest.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Data import org.meshtastic.proto.FromRadio import org.meshtastic.proto.MeshPacket @@ -35,7 +36,6 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.seconds -import kotlinx.io.bytestring.ByteString as KByteString /** * P1 engine hardening fixes: @@ -416,7 +416,7 @@ class P1EngineHardeningTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(KByteString(frameBytes)) + return Frame(frameBytes.toByteString()) } /** Transport that connects but never responds — used to drive Stage 1 to its timeout. */ diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index 877ef7a..ca351fb 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -763,7 +764,7 @@ class P2AdminRpcTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(kotlinx.io.bytestring.ByteString(bytes)) + return Frame(bytes.toByteString()) } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt index 5006423..8197b1c 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt @@ -11,8 +11,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.io.Buffer -import kotlinx.io.readByteArray import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket @@ -234,7 +232,7 @@ class RadioClientSendTest { withConnectedClient { client, transport -> val before = transport.outboundPackets().size val payload = byteArrayOf(0x0A, 0x0B, 0x0C) - val buffer = Buffer().apply { write(payload) } + val buffer = payload.toByteString() val handle = client.send( portnum = PortNum.NODEINFO_APP, @@ -246,7 +244,6 @@ class RadioClientSendTest { ) assertCondition(handle.id.raw != 0, "Expected non-zero message id") - assertContentEquals(byteArrayOf(), buffer.readByteArray()) runCurrent() val outbound = transport.lastNewOutboundPacket(before) @@ -264,7 +261,7 @@ class RadioClientSendTest { @Test fun sendBuffer_notConnectedThrows() = runTest { val client = buildClient() - val buffer = Buffer().apply { write("hello".encodeToByteArray()) } + val buffer = "hello".encodeToByteArray().toByteString() assertFailsWith { client.send(PortNum.TEXT_MESSAGE_APP, buffer) @@ -274,7 +271,7 @@ class RadioClientSendTest { @Test fun sendBuffer_payloadTooLargeThrows() = runTest { withConnectedClient { client, _ -> - val buffer = Buffer().apply { write(ByteArray(DATA_PAYLOAD_LEN + 1)) } + val buffer = ByteArray(DATA_PAYLOAD_LEN + 1).toByteString() assertFailsWith { client.send(PortNum.TEXT_MESSAGE_APP, buffer) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/StorageResilienceTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/StorageResilienceTest.kt index b2a12af..5fe828f 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/StorageResilienceTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/StorageResilienceTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Channel import org.meshtastic.proto.NodeInfo import org.meshtastic.sdk.testing.FakeRadioTransport @@ -161,6 +162,6 @@ class StorageResilienceTest { this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - return Frame(kotlinx.io.bytestring.ByteString(bytes)) + return Frame(bytes.toByteString()) } } diff --git a/docs/api-reference.md b/docs/api-reference.md index e60af72..b1f6634 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -26,10 +26,10 @@ Deliberate, enforced choices about the shape of the public surface: - **Proto types are exposed unwrapped** ([ADR-001](./decisions/001-public-api-uses-generated-protobufs.md)). The SDK's ABI therefore tracks the `org.meshtastic:protobufs` artifact; proto bumps are priced into the SemVer table in [`versioning.md`](./versioning.md). -- **Two byte-string vocabularies, bridged.** Wire-generated proto fields use `okio.ByteString` - (Wire's runtime type); SDK-native types (`Frame`, `SessionPasskey`) use - `kotlinx.io.bytestring.ByteString`. Use `toKotlinxByteString()` / `toOkioByteString()` - (`ByteStrings.kt`) to cross — never round-trip through raw arrays yourself. +- **One byte-string vocabulary: `okio.ByteString`.** Wire-generated proto fields use it + (Wire's runtime type), so it is unavoidable in the surface — and therefore SDK-native types + (`Frame`, `SessionPasskey`, the `send(portnum, payload)` overload) use it too, rather than + introducing a second vocabulary. `kotlinx-io` is deliberately **not** a dependency. - **No blocking bridges.** Lifecycle is suspend-only (`connect()`/`disconnect()`); there is deliberately no `AutoCloseable`/`close()` on `RadioClient`. Blocking teardown invites ANR on Android main and deadlock on iOS main. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6773653..adccc07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,6 @@ androidTargetSdk = "37" # --- Coroutines / IO / time --- coroutines = "1.11.0" # stable (was 1.11.0-rc02); matches Meshtastic-Android -kotlinxIo = "0.9.0" kotlinxDatetime = "0.7.1" kotlinxSerialization = "1.11.0" atomicfu = "0.32.1" @@ -70,7 +69,6 @@ coroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutin coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinxDatetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinxSerializationCore = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } -kotlinxIoCore = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIo" } atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } diff --git a/storage-sqldelight/src/commonMain/kotlin/org/meshtastic/sdk/storage/sqldelight/SqlDelightStorage.kt b/storage-sqldelight/src/commonMain/kotlin/org/meshtastic/sdk/storage/sqldelight/SqlDelightStorage.kt index 5e14fe9..0d3e2b6 100644 --- a/storage-sqldelight/src/commonMain/kotlin/org/meshtastic/sdk/storage/sqldelight/SqlDelightStorage.kt +++ b/storage-sqldelight/src/commonMain/kotlin/org/meshtastic/sdk/storage/sqldelight/SqlDelightStorage.kt @@ -10,6 +10,7 @@ package org.meshtastic.sdk.storage.sqldelight import app.cash.sqldelight.db.SqlDriver import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata @@ -260,7 +261,7 @@ internal class SqlDelightStorage( } return@withContext null } - SessionPasskey(kotlinx.io.bytestring.ByteString(bytes), expiresAtMs) + SessionPasskey(bytes.toByteString(), expiresAtMs) } // ── Heartbeat persistence (P1-6) ────────────────────────────────────────── diff --git a/testing/build.gradle.kts b/testing/build.gradle.kts index ce08a18..63d8eea 100644 --- a/testing/build.gradle.kts +++ b/testing/build.gradle.kts @@ -18,7 +18,6 @@ kotlin { api(project(":core")) api(libs.turbine) api(libs.coroutinesTest) - implementation(libs.kotlinxIoCore) } } } diff --git a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt index 7d0d4ed..abc8802 100644 --- a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt +++ b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt @@ -13,7 +13,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow -import kotlinx.io.bytestring.ByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Data import org.meshtastic.proto.FromRadio @@ -309,6 +310,6 @@ public class FakeRadioTransport( this[3] = (proto.size and 0xFF).toByte() proto.copyInto(this, destinationOffset = 4) } - inboundChannel.trySend(Frame(ByteString(frameBytes))) + inboundChannel.trySend(Frame(frameBytes.toByteString())) } } diff --git a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt index 08867f2..ab146c9 100644 --- a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt +++ b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt @@ -31,7 +31,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout -import kotlinx.io.bytestring.ByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.sdk.Frame import org.meshtastic.sdk.MeshtasticException import org.meshtastic.sdk.RadioTransport @@ -410,7 +411,7 @@ public class BleTransport( out[2] = (payload.size shr 8).toByte() out[3] = (payload.size and 0xFF).toByte() payload.copyInto(out, destinationOffset = 4) - return ByteString(*out) + return out.toByteString() } } } diff --git a/transport-serial/build.gradle.kts b/transport-serial/build.gradle.kts index 1570762..8f8187a 100644 --- a/transport-serial/build.gradle.kts +++ b/transport-serial/build.gradle.kts @@ -33,7 +33,6 @@ kotlin { sourceSets { commonMain.dependencies { api(project(":core")) - implementation(libs.kotlinxIoCore) } val jvmAndroidMain = getByName("jvmAndroidMain") { dependencies { diff --git a/transport-serial/src/commonMain/kotlin/org/meshtastic/sdk/transport/serial/internal/SerialFrameAssembler.kt b/transport-serial/src/commonMain/kotlin/org/meshtastic/sdk/transport/serial/internal/SerialFrameAssembler.kt index 0f7ff2e..5161290 100644 --- a/transport-serial/src/commonMain/kotlin/org/meshtastic/sdk/transport/serial/internal/SerialFrameAssembler.kt +++ b/transport-serial/src/commonMain/kotlin/org/meshtastic/sdk/transport/serial/internal/SerialFrameAssembler.kt @@ -7,9 +7,8 @@ */ package org.meshtastic.sdk.transport.serial.internal -import kotlinx.io.Buffer -import kotlinx.io.bytestring.ByteString -import kotlinx.io.readByteArray +import okio.Buffer +import okio.ByteString.Companion.toByteString import org.meshtastic.sdk.Frame import org.meshtastic.sdk.WireFraming @@ -56,7 +55,7 @@ internal class SerialFrameAssembler(private val onFrame: (Frame) -> Unit) { } payloadLen == 0 -> { - onFrame(Frame(ByteString(byteArrayOf(START1, START2, 0, 0)))) + onFrame(Frame(byteArrayOf(START1, START2, 0, 0).toByteString())) state = State.SCAN_FOR_START1 } @@ -68,7 +67,7 @@ internal class SerialFrameAssembler(private val onFrame: (Frame) -> Unit) { } State.READ_PAYLOAD -> { - payloadBuf.writeByte(byte) + payloadBuf.writeByte(byte.toInt()) if (--bytesRemaining == 0) { val payload = payloadBuf.readByteArray() payloadBuf.clear() @@ -78,7 +77,7 @@ internal class SerialFrameAssembler(private val onFrame: (Frame) -> Unit) { (payloadLen shr 8).toByte(), (payloadLen and 0xFF).toByte(), ) - onFrame(Frame(ByteString(header + payload))) + onFrame(Frame((header + payload).toByteString())) state = State.SCAN_FOR_START1 } } diff --git a/transport-serial/src/jvmAndroidTest/kotlin/org/meshtastic/sdk/transport/serial/internal/FrameChannelPublisherTest.kt b/transport-serial/src/jvmAndroidTest/kotlin/org/meshtastic/sdk/transport/serial/internal/FrameChannelPublisherTest.kt index a3af283..e330869 100644 --- a/transport-serial/src/jvmAndroidTest/kotlin/org/meshtastic/sdk/transport/serial/internal/FrameChannelPublisherTest.kt +++ b/transport-serial/src/jvmAndroidTest/kotlin/org/meshtastic/sdk/transport/serial/internal/FrameChannelPublisherTest.kt @@ -8,7 +8,8 @@ package org.meshtastic.sdk.transport.serial.internal import kotlinx.coroutines.channels.Channel -import kotlinx.io.bytestring.ByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.sdk.Frame import kotlin.test.Test import kotlin.test.assertEquals @@ -22,7 +23,7 @@ import kotlin.test.assertTrue */ class FrameChannelPublisherTest { - private fun frame(b: Byte): Frame = Frame(ByteString(b)) + private fun frame(b: Byte): Frame = Frame(byteArrayOf(b).toByteString()) @Test fun successfulPublishDoesNotIncrementDropCount() { diff --git a/transport-serial/src/jvmAndroidTest/kotlin/org/meshtastic/sdk/transport/serial/internal/JSerialCommTransportSendSizeTest.kt b/transport-serial/src/jvmAndroidTest/kotlin/org/meshtastic/sdk/transport/serial/internal/JSerialCommTransportSendSizeTest.kt index 1b2123f..310afc6 100644 --- a/transport-serial/src/jvmAndroidTest/kotlin/org/meshtastic/sdk/transport/serial/internal/JSerialCommTransportSendSizeTest.kt +++ b/transport-serial/src/jvmAndroidTest/kotlin/org/meshtastic/sdk/transport/serial/internal/JSerialCommTransportSendSizeTest.kt @@ -9,7 +9,8 @@ package org.meshtastic.sdk.transport.serial.internal import com.fazecast.jSerialComm.SerialPort import kotlinx.coroutines.test.runTest -import kotlinx.io.bytestring.ByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.sdk.Frame import org.meshtastic.sdk.MeshtasticException import org.meshtastic.sdk.TransportIdentity @@ -41,7 +42,7 @@ class JSerialCommTransportSendSizeTest { val transport = newTransport() ?: return@runTest val maxEnvelope = ByteArray(WireFraming.MAX_FRAME_ON_WIRE) // 516 B val ex = assertFailsWith { - transport.send(Frame(ByteString(maxEnvelope))) + transport.send(Frame(maxEnvelope.toByteString())) } // Size check must pass; we should reach the "not connected" branch. assertEquals("Serial not connected", ex.message) @@ -52,7 +53,7 @@ class JSerialCommTransportSendSizeTest { val transport = newTransport() ?: return@runTest val tooBig = ByteArray(WireFraming.MAX_FRAME_ON_WIRE + 1) assertFailsWith { - transport.send(Frame(ByteString(tooBig))) + transport.send(Frame(tooBig.toByteString())) } } } diff --git a/transport-tcp/src/commonMain/kotlin/org/meshtastic/sdk/transport/tcp/TcpTransport.kt b/transport-tcp/src/commonMain/kotlin/org/meshtastic/sdk/transport/tcp/TcpTransport.kt index f3351c3..ed3703f 100644 --- a/transport-tcp/src/commonMain/kotlin/org/meshtastic/sdk/transport/tcp/TcpTransport.kt +++ b/transport-tcp/src/commonMain/kotlin/org/meshtastic/sdk/transport/tcp/TcpTransport.kt @@ -26,9 +26,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.io.Buffer -import kotlinx.io.bytestring.ByteString -import kotlinx.io.readByteArray +import okio.Buffer +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.sdk.Frame import org.meshtastic.sdk.MeshtasticException import org.meshtastic.sdk.RadioTransport @@ -160,7 +160,7 @@ public class TcpTransport(private val host: String, private val port: Int = 4403 payloadLen == 0 -> { val header = byteArrayOf(START1, START2, 0, 0) - emit(Frame(ByteString(header))) + emit(Frame(header.toByteString())) fsmState = FsmState.SCAN_FOR_START1 } @@ -172,7 +172,7 @@ public class TcpTransport(private val host: String, private val port: Int = 4403 } FsmState.READ_PAYLOAD -> { - payloadBuf.writeByte(b) + payloadBuf.writeByte(b.toInt()) if (--bytesRemaining == 0) { val payload = payloadBuf.readByteArray() payloadBuf.clear() @@ -184,7 +184,7 @@ public class TcpTransport(private val host: String, private val port: Int = 4403 (payloadLen shr 8).toByte(), (payloadLen and 0xFF).toByte(), ) - emit(Frame(ByteString(header + payload))) + emit(Frame((header + payload).toByteString())) } } } diff --git a/transport-tcp/src/commonTest/kotlin/org/meshtastic/sdk/transport/tcp/TcpTransportSendSizeTest.kt b/transport-tcp/src/commonTest/kotlin/org/meshtastic/sdk/transport/tcp/TcpTransportSendSizeTest.kt index b4220ce..7765555 100644 --- a/transport-tcp/src/commonTest/kotlin/org/meshtastic/sdk/transport/tcp/TcpTransportSendSizeTest.kt +++ b/transport-tcp/src/commonTest/kotlin/org/meshtastic/sdk/transport/tcp/TcpTransportSendSizeTest.kt @@ -8,7 +8,8 @@ package org.meshtastic.sdk.transport.tcp import kotlinx.coroutines.test.runTest -import kotlinx.io.bytestring.ByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.sdk.Frame import org.meshtastic.sdk.MeshtasticException import org.meshtastic.sdk.WireFraming @@ -31,7 +32,7 @@ class TcpTransportSendSizeTest { val transport = TcpTransport("127.0.0.1", 4403) val maxEnvelope = ByteArray(WireFraming.MAX_FRAME_ON_WIRE) // 516 B val ex = assertFailsWith { - transport.send(Frame(ByteString(maxEnvelope))) + transport.send(Frame(maxEnvelope.toByteString())) } // Size check must pass; we should reach the "not connected" branch. assertEquals("TCP not connected", ex.message) @@ -42,7 +43,7 @@ class TcpTransportSendSizeTest { val transport = TcpTransport("127.0.0.1", 4403) val tooBig = ByteArray(WireFraming.MAX_FRAME_ON_WIRE + 1) assertFailsWith { - transport.send(Frame(ByteString(tooBig))) + transport.send(Frame(tooBig.toByteString())) } } } From 0daac92ae5b5307dbc9a39bb1cb82b5d8f78480b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:45:37 -0500 Subject: [PATCH 07/16] =?UTF-8?q?refactor:=20simplification=20pass=20?= =?UTF-8?q?=E2=80=94=20shared=20test=20framing,=20single=20admin=20decode,?= =?UTF-8?q?=20leaner=20accumulators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-angle cleanup review (reuse / simplification / efficiency / altitude) over the branch diff, fixes applied: - testing: add FromRadio.toFrame() — the device-side wire framing that 8 test files (and FakeRadioTransport) each re-implemented now lives in one helper; the local copies delegate to it. - engine: decode AdminMessage once per inbound ADMIN_APP packet — the passkey latch and external-config-change detection each ran their own decode of the same payload on a hot path. - engine: pendingConfigs/pendingModuleConfigs become immutable-list vars reassigned via mergeConfigs/mergeModuleConfigs, dropping the clear()+addAll() churn per config frame during the handshake. - engine: drain handshakePacketBuffer in place instead of copying the backlog to a list at Ready. - transport-ble: source START1/START2 from WireFraming (TCP and serial already did). Reviewed and deliberately NOT changed: prepareOutboundAdminPacket's decode+re-encode passkey stamp (binary-patching Wire payloads is fragile; remote admin is low-frequency and the single engine choke point is worth keeping); postConnectHook as an internal var (constructor plumbing isn't worth it for one platform hook); the queueStatus arm's bookkeeping (pre-transmit rejection is semantically distinct from routing NAKs); moving ScriptedTransport to :testing (900-line virtual-time fixture — tracked as follow-up, too churny for this PR). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../org/meshtastic/sdk/internal/MeshEngine.kt | 60 ++++++++----------- .../sdk/AdminApiImplComprehensiveTest.kt | 13 +--- .../sdk/AndroidCutoverPrereqsTest.kt | 13 +--- .../meshtastic/sdk/EngineAuditFixesTest.kt | 13 +--- .../sdk/HandshakeAndReconnectTest.kt | 25 +------- .../org/meshtastic/sdk/HandshakeFsmTest.kt | 13 +--- .../meshtastic/sdk/P1EngineHardeningTest.kt | 13 +--- .../meshtastic/sdk/StorageResilienceTest.kt | 13 +--- testing/api/jvm/testing.api | 4 ++ testing/api/testing.klib.api | 2 + .../sdk/testing/FakeRadioTransport.kt | 10 +--- .../org/meshtastic/sdk/testing/Frames.kt | 35 +++++++++++ .../sdk/transport/ble/BleTransport.kt | 5 +- 13 files changed, 84 insertions(+), 135 deletions(-) create mode 100644 testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/Frames.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index 4d92611..98026a3 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -185,8 +185,8 @@ internal class MeshEngine( private val pendingNodes = mutableMapOf() // Config accumulator during Stage 1 draining. - private val pendingConfigs = mutableListOf() - private val pendingModuleConfigs = mutableListOf() + private var pendingConfigs = emptyList() + private var pendingModuleConfigs = emptyList() private val pendingChannels = mutableListOf() private var pendingMyInfo: org.meshtastic.proto.MyNodeInfo? = null private var pendingMetadata: org.meshtastic.proto.DeviceMetadata? = null @@ -564,8 +564,8 @@ internal class MeshEngine( sessionPasskeys.clear() handshakePacketBuffer.clear() pendingNodes.clear() - pendingConfigs.clear() - pendingModuleConfigs.clear() + pendingConfigs = emptyList() + pendingModuleConfigs = emptyList() pendingChannels.clear() pendingMyInfo = null pendingMetadata = null @@ -1037,17 +1037,9 @@ internal class MeshEngine( pendingChannels.add(channel) } - config != null -> { - val merged = mergeConfigs(pendingConfigs, listOf(config)) - pendingConfigs.clear() - pendingConfigs.addAll(merged) - } + config != null -> pendingConfigs = mergeConfigs(pendingConfigs, listOf(config)) - modConfig != null -> { - val merged = mergeModuleConfigs(pendingModuleConfigs, listOf(modConfig)) - pendingModuleConfigs.clear() - pendingModuleConfigs.addAll(merged) - } + modConfig != null -> pendingModuleConfigs = mergeModuleConfigs(pendingModuleConfigs, listOf(modConfig)) deviceUIConf != null -> pendingDeviceUIConfig = deviceUIConf @@ -1217,8 +1209,8 @@ internal class MeshEngine( val bundle = ConfigBundle( myInfo = myInfo ?: org.meshtastic.proto.MyNodeInfo(), metadata = pendingMetadata ?: org.meshtastic.proto.DeviceMetadata(), - configs = pendingConfigs.toList(), - moduleConfigs = pendingModuleConfigs.toList(), + configs = pendingConfigs, + moduleConfigs = pendingModuleConfigs, deviceUIConfig = pendingDeviceUIConfig, ) meshState = meshState.withConfig(bundle) @@ -1427,11 +1419,9 @@ internal class MeshEngine( // Flush mesh packets that arrived mid-handshake through the normal Ready pipeline // (presence, dedup, packets flow, RPC/ACK correlation) before draining queued sends. if (handshakePacketBuffer.isNotEmpty()) { - val backlog = handshakePacketBuffer.toList() - handshakePacketBuffer.clear() - logger.info(TAG) { "Flushing ${backlog.size} packets received during handshake" } - for (packet in backlog) { - processInboundMeshPacket(packet) + logger.info(TAG) { "Flushing ${handshakePacketBuffer.size} packets received during handshake" } + while (handshakePacketBuffer.isNotEmpty()) { + processInboundMeshPacket(handshakePacketBuffer.removeFirst()) } } @@ -1551,12 +1541,17 @@ internal class MeshEngine( return } emitPacketOrLog(packet) - if (decoded.portnum == PortNum.ADMIN_APP) { + // Decode the admin payload once; both the passkey latch and external-change detection + // need it, and inbound ADMIN_APP traffic is hot during config syncs. + val adminMsg = if (decoded.portnum == PortNum.ADMIN_APP) { + runCatching { AdminMessage.ADAPTER.decode(decoded.payload) }.getOrNull() + } else { + null + } + if (adminMsg != null) { // every admin response refreshes the responder's session passkey — latch them all // (keyed per node) so remote admin sessions stay alive without re-seeding. - runCatching { AdminMessage.ADAPTER.decode(decoded.payload) } - .getOrNull() - ?.let { latchSessionPasskey(packet.from.takeIf { n -> n != 0 } ?: myNodeNum, it) } + latchSessionPasskey(packet.from.takeIf { n -> n != 0 } ?: myNodeNum, adminMsg) } // RPC dispatch: complete any pending getter / traceRoute / neighborInfo // request matching this packet's request_id. Must run before processRoutingAck @@ -1569,7 +1564,7 @@ internal class MeshEngine( maybeEmitCongestionWarning(packet) // External config change detection: unsolicited admin messages from firmware // indicating another client modified channels/config on this device. - maybeProcessExternalAdminChange(packet) + if (adminMsg != null) maybeProcessExternalAdminChange(packet, adminMsg) } /** @@ -1826,20 +1821,13 @@ internal class MeshEngine( * Only processes packets addressed to us (from our own node) with request_id == 0 * (unsolicited push from firmware, not a response to our own RPC). */ - private fun maybeProcessExternalAdminChange(packet: MeshPacket) { + private fun maybeProcessExternalAdminChange(packet: MeshPacket, adminMsg: AdminMessage) { val decoded = packet.decoded ?: return - if (decoded.portnum != PortNum.ADMIN_APP) return // Only consider packets from our own node (firmware pushing changes locally) if (packet.from != myNodeNum && packet.from != 0) return // Solicited responses have a non-zero request_id matching a pending RPC if (decoded.request_id != 0) return - val adminMsg = try { - AdminMessage.ADAPTER.decode(decoded.payload) - } catch (_: Exception) { - return - } - adminMsg.get_channel_response?.let { channel -> logger.info(TAG) { "External channel change detected (index=${channel.index})" } updateChannelAndPersist(channel) @@ -1997,8 +1985,8 @@ internal class MeshEngine( handshakeStage = HandshakeStage.Idle configBundleState.value = null pendingNodes.clear() - pendingConfigs.clear() - pendingModuleConfigs.clear() + pendingConfigs = emptyList() + pendingModuleConfigs = emptyList() pendingChannels.clear() pendingMyInfo = null pendingMetadata = null diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt index ed8440a..d6340c6 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt @@ -34,6 +34,7 @@ import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import org.meshtastic.sdk.testing.FakeRadioTransport import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.sdk.testing.toFrame import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -1159,17 +1160,7 @@ class AdminApiImplComprehensiveTest { private fun handshakeFrames(vararg fromRadio: org.meshtastic.proto.FromRadio): List = fromRadio.map(::fromRadioFrame) - private fun fromRadioFrame(fromRadio: org.meshtastic.proto.FromRadio): Frame { - val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) - val bytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - return Frame(bytes.toByteString()) - } + private fun fromRadioFrame(fromRadio: org.meshtastic.proto.FromRadio): Frame = fromRadio.toFrame() private suspend fun TestScope.assertAckedOperation( storageProvider: StorageProvider = InMemoryStorageProvider(), diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt index eddc35f..84c61dd 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt @@ -28,6 +28,7 @@ import org.meshtastic.proto.User import org.meshtastic.proto.XModem import org.meshtastic.sdk.testing.FakeRadioTransport import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.sdk.testing.toFrame import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -202,17 +203,7 @@ class AndroidCutoverPrereqsTest { } } - private fun frameOf(fromRadio: FromRadio): Frame { - val proto = FromRadio.ADAPTER.encode(fromRadio) - val bytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - return Frame(bytes.toByteString()) - } + private fun frameOf(fromRadio: FromRadio): Frame = fromRadio.toFrame() private companion object { const val TEST_NODE_NUM: Int = 0x11111111 diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt index c046644..c4a7b7c 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt @@ -34,6 +34,7 @@ import org.meshtastic.proto.Routing import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.testing.FakeRadioTransport import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.sdk.testing.toFrame import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -338,17 +339,7 @@ class EngineAuditFixesTest { return encodeFromRadio(FromRadio(packet = packet)) } - private fun encodeFromRadio(fromRadio: FromRadio): Frame { - val proto = FromRadio.ADAPTER.encode(fromRadio) - val frameBytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - return Frame(frameBytes.toByteString()) - } + private fun encodeFromRadio(fromRadio: FromRadio): Frame = fromRadio.toFrame() /** * Transport that responds normally to the two-stage handshake but injects [beforeStage1Complete] diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt index 587cf79..d8e260a 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt @@ -34,6 +34,7 @@ import org.meshtastic.proto.Routing import org.meshtastic.proto.ToRadio import org.meshtastic.proto.User import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.sdk.testing.toFrame import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -797,17 +798,7 @@ class HandshakeAndReconnectTest { return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(4, bytes.size)) }.getOrNull() } - private fun encodeFromRadio(fromRadio: org.meshtastic.proto.FromRadio): Frame { - val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) - val bytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - return Frame(bytes.toByteString()) - } + private fun encodeFromRadio(fromRadio: org.meshtastic.proto.FromRadio): Frame = fromRadio.toFrame() private sealed interface ConnectOutcome { data object Success : ConnectOutcome @@ -974,17 +965,7 @@ class HandshakeAndReconnectTest { return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(4, bytes.size)) }.getOrNull() } - private fun encodeFromRadioFrame(fromRadio: org.meshtastic.proto.FromRadio): Frame { - val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) - val bytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - return Frame(bytes.toByteString()) - } + private fun encodeFromRadioFrame(fromRadio: org.meshtastic.proto.FromRadio): Frame = fromRadio.toFrame() private fun decodeAdmin(packet: MeshPacket): AdminMessage? { val payload = packet.decoded?.payload ?: return null diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt index a75996a..d6a832a 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt @@ -23,6 +23,7 @@ import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.testing.FakeRadioTransport import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.sdk.testing.toFrame import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -381,17 +382,7 @@ class HandshakeFsmTest { // ── Helpers ─────────────────────────────────────────────────────────────── - private fun encodeFromRadio(fromRadio: FromRadio): Frame { - val proto = FromRadio.ADAPTER.encode(fromRadio) - val bytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - return Frame(bytes.toByteString()) - } + private fun encodeFromRadio(fromRadio: FromRadio): Frame = fromRadio.toFrame() private fun decodeToRadioOrNull(frame: Frame): ToRadio? { val bytes = frame.bytes.toByteArray() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P1EngineHardeningTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P1EngineHardeningTest.kt index f8e76b9..7675292 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P1EngineHardeningTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P1EngineHardeningTest.kt @@ -31,6 +31,7 @@ import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.internal.MeshEngine import org.meshtastic.sdk.testing.FakeRadioTransport import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.sdk.testing.toFrame import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -407,17 +408,7 @@ class P1EngineHardeningTest { return encodeFromRadio(FromRadio(packet = packet)) } - private fun encodeFromRadio(fromRadio: FromRadio): Frame { - val proto = FromRadio.ADAPTER.encode(fromRadio) - val frameBytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - return Frame(frameBytes.toByteString()) - } + private fun encodeFromRadio(fromRadio: FromRadio): Frame = fromRadio.toFrame() /** Transport that connects but never responds — used to drive Stage 1 to its timeout. */ private inner class SilentTransport(override val identity: TransportIdentity) : RadioTransport { diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/StorageResilienceTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/StorageResilienceTest.kt index 5fe828f..0b4653f 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/StorageResilienceTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/StorageResilienceTest.kt @@ -16,6 +16,7 @@ import org.meshtastic.proto.Channel import org.meshtastic.proto.NodeInfo import org.meshtastic.sdk.testing.FakeRadioTransport import org.meshtastic.sdk.testing.InMemoryStorage +import org.meshtastic.sdk.testing.toFrame import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -153,15 +154,5 @@ class StorageResilienceTest { c2.disconnect() } - private fun framed(fromRadio: org.meshtastic.proto.FromRadio): Frame { - val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) - val bytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - return Frame(bytes.toByteString()) - } + private fun framed(fromRadio: org.meshtastic.proto.FromRadio): Frame = fromRadio.toFrame() } diff --git a/testing/api/jvm/testing.api b/testing/api/jvm/testing.api index 30cf75b..bffd71e 100644 --- a/testing/api/jvm/testing.api +++ b/testing/api/jvm/testing.api @@ -33,6 +33,10 @@ public final class org/meshtastic/sdk/testing/FakeRadioTransport : org/meshtasti public static synthetic fun simulateError$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;Ljava/lang/Throwable;ZILjava/lang/Object;)V } +public final class org/meshtastic/sdk/testing/FramesKt { + public static final fun toFrame (Lorg/meshtastic/proto/FromRadio;)Lorg/meshtastic/sdk/Frame; +} + public final class org/meshtastic/sdk/testing/InMemoryStorage : org/meshtastic/sdk/DeviceStorage { public fun ()V public fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/testing/api/testing.klib.api b/testing/api/testing.klib.api index ba132d3..8c75df7 100644 --- a/testing/api/testing.klib.api +++ b/testing/api/testing.klib.api @@ -68,3 +68,5 @@ final class org.meshtastic.sdk.testing/TestClock : kotlin.time/Clock { // org.me final fun now(): kotlin.time/Instant // org.meshtastic.sdk.testing/TestClock.now|now(){}[0] final fun set(kotlin.time/Instant) // org.meshtastic.sdk.testing/TestClock.set|set(kotlin.time.Instant){}[0] } + +final fun (org.meshtastic.proto/FromRadio).org.meshtastic.sdk.testing/toFrame(): org.meshtastic.sdk/Frame // org.meshtastic.sdk.testing/toFrame|toFrame@org.meshtastic.proto.FromRadio(){}[0] diff --git a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt index abc8802..0b64623 100644 --- a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt +++ b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt @@ -302,14 +302,6 @@ public class FakeRadioTransport( } private fun injectFromRadio(fromRadio: FromRadio) { - val proto = FromRadio.ADAPTER.encode(fromRadio) - val frameBytes = ByteArray(4 + proto.size).apply { - this[0] = 0x94.toByte() - this[1] = 0xC3.toByte() - this[2] = (proto.size shr 8).toByte() - this[3] = (proto.size and 0xFF).toByte() - proto.copyInto(this, destinationOffset = 4) - } - inboundChannel.trySend(Frame(frameBytes.toByteString())) + inboundChannel.trySend(fromRadio.toFrame()) } } diff --git a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/Frames.kt b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/Frames.kt new file mode 100644 index 0000000..62abe11 --- /dev/null +++ b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/Frames.kt @@ -0,0 +1,35 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.testing + +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.FromRadio +import org.meshtastic.sdk.Frame +import org.meshtastic.sdk.WireFraming + +/** + * Encode a device-side [FromRadio] envelope into a wire [Frame] (4-byte STREAM_API header + + * protobuf payload), as a real transport would deliver it. + * + * The single source of truth for the framing tests need when driving an engine through a fake + * transport — use with [FakeRadioTransport.injectFrame] for `FromRadio` variants that have no + * dedicated `inject*` helper. + * + * @since 0.2.0 + */ +public fun FromRadio.toFrame(): Frame { + val proto = FromRadio.ADAPTER.encode(this) + val bytes = ByteArray(WireFraming.HEADER_SIZE + proto.size).apply { + this[0] = WireFraming.MAGIC_0 + this[1] = WireFraming.MAGIC_1 + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = WireFraming.HEADER_SIZE) + } + return Frame(bytes.toByteString()) +} diff --git a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt index ab146c9..f151c61 100644 --- a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt +++ b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt @@ -39,6 +39,7 @@ import org.meshtastic.sdk.RadioTransport import org.meshtastic.sdk.TransportIdentity import org.meshtastic.sdk.TransportSpec import org.meshtastic.sdk.TransportState +import org.meshtastic.sdk.WireFraming import org.meshtastic.sdk.transport.ble.internal.AndroidGattStatus import org.meshtastic.sdk.transport.ble.internal.DrainCoordinator import org.meshtastic.sdk.transport.ble.internal.classifyGattError @@ -387,8 +388,8 @@ public class BleTransport( } internal companion object { - private const val START1: Byte = 0x94.toByte() - private const val START2: Byte = 0xC3.toByte() + private const val START1: Byte = WireFraming.MAGIC_0 + private const val START2: Byte = WireFraming.MAGIC_1 /** Max protobuf size for ToRadio/FromRadio (`mesh.proto MAX_TO_FROM_RADIO_SIZE`). */ internal const val MAX_TO_FROM_RADIO_SIZE: Int = 512 From 5dec9bc7e99f72ec9ecbf23d717266ea0365823f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:56:48 -0500 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20consumer-ergonomics=20pass=20?= =?UTF-8?q?=E2=80=94=20withConnection,=20nodeMap=20folding,=20DSL=20factor?= =?UTF-8?q?y,=20one=20decoder=20family?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API-shape review for consumer convenience: - RadioClient.withConnection { } — connect, run the block, always disconnect (success, exception, AND cancellation via NonCancellable teardown). The structured-concurrency replacement for the blocking use{} idiom this API deliberately dropped. - Flow.asNodeMap() + RadioClient.nodeMap() — folds the node delta stream into a live Map. This is the exact scan accumulator every consumer (including the Android migration guide) was hand-writing; now it ships with stateIn-ready semantics. - RadioClient { } builder-lambda factory as Kotlin-idiomatic sugar over Builder (which stays, for Swift and step-wise construction). - Removed the duplicate typed-decoder family (decodeAsText/Position/ User/NodeInfo/Telemetry/Routing/Admin) that shadowed the asText()/ asPosition()/… accessors in PayloadAccessors.kt — one accessor family remains, plus the generic decodeAs(adapter) escape hatch for portnums without a typed accessor (Paxcount, StoreAndForward, …). ABI dumps refreshed; new RadioClientSugarTest covers all three sugars. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- CHANGELOG.md | 19 ++++ core/api/core.klib.api | 11 +- core/api/jvm/core.api | 14 +-- .../kotlin/org/meshtastic/sdk/Connect.kt | 34 ++++++ .../kotlin/org/meshtastic/sdk/NodeChanges.kt | 48 ++++++++ .../kotlin/org/meshtastic/sdk/PacketDecode.kt | 40 +------ .../kotlin/org/meshtastic/sdk/RadioClient.kt | 18 +++ .../meshtastic/sdk/RadioClientSugarTest.kt | 103 ++++++++++++++++++ .../sdk/ext/send/ProtoBytesAndDecodeTest.kt | 14 +-- 9 files changed, 243 insertions(+), 58 deletions(-) create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/NodeChanges.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSugarTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e340a2..77785fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,25 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **send:** `RadioClient.sendText(..., replyId)` for threaded replies (`decoded.reply_id` without the emoji flag). - **engine:** Mesh packets received while the handshake is still in flight (live traffic interleaved with the config/NodeDB drain, including the seeding window) are now buffered (drop-oldest at 64, observable via `PacketsDropped`) and flushed through the normal packet pipeline at Ready — previously they were silently dropped. +### Added (ergonomics) + +- `RadioClient { … }` builder-lambda factory (sugar over `RadioClient.Builder`; Swift callers + keep the builder). +- `RadioClient.withConnection { … }` — connect, run the block, and always disconnect (success, + exception, and cancellation; teardown runs under `NonCancellable`). The structured replacement + for the removed blocking `use { }` idiom. +- `Flow.asNodeMap()` / `RadioClient.nodeMap()` — fold the node delta stream into a + live `Map` (the accumulator every consumer otherwise hand-writes), ready for + `stateIn`. + +### Removed (API shape) + +- The duplicate typed-decoder family in `PacketDecode.kt` (`decodeAsText`, `decodeAsPosition`, + `decodeAsUser`, `decodeAsNodeInfo`, `decodeAsTelemetry`, `decodeAsRouting`, `decodeAsAdmin`) — + these shadowed the `asText()`/`asPosition()`/… accessors in `PayloadAccessors.kt`. One family + remains, plus the generic `MeshPacket.decodeAs(adapter)` escape hatch for portnums without a + typed accessor. + ### Fixed - **engine:** A `want_config_id` retry restarts the firmware's config drain from scratch (PhoneAPI resets its read index), which previously duplicated channels / config sections in the committed `ConfigBundle` and `channels` state. Stage 1 accumulators now replace by key (channel index / config section), latest occurrence wins. diff --git a/core/api/core.klib.api b/core/api/core.klib.api index 2c550e7..899e01c 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -1981,6 +1981,7 @@ final fun (kotlin/ByteArray).org.meshtastic.sdk/toFromRadio(): org.meshtastic.pr final fun (kotlin/ByteArray).org.meshtastic.sdk/toMeshPacket(): org.meshtastic.proto/MeshPacket? // org.meshtastic.sdk/toMeshPacket|toMeshPacket@kotlin.ByteArray(){}[0] final fun (kotlin/ByteArray).org.meshtastic.sdk/toToRadio(): org.meshtastic.proto/ToRadio? // org.meshtastic.sdk/toToRadio|toToRadio@kotlin.ByteArray(){}[0] final fun (kotlin/Int).org.meshtastic.sdk/firmwareSecondsToInstant(): kotlin.time/Instant? // org.meshtastic.sdk/firmwareSecondsToInstant|firmwareSecondsToInstant@kotlin.Int(){}[0] +final fun (kotlinx.coroutines.flow/Flow).org.meshtastic.sdk/asNodeMap(): kotlinx.coroutines.flow/Flow> // org.meshtastic.sdk/asNodeMap|asNodeMap@kotlinx.coroutines.flow.Flow(){}[0] final fun (org.meshtastic.proto/Channel.Companion).org.meshtastic.sdk/default(): org.meshtastic.proto/Channel // org.meshtastic.sdk/default|default@org.meshtastic.proto.Channel.Companion(){}[0] final fun (org.meshtastic.proto/ChannelSet).org.meshtastic.sdk/toUrl(): kotlin/String // org.meshtastic.sdk/toUrl|toUrl@org.meshtastic.proto.ChannelSet(){}[0] final fun (org.meshtastic.proto/ChannelSettings.Companion).org.meshtastic.sdk/hash(kotlin/String, kotlin/ByteArray): kotlin/Int // org.meshtastic.sdk/hash|hash@org.meshtastic.proto.ChannelSettings.Companion(kotlin.String;kotlin.ByteArray){}[0] @@ -1996,13 +1997,6 @@ final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asTelemetry(): or final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asText(): kotlin/String? // org.meshtastic.sdk/asText|asText@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asTraceroute(): org.meshtastic.proto/RouteDiscovery? // org.meshtastic.sdk/asTraceroute|asTraceroute@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asWaypoint(): org.meshtastic.proto/Waypoint? // org.meshtastic.sdk/asWaypoint|asWaypoint@org.meshtastic.proto.MeshPacket(){}[0] -final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsAdmin(): org.meshtastic.proto/AdminMessage? // org.meshtastic.sdk/decodeAsAdmin|decodeAsAdmin@org.meshtastic.proto.MeshPacket(){}[0] -final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsNodeInfo(): org.meshtastic.proto/NodeInfo? // org.meshtastic.sdk/decodeAsNodeInfo|decodeAsNodeInfo@org.meshtastic.proto.MeshPacket(){}[0] -final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsPosition(): org.meshtastic.proto/Position? // org.meshtastic.sdk/decodeAsPosition|decodeAsPosition@org.meshtastic.proto.MeshPacket(){}[0] -final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsRouting(): org.meshtastic.proto/Routing? // org.meshtastic.sdk/decodeAsRouting|decodeAsRouting@org.meshtastic.proto.MeshPacket(){}[0] -final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsTelemetry(): org.meshtastic.proto/Telemetry? // org.meshtastic.sdk/decodeAsTelemetry|decodeAsTelemetry@org.meshtastic.proto.MeshPacket(){}[0] -final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsText(): kotlin/String? // org.meshtastic.sdk/decodeAsText|decodeAsText@org.meshtastic.proto.MeshPacket(){}[0] -final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsUser(): org.meshtastic.proto/User? // org.meshtastic.sdk/decodeAsUser|decodeAsUser@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/signalQuality(): kotlin/Int? // org.meshtastic.sdk/signalQuality|signalQuality@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/toByteArray(): kotlin/ByteArray // org.meshtastic.sdk/toByteArray|toByteArray@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/toRadioMetrics(): org.meshtastic.sdk/RadioMetrics? // org.meshtastic.sdk/toRadioMetrics|toRadioMetrics@org.meshtastic.proto.MeshPacket(){}[0] @@ -2020,6 +2014,7 @@ final fun (org.meshtastic.sdk/NodeId).org.meshtastic.sdk/toDefaultId(): kotlin/S final fun (org.meshtastic.sdk/NodeId).org.meshtastic.sdk/toHex(): kotlin/String // org.meshtastic.sdk/toHex|toHex@org.meshtastic.sdk.NodeId(){}[0] final fun (org.meshtastic.sdk/NodeId.Companion).org.meshtastic.sdk/fromDefaultId(kotlin/String): org.meshtastic.sdk/NodeId? // org.meshtastic.sdk/fromDefaultId|fromDefaultId@org.meshtastic.sdk.NodeId.Companion(kotlin.String){}[0] final fun (org.meshtastic.sdk/NodeId.Companion).org.meshtastic.sdk/fromHex(kotlin/String): org.meshtastic.sdk/NodeId? // org.meshtastic.sdk/fromHex|fromHex@org.meshtastic.sdk.NodeId.Companion(kotlin.String){}[0] +final fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/nodeMap(): kotlinx.coroutines.flow/Flow> // org.meshtastic.sdk/nodeMap|nodeMap@org.meshtastic.sdk.RadioClient(){}[0] final fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/requestPosition(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/ChannelIndex = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/requestPosition|requestPosition@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex){}[0] final fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/sendDirectMessageEncrypted(org.meshtastic.sdk/NodeId, kotlin/String, kotlin/Boolean = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/sendDirectMessageEncrypted|sendDirectMessageEncrypted@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.NodeId;kotlin.String;kotlin.Boolean){}[0] final fun (org.meshtastic.sdk/SendFailure).org.meshtastic.sdk/humanMessage(): kotlin/String // org.meshtastic.sdk/humanMessage|humanMessage@org.meshtastic.sdk.SendFailure(){}[0] @@ -2027,6 +2022,7 @@ final fun <#A: com.squareup.wire/Message<#A, *>> (org.meshtastic.proto/MeshPacke final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrElse(#A): #A // org.meshtastic.sdk/getOrElse|getOrElse@org.meshtastic.sdk.AdminResult<0:0>(0:0){0§}[0] final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrNull(): #A? // org.meshtastic.sdk/getOrNull|getOrNull@org.meshtastic.sdk.AdminResult<0:0>(){0§}[0] final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrThrow(): #A // org.meshtastic.sdk/getOrThrow|getOrThrow@org.meshtastic.sdk.AdminResult<0:0>(){0§}[0] +final fun org.meshtastic.sdk/RadioClient(kotlin/Function1): org.meshtastic.sdk/RadioClient // org.meshtastic.sdk/RadioClient|RadioClient(kotlin.Function1){}[0] final fun org.meshtastic.sdk/channelNameHashDjb2(kotlin/String): kotlin/UInt // org.meshtastic.sdk/channelNameHashDjb2|channelNameHashDjb2(kotlin.String){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/debug(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/debug|debug@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/error(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/error|error@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] @@ -2068,3 +2064,4 @@ final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/connectAnd final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/send(kotlin/Function1): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/send|send@org.meshtastic.sdk.RadioClient(kotlin.Function1){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/sendDirectMessage(org.meshtastic.sdk/NodeId, kotlin/String, org.meshtastic.sdk/ChannelIndex = ..., kotlin/Boolean = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/sendDirectMessage|sendDirectMessage@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.NodeId;kotlin.String;org.meshtastic.sdk.ChannelIndex;kotlin.Boolean){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/sendPosition(org.meshtastic.sdk/LatLng, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Boolean = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/sendPosition|sendPosition@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.LatLng;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Boolean){}[0] +final suspend fun <#A: kotlin/Any?> (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/withConnection(kotlin.coroutines/SuspendFunction1): #A // org.meshtastic.sdk/withConnection|withConnection@org.meshtastic.sdk.RadioClient(kotlin.coroutines.SuspendFunction1){0§}[0] diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index 73060f6..54f7cbb 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -378,6 +378,7 @@ public final class org/meshtastic/sdk/CongestionMetrics$Companion { public final class org/meshtastic/sdk/ConnectKt { public static final fun connectAndAwaitReady-8Mi8wO0 (Lorg/meshtastic/sdk/RadioClient;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun connectAndAwaitReady-8Mi8wO0$default (Lorg/meshtastic/sdk/RadioClient;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun withConnection (Lorg/meshtastic/sdk/RadioClient;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class org/meshtastic/sdk/ConnectionQuality : java/lang/Enum { @@ -1130,6 +1131,11 @@ public final class org/meshtastic/sdk/NodeChange$WentOffline : org/meshtastic/sd public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/NodeChangesKt { + public static final fun asNodeMap (Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; + public static final fun nodeMap (Lorg/meshtastic/sdk/RadioClient;)Lkotlinx/coroutines/flow/Flow; +} + public final class org/meshtastic/sdk/NodeField : java/lang/Enum { public static final field Battery Lorg/meshtastic/sdk/NodeField; public static final field DeviceInfo Lorg/meshtastic/sdk/NodeField; @@ -1202,13 +1208,6 @@ public final class org/meshtastic/sdk/NodeStatusKt { public final class org/meshtastic/sdk/PacketDecodeKt { public static final fun decodeAs (Lorg/meshtastic/proto/MeshPacket;Lcom/squareup/wire/ProtoAdapter;)Lcom/squareup/wire/Message; - public static final fun decodeAsAdmin (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/AdminMessage; - public static final fun decodeAsNodeInfo (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/NodeInfo; - public static final fun decodeAsPosition (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Position; - public static final fun decodeAsRouting (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Routing; - public static final fun decodeAsTelemetry (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Telemetry; - public static final fun decodeAsText (Lorg/meshtastic/proto/MeshPacket;)Ljava/lang/String; - public static final fun decodeAsUser (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/User; } public final class org/meshtastic/sdk/PayloadAccessorsKt { @@ -1317,6 +1316,7 @@ public final class org/meshtastic/sdk/RadioClient$Companion { public final class org/meshtastic/sdk/RadioClientKt { public static final field DATA_PAYLOAD_LEN I + public static final fun RadioClient (Lkotlin/jvm/functions/Function1;)Lorg/meshtastic/sdk/RadioClient; } public final class org/meshtastic/sdk/RadioMetrics { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt index ff57d6e..c98bb0d 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt @@ -7,7 +7,9 @@ */ package org.meshtastic.sdk +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.meshtastic.sdk.ConfigBundle import org.meshtastic.sdk.ConnectionState @@ -16,6 +18,38 @@ import org.meshtastic.sdk.RadioClient import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * Run [block] against a connected session, guaranteeing teardown. + * + * Connects, executes [block], and always disconnects afterwards — on success, on exception, + * and on cancellation (the disconnect runs under [NonCancellable] so a cancelled caller still + * tears the session down cleanly). This is the structured-concurrency replacement for a + * blocking `use { }` idiom, which `RadioClient` deliberately does not offer: + * + * ```kotlin + * val owner = client.withConnection { + * admin.getOwner().getOrThrow() + * } + * ``` + * + * The client is **single-use**: after [withConnection] returns, build a new [RadioClient] + * for the next session. + * + * @return whatever [block] returns + * @throws MeshtasticException any failure surfaced by [RadioClient.connect] + * @since 0.2.0 + */ +public suspend fun RadioClient.withConnection(block: suspend RadioClient.() -> T): T { + connect() + return try { + block() + } finally { + withContext(NonCancellable) { + disconnect() + } + } +} + /** * Connect and suspend until the handshake settles, returning the resolved [ConfigBundle] (HLP-34). * diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeChanges.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeChanges.kt new file mode 100644 index 0000000..45a24fc --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeChanges.kt @@ -0,0 +1,48 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.scan +import org.meshtastic.proto.NodeInfo + +/** + * Fold a [NodeChange] delta stream into a live node map. + * + * Applies the canonical accumulator every consumer otherwise hand-writes: a + * [NodeChange.Snapshot] replaces the map wholesale (emitted on first subscription and after + * reconnect), [NodeChange.Added]/[NodeChange.Updated] upsert, [NodeChange.Removed] deletes, + * and the presence deltas ([NodeChange.WentOffline]/[NodeChange.CameOnline]) leave the map + * untouched — presence is signalled, not stored. + * + * The first emission is an empty map (before the Snapshot lands), which makes the result + * directly usable with `stateIn`: + * + * ```kotlin + * val nodes: StateFlow> = client.nodeMap() + * .stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + * ``` + * + * @since 0.2.0 + */ +public fun Flow.asNodeMap(): Flow> = scan(emptyMap()) { acc, change -> + when (change) { + is NodeChange.Snapshot -> change.nodes + is NodeChange.Added -> acc + (NodeId(change.node.num) to change.node) + is NodeChange.Updated -> acc + (NodeId(change.node.num) to change.node) + is NodeChange.Removed -> acc - change.nodeId + is NodeChange.WentOffline, is NodeChange.CameOnline -> acc + } +} + +/** + * The live node map for this client: [RadioClient.nodes] folded via [asNodeMap]. + * + * @since 0.2.0 + */ +public fun RadioClient.nodeMap(): Flow> = nodes.asNodeMap() diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PacketDecode.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PacketDecode.kt index 3238d65..c7bf971 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/PacketDecode.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PacketDecode.kt @@ -9,14 +9,7 @@ package org.meshtastic.sdk import com.squareup.wire.Message import com.squareup.wire.ProtoAdapter -import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NodeInfo -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.Position -import org.meshtastic.proto.Routing -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User /** * Decode this packet's `decoded.payload` using the supplied Wire [adapter]. * @@ -27,8 +20,9 @@ import org.meshtastic.proto.User * surfaced as `null` so callers can branch without a `try/catch`). * * **No portnum check** — callers asserting a payload type are responsible for verifying - * `decoded.portnum` first if mismatch matters. The convenience overloads on this file - * (e.g. [decodeAsPosition]) bake in the matching portnum guard. + * `decoded.portnum` first if mismatch matters. The typed accessors in `PayloadAccessors.kt` + * (e.g. [asPosition], [asWaypoint]) bake in the matching portnum guard; this generic form is + * the escape hatch for portnums without a typed accessor (Paxcount, StoreAndForward, …). * * **Why not reified?** Wire's Kotlin runtime exposes one `ADAPTER` per generated message * class, but does not expose a generic way to recover that adapter from a reified type @@ -49,31 +43,3 @@ public fun > MeshPacket.decodeAs(adapter: ProtoAdapter): T? if (payload.size == 0) return null return runCatching { adapter.decode(payload.toByteArray()) }.getOrNull() } - -private fun > MeshPacket.decodeIfPortnum(expected: PortNum, adapter: ProtoAdapter): T? = - if (decoded?.portnum == expected) decodeAs(adapter) else null - -/** Decode the payload as UTF-8 text iff `decoded.portnum == TEXT_MESSAGE_APP`. */ -public fun MeshPacket.decodeAsText(): String? = if (decoded?.portnum == PortNum.TEXT_MESSAGE_APP) { - decoded?.payload?.utf8() -} else { - null -} - -/** Decode the payload as a [Position] iff `decoded.portnum == POSITION_APP`. */ -public fun MeshPacket.decodeAsPosition(): Position? = decodeIfPortnum(PortNum.POSITION_APP, Position.ADAPTER) - -/** Decode the payload as a [User] (NodeInfo user record) iff `decoded.portnum == NODEINFO_APP`. */ -public fun MeshPacket.decodeAsUser(): User? = decodeIfPortnum(PortNum.NODEINFO_APP, User.ADAPTER) - -/** Decode the payload as a full [NodeInfo] iff `decoded.portnum == NODEINFO_APP`. */ -public fun MeshPacket.decodeAsNodeInfo(): NodeInfo? = decodeIfPortnum(PortNum.NODEINFO_APP, NodeInfo.ADAPTER) - -/** Decode the payload as [Telemetry] iff `decoded.portnum == TELEMETRY_APP`. */ -public fun MeshPacket.decodeAsTelemetry(): Telemetry? = decodeIfPortnum(PortNum.TELEMETRY_APP, Telemetry.ADAPTER) - -/** Decode the payload as [Routing] iff `decoded.portnum == ROUTING_APP`. */ -public fun MeshPacket.decodeAsRouting(): Routing? = decodeIfPortnum(PortNum.ROUTING_APP, Routing.ADAPTER) - -/** Decode the payload as [AdminMessage] iff `decoded.portnum == ADMIN_APP`. */ -public fun MeshPacket.decodeAsAdmin(): AdminMessage? = decodeIfPortnum(PortNum.ADMIN_APP, AdminMessage.ADAPTER) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index b774052..23bb2e8 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -863,3 +863,21 @@ public interface PayloadRedactor { * @since 0.1.0 */ public const val DATA_PAYLOAD_LEN: Int = 233 + +/** + * Build a [RadioClient] with Kotlin builder-lambda syntax — sugar over [RadioClient.Builder]: + * + * ```kotlin + * val client = RadioClient { + * transport(TcpTransport(host = "meshtastic.local")) + * storage(SqlDelightStorageProvider(baseDir = dataDir)) + * autoReconnect(AutoReconnectConfig(enabled = true)) + * } + * ``` + * + * Swift and step-wise callers should keep using [RadioClient.Builder] directly. + * + * @since 0.2.0 + */ +public fun RadioClient(configure: RadioClient.Builder.() -> Unit): RadioClient = + RadioClient.Builder().apply(configure).build() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSugarTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSugarTest.kt new file mode 100644 index 0000000..f158dbc --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSugarTest.kt @@ -0,0 +1,103 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.User +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** Consumer-convenience surface: `RadioClient { }`, `withConnection { }`, `asNodeMap()`. */ +@OptIn(ExperimentalCoroutinesApi::class) +class RadioClientSugarTest { + + @Test + fun dslFactoryBuildsAWorkingClient() = runTest { + val transport = fakeTransport() + val client = RadioClient { + transport(transport) + storage(InMemoryStorageProvider()) + coroutineContext(backgroundScope.coroutineContext) + autoSyncTimeOnConnect(false) + } + + client.connect() + runCurrent() + assertEquals(ConnectionState.Connected, client.connection.value) + client.disconnect() + runCurrent() + } + + @Test + fun withConnectionRunsBlockConnectedAndAlwaysDisconnects() = runTest { + val client = buildClient(fakeTransport()) + + val state = client.withConnection { connection.value } + + assertEquals(ConnectionState.Connected, state, "Block must observe a connected session") + runCurrent() + assertEquals(ConnectionState.Disconnected, client.connection.value, "Session must be torn down") + } + + @Test + fun withConnectionDisconnectsWhenBlockThrows() = runTest { + val client = buildClient(fakeTransport()) + + assertFailsWith { + client.withConnection { error("boom") } + } + runCurrent() + assertEquals(ConnectionState.Disconnected, client.connection.value, "Teardown must survive exceptions") + } + + @Test + fun asNodeMapFoldsTheCanonicalAccumulator() = runTest { + val alice = NodeInfo(num = 1, user = User(id = "!1", long_name = "Alice")) + val bob = NodeInfo(num = 2, user = User(id = "!2", long_name = "Bob")) + val bobRenamed = NodeInfo(num = 2, user = User(id = "!2", long_name = "Bobby")) + + val emissions = flowOf( + NodeChange.Snapshot(mapOf(NodeId(1) to alice)), + NodeChange.Added(bob), + NodeChange.Updated(bobRenamed, setOf(NodeField.Name)), + NodeChange.WentOffline(NodeId(1), lastHeard = 0), + NodeChange.Removed(NodeId(1)), + ).asNodeMap().toList() + + assertEquals(emptyMap(), emissions.first(), "scan seeds with an empty map") + assertEquals(mapOf(NodeId(1) to alice), emissions[1]) + assertEquals(mapOf(NodeId(1) to alice, NodeId(2) to bob), emissions[2]) + assertEquals(bobRenamed, emissions[3][NodeId(2)]) + assertEquals(emissions[3], emissions[4], "Presence deltas must not mutate the map") + assertEquals(mapOf(NodeId(2) to bobRenamed), emissions.last()) + } + + // ── Harness ───────────────────────────────────────────────────────────── + + private fun fakeTransport(): FakeRadioTransport = FakeRadioTransport( + identity = TransportIdentity("fake:sugar"), + autoHandshake = true, + nodeNum = 0x11111111, + ) + + private fun TestScope.buildClient(transport: FakeRadioTransport): RadioClient = RadioClient { + transport(transport) + storage(InMemoryStorageProvider()) + coroutineContext(backgroundScope.coroutineContext) + autoSyncTimeOnConnect(false) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/send/ProtoBytesAndDecodeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/send/ProtoBytesAndDecodeTest.kt index 83afa70..cefe1ed 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/send/ProtoBytesAndDecodeTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/send/ProtoBytesAndDecodeTest.kt @@ -49,29 +49,29 @@ class ProtoBytesAndDecodeTest { } @Test - fun decodeAsText_readsTextPayload() { + fun asText_readsTextPayload() { val packet = MeshPacket( decoded = Data( portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.of(*"hello".encodeToByteArray()), ), ) - assertEquals("hello", packet.decodeAsText()) + assertEquals("hello", packet.asText()) } @Test - fun decodeAsText_returnsNullForWrongPortnum() { + fun asText_returnsNullForWrongPortnum() { val packet = MeshPacket( decoded = Data( portnum = PortNum.POSITION_APP, payload = ByteString.of(*"bytes".encodeToByteArray()), ), ) - assertNull(packet.decodeAsText()) + assertNull(packet.asText()) } @Test - fun decodeAsPosition_roundTrip() { + fun asPosition_roundTrip() { val pos = Position(latitude_i = 377749000, longitude_i = -1224194000, altitude = 12) val packet = MeshPacket( decoded = Data( @@ -79,13 +79,13 @@ class ProtoBytesAndDecodeTest { payload = ByteString.of(*Position.ADAPTER.encode(pos)), ), ) - val decoded = packet.decodeAsPosition() + val decoded = packet.asPosition() assertNotNull(decoded) assertEquals(pos, decoded) } @Test fun decodeAs_emptyPayloadReturnsNull() { - assertNull(MeshPacket().decodeAsPosition()) + assertNull(MeshPacket().asPosition()) } } From 6e6db27e2e1bcb6cf917f70932e804cffb53ca3a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:31:40 -0500 Subject: [PATCH 09/16] =?UTF-8?q?fix(core)!:=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20per-subscription=20node=20snapshots,=20actor-safe=20admin,?= =?UTF-8?q?=20ERRNO=20queue=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a four-angle adversarial review of this branch (correctness / concurrency / API surface / tests), all fixed: HIGH — nodes flow late-subscriber contract (new in this branch): Every subscription now receives a fresh NodeChange.Snapshot first, seeded via onSubscription from the engine's current node map; the engine SharedFlow drops its replay slot. Previously a late subscriber got whatever DELTA was emitted last instead of the Snapshot, leaving nodeMap()-style folds near-empty until the next reconnect. HIGH — off-actor mutation of sessionPasskeys (regression in this branch): the fire-and-forget admin path (enterDfuMode, setTimeOnly) ran prepareOutboundAdminPacket — which prunes the actor-owned per-node passkey map — on the caller's coroutine. sendAdmin now posts an EngineMessage.AdminFireAndForget through the inbox (ADR-002). Firmware conformance — QueueStatus.res is the ERRNO namespace, not Routing.Error: 35 (ERRNO_SHOULD_RELEASE) is success and counts as Sent; ERRNO rejections (>= 32) fail pending RPCs as NodeUnreachable via the new CommandDispatcher.tryFail; 1..31 map through the normal routing taxonomy. Previously 32 read as BAD_REQUEST, 33 as NOT_AUTHORIZED, and the success code 35 produced a false failure. Remote-admin correctness: - Passkeys latch only from response-shaped admin messages; a remote node administering US no longer poisons our passkey cache with the key WE issued it. - Managed-mode client gate applies to local targets only (firmware only rejects local admin on managed devices; remote targets authorize via their own admin keys). - Session passkeys are no longer persisted (local ones are never required — firmware rewrites phone packets to from=0; remote ones expire in ~4 min). Storage methods remain, documented host-facing. Pre-existing engine defects (surfaced by the same review): - dispatchSend guards caller-supplied wire-id collisions at the real key — the second send no longer strands the first handle forever. - Stage-1 settle replay re-buffers frames behind a duplicate completion instead of silently dropping them. - The seeding window routes non-packet variants (node_info, client notifications, MQTT proxy, ...) instead of silently dropping them. - Stage-2 commit snapshots actor-owned collections before its async storage flush (CME risk mis-reported as StorageDegraded). API shape (pre-publication, nothing on Central): - MeshEvent.FileInfo -> FileInfoReceived (no longer shadows org.meshtastic.proto.FileInfo verbatim). - sendText is (text, to, channel, replyId) — aligned with sendReaction; all callers were named-arg or text-only. - withConnection gains teardownTimeout (default 10s): the NonCancellable disconnect is bounded so a wedged transport cannot pin a cancelled caller forever. - detekt ForbiddenImport now bans kotlinx.io.* (one byte vocabulary). Tests: 16 new tests pin the riskiest logic — PKC asymmetric-key fallbacks, caller-built PKC passthrough, caller priority + broadcast guards, two-remote passkey isolation, passkey TTL pruning, identity- rebind passkey clearing, queue rejection of in-flight RPCs, ERRNO vs routing-error mapping, res=35 success, unmapped res, buffer-overflow PacketsDropped + drop-oldest order, seeding-window buffering, settle- replay preservation, post-Ready dedupe of flushed packets, seeded late-subscriber snapshots, withConnection cancellation teardown, and direct decodeAs escape-hatch coverage. ABI dumps regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- config/detekt/detekt.yml | 2 + core/api/core.klib.api | 22 +- core/api/jvm/core.api | 15 +- .../kotlin/org/meshtastic/sdk/Connect.kt | 14 +- .../kotlin/org/meshtastic/sdk/Node.kt | 14 +- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 23 +- .../kotlin/org/meshtastic/sdk/Storage.kt | 9 +- .../meshtastic/sdk/internal/AdminApiImpl.kt | 20 +- .../sdk/internal/CommandDispatcher.kt | 15 +- .../meshtastic/sdk/internal/EngineMessage.kt | 12 + .../org/meshtastic/sdk/internal/MeshEngine.kt | 267 ++++++++++------- .../sdk/AndroidCutoverPrereqsTest.kt | 253 +++++++++++++++- .../sdk/HandshakeAndReconnectTest.kt | 273 +++++++++++++++++- .../meshtastic/sdk/RadioClientSugarTest.kt | 26 ++ .../sdk/ext/send/ProtoBytesAndDecodeTest.kt | 26 +- 15 files changed, 845 insertions(+), 146 deletions(-) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 512a0bb..c21ba3d 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -58,6 +58,8 @@ style: # import it either, to keep us honest at the boundary. active: true forbiddenImports: + - 'kotlinx.io.Buffer' # one byte vocabulary: okio (api-reference § API conventions) + - 'kotlinx.io.bytestring.ByteString' # one byte vocabulary: okio - 'kotlinx.coroutines.sync.Mutex' # ADR-002: single-writer actor; no mutexes - 'kotlinx.coroutines.sync.Semaphore' # ADR-002: single-writer actor; no semaphores - 'java.util.concurrent.locks.ReentrantLock' # ADR-002: single-writer actor; no locks diff --git a/core/api/core.klib.api b/core/api/core.klib.api index 899e01c..63dc4f9 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -436,17 +436,17 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.toString|toString(){}[0] } - final class FileInfo : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.FileInfo|null[0] - constructor (org.meshtastic.proto/FileInfo) // org.meshtastic.sdk/MeshEvent.FileInfo.|(org.meshtastic.proto.FileInfo){}[0] + final class FileInfoReceived : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.FileInfoReceived|null[0] + constructor (org.meshtastic.proto/FileInfo) // org.meshtastic.sdk/MeshEvent.FileInfoReceived.|(org.meshtastic.proto.FileInfo){}[0] - final val info // org.meshtastic.sdk/MeshEvent.FileInfo.info|{}info[0] - final fun (): org.meshtastic.proto/FileInfo // org.meshtastic.sdk/MeshEvent.FileInfo.info.|(){}[0] + final val info // org.meshtastic.sdk/MeshEvent.FileInfoReceived.info|{}info[0] + final fun (): org.meshtastic.proto/FileInfo // org.meshtastic.sdk/MeshEvent.FileInfoReceived.info.|(){}[0] - final fun component1(): org.meshtastic.proto/FileInfo // org.meshtastic.sdk/MeshEvent.FileInfo.component1|component1(){}[0] - final fun copy(org.meshtastic.proto/FileInfo = ...): org.meshtastic.sdk/MeshEvent.FileInfo // org.meshtastic.sdk/MeshEvent.FileInfo.copy|copy(org.meshtastic.proto.FileInfo){}[0] - final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.FileInfo.equals|equals(kotlin.Any?){}[0] - final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.FileInfo.hashCode|hashCode(){}[0] - final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.FileInfo.toString|toString(){}[0] + final fun component1(): org.meshtastic.proto/FileInfo // org.meshtastic.sdk/MeshEvent.FileInfoReceived.component1|component1(){}[0] + final fun copy(org.meshtastic.proto/FileInfo = ...): org.meshtastic.sdk/MeshEvent.FileInfoReceived // org.meshtastic.sdk/MeshEvent.FileInfoReceived.copy|copy(org.meshtastic.proto.FileInfo){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.FileInfoReceived.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.FileInfoReceived.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.FileInfoReceived.toString|toString(){}[0] } final class IdentityRebound : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.IdentityRebound|null[0] @@ -1460,7 +1460,7 @@ final class org.meshtastic.sdk/RadioClient { // org.meshtastic.sdk/RadioClient|n final fun send(org.meshtastic.proto/MeshPacket): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.send|send(org.meshtastic.proto.MeshPacket){}[0] final fun sendRaw(org.meshtastic.proto/ToRadio) // org.meshtastic.sdk/RadioClient.sendRaw|sendRaw(org.meshtastic.proto.ToRadio){}[0] final fun sendReaction(kotlin/String, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Int): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendReaction|sendReaction(kotlin.String;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Int){}[0] - final fun sendText(kotlin/String, org.meshtastic.sdk/ChannelIndex = ..., org.meshtastic.sdk/NodeId = ..., kotlin/Int = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendText|sendText(kotlin.String;org.meshtastic.sdk.ChannelIndex;org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + final fun sendText(kotlin/String, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Int = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendText|sendText(kotlin.String;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Int){}[0] final suspend fun connect() // org.meshtastic.sdk/RadioClient.connect|connect(){}[0] final suspend fun disconnect() // org.meshtastic.sdk/RadioClient.disconnect|disconnect(){}[0] final suspend fun nodeSnapshot(): kotlin.collections/Map // org.meshtastic.sdk/RadioClient.nodeSnapshot|nodeSnapshot(){}[0] @@ -2064,4 +2064,4 @@ final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/connectAnd final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/send(kotlin/Function1): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/send|send@org.meshtastic.sdk.RadioClient(kotlin.Function1){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/sendDirectMessage(org.meshtastic.sdk/NodeId, kotlin/String, org.meshtastic.sdk/ChannelIndex = ..., kotlin/Boolean = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/sendDirectMessage|sendDirectMessage@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.NodeId;kotlin.String;org.meshtastic.sdk.ChannelIndex;kotlin.Boolean){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/sendPosition(org.meshtastic.sdk/LatLng, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Boolean = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/sendPosition|sendPosition@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.LatLng;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Boolean){}[0] -final suspend fun <#A: kotlin/Any?> (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/withConnection(kotlin.coroutines/SuspendFunction1): #A // org.meshtastic.sdk/withConnection|withConnection@org.meshtastic.sdk.RadioClient(kotlin.coroutines.SuspendFunction1){0§}[0] +final suspend fun <#A: kotlin/Any?> (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/withConnection(kotlin.time/Duration = ..., kotlin.coroutines/SuspendFunction1): #A // org.meshtastic.sdk/withConnection|withConnection@org.meshtastic.sdk.RadioClient(kotlin.time.Duration;kotlin.coroutines.SuspendFunction1){0§}[0] diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index 54f7cbb..8a8314b 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -378,7 +378,8 @@ public final class org/meshtastic/sdk/CongestionMetrics$Companion { public final class org/meshtastic/sdk/ConnectKt { public static final fun connectAndAwaitReady-8Mi8wO0 (Lorg/meshtastic/sdk/RadioClient;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun connectAndAwaitReady-8Mi8wO0$default (Lorg/meshtastic/sdk/RadioClient;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static final fun withConnection (Lorg/meshtastic/sdk/RadioClient;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun withConnection-dWUq8MI (Lorg/meshtastic/sdk/RadioClient;JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun withConnection-dWUq8MI$default (Lorg/meshtastic/sdk/RadioClient;JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class org/meshtastic/sdk/ConnectionQuality : java/lang/Enum { @@ -639,11 +640,11 @@ public final class org/meshtastic/sdk/MeshEvent$ExternalConfigChange : org/mesht public fun toString ()Ljava/lang/String; } -public final class org/meshtastic/sdk/MeshEvent$FileInfo : org/meshtastic/sdk/MeshEvent { +public final class org/meshtastic/sdk/MeshEvent$FileInfoReceived : org/meshtastic/sdk/MeshEvent { public fun (Lorg/meshtastic/proto/FileInfo;)V public final fun component1 ()Lorg/meshtastic/proto/FileInfo; - public final fun copy (Lorg/meshtastic/proto/FileInfo;)Lorg/meshtastic/sdk/MeshEvent$FileInfo; - public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$FileInfo;Lorg/meshtastic/proto/FileInfo;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$FileInfo; + public final fun copy (Lorg/meshtastic/proto/FileInfo;)Lorg/meshtastic/sdk/MeshEvent$FileInfoReceived; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$FileInfoReceived;Lorg/meshtastic/proto/FileInfo;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$FileInfoReceived; public fun equals (Ljava/lang/Object;)Z public final fun getInfo ()Lorg/meshtastic/proto/FileInfo; public fun hashCode ()I @@ -1281,9 +1282,9 @@ public final class org/meshtastic/sdk/RadioClient { public final fun sendReaction (Ljava/lang/String;Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/ChannelIndex;I)Lorg/meshtastic/sdk/MessageHandle; public final fun sendReaction-soBxvt8 (Ljava/lang/String;III)Lorg/meshtastic/sdk/MessageHandle; public static synthetic fun sendReaction-soBxvt8$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; - public final fun sendText (Ljava/lang/String;Lorg/meshtastic/sdk/ChannelIndex;Lorg/meshtastic/sdk/NodeId;I)Lorg/meshtastic/sdk/MessageHandle; - public final fun sendText-HaIeDj0 (Ljava/lang/String;III)Lorg/meshtastic/sdk/MessageHandle; - public static synthetic fun sendText-HaIeDj0$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; + public final fun sendText (Ljava/lang/String;Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/ChannelIndex;I)Lorg/meshtastic/sdk/MessageHandle; + public final fun sendText-soBxvt8 (Ljava/lang/String;III)Lorg/meshtastic/sdk/MessageHandle; + public static synthetic fun sendText-soBxvt8$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; } public final class org/meshtastic/sdk/RadioClient$Builder { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt index c98bb0d..cd1f86b 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt @@ -35,17 +35,27 @@ import kotlin.time.Duration.Companion.seconds * The client is **single-use**: after [withConnection] returns, build a new [RadioClient] * for the next session. * + * @param teardownTimeout upper bound on the guaranteed disconnect (default 10s) — protects the + * caller from a wedged transport teardown while still covering the polite-goodbye flush and + * storage close on every healthy path * @return whatever [block] returns * @throws MeshtasticException any failure surfaced by [RadioClient.connect] * @since 0.2.0 */ -public suspend fun RadioClient.withConnection(block: suspend RadioClient.() -> T): T { +public suspend fun RadioClient.withConnection( + teardownTimeout: Duration = 10.seconds, + block: suspend RadioClient.() -> T, +): T { connect() return try { block() } finally { withContext(NonCancellable) { - disconnect() + // Bound the guaranteed teardown: a hung transport disconnect (e.g. a wedged + // Android BLE stack) must not pin the caller uncancellably forever. + withTimeoutOrNull(teardownTimeout) { + disconnect() + } } } } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index 2c08c0f..cd3fc87 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -60,13 +60,13 @@ public enum class NodeField { */ public sealed interface NodeChange { /** - * All known nodes at subscription time. + * All known nodes as a full replacement of any previously folded state. * - * Emitted exactly once to each new subscriber (never again on the same subscription), before - * any live delta. Implemented via single-replay under the actor; subsequent [Added], - * [Updated], and [Removed] are guaranteed to apply on top of this snapshot in causal order. - * - * Late subscribers see this snapshot instead of being stranded without context. + * Subscribers receive one as the **first** emission of every subscription (seeded + * per-subscription from the engine's current node map via `onSubscription`), and again + * live whenever a handshake commits (first connect and every reconnect). Deltas that + * follow apply on top in causal order; a delta already reflected in the seeded snapshot + * re-applies idempotently. */ public data class Snapshot(val nodes: Map) : NodeChange @@ -180,7 +180,7 @@ public sealed interface MeshEvent { * @property info the wire [FileInfo][org.meshtastic.proto.FileInfo] * @since 0.2.0 */ - public data class FileInfo(val info: org.meshtastic.proto.FileInfo) : MeshEvent + public data class FileInfoReceived(val info: org.meshtastic.proto.FileInfo) : MeshEvent /** * A transport-level error occurred. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 23bb2e8..053a851 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString import org.meshtastic.proto.MeshPacket @@ -123,9 +124,14 @@ public class RadioClient internal constructor( public val channels: StateFlow?> = engine.channelsState.asStateFlow() /** - * Node-change deltas. Late subscribers receive a [NodeChange.Snapshot] immediately - * (single-replay), then live [NodeChange.Added] / [NodeChange.Updated] / - * [NodeChange.Removed] / [NodeChange.WentOffline] / [NodeChange.CameOnline] in causal order. + * Node-change deltas. **Every** subscriber — early or late — first receives a + * [NodeChange.Snapshot] of the engine's current node map (seeded per-subscription via + * `onSubscription`), then live [NodeChange.Added] / [NodeChange.Updated] / + * [NodeChange.Removed] / [NodeChange.WentOffline] / [NodeChange.CameOnline] in causal + * order. A delta whose change is already reflected in the seeded snapshot re-applies + * idempotently. The handshake additionally emits a live [NodeChange.Snapshot] at Stage-2 + * commit (and after each reconnect), which existing subscribers must treat as a full + * replacement. * * **Buffering and backpressure:** the underlying `MutableSharedFlow` uses * `extraBufferCapacity = 256` with `BufferOverflow.SUSPEND` (per ADR-005). Slow collectors @@ -133,7 +139,8 @@ public class RadioClient internal constructor( * flow. If the engine inbox itself fills as a result, drops surface as * [MeshEvent.PacketsDropped] on [events] — see ADR-005 §"Backpressure policy". */ - public val nodes: Flow = engine.nodes.hide() + public val nodes: Flow = engine.nodes + .onSubscription { emit(NodeChange.Snapshot(engine.nodeMapSnapshot())) } /** * Inbound decoded packets. @@ -331,11 +338,13 @@ public class RadioClient internal constructor( * The returned [MessageHandle] will resolve to [SendOutcome.Success] on ACK, or * [SendOutcome.Failure] if the firmware exhausts retransmissions without confirmation. * + * Parameter order matches [sendReaction] (`to` before `channel`) as of 0.2.0. + * * @param text the message text (UTF-8 encoded) - * @param channel the channel index (default: 0) * @param to the destination [NodeId] (default: [NodeId.BROADCAST]) + * @param channel the channel index (default: 0) * @param replyId the packet ID of the message this text replies to (threaded reply), or `0` - * (default) for a standalone message + * (default) for a standalone message (parameter added in 0.2.0) * @return a handle tracking delivery state * @throws MeshtasticException.NotConnected if not currently connected * @throws MeshtasticException.PayloadTooLarge if the encoded text exceeds the device limit @@ -343,8 +352,8 @@ public class RadioClient internal constructor( @Throws(MeshtasticException::class) public fun sendText( text: String, - channel: ChannelIndex = ChannelIndex(0), to: NodeId = NodeId.BROADCAST, + channel: ChannelIndex = ChannelIndex(0), replyId: Int = 0, ): MessageHandle { val payload = text.encodeToByteArray() diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt index 469f037..c404152 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt @@ -197,8 +197,11 @@ public interface DeviceStorage : AutoCloseable { /** * Persist the latched session passkey for this transport identity. * - * The passkey is reapplied on the next connect so admin RPCs can resume without re-running - * `get_owner_request`. Implementations must overwrite any previous passkey atomically. + * **The engine no longer calls this** (since 0.2.0): session passkeys are per-node and + * in-memory only — the local node's passkey is never required (firmware rewrites phone + * packets to `from = 0`, which is passkey-exempt), and remote-node passkeys expire after + * ~4 minutes, making persistence useless across process restarts. Retained as a + * host-facing capability. Implementations must overwrite any previous passkey atomically. */ @Throws(MeshtasticException.StorageUnavailable::class, kotlin.coroutines.cancellation.CancellationException::class) public suspend fun saveSessionPasskey(passkey: SessionPasskey) @@ -206,6 +209,8 @@ public interface DeviceStorage : AutoCloseable { /** * Load the persisted session passkey, or `null` if none is stored or the stored entry has * expired (per [SessionPasskey.expiresAtEpochMs]). + * + * **The engine no longer calls this** (since 0.2.0) — see [saveSessionPasskey]. */ @Throws(MeshtasticException.StorageUnavailable::class, kotlin.coroutines.cancellation.CancellationException::class) public suspend fun loadSessionPasskey(): SessionPasskey? diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 8d530e0..84784f1 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -75,11 +75,17 @@ internal class AdminApiImpl( } /** - * Returns `true` if the device is in managed mode, meaning all admin commands from non-zero - * `from` addresses are silently dropped by firmware. The SDK always sends with - * `from = myNodeNum` (non-zero post-handshake), so all admin commands would be ignored. + * Returns `true` when this AdminApi targets the **local** device and that device is in + * managed mode. Firmware rewrites every phone packet to `from = 0` (MeshService + * `handleToRadio`) and rejects local admin on a managed device via that branch + * (AdminModule: `mp.from == 0 && is_managed`). Admin packets addressed to *remote* nodes + * are routed into the mesh untouched — the target's own admin-key config authorizes them — + * so remote admin must NOT be short-circuited by the local device's managed flag + * (managed-fleet deployments administer remote managed nodes from a managed local node). */ - private fun isDeviceManaged(): Boolean { + private fun isLocalTargetManaged(): Boolean { + val isLocalTarget = targetNode == null || targetNode.raw == engine.myNodeNumOrNull() + if (!isLocalTarget) return false val bundle = engine.configBundleState.value ?: return false return bundle.configs.any { config -> config.security?.is_managed == true @@ -256,7 +262,7 @@ internal class AdminApiImpl( // ── DFU / file management ─────────────────────────────────────────────── override suspend fun enterDfuMode(): AdminResult { - if (isDeviceManaged()) return AdminResult.Unauthorized + if (isLocalTargetManaged()) return AdminResult.Unauthorized return submitAdminFireAndForget(AdminMessage(enter_dfu_mode_request = true)) } @@ -357,7 +363,7 @@ internal class AdminApiImpl( } override suspend fun setTimeOnly(unixTime: Int): AdminResult { - if (isDeviceManaged()) return AdminResult.Unauthorized + if (isLocalTargetManaged()) return AdminResult.Unauthorized return submitAdminFireAndForget(AdminMessage(set_time_only = unixTime)) } @@ -476,7 +482,7 @@ internal class AdminApiImpl( * so a second `SessionKeyExpired` surfaces to the caller (the device is rejecting our key). */ private suspend fun retryOnSessionExpiry(block: suspend () -> AdminResult): AdminResult { - if (isDeviceManaged()) return AdminResult.Unauthorized + if (isLocalTargetManaged()) return AdminResult.Unauthorized val first = block() if (first !is AdminResult.SessionKeyExpired) return first // Re-seed against the node this AdminApiImpl is scoped to. Session passkeys are diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt index e725fd2..23956b3 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt @@ -146,15 +146,24 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { * remote-admin failures (Unauthorized, SessionKeyExpired, NoRoute) surface to getter callers. */ fun tryFailFromRouting(requestId: Int, error: Routing.Error): Boolean { - if (requestId == 0) return false - val entry = pending[requestId] ?: return false // NONE on ROUTING_APP is "ack" — a setter would resolve via MessageHandle, but a pending // dispatcher entry expects a response payload. Treat NONE as no-op here so the dispatcher // keeps waiting; the actual response packet (if any) will land via tryComplete. if (error == Routing.Error.NONE) return false + return tryFail(requestId, mapRoutingError(error)) + } + + /** + * Resolve a pending entry with an arbitrary failure [result]. Used for failure surfaces + * that are NOT Routing.Error-shaped — e.g. a local `QueueStatus` enqueue rejection in the + * firmware's ERRNO namespace. + */ + fun tryFail(requestId: Int, result: AdminResult): Boolean { + if (requestId == 0) return false + val entry = pending[requestId] ?: return false entry.timeoutJob?.cancel() pending.remove(requestId) - entry.deferred.complete(mapRoutingError(error)) + entry.deferred.complete(result) return true } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt index 843b094..fbceb10 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt @@ -93,6 +93,18 @@ internal sealed interface EngineMessage { /** Cancel a previously queued send before it leaves the host. Idempotent. */ data class CancelHandle(val id: MessageId) : EngineMessage + /** + * Fire-and-forget admin message (no RPC correlation, no MessageHandle). Posted by + * [MeshEngine.sendAdmin] so that packet preparation — which reads and prunes the + * actor-owned per-node session-passkey map — always runs on the engine actor (ADR-002), + * never on the caller's coroutine. + */ + data class AdminFireAndForget( + val adminMsg: org.meshtastic.proto.AdminMessage, + val wantResponse: Boolean, + val to: Int, + ) : EngineMessage + // ── Timers ───────────────────────────────────────────────────────────── /** Periodic heartbeat tick (every 30 s per protocol.md §16). */ diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index 98026a3..c822cdf 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -116,8 +116,11 @@ internal class MeshEngine( */ val channelsState = MutableStateFlow?>(null) + // No replay: RadioClient.nodes seeds every subscription with a fresh Snapshot via + // onSubscription (see nodeMapSnapshot()). A replay slot would hand late subscribers + // whatever delta happened to be emitted last — NOT the Snapshot — leaving them with a + // near-empty fold until the next reconnect. val nodes = MutableSharedFlow( - replay = 1, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.SUSPEND, ) @@ -612,6 +615,7 @@ internal class MeshEngine( is EngineMessage.TransportStateChanged -> handleTransportStateChanged(msg) is EngineMessage.Send -> handleSend(msg) is EngineMessage.CancelHandle -> handleCancelHandle(msg) + is EngineMessage.AdminFireAndForget -> sendAdminPacket(msg.adminMsg, msg.wantResponse, msg.to) EngineMessage.HeartbeatTick -> handleHeartbeatTick() EngineMessage.LivenessTick -> handleLivenessTick() EngineMessage.PresenceCheckTick -> handlePresenceCheckTick() @@ -760,25 +764,6 @@ internal class MeshEngine( null } - // restore the persisted session passkey so admin RPCs work immediately, instead - // of waiting for the next get_owner_request response. Failures are non-fatal — we can - // always re-seed via the handshake's get_owner round-trip. The persisted passkey is the - // *local* device's seed passkey, so it is keyed under the previously-persisted identity - // (remote-node passkeys are ephemeral and never persisted). - val persisted = try { - storage?.loadSessionPasskey() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logger.warn(TAG, e) { "Failed to load persisted session passkey" } - null - } - val restoredOwner = previousMyNodeNum - if (persisted != null && restoredOwner != null) { - sessionPasskeys[restoredOwner] = - SessionPasskeyEntry(persisted.bytes.toByteArray(), persisted.expiresAtEpochMs) - } - // hydrate in-memory heartbeat map so subscribers don't see every previously-known // node as silent on reconnect. Failures are non-fatal; we just start fresh. try { @@ -1089,11 +1074,14 @@ internal class MeshEngine( val pending = settleBuffer.toList() settleBuffer.clear() handshakeStage = HandshakeStage.Stage1Draining - for (env in pending) { + for ((index, env) in pending.withIndex()) { processStage1Envelope(env) if (handshakeStage == HandshakeStage.Stage1Settling) { - // A replayed frame re-armed the settle timer — abort this handler and let - // the new HandshakeStage1SettleComplete fire. + // A replayed frame re-armed the settle timer — re-buffer the frames we + // haven't processed yet (they were already removed from settleBuffer) + // so the next HandshakeStage1SettleComplete replays them instead of + // silently dropping them, then abort this handler. + settleBuffer.addAll(pending.subList(index + 1, pending.size)) return } } @@ -1223,39 +1211,49 @@ internal class MeshEngine( emitNodeChangeOrLog(NodeChange.Snapshot(pendingNodes.toMap())) } - // Persist to storage. - engineScope?.launch { - if (storageDegraded) return@launch - val s = storage ?: return@launch - try { - if (myInfo != null) { - s.recordOwnNode(NodeId(myInfo.my_node_num), pendingMetadata?.firmware_version ?: "") - } - for ((nodeId, node) in pendingNodes) { - s.saveNode(node) - // every node in the handshake snapshot was just heard, so - // seed their heartbeat row as well. - lastHeartbeatAt[nodeId]?.let { ts -> s.saveHeartbeat(nodeId, ts) } - } - if (pendingChannels.isNotEmpty()) s.saveChannels(pendingChannels) - // Populate channelsState from handshake payload; fall back to storage - // for reconnect sessions where the device skips re-sending channels. - channelsState.value = if (pendingChannels.isNotEmpty()) { - pendingChannels.toList() - } else { - s.loadChannels().ifEmpty { null } - } - meshState.configBundle?.let { - s.saveConfig(it) - configBundleState.value = it + // Persist to storage. Snapshot every actor-owned collection BEFORE launching: + // the async block runs concurrently with the actor, which keeps mutating + // pendingNodes / pendingChannels / lastHeartbeatAt (markNodeHeard on every + // inbound frame, scheduleReconnect clears) — iterating the live collections + // off-actor risks ConcurrentModificationException mis-reported as + // StorageDegraded. + run { + val nodesSnapshot = pendingNodes.toMap() + val channelsSnapshot = pendingChannels.toList() + val heartbeatSnapshot = nodesSnapshot.keys.associateWith { lastHeartbeatAt[it] } + val firmwareVersion = pendingMetadata?.firmware_version ?: "" + val bundleSnapshot = meshState.configBundle + engineScope?.launch { + if (storageDegraded) return@launch + val s = storage ?: return@launch + try { + if (myInfo != null) { + s.recordOwnNode(NodeId(myInfo.my_node_num), firmwareVersion) + } + for ((nodeId, node) in nodesSnapshot) { + s.saveNode(node) + // every node in the handshake snapshot was just heard, so + // seed their heartbeat row as well. + heartbeatSnapshot[nodeId]?.let { ts -> s.saveHeartbeat(nodeId, ts) } + } + if (channelsSnapshot.isNotEmpty()) s.saveChannels(channelsSnapshot) + // Populate channelsState from handshake payload; fall back to storage + // for reconnect sessions where the device skips re-sending channels. + channelsState.value = channelsSnapshot.ifEmpty { + s.loadChannels().ifEmpty { null } + } + bundleSnapshot?.let { + s.saveConfig(it) + configBundleState.value = it + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + reportStorageDegraded( + "persisting handshake snapshot failed: ${e.message ?: e::class.simpleName}", + e, + ) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - reportStorageDegraded( - "persisting handshake snapshot failed: ${e.message ?: e::class.simpleName}", - e, - ) } } @@ -1289,7 +1287,18 @@ internal class MeshEngine( } private fun processSeedingEnvelope(fromRadio: org.meshtastic.proto.FromRadio) { - val packet = fromRadio.packet ?: return + val packet = fromRadio.packet ?: run { + // Non-packet variants during the seeding window get the same no-silent-loss + // treatment as Stage 1/2: node_info merges into the DB (Stage-2 commit already + // happened, so the live path is correct), everything else routes through the + // shared auxiliary handler. + fromRadio.node_info?.let { + processInboundNodeInfo(it) + return + } + handleAuxiliaryFromRadioVariant(fromRadio, HandshakeStage.SeedingSession) + return + } val decoded = packet.decoded ?: return if (decoded.portnum != PortNum.ADMIN_APP) { // live mesh traffic during the seeding window — same treatment as Stage 1/2. @@ -1325,22 +1334,24 @@ internal class MeshEngine( val expiresAtMs = clock.now().toEpochMilliseconds() + SESSION_PASSKEY_TTL.inWholeMilliseconds sessionPasskeys[fromNum] = SessionPasskeyEntry(bytes, expiresAtMs) logger.debug(TAG) { "Session passkey latched for 0x${fromNum.toString(16)} (${bytes.size} bytes)" } - if (fromNum != myNodeNum) return - engineScope?.launch { - if (storageDegraded) return@launch - try { - storage?.saveSessionPasskey(SessionPasskey(bytes.toByteString(), expiresAtMs)) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - reportStorageDegraded( - "saveSessionPasskey failed: ${e.message ?: e::class.simpleName}", - e, - ) - } - } } + /** + * Whether this admin message is response-shaped (one of the `get_*_response` oneof arms is + * set). Mirrors the firmware's `messageIsResponse` classification — only responses carry a + * passkey that the *sender* issued. + */ + private fun AdminMessage.isAdminResponse(): Boolean = get_channel_response != null || + get_owner_response != null || + get_config_response != null || + get_module_config_response != null || + get_canned_message_module_messages_response != null || + get_device_metadata_response != null || + get_ringtone_response != null || + get_device_connection_status_response != null || + get_node_remote_hardware_pins_response != null || + get_ui_config_response != null + /** Returns the unexpired session passkey for [nodeNum], pruning a stale entry if present. */ private fun validSessionPasskeyFor(nodeNum: Int): ByteArray? { val entry = sessionPasskeys[nodeNum] ?: return null @@ -1447,21 +1458,7 @@ internal class MeshEngine( when { packet != null -> processInboundMeshPacket(packet) - nodeInfo != null -> { - val nodeId = NodeId(nodeInfo.num) - markNodeHeard(nodeId) - val existing = meshState.nodes[nodeId] - meshState = meshState.withNodes(meshState.nodes + (nodeId to nodeInfo)) - if (nodeId == NodeId(myNodeNum)) ownNode.value = nodeInfo - if (existing == null) { - emitNodeChangeOrLog(NodeChange.Added(nodeInfo)) - } else { - val changed = diffNodeFields(existing, nodeInfo) - if (changed.isNotEmpty()) { - emitNodeChangeOrLog(NodeChange.Updated(nodeInfo, changed)) - } - } - } + nodeInfo != null -> processInboundNodeInfo(nodeInfo) queueStatus != null -> { // firmware emits QueueStatus with mesh_packet_id == 0 as a @@ -1470,15 +1467,29 @@ internal class MeshEngine( // successful send for a dropped packet would corrupt user-visible state. Guard. if (queueStatus.mesh_packet_id == 0) return val packetId = MessageId(queueStatus.mesh_packet_id) - if (queueStatus.res == 0) { + // `res` is the firmware's enqueue result in the ERRNO namespace + // (MeshTypes.h), NOT Routing.Error: 0 = OK, 32 = queue full / unknown, + // 33 = no radio interface, 34 = radio disabled, and 35 + // (ERRNO_SHOULD_RELEASE) means "no error, packet handled without LoRa TX" — + // a SUCCESS. Values 1..31 are genuine Routing.Error codes that + // Router::send returns directly (e.g. DUTY_CYCLE_LIMIT = 9). The two + // namespaces collide at >= 32 (BAD_REQUEST = 32, NOT_AUTHORIZED = 33, …), + // so ERRNO values must never be decoded as Routing.Error. + if (queueStatus.res == 0 || queueStatus.res == ERRNO_SHOULD_RELEASE) { pendingSends[packetId]?.value = SendState.Sent } else { - // the firmware rejected the packet at its transmit queue (typically - // queue-full). Fast-fail the handle / pending RPC instead of letting the - // caller wait out the full ACK timer for a packet that never left the device. - Routing.Error.fromValue(queueStatus.res)?.let { err -> - dispatcher.tryFailFromRouting(packetId.raw, err) - } + // The firmware rejected the packet at its transmit queue. Fast-fail + // the handle / pending RPC instead of letting the caller wait out the + // full ACK timer for a packet that never left the device. + val rpcResult: AdminResult = + if (queueStatus.res < ERRNO_BASE) { + Routing.Error.fromValue(queueStatus.res) + ?.let(CommandDispatcher::mapRoutingError) + ?: AdminResult.NodeUnreachable + } else { + AdminResult.NodeUnreachable + } + dispatcher.tryFail(packetId.raw, rpcResult) val flow = pendingSends.remove(packetId) ackTimeoutJobs.remove(packetId)?.cancel() if (flow != null) { @@ -1505,6 +1516,27 @@ internal class MeshEngine( } } + /** + * Merge an unsolicited `FromRadio.node_info` into the node DB and emit the matching + * delta. Called for live frames in [routeNormalEnvelope] and for frames that arrive + * during the seeding window in [processSeedingEnvelope]. + */ + private fun processInboundNodeInfo(nodeInfo: NodeInfo) { + val nodeId = NodeId(nodeInfo.num) + markNodeHeard(nodeId) + val existing = meshState.nodes[nodeId] + meshState = meshState.withNodes(meshState.nodes + (nodeId to nodeInfo)) + if (nodeId == NodeId(myNodeNum)) ownNode.value = nodeInfo + if (existing == null) { + emitNodeChangeOrLog(NodeChange.Added(nodeInfo)) + } else { + val changed = diffNodeFields(existing, nodeInfo) + if (changed.isNotEmpty()) { + emitNodeChangeOrLog(NodeChange.Updated(nodeInfo, changed)) + } + } + } + /** * The Ready-stage pipeline for a single inbound [MeshPacket]: presence, dedup, public * `packets` emission, session-passkey latching, RPC/ACK correlation, telemetry merge, and @@ -1548,9 +1580,12 @@ internal class MeshEngine( } else { null } - if (adminMsg != null) { - // every admin response refreshes the responder's session passkey — latch them all + if (adminMsg != null && (decoded.request_id != 0 || adminMsg.isAdminResponse())) { + // every admin RESPONSE refreshes the responder's session passkey — latch them all // (keyed per node) so remote admin sessions stay alive without re-seeding. + // Requests are excluded: when a remote node administers US, its request carries + // the passkey WE issued to it — latching that under the remote's key would poison + // our next RPC to that node with a passkey it never issued. latchSessionPasskey(packet.from.takeIf { n -> n != 0 } ?: myNodeNum, adminMsg) } // RPC dispatch: complete any pending getter / traceRoute / neighborInfo @@ -1626,7 +1661,7 @@ internal class MeshEngine( } fromRadio.fileInfo != null -> { - events.tryEmit(MeshEvent.FileInfo(fromRadio.fileInfo!!)) + events.tryEmit(MeshEvent.FileInfoReceived(fromRadio.fileInfo!!)) true } @@ -2104,6 +2139,23 @@ internal class MeshEngine( // Ensure the wire packet ID is set to the SDK-level MessageId value so that // QueueStatus / Routing ACK correlation via pendingSends[MessageId(x)] works. val wireId = if (msg.packet.id != 0) msg.packet.id else msg.id.raw + // Guard caller-supplied wire-id collisions at the REAL key. handleSend's guard runs + // on the engine-allocated MessageId (always unique); without this check a second + // send carrying the same caller-supplied packet.id would silently overwrite the + // first handle's pendingSends entry, stranding it un-completable forever (even + // cleanup() couldn't reach it). + val wireKey = MessageId(wireId) + val prior = pendingSends[wireKey] + if (prior != null && prior !== msg.stateFlow) { + val state = prior.value + val terminal = state is SendState.Failed || state == SendState.Acked || state == SendState.Delivered + if (!terminal) { + logger.warn(TAG) { "Send id=$wireId collides with in-flight wire id; rejecting" } + msg.stateFlow.value = SendState.Failed(SendFailure.IdCollision) + pendingSends.remove(msg.id) + return + } + } // Remote ADMIN_APP packets pick up the target's session passkey + PKC routing here // (no-op for everything else). val packet = prepareOutboundAdminPacket( @@ -2114,7 +2166,7 @@ internal class MeshEngine( ) // Re-key so both QueueStatus and Routing ACK can look up via MessageId(wireId). pendingSends.remove(msg.id) - pendingSends[MessageId(wireId)] = msg.stateFlow + pendingSends[wireKey] = msg.stateFlow val encoded = WireCodec.encodeToRadio(ToRadio(packet = packet)) outbound.trySend(Frame(encoded.toByteString())) @@ -2262,6 +2314,16 @@ internal class MeshEngine( supervisorJobRef.value?.cancel() } + /** + * Current node map for seeding per-subscription [NodeChange.Snapshot]s. + * + * Called from subscriber coroutines (off-actor). Safe without posting to the inbox: + * [meshState] is a `var` holding an immutable snapshot object, so this is a benign + * stale-by-at-most-one-message read — references are word-atomic and the deltas that + * follow on the subscription re-apply idempotently on top of whatever map was read. + */ + internal fun nodeMapSnapshot(): Map = meshState.nodes + internal fun sendToRadio(msg: ToRadio) { try { val encoded = WireCodec.encodeToRadio(msg) @@ -2271,8 +2333,13 @@ internal class MeshEngine( } } + /** + * Fire-and-forget admin send. Posts to the actor inbox rather than building the packet + * inline: [prepareOutboundAdminPacket] reads (and prunes) the actor-owned + * [sessionPasskeys] map, so it must never run on the caller's coroutine (ADR-002). + */ internal fun sendAdmin(adminMsg: AdminMessage, wantResponse: Boolean = false, to: Int = myNodeNum) { - sendAdminPacket(adminMsg, wantResponse, to) + inbox.trySend(EngineMessage.AdminFireAndForget(adminMsg, wantResponse, to)) } private fun sendAdminPacket(adminMsg: AdminMessage, wantResponse: Boolean = false, to: Int = myNodeNum) { @@ -2531,6 +2598,12 @@ internal class MeshEngine( // after wire round-trip). Used so we don't arm an ACK timer for broadcasts. const val BROADCAST_ADDR: Int = -1 + // QueueStatus.res namespace boundaries (firmware MeshTypes.h). Values below + // ERRNO_BASE are Routing.Error codes; values at/above it are ERRNO_* enqueue + // results. ERRNO_SHOULD_RELEASE explicitly means "no error". + const val ERRNO_BASE: Int = 32 + const val ERRNO_SHOULD_RELEASE: Int = 35 + // Firmware regenerates session passkeys every ~150s and they remain valid for ~300s. // A 4-minute TTL ensures we don't use a stale passkey on reconnect while still // avoiding unnecessary re-seeding within a single connected session. diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt index 84c61dd..4fb5f29 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt @@ -10,6 +10,7 @@ package org.meshtastic.sdk import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -94,7 +95,7 @@ class AndroidCutoverPrereqsTest { val fileInfo = org.meshtastic.proto.FileInfo(file_name = "firmware.uf2", size_bytes = 1024) transport.injectFrame(frameOf(FromRadio(fileInfo = fileInfo))) runCurrent() - assertEquals(MeshEvent.FileInfo(fileInfo), awaitItem()) + assertEquals(MeshEvent.FileInfoReceived(fileInfo), awaitItem()) // lockdown_status (protobufs 2.7.25+) is not yet typed — but it must not be // silently dropped either. @@ -176,6 +177,254 @@ class AndroidCutoverPrereqsTest { } } + @Test + fun remoteAdminFallsBackWhenOwnNodeHasNoKey() = runTest { + withConnectedClient { client, transport -> + // Only the TARGET has a published key — PKC requires BOTH sides. + transport.injectFrame( + frameOf( + FromRadio( + node_info = NodeInfo(num = REMOTE_NODE.raw, user = User(id = "!2", public_key = REMOTE_KEY)), + ), + ), + ) + runCurrent() + + val before = transport.outboundPackets().size + val deferred = backgroundScope.async { client.admin.forNode(REMOTE_NODE).getDeviceMetadata() } + runCurrent() + + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + assertFalse(outbound.pki_encrypted, "Own node has no key — PKC must not be used") + assertEquals(0, outbound.public_key.size) + deferred.cancel() + } + } + + @Test + fun remoteAdminFallsBackWhenTargetHasNoKey() = runTest { + withConnectedClient { client, transport -> + // Only OUR node has a published key. + transport.injectFrame( + frameOf( + FromRadio(node_info = NodeInfo(num = TEST_NODE_NUM, user = User(id = "!1", public_key = MY_KEY))), + ), + ) + runCurrent() + + val before = transport.outboundPackets().size + val deferred = backgroundScope.async { client.admin.forNode(REMOTE_NODE).getDeviceMetadata() } + runCurrent() + + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + assertFalse(outbound.pki_encrypted, "Target has no key — PKC must not be used") + deferred.cancel() + } + } + + @Test + fun callerBuiltPkcPacketKeepsItsRoutingAndGainsPasskey() = runTest { + withConnectedClient { client, transport -> + // Latch a passkey for the remote via a normal RPC round-trip. + val seed = backgroundScope.async { client.admin.forNode(REMOTE_NODE).getDeviceMetadata() } + runCurrent() + val seedReq = transport.outboundPackets().last { it.to == REMOTE_NODE.raw } + transport.injectAdminResponse( + requestId = seedReq.id, + response = AdminMessage( + get_device_metadata_response = DeviceMetadata(firmware_version = "2.7.0"), + session_passkey = POISON_PASSKEY.toByteString(), + ), + fromNode = REMOTE_NODE.raw, + ) + runCurrent() + assertIs>(seed.await()) + + // A caller-built PKC admin packet must keep its own routing; only the passkey lands. + val before = transport.outboundPackets().size + client.send( + MeshPacket( + to = REMOTE_NODE.raw, + channel = 3, + pki_encrypted = true, + public_key = CALLER_KEY, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.ADMIN_APP, + payload = AdminMessage.ADAPTER.encode(AdminMessage(get_owner_request = true)).toByteString(), + ), + ), + ) + runCurrent() + + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + assertTrue(outbound.pki_encrypted) + assertContentEquals(CALLER_KEY.toByteArray(), outbound.public_key.toByteArray()) + assertEquals(3, outbound.channel, "Caller-chosen channel must survive") + val stamped = AdminMessage.ADAPTER.decode(assertNotNull(outbound.decoded).payload) + assertContentEquals(POISON_PASSKEY, stamped.session_passkey.toByteArray()) + } + } + + @Test + fun remoteAdminPreservesCallerPriority() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + client.send( + MeshPacket( + to = REMOTE_NODE.raw, + priority = MeshPacket.Priority.BACKGROUND, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.ADMIN_APP, + payload = AdminMessage.ADAPTER.encode(AdminMessage(get_owner_request = true)).toByteString(), + ), + ), + ) + runCurrent() + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + assertEquals(MeshPacket.Priority.BACKGROUND, outbound.priority, "Caller priority must survive") + } + } + + @Test + fun broadcastAdminPacketIsNotRewritten() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + client.send( + MeshPacket( + to = -1, // broadcast + channel = 2, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.ADMIN_APP, + payload = AdminMessage.ADAPTER.encode(AdminMessage(get_owner_request = true)).toByteString(), + ), + ), + ) + runCurrent() + val outbound = transport.outboundPackets().drop(before).last { it.to == -1 } + assertFalse(outbound.pki_encrypted) + assertEquals(2, outbound.channel, "Broadcast admin must not be re-routed") + val admin = AdminMessage.ADAPTER.decode(assertNotNull(outbound.decoded).payload) + assertEquals(0, admin.session_passkey.size, "Broadcast admin must not be passkey-stamped") + } + } + + @Test + fun queueStatusRejectionFastFailsInFlightRpc() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val deferred = backgroundScope.async { client.admin.forNode(REMOTE_NODE).getDeviceMetadata() } + runCurrent() + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + + // res in 1..31 is a genuine Routing.Error (9 = DUTY_CYCLE_LIMIT). + transport.injectFrame( + frameOf(FromRadio(queueStatus = QueueStatus(res = 9, mesh_packet_id = outbound.id))), + ) + runCurrent() + assertEquals(AdminResult.Failed(org.meshtastic.proto.Routing.Error.DUTY_CYCLE_LIMIT), deferred.await()) + } + } + + @Test + fun queueStatusErrnoFailsRpcAsNodeUnreachable() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val deferred = backgroundScope.async { client.admin.forNode(REMOTE_NODE).getDeviceMetadata() } + runCurrent() + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + + // res >= 32 is the firmware ERRNO namespace (32 = queue full) — NOT Routing.Error. + transport.injectFrame( + frameOf(FromRadio(queueStatus = QueueStatus(res = 32, mesh_packet_id = outbound.id))), + ) + runCurrent() + assertEquals(AdminResult.NodeUnreachable, deferred.await()) + } + } + + @Test + fun queueStatusShouldReleaseCountsAsSent() = runTest { + withConnectedClient { client, transport -> + val handle = client.sendText("ok", to = REMOTE_NODE) + runCurrent() + assertEquals(SendState.Sent, handle.state.value) + + // ERRNO_SHOULD_RELEASE (35) is "no error" — must NOT fail the handle. + transport.injectFrame( + frameOf(FromRadio(queueStatus = QueueStatus(res = 35, mesh_packet_id = handle.id.raw))), + ) + runCurrent() + assertEquals(SendState.Sent, handle.state.value, "res=35 means success, not rejection") + } + } + + @Test + fun queueStatusWithUnmappedResStillFailsTheHandle() = runTest { + withConnectedClient { client, transport -> + val handle = client.sendText("burst2") + runCurrent() + transport.injectFrame( + frameOf(FromRadio(queueStatus = QueueStatus(res = 100, mesh_packet_id = handle.id.raw))), + ) + runCurrent() + val state = assertIs(handle.state.value) + assertEquals(SendFailure.QueueRejected(res = 100), state.reason) + } + } + + @Test + fun inboundAdminRequestDoesNotPoisonRemotePasskeys() = runTest { + withConnectedClient { client, transport -> + // A remote node administering US sends a REQUEST carrying the passkey WE issued it. + transport.injectFrame( + frameOf( + FromRadio( + packet = MeshPacket( + from = REMOTE_NODE.raw, + to = TEST_NODE_NUM, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.ADMIN_APP, + payload = AdminMessage.ADAPTER.encode( + AdminMessage( + set_owner = User(long_name = "intruder"), + session_passkey = POISON_PASSKEY.toByteString(), + ), + ).toByteString(), + ), + ), + ), + ), + ) + runCurrent() + + // Our next RPC to that node must NOT be stamped with a passkey it never issued. + val before = transport.outboundPackets().size + val deferred = backgroundScope.async { client.admin.forNode(REMOTE_NODE).getDeviceMetadata() } + runCurrent() + val outbound = transport.outboundPackets().drop(before).last { it.to == REMOTE_NODE.raw } + val admin = AdminMessage.ADAPTER.decode(assertNotNull(outbound.decoded).payload) + assertEquals(0, admin.session_passkey.size, "Request-borne passkeys must not be latched") + deferred.cancel() + } + } + + @Test + fun lateSubscribersReceiveSeededNodeSnapshot() = runTest { + withConnectedClient { client, transport -> + transport.injectFrame( + frameOf( + FromRadio(node_info = NodeInfo(num = REMOTE_NODE.raw, user = User(id = "!2", long_name = "Late"))), + ), + ) + runCurrent() + + // Subscribe AFTER the node arrived — the seeded Snapshot must contain it. + val first = client.nodes.first() + val snapshot = assertIs(first) + assertEquals("Late", snapshot.nodes[REMOTE_NODE]?.user?.long_name) + } + } + // ── Harness ───────────────────────────────────────────────────────────── private fun TestScope.buildClient(transport: FakeRadioTransport): RadioClient = RadioClient.Builder() @@ -210,5 +459,7 @@ class AndroidCutoverPrereqsTest { val REMOTE_NODE: NodeId = NodeId(0x22222222) val MY_KEY = byteArrayOf(1, 1, 2, 2).toByteString() val REMOTE_KEY = byteArrayOf(3, 3, 4, 4).toByteString() + val CALLER_KEY = byteArrayOf(5, 5, 6, 6).toByteString() + val POISON_PASSKEY = byteArrayOf(7, 7, 8, 8) } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt index d8e260a..c65cba8 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt @@ -351,6 +351,12 @@ class HandshakeAndReconnectTest { assertEquals(ConnectionState.Connected, client.connection.value) assertEquals(777, awaitItem().id, "Buffered handshake packet must flush at Ready") + + // A mesh retransmission of the same packet after Ready must dedupe against the + // flushed copy — delivered exactly once. + transport.injectAlivePacket(packetId = 777) + drainCurrent() + expectNoEvents() cancelAndIgnoreRemainingEvents() } } @@ -739,6 +745,266 @@ class HandshakeAndReconnectTest { assertEquals(ConnectionState.Connected, client.connection.value) } + @Test + fun remotePasskeysAreIsolatedPerTargetNode() = runTest { + val transport = ScriptedTransport(TransportIdentity("fake:two-remotes"), nowMs = { currentTime }) + val client = buildClient(transport) + val nodeA = NodeId(0x0AAAA) + val nodeB = NodeId(0x0BBBB) + client.connect() + runCurrent() + + // RPC to A; its response latches PASSKEY_A keyed under A. + val firstA = backgroundScope.async { client.admin.forNode(nodeA).getDeviceMetadata() } + runCurrent() + val requestA = transport.outboundPackets().last { it.to == nodeA.raw } + transport.injectAdminResponse( + requestId = requestA.id, + response = AdminMessage( + get_device_metadata_response = org.meshtastic.proto.DeviceMetadata(firmware_version = "a"), + session_passkey = PASSKEY_A.toByteString(), + ), + fromNode = nodeA.raw, + ) + runCurrent() + assertIs>(firstA.await()) + + // First RPC to B must NOT carry A's passkey — the cross-contamination bug class. + val firstB = backgroundScope.async { client.admin.forNode(nodeB).getDeviceMetadata() } + runCurrent() + val requestB = transport.outboundPackets().last { it.to == nodeB.raw } + assertEquals(0, adminOf(requestB)?.session_passkey?.size, "Node A's passkey must not leak to node B") + transport.injectAdminResponse( + requestId = requestB.id, + response = AdminMessage( + get_device_metadata_response = org.meshtastic.proto.DeviceMetadata(firmware_version = "b"), + session_passkey = PASSKEY_B.toByteString(), + ), + fromNode = nodeB.raw, + ) + runCurrent() + assertIs>(firstB.await()) + + // Follow-up RPCs carry each node's OWN passkey. + val secondA = backgroundScope.async { client.admin.forNode(nodeA).getDeviceMetadata() } + runCurrent() + val requestA2 = transport.outboundPackets().last { it.to == nodeA.raw && it.id != requestA.id } + assertContentEquals(PASSKEY_A, adminOf(requestA2)?.session_passkey?.toByteArray()) + secondA.cancel() + + val secondB = backgroundScope.async { client.admin.forNode(nodeB).getDeviceMetadata() } + runCurrent() + val requestB2 = transport.outboundPackets().last { it.to == nodeB.raw && it.id != requestB.id } + assertContentEquals(PASSKEY_B, adminOf(requestB2)?.session_passkey?.toByteArray()) + secondB.cancel() + } + + @Test + fun staleRemotePasskeyIsPrunedNotStamped() = runTest { + val transport = ScriptedTransport(TransportIdentity("fake:passkey-ttl"), nowMs = { currentTime }) + val client = buildClient(transport) + val remoteNode = NodeId(0x0AB1) + client.connect() + runCurrent() + + val first = backgroundScope.async { client.admin.forNode(remoteNode).getDeviceMetadata() } + runCurrent() + val request = transport.outboundPackets().last { it.to == remoteNode.raw } + transport.injectAdminResponse( + requestId = request.id, + response = AdminMessage( + get_device_metadata_response = org.meshtastic.proto.DeviceMetadata(firmware_version = "x"), + session_passkey = REMOTE_PASSKEY.toByteString(), + ), + fromNode = remoteNode.raw, + ) + runCurrent() + assertIs>(first.await()) + + // Cross the 4-minute client-side TTL while keeping the session alive. + keepSessionAlive(transport, 300.seconds.inWholeMilliseconds) + + val before = transport.outboundPackets().size + val second = backgroundScope.async { client.admin.forNode(remoteNode).getDeviceMetadata() } + runCurrent() + val stale = transport.outboundPackets().drop(before).last { it.to == remoteNode.raw } + assertEquals(0, adminOf(stale)?.session_passkey?.size, "Expired passkey must be pruned, not stamped") + second.cancel() + } + + @Test + fun identityRebindDropsLatchedRemotePasskeys() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:rebind-passkeys"), + nowMs = { currentTime }, + reconnectOutcomes = listOf(ConnectOutcome.Success), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 2.seconds, + jitter = 0.0, + ), + ) + val remoteNode = NodeId(0x0AB2) + client.connect() + runCurrent() + + val first = backgroundScope.async { client.admin.forNode(remoteNode).getDeviceMetadata() } + runCurrent() + val request = transport.outboundPackets().last { it.to == remoteNode.raw } + transport.injectAdminResponse( + requestId = request.id, + response = AdminMessage( + get_device_metadata_response = org.meshtastic.proto.DeviceMetadata(firmware_version = "x"), + session_passkey = REMOTE_PASSKEY.toByteString(), + ), + fromNode = remoteNode.raw, + ) + runCurrent() + assertIs>(first.await()) + + // The device comes back from a reconnect with a DIFFERENT NodeNum (factory reset / + // radio swap) — passkeys issued under the old identity are meaningless. + transport.nodeNum = DEFAULT_NODE_NUM + 7 + transport.simulateRecoverableError("rebind") + runCurrent() + advanceTimeBy(1_000L) + drainCurrent() + awaitConnected(client) + + val before = transport.outboundPackets().size + val second = backgroundScope.async { client.admin.forNode(remoteNode).getDeviceMetadata() } + runCurrent() + val postRebind = transport.outboundPackets().drop(before).last { it.to == remoteNode.raw } + assertEquals(0, adminOf(postRebind)?.session_passkey?.size, "Rebind must clear latched remote passkeys") + second.cancel() + } + + @Test + fun handshakeBufferOverflowDropsOldestAndSignalsPacketsDropped() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:buffer-overflow"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { client.connect() } + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + + client.events.test { + // Two past the 64-packet cap: the two oldest are dropped, each observably. + repeat(66) { index -> transport.injectAlivePacket(packetId = 1000 + index) } + drainCurrent() + repeat(2) { + val dropped = assertIs(awaitItem()) + assertEquals(DroppedFlow.Packets, dropped.flow) + } + cancelAndIgnoreRemainingEvents() + } + + client.packets.test { + transport.injectFromRadio(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE2)) + drainCurrent() + connectJob.await() + assertEquals(1002, awaitItem().id, "Drop-oldest: the flush must start at the third packet") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun packetsAndStrayAdminDuringSeedingAreBufferedUntilOwnerResponse() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:seeding-buffer"), + nowMs = { currentTime }, + autoRespondGetOwner = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { client.connect() } + drainCurrent() + // Walk the virtual-time settle windows (Stage-1 settle + heartbeat settle) so the + // engine reaches the seeding stage and sends its get_owner_request. + repeat(3) { + advanceTimeBy(INTER_STAGE_SETTLE_MS + 1) + drainCurrent() + } + + client.packets.test { + // Live traffic + a stray (request-shaped) admin packet during the seeding window. + transport.injectAlivePacket(packetId = 555) + transport.injectFromRadio( + org.meshtastic.proto.FromRadio( + packet = MeshPacket( + from = 0x0DEAD, + to = transport.nodeNum, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = AdminMessage.ADAPTER + .encode(AdminMessage(set_owner = User(long_name = "stray"))) + .toByteString(), + ), + ), + ), + ) + drainCurrent() + expectNoEvents() + assertIs(client.connection.value) + assertFalse(connectJob.isCompleted, "Stray admin must not complete the seeding stage") + + // Answer the seed; both buffered packets flush through the Ready pipeline. + val seedRequest = transport.outboundPackets().first { adminOf(it)?.get_owner_request == true } + transport.injectAdminResponse( + requestId = seedRequest.id, + response = AdminMessage( + get_owner_response = User(id = "!00000001", long_name = "ScriptedNode", short_name = "SN"), + session_passkey = SEEDED_PASSKEY.toByteString(), + ), + fromNode = transport.nodeNum, + ) + drainCurrent() + connectJob.await() + assertEquals(555, awaitItem().id) + assertEquals(PortNum.ADMIN_APP, awaitItem().decoded?.portnum) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun settleReplayPreservesFramesAfterDuplicateStage1Completion() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:settle-replay"), + nowMs = { currentTime }, + autoCompleteStage1 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { client.connect() } + runCurrent() + + // Manual Stage 1, then a duplicate completion AND a live packet inside the settle window + // (the firmware re-drains from scratch on a want_config retry, producing exactly this). + transport.injectFromRadio(org.meshtastic.proto.FromRadio(my_info = MyNodeInfo(my_node_num = transport.nodeNum))) + transport.injectFromRadio(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE1)) + runCurrent() + transport.injectFromRadio(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE1)) + transport.injectAlivePacket(packetId = 777) + runCurrent() + + client.packets.test { + // Settle #1 replays the duplicate completion (re-arms the window) — the packet + // behind it must be RE-BUFFERED, not dropped. Settle #2 then processes it. + repeat(4) { + advanceTimeBy(INTER_STAGE_SETTLE_MS + 1) + drainCurrent() + } + connectJob.await() + assertEquals(ConnectionState.Connected, client.connection.value) + assertEquals(777, awaitItem().id, "Settle replay must not drop frames behind a duplicate completion") + cancelAndIgnoreRemainingEvents() + } + } + private fun TestScope.buildClient( transport: RadioTransport, autoReconnect: AutoReconnectConfig = AutoReconnectConfig.Disabled, @@ -811,7 +1077,8 @@ class HandshakeAndReconnectTest { reconnectOutcomes: List = emptyList(), var autoCompleteStage1: Boolean = true, var autoCompleteStage2: Boolean = true, - val nodeNum: Int = DEFAULT_NODE_NUM, + var nodeNum: Int = DEFAULT_NODE_NUM, + var autoRespondGetOwner: Boolean = true, private val sessionPasskey: ByteArray = SEEDED_PASSKEY, ) : RadioTransport { private val connectPlan = ArrayDeque(reconnectOutcomes) @@ -945,6 +1212,7 @@ class HandshakeAndReconnectTest { private fun handleAdminPacket(packet: MeshPacket) { val admin = decodeAdmin(packet) ?: return + if (!autoRespondGetOwner) return if (packet.to != nodeNum || admin.get_owner_request != true) return val user = User( id = "!00000001", @@ -981,5 +1249,8 @@ class HandshakeAndReconnectTest { const val STAGE2_HARD_CAP_MS = 60_000L val SEEDED_PASSKEY = byteArrayOf(1, 2, 3, 4) val REMOTE_PASSKEY = byteArrayOf(9, 8, 7, 6) + val PASSKEY_A = byteArrayOf(10, 11, 12, 13) + val PASSKEY_B = byteArrayOf(20, 21, 22, 23) + const val INTER_STAGE_SETTLE_MS = 100L } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSugarTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSugarTest.kt index f158dbc..9fc1c5c 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSugarTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSugarTest.kt @@ -8,9 +8,12 @@ package org.meshtastic.sdk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.meshtastic.proto.NodeInfo @@ -64,6 +67,29 @@ class RadioClientSugarTest { assertEquals(ConnectionState.Disconnected, client.connection.value, "Teardown must survive exceptions") } + @Test + fun withConnectionDisconnectsWhenCallerIsCancelled() = runTest { + val client = buildClient(fakeTransport()) + + val job = launch { + client.withConnection { awaitCancellation() } + } + // connect() runs inside the launched job, so the handshake's virtual-time settle + // windows (2 x 100 ms) need explicit advancement. + advanceTimeBy(1_000) + runCurrent() + assertEquals(ConnectionState.Connected, client.connection.value) + + job.cancel() + advanceTimeBy(1_000) + runCurrent() + assertEquals( + ConnectionState.Disconnected, + client.connection.value, + "NonCancellable teardown must run when the caller is cancelled", + ) + } + @Test fun asNodeMapFoldsTheCanonicalAccumulator() = runTest { val alice = NodeInfo(num = 1, user = User(id = "!1", long_name = "Alice")) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/send/ProtoBytesAndDecodeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/send/ProtoBytesAndDecodeTest.kt index cefe1ed..8f5415b 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/send/ProtoBytesAndDecodeTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/send/ProtoBytesAndDecodeTest.kt @@ -8,6 +8,7 @@ package org.meshtastic.sdk import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Data import org.meshtastic.proto.FromRadio import org.meshtastic.proto.MeshPacket @@ -85,7 +86,30 @@ class ProtoBytesAndDecodeTest { } @Test - fun decodeAs_emptyPayloadReturnsNull() { + fun asPosition_missingDecodedReturnsNull() { assertNull(MeshPacket().asPosition()) } + + @Test + fun decodeAs_ignoresPortnumAndSwallowsCorruptBytes() { + val position = Position(latitude_i = 450000000, longitude_i = -930000000) + // decodeAs is the documented escape hatch: NO portnum guard (Paxcount/StoreAndForward + // consumers decode payloads carried under arbitrary ports). + val mismatchedPort = MeshPacket( + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = Position.ADAPTER.encode(position).toByteString(), + ), + ) + assertEquals(position, mismatchedPort.decodeAs(Position.ADAPTER)) + + val corrupt = MeshPacket( + decoded = Data( + portnum = PortNum.POSITION_APP, + payload = byteArrayOf(-1, -1, -1).toByteString(), + ), + ) + assertNull(corrupt.decodeAs(Position.ADAPTER)) + assertNull(MeshPacket().decodeAs(Position.ADAPTER)) + } } From e9dd38100dcb283e375e41634a1517e36fbcf905 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:32:01 -0500 Subject: [PATCH 10/16] =?UTF-8?q?fix(transport-ble):=20per-connect=20tunin?= =?UTF-8?q?g=20scope=20=E2=80=94=20stale=20priority=20downgrade=20can't=20?= =?UTF-8?q?leak=20across=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android factory's delayed CONNECTION_PRIORITY downgrade was launched on the transport-lifetime scope; disconnect() didn't cancel it, and the transport supports reuse-after-disconnect (the engine's auto-reconnect path), so a 30-second timer from session N could fire mid-handshake of session N+1 and downgrade the connection priority during exactly the boost window it exists to protect. postConnectHook now receives a per-connect-cycle scope (child of the transport scope), cancelled in cancelJobs()/disconnect(). New BleTransportHookTest drives a fake Kable Peripheral through connect/disconnect/reconnect and proves: the hook runs once per connect and failures are non-fatal; hook-scheduled work is cancelled at disconnect; and prior-session work never fires into the next session (each test fails against the pre-fix code). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../sdk/transport/ble/BleTransport.android.kt | 7 +- .../sdk/transport/ble/BleTransport.kt | 22 ++- .../sdk/transport/ble/BleTransportHookTest.kt | 175 ++++++++++++++++++ 3 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 transport-ble/src/commonTest/kotlin/org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt diff --git a/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt b/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt index 27a5b1d..56ec9f2 100644 --- a/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt +++ b/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt @@ -37,8 +37,9 @@ private const val CONNECTION_PRIORITY_BOOST_MS: Long = 30_000L * * After each successful link establishment the transport negotiates the ATT MTU * ([PREFERRED_MTU]) and requests a high connection priority for the handshake window - * (downgraded to Balanced after [CONNECTION_PRIORITY_BOOST_MS]). Both are best-effort: a - * failure leaves the OS defaults in place and never fails the connect. + * (downgraded to Balanced after [CONNECTION_PRIORITY_BOOST_MS]; the pending downgrade is + * cancelled if the session disconnects first). Both are best-effort: a failure leaves the + * OS defaults in place and never fails the connect. * * Example — bonded device (no fresh advertisement, must use `autoConnect`): * ```kotlin @@ -62,6 +63,8 @@ public fun BleTransport(address: String, builderAction: PeripheralBuilder.() -> if (android != null) { tryTune { android.requestMtu(PREFERRED_MTU) } tryTune { android.requestConnectionPriority(AndroidPeripheral.Priority.High) } + // Receiver scope is per-connect-cycle: this delayed downgrade is cancelled at + // disconnect(), so a stale timer can never fire into a later session's boost window. launch { delay(CONNECTION_PRIORITY_BOOST_MS) tryTune { android.requestConnectionPriority(AndroidPeripheral.Priority.Balanced) } diff --git a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt index f151c61..f71feb5 100644 --- a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt +++ b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt @@ -84,6 +84,11 @@ public class BleTransport( private var observeJob: Job? = null private var coordinator: DrainCoordinator? = null + // Per-connect-cycle scope handed to [postConnectHook]; cancelled by disconnect()/shutdown() + // so stale tuning work (e.g. a delayed connection-priority downgrade) can't leak into the + // next session when the transport is reused after disconnect. + private var tuningScope: CoroutineScope? = null + private val frameChannel = Channel(capacity = 64) // ADR-012: lifecycle idempotency — idempotent shutdown flag. @@ -102,9 +107,10 @@ public class BleTransport( * Platform hook invoked once per successful link establishment, after the GATT connection * (and Kable's service discovery) completes and before the warmup read. Platform factories * use it for link tuning — e.g. the Android factory negotiates the ATT MTU and requests a - * faster connection interval here. Receives the transport's [CoroutineScope] as receiver - * for scheduling follow-ups (e.g. a delayed priority downgrade). Best-effort: failures are - * swallowed and never fail the connect. + * faster connection interval here. Receives a **per-connect-cycle** [CoroutineScope] as + * receiver for scheduling follow-ups (e.g. a delayed priority downgrade). That scope is + * cancelled at [disconnect] (and [shutdown]), so anything launched into it must not assume + * it outlives the session. Best-effort: failures are swallowed and never fail the connect. */ internal var postConnectHook: suspend CoroutineScope.(Peripheral) -> Unit = {} @@ -117,8 +123,12 @@ public class BleTransport( try { connectWithRetry() + // Child of the transport scope (dies with it on shutdown), but independently + // cancellable per connect cycle via cancelJobs()/disconnect(). + val tScope = CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) + tuningScope = tScope try { - postConnectHook(scope, peripheral) + postConnectHook(tScope, peripheral) } catch (e: CancellationException) { throw e } catch (_: Exception) { @@ -298,6 +308,8 @@ public class BleTransport( drainJob = null observeJob = null stateBridgeJob = null + // tuningScope's SupervisorJob is a child of scope's Job, so scope.cancel() cancels it. + tuningScope = null coordinator = null scope.cancel() frameChannel.close() @@ -311,6 +323,8 @@ public class BleTransport( observeJob = null stateBridgeJob?.cancel() stateBridgeJob = null + tuningScope?.cancel() + tuningScope = null coordinator = null } diff --git a/transport-ble/src/commonTest/kotlin/org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt b/transport-ble/src/commonTest/kotlin/org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt new file mode 100644 index 0000000..d6a5eb2 --- /dev/null +++ b/transport-ble/src/commonTest/kotlin/org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt @@ -0,0 +1,175 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.transport.ble + +import com.juul.kable.Characteristic +import com.juul.kable.Descriptor +import com.juul.kable.DiscoveredService +import com.juul.kable.ExperimentalApi +import com.juul.kable.Identifier +import com.juul.kable.Peripheral +import com.juul.kable.State +import com.juul.kable.WriteType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.sdk.TransportState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Lifecycle contract of [BleTransport.postConnectHook]: it runs exactly once per connect + * cycle, its failures never fail the connect, and any work it launches into its receiver + * scope is cancelled at [BleTransport.disconnect] — a stale tuning timer from session N + * must never fire into session N+1 when the transport is reused after disconnect (the + * engine's auto-reconnect path). + */ +@OptIn(ExperimentalCoroutinesApi::class) // advanceTimeBy/runCurrent (virtual-time control) +class BleTransportHookTest { + + @Test + fun postConnectHookRunsOncePerConnectAndFailureIsNonFatal() = runTest { + val transport = BleTransport(FakePeripheral(), address = TEST_ADDRESS) + var invocations = 0 + transport.postConnectHook = { + invocations++ + error("tune failed") + } + + transport.connect() + assertEquals(TransportState.Connected, transport.state.value) + assertEquals(1, invocations) + + transport.disconnect() + transport.connect() + assertEquals(TransportState.Connected, transport.state.value) + assertEquals(2, invocations) + + transport.shutdown() + } + + @Test + fun hookScheduledWorkIsCancelledOnDisconnect() = runTest { + val transport = BleTransport(FakePeripheral(), address = TEST_ADDRESS) + val testDispatcher = StandardTestDispatcher(testScheduler) + var fired = false + transport.postConnectHook = { + // Mirrors the Android factory's delayed priority downgrade. The explicit test + // dispatcher gives the delay virtual time; the job parent comes from the + // receiver scope, which is what this test is about. + launch(testDispatcher) { + delay(30_000) + fired = true + } + } + + transport.connect() + transport.disconnect() + + advanceTimeBy(31_000) + runCurrent() + assertFalse(fired, "work launched into the hook scope must be cancelled at disconnect()") + + transport.shutdown() + } + + @Test + fun hookWorkFromPriorSessionDoesNotLeakIntoNext() = runTest { + val transport = BleTransport(FakePeripheral(), address = TEST_ADDRESS) + val testDispatcher = StandardTestDispatcher(testScheduler) + var session1Fired = false + var connects = 0 + transport.postConnectHook = { + connects++ + if (connects == 1) { + launch(testDispatcher) { + delay(30_000) + session1Fired = true + } + } + } + + transport.connect() // session 1 — schedules delayed tuning work + transport.disconnect() + transport.connect() // session 2 — same instance (engine auto-reconnect reuse) + + advanceTimeBy(31_000) // past session 1's original deadline, well inside session 2 + runCurrent() + assertFalse(session1Fired, "session-1 tuning work must not fire into session 2") + assertEquals(2, connects) + + transport.shutdown() + } + + private companion object { + const val TEST_ADDRESS = "AA:BB:CC:DD:EE:FF" + } +} + +/** + * Minimal in-memory [Peripheral]: [connect]/[disconnect] flip [state], [read] returns an + * empty payload (queue drained — satisfies the warmup read), [observe] never emits. + * Members [BleTransport] never touches throw [UnsupportedOperationException]. + */ +@OptIn(ExperimentalApi::class) +private class FakePeripheral : Peripheral { + + private val stateFlow = MutableStateFlow(State.Disconnected()) + + override val scope: CoroutineScope = CoroutineScope(SupervisorJob()) + + override val state: StateFlow = stateFlow + + override val identifier: Identifier + get() = throw UnsupportedOperationException("not used by BleTransport") + + override val name: String? = null + + override val services: StateFlow?> = MutableStateFlow(null) + + override suspend fun connect(): CoroutineScope { + stateFlow.value = State.Connected(scope) + return scope + } + + override suspend fun disconnect() { + stateFlow.value = State.Disconnected() + } + + override suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int = 512 + + override suspend fun rssi(): Int = throw UnsupportedOperationException("not used by BleTransport") + + override suspend fun read(characteristic: Characteristic): ByteArray = ByteArray(0) + + override suspend fun write(characteristic: Characteristic, data: ByteArray, writeType: WriteType) = Unit + + override suspend fun read(descriptor: Descriptor): ByteArray = + throw UnsupportedOperationException("not used by BleTransport") + + override suspend fun write(descriptor: Descriptor, data: ByteArray) = Unit + + override fun observe(characteristic: Characteristic, onSubscription: suspend () -> Unit): Flow = + emptyFlow() + + override fun close() { + scope.cancel() + } +} From ad173ba251859e56e3d622cce0a03cbd7c415fbd Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:32:01 -0500 Subject: [PATCH 11/16] docs: sync every guidance surface with the 0.2.0 API reality The review found the documentation tree contradicting the shipped surface on every axis this branch changed. Fixed across 16 files: - CONTRIBUTING/AGENTS/GEMINI/.specify templates: the byte-payload house rule inverted to okio.ByteString (kotlinx-io is deliberately not a dependency; now also detekt-banned). - SPEC.md v2.2 -> v2.3: AutoCloseable/close() removed, okio vocabulary, send overload, sendText signature, new MeshEvent/SendFailure variants, nodes-flow onSubscription seeding, storage passkey methods marked host-facing, decision-log reversal entries appended with rationale. - ADR-003 + ADR-000/001/006: superseded-by notes appended (history preserved, not rewritten). - api-reference.md: Frame signature, new sendText row, RadioClient {} factory, withConnection, nodeMap()/asNodeMap(), the three new MeshEvent rows, AutoReconnectConfig since-tag corrected to 0.1.0. - error-taxonomy.md: QueueRejected + the QueueStatus/ERRNO namespace section (32/33/34 rejections, 35 = success, 1..31 Routing.Error). - transport-isolation.md: close() -> disconnect() throughout. - README/bom README: transport-tcp is Ktor (not kotlinx-io); proto row is Wire-generated (not kotlinx.serialization); 233-byte payload limit. - security.md: storage at-rest guidance references SQLDelight. - CHANGELOG: restructured per versioning.md with a consolidated '### Breaking' section; adds the testing toFrame() entry and all of this branch's review fixes. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .specify/templates/checklist-template.md | 2 +- .specify/templates/plan-template.md | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 92 +++++++++++-------- CONTRIBUTING.md | 2 +- GEMINI.md | 2 +- README.md | 6 +- bom/README.md | 2 +- docs/SPEC.md | 57 ++++++++---- docs/api-reference.md | 38 ++++++-- docs/architecture/transport-isolation.md | 10 +- docs/decisions/000-charter.md | 2 +- ...001-public-api-uses-generated-protobufs.md | 2 +- docs/decisions/003-tooling.md | 4 + docs/decisions/006-multi-module-rationale.md | 2 +- docs/error-taxonomy.md | 5 + docs/security.md | 2 +- 17 files changed, 151 insertions(+), 81 deletions(-) diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md index c292003..69b2efb 100644 --- a/.specify/templates/checklist-template.md +++ b/.specify/templates/checklist-template.md @@ -51,7 +51,7 @@ - [ ] CHK015 All affected modules compile for: `jvm`, `androidTarget`, `iosArm64`, `iosX64`, `iosSimulatorArm64` - [ ] CHK016 Platform-specific code uses `expect/actual` — not conditional compilation - [ ] CHK017 `Dispatchers.IO` not used from `commonMain` (use per-platform `expect/actual` dispatchers) -- [ ] CHK018 Byte payloads use `kotlinx.io.bytestring.ByteString`, not `okio.ByteString` +- [ ] CHK018 Byte payloads use `okio.ByteString` (kotlinx-io is not a dependency) ## Testing diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index 43cff7e..2f524f0 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -13,7 +13,7 @@ **Language/Version**: Kotlin 2.3.x (KMP), JDK 21 toolchain, JDK 17 bytecode target **Build System**: Gradle with convention plugins (`build-logic/`), axion-release versioning -**Primary Dependencies**: Wire 6 (protobuf), kotlinx-coroutines, kotlinx-io, Kable (BLE), ktor-network (TCP) +**Primary Dependencies**: Wire 6 (protobuf), kotlinx-coroutines, Okio (Wire's runtime), Kable (BLE), ktor-network (TCP) **Storage**: SQLDelight 2.x (`:storage-sqldelight`); interface-based (`StorageProvider`) — consumers may substitute **Testing**: kotlin-test, Turbine (Flow testing), Kotest assertions, coroutines-test; fakes in `:testing` module **Target Platforms**: JVM, Android (minSdk 26), iOS (Arm64, X64, SimulatorArm64) diff --git a/AGENTS.md b/AGENTS.md index 2094fa8..7c38a7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Prefer targeted tasks while iterating, then run `./gradlew check` before finishi - `:core` must depend only on `:proto`. - Do not add transport/storage implementation dependencies into `:core`. - Engine concurrency model is single-writer actor; do not introduce mutex/atomic/synchronized patterns in engine paths (see ADR-002). -- No `java.*` or `android.*` imports in `commonMain`; use `kotlinx-io` for byte payloads and `kotlinx-datetime` for time. +- No `java.*` or `android.*` imports in `commonMain`; use `okio.ByteString` for byte payloads (Wire's runtime type; kotlinx-io is deliberately not a dependency) and `kotlinx-datetime` for time. - Transport-side locks/atomics (lifecycle/handle ownership only) are allowed but MUST carry an inline `// ADR-012: native-handle ownership` or `// ADR-012: lifecycle idempotency` comment; see `docs/decisions/012-transport-threading.md`. ## Public API Rules diff --git a/CHANGELOG.md b/CHANGELOG.md index 77785fc..b8f8258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,53 +9,66 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Added +### Breaking -- **events:** Inbound MQTT client-proxy, XModem, and file-info frames are now surfaced as typed events — `MeshEvent.MqttProxyMessage`, `MeshEvent.XmodemPacket`, `MeshEvent.FileInfo` — instead of being dropped with a `ProtocolWarning`. Outbound counterparts go through `RadioClient.sendRaw(ToRadio(...))`. -- **send:** `SendFailure.QueueRejected(res)` — a firmware `QueueStatus.res != 0` (transmit queue full/rejected) now fast-fails the `MessageHandle` (and any pending admin RPC sharing the wire id) instead of waiting out the full ACK timeout. -- **send:** `RadioClient.sendText(..., replyId)` for threaded replies (`decoded.reply_id` without the emoji flag). -- **engine:** Mesh packets received while the handshake is still in flight (live traffic interleaved with the config/NodeDB drain, including the seeding window) are now buffered (drop-oldest at 64, observable via `PacketsDropped`) and flushed through the normal packet pipeline at Ready — previously they were silently dropped. +Pre-1.0 policy: breaking changes ship in a MINOR bump (0.2.0). Nothing below has ever been +published to Maven Central (0.1.0 was tagged `rc1` only), so these break no external consumer. -### Added (ergonomics) - -- `RadioClient { … }` builder-lambda factory (sugar over `RadioClient.Builder`; Swift callers - keep the builder). -- `RadioClient.withConnection { … }` — connect, run the block, and always disconnect (success, - exception, and cancellation; teardown runs under `NonCancellable`). The structured replacement - for the removed blocking `use { }` idiom. -- `Flow.asNodeMap()` / `RadioClient.nodeMap()` — fold the node delta stream into a - live `Map` (the accumulator every consumer otherwise hand-writes), ready for - `stateIn`. +- **`RadioClient.close()` / `AutoCloseable` removed** — the blocking bridge + (`runBlocking { disconnect() }`) was an ANR/deadlock trap on Android and iOS main threads. + Lifecycle is suspend-only; `withConnection { }` (below) is the structured replacement for + `use { }`. +- **One byte-string vocabulary.** `Frame`, `SessionPasskey`, and the streaming `send` overload + now use `okio.ByteString` — the type Wire-generated proto fields already force into the + surface. `send(portnum, payload: kotlinx.io.Buffer)` is replaced by + `send(portnum, payload: okio.ByteString)`; `kotlinx-io` is no longer a dependency of any + published module (and is now detekt-banned via `ForbiddenImport`). +- **`sendText` parameter order is now `(text, to, channel, replyId)`** — aligned with + `sendReaction(emoji, to, channel, replyId)`. Source-compatible for named/text-only callers; + binary signature changed (value-class mangling). +- **Duplicate typed-decoder family removed** (`decodeAsText`, `decodeAsPosition`, + `decodeAsUser`, `decodeAsNodeInfo`, `decodeAsTelemetry`, `decodeAsRouting`, `decodeAsAdmin`) + — they shadowed the canonical `asText()`/`asPosition()`/… accessors in `PayloadAccessors.kt`. + The generic `MeshPacket.decodeAs(adapter)` escape hatch remains (no portnum guard — for + Paxcount/StoreAndForward-style payloads). +- **New sealed variants break exhaustive `when`s:** `MeshEvent.MqttProxyMessage`, + `MeshEvent.XmodemPacket`, `MeshEvent.FileInfoReceived` (named to avoid shadowing + `org.meshtastic.proto.FileInfo`), and `SendFailure.QueueRejected(res)`. +- **`nodes` flow seeding model:** every subscription now receives a fresh + `NodeChange.Snapshot` first (seeded via `onSubscription` from the engine's current node + map); the engine's SharedFlow no longer keeps a replay slot. Previously a late subscriber + got whatever **delta** happened to be emitted last instead of the Snapshot, leaving + `nodeMap()`-style folds near-empty until the next reconnect. +- **Session passkeys are no longer persisted** — they are per-node and in-memory only. The + local node's passkey is never required (firmware rewrites phone packets to `from = 0`, + which is passkey-exempt) and remote passkeys expire after ~4 minutes, so persistence bought + nothing. `DeviceStorage.saveSessionPasskey`/`loadSessionPasskey` remain in the interface as + host-facing capabilities (like `loadNodes`). -### Removed (API shape) +### Added -- The duplicate typed-decoder family in `PacketDecode.kt` (`decodeAsText`, `decodeAsPosition`, - `decodeAsUser`, `decodeAsNodeInfo`, `decodeAsTelemetry`, `decodeAsRouting`, `decodeAsAdmin`) — - these shadowed the `asText()`/`asPosition()`/… accessors in `PayloadAccessors.kt`. One family - remains, plus the generic `MeshPacket.decodeAs(adapter)` escape hatch for portnums without a - typed accessor. +- **events:** Inbound MQTT client-proxy, XModem, and file-info frames are now surfaced as typed events — `MeshEvent.MqttProxyMessage`, `MeshEvent.XmodemPacket`, `MeshEvent.FileInfoReceived` — instead of being dropped with a `ProtocolWarning`. Outbound counterparts go through `RadioClient.sendRaw(ToRadio(...))`. +- **send:** `SendFailure.QueueRejected(res)` — a firmware `QueueStatus` enqueue rejection now fast-fails the `MessageHandle` (and any pending admin RPC sharing the wire id) instead of waiting out the full ACK timeout. +- **send:** `RadioClient.sendText(..., replyId)` for threaded replies (`decoded.reply_id` without the emoji flag). +- **engine:** Mesh packets received while the handshake is still in flight (live traffic interleaved with the config/NodeDB drain, including the seeding window) are now buffered (drop-oldest at 64, observable via `PacketsDropped`) and flushed through the normal packet pipeline at Ready — previously they were silently dropped. +- **ergonomics:** `RadioClient { … }` builder-lambda factory (sugar over `RadioClient.Builder`; Swift callers keep the builder). +- **ergonomics:** `RadioClient.withConnection(teardownTimeout = 10.seconds) { … }` — connect, run the block, and always disconnect (success, exception, and cancellation; teardown runs under `NonCancellable`, bounded by `teardownTimeout` so a wedged transport cannot pin the caller forever). +- **ergonomics:** `Flow.asNodeMap()` / `RadioClient.nodeMap()` — fold the node delta stream into a live `Map` (the accumulator every consumer otherwise hand-writes), ready for `stateIn`. +- **testing:** `FromRadio.toFrame()` — encode a device-side envelope into a wire `Frame` for use with `FakeRadioTransport.injectFrame` (replaces eight per-test-file copies of the framing helper). ### Fixed -- **engine:** A `want_config_id` retry restarts the firmware's config drain from scratch (PhoneAPI resets its read index), which previously duplicated channels / config sections in the committed `ConfigBundle` and `channels` state. Stage 1 accumulators now replace by key (channel index / config section), latest occurrence wins. +- **remote admin / firmware conformance:** `QueueStatus.res` is decoded in the firmware's **ERRNO namespace**, not `Routing.Error`: `35` (`ERRNO_SHOULD_RELEASE`) is success and now counts as `Sent`; ERRNO rejections (`32` queue-full, `33` no interface, `34` radio disabled) fail pending admin RPCs as `NodeUnreachable`; values `1..31` (genuine `Routing.Error` codes such as `DUTY_CYCLE_LIMIT`) map through the normal routing-error taxonomy. Previously `res = 32` was misread as `BAD_REQUEST`, `33` as `NOT_AUTHORIZED`, and the success code `35` produced a false failure. +- **remote admin:** session passkeys are only latched from **response-shaped** admin messages. Previously a remote node administering *us* would have its request — carrying the passkey *we* issued — latched under the remote's key, poisoning our next RPC to it. +- **remote admin:** the fire-and-forget admin path (`enterDfuMode`, `setTimeOnly`) now posts through the engine actor's inbox; it previously prepared the packet on the caller's coroutine, structurally mutating the actor-owned per-node passkey map (data race under ADR-002). +- **remote admin:** the managed-mode client-side gate now applies only to **local** targets. Firmware rejects only local admin on a managed device (`from == 0` branch); remote targets authorize against their own admin keys — managed-fleet deployments administer remote managed nodes from a managed local node. +- **engine:** a `want_config_id` retry restarts the firmware's config drain from scratch, which previously duplicated channels / config sections in the committed `ConfigBundle` and `channels` state. Stage 1 accumulators now replace by key, latest occurrence wins. - **engine:** `FromRadio.lockdown_status` (protobufs 2.7.25+) was silently dropped — it now surfaces as a structured `ProtocolWarning` until typed lockdown support lands. - -### Removed - -- **`RadioClient.close()` / `AutoCloseable` (breaking):** the blocking `close()` bridge - (`runBlocking { disconnect() }`) was an ANR/deadlock trap on Android and iOS main threads. - Lifecycle is suspend-only — use `try { … } finally { client.disconnect() }`. - -### Changed (API conventions, breaking) - -- **One byte-string vocabulary.** `Frame`, `SessionPasskey`, and the streaming `send` overload - now use `okio.ByteString` — the type Wire-generated proto fields already force into the - surface — instead of `kotlinx.io.bytestring.ByteString`. The `send(portnum, payload: - kotlinx.io.Buffer)` overload is replaced by `send(portnum, payload: okio.ByteString)`. - `kotlinx-io` is no longer a dependency of any published module. -- `docs/api-reference.md` gains an **API conventions** section codifying the proto-exposure, - byte-string, no-blocking-bridge, data-class, and Kotlin/Swift-first policies (Poko migration - for event/result types tracked in the roadmap pending Kotlin 2.3.21 support). +- **engine:** the Stage-1 settle replay no longer drops buffered frames that follow a duplicate Stage-1 completion — the unprocessed remainder is re-buffered for the next settle window. +- **engine:** the seeding window no longer silently drops non-packet `FromRadio` variants — `node_info` merges into the node DB and the rest route through the shared auxiliary handler (client notifications, MQTT proxy, …). +- **engine:** caller-supplied wire-id collisions are now rejected with `SendFailure.IdCollision` at the wire-id key in `dispatchSend`; previously the second send silently overwrote the first handle's bookkeeping, stranding it un-completable forever. +- **engine:** the Stage-2 commit's async storage flush now snapshots the accumulated nodes/channels/heartbeats before launching; it previously iterated live actor-owned collections off-actor (CME risk mis-reported as `StorageDegraded`). +- **transport-ble (Android):** the delayed connection-priority downgrade is now scheduled on a per-connect-cycle scope cancelled at `disconnect()` — a stale 30-second timer from session N can no longer fire mid-handshake of session N+1 (the auto-reconnect path) and defeat the priority boost. ### Changed @@ -63,6 +76,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **remote admin:** Session passkeys are now cached **per node** (each node issues its own in its admin responses; every inbound admin response refreshes the issuer's entry). Previously a single shared slot cross-contaminated concurrent admin sessions against different nodes and stamped the *local* node's passkey onto remote targets. The `SessionKeyExpired` single-shot retry now re-seeds against the *target* node. - **storage:** Documented that `DeviceStorage.loadNodes()` is never called by the engine (node DB reseeds from the handshake); it exists for hosts (offline node access) and tests. - **transport-ble (Android):** `BleTransport(address)` now negotiates the ATT MTU (517) after each link establishment and requests `CONNECTION_PRIORITY_HIGH` for the 30-second handshake window before downgrading to Balanced. Without the MTU request Android stays at the BLE minimum (23) and any ToRadio write over 20 bytes fails. +- **docs:** `docs/api-reference.md` gains an **API conventions** section (proto exposure, byte vocabulary, no blocking bridges, data-class policy, Kotlin/Swift-first interop); SPEC bumped to v2.3 and synced; CONTRIBUTING/AGENTS house rules flipped to the okio vocabulary with superseded-by notes preserved in ADR-003. - **build:** Kable 0.42.0 → 0.43.0 (aligns with Meshtastic-Android). - **build:** Aligned the toolchain with Meshtastic-Android — Kotlin 2.3.21 (SKIE 0.10.12), Wire 6.4.0, Ktor 3.5.0, and stable coroutines 1.11.0 (was 1.11.0-rc02). Validated locally: iOS framework link, jvmTest, and Kotlin ABI check all green. - **docs(SPEC.md):** Bumped spec from v2.1 to v2.2 — full post-audit sync aligning spec with shipped implementation. Key areas synchronized: AdminApi expansion (~15 → ~45 methods), `StoreForwardApi`, presence tracking (`WentOffline`/`CameOnline`), `AutoReconnectConfig`, `CongestionWarning`/`ExternalConfigChange`/`StorageDegraded` MeshEvent variants, send DSL, `connectAndAwaitReady()`, `SessionPasskey`, `ConfigBundle.deviceUIConfig`, `SendFailure.IdCollision`/`AckTimeout`/`HandshakeFailed`, `AdminResult` extensions, `ConnectionState` extensions, `MeshtasticException` context fields, convention plugin + version catalog correction (JVM 17→21, Android SDK→36, Kotlin 2.3.20). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcc8fec..3646a29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -211,7 +211,7 @@ The engine architecture has hard rules enforced by detekt + Gradle (see [ADR-008 1. `:core` depends only on `:proto`. Never on a transport or storage implementation. (The `RadioTransport`, `StorageProvider`, and `DeviceStorage` interfaces live inside `:core` itself — see ADR-006.) 2. No `java.*`, `android.*`, `kotlin.Result` in `commonMain` or public API. 3. No `Mutex` / `atomicfu` / `synchronized` in the engine package — the actor IS the synchronization primitive (see [ADR-002](docs/decisions/002-architecture.md)). -4. Public byte payloads use `kotlinx.io.bytestring.ByteString`, not `okio.ByteString`. +4. Public byte payloads use `okio.ByteString` (Wire's runtime type — already forced into the surface by the generated protos); `kotlinx-io` is deliberately not a dependency. See docs/api-reference.md § API conventions. If you find these rules in your way, that's a design discussion — open an issue first; don't disable the rule. diff --git a/GEMINI.md b/GEMINI.md index 71fd0f9..816ce24 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -57,7 +57,7 @@ The project includes a sample CLI for testing: ### Engineering Standards - **Thread Safety:** Never use `Mutex` or atomics inside the `engine` package; rely on the Actor's single-writer invariant. -- **Platform Limits:** No `java.*` or `android.*` in `commonMain`. Use `kotlinx-io` for byte payloads and `kotlinx-datetime` for time. +- **Platform Limits:** No `java.*` or `android.*` in `commonMain`. Use `okio.ByteString` for byte payloads (Wire's runtime type; kotlinx-io is deliberately not a dependency) and `kotlinx-datetime` for time. - **Documentation:** Every public symbol MUST have a KDoc. Dokka coverage is a CI gate. - **Testing:** - Use `testing/` module fakes (`FakeRadioTransport`, `InMemoryStorage`) for unit tests. diff --git a/README.md b/README.md index c73c91d..ee95de2 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ the integration guide. | `connect()` throws `MeshtasticException.TransportFailure` on TCP | Host unreachable or firmware TCP API disabled | Verify the radio's IP/hostname and that WiFi/Ethernet is enabled. See [TCP setup](docs/integration-guide.md#tcp). | | `connect()` hangs or fails repeatedly on BLE | Device not bonded with the OS | Pair the radio in your OS Bluetooth settings before `connect()`. See [BLE platform requirements](docs/integration-guide.md#ble). | | `JvmSerialPorts.open(...)` throws permission denied | Serial device permission not granted | On Linux, add the user to `dialout`. On Android, request USB permission first. See [Serial (USB)](docs/integration-guide.md#serial-usb). | -| `sendText` returns `SendFailure` immediately on long messages | Payload exceeds the 228-byte text limit | Split the text or send a smaller payload. See [Sending messages](docs/integration-guide.md#7-sending-messages). | +| `sendText` returns `SendFailure` immediately on long messages | Payload exceeds the SDK-enforced 233-byte payload limit (`DATA_PAYLOAD_LEN`) | Split the text or send a smaller payload. See [Sending messages](docs/integration-guide.md#7-sending-messages). | | `connect()` throws `MeshtasticException.HandshakeTimeout` | `want_config_id` reply never arrived from the device | Power-cycle the radio, verify firmware ≥ 2.3, then retry. See [Build a `RadioClient`](docs/integration-guide.md#3-build-a-radioclient). | ```kotlin @@ -223,9 +223,9 @@ authoritative dependency graph. | Module | JVM | Android (`minSdk 26`) | iOS Arm64 | iOS Sim Arm64 | iOS X64 | Notes | |----------------------------|:---:|:---------------------:|:---------:|:-------------:|:-------:|-----------------------------------------------------------------------| | `core` | ✓ | ✓ | ✓ | ✓ | ✓ | Pure-Kotlin engine; no platform deps beyond `:proto`. | -| `proto` | ✓ | ✓ | ✓ | ✓ | ✓ | Generated `kotlinx.serialization` proto types. | +| `proto` | ✓ | ✓ | ✓ | ✓ | ✓ | Wire-generated proto types (`org.meshtastic:protobufs`). | | `transport-ble` | ✓¹ | ✓ | ✓ | ✓ | ✓ | ¹ JVM uses `BlueZ`/`BleZ`-style adapter where available; see module README. | -| `transport-tcp` | ✓ | ✓ | ✓ | ✓ | ✓ | Built on `kotlinx-io` sockets. | +| `transport-tcp` | ✓ | ✓ | ✓ | ✓ | ✓ | Built on Ktor sockets. | | `transport-serial` | ✓ | ✓ | — | — | — | iOS targets compile (empty actuals) but no USB-serial API on iOS. | | `storage-sqldelight` | ✓ | ✓ | ✓ | ✓ | ✓ | SQLDelight native driver on iOS, JDBC on JVM/Android. | | `testing` | ✓ | ✓ | ✓ | ✓ | ✓ | In-memory fakes + `TestClock`; safe to use in `commonTest`. | diff --git a/bom/README.md b/bom/README.md index 754f487..73c6b80 100644 --- a/bom/README.md +++ b/bom/README.md @@ -83,7 +83,7 @@ BOM itself (axion-release-driven from the git tag). Source of truth: | `org.meshtastic:sdk-proto` | `:proto` | Wire-generated protobuf types (re-exported by `:core`). | | `org.meshtastic:sdk-core` | `:core` | `RadioClient`, engine, public API surface. | | `org.meshtastic:sdk-transport-ble` | `:transport-ble` | BLE GATT transport (Kable). Android / iOS / JVM. | -| `org.meshtastic:sdk-transport-tcp` | `:transport-tcp` | TCP/4403 transport (kotlinx-io). All targets. | +| `org.meshtastic:sdk-transport-tcp` | `:transport-tcp` | TCP/4403 transport (Ktor). All targets. | | `org.meshtastic:sdk-transport-serial` | `:transport-serial` | USB-serial transport (jSerialComm + usb-serial-for-android). JVM/Android only. | | `org.meshtastic:sdk-storage-sqldelight` | `:storage-sqldelight` | Persistent `StorageProvider` backed by SQLDelight. | | `org.meshtastic:sdk-testing` | `:testing` | `FakeRadioTransport`, `InMemoryStorage`, `TestClock`. | diff --git a/docs/SPEC.md b/docs/SPEC.md index afbff0c..e0714c5 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1,4 +1,4 @@ -# `meshtastic-sdk` — Implementation Plan (v2.2, post-audit) +# `meshtastic-sdk` — Implementation Plan (v2.3, post-audit) > **Mission.** A drop-in Kotlin Multiplatform SDK that lets any Android / iOS / JVM-desktop app talk to a Meshtastic radio through a clean, modern Kotlin API. > @@ -39,7 +39,7 @@ A faithful Kotlin client of the Meshtastic device PhoneAPI (the host-side wire p ### Hard rules 1. **License: GPL-3.0**, consistent across repo, README, ADRs, and POMs. (Note: this is the same license as `Meshtastic-Android`, which constrains downstream adopters to GPL-compatible terms — accepted trade-off given the project's existing GPL ecosystem.) -2. **No `java.*` or `android.*` in `commonMain`.** Use `kotlinx-io.bytestring` for public byte payloads (Okio is acceptable transport-internally); `kotlinx.coroutines.sync.Mutex` is allowed only outside the engine package (see rule 5 / [ADR-002](./decisions/002-architecture.md)); use atomicfu for atomics and kotlinx-datetime for time. +2. **No `java.*` or `android.*` in `commonMain`.** Use `okio.ByteString` for public byte payloads — it is Wire's runtime type and the single byte vocabulary; `kotlinx-io` is deliberately not a dependency (reversed in 0.2.0; see §11); `kotlinx.coroutines.sync.Mutex` is allowed only outside the engine package (see rule 5 / [ADR-002](./decisions/002-architecture.md)); use atomicfu for atomics and kotlinx-datetime for time. 3. **No `kotlin.Result` in any public API** (poor Swift bridging). 4. **Public API uses three response shapes deliberately:** - `suspend fun` *throwing* sealed `MeshtasticException` — for fatal/programmer errors and transport unreachable @@ -155,7 +155,7 @@ MQTTastic has 2 transports (TCP, WS) over a single Ktor dependency, with no plat > **ADR-001:** the SDK exposes Wire-generated protobuf types directly. Anywhere the API below references a protocol payload (`MeshPacket`, `NodeInfo`, `Config`, `User`, `Channel`, `Position`, `Telemetry`, `AdminMessage`, `PortNum`, etc.), that is the **Wire-generated type from `org.meshtastic.proto.*`**, not a hand-rolled mirror. The SDK curates only what does not exist in the proto schema (lifecycle, transport, IDs, send tracking). ```kotlin -public class RadioClient internal constructor(/* ... */) : AutoCloseable { +public class RadioClient internal constructor(/* ... */) { public val connection: StateFlow @@ -196,8 +196,12 @@ public class RadioClient internal constructor(/* ... */) : AutoCloseable { /** Idempotent; never throws. */ public suspend fun disconnect() - /** Blocking close for AutoCloseable / use {} conformance. Delegates to disconnect via runBlocking. */ - override fun close() + // Lifecycle is suspend-only: there is deliberately NO AutoCloseable/close() blocking bridge + // (blocking teardown invites ANR on Android main / deadlock on iOS main). For scoped use, the + // `suspend fun RadioClient.withConnection(teardownTimeout: Duration = 10.seconds, + // block: suspend RadioClient.() -> T): T` extension connects, runs the block, and always + // disconnects (success, exception, or cancellation; teardown bounded by the timeout under + // NonCancellable). /** * Enqueue an outbound packet. Returns immediately with a handle whose [MessageHandle.state] @@ -209,8 +213,8 @@ public class RadioClient internal constructor(/* ... */) : AutoCloseable { @Throws(MeshtasticException::class) public fun send(packet: MeshPacket): MessageHandle - /** Convenience: wraps text in TEXT_MESSAGE_APP. */ - public fun sendText(text: String, channel: ChannelIndex = ChannelIndex(0), to: NodeId = NodeId.BROADCAST): MessageHandle + /** Convenience: wraps text in TEXT_MESSAGE_APP. Parameter order aligned with sendReaction. */ + public fun sendText(text: String, to: NodeId = NodeId.BROADCAST, channel: ChannelIndex = ChannelIndex(0), replyId: Int = 0): MessageHandle /** Convenience: send an emoji reaction to an existing message. */ public fun sendReaction(emoji: String, to: NodeId = NodeId.BROADCAST, channel: ChannelIndex = ChannelIndex(0), replyId: Int): MessageHandle @@ -218,8 +222,8 @@ public class RadioClient internal constructor(/* ... */) : AutoCloseable { /** Typed send overload: build Data(portnum, payload) and call send(). */ public suspend fun send(portnum: PortNum, payload: ByteArray, to: NodeId = NodeId.BROADCAST, channel: ChannelIndex = ChannelIndex(0), wantAck: Boolean = false, hopLimit: Int? = null): MessageHandle - /** Buffer payload overload (consumes the kotlinx.io.Buffer). */ - public suspend fun send(portnum: PortNum, payload: kotlinx.io.Buffer, ...): MessageHandle + /** ByteString payload overload (okio.ByteString — the single public byte vocabulary). */ + public suspend fun send(portnum: PortNum, payload: okio.ByteString, ...): MessageHandle /** Low-level escape hatch: send a raw ToRadio frame directly. */ public fun sendRaw(frame: ToRadio) @@ -329,6 +333,7 @@ public sealed interface SendFailure { public data object Cancelled : SendFailure // MessageHandle.cancel() called pre-Sent public data object IdCollision : SendFailure // duplicate packet id rejected public data object AckTimeout : SendFailure // per-send ACK timeout (unicast want_ack only) + public data class QueueRejected(val res: Int) : SendFailure // QueueStatus.res != 0 (and != 35) — firmware transmit queue rejected the packet before transmission; res is the firmware ERRNO namespace (32=queue full, 33=no interfaces, 34=disabled; 35 means success), 1..31 are genuine Routing.Error codes public data class Other(val routingError: Routing.Error) : SendFailure // Wire enum from :proto public data class Unknown(val message: String) : SendFailure } @@ -361,6 +366,12 @@ public sealed interface MeshEvent { public data class CongestionWarning(val metrics: CongestionMetrics) : MeshEvent /** External client modified channels/config on this device (unsolicited admin push). */ public data class ExternalConfigChange(val kind: ExternalChangeKind) : MeshEvent + /** Device-as-MQTT-proxy side-channel traffic (FromRadio.mqttClientProxyMessage). */ + public data class MqttProxyMessage(val message: MqttClientProxyMessage) : MeshEvent + /** Raw XModem file-transfer packet from the device. */ + public data class XmodemPacket(val packet: XModem) : MeshEvent + /** File advertisement from the device. Named FileInfoReceived (not FileInfo) to avoid clashing with the proto type. */ + public data class FileInfoReceived(val info: org.meshtastic.proto.FileInfo) : MeshEvent } public enum class DroppedFlow { Packets, Events } @@ -369,8 +380,10 @@ public enum class ExternalChangeKind { CHANNEL, CONFIG, MODULE_CONFIG } public sealed interface NodeChange { /** * First emission to every new subscriber; never emitted again on the same subscription. - * Implemented by replaying the engine's current `Map` on `collect()` (single-replay), - * then stitching live deltas. + * Implemented per-subscription via `onSubscription`: each collector is seeded with a Snapshot + * built from the engine's current `Map` (the engine's backing SharedFlow has + * `replay = 0`), then stitched with live deltas. The handshake additionally emits a live + * Snapshot to all subscribers at Stage-2 commit. */ public data class Snapshot(val nodes: Map) : NodeChange public data class Added(val node: NodeInfo) : NodeChange @@ -645,7 +658,11 @@ public interface DeviceStorage : AutoCloseable { public suspend fun clear() override fun close() - /** Persist / load the session passkey for admin RPC resumption across sessions. */ + /** + * Host-facing persist / load of a session passkey. Since 0.2.0 the engine no longer calls + * these — admin session passkeys are per-node, have a 4-minute TTL, and are held in memory + * only. Like `loadNodes`, these remain on the interface for host use. + */ public suspend fun saveSessionPasskey(passkey: SessionPasskey) public suspend fun loadSessionPasskey(): SessionPasskey? @@ -663,9 +680,9 @@ public data class ConfigBundle( public val deviceUIConfig: DeviceUIConfig? = null, ) -/** Persisted session passkey with absolute expiry. */ +/** Session passkey with absolute expiry. */ public data class SessionPasskey( - public val bytes: kotlinx.io.bytestring.ByteString, + public val bytes: okio.ByteString, public val expiresAtEpochMs: Long, ) ``` @@ -701,7 +718,7 @@ public sealed interface TransportState { public data class Error(val cause: Throwable, val recoverable: Boolean) : TransportState } -public class Frame(public val bytes: ByteString) // kotlinx.io.bytestring.ByteString (matches §5.4 + MQTTastic-Client-KMP) +public data class Frame(public val bytes: ByteString) // okio.ByteString — the single public byte vocabulary (see §11 v2.3) ``` ### 3.5 Send DSL @@ -817,7 +834,7 @@ These are the protocol behaviors any working Meshtastic client must honor. They - **One transport-observer coroutine** collects `transport.state` and posts `EngineMessage.TransportStateChanged` to the inbox for disconnect/error detection. - **Timer coroutines** (heartbeat, liveness, presence check, reconnect backoff) post tick messages into the inbox; all mutations happen on the engine coroutine — timers never mutate state directly. - Public `Flow`s are backed by engine-owned writable shared state. Per-flow buffering policy: - - `nodes: Flow` — replay snapshot once on subscribe, then `extraBufferCapacity = 256` with `SUSPEND` overflow (deltas MUST NOT drop; the snapshot is the consistency anchor). + - `nodes: Flow` — each subscription is seeded with a `NodeChange.Snapshot` of the engine's current node map via `onSubscription` (the engine's backing SharedFlow has `replay = 0`), then `extraBufferCapacity = 256` with `SUSPEND` overflow (deltas MUST NOT drop; the snapshot is the consistency anchor). The handshake still emits a live Snapshot at Stage-2 commit. - `packets: Flow` — `extraBufferCapacity = 128` with `SUSPEND` overflow (chat/text loss is unacceptable). Slow consumers backpressure the engine; if the engine inbox fills, the engine emits `MeshEvent.PacketsDropped(Packets, count)` and drops the *oldest engine-side queued frame* rather than blocking the transport reader. This converts silent loss into an observable event. - `events: Flow` — `extraBufferCapacity = 64`, `DROP_OLDEST` (events are advisory; never block the engine on observability). Drop bursts surface as `PacketsDropped(Events, n)` on the next event after pressure clears. - `connectionState`, `ownNode`, `configBundleState`, `channelsState` — `MutableStateFlow` (conflate, never drop). @@ -869,7 +886,7 @@ Module Graph Assert (graph complexity grows), Dependency Analysis Plugin (leaky ### 5.4 Reject -- ~~`kotlinx-io` — Okio is sufficient; don't ship two IO abstractions~~ **REVERSED:** MQTTastic-Client-KMP uses `kotlinx-io-bytestring` for payloads. Align — use `kotlinx-io` for bytes/buffers in commonMain to match the org's house style. Okio remains acceptable for transport-internal IO where it's already idiomatic (Ktor sockets), but public payload types are `kotlinx.io.bytestring.ByteString`. +- ~~`kotlinx-io` — Okio is sufficient; don't ship two IO abstractions~~ **REVERSED:** MQTTastic-Client-KMP uses `kotlinx-io-bytestring` for payloads. Align — use `kotlinx-io` for bytes/buffers in commonMain to match the org's house style. Okio remains acceptable for transport-internal IO where it's already idiomatic (Ktor sockets), but public payload types are `kotlinx.io.bytestring.ByteString`. **REVERSED AGAIN (0.2.0):** Wire protos force `okio.ByteString` into the public surface for every proto-typed call (both this SDK and Meshtastic-Android consume the same protobufs artifact); a second vocabulary bought nothing — okio standardized, kotlinx-io removed. - In-memory `StorageProvider` as default — violates §4.3 invariant 4 - `kotlin.Result` in any public API — Swift doesn't bridge it @@ -1101,6 +1118,10 @@ After each phase: ## 11. What Changed From v1 (for reviewers) +### v2.3 (2026-06-11) — 0.2.0 API-state sync + +Synchronizes the spec with the 0.2.0 surface: `okio.ByteString` is the single public byte vocabulary (`kotlinx-io` removed — Wire protos force `okio.ByteString` into the public surface for every proto-typed call, and both this SDK and Meshtastic-Android consume the same protobufs artifact, so a second vocabulary bought nothing); lifecycle is suspend-only (no `AutoCloseable`/`close()`; `withConnection { }` helper added); `sendText(text, to, channel, replyId)` parameter order; new `MeshEvent` variants (`MqttProxyMessage`, `XmodemPacket`, `FileInfoReceived`) and `SendFailure.QueueRejected`; `nodes` snapshots seeded per-subscription via `onSubscription` (engine flow `replay = 0`); `DeviceStorage` passkey methods are host-facing only. + ### v2.2 (2026-05-10) — Post-audit sync Full audit of implementation vs spec revealed significant drift. This revision synchronizes the spec with the shipped codebase: @@ -1142,7 +1163,7 @@ The Meshtastic org shipped [`MQTTastic-Client-KMP`](https://github.com/meshtasti | Phase −1 governance as blocker | Resolved; ADR + cross-link issue only | Org already accepts KMP libs | | Phase 0 build-logic from scratch | **Forked verbatim from MQTTastic** (Spotless config, Detekt config, CI workflows, AGENTS.md pattern, Develocity, foojay, vanniktech `publishToMavenCentral()`) | Don't re-derive solved problems | | Spotless + ktfmt | Spotless + **ktlint** + `licenseHeaderFile` | Match MQTTastic | -| `kotlinx-io` rejected | `kotlinx-io-bytestring` adopted for payloads | Match MQTTastic house style | +| `kotlinx-io` rejected | `kotlinx-io-bytestring` adopted for payloads | Match MQTTastic house style. **Reversed in 0.2.0 (see v2.3 above):** Wire protos force `okio.ByteString` into the public surface for every proto-typed call (both this SDK and Meshtastic-Android consume the same protobufs artifact); a second vocabulary bought nothing — okio standardized, kotlinx-io removed | | BCV gate from Phase 5 | BCV gate from Phase 0 (`checkKotlinAbi` + initial `updateKotlinAbi`) | MQTTastic enforces day-1; cheap to do same | | Kover not specified | Kover with `minBound(80)` from Phase 0 | Match MQTTastic | | Arch-test framework "adopt only when justified" | detekt `ForbiddenImport` + `:core:verifyModuleBoundary` from Phase 0 (an arch-test library was evaluated and dropped — see [ADR-008](decisions/008-architecture-enforcement.md)) | MQTTastic ships an arch-test library; we get the same invariants from tools we already run | diff --git a/docs/api-reference.md b/docs/api-reference.md index b1f6634..8b2e9ff 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -32,7 +32,8 @@ Deliberate, enforced choices about the shape of the public surface: introducing a second vocabulary. `kotlinx-io` is deliberately **not** a dependency. - **No blocking bridges.** Lifecycle is suspend-only (`connect()`/`disconnect()`); there is deliberately no `AutoCloseable`/`close()` on `RadioClient`. Blocking teardown invites ANR on - Android main and deadlock on iOS main. + Android main and deadlock on iOS main. For scoped use, the `withConnection(teardownTimeout) { … }` + extension (see [Lifecycle](#lifecycle)) is the structured replacement for `use { }`. - **Data classes in the public API are a deliberate trade.** Event/result types are `data` so consumers get structural equality in tests and exhaustive `when` matching. The ABI cost (`copy`/`componentN` lock-in) is mitigated by the pre-1.0 window, the CI-gated Kotlin ABI @@ -79,7 +80,19 @@ RadioClient.Builder() `Builder.transport(spec: TransportSpec)` is also available for spec-driven setups, but you must additionally provide a concrete `RadioTransport` — see `samples/cli` for a `TransportSpec → RadioTransport` opener pattern. -#### `AutoReconnectConfig` *(since 0.2.0)* +Kotlin callers can use the `RadioClient { … }` builder-lambda factory instead *(since 0.2.0)*: + +```kotlin +val client = RadioClient { + transport(TcpTransport(host = "meshtastic.local", port = 4403)) + storage(SqlDelightStorageProvider(baseDir = "/var/data")) + logger(MyLogSink) +} +``` + +`Builder` remains available for Swift and other step-wise callers. + +#### `AutoReconnectConfig` *(since 0.1.0)* ```kotlin public data class AutoReconnectConfig( @@ -100,6 +113,7 @@ When enabled, the engine automatically reconnects on transport drops, transition | `connect()` | `Unit` (suspends until `Connected`) | `MeshtasticException` (`AlreadyConnected`, `Transport`, `Protocol`, `StorageUnavailable`, `HandshakeTimeout`, `FirmwareTooOld`), `CancellationException` | **Not** idempotent — calling `connect()` while already `Connected` throws `AlreadyConnected` (deliberate; silent no-op hides reconnect-loop bugs). | | `connectAndAwaitReady()` | `Unit` (suspends until `Connected` with session passkey seeded) | same as `connect()` | Convenience wrapper: calls `connect()`, then awaits the post-handshake admin session passkey seed. Prefer this unless you need to observe handshake progress. | | `disconnect()` | `Unit` | never | Idempotent. Cancels supervisor; resolves all open `MessageHandle`s to `Failed(Disconnected)`. | +| `withConnection(teardownTimeout: Duration = 10.seconds) { … }` *(extension, since 0.2.0)* | `T` (the block's result) | rethrows from `connect()` / block | `suspend fun RadioClient.withConnection(teardownTimeout: Duration = 10.seconds, block: suspend RadioClient.() -> T): T`. Connects, runs `block`, and **always** disconnects — on success, exception, or cancellation. Teardown runs under `NonCancellable`, bounded by `teardownTimeout`. The structured replacement for the deliberately absent `close()`/`use { }`. | | `connection: StateFlow` | — | — | Conflated. Ordering: `Disconnected → Connecting → Configuring* → Connected`; on drop: `Connected → Reconnecting → Connecting → …`. | | `ownNode: StateFlow` | — | — | `null` until handshake completes. After: always populated; updated on `node_info` for our own `NodeNum`. | | `configBundle: StateFlow` | — | — | `null` until Stage 1 completes. Contains `MyNodeInfo`, `DeviceMetadata`, configs, module configs, and `DeviceUIConfig`. Updated on unsolicited config pushes. | @@ -109,16 +123,25 @@ When enabled, the engine automatically reconnects on transport drops, transition | Member | Backing | Buffer / overflow | |---|---|---| -| `nodes: Flow` | custom snapshot+delta | 256 / `SUSPEND` | +| `nodes: Flow` | snapshot+delta over `MutableSharedFlow(replay=0)` | 256 / `SUSPEND` | +| `nodeMap(): Flow>` *(since 0.2.0)* | folds `nodes` | same as `nodes` | | `packets: Flow` | `MutableSharedFlow(replay=0)` | 128 / `SUSPEND` | | `events: Flow` | `MutableSharedFlow(replay=0)` | 64 / `DROP_OLDEST` | +`nodes` seeds **each** subscription with a `NodeChange.Snapshot` of the engine's current node +map (via `onSubscription`) before deltas — late subscribers never miss the baseline even though +the engine's backing `SharedFlow` has `replay = 0`. The handshake additionally emits a live +`Snapshot` to all subscribers at Stage-2 commit. `nodeMap()` *(since 0.2.0)* folds `nodes` into +a flow of the full `Map`; the underlying +`Flow.asNodeMap()` extension is also public for transforming an existing +`nodes` collection. + ### Outbound | Member | Returns | Throws | |---|---|---| | `send(packet: MeshPacket): MessageHandle` | handle (state already `Queued`) | `NotConnected`, `PayloadTooLarge` | -| `sendText(text: String, channel: ChannelIndex = ChannelIndex(0), to: NodeId = NodeId.BROADCAST): MessageHandle` | handle | same as `send` | +| `sendText(text: String, to: NodeId = NodeId.BROADCAST, channel: ChannelIndex = ChannelIndex(0), replyId: Int = 0): MessageHandle` | handle | same as `send` | | `sendReaction(emoji: String, to: NodeId = NodeId.BROADCAST, channel: ChannelIndex = ChannelIndex(0), replyId: Int): MessageHandle` | handle | same as `send` | | `sendRaw(frame: ToRadio)` | `Unit` | `NotConnected` | | `requestNodeInfo(node: NodeId): AdminResult` | result | `NotConnected` | @@ -208,6 +231,9 @@ public sealed interface MeshEvent { public data class DeviceRebooted(val rebootCount: Int) : MeshEvent public data class CongestionWarning(val freeSlots: Int, val totalSlots: Int) : MeshEvent public data class ExternalConfigChange(val config: Config) : MeshEvent + public data class MqttProxyMessage(val message: MqttClientProxyMessage) : MeshEvent + public data class XmodemPacket(val packet: XModem) : MeshEvent + public data class FileInfoReceived(val info: org.meshtastic.proto.FileInfo) : MeshEvent public sealed interface SecurityWarning : MeshEvent { public data class DuplicatedPublicKey(val nodeId: NodeId) : SecurityWarning public data class LowEntropyKey(val nodeId: NodeId) : SecurityWarning @@ -216,7 +242,7 @@ public sealed interface MeshEvent { public enum class DroppedFlow { Packets, Events } ``` -`ProtocolWarning` is non-fatal advisory ("skipped malformed envelope", etc.); the optional `details` map carries structured context (e.g., `"packet_id" to "0x1234"`). `IdentityRebound` is the dedicated signal for the engine clearing storage after a NodeNum change (see [storage.md §"Consumer-observable signal (R-9)"](./architecture/storage.md#consumer-observable-signal-r-9)). `PacketsDropped` is the only observability hook for backpressure-induced loss; emitted at most once per drop burst. `StorageDegraded` fires when the storage backend transitions to read-through-only mode. `CongestionWarning` fires when the device's outbound queue is critically full. `ExternalConfigChange` fires when an unsolicited admin message from firmware updates the local config state. `SecurityWarning.*` flag suspect key material observed in inbound `node_info` payloads. +`ProtocolWarning` is non-fatal advisory ("skipped malformed envelope", etc.); the optional `details` map carries structured context (e.g., `"packet_id" to "0x1234"`). `IdentityRebound` is the dedicated signal for the engine clearing storage after a NodeNum change (see [storage.md §"Consumer-observable signal (R-9)"](./architecture/storage.md#consumer-observable-signal-r-9)). `PacketsDropped` is the only observability hook for backpressure-induced loss; emitted at most once per drop burst. `StorageDegraded` fires when the storage backend transitions to read-through-only mode. `CongestionWarning` fires when the device's outbound queue is critically full. `ExternalConfigChange` fires when an unsolicited admin message from firmware updates the local config state. `SecurityWarning.*` flag suspect key material observed in inbound `node_info` payloads. *(since 0.2.0)* `MqttProxyMessage` carries the device's `mqttClientProxyMessage` traffic for hosts implementing the device-as-MQTT-proxy side channel; `XmodemPacket` carries raw `XModem` file-transfer packets; `FileInfoReceived` carries an `org.meshtastic.proto.FileInfo` advertisement (the variant is named `FileInfoReceived` — not `FileInfo` — to avoid clashing with the proto type). ## `NodeChange` @@ -607,7 +633,7 @@ public sealed interface TransportState { public data class Error(val cause: Throwable, val recoverable: Boolean) : TransportState } -public class Frame(public val bytes: kotlinx.io.bytestring.ByteString) +public data class Frame(public val bytes: okio.ByteString) ``` ## Logging diff --git a/docs/architecture/transport-isolation.md b/docs/architecture/transport-isolation.md index a3d5184..1f38da3 100644 --- a/docs/architecture/transport-isolation.md +++ b/docs/architecture/transport-isolation.md @@ -65,17 +65,17 @@ All emit `TransportState.Error(recoverable: Boolean)` to the engine inbox. The e - Outbound writer drains `outbound: Channel` and calls `transport.send(frame)` sequentially. 4. **On disconnect**: - - Engine's `finally` block calls `transport.close()`. + - Engine's `finally` block calls `transport.disconnect()`. - SupervisorJob cancellation propagates; frame reader and outbound writer exit. ### Ownership and cleanup -- The **engine owns** the `RadioTransport` instance and is responsible for `close()` on shutdown. +- The **engine owns** the `RadioTransport` instance and is responsible for `disconnect()` on shutdown. - The **transport owns** any platform-specific resources (BLE GATT connection, TCP socket, serial port file descriptor). -- On `RadioClient.close()`: +- On `RadioClient.disconnect()`: 1. SupervisorJob is cancelled. 2. Frame reader and outbound writer exit (CancellationException caught and re-thrown). - 3. Engine's `finally` calls `transport.close()` (must not throw). + 3. Engine's `finally` calls `transport.disconnect()` (must not throw). 4. Storage is flushed and closed. ## Adding a new transport @@ -88,7 +88,7 @@ All emit `TransportState.Error(recoverable: Boolean)` to the engine inbox. The e override val state: StateFlow = /* observe your platform's connection state */ override val identity: TransportIdentity = /* e.g., url, port, device ID */ override suspend fun connect() { /* open your resource */ } - override suspend fun close() { /* release your resource */ } + override suspend fun disconnect() { /* release your resource */ } override fun frames(): Flow { /* collect inbound frames */ } override suspend fun send(frame: Frame) { /* write to wire */ } } diff --git a/docs/decisions/000-charter.md b/docs/decisions/000-charter.md index 1a62d60..a820db1 100644 --- a/docs/decisions/000-charter.md +++ b/docs/decisions/000-charter.md @@ -50,7 +50,7 @@ MVP: `androidTarget()`, `jvm()`, `iosArm64()`, `iosX64()`, `iosSimulatorArm64()` | `meshtastic/firmware` | **Read-only behavior reference.** Ground truth when Apple and Android disagree. | We do not embed firmware code, schemas other than `protobufs`, or build artifacts. | | `meshtastic/Meshtastic-Android` | **Cross-validation reference.** We consult its `core/network` for how the canonical Android client implements the protocol. As an org-internal codebase we may also lift idiomatic snippets directly. | We do not depend on it; it does not depend on us (yet). | | `meshtastic/Meshtastic-Apple` | **Cross-validation reference**, especially for transport `requiresPeriodicHeartbeat` semantics, accessory framing, and the canonical Swift bridging surface. | Same as above. | -| `meshtastic/mqtt-client` | **Sibling KMP library.** Establishes the house style we follow (Kotlin version, Wire 6, kotlinx-io, Ktor, axion-release; see ADR-003). | We do not implement broker-side MQTT; consumers depending on broker-side MQTT add `mqtt-client` themselves. | +| `meshtastic/mqtt-client` | **Sibling KMP library.** Establishes the house style we follow (Kotlin version, Wire 6, kotlinx-io *(byte-string alignment since reversed in 0.2.0 — `okio.ByteString`; see ADR-003 superseded notes)*, Ktor, axion-release; see ADR-003). | We do not implement broker-side MQTT; consumers depending on broker-side MQTT add `mqtt-client` themselves. | ## Alternatives considered diff --git a/docs/decisions/001-public-api-uses-generated-protobufs.md b/docs/decisions/001-public-api-uses-generated-protobufs.md index f269d93..2ee780d 100644 --- a/docs/decisions/001-public-api-uses-generated-protobufs.md +++ b/docs/decisions/001-public-api-uses-generated-protobufs.md @@ -38,7 +38,7 @@ The SDK is a *client library wrapped around* the protobufs, not a *translation l - `MessageHandle` — handle returned from `send()` for tracking lifecycle - `NodeChange` — delta events (`Snapshot`, `Added`, `Updated`, `Removed`) wrapping `NodeInfo` - Value-class IDs: `NodeId`, `ChannelIndex`, `MessageId` — type-safe wrappers around the `Int`/`UInt32` fields used as IDs, used in *operation signatures* (e.g., `sendText(to: NodeId, …)`), not as replacements for protobuf fields -- High-level send helpers: `sendText(text, channel, to)`, `requestPosition(node)`, `traceRoute(dest)` — convenience over `send(packet: MeshPacket)` +- High-level send helpers: `sendText(text, to, channel, replyId)`, `requestPosition(node)`, `traceRoute(dest)` — convenience over `send(packet: MeshPacket)` ### What the SDK does NOT do diff --git a/docs/decisions/003-tooling.md b/docs/decisions/003-tooling.md index 8f72e29..4981571 100644 --- a/docs/decisions/003-tooling.md +++ b/docs/decisions/003-tooling.md @@ -55,6 +55,8 @@ We considered `protobuf-kotlin` (Google's Java/Kotlin codegen) and rejected it f | `okio.ByteString` / `okio.Buffer` | transport-internal IO only | Kept for Ktor socket interop where it's idiomatic. Never crosses the public API. | | `kotlinx.atomicfu` | engine-internal counters (request_id, heartbeat nonce) | Single-writer guarantee documented at each call site. | +> **Superseded (0.2.0):** The byte-string rows above were reversed in 0.2.0 — `okio.ByteString` is the **single** public byte vocabulary, because Wire's generated protos already expose it on every proto-typed call; `kotlinx-io` was removed entirely and is no longer a dependency of any module. `Frame.bytes` and every public byte-payload field is `okio.ByteString`. + We do not allow `java.util.concurrent`, `java.util.Date`, or `android.os.*` in `commonMain` — this is enforced by detekt's `ForbiddenImport` rules in `config/detekt/detekt.yml` (see ADR-008). ### Transports (MVP) @@ -129,6 +131,7 @@ Storage is **required** at `Builder.build()` time — no in-memory default in `: - **`protobuf-kotlin` instead of Wire.** Rejected per ADR-001 + worse multiplatform story. - **`kotlinx.serialization` for protobuf.** Rejected — the `kotlinx-serialization-protobuf` runtime decodes from a manual `@Serializable` schema; we lose the `meshtastic/protobufs` schema as the source of truth. - **Okio for public payloads.** Rejected — diverges from `mqtt-client` and the broader `kotlinx-io` direction. Kept for transport-internal use. + > **Superseded (0.2.0):** This rejection was itself reversed — `okio.ByteString` is now the public payload type (Wire's generated protos force it into the surface anyway), and `kotlinx-io` was removed entirely. See the note in "IO, time, and concurrency primitives" above. - **Kermit for logging.** Considered. We instead expose a `LogSink` interface and ship `Kermit` only as a sample binding under `:samples`. Production hosts likely already have a logger; we won't dictate. - **Koin for DI.** Rejected — SDK does not use DI internally; consumers wire whatever they want. - **JReleaser.** Considered for release; `axion-release` matches the org's other libs, prefer consistency. @@ -139,4 +142,5 @@ Storage is **required** at `Builder.build()` time — no in-memory default in `: - **Wire 6 lock.** Bumping major Wire is a breaking change for proto consumers; treated as a SemVer-major event. - **No `java.*` in `commonMain` is enforced, not aspirational.** Detekt's `ForbiddenImport` rule fails the build if a `java.util.UUID` import sneaks in. - **Two ByteString types in the codebase** (kotlinx-io public, Okio internal). Reviewers must keep public surface kotlinx-io. KGP `checkKotlinAbi` plus detekt's `ForbiddenImport` covers the `:core` public symbols. + > **Superseded (0.2.0):** There is now **one** ByteString type — `okio.ByteString` everywhere, public and internal; `kotlinx-io` was removed entirely. The detekt `ForbiddenImport` rules now ban `kotlinx.io.*` imports instead (rule added in 0.2.0). - **`wasmJs` is post-1.0.** ADR-006 details the MVP per-target matrix and the future wasm posture. diff --git a/docs/decisions/006-multi-module-rationale.md b/docs/decisions/006-multi-module-rationale.md index 0254521..2df72c4 100644 --- a/docs/decisions/006-multi-module-rationale.md +++ b/docs/decisions/006-multi-module-rationale.md @@ -103,7 +103,7 @@ When the roadmap lands, `:core` adds a `wasmJs` source set containing only RPC-c - Only `:transport-rpc` (and optionally `:transport-http`, `:transport-mqtt-proxy`) carry `wasmJs` source sets. - `:transport-ble`, `:transport-tcp`, `:transport-serial-*`, and `:storage-sqldelight` will not target `wasmJs`. -Implication for MVP design: even though `wasmJs` is deferred, `:core`'s `commonMain` should avoid APIs that lack a `wasmJs` implementation in `kotlinx-coroutines` / `kotlinx-io` / `kotlinx-datetime`, so the future addition is non-breaking. We pin compatible versions in `libs.versions.toml`. +Implication for MVP design: even though `wasmJs` is deferred, `:core`'s `commonMain` should avoid APIs that lack a `wasmJs` implementation in `kotlinx-coroutines` / `kotlinx-datetime` / Okio, so the future addition is non-breaking. We pin compatible versions in `libs.versions.toml`. ### Versioning diff --git a/docs/error-taxonomy.md b/docs/error-taxonomy.md index 9cb9fed..2243015 100644 --- a/docs/error-taxonomy.md +++ b/docs/error-taxonomy.md @@ -64,6 +64,7 @@ public sealed interface SendFailure { public data object IdCollision : SendFailure // outbound packet id clashed with in-flight public data object AckTimeout : SendFailure // ACK never observed within Builder.sendTimeout public data object HandshakeFailed : SendFailure // send attempted while handshake unwinding + public data class QueueRejected(val res: Int) : SendFailure // QueueStatus.res != 0 (and != 35) — firmware transmit queue rejected the packet before transmission public data class Other(val routingError: Routing.Error) : SendFailure public data class Unknown(val message: String) : SendFailure } @@ -96,6 +97,10 @@ The Wire-generated `Routing.Error` enum (from `meshtastic/protobufs:mesh.proto`) `SendFailure.Unknown` is reserved for engine-internal anomalies (encoded `MeshPacket` with no decoded payload, etc.) and should never appear in production. +### `QueueStatus` → `SendFailure.QueueRejected` mapping + +`QueueStatus`-driven failures are different from `Routing.Error` NAKs: a non-zero `QueueStatus.res` means the **local** device's transmit queue rejected the packet before it was ever transmitted, and the value lives in the firmware's ERRNO namespace, not the `Routing.Error` enum. In that namespace `32` = unknown/queue full, `33` = no interfaces, `34` = radio disabled, and `35` (`ERRNO_SHOULD_RELEASE`) actually means **success** — the SDK treats `res == 35` as `Sent`, not a failure. Values `1..31` overlap with genuine `Routing.Error` codes (e.g. `DUTY_CYCLE_LIMIT` = 9). The SDK surfaces any rejecting `res` (`!= 0` and `!= 35`) as `SendFailure.QueueRejected(res)`. For in-flight **admin RPCs**, ERRNO rejections (`32..34`) map to `AdminResult.NodeUnreachable`, while `1..31` flow through the normal `Routing.Error` → `AdminResult` mapping below. + ## `AdminResult.Error` ```kotlin diff --git a/docs/security.md b/docs/security.md index ba4b199..315f1d1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -68,7 +68,7 @@ Roadmap transports (`transport-mqtt-proxy`, `transport-rpc`, `transport-http`) c - Wrap their `StorageProvider` with platform-native encrypted storage: - Android: `EncryptedSharedPreferences` / `EncryptedFile` (Jetpack Security). - - iOS: Keychain-backed file or `NSFileProtectionComplete` flags via `kotlinx-io` file backend. + - iOS: Keychain-backed key material or `NSFileProtectionComplete` flags on the SQLDelight database file. - JVM desktop: OS keystore + a KEK that decrypts the SQLDelight DB at startup. - Or, in headless server contexts, run on a filesystem with full-disk encryption. From 5545e0047b5dc977023c0472140f38e41b922955 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:53:07 -0500 Subject: [PATCH 12/16] fix(transport-ble): opt in to ExperimentalUuidApi for the iOS test targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FakePeripheral overrides Peripheral.identifier, which is kotlin.uuid.Uuid on Kable's iOS targets (experimental API) — the override compiled on JVM/Android (String typealias) but failed compileTestKotlinIosX64 / IosSimulatorArm64 in CI's full-check. Co-Authored-By: Claude Fable 5 Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/transport-ble/src/commonTest/kotlin/org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt b/transport-ble/src/commonTest/kotlin/org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt index d6a5eb2..6c28b03 100644 --- a/transport-ble/src/commonTest/kotlin/org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt +++ b/transport-ble/src/commonTest/kotlin/org/meshtastic/sdk/transport/ble/BleTransportHookTest.kt @@ -128,7 +128,9 @@ class BleTransportHookTest { * empty payload (queue drained — satisfies the warmup read), [observe] never emits. * Members [BleTransport] never touches throw [UnsupportedOperationException]. */ -@OptIn(ExperimentalApi::class) +// ExperimentalUuidApi: on iOS targets Kable's `Identifier` is kotlin.uuid.Uuid (experimental); +// the override below trips the opt-in only when compiled for those targets. +@OptIn(ExperimentalApi::class, kotlin.uuid.ExperimentalUuidApi::class) private class FakePeripheral : Peripheral { private val stateFlow = MutableStateFlow(State.Disconnected()) From 15c824ca207cfc35f71ebf97efad7729aeb1b7a6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:05:04 -0500 Subject: [PATCH 13/16] test(transport-ble): add on-device BLE conformance harness androidDeviceTest source set running the cs1-cs6 conformance envelope against a real radio: scan by Meshtastic service UUID (bonded-first, RSSI-sorted, 3-candidate retry), connect through the production BleTransport(address) factory (MTU-517 + connection-priority hook), two-stage handshake, read-only admin RPC round-trips, a 177-byte send to the radio's own node num as MTU proof (no LoRa TX), SQLDelight persistence, and same-transport reconnect. Assume-skips when no radio is advertising so CI never requires hardware. Run with: ./gradlew :transport-ble:connectedAndroidDeviceTest Co-Authored-By: Claude Fable 5 Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- gradle/libs.versions.toml | 4 + transport-ble/build.gradle.kts | 15 ++ .../src/androidDeviceTest/AndroidManifest.xml | 18 ++ .../ble/MeshtasticBleConformanceTest.kt | 227 ++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 transport-ble/src/androidDeviceTest/AndroidManifest.xml create mode 100644 transport-ble/src/androidDeviceTest/kotlin/org/meshtastic/sdk/transport/ble/MeshtasticBleConformanceTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adccc07..5929a4b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,8 @@ turbine = "1.2.1" kotest = "6.1.11" mockk = "1.14.9" junit = "4.13.2" +androidxTestRunner = "1.7.0" +androidxTestRules = "1.7.0" # --- Publishing / iOS distribution --- vanniktechMavenPublish = "0.36.0" @@ -99,6 +101,8 @@ turbine = { module = "app.cash.turbine:turbine", kotestAssertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } junit = { module = "junit:junit", version.ref = "junit" } +androidxTestRunner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +androidxTestRules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } androidxActivityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } lifecycleRuntimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycleCompose" } diff --git a/transport-ble/build.gradle.kts b/transport-ble/build.gradle.kts index 112a08b..116d894 100644 --- a/transport-ble/build.gradle.kts +++ b/transport-ble/build.gradle.kts @@ -12,6 +12,15 @@ plugins { } kotlin { + // Real-hardware conformance tests (androidDeviceTest) — run on demand against an + // advertising Meshtastic radio via `:transport-ble:connectedAndroidTest`. They + // assume-skip when no radio is in range, so device-less runs never fail. + extensions.configure { + withDeviceTest { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + sourceSets.all { } @@ -25,6 +34,12 @@ kotlin { implementation(libs.kotlinTest) implementation(libs.coroutinesTest) } + getByName("androidDeviceTest").dependencies { + implementation(libs.junit) + implementation(libs.androidxTestRunner) + implementation(libs.androidxTestRules) + implementation(project(":storage-sqldelight")) + } } } diff --git a/transport-ble/src/androidDeviceTest/AndroidManifest.xml b/transport-ble/src/androidDeviceTest/AndroidManifest.xml new file mode 100644 index 0000000..460d6e8 --- /dev/null +++ b/transport-ble/src/androidDeviceTest/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/transport-ble/src/androidDeviceTest/kotlin/org/meshtastic/sdk/transport/ble/MeshtasticBleConformanceTest.kt b/transport-ble/src/androidDeviceTest/kotlin/org/meshtastic/sdk/transport/ble/MeshtasticBleConformanceTest.kt new file mode 100644 index 0000000..899a464 --- /dev/null +++ b/transport-ble/src/androidDeviceTest/kotlin/org/meshtastic/sdk/transport/ble/MeshtasticBleConformanceTest.kt @@ -0,0 +1,227 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.transport.ble + +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.juul.kable.Scanner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.meshtastic.sdk.ConnectionState +import org.meshtastic.sdk.MeshEvent +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.SendState +import org.meshtastic.sdk.getOrNull +import org.meshtastic.sdk.isSuccess +import org.meshtastic.sdk.storage.sqldelight.AndroidContextHolder +import org.meshtastic.sdk.storage.sqldelight.SqlDelightStorageProvider + +/** + * Real-hardware BLE conformance for the Android transport path (the cs1–cs6 envelope from + * `samples/cli`, driven on-device): scan by Meshtastic service UUID, connect through the + * production `BleTransport(address)` factory (exercising the MTU-517 + connection-priority + * hook), complete the two-stage handshake, run admin RPC round-trips, prove >20-byte writes + * (MTU negotiation), persist through the real SQLDelight storage, and reconnect. + * + * **Read-only by design**: no config writes, and the send test targets the radio's own node + * number (handled locally by firmware — nothing is transmitted over LoRa). + * + * Skips (does not fail) when no Meshtastic radio is advertising in range. + */ +@OptIn(kotlin.uuid.ExperimentalUuidApi::class) // BleConstants UUIDs are kotlin.uuid.Uuid +class MeshtasticBleConformanceTest { + + @get:Rule + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT, + ) + + @Test + fun conformance_scanConnectAdminSendReconnect() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + AndroidContextHolder.context = context + val storageDir = context.cacheDir.resolve("sdk-conformance").apply { mkdirs() } + + // ── Scan: collect every advertising Meshtastic radio for a window ─── + val candidates = linkedMapOf>() // address -> (name, rssi) + withTimeoutOrNull(SCAN_WINDOW_MS) { + Scanner { + filters { + match { services = listOf(BleConstants.MESH_SERVICE_UUID) } + } + }.advertisements.collect { ad -> + candidates[ad.identifier] = (ad.name ?: candidates[ad.identifier]?.first) to ad.rssi + } + } + assumeTrue("No Meshtastic radio advertising in range — skipping", candidates.isNotEmpty()) + + // Prefer radios already bonded to this phone (no pairing dialog), then strongest RSSI. + val bondedAddresses = context.getSystemService(android.bluetooth.BluetoothManager::class.java) + .adapter.bondedDevices.map { it.address }.toSet() + val ordered = candidates.entries.sortedWith( + compareByDescending>> { it.key in bondedAddresses } + .thenByDescending { it.value.second }, + ) + Log.i( + TAG, + "Candidates: " + ordered.joinToString { + "${it.value.first ?: "?"}@${it.key} rssi=${it.value.second} bonded=${it.key in bondedAddresses}" + }, + ) + + // ── cs1: connect via the production factory (MTU + priority hook) ─── + var transport: BleTransport? = null + var client: RadioClient? = null + var handshakeMs = 0L + val eventScope = CoroutineScope(SupervisorJob()) + val events = mutableListOf() + for ((address, meta) in ordered.take(MAX_CONNECT_CANDIDATES).map { it.key to it.value }) { + Log.i(TAG, "Connecting to ${meta.first ?: "?"} @ $address …") + val attemptTransport = BleTransport(address = address) + val attemptClient = RadioClient { + transport(attemptTransport) + storage(SqlDelightStorageProvider(baseDir = storageDir.absolutePath)) + logger(logcatSink) + protocolLogging(org.meshtastic.sdk.LogLevel.DEBUG) + } + attemptClient.events.onEach { synchronized(events) { events.add(it) } }.launchIn(eventScope) + val startMs = System.currentTimeMillis() + try { + withTimeout(CONNECT_TIMEOUT_MS) { attemptClient.connect() } + handshakeMs = System.currentTimeMillis() - startMs + transport = attemptTransport + client = attemptClient + break + } catch (e: Exception) { + Log.w(TAG, "Connect to $address failed after ${System.currentTimeMillis() - startMs}ms: $e") + runCatching { attemptClient.disconnect() } + attemptTransport.shutdown() + } + } + val connectedEvents = synchronized(events) { events.toList() } + assertNotNull( + "No candidate radio completed the handshake; events=$connectedEvents", + client, + ) + client!! + transport!! + Log.i(TAG, "cs1 handshake completed in ${handshakeMs}ms") + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue("Handshake must complete within 30s (took ${handshakeMs}ms)", handshakeMs < 30_000) + + try { + // ── cs5: handshake state — config, channels, node DB, own node ── + val bundle = checkNotNull(client.configBundle.value) { "configBundle must be populated" } + assertTrue("config sections expected", bundle.configs.isNotEmpty()) + val ownNode = checkNotNull(client.ownNode.value) { "ownNode must be populated" } + val myNum = ownNode.num + val nodes = client.nodeSnapshot() + Log.i( + TAG, + "cs5 nodeDb=${nodes.size} nodes, fw=${bundle.metadata.firmware_version}, " + + "configs=${bundle.configs.size}, channels=${client.channels.value?.size}", + ) + assertTrue("node DB must contain at least the local node", nodes.isNotEmpty()) + assertNotNull("channels must be populated", client.channels.value) + + // ── cs3: admin RPC round-trips (read-only) ────────────────────── + val owner = client.admin.getOwner() + assertTrue("getOwner must succeed: $owner", owner.isSuccess) + Log.i(TAG, "cs3 owner=${owner.getOrNull()?.long_name}") + + val metadata = client.admin.getDeviceMetadata() + assertTrue("getDeviceMetadata must succeed: $metadata", metadata.isSuccess) + + // 8 sequential get_channel RPCs — repeated large inbound reads. + val channels = client.admin.listChannels() + assertTrue("listChannels must succeed: $channels", channels.isSuccess) + Log.i(TAG, "cs3 listChannels=${channels.getOrNull()?.size} entries") + + // ── MTU proof: a >20-byte write fails outright at ATT MTU 23 ──── + // Targets our own node number: firmware handles it locally, no LoRa TX. + val bigPayload = "MTU conformance " + "x".repeat(160) + val handle = client.sendText(bigPayload, to = NodeId(myNum)) + val sent = withTimeoutOrNull(SEND_TIMEOUT_MS) { + handle.state.first { it != SendState.Queued } + } + Log.i(TAG, "MTU send state=$sent") + assertNotNull("send must leave Queued (write reached the radio)", sent) + assertFalse("177-byte write must not fail (MTU negotiation)", sent is SendState.Failed) + + // ── telemetry: local stats (non-fatal — firmware-version dependent) + val stats = client.telemetry.requestLocalStats() + Log.i(TAG, "localStats=${if (stats.isSuccess) "ok" else stats.toString()}") + } finally { + client.disconnect() + } + assertEquals(ConnectionState.Disconnected, client.connection.value) + + // ── cs6: reconnect on the SAME transport instance, fresh client ───── + val client2 = RadioClient { + transport(transport) + storage(SqlDelightStorageProvider(baseDir = storageDir.absolutePath)) + logger(logcatSink) + protocolLogging(org.meshtastic.sdk.LogLevel.DEBUG) + } + try { + val reconnectStartMs = System.currentTimeMillis() + withTimeout(CONNECT_TIMEOUT_MS) { client2.connect() } + Log.i(TAG, "cs6 reconnect in ${System.currentTimeMillis() - reconnectStartMs}ms") + assertEquals(ConnectionState.Connected, client2.connection.value) + assertNotNull("ownNode after reconnect", client2.ownNode.value) + } finally { + client2.disconnect() + transport.shutdown() + } + + // ── No storage degradation; surface drop accounting ──────────────── + val snapshot = synchronized(events) { events.toList() } + val degraded = snapshot.filterIsInstance() + assertTrue("storage must not degrade: $degraded", degraded.isEmpty()) + val dropped = snapshot.filterIsInstance().sumOf { it.count } + val warnings = snapshot.filterIsInstance().map { it.message } + Log.i(TAG, "events: dropped=$dropped warnings=$warnings") + eventScope.cancel() + } + + private companion object { + const val TAG = "BleConformance" + + /** Routes SDK engine/protocol logs into logcat for failure diagnosis. */ + val logcatSink = org.meshtastic.sdk.LogSink { level, tag, message, cause -> + val prio = when (level) { + org.meshtastic.sdk.LogLevel.ERROR -> Log.ERROR + org.meshtastic.sdk.LogLevel.WARN -> Log.WARN + org.meshtastic.sdk.LogLevel.INFO -> Log.INFO + else -> Log.DEBUG + } + Log.println(prio, "MeshSdk.$tag", if (cause != null) "$message ($cause)" else message) + } + const val SCAN_WINDOW_MS = 10_000L + const val MAX_CONNECT_CANDIDATES = 3 + const val CONNECT_TIMEOUT_MS = 90_000L + const val SEND_TIMEOUT_MS = 15_000L + } +} From 9d63247681c2c45c5752dcd3673c60042b97aca0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:05:04 -0500 Subject: [PATCH 14/16] fix: real-hardware BLE conformance fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five ship-blocking bugs caught by MeshtasticBleConformanceTest on a Pixel 6a against live radios (fw 2.7.24), none reachable by JVM/fake tests: - transport-ble: TORADIO is CHR_PROPS_WRITE only on real firmware (NRF52Bluetooth.cpp:213) — write-without-response is refused by Android outright; use WriteType.WithResponse. Docs updated to match. - engine: seed the wire packet-id counter randomly per instance. Starting at 1 every session collided with the firmware's ~10-minute packet-history dedup across reconnects, silently swallowing every RPC of the new session (Timeout with no response on the air). nextMessageId() now also skips 0 ("unset" on the wire). - engine: publish configBundle/channels synchronously at the Stage-2 commit, before connect() resumes — visibility was previously gated on the async storage flush and lost the race on real disk latency. - storage-sqldelight (Android): enable WAL via enableWriteAheadLogging(); execSQL("PRAGMA journal_mode=WAL") returns a result row and throws on re-activation. - transport-ble: frames() is re-collectable after disconnect() (per-cycle frame channel + collector guard reset), honouring the documented reuse-after-disconnect contract exercised by cs6. Co-Authored-By: Claude Fable 5 Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- CHANGELOG.md | 8 ++++ .../org/meshtastic/sdk/internal/MeshEngine.kt | 43 ++++++++++++++----- docs/architecture/transport-comparison.md | 2 +- docs/protocol.md | 2 +- .../SqlDelightStorageProvider.android.kt | 7 +-- transport-ble/Module.md | 2 +- .../sdk/transport/ble/BleConstants.kt | 2 +- .../sdk/transport/ble/BleTransport.kt | 37 ++++++++++++---- 8 files changed, 76 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f8258..5f44197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ published to Maven Central (0.1.0 was tagged `rc1` only), so these break no exte ### Added +- **on-device BLE conformance harness:** `:transport-ble:connectedAndroidDeviceTest` runs the cs1–cs6 conformance envelope against a real radio from an Android device — scan by service UUID (bonded-first), production `BleTransport(address)` factory connect, two-stage handshake, read-only admin RPC round-trips, a >MTU-23 write proof, SQLDelight persistence, and same-transport reconnect. Skips (does not fail) when no Meshtastic radio is advertising, so CI never needs hardware. This harness caught every Fixed item below tagged *found on hardware*. + - **events:** Inbound MQTT client-proxy, XModem, and file-info frames are now surfaced as typed events — `MeshEvent.MqttProxyMessage`, `MeshEvent.XmodemPacket`, `MeshEvent.FileInfoReceived` — instead of being dropped with a `ProtocolWarning`. Outbound counterparts go through `RadioClient.sendRaw(ToRadio(...))`. - **send:** `SendFailure.QueueRejected(res)` — a firmware `QueueStatus` enqueue rejection now fast-fails the `MessageHandle` (and any pending admin RPC sharing the wire id) instead of waiting out the full ACK timeout. - **send:** `RadioClient.sendText(..., replyId)` for threaded replies (`decoded.reply_id` without the emoji flag). @@ -58,6 +60,12 @@ published to Maven Central (0.1.0 was tagged `rc1` only), so these break no exte ### Fixed +- **transport-ble:** `toradio` writes now use **acknowledged writes** (`WriteType.WithResponse`). Firmware declares the characteristic `CHR_PROPS_WRITE` only — write-without-response is not in its properties, and Android refuses the write outright, making every connect fail on real radios. *Found on hardware.* +- **engine:** wire packet ids are **randomly seeded per engine instance**. The counter previously started at 1 every session, so a reconnect (or app restart) re-issued the same ids and the firmware's ~10-minute packet-history dedup silently dropped the repeats — every admin RPC in the new session timed out with no response. Matches the reference clients' randomized id seeding. *Found on hardware.* +- **engine:** the Stage-2 commit now publishes `configBundle` and `channels` to the public flows **synchronously on the actor** before `connect()` resumes. Publication previously ran inside the async storage flush, so on devices with real storage latency `connect()` returned while `configBundle.value` was still `null`. *Found on hardware.* +- **storage-sqldelight (Android):** WAL is enabled via `SupportSQLiteDatabase.enableWriteAheadLogging()` instead of `execSQL("PRAGMA journal_mode=WAL")` — the PRAGMA returns a result row, which Android's `execSQL` rejects (`SQLiteException: Queries can be performed using SQLiteDatabase query or rawQuery methods only`), failing the second storage activation. *Found on hardware.* +- **transport-ble:** `frames()` is re-collectable after `disconnect()`, honouring the documented reuse-after-disconnect contract — the frame channel is recreated per connect cycle and the single-collector guard resets when a collector completes. Previously the second session's engine failed with "frames() may only be collected once per instance". *Found on hardware.* + - **remote admin / firmware conformance:** `QueueStatus.res` is decoded in the firmware's **ERRNO namespace**, not `Routing.Error`: `35` (`ERRNO_SHOULD_RELEASE`) is success and now counts as `Sent`; ERRNO rejections (`32` queue-full, `33` no interface, `34` radio disabled) fail pending admin RPCs as `NodeUnreachable`; values `1..31` (genuine `Routing.Error` codes such as `DUTY_CYCLE_LIMIT`) map through the normal routing-error taxonomy. Previously `res = 32` was misread as `BAD_REQUEST`, `33` as `NOT_AUTHORIZED`, and the success code `35` produced a false failure. - **remote admin:** session passkeys are only latched from **response-shaped** admin messages. Previously a remote node administering *us* would have its request — carrying the passkey *we* issued — latched under the remote's key, poisoning our next RPC to it. - **remote admin:** the fire-and-forget admin path (`enterDfuMode`, `setTimeOnly`) now posts through the engine actor's inbox; it previously prepared the packet on the caller's coroutine, structurally mutating the actor-owned per-node passkey map (data race under ADR-002). diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index c822cdf..e9ef4e4 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -282,7 +282,13 @@ internal class MeshEngine( // Dispatcher is engine-actor-owned; mutated only inside processMessage. RPC wire ids share // the same allocator as user-facing sends so a getConfig can never collide with a sendText. private val dispatcher = CommandDispatcher(logger) - private val nextWireId = atomic(1) + + // Randomly seeded per engine instance: firmware dedups packets by (sender, id) with a + // ~10-minute history window, so a deterministic seed (e.g. always starting at 1) makes + // every session re-issue the same wire ids and the radio silently drops the repeats — + // RPCs then time out with no response. Matches the reference clients, which both + // randomize the starting packet id. Caught on hardware by MeshtasticBleConformanceTest. + private val nextWireId = atomic(Random.nextInt()) suspend fun connect() { val sup = SupervisorJob(parentContext[Job]) @@ -400,7 +406,14 @@ internal class MeshEngine( * `sendText`'s packet id. Wraps at `Int.MAX_VALUE`; the firmware uses fixed32 so we just * keep climbing. */ - fun nextMessageId(): MessageId = MessageId(nextWireId.incrementAndGet()) + fun nextMessageId(): MessageId { + // id=0 means "unset" on the wire (firmware would assign its own); skip it so + // QueueStatus / Routing ACK correlation always has a real key. + while (true) { + val id = nextWireId.incrementAndGet() + if (id != 0) return MessageId(id) + } + } /** * Submit a typed RPC and suspend until the response arrives, the timer fires, or the engine @@ -1206,6 +1219,15 @@ internal class MeshEngine( if (pendingChannels.isNotEmpty()) { meshState = meshState.withChannels(pendingChannels.toList()) } + // Publish handshake state to the public flows synchronously on the actor: + // connect() resumes at Ready, and consumers legitimately read + // configBundle/channels immediately after — the async persistence block + // below must not gate visibility (real-device storage latency loses that + // race; caught by MeshtasticBleConformanceTest). + meshState.configBundle?.let { configBundleState.value = it } + if (pendingChannels.isNotEmpty()) { + channelsState.value = pendingChannels.toList() + } if (pendingNodes.isNotEmpty()) { meshState = meshState.withNodes(pendingNodes.toMap()) emitNodeChangeOrLog(NodeChange.Snapshot(pendingNodes.toMap())) @@ -1236,16 +1258,15 @@ internal class MeshEngine( // seed their heartbeat row as well. heartbeatSnapshot[nodeId]?.let { ts -> s.saveHeartbeat(nodeId, ts) } } - if (channelsSnapshot.isNotEmpty()) s.saveChannels(channelsSnapshot) - // Populate channelsState from handshake payload; fall back to storage - // for reconnect sessions where the device skips re-sending channels. - channelsState.value = channelsSnapshot.ifEmpty { - s.loadChannels().ifEmpty { null } - } - bundleSnapshot?.let { - s.saveConfig(it) - configBundleState.value = it + if (channelsSnapshot.isNotEmpty()) { + s.saveChannels(channelsSnapshot) + } else if (channelsState.value == null) { + // Reconnect sessions where the device skips re-sending + // channels: fall back to the persisted set (storage I/O, so + // it stays in this async block). + channelsState.value = s.loadChannels().ifEmpty { null } } + bundleSnapshot?.let { s.saveConfig(it) } } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/docs/architecture/transport-comparison.md b/docs/architecture/transport-comparison.md index 60b41b1..97ffce6 100644 --- a/docs/architecture/transport-comparison.md +++ b/docs/architecture/transport-comparison.md @@ -14,7 +14,7 @@ and [ADR-012](../decisions/012-transport-threading.md) for the threading contrac | Backing library | Ktor `ktor-network` | JuulLabs Kable (`kable-core`) | jSerialComm | | Typical use case | Dev / testing, LAN-attached routers, gateways, JVM samples | Phone ↔ handheld radio (the default consumer path) | USB-tethered desktop debugging, Android USB-OTG, headless setups | | Latency | Lowest (native sockets) | Highest — GATT round-trips, optional bonding | Low (raw byte stream) | -| Throughput | Highest — TCP MSS / network-bound | Lowest — ATT MTU, write-without-response cadence | High — 115 200 baud (≈ 11 KB/s) | +| Throughput | Highest — TCP MSS / network-bound | Lowest — ATT MTU, acknowledged-write cadence | High — 115 200 baud (≈ 11 KB/s) | | Requires hardware | None (just a routable host) | BLE radio on host **and** device | USB host port + USB-serial bridge IC | | Multiple devices per host | Yes — one `RadioClient` per `(host, port)` | Yes — one `RadioClient` per peripheral | Yes — one `RadioClient` per port | | Bonding / pairing | None | Yes — surfaced as [`TransportState.Bonding`](../../core/src/commonMain/kotlin/org/meshtastic/sdk/Transport.kt) on first encrypted read | None | diff --git a/docs/protocol.md b/docs/protocol.md index 4a355df..0db5694 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -150,7 +150,7 @@ BLE uses GATT message boundaries directly. The phone subscribes to a notificatio |---|---|---| | **Mesh service** | `6BA1B218-15A8-461F-9FA8-5DCAE273EAFD` | — | | `fromradio` | `2C55E69E-4993-11ED-B878-0242AC120002` | **READ** — returns one `FromRadio` protobuf per read; empty read means queue drained | -| `toradio` | `F75C76D2-129E-4DAD-A1DD-7866124401E7` | **WRITE** (with or without response) — phone writes one `ToRadio` protobuf per write. Clients SHOULD prefer WRITE-WITHOUT-RESPONSE when `maximumWriteValueLength(WITHOUT_RESPONSE)` suffices for throughput. | +| `toradio` | `F75C76D2-129E-4DAD-A1DD-7866124401E7` | **WRITE** (acknowledged) — phone writes one `ToRadio` protobuf per write. Firmware declares `CHR_PROPS_WRITE` only (NRF52Bluetooth.cpp; NimBLE equivalent): write-without-response is NOT in the characteristic properties and strict stacks (Android/Kable) refuse it — verified against real radios. | | `fromnum` | `ED9DA18C-A800-4F66-A670-AA7547E34453` | **NOTIFY** + **READ** — 4-byte little-endian counter that increments whenever the device pushes data into the `fromradio` queue | | `logradio` (optional, current) | `5A3D6E49-06E6-4423-9944-E9DE8CDF9547` | **NOTIFY** + **READ** — streaming device log lines; informational only. Primary on current firmware. | | `logradio` (optional, legacy) | `6C6FD238-78FA-436B-AACF-15C5BE1EF2E2` | **NOTIFY** + **READ** — legacy log characteristic. Kept by firmware for backward compatibility; clients should subscribe to whichever is advertised. | diff --git a/storage-sqldelight/src/androidMain/kotlin/org/meshtastic/sdk/storage/sqldelight/SqlDelightStorageProvider.android.kt b/storage-sqldelight/src/androidMain/kotlin/org/meshtastic/sdk/storage/sqldelight/SqlDelightStorageProvider.android.kt index b32e18a..82f4f1c 100644 --- a/storage-sqldelight/src/androidMain/kotlin/org/meshtastic/sdk/storage/sqldelight/SqlDelightStorageProvider.android.kt +++ b/storage-sqldelight/src/androidMain/kotlin/org/meshtastic/sdk/storage/sqldelight/SqlDelightStorageProvider.android.kt @@ -25,9 +25,10 @@ internal actual fun createDriver(dbPath: String): SqlDriver { override fun onConfigure(db: SupportSQLiteDatabase) { super.onConfigure(db) db.setForeignKeyConstraintsEnabled(true) - // Android framework defaults to WAL on API 16+; still execute explicitly to - // guarantee the setting for any non-default open helper. - if (fileBacked) db.execSQL("PRAGMA journal_mode=WAL;") + // WAL must go through the framework API: `PRAGMA journal_mode=WAL` returns a + // result row, which Android's execSQL rejects ("Queries can be performed using + // SQLiteDatabase query or rawQuery methods only") once the mode is already set. + if (fileBacked) db.enableWriteAheadLogging() db.execSQL("PRAGMA synchronous=NORMAL;") } } diff --git a/transport-ble/Module.md b/transport-ble/Module.md index f59b9a4..1482ff3 100644 --- a/transport-ble/Module.md +++ b/transport-ble/Module.md @@ -24,7 +24,7 @@ The Meshtastic firmware exposes a single primary service with three characterist |---|---|---| | Mesh service (scan filter) | `6ba1b218-15a8-461f-9fa8-5dcae273eafd` | — | | `fromradio` | `2c55e69e-4993-11ed-b878-0242ac120002` | READ — one full `FromRadio` per read; empty buffer means drained | -| `toradio` | `f75c76d2-129e-4dad-a1dd-7866124401e7` | WRITE-WITHOUT-RESPONSE — one full `ToRadio` per write | +| `toradio` | `f75c76d2-129e-4dad-a1dd-7866124401e7` | WRITE (acknowledged) — one full `ToRadio` per write; firmware declares `CHR_PROPS_WRITE` only | | `fromnum` | `ed9da18c-a800-4f66-a670-aa7547e34453` | NOTIFY+READ — 4-byte LE wake counter; treat as a wake signal only and always drain `fromradio` to empty | GATT message boundaries replace the stream framer (`0x94 0xC3 LEN_HI LEN_LO`) used by TCP/serial: diff --git a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleConstants.kt b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleConstants.kt index a14d300..bb44323 100644 --- a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleConstants.kt +++ b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleConstants.kt @@ -33,7 +33,7 @@ public object BleConstants { Uuid.parse("2c55e69e-4993-11ed-b878-0242ac120002") /** - * `toradio` — WRITE-WITHOUT-RESPONSE characteristic. Each write carries one full + * `toradio` — WRITE (acknowledged) characteristic. Each write carries one full * `ToRadio` protobuf payload (no `0x94 0xC3` framing — BLE uses GATT message * boundaries directly). */ diff --git a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt index f71feb5..dd3e14f 100644 --- a/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt +++ b/transport-ble/src/commonMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.kt @@ -27,7 +27,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout @@ -53,10 +56,12 @@ import kotlin.coroutines.EmptyCoroutineContext * BLE transport for Meshtastic radios using JuulLabs Kable. * * Implements GATT framing: subscribes to `fromnum` notifications, drains `fromradio` until - * empty on each notification, writes to `toradio` with WriteWithoutResponse. + * empty on each notification, writes to `toradio` with acknowledged writes (the + * firmware declares write-with-response only). * * Strips/prepends the 4-byte stream header so the engine stays transport-agnostic. - * Single-collection [frames]; [shutdown] is idempotent and cannot be undone. + * [frames] allows one active collector at a time (re-collectable after disconnect); + * [shutdown] is idempotent and cannot be undone. * * @param peripheral Kable peripheral pointing at the target radio. * @param address stable platform-specific address for storage identity keying. @@ -89,12 +94,15 @@ public class BleTransport( // next session when the transport is reused after disconnect. private var tuningScope: CoroutineScope? = null - private val frameChannel = Channel(capacity = 64) + // Recreated per connect cycle: a completed/cancelled collector leaves the previous + // channel unusable (consumeAsFlow cancels it), and reuse-after-disconnect needs a live one. + private var frameChannel = Channel(capacity = 64) // ADR-012: lifecycle idempotency — idempotent shutdown flag. private val shuttingDown = atomic(false) - // ADR-012: lifecycle idempotency — enforces single-collection of frames(). + // ADR-012: lifecycle idempotency — enforces a single ACTIVE collector of frames(). + // Reset when a collector completes so reuse-after-disconnect can collect again. private val framesCollected = atomic(false) // ADR-012: lifecycle idempotency — while false, bridgeKableState() won't promote to Connected (warmup read pending). @@ -119,6 +127,11 @@ public class BleTransport( // Reset per-connect lifecycle flags so reuse-after-disconnect works. warmupComplete.value = false errorPublished.value = false + // The previous session's collector cancelled its channel on completion; start each + // cycle with a fresh one. Safe unconditionally: drain/warmup senders for this cycle + // are launched later in connect(), and prior-cycle senders were cancelled by + // disconnect(). + frameChannel = Channel(capacity = 64) _state.value = TransportState.Connecting try { connectWithRetry() @@ -270,7 +283,11 @@ public class BleTransport( ) } try { - peripheral.write(BleConstants.TORADIO, payload, WriteType.WithoutResponse) + // Acknowledged write: firmware exposes `toradio` as CHR_PROPS_WRITE only + // (NRF52Bluetooth.cpp:213, NimBLE equivalent) — write-without-response is NOT + // in the characteristic's properties and Android/Kable refuses the write + // outright. Verified against real radios via MeshtasticBleConformanceTest. + peripheral.write(BleConstants.TORADIO, payload, WriteType.WithResponse) } catch (e: NotConnectedException) { throw MeshtasticException.Transport( "BLE write dropped — peripheral not connected: ${e.message}", @@ -295,12 +312,14 @@ public class BleTransport( } /** Cold flow over received frames. Single-collection only. */ - override fun frames(): Flow { + override fun frames(): Flow = flow { check(framesCollected.compareAndSet(expect = false, update = true)) { - "BleTransport.frames() may only be collected once per instance" + "BleTransport.frames() already has an active collector" } - return frameChannel.consumeAsFlow() - } + // Resolve frameChannel at collection time (not call time) so a collector started + // after reconnect picks up the per-cycle replacement channel. + emitAll(frameChannel.consumeAsFlow()) + }.onCompletion { framesCollected.value = false } /** Release internal coroutines and frame channel. Idempotent; does not close [Peripheral]. */ public fun shutdown() { From e36f62baff6b0469cf25d45004196ee7f103b33b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:24:34 -0500 Subject: [PATCH 15/16] feat(core): add storage-lockdown admin support Completes AdminMessage coverage with the hardened-build (MESHTASTIC_LOCKDOWN) lockdown surface, and migrates the protobuf dependency model + all docs from the vendored-submodule scheme to the published org.meshtastic:protobufs artifact. Lockdown: - AdminApi.lockdown(LockdownAuth): local-only (rejects forNode targeting; the firmware consumes the passphrase inline on the phone link), fire-and-forget; the device replies with a fresh lockdown_status. - MeshEvent.LockdownStatusChanged: replaces the prior ProtocolWarning stub; handled identically mid- and post-handshake. - Tests (outbound + inbound), updated CapturingAdminApi fake, regenerated ABI. - Builds on the pinned 2.7.25 proto; the newer LockdownAuth.disable / max_session_seconds and LockdownStatus.State.DISABLED arrive in a follow-up once a stable proto release ships them. Protobufs: - Stays pinned at 2.7.25. Audit found zero field changes across the 46 SDK-consumed messages vs develop-SNAPSHOT; the only delta is the device-only NodeDatabase restructure, not consumed here. Docs: - protocol.md: correct the AdminMessage field numbers and add a lockdown section. - ADR-015 records the submodule + :proto-module -> published-artifact migration; superseded-by notes added to ADR-000/001/003/006/008/013. - Sweep guidance/architecture docs (AGENTS, GEMINI, CONTRIBUTING, README, versioning, ci-cd, module-graph, enforcement, transport-*, migration guides, wasm roadmap) to the artifact model; drop the dead git-submodules Renovate manager; fix Dokka V1 -> V2 (dokkaGenerate) command references. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- AGENTS.md | 10 +- CHANGELOG.md | 8 +- CONTRIBUTING.md | 50 +++---- GEMINI.md | 4 +- README.md | 9 +- core/api/core.klib.api | 14 ++ core/api/jvm/core.api | 12 ++ .../kotlin/org/meshtastic/sdk/AdminApi.kt | 35 ++++- .../kotlin/org/meshtastic/sdk/Node.kt | 28 ++++ .../meshtastic/sdk/internal/AdminApiImpl.kt | 18 +++ .../org/meshtastic/sdk/internal/MeshEngine.kt | 8 +- .../meshtastic/sdk/AdminApiRemainingTest.kt | 45 ++++++ .../sdk/AndroidCutoverPrereqsTest.kt | 11 +- .../org/meshtastic/sdk/ConfigBuildersTest.kt | 2 + .../meshtastic/sdk/LockdownStatusEventTest.kt | 75 ++++++++++ docs/SPEC.md | 15 +- docs/api-reference.md | 2 +- docs/architecture/enforcement.md | 4 +- .../meshtastic-android-migration.md | 2 +- docs/architecture/module-graph.md | 36 ++--- docs/architecture/transport-comparison.md | 2 +- docs/architecture/transport-isolation.md | 4 +- docs/ci-cd.md | 25 ++-- docs/decisions/000-charter.md | 4 +- ...001-public-api-uses-generated-protobufs.md | 6 +- docs/decisions/003-tooling.md | 4 +- docs/decisions/006-multi-module-rationale.md | 6 +- .../decisions/008-architecture-enforcement.md | 4 +- docs/decisions/013-proto-json-envelope.md | 4 +- ...15-consume-published-protobufs-artifact.md | 67 +++++++++ docs/future/wasm-rpc-roadmap.md | 7 +- docs/protocol.md | 139 ++++++++++++------ docs/testing.md | 1 - docs/versioning.md | 45 +++--- renovate.json | 15 +- 35 files changed, 528 insertions(+), 193 deletions(-) create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/LockdownStatusEventTest.kt create mode 100644 docs/decisions/015-consume-published-protobufs-artifact.md diff --git a/AGENTS.md b/AGENTS.md index 7c38a7e..229bbc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,9 @@ Use links above as source of truth; do not restate their contents in PR descript - JDK 21. - Android SDK API 35 for Android targets (`ANDROID_HOME` set). - Xcode 15+ for iOS targets. -- Clone with submodules (`--recurse-submodules`) because protobuf definitions are vendored. +- No submodules to init — protobuf types come from the published `org.meshtastic:protobufs` Maven artifact, pinned in `gradle/libs.versions.toml` (`meshtasticProtobufs`). -If protocol/generated symbols look wrong, verify submodule state under `proto/src/protobufs`. +If protocol/generated symbols look wrong, verify the `meshtasticProtobufs` pin resolves (`./gradlew :core:dependencies --configuration jvmCompileClasspath | grep protobufs`). ## Commands Agents Should Run @@ -43,7 +43,7 @@ Prefer targeted tasks while iterating, then run `./gradlew check` before finishi ## Hard Architectural Rules - Respect module boundaries from `docs/architecture/module-graph.md`. -- `:core` must depend only on `:proto`. +- `:core` must declare no in-tree project dependencies; its only proto dependency is the published `org.meshtastic:protobufs` artifact (re-exported via `api`). - Do not add transport/storage implementation dependencies into `:core`. - Engine concurrency model is single-writer actor; do not introduce mutex/atomic/synchronized patterns in engine paths (see ADR-002). - No `java.*` or `android.*` imports in `commonMain`; use `okio.ByteString` for byte payloads (Wire's runtime type; kotlinx-io is deliberately not a dependency) and `kotlinx-datetime` for time. @@ -54,7 +54,7 @@ Prefer targeted tasks while iterating, then run `./gradlew check` before finishi - Follow API shape rules in ADR-005. - Do not introduce `kotlin.Result` in public API. - If public API changes are intentional, include regenerated `api/*.api` files from `updateKotlinAbi`. -- Every public symbol MUST have a KDoc comment; Dokka coverage is a CI gate (`./gradlew dokkaHtml`). +- Every public symbol MUST have a KDoc comment; Dokka coverage is a CI gate (`./gradlew dokkaGenerate` — Dokka V2; the legacy `dokkaHtml` task is removed and errors under V2 mode). ## Workflow Expectations @@ -113,7 +113,7 @@ Slim by design. The full inventory: ## Common Pitfalls -- Forgetting submodules leads to proto/codegen failures. +- The `org.meshtastic:protobufs` pin in `gradle/libs.versions.toml` is the proto contract; a stale pin (or a moving `-SNAPSHOT`) is the usual cause of missing or renamed generated symbols. - Running `checkKotlinAbi` without `updateKotlinAbi` after intentional public API edits causes CI failure. - Cross-platform changes should consider target matrix constraints documented in `docs/architecture/module-graph.md`. - `Dispatchers.IO` from `commonMain` fails on Native/iOS ("it is internal"); use `expect/actual` per-platform dispatchers instead (see `storage-sqldelight/src/{jvm,android,apple}Main/.../StorageDispatcher.*.kt`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f44197..dd1e603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,8 @@ published to Maven Central (0.1.0 was tagged `rc1` only), so these break no exte Paxcount/StoreAndForward-style payloads). - **New sealed variants break exhaustive `when`s:** `MeshEvent.MqttProxyMessage`, `MeshEvent.XmodemPacket`, `MeshEvent.FileInfoReceived` (named to avoid shadowing - `org.meshtastic.proto.FileInfo`), and `SendFailure.QueueRejected(res)`. + `org.meshtastic.proto.FileInfo`), `MeshEvent.LockdownStatusChanged`, and + `SendFailure.QueueRejected(res)`. - **`nodes` flow seeding model:** every subscription now receives a fresh `NodeChange.Snapshot` first (seeded via `onSubscription` from the engine's current node map); the engine's SharedFlow no longer keeps a replay slot. Previously a late subscriber @@ -50,6 +51,8 @@ published to Maven Central (0.1.0 was tagged `rc1` only), so these break no exte - **on-device BLE conformance harness:** `:transport-ble:connectedAndroidDeviceTest` runs the cs1–cs6 conformance envelope against a real radio from an Android device — scan by service UUID (bonded-first), production `BleTransport(address)` factory connect, two-stage handshake, read-only admin RPC round-trips, a >MTU-23 write proof, SQLDelight persistence, and same-transport reconnect. Skips (does not fail) when no Meshtastic radio is advertising, so CI never needs hardware. This harness caught every Fixed item below tagged *found on hardware*. - **events:** Inbound MQTT client-proxy, XModem, and file-info frames are now surfaced as typed events — `MeshEvent.MqttProxyMessage`, `MeshEvent.XmodemPacket`, `MeshEvent.FileInfoReceived` — instead of being dropped with a `ProtocolWarning`. Outbound counterparts go through `RadioClient.sendRaw(ToRadio(...))`. +- **admin / lockdown:** `AdminApi.lockdown(LockdownAuth)` drives storage lockdown on hardened (`MESHTASTIC_LOCKDOWN`) firmware builds — provision, unlock, or `lock_now`. Local-only by design (a `forNode(...)`-scoped call returns `Unauthorized`; the firmware consumes the passphrase inline on the phone link and never routes it over the mesh) and fire-and-forget (the device replies with a fresh `lockdown_status` rather than a routing ACK). Completes AdminMessage coverage — every `payload_variant` is now exposed. +- **events:** `MeshEvent.LockdownStatusChanged` surfaces the device's `FromRadio.lockdown_status` report (sent right after `config_complete_id` and after each `lockdown` command) as a typed event. The device's runtime status report — not a firmware-version flag — is the source of truth for lockdown availability (lockdown is a build-time firmware option). - **send:** `SendFailure.QueueRejected(res)` — a firmware `QueueStatus` enqueue rejection now fast-fails the `MessageHandle` (and any pending admin RPC sharing the wire id) instead of waiting out the full ACK timeout. - **send:** `RadioClient.sendText(..., replyId)` for threaded replies (`decoded.reply_id` without the emoji flag). - **engine:** Mesh packets received while the handshake is still in flight (live traffic interleaved with the config/NodeDB drain, including the seeding window) are now buffered (drop-oldest at 64, observable via `PacketsDropped`) and flushed through the normal packet pipeline at Ready — previously they were silently dropped. @@ -71,7 +74,7 @@ published to Maven Central (0.1.0 was tagged `rc1` only), so these break no exte - **remote admin:** the fire-and-forget admin path (`enterDfuMode`, `setTimeOnly`) now posts through the engine actor's inbox; it previously prepared the packet on the caller's coroutine, structurally mutating the actor-owned per-node passkey map (data race under ADR-002). - **remote admin:** the managed-mode client-side gate now applies only to **local** targets. Firmware rejects only local admin on a managed device (`from == 0` branch); remote targets authorize against their own admin keys — managed-fleet deployments administer remote managed nodes from a managed local node. - **engine:** a `want_config_id` retry restarts the firmware's config drain from scratch, which previously duplicated channels / config sections in the committed `ConfigBundle` and `channels` state. Stage 1 accumulators now replace by key, latest occurrence wins. -- **engine:** `FromRadio.lockdown_status` (protobufs 2.7.25+) was silently dropped — it now surfaces as a structured `ProtocolWarning` until typed lockdown support lands. +- **engine:** `FromRadio.lockdown_status` was silently dropped, then briefly surfaced as a generic `ProtocolWarning`; it now lands as the typed `MeshEvent.LockdownStatusChanged` (see Added), handled identically mid-handshake and post-handshake. - **engine:** the Stage-1 settle replay no longer drops buffered frames that follow a duplicate Stage-1 completion — the unprocessed remainder is re-buffered for the next settle window. - **engine:** the seeding window no longer silently drops non-packet `FromRadio` variants — `node_info` merges into the node DB and the rest route through the shared auxiliary handler (client notifications, MQTT proxy, …). - **engine:** caller-supplied wire-id collisions are now rejected with `SendFailure.IdCollision` at the wire-id key in `dispatchSend`; previously the second send silently overwrote the first handle's bookkeeping, stranding it un-completable forever. @@ -85,6 +88,7 @@ published to Maven Central (0.1.0 was tagged `rc1` only), so these break no exte - **storage:** Documented that `DeviceStorage.loadNodes()` is never called by the engine (node DB reseeds from the handshake); it exists for hosts (offline node access) and tests. - **transport-ble (Android):** `BleTransport(address)` now negotiates the ATT MTU (517) after each link establishment and requests `CONNECTION_PRIORITY_HIGH` for the 30-second handshake window before downgrading to Balanced. Without the MTU request Android stays at the BLE minimum (23) and any ToRadio write over 20 bytes fails. - **docs:** `docs/api-reference.md` gains an **API conventions** section (proto exposure, byte vocabulary, no blocking bridges, data-class policy, Kotlin/Swift-first interop); SPEC bumped to v2.3 and synced; CONTRIBUTING/AGENTS house rules flipped to the okio vocabulary with superseded-by notes preserved in ADR-003. +- **build:** `org.meshtastic:protobufs` stays pinned at `2.7.25`. A field-level diff against the published `develop-SNAPSHOT` found **zero** changes across all 46 SDK-consumed proto messages — the only structural delta is the device-only `NodeDatabase` flash-storage restructure (new `NodeStatus/Position/Telemetry/Environment` entry types), which the SDK does not consume. The newer lockdown fields (`LockdownAuth.disable`, `LockdownAuth.max_session_seconds`, `LockdownStatus.State.DISABLED`) arrive in a follow-up once a stable proto release ships them. - **build:** Kable 0.42.0 → 0.43.0 (aligns with Meshtastic-Android). - **build:** Aligned the toolchain with Meshtastic-Android — Kotlin 2.3.21 (SKIE 0.10.12), Wire 6.4.0, Ktor 3.5.0, and stable coroutines 1.11.0 (was 1.11.0-rc02). Validated locally: iOS framework link, jvmTest, and Kotlin ABI check all green. - **docs(SPEC.md):** Bumped spec from v2.1 to v2.2 — full post-audit sync aligning spec with shipped implementation. Key areas synchronized: AdminApi expansion (~15 → ~45 methods), `StoreForwardApi`, presence tracking (`WentOffline`/`CameOnline`), `AutoReconnectConfig`, `CongestionWarning`/`ExternalConfigChange`/`StorageDegraded` MeshEvent variants, send DSL, `connectAndAwaitReady()`, `SessionPasskey`, `ConfigBundle.deviceUIConfig`, `SendFailure.IdCollision`/`AckTimeout`/`HandshakeFailed`, `AdminResult` extensions, `ConnectionState` extensions, `MeshtasticException` context fields, convention plugin + version catalog correction (JVM 17→21, Android SDK→36, Kotlin 2.3.20). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3646a29..628e8b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,20 +47,17 @@ git push --force-with-lease | Gradle | 8.4+ (bundled; see `gradle/wrapper/gradle-wrapper.properties`) | | Android SDK | API 36 platform (only needed for Android targets); set `ANDROID_HOME` | | Xcode | 15+ with iOS 14+ SDK (only needed for iOS targets, mac only) | -| Git | Any modern version with submodule support | +| Git | Any modern version | -Clone with submodules: +Clone: ```bash -git clone --recurse-submodules git@github.com:meshtastic/meshtastic-sdk.git +git clone git@github.com:meshtastic/meshtastic-sdk.git cd meshtastic-sdk ``` -If you already cloned without submodules: - -```bash -git submodule update --init --recursive -``` +There are no submodules — protobuf types come from the published `org.meshtastic:protobufs` +Maven artifact, resolved by Gradle (pinned in `gradle/libs.versions.toml`). ## Build requirements @@ -96,18 +93,18 @@ sdk install java 21-tem # sdkman brew install openjdk@21 # macOS ``` -### Protocol submodule +### Protocol types -The `proto/` directory is a git submodule pointing to the canonical Meshtastic protobufs. If you see strange `MeshPacket`, `FromRadio`, etc. type resolution errors, verify the submodule state: +Protobuf types come from the published `org.meshtastic:protobufs` Maven artifact (there is no +`proto/` submodule). If you see strange `MeshPacket`, `FromRadio`, etc. type-resolution errors, +verify the pinned version resolves: ```bash -git submodule status -# Should show something like: " 1a2b3c4d5e6f proto/src/protobufs (v1.2.3)" - -# If detached or missing: -git submodule update --init --recursive +./gradlew :core:dependencies --configuration jvmCompileClasspath | grep protobufs ``` +The version is pinned in `gradle/libs.versions.toml` (`meshtasticProtobufs`). + ## Build & test | Task | Command | @@ -120,7 +117,7 @@ git submodule update --init --recursive | Lint | `./gradlew detekt spotlessCheck` | | Format | `./gradlew spotlessApply` | | Architecture rules | `./gradlew detekt :core:verifyModuleBoundary` (matrix in [`docs/architecture/enforcement.md`](docs/architecture/enforcement.md), rationale in [ADR-008](docs/decisions/008-architecture-enforcement.md)) | -| Docs preview | `./gradlew dokkaHtmlMultiModule` (output: `build/dokka/htmlMultiModule`) | +| Docs preview | `./gradlew dokkaGenerate` (Dokka V2; per-module HTML under `*/build/dokka/html`) | Pre-commit hook (opt-in): @@ -136,7 +133,7 @@ pushing. ## Public API changes (`checkKotlinAbi` vs `updateKotlinAbi`) -The `:core`, `:proto`, `:transport-*`, `:storage-sqldelight`, `:testing`, +The `:core`, `:transport-*`, `:storage-sqldelight`, `:testing`, and `:bom` modules are publishable artifacts; their public Kotlin surface is captured in `/api/.klib.api` (and `/api/jvm/.api` for JVM). The Kotlin Gradle Plugin's @@ -208,7 +205,7 @@ they always start at the latest schema. The engine architecture has hard rules enforced by detekt + Gradle (see [ADR-008](docs/decisions/008-architecture-enforcement.md)): -1. `:core` depends only on `:proto`. Never on a transport or storage implementation. (The `RadioTransport`, `StorageProvider`, and `DeviceStorage` interfaces live inside `:core` itself — see ADR-006.) +1. `:core` declares no in-tree project dependencies — its only proto dependency is the published `org.meshtastic:protobufs` artifact (re-exported via `api`). Never depend on a transport or storage implementation. (The `RadioTransport`, `StorageProvider`, and `DeviceStorage` interfaces live inside `:core` itself — see ADR-006 and ADR-015.) 2. No `java.*`, `android.*`, `kotlin.Result` in `commonMain` or public API. 3. No `Mutex` / `atomicfu` / `synchronized` in the engine package — the actor IS the synchronization primitive (see [ADR-002](docs/decisions/002-architecture.md)). 4. Public byte payloads use `okio.ByteString` (Wire's runtime type — already forced into the surface by the generated protos); `kotlinx-io` is deliberately not a dependency. See docs/api-reference.md § API conventions. @@ -261,22 +258,19 @@ If your change involves wire-level behavior (handshake, framing, ACK semantics, 3. Update [`docs/protocol.md`](docs/protocol.md) — the wire spec is the source of truth. 4. Add a manual-test entry in [`docs/manual-tests.md`](docs/manual-tests.md) if the change can only be validated against a real device. -## Bumping the proto submodule +## Bumping the proto artifact -Routine bumps happen automatically via Renovate's `git-submodules` manager -(see `renovate.json`). Manually: +Routine bumps happen automatically via Renovate's `gradle` manager +(see `renovate.json`), which proposes new `org.meshtastic:protobufs` versions. Manually: ```bash -cd proto/src/protobufs -git fetch origin master -git checkout origin/master -cd ../../.. +# edit gradle/libs.versions.toml: meshtasticProtobufs = "" ./gradlew updateKotlinAbi -git add proto/src/protobufs api/ -git commit -s -m "chore(proto): bump submodule (..)" +git add gradle/libs.versions.toml core/api/ +git commit -s -m "chore(proto): bump org.meshtastic:protobufs to " ``` -Review the generated API diff carefully — new `oneof` arms break exhaustive `when` for consumers. SemVer impact per [`docs/versioning.md`](docs/versioning.md). +Review the regenerated ABI diff carefully — new `oneof` arms break exhaustive `when` for consumers. SemVer impact per [`docs/versioning.md`](docs/versioning.md). ## Reusing code from sibling Meshtastic-org projects diff --git a/GEMINI.md b/GEMINI.md index 816ce24..5673f38 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -36,7 +36,7 @@ The SDK follows a strictly defined architecture documented in `docs/SPEC.md`: - **Run Tests:** `./gradlew allTests` - **Linting:** `./gradlew spotlessCheck` / `./gradlew spotlessApply` - **Static Analysis:** `./gradlew detekt` -- **API Documentation:** `./gradlew dokkaHtml` (output in `build/dokka/html`) +- **API Documentation:** `./gradlew dokkaGenerate` (Dokka V2; output in `core/build/dokka/html`. The legacy `dokkaHtml` task is removed and errors under V2 mode.) - **ABI Management:** `./gradlew checkKotlinAbi` (validate) or `./gradlew updateKotlinAbi` (after intentional API changes) ### Sample CLI @@ -68,6 +68,6 @@ The project includes a sample CLI for testing: - `docs/SPEC.md`: The authoritative implementation plan and architecture bible. - `docs/protocol.md`: The wire-level protocol reference (PhoneAPI). - `core/`: The main SDK engine and public facade. -- `proto/`: Vendored Meshtastic protobufs and generated Kotlin DTOs. +- Protobuf types: the published `org.meshtastic:protobufs` Maven artifact (no vendored `proto/` dir or submodule); pinned in `gradle/libs.versions.toml`. - `build-logic/`: Custom Gradle convention plugins for KMP, Android, and Publishing. - `gradle/libs.versions.toml`: The single source of truth for all dependencies and versions. diff --git a/README.md b/README.md index ee95de2..b13e416 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ dependencies { } ``` -Snapshots are rebuilt on every commit; pin to a specific commit by checking out a Git submodule for reproducibility. +Snapshot artifacts are mutable — rebuilt on every commit to `main`. For reproducible builds, depend on a released version (e.g. `org.meshtastic:sdk-core:0.1.0`) rather than a `-SNAPSHOT`. Roadmap (post-1.0, non-breaking adds): `transport-mqtt-proxy`, `transport-rpc`, `host-rpc-server`, `wasmJs` browser support — see [`docs/future/wasm-rpc-roadmap.md`](docs/future/wasm-rpc-roadmap.md). @@ -222,8 +222,7 @@ authoritative dependency graph. | Module | JVM | Android (`minSdk 26`) | iOS Arm64 | iOS Sim Arm64 | iOS X64 | Notes | |----------------------------|:---:|:---------------------:|:---------:|:-------------:|:-------:|-----------------------------------------------------------------------| -| `core` | ✓ | ✓ | ✓ | ✓ | ✓ | Pure-Kotlin engine; no platform deps beyond `:proto`. | -| `proto` | ✓ | ✓ | ✓ | ✓ | ✓ | Wire-generated proto types (`org.meshtastic:protobufs`). | +| `core` | ✓ | ✓ | ✓ | ✓ | ✓ | Pure-Kotlin engine; re-exports the `org.meshtastic:protobufs` types (no other deps). | | `transport-ble` | ✓¹ | ✓ | ✓ | ✓ | ✓ | ¹ JVM uses `BlueZ`/`BleZ`-style adapter where available; see module README. | | `transport-tcp` | ✓ | ✓ | ✓ | ✓ | ✓ | Built on Ktor sockets. | | `transport-serial` | ✓ | ✓ | — | — | — | iOS targets compile (empty actuals) but no USB-serial API on iOS. | @@ -255,7 +254,7 @@ authoritative dependency graph. ## Building from source ```bash -git clone --recurse-submodules git@github.com:meshtastic/meshtastic-sdk.git +git clone git@github.com:meshtastic/meshtastic-sdk.git cd meshtastic-sdk ./gradlew check # build + test + lint + checkKotlinAbi + detekt + :core:verifyModuleBoundary ``` @@ -296,6 +295,6 @@ GPL-3.0-only. See [`LICENSE`](LICENSE) and [`docs/decisions/004-licensing.md`](d ## Related Meshtastic projects - [`meshtastic/firmware`](https://github.com/meshtastic/firmware) — device-side reference (read-only behavior anchor here). -- [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) — wire schema (vendored here as a submodule). +- [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) — wire schema; consumed as the published `org.meshtastic:protobufs` artifact. - [`meshtastic/Meshtastic-Android`](https://github.com/meshtastic/Meshtastic-Android), [`meshtastic/Meshtastic-Apple`](https://github.com/meshtastic/Meshtastic-Apple) — flagship apps; cross-validation references. - [`meshtastic/mqtt-client`](https://github.com/meshtastic/mqtt-client) — sibling KMP library for direct MQTT broker use. diff --git a/core/api/core.klib.api b/core/api/core.klib.api index 63dc4f9..ddaba03 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -148,6 +148,7 @@ abstract interface org.meshtastic.sdk/AdminApi { // org.meshtastic.sdk/AdminApi| abstract suspend fun getUIConfig(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getUIConfig|getUIConfig(){}[0] abstract suspend fun keyVerification(org.meshtastic.proto/KeyVerificationAdmin): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.keyVerification|keyVerification(org.meshtastic.proto.KeyVerificationAdmin){}[0] abstract suspend fun listChannels(): org.meshtastic.sdk/AdminResult> // org.meshtastic.sdk/AdminApi.listChannels|listChannels(){}[0] + abstract suspend fun lockdown(org.meshtastic.proto/LockdownAuth): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.lockdown|lockdown(org.meshtastic.proto.LockdownAuth){}[0] abstract suspend fun nodeDbReset(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.nodeDbReset|nodeDbReset(){}[0] abstract suspend fun otaRequest(org.meshtastic.proto/AdminMessage.OTAEvent): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.otaRequest|otaRequest(org.meshtastic.proto.AdminMessage.OTAEvent){}[0] abstract suspend fun reboot(kotlin.time/Duration = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.reboot|reboot(kotlin.time.Duration){}[0] @@ -481,6 +482,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.KeyVerification.toString|toString(){}[0] } + final class LockdownStatusChanged : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged|null[0] + constructor (org.meshtastic.proto/LockdownStatus) // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged.|(org.meshtastic.proto.LockdownStatus){}[0] + + final val status // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged.status|{}status[0] + final fun (): org.meshtastic.proto/LockdownStatus // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged.status.|(){}[0] + + final fun component1(): org.meshtastic.proto/LockdownStatus // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged.component1|component1(){}[0] + final fun copy(org.meshtastic.proto/LockdownStatus = ...): org.meshtastic.sdk/MeshEvent.LockdownStatusChanged // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged.copy|copy(org.meshtastic.proto.LockdownStatus){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.LockdownStatusChanged.toString|toString(){}[0] + } + final class MqttDisconnected : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.MqttDisconnected|null[0] constructor (kotlin/String? = ...) // org.meshtastic.sdk/MeshEvent.MqttDisconnected.|(kotlin.String?){}[0] diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index 8a8314b..6991edb 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -22,6 +22,7 @@ public abstract interface class org/meshtastic/sdk/AdminApi { public abstract fun getUIConfig (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun keyVerification (Lorg/meshtastic/proto/KeyVerificationAdmin;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun listChannels (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun lockdown (Lorg/meshtastic/proto/LockdownAuth;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun nodeDbReset (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun otaRequest (Lorg/meshtastic/proto/AdminMessage$OTAEvent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun reboot-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -684,6 +685,17 @@ public final class org/meshtastic/sdk/MeshEvent$KeyVerification : org/meshtastic public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshEvent$LockdownStatusChanged : org/meshtastic/sdk/MeshEvent { + public fun (Lorg/meshtastic/proto/LockdownStatus;)V + public final fun component1 ()Lorg/meshtastic/proto/LockdownStatus; + public final fun copy (Lorg/meshtastic/proto/LockdownStatus;)Lorg/meshtastic/sdk/MeshEvent$LockdownStatusChanged; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$LockdownStatusChanged;Lorg/meshtastic/proto/LockdownStatus;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$LockdownStatusChanged; + public fun equals (Ljava/lang/Object;)Z + public final fun getStatus ()Lorg/meshtastic/proto/LockdownStatus; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$MqttConnected : org/meshtastic/sdk/MeshEvent { public static final field INSTANCE Lorg/meshtastic/sdk/MeshEvent$MqttConnected; public fun equals (Ljava/lang/Object;)Z diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index b6e94ae..44b0ba4 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -15,6 +15,7 @@ import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.HamParameters import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.LockdownAuth import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.Position @@ -46,7 +47,9 @@ public interface AdminApi { * All calls on the returned instance route admin messages to the specified remote node * over the mesh. Note: `editSettings`, `batch`, `getDeviceConnectionStatus`, and lifecycle * commands (`reboot`, `shutdown`, `factoryReset`, `nodeDbReset`) work identically — the - * firmware handles admin-over-mesh transparently. + * firmware handles admin-over-mesh transparently. The sole exception is [lockdown], which is + * local-only (the firmware consumes its passphrase inline on the phone link); calling it on a + * remote-targeting instance returns [AdminResult.Unauthorized]. * * ```kotlin * val remoteAdmin = client.admin.forNode(NodeId(0x12345678.toInt())) @@ -228,6 +231,36 @@ public interface AdminApi { /** Initiate or respond to a key verification exchange. */ public suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult + // ── Lockdown (hardened builds) ────────────────────────────────────────── + + /** + * Provision, unlock, or lock the device's storage lockdown + * (`MESHTASTIC_LOCKDOWN` hardened firmware builds only). + * + * The device reports its current state via [MeshEvent.LockdownStatusChanged] — observe that to + * decide which [LockdownAuth] to send and to learn the outcome: + * - **Provision / unlock:** set [LockdownAuth.passphrase] (1–32 bytes). On success the device + * issues a session token bounded by [LockdownAuth.boots_remaining] (boot-count TTL, `0` = + * firmware default) and [LockdownAuth.valid_until_epoch] (absolute wall-clock expiry, `0` = + * no time limit). + * - **Lock now:** set [LockdownAuth.lock_now] = `true` (ignores the passphrase) to immediately + * revoke admin authorization and reboot into the locked state. + * + * **Local device only.** The firmware consumes `lockdown_auth` inline on the direct phone link + * (BLE/serial/TCP) and wipes the passphrase from memory before it can reach the mesh/admin + * routing layer. Calling this on a [forNode]-scoped instance therefore returns + * [AdminResult.Unauthorized] rather than leaking a passphrase onto the mesh. + * + * **Fire-and-forget.** [AdminResult.Success] means the request was queued onto the local link, + * not that the device accepted the passphrase — the device answers with a fresh + * [MeshEvent.LockdownStatusChanged] rather than a routing ACK. Returns + * [AdminResult.NodeUnreachable] if no session has been established yet. + * + * @param auth the lockdown command parameters + * @since 0.3.0 + */ + public suspend fun lockdown(auth: LockdownAuth): AdminResult + // ── OTA updates ───────────────────────────────────────────────────────── /** Reboot into OTA update mode after [after] (default: immediately). */ diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index cd3fc87..894bb87 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -182,6 +182,34 @@ public sealed interface MeshEvent { */ public data class FileInfoReceived(val info: org.meshtastic.proto.FileInfo) : MeshEvent + /** + * The connected device reported its hardened-build lockdown state. + * + * Only ever emitted by firmware compiled with `MESHTASTIC_LOCKDOWN` (storage-encryption / + * tamper-hardened builds). Such a device sends the variant **immediately after the handshake's + * `config_complete_id`** — to tell a freshly-connected, possibly-unauthorized client what it + * must do — and again in response to every [AdminApi.lockdown] command. Standard (non-hardened) + * builds simply never send it. + * + * Use [status] to drive provisioning / unlock UX: + * - [State.NEEDS_PROVISION][org.meshtastic.proto.LockdownStatus.State.NEEDS_PROVISION] — the + * device has no boot token; send an [AdminApi.lockdown] with a passphrase to provision it. + * - [State.LOCKED][org.meshtastic.proto.LockdownStatus.State.LOCKED] — storage is locked; + * `lock_reason` carries a machine-readable cause. Prompt for the passphrase and call + * [AdminApi.lockdown]. Treat unknown `lock_reason` values as "locked, ask for passphrase". + * - [State.UNLOCKED][org.meshtastic.proto.LockdownStatus.State.UNLOCKED] — `boots_remaining` + * and `valid_until_epoch` describe the issued session token's lifetime. + * - [State.UNLOCK_FAILED][org.meshtastic.proto.LockdownStatus.State.UNLOCK_FAILED] — + * `backoff_seconds` is how long the client must wait before the next passphrase attempt. + * + * This is the SDK's source of truth for lockdown availability: there is no firmware-version + * capability flag because lockdown is a build-time option, not a version gate. + * + * @property status the wire [LockdownStatus][org.meshtastic.proto.LockdownStatus] + * @since 0.3.0 + */ + public data class LockdownStatusChanged(val status: org.meshtastic.proto.LockdownStatus) : MeshEvent + /** * A transport-level error occurred. * diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 84784f1..77bc2c1 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -20,6 +20,7 @@ import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.HamParameters import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.LockdownAuth import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.NodeRemoteHardwarePinsResponse @@ -315,6 +316,23 @@ internal class AdminApiImpl( submitAdminAck(AdminMessage(key_verification = verification)) } + // ── Lockdown (hardened builds) ────────────────────────────────────────── + + override suspend fun lockdown(auth: LockdownAuth): AdminResult { + // Local-only: firmware (PhoneAPI::handleLockdownAuthInline) consumes lockdown_auth inline + // on the direct phone link and wipes the passphrase before it can reach the mesh. A + // remote-targeting instance must NOT send it — that would leak a passphrase onto the mesh + // where no inline handler exists. Managed-mode is intentionally NOT consulted: lockdown is + // a pre-auth security primitive, independent of admin authorization. + if (targetNode != null && targetNode.raw != engine.myNodeNumOrNull()) { + return AdminResult.Unauthorized + } + val local = engine.myNodeNumOrNull() ?: return AdminResult.NodeUnreachable + // Fire-and-forget: the device answers with a fresh FromRadio.lockdown_status + // (MeshEvent.LockdownStatusChanged), not a routing ACK. + return submitAdminFireAndForget(AdminMessage(lockdown_auth = auth), to = NodeId(local)) + } + // ── OTA updates ───────────────────────────────────────────────────────── override suspend fun rebootOta(after: Duration): AdminResult = retryOnSessionExpiry { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index e9ef4e4..73dbbb3 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -1686,10 +1686,12 @@ internal class MeshEngine( true } - // Lockdown (protobufs 2.7.25+, firmware support in flight): surfaced as a structured - // warning until a typed event lands alongside AdminMessage.lockdown_auth support. + // Lockdown state report from hardened (MESHTASTIC_LOCKDOWN) firmware builds. Sent right + // after config_complete_id (so it can arrive mid-handshake) and after every + // AdminApi.lockdown command. Surfaced as a typed event so hosts can drive provisioning / + // unlock UX without inspecting raw FromRadio variants. fromRadio.lockdown_status != null -> { - warnUnhandledVariant("lockdown_status", stage) + events.tryEmit(MeshEvent.LockdownStatusChanged(fromRadio.lockdown_status!!)) true } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt index 25006e9..67a5a6c 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt @@ -15,10 +15,12 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.DeviceConnectionStatus import org.meshtastic.proto.HamParameters import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.LockdownAuth import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum @@ -95,6 +97,49 @@ class AdminApiRemainingTest { ) } + @Test + fun lockdownLockNowSendsFireAndForgetToLocalNode() = runTest { + val auth = LockdownAuth(lock_now = true) + assertFireAndForgetSuccess( + call = { it.lockdown(auth) }, + requestMatches = { it.lockdown_auth == auth }, + ) + } + + @Test + fun lockdownProvisionSendsPassphraseFields() = runTest { + val auth = LockdownAuth( + passphrase = "hunter2".encodeToByteArray().toByteString(), + boots_remaining = 10, + valid_until_epoch = 1_900_000_000, + ) + assertFireAndForgetSuccess( + call = { it.lockdown(auth) }, + requestMatches = { it.lockdown_auth == auth }, + ) + } + + @Test + fun lockdownRejectsRemoteTargeting() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val remote = client.admin.forNode(NodeId(0x22222222)) + val result = remote.lockdown(LockdownAuth(lock_now = true)) + runCurrent() + + assertEquals(AdminResult.Unauthorized, result) + // The passphrase must never reach the wire when targeting a remote node. + val sentLockdown = transport.outboundPackets().drop(outboundBefore) + .any { adminOf(it)?.lockdown_auth != null } + assertFalse(sentLockdown, "lockdown_auth must not be sent to a remote node") + } finally { + runCatching { client.disconnect() } + } + } + @Test fun getRemoteHardwarePinsRequestsRemotePins() = runTest { val expected = NodeRemoteHardwarePinsResponse() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt index 4fb5f29..c1b13cc 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AndroidCutoverPrereqsTest.kt @@ -97,12 +97,13 @@ class AndroidCutoverPrereqsTest { runCurrent() assertEquals(MeshEvent.FileInfoReceived(fileInfo), awaitItem()) - // lockdown_status (protobufs 2.7.25+) is not yet typed — but it must not be - // silently dropped either. - transport.injectFrame(frameOf(FromRadio(lockdown_status = org.meshtastic.proto.LockdownStatus()))) + // lockdown_status (hardened MESHTASTIC_LOCKDOWN builds) surfaces as a typed event. + val lockdown = org.meshtastic.proto.LockdownStatus( + state = org.meshtastic.proto.LockdownStatus.State.LOCKED, + ) + transport.injectFrame(frameOf(FromRadio(lockdown_status = lockdown))) runCurrent() - val warning = assertIs(awaitItem()) - assertEquals("lockdown_status", warning.details["variant"]) + assertEquals(MeshEvent.LockdownStatusChanged(lockdown), awaitItem()) cancelAndIgnoreRemainingEvents() } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt index 7f657c4..6a67ee5 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt @@ -449,6 +449,8 @@ private class CapturingAdminApi : AdminApi { override suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult = unused() + override suspend fun lockdown(auth: org.meshtastic.proto.LockdownAuth): AdminResult = unused() + override suspend fun rebootOta(after: Duration): AdminResult = unused() override suspend fun otaRequest(event: AdminMessage.OTAEvent): AdminResult = unused() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/LockdownStatusEventTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/LockdownStatusEventTest.kt new file mode 100644 index 0000000..8f310b0 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/LockdownStatusEventTest.kt @@ -0,0 +1,75 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.LockdownStatus +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.sdk.testing.toFrame +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Inbound `FromRadio.lockdown_status` (hardened `MESHTASTIC_LOCKDOWN` firmware builds) must surface + * as a typed [MeshEvent.LockdownStatusChanged] rather than a generic `ProtocolWarning`. The same + * dispatch helper handles the variant during the handshake and after it, so this also covers the + * mid-handshake case the firmware actually uses (sent right after `config_complete_id`). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LockdownStatusEventTest { + + @Test + fun lockdownStatusSurfacesAsTypedEvent() = runTest { + val (transport, client) = connectedClient() + val events = mutableListOf() + val job = backgroundScope.launch { client.events.collect { events.add(it) } } + client.connect() + runCurrent() + + val status = LockdownStatus( + state = LockdownStatus.State.LOCKED, + lock_reason = "needs_auth", + backoff_seconds = 0, + ) + transport.injectFrame(FromRadio(lockdown_status = status).toFrame()) + runCurrent() + + val event = events.filterIsInstance().singleOrNull() + assertTrue(event != null, "expected a typed LockdownStatusChanged event, got: $events") + assertEquals(status, event.status) + // No generic protocol warning should be emitted for a recognized variant. + assertTrue(events.none { it is MeshEvent.ProtocolWarning }) + + job.cancel() + runCatching { client.disconnect() } + } + + private fun kotlinx.coroutines.test.TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:lockdown"), + autoHandshake = true, + nodeNum = 0x11111111, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .rpcTimeout(60.seconds) + .sendTimeout(60.seconds) + .build() + return transport to client + } +} diff --git a/docs/SPEC.md b/docs/SPEC.md index e0714c5..505c5c1 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -53,7 +53,7 @@ A faithful Kotlin client of the Meshtastic device PhoneAPI (the host-side wire p 10. **Pre-1.0:** breaking changes allowed; require `updateKotlinAbi` + CHANGELOG entry. **1.0+:** binary-compatibility-validator hard-gates. ### Authoritative protocol sources -- **Schema:** [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) — vendored as `proto/src/protobufs` git submodule. +- **Schema:** [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) — consumed as the published `org.meshtastic:protobufs` Maven artifact (pinned in `gradle/libs.versions.toml`), not vendored. - **Behavior reference:** [`meshtastic/firmware`](https://github.com/meshtastic/firmware) (read-only). - **`Meshtastic-Android`** — port what's useful (codec, handshake FSM, BLE handler, encryption helpers) into `commonMain` / `androidMain` as appropriate. - **Real device** — required from Phase 2 onward. @@ -548,6 +548,9 @@ public interface AdminApi { public suspend fun addContact(contact: SharedContact): AdminResult public suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult + // ── Lockdown (hardened MESHTASTIC_LOCKDOWN builds; local-only, fire-and-forget) ── + public suspend fun lockdown(auth: LockdownAuth): AdminResult + // ── OTA / Sensor / Simulator ── public suspend fun rebootOta(after: Duration = Duration.ZERO): AdminResult public suspend fun otaRequest(event: AdminMessage.OTAEvent): AdminResult @@ -853,8 +856,8 @@ These are the protocol behaviors any working Meshtastic client must honor. They | Tool | Purpose | |---|---| | Gradle 9.x + Kotlin DSL + version catalog (`gradle/libs.versions.toml`) | Build (Gradle 9.4.1) | -| Convention plugins in `build-logic/convention/` | `meshtastic.kmp.library`, `meshtastic.android.library`, `meshtastic.publishing`, `meshtastic.proto`, `meshtastic.ios.framework`, `meshtastic.sample.jvm`, `meshtastic.sample.android` | -| Kotlin `explicitApi("strict")` on all `:sdk-*` modules **except `:proto`** (Wire-generated; modifier doesn't apply meaningfully to codegen) | +| Convention plugins in `build-logic/convention/` | `meshtastic.kmp.library`, `meshtastic.android.library`, `meshtastic.publishing`, `meshtastic.ios.framework`, `meshtastic.sample.jvm`, `meshtastic.sample.android` | +| Kotlin `explicitApi("strict")` on all `:sdk-*` modules (proto types are an external artifact, not an in-tree module) | | `org.jetbrains.kotlin.multiplatform` + `com.android.kotlin.multiplatform.library` | New Android KMP library plugin — what MQTTastic uses | | Spotless + ktlint + `licenseHeaderFile` | Formatting + GPL-3.0 header enforcement (copy `config/spotless/copyright.kt` from MQTTastic) | | Detekt (config from MQTTastic `config/detekt/detekt.yml`) | Static analysis | @@ -917,7 +920,7 @@ The Meshtastic org already publishes official KMP libraries under `org.meshtasti - `AGENTS.md` + `CLAUDE.md` + `.github/copilot-instructions.md` (redirect pattern) - `docker/` if relevant for CI integration testing - [ ] `.gitignore`. `.editorconfig`. -- [ ] Submodule: `proto/src/protobufs` → `meshtastic/protobufs` `main`. +- [ ] ~~Submodule: `proto/src/protobufs` → `meshtastic/protobufs` `main`.~~ **Superseded by [ADR-015](decisions/015-consume-published-protobufs-artifact.md):** the schema is consumed as the published `org.meshtastic:protobufs` artifact, not vendored as a submodule. - [ ] Gradle wrapper (9.x). `settings.gradle.kts` with `pluginManagement` + `dependencyResolutionManagement` (copy from MQTTastic, add multi-module includes). - [ ] Add to version catalog the SDK-specific deps not present in MQTTastic: `wire`, `sqldelight`, `kable`, `usb-serial-for-android`, `jSerialComm`, `mokkery`, `turbine`, `kotest`, `power-assert`, `axion-release`. - [ ] `build-logic/convention/` plugins: `meshtastic.kmp.library`, `meshtastic.android.library`, `meshtastic.publishing`, `meshtastic.proto`, `meshtastic.ios.framework`, `meshtastic.sample.jvm`, `meshtastic.sample.android`. Library plugin encodes the MQTTastic `applyDefaultHierarchyTemplate()` + custom intermediate-source-set pattern. @@ -932,7 +935,7 @@ The Meshtastic org already publishes official KMP libraries under `org.meshtasti ### Phase 1 — Codec + engine skeleton -- [ ] `proto/` — Wire 6 generates DTOs into private package from `proto/src/protobufs`. All targets. +- [ ] ~~`proto/` — Wire 6 generates DTOs into private package from `proto/src/protobufs`. All targets.~~ **Superseded by [ADR-015](decisions/015-consume-published-protobufs-artifact.md):** there is no `:proto` module; `:core` depends on the published `org.meshtastic:protobufs` artifact via `api(libs.meshtasticProtobufs)`. - [ ] ~~`proto-raw/` — Re-export generated package as a public escape-hatch artifact.~~ **Dropped per ADR-001:** `:proto` itself is the public artifact; no separate escape-hatch needed. - [ ] `core/` — Value classes (`NodeId`, `ChannelIndex`, `MessageId`, `TransportIdentity`), sealed `MeshtasticException`, `Frame`, `RadioTransport` interface, `StorageProvider`/`DeviceStorage` interfaces, `LogSink`. - [ ] `core/` — `WireCodec`: framing encode/decode, resync (per v1 §8.2: scan for `0x94 0xC3`, length ≤ 512, drop garbage). Property tests for round-trip on every `ToRadio`/`FromRadio` variant. **Fuzz tests** on random byte streams to validate resync robustness. @@ -1078,7 +1081,7 @@ From Phase 2: a dedicated radio either attached to a runner (TCP-over-WiFi night ## 9. References ### 9.1 Authoritative protocol -- [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) — schema (vendored) +- [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) — schema (published `org.meshtastic:protobufs` artifact) - [`meshtastic/firmware`](https://github.com/meshtastic/firmware) — behavior reference - [Meshtastic dev docs — Client API](https://meshtastic.org/docs/development/device/client-api/) - [Meshtastic dev docs — Mesh Algorithm](https://meshtastic.org/docs/overview/mesh-algo/) diff --git a/docs/api-reference.md b/docs/api-reference.md index 8b2e9ff..4da1dde 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -242,7 +242,7 @@ public sealed interface MeshEvent { public enum class DroppedFlow { Packets, Events } ``` -`ProtocolWarning` is non-fatal advisory ("skipped malformed envelope", etc.); the optional `details` map carries structured context (e.g., `"packet_id" to "0x1234"`). `IdentityRebound` is the dedicated signal for the engine clearing storage after a NodeNum change (see [storage.md §"Consumer-observable signal (R-9)"](./architecture/storage.md#consumer-observable-signal-r-9)). `PacketsDropped` is the only observability hook for backpressure-induced loss; emitted at most once per drop burst. `StorageDegraded` fires when the storage backend transitions to read-through-only mode. `CongestionWarning` fires when the device's outbound queue is critically full. `ExternalConfigChange` fires when an unsolicited admin message from firmware updates the local config state. `SecurityWarning.*` flag suspect key material observed in inbound `node_info` payloads. *(since 0.2.0)* `MqttProxyMessage` carries the device's `mqttClientProxyMessage` traffic for hosts implementing the device-as-MQTT-proxy side channel; `XmodemPacket` carries raw `XModem` file-transfer packets; `FileInfoReceived` carries an `org.meshtastic.proto.FileInfo` advertisement (the variant is named `FileInfoReceived` — not `FileInfo` — to avoid clashing with the proto type). +`ProtocolWarning` is non-fatal advisory ("skipped malformed envelope", etc.); the optional `details` map carries structured context (e.g., `"packet_id" to "0x1234"`). `IdentityRebound` is the dedicated signal for the engine clearing storage after a NodeNum change (see [storage.md §"Consumer-observable signal (R-9)"](./architecture/storage.md#consumer-observable-signal-r-9)). `PacketsDropped` is the only observability hook for backpressure-induced loss; emitted at most once per drop burst. `StorageDegraded` fires when the storage backend transitions to read-through-only mode. `CongestionWarning` fires when the device's outbound queue is critically full. `ExternalConfigChange` fires when an unsolicited admin message from firmware updates the local config state. `SecurityWarning.*` flag suspect key material observed in inbound `node_info` payloads. *(since 0.2.0)* `MqttProxyMessage` carries the device's `mqttClientProxyMessage` traffic for hosts implementing the device-as-MQTT-proxy side channel; `XmodemPacket` carries raw `XModem` file-transfer packets; `FileInfoReceived` carries an `org.meshtastic.proto.FileInfo` advertisement (the variant is named `FileInfoReceived` — not `FileInfo` — to avoid clashing with the proto type). *(since 0.3.0)* `LockdownStatusChanged` carries the device's `LockdownStatus` report from hardened (`MESHTASTIC_LOCKDOWN`) firmware builds — sent right after `config_complete_id` and after each `AdminApi.lockdown` command — and is the source of truth for lockdown availability (no firmware-version capability flag — lockdown is a build-time firmware option). ## `NodeChange` diff --git a/docs/architecture/enforcement.md b/docs/architecture/enforcement.md index 30c9e2a..331e0c0 100644 --- a/docs/architecture/enforcement.md +++ b/docs/architecture/enforcement.md @@ -12,8 +12,8 @@ home — do not duplicate the same rule across tools. | Invariant | Source of truth | Enforced by | Where to look | | --- | --- | --- | --- | -| `:core` depends only on `:proto` | [ADR-002](../decisions/002-architecture.md), [`module-graph.md`](module-graph.md) | Gradle task: `:core:verifyModuleBoundary` (wired into `check`) | [`core/build.gradle.kts`](../../core/build.gradle.kts) | -| No transport / storage code in `:core` | [ADR-002](../decisions/002-architecture.md) | Same `:core:verifyModuleBoundary` task — any non-`:proto` project dep fails the build | [`core/build.gradle.kts`](../../core/build.gradle.kts) | +| `:core` declares no in-tree project dependencies (proto types come from the `org.meshtastic:protobufs` artifact) | [ADR-002](../decisions/002-architecture.md), [ADR-015](../decisions/015-consume-published-protobufs-artifact.md), [`module-graph.md`](module-graph.md) | Gradle task: `:core:verifyModuleBoundary` (wired into `check`) | [`core/build.gradle.kts`](../../core/build.gradle.kts) | +| No transport / storage code in `:core` | [ADR-002](../decisions/002-architecture.md) | Same `:core:verifyModuleBoundary` task — any project dependency fails the build | [`core/build.gradle.kts`](../../core/build.gradle.kts) | | Engine paths use no shared-state primitives (`Mutex`, `Semaphore`, `ReentrantLock`) | [ADR-002](../decisions/002-architecture.md) — single-writer actor | detekt `ForbiddenImport` rule (`style.ForbiddenImport`) | [`config/detekt/detekt.yml`](../../config/detekt/detekt.yml) | | `kotlin.Result` is never used (public or internal) | [ADR-005](../decisions/005-api-shape.md) | detekt `ForbiddenImport` rule + Binary Compatibility Validator catches public-API leaks | [`config/detekt/detekt.yml`](../../config/detekt/detekt.yml), `api/*.api` | | Public API surface is reviewed and stable | [ADR-005](../decisions/005-api-shape.md), [`docs/versioning.md`](../versioning.md) | Kotlinx Binary Compatibility Validator (`./gradlew checkKotlinAbi`) | `api/jvm/*.api`, `api/android/*.api` per module | diff --git a/docs/architecture/meshtastic-android-migration.md b/docs/architecture/meshtastic-android-migration.md index e8b7480..07516ce 100644 --- a/docs/architecture/meshtastic-android-migration.md +++ b/docs/architecture/meshtastic-android-migration.md @@ -36,7 +36,7 @@ graph TD 1. **`libs.versions.toml` Alignment:** * Align Wire, Coroutines, and KMP versions with the SDK. Current pinned versions: Wire 6.2.0, Coroutines 1.10.2, Kotlin 2.3.21, Koin 4.2.1 — verify these match SDK requirements before bumping. - * Add `:sdk-core`, `:sdk-proto`, `:sdk-transport-ble`, `:sdk-transport-tcp`, `:sdk-transport-serial`, `:sdk-storage-sqldelight`, and `:sdk-testing` modules. + * Add `:sdk-core` (which re-exports the `org.meshtastic.proto.*` types via the `org.meshtastic:protobufs` artifact), `:sdk-transport-ble`, `:sdk-transport-tcp`, `:sdk-transport-serial`, `:sdk-storage-sqldelight`, and `:sdk-testing` modules. 2. **Model Swap (`core/model` to Wire):** * The app has `core:model:NodeInfo`. The SDK uses `org.meshtastic.proto.NodeInfo` (Wire). * Delete the custom `core/model` protobuf mappings (70 files, ~719 import sites across `app` and `feature` modules — this is the single largest mechanical task in the migration). Perform a surgical package rename across the `app` and `core` modules to use the generated Wire types directly. Keep in mind that Wire generates `snake_case` properties (e.g. `node.user.long_name`). diff --git a/docs/architecture/module-graph.md b/docs/architecture/module-graph.md index 4ab8090..95c80dc 100644 --- a/docs/architecture/module-graph.md +++ b/docs/architecture/module-graph.md @@ -2,7 +2,7 @@ > Visual companion to [`../decisions/006-multi-module-rationale.md`](../decisions/006-multi-module-rationale.md). The graph is enforced by the Gradle build (`:core:verifyModuleBoundary`) and detekt. > -> **`RadioTransport`, `Frame`, and `TransportIdentity` live in `:core`** (per `SPEC.md` §3.4) — there is no `:transport-api` module. Transport implementation modules depend directly on `:core` for the interface and on `:proto` for the wire types. +> **`RadioTransport`, `Frame`, and `TransportIdentity` live in `:core`** (per `SPEC.md` §3.4) — there is no `:transport-api` module. There is also **no `:proto` module**: protobuf types come from the published `org.meshtastic:protobufs` artifact, which `:core` re-exports via `api(...)`. Transport implementation modules depend only on `:core` for both the interface and the wire types (the latter transitively). ## MVP library modules @@ -11,8 +11,7 @@ flowchart TB classDef pub fill:#dff7df,stroke:#2a8a2a; classDef external fill:#eee,stroke:#888,stroke-dasharray: 4 4; - proto[":proto
(Wire-generated)"]:::pub - core[":core
RadioClient, engine,
RadioTransport interface"]:::pub + core[":core
RadioClient, engine,
RadioTransport interface,
re-exports proto types (api)"]:::pub ble[":transport-ble
(Kable)"]:::pub tcp[":transport-tcp
(Ktor sockets)"]:::pub @@ -23,33 +22,24 @@ flowchart TB testing[":testing
(InMemoryStorage, fakes)"]:::pub bom[":bom
(version alignment)"]:::pub - extProtobufs[("meshtastic/
protobufs")]:::external + extProtobufs[("org.meshtastic:protobufs
(published artifact)")]:::external - extProtobufs -->|submodule| proto + extProtobufs -->|api dependency| core - proto --> core - - proto --> ble core --> ble - proto --> tcp core --> tcp - proto --> serial core --> serial - core --> storageSql - proto --> storageSql - core --> testing - proto --> testing ``` -**Read the arrows as `produces input for`** (i.e., `:proto → :core` means `:core` depends on `:proto`). +**Read the arrows as `produces input for`** (i.e., `:core → :transport-ble` means `:transport-ble` depends on `:core`). The wire (proto) types reach every module transitively through `:core`'s `api(org.meshtastic:protobufs)` declaration — only `:core` names the artifact directly. ### Hard rules (Gradle + detekt enforced) -1. `:core` depends on `:proto`. **Nothing else.** It defines the `RadioTransport` interface itself. -2. Transport modules depend on `:core` (for `RadioTransport` + `Frame` + `TransportIdentity`) and `:proto` (for wire payloads). They are wired into a `RadioClient` via `Builder.transport(...)` at construction time. -3. Storage modules depend on `:core` (for `StorageProvider` + `DeviceStorage`) and `:proto`. +1. `:core` depends only on the published `org.meshtastic:protobufs` artifact — **no in-tree project dependencies** (enforced by `:core:verifyModuleBoundary`). It defines the `RadioTransport` interface itself and re-exports proto types via `api`. +2. Transport modules depend on `:core` (for `RadioTransport` + `Frame` + `TransportIdentity`, plus the wire types transitively). They are wired into a `RadioClient` via `Builder.transport(...)` at construction time. +3. Storage modules depend on `:core` (for `StorageProvider` + `DeviceStorage`, plus the wire types transitively). 4. `:testing` may depend on anything published. 5. `:samples/*` may depend on anything; **nothing depends on `:samples/*`**; samples are not published. 6. `:bom` is a leaf — it lists every published artifact's coordinate but compiles to a Maven BOM only. @@ -58,7 +48,6 @@ flowchart TB | Module | android | jvm | iosArm64 | iosX64 | iosSimulatorArm64 | |---|:---:|:---:|:---:|:---:|:---:| -| `:proto` | ✓ | ✓ | ✓ | ✓ | ✓ | | `:core` | ✓ | ✓ | ✓ | ✓ | ✓ | | `:transport-ble` | ✓ | ✓ | ✓ | ✓ | ✓ | | `:transport-tcp` | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -122,17 +111,18 @@ flowchart TB | Module | Targets (planned) | Depends on | Roadmap link | |---|---|---|---| -| `:rpc` | all incl. `wasmJs` | `:proto` | wasm-rpc-roadmap | -| `:transport-rpc` | all incl. `wasmJs` | `:rpc`, `:proto` (NOT `:core`) | wasm-rpc-roadmap | +| `:rpc` | all incl. `wasmJs` | `org.meshtastic:protobufs` | wasm-rpc-roadmap | +| `:transport-rpc` | all incl. `wasmJs` | `:rpc`, `org.meshtastic:protobufs` (NOT `:core`) | wasm-rpc-roadmap | | `:host-rpc-server` | jvm, android | `:core`, `:rpc` | wasm-rpc-roadmap | -| `:transport-mqtt-proxy` | android, jvm, ios | `:core`, `:proto`, `org.meshtastic:mqtt-client` | SPEC §6 Phase 6 | -| `wasmJs` source set on `:proto` and `:core` | — | — | wasm-rpc-roadmap | +| `:transport-mqtt-proxy` | android, jvm, ios | `:core`, `org.meshtastic:mqtt-client` | SPEC §6 Phase 6 | +| `wasmJs` source set on `:core` | — | — | wasm-rpc-roadmap | These do not exist in the MVP graph; they are a destination, not a current state. ## Related - ADR-006 — module rationale. +- ADR-015 — proto sourcing (the external `org.meshtastic:protobufs` artifact replaces the former in-tree `:proto` module). - ADR-002 — engine architecture (justifies the `:core` ↔ transport boundary). - ADR-003 — tooling (justifies which library each transport module wraps). - `transport-isolation.md` — deep dive on why transports are separate modules, error handling differences, and how to add a new transport. diff --git a/docs/architecture/transport-comparison.md b/docs/architecture/transport-comparison.md index 97ffce6..96d23b4 100644 --- a/docs/architecture/transport-comparison.md +++ b/docs/architecture/transport-comparison.md @@ -53,7 +53,7 @@ Per-target Android / FGS detail: [`android-platform-constraints.md`](./android-p The `RadioTransport` contract is small and stable. To add a new transport (LoRa proxy, WebSocket bridge, mock): -1. Create a new module (e.g. `:transport-foo`); depend only on `:core` and `:proto`. +1. Create a new module (e.g. `:transport-foo`); depend only on `:core` (which re-exports the wire types). 2. Implement `RadioTransport` honoring [ADR-012](../decisions/012-transport-threading.md): never block the engine's coroutine context; funnel inbound bytes through a single `Flow`; expose a `StateFlow` that walks diff --git a/docs/architecture/transport-isolation.md b/docs/architecture/transport-isolation.md index 1f38da3..d31e2a6 100644 --- a/docs/architecture/transport-isolation.md +++ b/docs/architecture/transport-isolation.md @@ -23,7 +23,7 @@ The `RadioTransport` interface is defined in `:core`, but concrete transport imp 3. **Pluggable at build time.** Custom transport implementations (e.g., `transport-mqtt-proxy`, or a proprietary `transport-lora`) can be swapped in without modifying `:core`. -4. **Cleaner dependency trees.** `:core` has exactly two dependencies: `:proto` (wire types) and `kotlinx-coroutines` (actor concurrency). Each transport brings only its own heavy lifting (Kable for BLE, Ktor for TCP, jSerialComm/usb-serial for serial). +4. **Cleaner dependency trees.** `:core` has exactly two dependencies: the `org.meshtastic:protobufs` artifact (wire types) and `kotlinx-coroutines` (actor concurrency). Each transport brings only its own heavy lifting (Kable for BLE, Ktor for TCP, jSerialComm/usb-serial for serial). ## Per-transport implementation patterns @@ -81,7 +81,7 @@ All emit `TransportState.Error(recoverable: Boolean)` to the engine inbox. The e ## Adding a new transport 1. Create a new module `:transport-newname` in the root. -2. Depend on `:core` (for `RadioTransport`, `Frame`, `TransportState`, `TransportIdentity`) and `:proto` (for wire types). +2. Depend on `:core` (for `RadioTransport`, `Frame`, `TransportState`, `TransportIdentity`; the wire types arrive transitively through `:core`). 3. Implement `RadioTransport`: ```kotlin class NewTransport : RadioTransport { diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 12ffc3f..fd5d079 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -83,9 +83,9 @@ turn: | `arch-consistency` | `./gradlew :core:verifyModuleBoundary detekt` | ADR-008 enforcement: `:core` deps + ForbiddenImport rules | | `full-check` | `./gradlew check` | Full gate (PR-gated) — runs every applicable task as a safety net | -All jobs check out submodules (`submodules: recursive`) because protobufs -are vendored. All jobs use `gradle/actions/setup-gradle` (build cache -shared across jobs). +Protobuf types resolve from the published `org.meshtastic:protobufs` Maven +artifact, so no submodule checkout is needed. All jobs use +`gradle/actions/setup-gradle` (build cache shared across jobs). ### Caching @@ -156,8 +156,7 @@ plugin layers an `androidTarget` (single-variant via | Module | JVM | Android | iOS¹ | Notes | |---|:-:|:-:|:-:|---| -| `:proto` | ✅ | ✅ | ✅ | Wire-generated. `-Werror` relaxed (see [`proto/build.gradle.kts`](../proto/build.gradle.kts)). | -| `:core` | ✅ | ✅ | ✅ | Engine + interfaces. ADR-008 enforces `:proto`-only deps. | +| `:core` | ✅ | ✅ | ✅ | Engine + interfaces; re-exports the `org.meshtastic:protobufs` types via `api`. ADR-008 enforces no in-tree project deps. | | `:storage-sqldelight` | ✅ (Sqlite JDBC) | ✅ (Android driver) | ✅ (Native driver, `appleMain`) | Per-target driver factory; PRAGMA `journal_mode=WAL` + `synchronous=NORMAL` set in [`SqlDelightStorageProvider.apple.kt`](../storage-sqldelight/src/appleMain/kotlin/org/meshtastic/kmp/storage/sqldelight/SqlDelightStorageProvider.apple.kt) (matched by JVM/Android driver factories). | | `:transport-tcp` | ✅ | ✅ | ✅ | Pure ktor-network. | | `:transport-ble` | ✅ (macOS / Windows / Linux via Kable JVM) | ✅ | ✅ | Kable backends per platform. | @@ -239,12 +238,12 @@ procedure. ## Renovate -Dependency updates (Gradle, GitHub Actions, **proto submodule**) are -managed by Renovate; config at [`../renovate.json`](../renovate.json). -The `git-submodules` manager opens periodic PRs for -`proto/src/protobufs` — review the API diff carefully because new -`oneof` arms break consumer exhaustive `when` and constitute a MINOR -bump per [`versioning.md`](./versioning.md). +Dependency updates (Gradle — including **`org.meshtastic:protobufs`** — and +GitHub Actions) are managed by Renovate; config at +[`../renovate.json`](../renovate.json). The `gradle` manager opens PRs for new +`org.meshtastic:protobufs` versions — review the API diff carefully because new +`oneof` arms break consumer exhaustive `when` and constitute a MINOR bump per +[`versioning.md`](./versioning.md). ## Roadmap @@ -275,5 +274,5 @@ own workflow file when implemented: `checkKotlinAbi` + the manual release workflow. - [`manual-tests.md`](./manual-tests.md) — what CI cannot test (real hardware paths). -- [`../renovate.json`](../renovate.json) — dependency + submodule - updater config. +- [`../renovate.json`](../renovate.json) — dependency updater config + (Gradle artifacts incl. `org.meshtastic:protobufs`, GitHub Actions). diff --git a/docs/decisions/000-charter.md b/docs/decisions/000-charter.md index a820db1..f2a275c 100644 --- a/docs/decisions/000-charter.md +++ b/docs/decisions/000-charter.md @@ -4,10 +4,12 @@ **Date:** 2026-04-17 **Deciders:** SDK leads (Meshtastic org) **Supersedes:** none -**Related:** [`../SPEC.md`](../SPEC.md), [`../protocol.md`](../protocol.md), [`meshtastic/mqtt-client`](https://github.com/meshtastic/mqtt-client), [`meshtastic/Meshtastic-Android`](https://github.com/meshtastic/Meshtastic-Android), [`meshtastic/Meshtastic-Apple`](https://github.com/meshtastic/Meshtastic-Apple) +**Related:** [`../SPEC.md`](../SPEC.md), [`../protocol.md`](../protocol.md), [ADR-015](015-consume-published-protobufs-artifact.md) (proto sourcing), [`meshtastic/mqtt-client`](https://github.com/meshtastic/mqtt-client), [`meshtastic/Meshtastic-Android`](https://github.com/meshtastic/Meshtastic-Android), [`meshtastic/Meshtastic-Apple`](https://github.com/meshtastic/Meshtastic-Apple) --- +> **Note (2026-06-13):** the `meshtastic/protobufs` git submodule referenced below was replaced by the published `org.meshtastic:protobufs` Maven artifact — see [ADR-015](015-consume-published-protobufs-artifact.md). The charter is otherwise unchanged: protobufs are still consumed unmodified, and bumping that dependency is still the only way new fields land. + ## Context The Meshtastic ecosystem already has: diff --git a/docs/decisions/001-public-api-uses-generated-protobufs.md b/docs/decisions/001-public-api-uses-generated-protobufs.md index 2ee780d..a0a4e2a 100644 --- a/docs/decisions/001-public-api-uses-generated-protobufs.md +++ b/docs/decisions/001-public-api-uses-generated-protobufs.md @@ -1,13 +1,15 @@ # ADR 001 — Public API uses Wire-generated protobuf types directly -**Status:** Accepted +**Status:** Accepted (proto *sourcing* superseded by [ADR-015](015-consume-published-protobufs-artifact.md); the decision below stands) **Date:** 2026-04-17 **Deciders:** SDK leads **Supersedes:** none -**Related:** [`../protocol.md`](../protocol.md), `meshtastic/protobufs` submodule, [Square Wire docs](https://square.github.io/wire/) +**Related:** [`../protocol.md`](../protocol.md), [ADR-015](015-consume-published-protobufs-artifact.md) (proto sourcing), [Square Wire docs](https://square.github.io/wire/) --- +> **Note (2026-06-13):** the in-tree, public `:proto` module and the `proto/src/protobufs` git submodule described below were replaced by the published `org.meshtastic:protobufs` Maven artifact — see [ADR-015](015-consume-published-protobufs-artifact.md). This ADR's decision is unaffected: Wire-generated types remain **the** SDK public data model; only their provenance moved from in-tree codegen to a published dependency. Read "`:proto` module" / "`api(project(":proto"))`" below as "the `org.meshtastic:protobufs` artifact" / "`api(libs.meshtasticProtobufs)`". + ## Context The Meshtastic device PhoneAPI is defined entirely by the protobuf schema vendored from `meshtastic/protobufs`. The SDK must encode/decode every message in that schema (`ToRadio`, `FromRadio`, `MeshPacket`, `Data`, `Position`, `NodeInfo`, `Telemetry`, `Config`, `ModuleConfig`, `Channel`, `User`, `AdminMessage`, …). diff --git a/docs/decisions/003-tooling.md b/docs/decisions/003-tooling.md index 4981571..b3f8e96 100644 --- a/docs/decisions/003-tooling.md +++ b/docs/decisions/003-tooling.md @@ -4,10 +4,12 @@ **Date:** 2026-04-17 **Deciders:** SDK leads **Supersedes:** none -**Related:** [`../SPEC.md`](../SPEC.md) §5, ADR-000 (charter), ADR-001 (proto choice), [`../versioning.md`](../versioning.md) (SemVer, ABI, release policy — authoritative source), [`meshtastic/mqtt-client`](https://github.com/meshtastic/mqtt-client) (sibling KMP library) +**Related:** [`../SPEC.md`](../SPEC.md) §5, ADR-000 (charter), ADR-001 (proto choice), [ADR-015](015-consume-published-protobufs-artifact.md) (proto sourcing), [`../versioning.md`](../versioning.md) (SemVer, ABI, release policy — authoritative source), [`meshtastic/mqtt-client`](https://github.com/meshtastic/mqtt-client) (sibling KMP library) --- +> **Note (2026-06-13):** the SDK no longer runs Wire codegen locally or vendors `proto/src/protobufs` as a submodule (see the `wire-gradle-plugin` row below). Protobuf types now come from the published `org.meshtastic:protobufs` artifact — Wire is still the upstream codegen, just run upstream. See [ADR-015](015-consume-published-protobufs-artifact.md). + ## Context `meshtastic-sdk` is a Kotlin Multiplatform library targeting Android, JVM, iOS (`Arm64`/`X64`/`SimulatorArm64`) for MVP, with `wasmJs` (RPC client only) on the post-1.0 roadmap once the supporting tooling matures (see [`../future/wasm-rpc-roadmap.md`](../future/wasm-rpc-roadmap.md)). It needs: diff --git a/docs/decisions/006-multi-module-rationale.md b/docs/decisions/006-multi-module-rationale.md index 2df72c4..ce340a6 100644 --- a/docs/decisions/006-multi-module-rationale.md +++ b/docs/decisions/006-multi-module-rationale.md @@ -1,13 +1,15 @@ # ADR 006 — Multi-module rationale -**Status:** Accepted +**Status:** Accepted (the `:proto` module was superseded by [ADR-015](015-consume-published-protobufs-artifact.md); the rest of the layout stands) **Date:** 2026-04-17 **Deciders:** SDK leads **Supersedes:** none -**Related:** [`../SPEC.md`](../SPEC.md) §2, ADR-000 (charter), ADR-001 (proto types), ADR-002 (architecture), ADR-007 (iOS distribution), [`../future/wasm-rpc-roadmap.md`](../future/wasm-rpc-roadmap.md), [`meshtastic/mqtt-client`](https://github.com/meshtastic/mqtt-client) (single-module precedent) +**Related:** [`../SPEC.md`](../SPEC.md) §2, ADR-000 (charter), ADR-001 (proto types), ADR-002 (architecture), ADR-007 (iOS distribution), [ADR-015](015-consume-published-protobufs-artifact.md) (proto sourcing), [`../future/wasm-rpc-roadmap.md`](../future/wasm-rpc-roadmap.md), [`meshtastic/mqtt-client`](https://github.com/meshtastic/mqtt-client) (single-module precedent) --- +> **Note (2026-06-13):** there is no longer an in-tree `:proto` module or `proto/src/protobufs` submodule. Protobuf types come from the published `org.meshtastic:protobufs` artifact, which `:core` re-exports via `api(libs.meshtasticProtobufs)` — see [ADR-015](015-consume-published-protobufs-artifact.md). Throughout this ADR, read the `:proto` module (and its dependency edges, per-target row, and "bump the proto submodule") as **that external artifact**; the multi-module rationale below is otherwise current. The live module graph is [`../architecture/module-graph.md`](../architecture/module-graph.md). + ## Context `mqtt-client` is a single Gradle module — and that's fine for a single-protocol library with no platform-specific transport variants. `meshtastic-sdk` is different: diff --git a/docs/decisions/008-architecture-enforcement.md b/docs/decisions/008-architecture-enforcement.md index 4e09a09..bded85e 100644 --- a/docs/decisions/008-architecture-enforcement.md +++ b/docs/decisions/008-architecture-enforcement.md @@ -6,10 +6,12 @@ | **Date** | 2026-04-19 | | **Deciders** | maintainers | | **Supersedes** | (none) | -| **Related** | [ADR-002](./002-architecture.md), [ADR-005](./005-api-shape.md), [ADR-006](./006-multi-module-rationale.md), [`docs/architecture/module-graph.md`](../architecture/module-graph.md) | +| **Related** | [ADR-002](./002-architecture.md), [ADR-005](./005-api-shape.md), [ADR-006](./006-multi-module-rationale.md), [ADR-015](./015-consume-published-protobufs-artifact.md), [`docs/architecture/module-graph.md`](../architecture/module-graph.md) | ## Context +> **Note (2026-06-13):** "`:core` depends only on `:proto`" below now reads as "`:core` declares **no** in-tree project dependencies" — proto types come from the published `org.meshtastic:protobufs` artifact, not an in-tree `:proto` module (see [ADR-015](./015-consume-published-protobufs-artifact.md)). `:core:verifyModuleBoundary` accordingly fails on *any* project dependency. The enforcement decision (detekt + Gradle, not Konsist) is unchanged. + The audit flagged that AGENTS.md, CONTRIBUTING.md, and the ADR template all reference `./gradlew :core:konsistTest` and "Konsist rule" follow-ups, but there is no Konsist test code anywhere in the repository — only a dangling diff --git a/docs/decisions/013-proto-json-envelope.md b/docs/decisions/013-proto-json-envelope.md index a53882f..51af1d1 100644 --- a/docs/decisions/013-proto-json-envelope.md +++ b/docs/decisions/013-proto-json-envelope.md @@ -6,7 +6,7 @@ | **Date** | 2026-04-19 | | **Deciders** | Maintainers | | **Supersedes** | — | -| **Related** | [ADR-001](001-public-api-uses-generated-protobufs.md), [ADR-009](009-cli-architecture.md) | +| **Related** | [ADR-001](001-public-api-uses-generated-protobufs.md), [ADR-009](009-cli-architecture.md), [ADR-015](015-consume-published-protobufs-artifact.md) | ## Context @@ -75,7 +75,7 @@ or CLI-defined object fits. Records are newline-delimited - Agents and CI eval scripts get a deterministic, schema-validated output stream. - New protobuf fields show up automatically in JSON output the next time - the submodule updates. + the `org.meshtastic:protobufs` artifact is bumped. - `jq` queries against CLI output become tests: `cli probe ... | jq -e '.payload.config_complete_id'`. diff --git a/docs/decisions/015-consume-published-protobufs-artifact.md b/docs/decisions/015-consume-published-protobufs-artifact.md new file mode 100644 index 0000000..1a5c451 --- /dev/null +++ b/docs/decisions/015-consume-published-protobufs-artifact.md @@ -0,0 +1,67 @@ +# ADR-015: Consume the published `org.meshtastic:protobufs` artifact instead of vendoring the schema + +| Field | Value | +|---|---| +| **Status** | Accepted | +| **Date** | 2026-06-13 | +| **Deciders** | Maintainers | +| **Supersedes** | The proto-*sourcing mechanism* of [ADR-001](001-public-api-uses-generated-protobufs.md) and [ADR-006](006-multi-module-rationale.md). Those decisions stand; only *how* the proto types are produced changes. | +| **Related** | [ADR-001](001-public-api-uses-generated-protobufs.md), [ADR-003](003-tooling.md), [ADR-006](006-multi-module-rationale.md), [`../versioning.md`](../versioning.md), [`../architecture/module-graph.md`](../architecture/module-graph.md) | + +## Context + +[ADR-001](001-public-api-uses-generated-protobufs.md) made the Wire-generated protobuf types the SDK's public data model, and the original module plan ([ADR-006](006-multi-module-rationale.md)) realised that by: + +- vendoring `meshtastic/protobufs` as a git submodule at `proto/src/protobufs`, and +- generating it in-tree into a public `:proto` Gradle module (Wire codegen via a `meshtastic.proto` convention plugin), which `:core` re-exported with `api(project(":proto"))`. + +Since then, the Meshtastic org began **publishing the generated bindings directly** as the `org.meshtastic:protobufs` Maven artifact — a Kotlin Multiplatform library (the same Wire output, built for all of the SDK's targets) versioned to track the schema. That makes the in-tree submodule + codegen redundant: the SDK would be regenerating, locally, bytes that are now a normal published dependency. + +Forces in tension: +- ADR-001's contract ("Wire types *are* the public API") must be preserved exactly — consumers still `import org.meshtastic.proto.*`. +- Contributor and CI setup should be as light as possible (no submodule init, no local Wire codegen step). +- Reproducible builds require an immutable version pin. + +## Decision + +The SDK **depends on the published `org.meshtastic:protobufs` Maven artifact** for all protobuf types. There is no `proto/src/protobufs` git submodule and no in-tree `:proto` Gradle module. + +- The version is pinned in [`../../gradle/libs.versions.toml`](../../gradle/libs.versions.toml) as `meshtasticProtobufs`. +- `:core` declares `api(libs.meshtasticProtobufs)`, so `org.meshtastic.proto.*` reaches every downstream module transitively — exactly the surface `api(project(":proto"))` used to provide. `:core:verifyModuleBoundary` keeps that the single declared path. +- Bumping the schema is now an ordinary Gradle dependency bump (Renovate's `gradle` manager), followed by `./gradlew updateKotlinAbi` when `:core`'s generated-symbol surface shifts. See [`../versioning.md`](../versioning.md) → "Proto artifact policy". + +ADR-001 is unchanged in substance: the generated types are still **the** public data model; only their provenance moved from in-tree codegen to a published artifact. + +## Rationale + +- **No local codegen or submodule.** Clones are a plain `git clone`; CI drops `submodules: recursive` and the Wire Gradle plugin. The artifact is the same Wire output, produced once upstream. +- **One contract, one source.** The proto surface is defined in exactly one place (the artifact), not shadowed by a local generation step that could drift from the upstream publish. +- **Standard dependency hygiene.** Version pinning, Renovate automation, and reproducibility work the same way they do for every other dependency. + +## Alternatives considered + +| Option | Why not | +|---|---| +| Keep the submodule + `:proto` module | Regenerates, locally, bytes that are now published; costs every contributor a submodule init and the build a codegen step, for no benefit now that upstream publishes the KMP artifact. | +| Consume the artifact but keep a thin `:proto` module that re-exports it | Pointless indirection — `:core`'s `api(...)` already re-exports the types; a wrapper module adds a publish coordinate with zero value. | + +## Consequences + +### Positive +- Simpler clone and CI; no submodule, no Wire Gradle plugin, no `:proto` publish coordinate. +- Proto provenance is a normal, automatable, pin-able dependency. + +### Negative / costs +- The SDK can only build against schema versions that have been **published** as `org.meshtastic:protobufs`. Early integration against an unpublished `develop` requires the org's `develop-SNAPSHOT`, which **must not back a tagged release** (a moving snapshot breaks reproducible builds — see CHANGELOG and `versioning.md`). +- BCV no longer dumps proto types at all (they are an external dependency, not an in-tree module); their source/binary compatibility is governed upstream by Wire's rules. + +### Follow-ups +- [x] `gradle/libs.versions.toml` carries `meshtasticProtobufs`; `:core` uses `api(libs.meshtasticProtobufs)`. +- [x] Docs synced: `AGENTS.md`, `GEMINI.md`, `CONTRIBUTING.md`, `README.md`, `docs/versioning.md`, `docs/ci-cd.md`, `docs/architecture/module-graph.md`, `renovate.json`. +- [x] `versioning.md` "Proto artifact policy" replaces the former "Proto submodule policy". + +## References + +- [ADR-001](001-public-api-uses-generated-protobufs.md) — Wire types are the public data model (still in force). +- [ADR-006](006-multi-module-rationale.md) — module layout (the `:proto` node is now the external artifact). +- `org.meshtastic:protobufs` — published on Maven Central. diff --git a/docs/future/wasm-rpc-roadmap.md b/docs/future/wasm-rpc-roadmap.md index 790bc75..cdac61e 100644 --- a/docs/future/wasm-rpc-roadmap.md +++ b/docs/future/wasm-rpc-roadmap.md @@ -46,19 +46,18 @@ Deferring lets the local engine settle to 1.0 first, then the RPC adapter layers | Module | Targets | Depends on | Published | |---|---|---|---| -| `:rpc` | all incl. `wasmJs` | `:proto` | yes | -| `:transport-rpc` | all incl. `wasmJs` | `:rpc`, `:proto` (NOT `:core`) | yes | +| `:rpc` | all incl. `wasmJs` | `org.meshtastic:protobufs` | yes | +| `:transport-rpc` | all incl. `wasmJs` | `:rpc`, `org.meshtastic:protobufs` (NOT `:core`) | yes | | `:host-rpc-server` | jvm, android | `:core`, `:rpc` | yes | | `:samples/wasm-app` | `wasmJs` | `:core`, `:transport-rpc` | no (sample) | | `:samples/host-rpc-server` | jvm | `:host-rpc-server` | no (sample) | -Existing modules grow a `wasmJs` source set only where they have no platform binding to native libs (`:proto`, `:core`'s public surface where reachable). `:transport-ble`, `:transport-tcp`, `:transport-serial-*`, `:storage-sqldelight` do NOT add `wasmJs` — there's no mature browser equivalent. +Existing modules grow a `wasmJs` source set only where they have no platform binding to native libs (`:core`'s public surface where reachable; the `org.meshtastic:protobufs` types are already KMP). `:transport-ble`, `:transport-tcp`, `:transport-serial-*`, `:storage-sqldelight` do NOT add `wasmJs` — there's no mature browser equivalent. ### Per-target compile matrix (post-roadmap) | Module | android | jvm | iosArm64 | iosX64 | iosSimulatorArm64 | wasmJs | |---|:---:|:---:|:---:|:---:|:---:|:---:| -| `:proto` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `:core` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `:rpc` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `:transport-rpc` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/docs/protocol.md b/docs/protocol.md index 0db5694..f4f0674 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -771,58 +771,73 @@ See §16 for the heartbeat policy by transport. Device administration (changing configs, rebooting, factory-reset, key management) goes through `PortNum.ADMIN_APP` carrying an `AdminMessage` payload. +> **Field numbers below are authoritative** — verified against `meshtastic/protobufs@develop` +> (and the published `org.meshtastic:protobufs` artifact). Always reconcile against `admin.proto` +> before relying on a tag value; this table is a reading aid, not the schema. + ```protobuf message AdminMessage { - bytes session_passkey = 101; // Required for state-changing requests oneof payload_variant { - bool get_channel_request = 1; // uint32: channel index + 1 (1-based, proto3 zero-value omission) - Channel get_channel_response = 2; - bool get_owner_request = 3; - User get_owner_response = 4; - AdminMessageConfigType get_config_request = 5; - Config get_config_response = 6; - AdminMessageModuleConfigType get_module_config_request = 7; - ModuleConfig get_module_config_response = 8; - bool get_canned_message_module_messages_request = 10; - string get_canned_message_module_messages_response = 11; - bool get_device_metadata_request = 12; - DeviceMetadata get_device_metadata_response = 13; - string get_ringtone_request = 14; - string get_ringtone_response = 15; - bool get_device_connection_status_request = 16; + uint32 get_channel_request = 1; // channel index + 1 (1-based; proto3 zero-value omission) + Channel get_channel_response = 2; + bool get_owner_request = 3; + User get_owner_response = 4; + ConfigType get_config_request = 5; + Config get_config_response = 6; + ModuleConfigType get_module_config_request = 7; + ModuleConfig get_module_config_response = 8; + bool get_canned_message_module_messages_request = 10; + string get_canned_message_module_messages_response = 11; + bool get_device_metadata_request = 12; + DeviceMetadata get_device_metadata_response = 13; // carries the session passkey + bool get_ringtone_request = 14; + string get_ringtone_response = 15; + bool get_device_connection_status_request = 16; DeviceConnectionStatus get_device_connection_status_response = 17; - HamParameters set_ham_mode = 18; - NodeRemoteHardwarePinsResponse get_node_remote_hardware_pins_response = 19; - bool get_node_remote_hardware_pins_request = 20; - bool begin_edit_settings = 64; - bool commit_edit_settings = 65; - fixed32 reboot_ota_seconds = 95; - bool exit_simulator = 96; - int32 reboot_seconds = 97; - int32 shutdown_seconds = 98; - int32 factory_reset_config = 99; - bool nodedb_reset = 100; - Position set_fixed_position = 102; - bool remove_fixed_position = 103; - fixed32 set_time_only = 104; - User set_owner = 32; - Channel set_channel = 33; - Config set_config = 34; - ModuleConfig set_module_config = 35; - string set_canned_message_module_messages = 36; - string set_ringtone_message = 37; - fixed32 remove_by_nodenum = 38; - fixed32 set_favorite_node = 39; - fixed32 remove_favorite_node = 40; - Position set_fixed_position_legacy = 41; // (deprecated) - bool enter_dfu_mode_request = 92; - string delete_file_request = 93; - BackupLocation backup_preferences = 94; - bool factory_reset_device = 105; - fixed32 set_ignored_node = 106; - fixed32 remove_ignored_node = 107; - // ... (full enum is large; consult admin.proto) + HamParameters set_ham_mode = 18; + bool get_node_remote_hardware_pins_request = 19; + NodeRemoteHardwarePinsResponse get_node_remote_hardware_pins_response = 20; + bool enter_dfu_mode_request = 21; + string delete_file_request = 22; + uint32 set_scale = 23; + BackupLocation backup_preferences = 24; + BackupLocation restore_preferences = 25; + BackupLocation remove_backup_preferences = 26; + InputEvent send_input_event = 27; + User set_owner = 32; + Channel set_channel = 33; + Config set_config = 34; + ModuleConfig set_module_config = 35; + string set_canned_message_module_messages = 36; + string set_ringtone_message = 37; + uint32 remove_by_nodenum = 38; + uint32 set_favorite_node = 39; + uint32 remove_favorite_node = 40; + Position set_fixed_position = 41; + bool remove_fixed_position = 42; + fixed32 set_time_only = 43; + bool get_ui_config_request = 44; + DeviceUIConfig get_ui_config_response = 45; + DeviceUIConfig store_ui_config = 46; + uint32 set_ignored_node = 47; + uint32 remove_ignored_node = 48; + uint32 toggle_muted_node = 49; + bool begin_edit_settings = 64; + bool commit_edit_settings = 65; + SharedContact add_contact = 66; + KeyVerificationAdmin key_verification = 67; + int32 factory_reset_device = 94; + int32 reboot_ota_seconds = 95; // deprecated; use ota_request + bool exit_simulator = 96; + int32 reboot_seconds = 97; + int32 shutdown_seconds = 98; + int32 factory_reset_config = 99; + bool nodedb_reset = 100; + OTAEvent ota_request = 102; + SensorConfig sensor_config = 103; + LockdownAuth lockdown_auth = 104; // hardened MESHTASTIC_LOCKDOWN builds; local-only } + bytes session_passkey = 101; // required for state-changing requests } ``` @@ -856,6 +871,36 @@ The SDK detects this condition from the config bundle received during handshake By convention, admin messages travel on the **admin channel** (a dedicated channel role). If the device has no admin channel configured, admin messages travel on the primary channel. +### Storage lockdown (hardened builds) + +Firmware compiled with `MESHTASTIC_LOCKDOWN` encrypts on-flash storage and gates it behind a +passphrase. Two protocol surfaces drive it: + +- **Outbound — `AdminMessage.lockdown_auth = 104` (`LockdownAuth`).** Unlike every other admin + payload, this is **local-only and never routed over the mesh**: the firmware intercepts it + inline in `PhoneAPI` (`handleLockdownAuthInline`), acts on it, and **wipes the passphrase from + the packet buffer** before any admin/PKC routing runs. `LockdownAuth` fields: `passphrase` + (1–32 bytes; empty when `lock_now`), `boots_remaining` and `valid_until_epoch` (token TTLs, + `0` = firmware default / no limit), `max_session_seconds`, `lock_now` (revoke + reboot into + locked state, ignores passphrase), and `disable` (turn the feature off). `max_session_seconds` + and `disable` were added after firmware `2.7.25` — they require the develop-snapshot proto. +- **Inbound — `FromRadio.lockdown_status` (`LockdownStatus`).** Sent **immediately after + `config_complete_id`** (so it can arrive mid-handshake) to tell a freshly-connected, + possibly-unauthorized client what to do, and again after every `lockdown_auth` command. + `state` ∈ {`STATE_UNSPECIFIED`, `NEEDS_PROVISION`, `LOCKED`, `UNLOCKED`, `UNLOCK_FAILED`, + `DISABLED`}; `lock_reason` is a machine-readable cause for `LOCKED` (treat unknown values as + "locked, ask for passphrase"); `backoff_seconds` is the wait after `UNLOCK_FAILED`. `DISABLED` + was added after `2.7.25`. + +Host SDK: +- Surfaces inbound status as `MeshEvent.LockdownStatusChanged` (a recognized variant, **not** a + `ProtocolWarning`). This is the source of truth for lockdown availability — there is no + firmware-version capability flag because lockdown is a build-time option, not a version gate. +- Exposes the outbound command as `AdminApi.lockdown(LockdownAuth)`: local-only (a + `forNode(...)`-scoped call returns `Unauthorized` rather than leaking a passphrase onto the + mesh) and fire-and-forget (success = queued onto the local link; the real outcome arrives as a + fresh `LockdownStatusChanged`). + --- ## 14. MQTT proxy mode diff --git a/docs/testing.md b/docs/testing.md index 3581843..1afa098 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -253,7 +253,6 @@ If/when we wire Kover, the targets we'd start from: | Module | Line coverage aim | Notes | |---|---|---| | `:core` | ≥ 85 % | Pure logic; the engine + codec live here. Hot reconnect paths still under-covered today. | -| `:proto` | n/a | Wire-generated code; not measured. | | `:storage-sqldelight` | ≥ 70 % | SQL is exercised but identity-rebind & migration paths are thin. | | `:transport-tcp` | ≥ 70 % | Reader loop + heartbeat. | | `:transport-ble` | ≥ 60 % | Bonded by Kable mocking limits; weighted toward `commonMain` helpers. | diff --git a/docs/versioning.md b/docs/versioning.md index cd8307f..bf6ea08 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -8,9 +8,9 @@ | Bump | When | |---|---| -| **MAJOR** | Source- or binary-incompatible change to `:core` or any storage interface. Adding a new sealed-class subtype (forces consumer `when` exhaustiveness break). Bumping the proto submodule MAJOR (rare). | -| **MINOR** | New public API; new transport module; bumping the proto submodule MINOR (most common — new `PortNum`, new `Config` field). New optional `Builder` method. | -| **PATCH** | Bug fix; internal refactor; doc-only change; bumping the proto submodule PATCH (additive proto field with default value). | +| **MAJOR** | Source- or binary-incompatible change to `:core` or any storage interface. Adding a new sealed-class subtype (forces consumer `when` exhaustiveness break). Bumping the `org.meshtastic:protobufs` artifact across a MAJOR upstream change (rare). | +| **MINOR** | New public API; new transport module; bumping the `org.meshtastic:protobufs` artifact across a MINOR upstream change (most common — new `PortNum`, new `Config` field). New optional `Builder` method. | +| **PATCH** | Bug fix; internal refactor; doc-only change; bumping the `org.meshtastic:protobufs` artifact across a PATCH upstream change (additive proto field with default value). | Snapshot builds (`X.Y.Z-SNAPSHOT`) publish on every push to `main` to the [Central Portal snapshot repository](https://central.sonatype.com/repository/maven-snapshots/); tagged releases publish to Maven Central via manual workflow dispatch. @@ -46,20 +46,18 @@ Kotlin 2.2 brings klib ABI validation into KGP itself, deprecating the standalon - Per-target `klibApiCheck` runs against each Kotlin/Native and Kotlin/Wasm target's effective ABI, in addition to the JVM `checkKotlinAbi`. - Configuration lives in `build-logic/convention/MeshtasticKmpPublishPlugin.kt` (one place, applied to every published module). -### `:proto` exemption +### Proto types and ABI validation -`:proto` is **excluded from ABI validation**. Reason: +Protobuf types are **not** part of this SDK's ABI surface: they come from the external +`org.meshtastic:protobufs` artifact, not an in-tree module, so BCV never dumps them, and their +own source/binary compatibility is governed upstream by Wire's generation rules. `:core` +re-exposes them via `api(libs.meshtasticProtobufs)`; `:core:verifyModuleBoundary` keeps that the +single declared path, so the proto surface stays defined in one place (the artifact), not +mirrored in-tree. -- `:proto` is mechanically generated by Wire from the vendored `meshtastic/protobufs` submodule. -- Every proto bump regenerates symbols; the diff is, by design, the change. -- We track proto bumps via the SemVer rules in the next section, not via line-by-line ABI gates. -- A consumer's reaction to a proto change is governed by Wire's own source/ABI rules, not by ours. +## Proto artifact policy -`checkKotlinAbi` is therefore disabled on `:proto` (configured in `MeshtasticKmpPublishPlugin` with `apiValidation { ignoredProjects += "proto" }` or equivalent in the new KGP config). The Gradle dep graph plus `:core:verifyModuleBoundary` enforces that `:core` does not re-export proto types beyond what `api(project(":proto"))` already exposes — the proto surface remains stable in *one* place (Wire), not two. - -## Proto submodule policy - -`proto/src/protobufs` is a git submodule pointing at `meshtastic/protobufs`. Bumping it is the integration point for every new firmware feature. +The `org.meshtastic:protobufs` version pinned in [`../gradle/libs.versions.toml`](../gradle/libs.versions.toml) (`meshtasticProtobufs`) is the integration point for every new firmware feature; bumping it is how the SDK tracks the wire schema. | Upstream proto change | This SDK's bump | |---|---| @@ -72,12 +70,12 @@ Kotlin 2.2 brings klib ABI validation into KGP itself, deprecating the standalon | Top-level message added (e.g., new `FromRadio` arm) | MINOR (Wire generates a sealed-class case in `FromRadio.body`; consumers' exhaustive `when`s break — but `FromRadio` is internal/transient enough that we accept this at MINOR pre-1.0; after 1.0 this also escalates to MAJOR) | The proto bump commit MUST: -- Update the submodule pointer. -- Run `./gradlew updateKotlinAbi` to capture the new generated symbols on `:core` (the BCV-relevant surface). `:proto`'s own dump is not part of the gate. -- Note the upstream commit range in the bump commit body. -- If a wire message has a new `oneof` arm, audit `WireCodec` for places that exhaustively `when` over it. +- Update the `meshtasticProtobufs` version in `gradle/libs.versions.toml`. +- Run `./gradlew updateKotlinAbi` to capture any change to `:core`'s generated-symbol surface (the BCV-relevant surface). +- Note the upstream version (and commit range, if known) in the bump commit body. +- If a wire message has a new `oneof` arm, audit `WireCodec` and the engine's `FromRadio` / `MeshPacket` handling for places that exhaustively `when` over it. -Renovate's `git-submodules` manager opens a PR weekly with the latest submodule pointer; a maintainer reviews and merges. See [`../renovate.json`](../renovate.json). +Renovate's `gradle` manager opens a PR when a new `org.meshtastic:protobufs` version is published; a maintainer reviews the API diff and merges. See [`../renovate.json`](../renovate.json). ## Release workflow @@ -114,8 +112,7 @@ Driven from a manual `release.yml` GitHub Actions workflow (`workflow_dispatch`) | Coordinates | Source module | |---|---| | `org.meshtastic:sdk-bom` | `:bom` (Maven BOM aligning the versions below) | -| `org.meshtastic:sdk-core` | `:core` | -| `org.meshtastic:sdk-proto` | `:proto` | +| `org.meshtastic:sdk-core` | `:core` (re-exports the `org.meshtastic:protobufs` types via `api`) | | `org.meshtastic:sdk-transport-ble` | `:transport-ble` | | `org.meshtastic:sdk-transport-tcp` | `:transport-tcp` | | `org.meshtastic:sdk-transport-serial` | `:transport-serial` (single KMP module covering JVM via jSerialComm and Android via usb-serial-for-android) | @@ -158,7 +155,7 @@ Generated from `CHANGELOG.md` `## [X.Y.Z]` section + a manually-curated highligh - ... ### Proto -- Bumped `meshtastic/protobufs` submodule to (range ..). +- Bumped `org.meshtastic:protobufs` to (upstream range ..). ``` ## Branching @@ -171,9 +168,9 @@ Generated from `CHANGELOG.md` `## [X.Y.Z]` section + a manually-curated highligh Documented in `docs/compatibility.md` (TBD) and the README: -| SDK version | Proto submodule | Min firmware (capability-gated) | Kotlin | Gradle | Min Android SDK | iOS deployment | +| SDK version | Proto artifact (`meshtasticProtobufs`) | Min firmware (capability-gated) | Kotlin | Gradle | Min Android SDK | iOS deployment | |---|---|---|---|---|---|---| -| `0.x.y` | (varies; commit pin in CHANGELOG) | No hard minimum; capabilities gate on proto fields (e.g. `DeviceMetadata.hasPKC` for PKI, `ClientNotification` variants for security warnings). Practical floor tracks the pinned submodule — currently ≈ firmware `2.6.x`. | latest stable at release | 9.x | 26 | iOS 14 | +| `0.x.y` | pinned in `gradle/libs.versions.toml` (see CHANGELOG for the version) | No hard minimum; capabilities gate on proto fields (e.g. `DeviceMetadata.hasPKC` for PKI, `ClientNotification` variants for security warnings). Practical floor tracks the pinned proto artifact — currently ≈ firmware `2.6.x`. | latest stable at release | 9.x | 26 | iOS 14 | The SDK is forward-compatible for additive proto fields and does **not** hard-fail on firmware-version mismatch today. `MeshtasticException.FirmwareTooOld` is reserved for a diff --git a/renovate.json b/renovate.json index 9380d5f..8a11be0 100644 --- a/renovate.json +++ b/renovate.json @@ -13,20 +13,19 @@ "rangeStrategy": "bump", "configMigration": true, "lockFileMaintenance": { "enabled": true, "schedule": ["before 6am on monday"] }, - "git-submodules": { - "enabled": true, - "schedule": ["before 6am on monday"], - "commitMessageTopic": "proto submodule", - "prBodyNotes": [ - "Bumps the vendored `meshtastic/protobufs` submodule. Review the generated API diff carefully — new `oneof` arms break exhaustive `when` for consumers; see `docs/versioning.md`." - ] - }, "packageRules": [ { "matchManagers": ["gradle", "gradle-wrapper"], "matchUpdateTypes": ["patch", "minor"], "automerge": false }, + { + "matchPackageNames": ["org.meshtastic:protobufs"], + "automerge": false, + "prBodyNotes": [ + "Bumps the `org.meshtastic:protobufs` artifact (the wire schema). Review the regenerated API diff carefully — new `oneof` arms break exhaustive `when` for consumers; run `./gradlew updateKotlinAbi`. See `docs/versioning.md`." + ] + }, { "matchPackagePatterns": ["^org\\.jetbrains\\.kotlin"], "groupName": "kotlin" From 727cd6c946454c98fb766dfec9b4879f6477933f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:36:33 -0500 Subject: [PATCH 16/16] feat(core): expose develop-only lockdown fields (disable, max_session_seconds, DISABLED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the 2.7.25 lockdown support. Bumps org.meshtastic:protobufs to develop-SNAPSHOT to pick up LockdownAuth.max_session_seconds / .disable and LockdownStatus.State.DISABLED, and restores the tests + KDoc that exercise them. DRAFT — do not merge until a stable proto release ships these fields; a moving -SNAPSHOT must not back a tagged release (see CHANGELOG / versioning.md). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- CHANGELOG.md | 6 +++--- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 8 ++++--- .../kotlin/org/meshtastic/sdk/Node.kt | 6 ++++-- .../meshtastic/sdk/AdminApiRemainingTest.kt | 6 +++++- .../meshtastic/sdk/LockdownStatusEventTest.kt | 21 +++++++++++++++++++ docs/api-reference.md | 2 +- gradle/libs.versions.toml | 6 +++++- 7 files changed, 44 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1e603..a58e8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,8 +51,8 @@ published to Maven Central (0.1.0 was tagged `rc1` only), so these break no exte - **on-device BLE conformance harness:** `:transport-ble:connectedAndroidDeviceTest` runs the cs1–cs6 conformance envelope against a real radio from an Android device — scan by service UUID (bonded-first), production `BleTransport(address)` factory connect, two-stage handshake, read-only admin RPC round-trips, a >MTU-23 write proof, SQLDelight persistence, and same-transport reconnect. Skips (does not fail) when no Meshtastic radio is advertising, so CI never needs hardware. This harness caught every Fixed item below tagged *found on hardware*. - **events:** Inbound MQTT client-proxy, XModem, and file-info frames are now surfaced as typed events — `MeshEvent.MqttProxyMessage`, `MeshEvent.XmodemPacket`, `MeshEvent.FileInfoReceived` — instead of being dropped with a `ProtocolWarning`. Outbound counterparts go through `RadioClient.sendRaw(ToRadio(...))`. -- **admin / lockdown:** `AdminApi.lockdown(LockdownAuth)` drives storage lockdown on hardened (`MESHTASTIC_LOCKDOWN`) firmware builds — provision, unlock, or `lock_now`. Local-only by design (a `forNode(...)`-scoped call returns `Unauthorized`; the firmware consumes the passphrase inline on the phone link and never routes it over the mesh) and fire-and-forget (the device replies with a fresh `lockdown_status` rather than a routing ACK). Completes AdminMessage coverage — every `payload_variant` is now exposed. -- **events:** `MeshEvent.LockdownStatusChanged` surfaces the device's `FromRadio.lockdown_status` report (sent right after `config_complete_id` and after each `lockdown` command) as a typed event. The device's runtime status report — not a firmware-version flag — is the source of truth for lockdown availability (lockdown is a build-time firmware option). +- **admin / lockdown:** `AdminApi.lockdown(LockdownAuth)` drives storage lockdown on hardened (`MESHTASTIC_LOCKDOWN`) firmware builds — provision, unlock, `lock_now`, or `disable`. Local-only by design (a `forNode(...)`-scoped call returns `Unauthorized`; the firmware consumes the passphrase inline on the phone link and never routes it over the mesh) and fire-and-forget (the device replies with a fresh `lockdown_status` rather than a routing ACK). Completes AdminMessage coverage — every `payload_variant` is now exposed. +- **events:** `MeshEvent.LockdownStatusChanged` surfaces the device's `FromRadio.lockdown_status` report (sent right after `config_complete_id` and after each `lockdown` command) as a typed event. This is the source of truth for lockdown availability — `LockdownStatus.State.DISABLED` means the feature is off — so there is no firmware-version capability flag (lockdown is a build-time option, not a version gate). - **send:** `SendFailure.QueueRejected(res)` — a firmware `QueueStatus` enqueue rejection now fast-fails the `MessageHandle` (and any pending admin RPC sharing the wire id) instead of waiting out the full ACK timeout. - **send:** `RadioClient.sendText(..., replyId)` for threaded replies (`decoded.reply_id` without the emoji flag). - **engine:** Mesh packets received while the handshake is still in flight (live traffic interleaved with the config/NodeDB drain, including the seeding window) are now buffered (drop-oldest at 64, observable via `PacketsDropped`) and flushed through the normal packet pipeline at Ready — previously they were silently dropped. @@ -88,7 +88,7 @@ published to Maven Central (0.1.0 was tagged `rc1` only), so these break no exte - **storage:** Documented that `DeviceStorage.loadNodes()` is never called by the engine (node DB reseeds from the handshake); it exists for hosts (offline node access) and tests. - **transport-ble (Android):** `BleTransport(address)` now negotiates the ATT MTU (517) after each link establishment and requests `CONNECTION_PRIORITY_HIGH` for the 30-second handshake window before downgrading to Balanced. Without the MTU request Android stays at the BLE minimum (23) and any ToRadio write over 20 bytes fails. - **docs:** `docs/api-reference.md` gains an **API conventions** section (proto exposure, byte vocabulary, no blocking bridges, data-class policy, Kotlin/Swift-first interop); SPEC bumped to v2.3 and synced; CONTRIBUTING/AGENTS house rules flipped to the okio vocabulary with superseded-by notes preserved in ADR-003. -- **build:** `org.meshtastic:protobufs` stays pinned at `2.7.25`. A field-level diff against the published `develop-SNAPSHOT` found **zero** changes across all 46 SDK-consumed proto messages — the only structural delta is the device-only `NodeDatabase` flash-storage restructure (new `NodeStatus/Position/Telemetry/Environment` entry types), which the SDK does not consume. The newer lockdown fields (`LockdownAuth.disable`, `LockdownAuth.max_session_seconds`, `LockdownStatus.State.DISABLED`) arrive in a follow-up once a stable proto release ships them. +- **build:** `org.meshtastic:protobufs` pinned to `develop-SNAPSHOT` (was `2.7.25`). The lockdown feature surface — `LockdownAuth.max_session_seconds`, `LockdownAuth.disable`, and `LockdownStatus.State.DISABLED` — was added after `2.7.25` and only resolves against the develop snapshot. The snapshot is otherwise a strict superset of `2.7.25` for the client wire contract: a field-level diff across all 46 SDK-consumed proto messages found **zero** changes; the only structural delta is the device-only `NodeDatabase` flash-storage restructure (new `NodeStatus/Position/Telemetry/Environment` entry types), which the SDK does not consume. **Repin to the next stable release (≥ the proto release that ships these fields) before tagging** — a moving `-SNAPSHOT` must not back a published artifact. - **build:** Kable 0.42.0 → 0.43.0 (aligns with Meshtastic-Android). - **build:** Aligned the toolchain with Meshtastic-Android — Kotlin 2.3.21 (SKIE 0.10.12), Wire 6.4.0, Ktor 3.5.0, and stable coroutines 1.11.0 (was 1.11.0-rc02). Validated locally: iOS framework link, jvmTest, and Kotlin ABI check all green. - **docs(SPEC.md):** Bumped spec from v2.1 to v2.2 — full post-audit sync aligning spec with shipped implementation. Key areas synchronized: AdminApi expansion (~15 → ~45 methods), `StoreForwardApi`, presence tracking (`WentOffline`/`CameOnline`), `AutoReconnectConfig`, `CongestionWarning`/`ExternalConfigChange`/`StorageDegraded` MeshEvent variants, send DSL, `connectAndAwaitReady()`, `SessionPasskey`, `ConfigBundle.deviceUIConfig`, `SendFailure.IdCollision`/`AckTimeout`/`HandshakeFailed`, `AdminResult` extensions, `ConnectionState` extensions, `MeshtasticException` context fields, convention plugin + version catalog correction (JVM 17→21, Android SDK→36, Kotlin 2.3.20). diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index 44b0ba4..f1747d2 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -234,17 +234,19 @@ public interface AdminApi { // ── Lockdown (hardened builds) ────────────────────────────────────────── /** - * Provision, unlock, or lock the device's storage lockdown + * Provision, unlock, lock, or disable the device's storage lockdown * (`MESHTASTIC_LOCKDOWN` hardened firmware builds only). * * The device reports its current state via [MeshEvent.LockdownStatusChanged] — observe that to * decide which [LockdownAuth] to send and to learn the outcome: * - **Provision / unlock:** set [LockdownAuth.passphrase] (1–32 bytes). On success the device * issues a session token bounded by [LockdownAuth.boots_remaining] (boot-count TTL, `0` = - * firmware default) and [LockdownAuth.valid_until_epoch] (absolute wall-clock expiry, `0` = - * no time limit). + * firmware default), [LockdownAuth.valid_until_epoch] (absolute wall-clock expiry, `0` = no + * time limit), and [LockdownAuth.max_session_seconds]. * - **Lock now:** set [LockdownAuth.lock_now] = `true` (ignores the passphrase) to immediately * revoke admin authorization and reboot into the locked state. + * - **Disable:** set [LockdownAuth.disable] = `true` to turn the feature off on a provisioned + * device. * * **Local device only.** The firmware consumes `lockdown_auth` inline on the direct phone link * (BLE/serial/TCP) and wipes the passphrase from memory before it can reach the mesh/admin diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index 894bb87..3b334af 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -188,8 +188,8 @@ public sealed interface MeshEvent { * Only ever emitted by firmware compiled with `MESHTASTIC_LOCKDOWN` (storage-encryption / * tamper-hardened builds). Such a device sends the variant **immediately after the handshake's * `config_complete_id`** — to tell a freshly-connected, possibly-unauthorized client what it - * must do — and again in response to every [AdminApi.lockdown] command. Standard (non-hardened) - * builds simply never send it. + * must do — and again in response to every [AdminApi.lockdown] command. Standard builds either + * never send it or report [org.meshtastic.proto.LockdownStatus.State.DISABLED]. * * Use [status] to drive provisioning / unlock UX: * - [State.NEEDS_PROVISION][org.meshtastic.proto.LockdownStatus.State.NEEDS_PROVISION] — the @@ -201,6 +201,8 @@ public sealed interface MeshEvent { * and `valid_until_epoch` describe the issued session token's lifetime. * - [State.UNLOCK_FAILED][org.meshtastic.proto.LockdownStatus.State.UNLOCK_FAILED] — * `backoff_seconds` is how long the client must wait before the next passphrase attempt. + * - [State.DISABLED][org.meshtastic.proto.LockdownStatus.State.DISABLED] — the feature is off + * on this build (proto ≥ the develop snapshot; older firmware simply omits the variant). * * This is the SDK's source of truth for lockdown availability: there is no firmware-version * capability flag because lockdown is a build-time option, not a version gate. diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt index 67a5a6c..ffb5fb3 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt @@ -107,11 +107,15 @@ class AdminApiRemainingTest { } @Test - fun lockdownProvisionSendsPassphraseFields() = runTest { + fun lockdownProvisionSendsPassphraseAndDevelopFields() = runTest { + // Exercises develop-SNAPSHOT-only fields (max_session_seconds, disable) alongside the + // passphrase, proving the SDK compiles and round-trips the full LockdownAuth surface. val auth = LockdownAuth( passphrase = "hunter2".encodeToByteArray().toByteString(), boots_remaining = 10, valid_until_epoch = 1_900_000_000, + max_session_seconds = 3600, + disable = false, ) assertFireAndForgetSuccess( call = { it.lockdown(auth) }, diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/LockdownStatusEventTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/LockdownStatusEventTest.kt index 8f310b0..670e523 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/LockdownStatusEventTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/LockdownStatusEventTest.kt @@ -56,6 +56,27 @@ class LockdownStatusEventTest { runCatching { client.disconnect() } } + @Test + fun lockdownDisabledStateSurfaces() = runTest { + // DISABLED is a develop-SNAPSHOT-only enum value; assert the SDK round-trips it. + val (transport, client) = connectedClient() + val events = mutableListOf() + val job = backgroundScope.launch { client.events.collect { events.add(it) } } + client.connect() + runCurrent() + + val status = LockdownStatus(state = LockdownStatus.State.DISABLED) + transport.injectFrame(FromRadio(lockdown_status = status).toFrame()) + runCurrent() + + val event = events.filterIsInstance().singleOrNull() + assertTrue(event != null, "expected a typed LockdownStatusChanged event, got: $events") + assertEquals(LockdownStatus.State.DISABLED, event.status.state) + + job.cancel() + runCatching { client.disconnect() } + } + private fun kotlinx.coroutines.test.TestScope.connectedClient(): Pair { val transport = FakeRadioTransport( identity = TransportIdentity("fake:lockdown"), diff --git a/docs/api-reference.md b/docs/api-reference.md index 4da1dde..2959132 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -242,7 +242,7 @@ public sealed interface MeshEvent { public enum class DroppedFlow { Packets, Events } ``` -`ProtocolWarning` is non-fatal advisory ("skipped malformed envelope", etc.); the optional `details` map carries structured context (e.g., `"packet_id" to "0x1234"`). `IdentityRebound` is the dedicated signal for the engine clearing storage after a NodeNum change (see [storage.md §"Consumer-observable signal (R-9)"](./architecture/storage.md#consumer-observable-signal-r-9)). `PacketsDropped` is the only observability hook for backpressure-induced loss; emitted at most once per drop burst. `StorageDegraded` fires when the storage backend transitions to read-through-only mode. `CongestionWarning` fires when the device's outbound queue is critically full. `ExternalConfigChange` fires when an unsolicited admin message from firmware updates the local config state. `SecurityWarning.*` flag suspect key material observed in inbound `node_info` payloads. *(since 0.2.0)* `MqttProxyMessage` carries the device's `mqttClientProxyMessage` traffic for hosts implementing the device-as-MQTT-proxy side channel; `XmodemPacket` carries raw `XModem` file-transfer packets; `FileInfoReceived` carries an `org.meshtastic.proto.FileInfo` advertisement (the variant is named `FileInfoReceived` — not `FileInfo` — to avoid clashing with the proto type). *(since 0.3.0)* `LockdownStatusChanged` carries the device's `LockdownStatus` report from hardened (`MESHTASTIC_LOCKDOWN`) firmware builds — sent right after `config_complete_id` and after each `AdminApi.lockdown` command — and is the source of truth for lockdown availability (no firmware-version capability flag — lockdown is a build-time firmware option). +`ProtocolWarning` is non-fatal advisory ("skipped malformed envelope", etc.); the optional `details` map carries structured context (e.g., `"packet_id" to "0x1234"`). `IdentityRebound` is the dedicated signal for the engine clearing storage after a NodeNum change (see [storage.md §"Consumer-observable signal (R-9)"](./architecture/storage.md#consumer-observable-signal-r-9)). `PacketsDropped` is the only observability hook for backpressure-induced loss; emitted at most once per drop burst. `StorageDegraded` fires when the storage backend transitions to read-through-only mode. `CongestionWarning` fires when the device's outbound queue is critically full. `ExternalConfigChange` fires when an unsolicited admin message from firmware updates the local config state. `SecurityWarning.*` flag suspect key material observed in inbound `node_info` payloads. *(since 0.2.0)* `MqttProxyMessage` carries the device's `mqttClientProxyMessage` traffic for hosts implementing the device-as-MQTT-proxy side channel; `XmodemPacket` carries raw `XModem` file-transfer packets; `FileInfoReceived` carries an `org.meshtastic.proto.FileInfo` advertisement (the variant is named `FileInfoReceived` — not `FileInfo` — to avoid clashing with the proto type). *(since 0.3.0)* `LockdownStatusChanged` carries the device's `LockdownStatus` report from hardened (`MESHTASTIC_LOCKDOWN`) firmware builds — sent right after `config_complete_id` and after each `AdminApi.lockdown` command — and is the source of truth for lockdown availability (`State.DISABLED` = feature off; no firmware-version capability flag because lockdown is a build-time option). ## `NodeChange` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5929a4b..5eaa2a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,11 @@ okio = "3.17.0" # --- Protobuf / Wire --- wire = "6.4.0" # wire-runtime version (transitive via protobufs SDK) protobuf = "4.34.1" -meshtasticProtobufs = "2.7.25" +# develop-SNAPSHOT: required for the lockdown feature surface (LockdownAuth.max_session_seconds / +# .disable, LockdownStatus.State.DISABLED) added after 2.7.25. Otherwise a strict superset of +# 2.7.25 for the client wire contract. REPIN to the next stable release before tagging — a moving +# SNAPSHOT must not back a published artifact (see CHANGELOG, RELEASING.md). +meshtasticProtobufs = "develop-SNAPSHOT" # --- Storage --- sqldelight = "2.3.2"