Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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, 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.
- **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` 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).
- **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
Loading