mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
refactor: delete POC ViewModels and stale references
- Delete SdkConfigViewModel, SdkMessagingViewModel, SdkTelemetryViewModel (unused POC) - Delete RadioClientViewModel, SdkNodeListViewModel (POC logging only) - Remove POC VM instantiation from Main.kt - Delete NoopRadioInterfaceService (superseded by SdkRadioInterfaceService) - Delete dead app/test/Fakes.kt (both classes unused) - Fix stale KDoc references to MeshConnectionManager, RadioInterfaceService.connectionState Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,186 +1,141 @@
|
||||
# SDK Migration — Remaining Work
|
||||
# SDK Migration — Status & Remaining Work
|
||||
|
||||
> Auto-generated from migration session. Tracks what's done vs what remains against
|
||||
> the [Clean Break Migration Guide](../meshtastic-sdk/docs/architecture/meshtastic-android-migration.md).
|
||||
> Tracks progress of the Meshtastic-Android clean-break migration to meshtastic-sdk.
|
||||
> Updated: 2026-05-05
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Completed:** ~70% of the Clean Break migration. AIDL dropped, SDK storage active,
|
||||
service broadcasts eliminated, management layers flattened, trivial UseCases deleted,
|
||||
test infrastructure established.
|
||||
**Completed:** ~90% of the Clean Break migration. AIDL dropped, SDK is sole radio path,
|
||||
transport layer fully deleted, Desktop uses shared SDK bridge, dead infrastructure gone.
|
||||
|
||||
**Remaining:** ViewModel direct-binding (blocked by module architecture), Desktop SDK
|
||||
migration, dead infrastructure cleanup, and 4 deferred UseCase deletions.
|
||||
**Remaining:** VM parameter slimming (optional — currently all SDK-backed), Room table
|
||||
cleanup, and minor test coverage for new code.
|
||||
|
||||
**Net change so far:** ~52 files changed, +162 / -2,395 lines across all sessions.
|
||||
**Net change:** 145 files changed, +2,752 / -15,059 lines (net -12,307 LOC removed)
|
||||
|
||||
---
|
||||
|
||||
## What's Done (by migration doc phase)
|
||||
## Architecture (current state)
|
||||
|
||||
### Phase 1: Environment & Dependency Alignment ✅
|
||||
- SDK composite build integrated (`settings.gradle.kts`)
|
||||
- Wire proto types used throughout (`org.meshtastic.proto.*`)
|
||||
- `sdk-core`, `sdk-proto`, `sdk-transport-*`, `sdk-storage-sqldelight`, `sdk-testing` all wired
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Feature VMs (NodeList, Settings, RadioConfig, Messages...) │
|
||||
│ inject: RadioController, NodeRepository, ServiceRepository │
|
||||
└───────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────────▼──────────────────────────────┐
|
||||
│ SDK-Backed Adapters (core/data) │
|
||||
│ SdkRadioController, SdkStateBridge, SdkNodeRepositoryImpl │
|
||||
│ SdkPacketHandler, SdkRadioInterfaceService │
|
||||
│ MessagePersistenceHandler │
|
||||
└───────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────────▼──────────────────────────────┐
|
||||
│ RadioClientAccessor (platform-specific providers) │
|
||||
│ Android: RadioClientProvider (app/) │
|
||||
│ Desktop: DesktopRadioClientProvider (desktop/) │
|
||||
└───────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────────▼──────────────────────────────┐
|
||||
│ meshtastic-sdk │
|
||||
│ RadioClient → MeshEngine → Transport (BLE/TCP/Serial) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Phase 2: One-Time Data Migration ✅
|
||||
- Room auto-migration 38→39 with `AutoMigration38to39` spec
|
||||
- `onPostMigrate` copies favorites/notes/ignored/muted/manuallyVerified from `nodes` → `node_metadata`
|
||||
- `NodeMetadataEntity` + `NodeMetadataDao` created
|
||||
- `SdkNodeRepositoryImpl` enriches SDK nodes with persisted metadata
|
||||
---
|
||||
|
||||
### Phase 3: The Great Deletion (partial) ✅
|
||||
- `ServiceBroadcasts` — deleted (both `core/service` android + `core/repository` common)
|
||||
- `MeshConnectionManagerImpl` — deleted (438 LOC)
|
||||
- `MeshConnectionManager` interface — deleted
|
||||
- `NodeRepositoryImpl` (old Room-backed) — deleted (290 LOC)
|
||||
- `NodeInfoWriteDataSource` + `SwitchingNodeInfoWriteDataSource` — deleted
|
||||
- AIDL — already removed in prior session
|
||||
## What's Done
|
||||
|
||||
### Phase 4: RadioClient as Core Dependency ✅
|
||||
- `RadioClientProvider` implemented in `app/` with BLE/TCP/Serial support
|
||||
- Exposed as `StateFlow<RadioClient?>` for reactive observation
|
||||
- Auto-reconnect enabled
|
||||
- `SdkClientLifecycle` interface bridges to `core/service` without reverse dependency
|
||||
### Infrastructure ✅
|
||||
- AIDL completely removed
|
||||
- SDK composite build integrated
|
||||
- `RadioClientProvider` (Android) + `DesktopRadioClientProvider` (Desktop)
|
||||
- `SdkClientLifecycle` bridges to service layer
|
||||
- SDK `sendRaw(ToRadio)` API added for MQTT/XModem
|
||||
|
||||
### Phase 5: Thin Foreground Service ✅
|
||||
- `MeshService` stripped to lifecycle holder + notification management
|
||||
- Uses `ServiceRepository` for connection state (bridged from SDK)
|
||||
- `MeshServiceOrchestrator` simplified to TAK lifecycle + notifications + DB init + widget
|
||||
### Transport Layer ✅
|
||||
- **Fully deleted:** BleRadioTransport, TcpRadioTransport, SerialRadioTransport, StreamTransport, HeartbeatSender, StreamFrameCodec, all transport factories, BleReconnectPolicy, TcpTransport
|
||||
- `RadioInterfaceService` slimmed to device-address surface only
|
||||
- `SdkRadioInterfaceService` created (thin adapter over RadioPrefs + RadioClientAccessor)
|
||||
- Desktop `NoopRadioInterfaceService` updated to match
|
||||
- `JvmUsbScanner` migrated to SDK's `JvmSerialPorts.list()`
|
||||
|
||||
### Phase 6: UI & Domain (partial)
|
||||
- **6.1 ViewModel Simplification:** `AppMetadataRepository` created ✅ — but VM refactoring blocked (see below)
|
||||
- **6.2 UseCase Decimation:** 8 trivial UseCases deleted ✅ — 4 deferred, complex ones kept
|
||||
### Pipeline ✅
|
||||
- **Fully deleted:** CommandSender, MeshRouter, MeshActionHandler, PacketHandlerImpl, MeshDataHandlerImpl, MeshConnectionManager, MeshConfigFlowManager, ServiceBroadcasts, DirectRadioControllerImpl
|
||||
- `SdkRadioController` is sole RadioController impl
|
||||
- `SdkStateBridge` bridges SDK events → repositories
|
||||
- `SdkPacketHandler` routes MeshPackets via `client.send()`, raw ToRadio via `client.sendRaw()`
|
||||
|
||||
### Phase 7: UI/VM Direct Binding
|
||||
- POC VMs exist (`SdkNodeListViewModel`, `SdkConfigViewModel`, etc.) ✅
|
||||
- Production VMs still use repository layer (blocked — see below)
|
||||
### Data Layer ✅
|
||||
- Room migration 38→39: NodeMetadata persistence
|
||||
- `SdkNodeRepositoryImpl` enriches SDK nodes with persisted favorites/notes/ignore
|
||||
- SDK storage (SqlDelight) is source of truth for node data
|
||||
- `AppMetadataRepository` provides firmware/hardware/model info
|
||||
|
||||
### Phase 8: Feature Integrations ✅
|
||||
- Location publishing moved to `SdkStateBridge`
|
||||
- TAK integration preserved (uses `ServiceAction` dispatch through `SdkStateBridge`)
|
||||
### Desktop ✅
|
||||
- Fully cut over to SDK via shared KMP bridge
|
||||
- `DesktopRadioClientProvider` manages TCP/Serial transport
|
||||
- No transport stubs needed — SDK handles everything
|
||||
|
||||
### Phase 9: Testing Strategy ✅
|
||||
- `sdk-testing` dependency added to `app` and `core/data`
|
||||
- `TestRadioClientProvider` created with `FakeRadioTransport(autoHandshake=true)`
|
||||
- Integration test validates connect → handshake → node injection → observation
|
||||
### UseCases Deleted ✅
|
||||
- ProcessRadioResponse (tests only — impl kept, has real packet parsing logic)
|
||||
- AdminActions (tests only — impl kept, has real reboot/reset logic)
|
||||
- SetMeshLogSettings (tests only — impl kept)
|
||||
- CleanNodeDatabase (tests only — impl kept)
|
||||
- IsOtaCapable (tests only — impl kept)
|
||||
- EnsureRemoteAdminSession (tests only — impl kept, complex concurrency)
|
||||
|
||||
---
|
||||
|
||||
## What Remains
|
||||
|
||||
### 1. ViewModel Direct-Binding (Phase 6.1 — BLOCKED)
|
||||
### 1. Room Table Cleanup (low priority)
|
||||
- Migration 39→40: DROP legacy `nodes`, `my_node` tables
|
||||
- Remove old `NodeEntity`, `MyNodeEntity` Room entities + DAOs
|
||||
- SDK SqlDelight is already source of truth; Room tables are unused dead weight
|
||||
|
||||
**Blocker:** Feature modules (`feature/node`, `feature/settings`, `feature/messaging`, etc.)
|
||||
are KMP `commonMain` and cannot depend on the SDK directly. Only the `app` module has
|
||||
`implementation(libs.sdk.core)`. The `RadioClientProvider` lives in `app/`.
|
||||
### 2. VM Parameter Slimming (optional, quality-of-life)
|
||||
VMs currently inject SDK-backed adapters (RadioController, NodeRepository, etc.)
|
||||
which work correctly. Direct SDK injection would reduce params but isn't required:
|
||||
|
||||
**Current state:** Feature VMs inject `NodeRepository`, `ServiceRepository`,
|
||||
`RadioConfigRepository`, `RadioController` — all of which are already SDK-backed thin
|
||||
adapters populated by `SdkStateBridge`. The indirection works correctly but isn't the
|
||||
"direct binding" the migration doc envisions.
|
||||
| VM | Current Params | Could Be |
|
||||
|----|---------------|----------|
|
||||
| RadioConfigVM | 15 | 8-10 |
|
||||
| SettingsVM | 12 | 8-10 |
|
||||
| MessageVM | 12 | 6-8 |
|
||||
| NodeListVM | 9 | 5-6 |
|
||||
| NodeDetailVM | 7 | 4-5 |
|
||||
|
||||
**To unblock (choose one):**
|
||||
1. **Option A — SDK dependency in `core/repository`:** Add `api(libs.sdk.core)` to
|
||||
`core/repository/build.gradle.kts`. Create a `RadioClientAccessor` interface in
|
||||
`core/repository` exposing `client: StateFlow<RadioClient?>`. Feature modules can then
|
||||
inject it. Trade-off: couples `core/repository` to SDK API surface.
|
||||
2. **Option B — New `core/sdk-bridge` module:** Create a thin KMP module that depends on
|
||||
`sdk-core` and exposes flow-based abstractions (nodes, config, connection, admin).
|
||||
Feature modules depend on this instead of raw `RadioClient`. More modular but adds a module.
|
||||
3. **Option C — Move VMs to `app`:** Move production VMs out of `feature/*` into `app/`.
|
||||
Breaks KMP desktop/iOS target sharing. Not recommended.
|
||||
### 3. NodeManager Merge (optional)
|
||||
`NodeManager` (25 methods, 8+ consumers) could merge into `SdkNodeRepositoryImpl`.
|
||||
Currently SDK feeds it via SdkStateBridge. Works fine as-is.
|
||||
|
||||
**VMs to migrate (22 total):**
|
||||
| Tier | VMs | Current Params | Target |
|
||||
|------|-----|----------------|--------|
|
||||
| Critical (5) | RadioConfigVM, SettingsVM, MessageVM, MetricsVM, NodeListVM | 9-19 | 3-5 |
|
||||
| Moderate (6) | NodeDetailVM, ContactsVM, BaseMapVM, DebugVM, ChannelVM, FilterSettingsVM | 3-8 | 2-3 |
|
||||
| Simple (3) | CleanNodeDatabaseVM, QuickChatVM, CompassVM | 1-4 | 1-2 |
|
||||
### 4. MeshActivity Restoration (cosmetic)
|
||||
`UIViewModel.meshActivity` currently emits `emptyFlow()`. Could be restored by
|
||||
having `SdkStateBridge` emit send/receive events when SDK delivers/receives packets.
|
||||
Purely cosmetic — affects connection icon animation only.
|
||||
|
||||
### 2. Dead Infrastructure Cleanup (Phase 3 — BLOCKED by Desktop)
|
||||
|
||||
**Blocker:** Desktop's `DesktopKoinModule` manually creates `DirectRadioControllerImpl`,
|
||||
which pulls in the entire old packet-routing chain via Koin.
|
||||
|
||||
**Files blocked from deletion (~10 files, ~2,000 LOC):**
|
||||
- `MeshRouterImpl` + `MeshRouter` interface
|
||||
- `MeshDataHandlerImpl` + `MeshDataHandler` interface
|
||||
- `AdminPacketHandlerImpl` + `AdminPacketHandler` interface
|
||||
- `PacketHandlerImpl` + `PacketHandler` interface
|
||||
- `MeshConfigFlowManagerImpl` + `MeshConfigFlowManager` interface (gutted but present)
|
||||
- `MeshActionHandlerImpl` + `MeshActionHandler` interface
|
||||
- `CommandSenderImpl` + `CommandSender` interface
|
||||
- `DirectRadioControllerImpl`
|
||||
|
||||
**To unblock:** Migrate Desktop to use SDK's `RadioClient` + TCP/Serial transport.
|
||||
Replace `DirectRadioControllerImpl` in `DesktopKoinModule` with an SDK-backed
|
||||
`RadioController` impl (similar to how Android's `SdkStateBridge` bridges SDK → repositories).
|
||||
|
||||
### 3. Deferred UseCase Deletions (4 remaining)
|
||||
|
||||
These UseCases have real logic and depend on the VM migration to be safely inlined:
|
||||
|
||||
| UseCase | Reason Kept |
|
||||
|---------|-------------|
|
||||
| `EnsureRemoteAdminSessionUseCase` | Session passkey management — needs SDK `admin.session` API |
|
||||
| `ObserveRemoteAdminSessionStatusUseCase` | Session status observation — needed until VMs use SDK directly |
|
||||
| `CleanNodeDatabaseUseCase` | Node cleanup logic with age/unknown filtering |
|
||||
| `IsOtaCapableUseCase` | OTA capability check (firmware + device model) |
|
||||
|
||||
Additionally kept (complex orchestration, not candidates for deletion):
|
||||
- `RadioConfigUseCase`, `MeshLocationUseCase`, `ImportProfileUseCase`,
|
||||
`ExportProfileUseCase`, `ExportSecurityConfigUseCase`, `InstallProfileUseCase`,
|
||||
`SetMeshLogSettingsUseCase`, `ExportDataUseCase`
|
||||
|
||||
### 4. Remaining Phase C Items (deferred)
|
||||
|
||||
| Item | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| C3 | Move raw packet forwarding — VMs observe `client.packets` directly | Blocked by VM migration |
|
||||
| C4 | Delete `ServiceRepository.emitMeshPacket()` / `meshPacketFlow` | Blocked by C3 |
|
||||
| C5 | Further simplify `MeshServiceOrchestrator` | Minor — mostly done |
|
||||
| C6 | Remove `SharedRadioInterfaceService` | Complex — SDK owns transport but address management still used |
|
||||
|
||||
### 5. Room Table Cleanup
|
||||
|
||||
The old `nodes`, `my_node`, and `metadata` Room tables still exist in the schema
|
||||
(data was copied to `node_metadata` in migration 38→39). A future migration should
|
||||
DROP these tables to reduce DB size.
|
||||
|
||||
### 6. `NodeInfoReadDataSource` Cleanup
|
||||
|
||||
`NodeInfoReadDataSource` interface and `SwitchingNodeInfoReadDataSource` impl are still
|
||||
referenced by `MeshLogRepositoryImpl` (for resolving node names in log entries).
|
||||
To delete: refactor `MeshLogRepositoryImpl` to get node names from `NodeRepository` or
|
||||
`AppMetadataRepository` instead.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
1. **Desktop SDK migration** — unblocks item #2 (dead code deletion, ~2,000 LOC)
|
||||
2. **Module restructuring** (Option A or B above) — unblocks item #1 (VM direct-binding)
|
||||
3. **VM migration** — migrate 22 VMs to use RadioClient directly (per-VM PRs)
|
||||
4. **UseCase cleanup** — delete 4 deferred UseCases after VM migration
|
||||
5. **Phase C completion** — C3/C4/C6 after VMs no longer use ServiceRepository packet flow
|
||||
6. **Room table cleanup** — DROP legacy tables in a final migration
|
||||
### 5. Test Coverage
|
||||
- New code (`SdkRadioInterfaceService`, `SdkPacketHandler`, `MessagePersistenceHandler`)
|
||||
has no dedicated tests yet (existing integration tests cover happy paths)
|
||||
- UseCase tests were deleted with the impls — should add back for kept impls
|
||||
|
||||
---
|
||||
|
||||
## What STAYS (permanent architecture)
|
||||
|
||||
These components are NOT candidates for deletion — they serve app-local purposes
|
||||
the SDK doesn't cover:
|
||||
These components are NOT migration candidates:
|
||||
|
||||
- `PacketRepository` — message persistence (SDK doesn't persist chat history)
|
||||
- `MeshLogRepository` — debug logging (app-local concern)
|
||||
- `QuickChatActionRepository` — quick-chat templates (app preference)
|
||||
- `DeviceHardwareRepository` / `FirmwareReleaseRepository` — GitHub API clients
|
||||
- `MeshLogRepository` — debug logging (app-local)
|
||||
- `QuickChatActionRepository` — quick-chat templates
|
||||
- `DeviceHardwareRepository` / `FirmwareReleaseRepository` — GitHub API
|
||||
- `NodeMetadataDao` / `AppMetadataRepository` — favorites, notes, ignore, mute
|
||||
- `MeshServiceOrchestrator` (simplified) — TAK lifecycle, notifications, DB init
|
||||
- `SdkStateBridge` (reduced) — SDK → repository bridging, location publishing, TAK dispatch
|
||||
- `RadioClientProvider` — SDK client lifecycle management
|
||||
- `ContactSettings` table — app-local mute/read state per contact
|
||||
- `MeshServiceOrchestrator` — TAK lifecycle, notifications, DB init, widget
|
||||
- `SdkStateBridge` — SDK → repository bridging, notifications, TAK dispatch
|
||||
- `MqttManager` / `HistoryManager` / `XModemManager` — real features
|
||||
- `TelemetryPacketHandler` / `NeighborInfoHandler` / `TracerouteHandler` — packet processors
|
||||
- `ContactSettings` — per-contact mute/read state
|
||||
- `SessionManager` — per-node admin session passkey management
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.sdk.ConnectionState
|
||||
import org.meshtastic.sdk.MeshEvent
|
||||
|
||||
/**
|
||||
* POC ViewModel that exposes the SDK [RadioClient] connection lifecycle to the UI.
|
||||
*
|
||||
* **Connection state:** Uses `flatMapLatest` on the `StateFlow<RadioClient?>` so that any screen collecting
|
||||
* [sdkConnectionState] automatically switches to the new client's connection flow when
|
||||
* [RadioClientProvider.rebuildAndConnect] replaces the active client. [SharingStarted.WhileSubscribed] with a 5 s
|
||||
* timeout keeps the upstream active briefly after the last subscriber leaves (e.g., configuration change) so the next
|
||||
* subscriber doesn't miss a fast `Connected` event.
|
||||
*
|
||||
* **Events:** Collected with [SharingStarted.Eagerly] so that [MeshEvent]s (device rebooted, storage degraded, security
|
||||
* warnings) are never dropped while navigating between screens. The collection is launched in [viewModelScope] which is
|
||||
* tied to the application lifecycle via Koin's `@KoinViewModel` singleton scope — not to any individual screen.
|
||||
*
|
||||
* SDK gaps surfaced here:
|
||||
* - [ConnectionState.Configuring] has no counterpart in the legacy [org.meshtastic.core.model.ConnectionState]
|
||||
* - [ConnectionState.Reconnecting] has no counterpart in the legacy model
|
||||
*/
|
||||
@KoinViewModel
|
||||
class RadioClientViewModel(private val provider: RadioClientProvider) : ViewModel() {
|
||||
|
||||
/** Live SDK connection state; `null` if no client is active (no radio configured). */
|
||||
val sdkConnectionState: StateFlow<ConnectionState?> =
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.connection ?: flowOf(null) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/**
|
||||
* Human-readable label for the SDK connection state. Useful as a debug overlay in POC builds to see SDK state
|
||||
* alongside the legacy state.
|
||||
*/
|
||||
val sdkConnectionLabel: StateFlow<String> =
|
||||
sdkConnectionState.map { it.toLabel() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "SDK: —")
|
||||
|
||||
init {
|
||||
// Collect events eagerly so none are dropped during navigation.
|
||||
// This scope lives as long as the ViewModel (application lifetime via Koin singleton).
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.events ?: emptyFlow() }
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is MeshEvent.StorageDegraded -> Logger.w { "[SDK] StorageDegraded: ${event.reason}" }
|
||||
is MeshEvent.DeviceRebooted -> Logger.i { "[SDK] DeviceRebooted" }
|
||||
is MeshEvent.SecurityWarning -> Logger.w { "[SDK] SecurityWarning: $event" }
|
||||
else -> Logger.d { "[SDK] Event: $event" }
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
/** Kick off a (re)connect using the current saved radio address. */
|
||||
fun connect() = provider.rebuildAndConnectAsync()
|
||||
|
||||
/** Disconnect the active client. */
|
||||
fun disconnect() = provider.disconnect()
|
||||
}
|
||||
|
||||
private const val PERCENT = 100
|
||||
|
||||
private fun ConnectionState?.toLabel(): String = when (this) {
|
||||
null -> "SDK: no client"
|
||||
ConnectionState.Disconnected -> "SDK: Disconnected"
|
||||
is ConnectionState.Connecting -> "SDK: Connecting (#$attempt)"
|
||||
is ConnectionState.Configuring -> "SDK: Configuring — ${phase.name} (${(progress * PERCENT).toInt()}%)"
|
||||
ConnectionState.Connected -> "SDK: Connected ✓"
|
||||
is ConnectionState.Reconnecting -> "SDK: Reconnecting (#$attempt) — $cause"
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.User
|
||||
import org.meshtastic.sdk.AdminResult
|
||||
import org.meshtastic.sdk.ConfigBundle
|
||||
|
||||
/**
|
||||
* POC ViewModel that reads device configuration from the SDK's [ConfigBundle] and writes changes back via
|
||||
* [org.meshtastic.sdk.AdminApi.editSettings].
|
||||
*
|
||||
* **Read path:** [RadioClient.configBundle] is a [StateFlow] cached from the handshake — zero RPCs required. It
|
||||
* contains all [Config] and [ModuleConfig] entries as they were at connect time.
|
||||
*
|
||||
* **Write path:** [editSettings] issues a single-RPC batch write. The SDK auto-refreshes [configBundle] after a
|
||||
* successful commit (Gap G resolved).
|
||||
*
|
||||
* **Gap C resolved:** [RadioClient.channels] is now a reactive StateFlow seeded from the handshake.
|
||||
*/
|
||||
@KoinViewModel
|
||||
class SdkConfigViewModel(private val provider: RadioClientProvider) : ViewModel() {
|
||||
|
||||
/** The raw ConfigBundle from the handshake; null until connected+configured. */
|
||||
val configBundle: StateFlow<ConfigBundle?> =
|
||||
provider.client
|
||||
.flatMapLatest { it?.configBundle ?: flowOf(null) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** Device config — read directly from SDK configBundle (Gap G resolved, no local overlay needed). */
|
||||
val deviceConfig: StateFlow<Config.DeviceConfig?> =
|
||||
configBundle
|
||||
.map { bundle -> bundle?.configs?.firstOrNull { it.device != null }?.device }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** LoRa config — read directly from SDK configBundle. */
|
||||
val loraConfig: StateFlow<Config.LoRaConfig?> =
|
||||
configBundle
|
||||
.map { bundle -> bundle?.configs?.firstOrNull { it.lora != null }?.lora }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** Reactive channel list from the SDK (Gap C resolved — seeded from handshake, updated on setChannel). */
|
||||
val channels: StateFlow<List<org.meshtastic.proto.Channel>> =
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.channels?.map { it.orEmpty() } ?: flowOf(emptyList()) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/**
|
||||
* Write a config update to the radio via [AdminApi.editSettings].
|
||||
*
|
||||
* The SDK automatically refreshes configBundle after a successful commit.
|
||||
*/
|
||||
fun setConfig(config: Config, typeKey: String) {
|
||||
val client =
|
||||
provider.client.value
|
||||
?: run {
|
||||
Logger.w { "[SDK] setConfig: no active client" }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (val result = client.admin.editSettings { setConfig(config) }) {
|
||||
is AdminResult.Success -> Logger.i { "[SDK] setConfig($typeKey) succeeded" }
|
||||
AdminResult.Timeout -> Logger.w { "[SDK] setConfig($typeKey): Timeout" }
|
||||
AdminResult.Unauthorized -> Logger.w { "[SDK] setConfig($typeKey): Unauthorized" }
|
||||
AdminResult.SessionKeyExpired ->
|
||||
Logger.w { "[SDK] setConfig($typeKey): SessionKeyExpired — reconnect needed" }
|
||||
AdminResult.NodeUnreachable -> Logger.w { "[SDK] setConfig($typeKey): NodeUnreachable" }
|
||||
is AdminResult.Failed -> Logger.e { "[SDK] setConfig($typeKey): Failed — ${result.routingError}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: update device config. */
|
||||
fun setDeviceConfig(device: Config.DeviceConfig) = setConfig(Config(device = device), "device")
|
||||
|
||||
/** Convenience: update LoRa config. */
|
||||
fun setLoraConfig(lora: Config.LoRaConfig) = setConfig(Config(lora = lora), "lora")
|
||||
|
||||
/** Update owner name on the radio. */
|
||||
fun setOwner(longName: String, shortName: String) {
|
||||
val client = provider.client.value ?: return
|
||||
viewModelScope.launch {
|
||||
when (val result = client.admin.setOwner(User(long_name = longName, short_name = shortName))) {
|
||||
is AdminResult.Success -> Logger.i { "[SDK] setOwner succeeded" }
|
||||
else -> Logger.w { "[SDK] setOwner failed: $result" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics: log the full ConfigBundle contents. Useful for POC validation — call from a debug menu or
|
||||
* LaunchedEffect.
|
||||
*/
|
||||
fun logConfigBundle() {
|
||||
val bundle = configBundle.value
|
||||
if (bundle == null) {
|
||||
Logger.i { "[SDK] configBundle: null (not yet connected)" }
|
||||
return
|
||||
}
|
||||
Logger.i { "[SDK] myNodeNum=${bundle.myInfo.my_node_num}" }
|
||||
Logger.i { "[SDK] firmwareVersion=${bundle.metadata.firmware_version}" }
|
||||
bundle.configs.forEach { c -> Logger.d { "[SDK] Config: $c" } }
|
||||
bundle.moduleConfigs.forEach { mc -> Logger.d { "[SDK] ModuleConfig: $mc" } }
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.sdk.ChannelIndex
|
||||
import org.meshtastic.sdk.MessageHandle
|
||||
import org.meshtastic.sdk.NodeId
|
||||
import org.meshtastic.sdk.SendState
|
||||
import org.meshtastic.sdk.asText
|
||||
|
||||
/** Stable Compose model for a received text message. */
|
||||
@Immutable
|
||||
data class IncomingTextMessage(val fromNodeNum: Int, val channelIndex: Int, val text: String, val rxTimeSeconds: Int)
|
||||
|
||||
/** Stable Compose model for an outbound message's delivery status. */
|
||||
@Immutable data class OutboundStatus(val messageId: Long, val state: SendState)
|
||||
|
||||
/**
|
||||
* POC ViewModel that wires text messaging to the SDK's [RadioClient].
|
||||
*
|
||||
* **Inbound:** Filters [RadioClient.packets] for TEXT_MESSAGE_APP packets using the SDK's [org.meshtastic.sdk.asText]
|
||||
* extension. Accumulated in [incomingMessages] (capped at 200 for the POC to avoid unbounded memory growth).
|
||||
*
|
||||
* **Outbound:** [sendText] calls [RadioClient.sendText] synchronously (non-suspending), receives a [MessageHandle], and
|
||||
* tracks [SendState] transitions in [outboundStatuses].
|
||||
*
|
||||
* **SDK Gap B surfaced:** [RadioClient] has [org.meshtastic.sdk.asText] as a packet-level extension, but no reactive
|
||||
* `RadioClient.textMessages: Flow<IncomingTextMessage>` convenience. Callers must filter `packets` themselves. Log as
|
||||
* Gap B for SDK fix.
|
||||
*
|
||||
* Note: Inbound packet collection uses `SharingStarted.Eagerly` (via [launchIn]) so messages are never dropped while
|
||||
* navigating between screens.
|
||||
*/
|
||||
@KoinViewModel
|
||||
class SdkMessagingViewModel(private val provider: RadioClientProvider) : ViewModel() {
|
||||
|
||||
private val _incomingMessages = MutableStateFlow<List<IncomingTextMessage>>(emptyList())
|
||||
val incomingMessages: StateFlow<List<IncomingTextMessage>> = _incomingMessages.asStateFlow()
|
||||
|
||||
private val _outboundStatuses = MutableStateFlow<List<OutboundStatus>>(emptyList())
|
||||
val outboundStatuses: StateFlow<List<OutboundStatus>> = _outboundStatuses.asStateFlow()
|
||||
|
||||
init {
|
||||
// Eagerly collect inbound text packets — must not drop while navigating.
|
||||
// Gap B: no RadioClient.textMessages flow; manually filter packets.
|
||||
provider.client
|
||||
.flatMapLatest { client -> client?.packets ?: emptyFlow() }
|
||||
.mapNotNull { packet ->
|
||||
val text = packet.asText() ?: return@mapNotNull null
|
||||
IncomingTextMessage(
|
||||
fromNodeNum = packet.from,
|
||||
channelIndex = packet.channel,
|
||||
text = text,
|
||||
rxTimeSeconds = packet.rx_time,
|
||||
)
|
||||
}
|
||||
.onEach { msg ->
|
||||
Logger.d { "[SDK] Received text from ${msg.fromNodeNum} ch${msg.channelIndex}: ${msg.text}" }
|
||||
_incomingMessages.update { prev -> (prev + msg).takeLast(MAX_MESSAGES) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message via the SDK.
|
||||
*
|
||||
* @param text the message text
|
||||
* @param channelIndex 0–7; defaults to primary channel (0)
|
||||
* @param toNodeNum destination node num; 0xFFFFFFFF (default) = broadcast
|
||||
*/
|
||||
fun sendText(text: String, channelIndex: Int = 0, toNodeNum: Int = BROADCAST_NODE_NUM) {
|
||||
val client =
|
||||
provider.client.value
|
||||
?: run {
|
||||
Logger.w { "[SDK] sendText: no active client" }
|
||||
return
|
||||
}
|
||||
val handle: MessageHandle =
|
||||
client.sendText(text = text, channel = ChannelIndex(channelIndex), to = NodeId(toNodeNum))
|
||||
|
||||
// Track delivery state for this outbound message
|
||||
handle.state
|
||||
.onEach { state ->
|
||||
Logger.d { "[SDK] Message ${handle.id} → $state" }
|
||||
_outboundStatuses.update { prev ->
|
||||
val updated = OutboundStatus(messageId = handle.id.raw.toLong(), state = state)
|
||||
val existing = prev.indexOfFirst { it.messageId == updated.messageId }
|
||||
if (existing >= 0) {
|
||||
prev.toMutableList().also { it[existing] = updated }
|
||||
} else {
|
||||
(prev + updated).takeLast(MAX_MESSAGES)
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_MESSAGES = 200
|
||||
private const val BROADCAST_NODE_NUM = -1 // 0xFFFFFFFF as signed Int
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.scan
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.proto.NodeInfo
|
||||
import org.meshtastic.sdk.ConnectionQuality
|
||||
import org.meshtastic.sdk.MeshNode
|
||||
import org.meshtastic.sdk.NodeChange
|
||||
import org.meshtastic.sdk.NodeId
|
||||
import org.meshtastic.sdk.SignalQuality
|
||||
import org.meshtastic.sdk.toMeshNode
|
||||
|
||||
/**
|
||||
* Stable, Compose-safe representation of a mesh node.
|
||||
*
|
||||
* Wire-generated [NodeInfo] is NOT `@Stable`; never pass it directly to Compose. This wrapper holds the
|
||||
* fields the node list UI needs for display, filtering, and sorting.
|
||||
*/
|
||||
@Immutable
|
||||
data class UiNode(
|
||||
val num: Int,
|
||||
val longName: String,
|
||||
val shortName: String,
|
||||
val snr: Float,
|
||||
val hopsAway: Int?,
|
||||
val lastHeard: Int,
|
||||
val viaMqtt: Boolean,
|
||||
// Enriched fields
|
||||
val isOnline: Boolean,
|
||||
val connectionQuality: ConnectionQuality,
|
||||
val signalQuality: SignalQuality,
|
||||
val batteryLevel: Int?,
|
||||
val voltage: Float?,
|
||||
val channelUtilization: Float?,
|
||||
val airUtilTx: Float?,
|
||||
val latitude: Double?,
|
||||
val longitude: Double?,
|
||||
val altitude: Int?,
|
||||
val isFavorite: Boolean,
|
||||
val isIgnored: Boolean,
|
||||
val isMuted: Boolean,
|
||||
val hwModel: String?,
|
||||
)
|
||||
|
||||
private fun MeshNode.toUiNode() = UiNode(
|
||||
num = nodeNum,
|
||||
longName = longName ?: "Unknown",
|
||||
shortName = shortName ?: "?",
|
||||
snr = snr,
|
||||
hopsAway = hopsAway,
|
||||
lastHeard = lastHeard,
|
||||
viaMqtt = viaMqtt,
|
||||
isOnline = isOnline,
|
||||
connectionQuality = connectionQuality,
|
||||
signalQuality = signalQuality,
|
||||
batteryLevel = batteryLevel,
|
||||
voltage = voltage,
|
||||
channelUtilization = channelUtilization,
|
||||
airUtilTx = airUtilTx,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
altitude = altitude,
|
||||
isFavorite = isFavorite,
|
||||
isIgnored = isIgnored,
|
||||
isMuted = isMuted,
|
||||
hwModel = hwModel?.name,
|
||||
)
|
||||
|
||||
/**
|
||||
* POC ViewModel that drives a node list directly from the SDK's [org.meshtastic.sdk.RadioClient].
|
||||
*
|
||||
* **Fold pattern:**
|
||||
* 1. `flatMapLatest` switches to the new client's `nodes` flow whenever [RadioClientProvider] replaces the active
|
||||
* client.
|
||||
* 2. `.catch {}` before `.scan {}` so that a transport error re-emits a safe [NodeChange.Snapshot] (empty map) rather
|
||||
* than terminating the downstream scan accumulator.
|
||||
* 3. `.scan {}` folds delta events — [NodeChange.Added], [NodeChange.Updated], [NodeChange.Removed] — onto the
|
||||
* accumulator map. The initial [NodeChange.Snapshot] is guaranteed by the SDK for every new subscriber; no explicit
|
||||
* replay config needed.
|
||||
* 4. `.flowOn(Dispatchers.Default)` keeps folding off the main thread.
|
||||
* 5. `.stateIn(WhileSubscribed(5_000))` keeps the upstream alive for 5 s after the last subscriber (safe across config
|
||||
* changes; SDK re-sends a Snapshot for later subscribers).
|
||||
*
|
||||
* This ViewModel is registered as a Koin singleton alongside [RadioClientViewModel]. Both are instantiated at
|
||||
* [org.meshtastic.app.ui.MainScreen] startup so the node map is warm before any screen subscribes.
|
||||
*/
|
||||
@KoinViewModel
|
||||
class SdkNodeListViewModel(provider: RadioClientProvider) : ViewModel() {
|
||||
|
||||
val nodes: StateFlow<List<UiNode>> =
|
||||
provider.client
|
||||
.flatMapLatest { client ->
|
||||
if (client == null) return@flatMapLatest flowOf(emptyList())
|
||||
client.nodes
|
||||
.catch { e ->
|
||||
Logger.e(e) { "[SDK] nodes flow error — resetting to empty" }
|
||||
emit(NodeChange.Snapshot(emptyMap()))
|
||||
}
|
||||
.scan(emptyMap<NodeId, NodeInfo>()) { 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
|
||||
}
|
||||
}
|
||||
.map { nodeMap ->
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
nodeMap.values.map { it.toMeshNode(now).toUiNode() }
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.sdk.AdminResult
|
||||
import org.meshtastic.sdk.NodeId
|
||||
|
||||
/**
|
||||
* POC ViewModel that surfaces per-node telemetry from [TelemetryApi.observe].
|
||||
*
|
||||
* **Gap D verified:** [TelemetryApi.observe] returns a plain [kotlinx.coroutines.flow.Flow] of unsolicited periodic
|
||||
* [Telemetry] packets (device metrics, environment metrics, etc.). It does NOT auto-poll — packets arrive only when the
|
||||
* radio pushes them. To request an immediate telemetry update, call [requestDeviceMetrics] which issues an RPC.
|
||||
*
|
||||
* Telemetry fields are nullable (Wire proto) — check per-field before display: [Telemetry.device_metrics],
|
||||
* [Telemetry.environment_metrics], [Telemetry.air_quality_metrics], [Telemetry.power_metrics]
|
||||
*
|
||||
* Usage: observe [deviceMetrics] / [environmentMetrics] in a node-detail Composable, call [requestDeviceMetrics] on
|
||||
* screen entry to prime the display.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
@KoinViewModel
|
||||
class SdkTelemetryViewModel(private val provider: RadioClientProvider) : ViewModel() {
|
||||
|
||||
/**
|
||||
* Observe all raw [Telemetry] packets for [nodeId].
|
||||
*
|
||||
* Re-subscribes automatically when [RadioClientProvider.client] changes (reconnect). Errors are caught and logged —
|
||||
* the flow resets to null rather than crashing.
|
||||
*/
|
||||
private fun telemetryFor(nodeId: NodeId): StateFlow<Telemetry?> = provider.client
|
||||
.flatMapLatest { c ->
|
||||
if (c == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
c.telemetry
|
||||
.observe(nodeId)
|
||||
.catch { e ->
|
||||
Logger.e(e) { "[SDK] telemetry.observe(${nodeId.raw}) error" }
|
||||
emit(Telemetry())
|
||||
}
|
||||
.map { it as Telemetry? }
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) // 5s screen-death buffer
|
||||
|
||||
/** Latest telemetry (any type) for the local node (NodeId.LOCAL). */
|
||||
val localTelemetry: StateFlow<Telemetry?> = telemetryFor(NodeId.LOCAL)
|
||||
|
||||
/**
|
||||
* Request an immediate device-metrics telemetry packet from [nodeId]. The result will be pushed back through
|
||||
* [telemetryFor]'s [TelemetryApi.observe] flow.
|
||||
*/
|
||||
fun requestDeviceMetrics(nodeId: NodeId = NodeId.LOCAL) {
|
||||
val client = provider.client.value ?: return
|
||||
viewModelScope.launch {
|
||||
when (val r = client.telemetry.requestDevice(nodeId)) {
|
||||
is AdminResult.Success -> Logger.d { "[SDK] requestDeviceMetrics(${nodeId.raw}): ${r.value}" }
|
||||
else -> Logger.w { "[SDK] requestDeviceMetrics(${nodeId.raw}) failed: $r" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-node telemetry StateFlow for a specific node num. Compose screens can call this once per node-detail
|
||||
* screen.
|
||||
*/
|
||||
fun observeNode(nodeNum: Int): StateFlow<Telemetry?> = telemetryFor(NodeId(nodeNum))
|
||||
}
|
||||
@@ -29,11 +29,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.app.BuildConfig
|
||||
import org.meshtastic.app.radio.RadioClientViewModel
|
||||
import org.meshtastic.app.radio.SdkNodeListViewModel
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.navigation.NodesRoute
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
@@ -57,15 +54,6 @@ import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val viewModel: UIViewModel = koinViewModel()
|
||||
// Instantiate the SDK ViewModel so event collection starts at app launch (Eagerly scope).
|
||||
// sdkConnectionLabel logged below for POC visibility; will feed the connection toolbar later.
|
||||
val radioClientViewModel: RadioClientViewModel = koinViewModel()
|
||||
// Warm the SDK node list at launch so it's ready before any screen subscribes.
|
||||
val sdkNodeListViewModel: SdkNodeListViewModel = koinViewModel()
|
||||
val sdkNodeCount by sdkNodeListViewModel.nodes.map { it.size }.collectAsStateWithLifecycle(initialValue = 0)
|
||||
val sdkLabel by radioClientViewModel.sdkConnectionLabel.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(sdkLabel) { Logger.d { sdkLabel } }
|
||||
LaunchedEffect(sdkNodeCount) { Logger.d { "SDK nodes: $sdkNodeCount" } }
|
||||
// Land on Connections for first-run / no-device-selected; otherwise on Nodes. Read synchronously
|
||||
// from the StateFlow (seeded from persisted prefs) so the initial tab is set in one shot.
|
||||
val initialTab =
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.service
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.mock
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
class Fakes {
|
||||
val service: RadioInterfaceService = mock(MockMode.autofill)
|
||||
}
|
||||
|
||||
class FakeMeshServiceNotifications : MeshServiceNotifications {
|
||||
override fun clearNotifications() {}
|
||||
|
||||
override fun initChannels() {}
|
||||
|
||||
override fun updateServiceStateNotification(
|
||||
state: org.meshtastic.core.model.ConnectionState,
|
||||
telemetry: Telemetry?,
|
||||
) {}
|
||||
|
||||
override suspend fun updateMessageNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override suspend fun updateWaypointNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
message: String,
|
||||
waypointId: Int,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override suspend fun updateReactionNotification(
|
||||
contactKey: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
isBroadcast: Boolean,
|
||||
channelName: String?,
|
||||
isSilent: Boolean,
|
||||
) {}
|
||||
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
|
||||
|
||||
override fun showNewNodeSeenNotification(node: Node) {}
|
||||
|
||||
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
|
||||
|
||||
override fun showClientNotification(clientNotification: ClientNotification) {}
|
||||
|
||||
override fun cancelMessageNotification(contactKey: String) {}
|
||||
|
||||
override fun cancelLowBatteryNotification(node: Node) {}
|
||||
|
||||
override fun clearClientNotification(notification: ClientNotification) {}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ package org.meshtastic.core.repository
|
||||
* Stage 1 (`CONFIG_NONCE`): requests device config, module config, and channels. Stage 2 (`NODE_INFO_NONCE`): requests
|
||||
* the full node database.
|
||||
*
|
||||
* Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these.
|
||||
* Both the SDK state bridge (consumer) and handshake initiator (sender) reference these.
|
||||
*/
|
||||
object HandshakeConstants {
|
||||
/** Nonce sent in `want_config_id` to request config-only (Stage 1). */
|
||||
|
||||
@@ -32,12 +32,8 @@ import org.meshtastic.proto.MeshPacket
|
||||
* maintains reactive flows for connection status, error messages, and incoming mesh traffic.
|
||||
*
|
||||
* **Connection state contract:** [connectionState] is the **canonical, app-level** connection state that all UI,
|
||||
* feature modules, and ViewModels should observe. It incorporates handshake progress, light-sleep policy, and transport
|
||||
* reconciliation — unlike [RadioInterfaceService.connectionState], which only reflects the raw hardware link status.
|
||||
* The [MeshConnectionManager] is the sole writer of this state; it bridges [RadioInterfaceService.connectionState]
|
||||
* changes into app-level transitions via [setConnectionState].
|
||||
*
|
||||
* @see RadioInterfaceService.connectionState
|
||||
* feature modules, and ViewModels should observe. The SDK's [SdkStateBridge] is the sole writer of this state;
|
||||
* it maps SDK connection events into app-level transitions via [setConnectionState].
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface ServiceRepository {
|
||||
@@ -45,24 +41,21 @@ interface ServiceRepository {
|
||||
* Canonical app-level connection state.
|
||||
*
|
||||
* This is the **single source of truth** for connection status across the entire application. All UI components,
|
||||
* feature modules, and ViewModels should observe this flow — never [RadioInterfaceService.connectionState].
|
||||
* feature modules, and ViewModels should observe this flow.
|
||||
*
|
||||
* State transitions are managed exclusively by [MeshConnectionManager], which reconciles transport-level events
|
||||
* with handshake progress and device sleep policy:
|
||||
* State transitions are managed by [SdkStateBridge], which maps SDK connection events into app-level transitions:
|
||||
* - [ConnectionState.Disconnected] — no active connection to a radio
|
||||
* - [ConnectionState.Connecting] — transport is up, mesh handshake (config + node-info) in progress
|
||||
* - [ConnectionState.Connected] — handshake complete, radio fully operational
|
||||
* - [ConnectionState.DeviceSleep] — radio entered light-sleep (transient disconnect)
|
||||
*
|
||||
* @see RadioInterfaceService.connectionState
|
||||
*/
|
||||
val connectionState: StateFlow<ConnectionState>
|
||||
|
||||
/**
|
||||
* Updates the canonical app-level connection state.
|
||||
*
|
||||
* **This should only be called by [MeshConnectionManager].** Direct mutation from other components would bypass the
|
||||
* transport-to-app reconciliation logic and create state inconsistencies.
|
||||
* **This should only be called by [SdkStateBridge].** Direct mutation from other components would bypass the
|
||||
* SDK-to-app state mapping logic and create state inconsistencies.
|
||||
*
|
||||
* @param connectionState The new [ConnectionState].
|
||||
*/
|
||||
|
||||
@@ -42,7 +42,7 @@ import org.meshtastic.proto.MeshPacket
|
||||
@Suppress("TooManyFunctions")
|
||||
open class ServiceRepositoryImpl : ServiceRepository {
|
||||
|
||||
// Canonical app-level connection state — written exclusively by MeshConnectionManager.
|
||||
// Canonical app-level connection state — written exclusively by SdkStateBridge.
|
||||
private val _connectionState: MutableStateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState>
|
||||
get() = _connectionState
|
||||
|
||||
@@ -23,8 +23,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.network.repository.MQTTRepository
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
@@ -33,7 +31,6 @@ import org.meshtastic.core.repository.LocationRepository
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.proto.MqttClientProxyMessage
|
||||
import org.meshtastic.mqtt.ConnectionState as MqttConnectionState
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
@@ -54,32 +51,6 @@ private fun logWarn(message: String) {
|
||||
Logger.w(tag = TAG) { message }
|
||||
}
|
||||
|
||||
// region Transport / Radio Stubs (Android BLE/USB — no commonMain impl)
|
||||
|
||||
class NoopRadioInterfaceService : RadioInterfaceService {
|
||||
override val supportedDeviceTypes: List<DeviceType> = emptyList()
|
||||
|
||||
override val currentDeviceAddressFlow = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun isMockTransport(): Boolean = false
|
||||
|
||||
override fun connect() {
|
||||
logWarn("NoopRadioInterfaceService.connect()")
|
||||
}
|
||||
|
||||
override suspend fun disconnect() {
|
||||
logWarn("NoopRadioInterfaceService.disconnect()")
|
||||
}
|
||||
|
||||
override fun getDeviceAddress(): String? = null
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean = false
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = ""
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Notification / Platform Stubs (Android-only)
|
||||
|
||||
class NoopPlatformAnalytics : PlatformAnalytics {
|
||||
|
||||
Reference in New Issue
Block a user