Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
748298b
feat(core): remote-admin correctness + inbound coverage for the Andro…
jamesarich Jun 11, 2026
216e07b
feat(transport-ble): negotiate ATT MTU and boost connection priority …
jamesarich Jun 11, 2026
a48046d
docs: changelog for the 0.2.0 Android-cutover prerequisites
jamesarich Jun 11, 2026
5d1b13c
fix(core): firmware-conformance fixes from PhoneAPI/AdminModule sourc…
jamesarich Jun 11, 2026
6064062
refactor(core)!: suspend-only lifecycle + API-convention hardening
jamesarich Jun 12, 2026
53be4e8
refactor!: standardize on okio.ByteString — one byte vocabulary acros…
jamesarich Jun 12, 2026
0daac92
refactor: simplification pass — shared test framing, single admin dec…
jamesarich Jun 12, 2026
5dec9bc
feat: consumer-ergonomics pass — withConnection, nodeMap folding, DSL…
jamesarich Jun 12, 2026
6e6db27
fix(core)!: review fixes — per-subscription node snapshots, actor-saf…
jamesarich Jun 12, 2026
e9dd381
fix(transport-ble): per-connect tuning scope — stale priority downgra…
jamesarich Jun 12, 2026
ad173ba
docs: sync every guidance surface with the 0.2.0 API reality
jamesarich Jun 12, 2026
5545e00
fix(transport-ble): opt in to ExperimentalUuidApi for the iOS test ta…
jamesarich Jun 12, 2026
15c824c
test(transport-ble): add on-device BLE conformance harness
jamesarich Jun 12, 2026
9d63247
fix: real-hardware BLE conformance fixes
jamesarich Jun 12, 2026
e36f62b
feat(core): add storage-lockdown admin support
jamesarich Jun 13, 2026
727cd6c
feat(core): expose develop-only lockdown fields (disable, max_session…
jamesarich Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/templates/checklist-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .specify/templates/plan-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -43,18 +43,18 @@ 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 `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

- Follow API shape rules in ADR-005.
- Do not introduce `kotlin.Result<T>` 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

Expand Down Expand Up @@ -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`).
Expand Down
79 changes: 79 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,87 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Breaking

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.

- **`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`), `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
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`).

### 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(...))`.
- **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.
- **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<NodeChange>.asNodeMap()` / `RadioClient.nodeMap()` — fold the node delta stream into a live `Map<NodeId, NodeInfo>` (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

- **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).
- **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` 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.
- **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

- **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.
- **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` 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).
- **docs:** Synchronized `api-reference.md`, `error-taxonomy.md`, `roadmap.md`, `module-graph.md`, `README.md`, `CONTRIBUTING.md` with spec v2.2 changes.
Expand Down
Loading