All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog. Versions follow Semantic Versioning.
Dependency-refresh release; no public API change (checkKotlinAbi clean, api/*.api
untouched since 0.1.0).
- Kotlin 2.4.0 → 2.4.10 and SKIE 0.10.13 → 0.10.14 (adds Kotlin 2.4.10 support plus SKIE's Gradle configuration-cache / isolated-projects fixes).
org.meshtastic:protobufs2.7.25 → 2.7.26.- Ktor 3.5.1 → 3.5.2, Okio 3.17.0 → 3.18.1, Kable 0.44.1 → 0.44.3.
The pre-release conformance sweep ran against a simulated TCP radio (the meshtastic-mcp
replay device at 127.0.0.1:4403) — no bench radio was available, and the SDK currently has
no published consumers. cs1/cs3/cs5 pass; cs2 (broadcast terminal state) and cs6 (instant
reconnect) fail identically with the released v0.1.0 CLI against the same simulator, so
both are simulator limitations (no routing-ACK emission; single-client teardown race), not
candidate regressions. cs4/cs7 skipped (no peer node). Full gate (check, checkKotlinAbi,
publishToMavenLocal, tooling guardrails) green on the release commit.
First published release (Maven Central). Everything under [0.1.0-rc1] below is included; the
sections here cover the rc cycle — changes between v0.1.0-rc1 and v0.1.0.
Breaking relative to the unpublished v0.1.0-rc1 tag only. Pre-1.0 policy would put breaking
changes in a MINOR bump (see docs/versioning.md), but nothing has ever been published to
Maven Central, so these break no external consumer and ship in 0.1.0.
RadioClient.close()/AutoCloseableremoved — 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 foruse { }.- One byte-string vocabulary.
Frame,SessionPasskey, and the streamingsendoverload now useokio.ByteString— the type Wire-generated proto fields already force into the surface.send(portnum, payload: kotlinx.io.Buffer)is replaced bysend(portnum, payload: okio.ByteString);kotlinx-iois no longer a dependency of any published module (and is now detekt-banned viaForbiddenImport). sendTextparameter order is now(text, to, channel, replyId)— aligned withsendReaction(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 canonicalasText()/asPosition()/… accessors inPayloadAccessors.kt. The genericMeshPacket.decodeAs(adapter)escape hatch remains (no portnum guard — for Paxcount/StoreAndForward-style payloads). - New sealed variants break exhaustive
whens:MeshEvent.MqttProxyMessage,MeshEvent.XmodemPacket,MeshEvent.FileInfoReceived(named to avoid shadowingorg.meshtastic.proto.FileInfo),MeshEvent.LockdownStatusChanged, andSendFailure.QueueRejected(res). nodesflow seeding model: every subscription now receives a freshNodeChange.Snapshotfirst (seeded viaonSubscriptionfrom 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, leavingnodeMap()-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/loadSessionPasskeyremain in the interface as host-facing capabilities (likeloadNodes).
-
on-device BLE conformance harness:
:transport-ble:connectedAndroidDeviceTestruns the cs1–cs6 conformance envelope against a real radio from an Android device — scan by service UUID (bonded-first), productionBleTransport(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 aProtocolWarning. Outbound counterparts go throughRadioClient.sendRaw(ToRadio(...)). -
admin / lockdown:
AdminApi.lockdown(LockdownAuth)drives storage lockdown on hardened (MESHTASTIC_LOCKDOWN) firmware builds — provision, unlock, orlock_now. Local-only by design (aforNode(...)-scoped call returnsUnauthorized; 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 freshlockdown_statusrather than a routing ACK). Completes AdminMessage coverage — everypayload_variantis now exposed. -
events:
MeshEvent.LockdownStatusChangedsurfaces the device'sFromRadio.lockdown_statusreport (sent right afterconfig_complete_idand after eachlockdowncommand) 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 firmwareQueueStatusenqueue rejection now fast-fails theMessageHandle(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_idwithout 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 overRadioClient.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 underNonCancellable, bounded byteardownTimeoutso a wedged transport cannot pin the caller forever). -
ergonomics:
Flow<NodeChange>.asNodeMap()/RadioClient.nodeMap()— fold the node delta stream into a liveMap<NodeId, NodeInfo>(the accumulator every consumer otherwise hand-writes), ready forstateIn. -
testing:
FromRadio.toFrame()— encode a device-side envelope into a wireFramefor use withFakeRadioTransport.injectFrame(replaces eight per-test-file copies of the framing helper).
-
transport-ble:
toradiowrites now use acknowledged writes (WriteType.WithResponse). Firmware declares the characteristicCHR_PROPS_WRITEonly — 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
configBundleandchannelsto the public flows synchronously on the actor beforeconnect()resumes. Publication previously ran inside the async storage flush, so on devices with real storage latencyconnect()returned whileconfigBundle.valuewas stillnull. Found on hardware. -
storage-sqldelight (Android): WAL is enabled via
SupportSQLiteDatabase.enableWriteAheadLogging()instead ofexecSQL("PRAGMA journal_mode=WAL")— the PRAGMA returns a result row, which Android'sexecSQLrejects (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 afterdisconnect(), 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.resis decoded in the firmware's ERRNO namespace, notRouting.Error:35(ERRNO_SHOULD_RELEASE) is success and now counts asSent; ERRNO rejections (32queue-full,33no interface,34radio disabled) fail pending admin RPCs asNodeUnreachable; values1..31(genuineRouting.Errorcodes such asDUTY_CYCLE_LIMIT) map through the normal routing-error taxonomy. Previouslyres = 32was misread asBAD_REQUEST,33asNOT_AUTHORIZED, and the success code35produced 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 == 0branch); remote targets authorize against their own admin keys — managed-fleet deployments administer remote managed nodes from a managed local node. -
engine: a
want_config_idretry restarts the firmware's config drain from scratch, which previously duplicated channels / config sections in the committedConfigBundleandchannelsstate. Stage 1 accumulators now replace by key, latest occurrence wins. -
engine:
FromRadio.lockdown_statuswas silently dropped, then briefly surfaced as a genericProtocolWarning; it now lands as the typedMeshEvent.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
FromRadiovariants —node_infomerges 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.IdCollisionat the wire-id key indispatchSend; 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.
- remote admin (breaking behavior, correctness): Remote admin packets are now routed the way modern firmware (2.5+) requires —
pki_encrypted = true+ the target'spublic_keyon channel 0 when both nodes have published keys, falling back to a channel namedadminotherwise, with priorityRELIABLE. 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
SessionKeyExpiredsingle-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 requestsCONNECTION_PRIORITY_HIGHfor 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.mdgains 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:protobufsstays pinned at2.7.25. A field-level diff against the publisheddevelop-SNAPSHOTfound zero changes across all 46 SDK-consumed proto messages — the only structural delta is the device-onlyNodeDatabaseflash-storage restructure (newNodeStatus/Position/Telemetry/Environmententry 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/StorageDegradedMeshEvent variants, send DSL,connectAndAwaitReady(),SessionPasskey,ConfigBundle.deviceUIConfig,SendFailure.IdCollision/AckTimeout/HandshakeFailed,AdminResultextensions,ConnectionStateextensions,MeshtasticExceptioncontext 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.mdwith spec v2.2 changes.
Initial release candidate of the Meshtastic Kotlin Multiplatform SDK (internal team-share; never published to Maven Central).
- Full PhoneAPI handshake — two-stage config exchange, session passkey seeding, auto time-sync.
- Three transports — BLE (Kable), TCP (Ktor sockets), USB-serial (jSerialComm / usb-serial-for-android).
- Persistent storage — SQLDelight-backed NodeDB, channels, configs with WAL + transactional writes.
- Engine actor model — single-writer concurrency (ADR-002), structured coroutine topology.
- ACK correlation & retries —
MessageHandletracksQueued → Sent → Acked/Delivered/Failedwith configurable timeout. - Phase 2 RPC surfaces —
AdminApi(16 methods +editSettingstransaction),TelemetryApi,RoutingApi. - Ergonomics helpers —
NodeId.toHex(),ChannelUrlcodec,BatteryStatus,RadioMetrics, typed payload accessors,connectAndAwaitReady(). - Auto-reconnect supervisor (opt-in) — exponential backoff with jitter,
ConnectionState.Reconnecting. - Kotlin ABI baselines —
checkKotlinAbigate on every PR. - Architecture enforcement — detekt
ForbiddenImport+:core:verifyModuleBoundary. - CI — SHA-pinned actions, JVM matrix, iOS sim tests, CodeQL, Scorecard, dependency review.
- CLI sample — Mosaic TUI dashboard exercising all three transports.
jvm, androidTarget (minSdk 26), iosArm64, iosX64, iosSimulatorArm64.