feat(wifi): introduce BLE-based WiFi provisioning for nymea-compatible devices (#4968)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run

This commit is contained in:
James Rich 2026-04-02 12:31:17 -05:00 committed by GitHub
parent 1fee6c4431
commit 7e041c00e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3326 additions and 50 deletions

View file

@ -62,6 +62,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.ContactSettings
@ -368,10 +369,11 @@ private fun MuteNotificationsDialog(
val remaining = settings.muteUntil - now
if (remaining > 0) {
val (days, hours) = formatMuteRemainingTime(remaining)
val hoursFormatted = NumberFormatter.format(hours, 1)
if (days >= 1) {
stringResource(Res.string.mute_status_muted_for_days, days, hours)
stringResource(Res.string.mute_status_muted_for_days, days, hoursFormatted)
} else {
stringResource(Res.string.mute_status_muted_for_hours, hours)
stringResource(Res.string.mute_status_muted_for_hours, hoursFormatted)
}
} else {
stringResource(Res.string.mute_status_unmuted)

View file

@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -42,17 +44,20 @@ import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.WifiProvisionRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.export_configuration
import org.meshtastic.core.resources.import_configuration
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.PersistenceSection
import org.meshtastic.feature.settings.component.PrivacySection
import org.meshtastic.feature.settings.component.ThemePickerDialog
@ -226,6 +231,12 @@ fun SettingsScreen(
onShowThemePicker = { showThemePickerDialog = true },
)
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = Icons.Rounded.Wifi) {
onNavigate(WifiProvisionRoutes.WifiProvision())
}
}
PersistenceSection(
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },

View file

@ -29,6 +29,7 @@ import androidx.compose.material.icons.rounded.FormatPaint
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -46,6 +47,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.WifiProvisionRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.acknowledgements
import org.meshtastic.core.resources.app_settings
@ -59,6 +61,7 @@ import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
@ -197,6 +200,12 @@ fun DesktopSettingsScreen(
)
}
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = Icons.Rounded.Wifi) {
onNavigate(WifiProvisionRoutes.WifiProvision())
}
}
NotificationSection(
messagesEnabled = settingsViewModel.messagesEnabled.collectAsStateWithLifecycle().value,
onToggleMessages = { settingsViewModel.setMessagesEnabled(it) },

View file

@ -0,0 +1,67 @@
# `:feature:wifi-provision`
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:feature:wifi-provision[wifi-provision]:::kmp-feature
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->
## WiFi Provisioning System
The `:feature:wifi-provision` module provides BLE-based WiFi provisioning for Meshtastic devices using the Nymea network manager protocol. It scans for provisioning-capable devices, retrieves available WiFi networks, and applies credentials — all over BLE via the Kable multiplatform library.
### Architecture
- **Protocol:** Nymea BLE network manager (GATT service `e081fec0-f757-4449-b9c9-bfa83133f7fc`)
- **Transport:** BLE via `core:ble` Kable abstractions with chunked packet codec
- **UI:** Single-screen Material 3 Expressive flow with 6 phases (Idle, ConnectingBle, DeviceFound, LoadingNetworks, Connected, Provisioning)
```mermaid
sequenceDiagram
participant App as Meshtastic App
participant BLE as BLE Scanner
participant Device as Provisioning Device
Note over App: Phase 1: Scan
App->>BLE: Scan for GATT service UUID
BLE-->>App: Device discovered
Note over App: Phase 2: Connect
App->>Device: BLE Connect
Device-->>App: Device name (confirmation)
Note over App, Device: Phase 3: Network List
App->>Device: GetNetworks command
Device-->>App: WiFi networks (deduplicated by SSID)
Note over App, Device: Phase 4: Provision
App->>Device: Connect(SSID, password)
Device-->>App: NetworkingStatus response
App->>Device: Disconnect BLE
```
### Key Classes
- `WifiProvisionViewModel.kt`: MVI state machine with 6 phases and SSID deduplication.
- `WifiProvisionScreen.kt`: Material 3 Expressive single-screen UI with Crossfade transitions.
- `NymeaWifiService.kt`: BLE service layer — connect, scan networks, provision, close.
- `NymeaPacketCodec.kt`: Chunked BLE packet encoder/decoder with reassembly.
- `NymeaProtocol.kt`: JSON serialization for Nymea network manager commands and responses.
- `ProvisionStatusCard.kt`: Inline status feedback card (idle/success/failed) with Material 3 colors.

View file

@ -0,0 +1,41 @@
/*
* 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/>.
*/
plugins {
alias(libs.plugins.meshtastic.kmp.feature)
alias(libs.plugins.meshtastic.kotlinx.serialization)
}
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.wifiprovision"
androidResources.enable = false
}
sourceSets {
commonMain.dependencies {
implementation(projects.core.ble)
implementation(projects.core.common)
implementation(projects.core.navigation)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.kotlinx.serialization.json)
}
}
}

View file

@ -0,0 +1,100 @@
/*
* 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.feature.wifiprovision
import kotlin.uuid.Uuid
/**
* GATT UUIDs for the nymea-networkmanager Bluetooth provisioning profile.
*
* Reference: https://github.com/nymea/nymea-networkmanager#bluetooth-gatt-profile
*/
internal object NymeaBleConstants {
// region Wireless Service
/** Primary service for WiFi management. */
val WIRELESS_SERVICE_UUID: Uuid = Uuid.parse("e081fec0-f757-4449-b9c9-bfa83133f7fc")
/**
* Write JSON commands (chunked into 20-byte packets, newline-terminated) to this characteristic. Each command
* generates a response on [COMMANDER_RESPONSE_UUID].
*/
val WIRELESS_COMMANDER_UUID: Uuid = Uuid.parse("e081fec1-f757-4449-b9c9-bfa83133f7fc")
/**
* Subscribe (notify) to receive JSON responses. Uses the same 20-byte chunked, newline-terminated framing as the
* commander.
*/
val COMMANDER_RESPONSE_UUID: Uuid = Uuid.parse("e081fec2-f757-4449-b9c9-bfa83133f7fc")
/** Read/notify: current WiFi adapter connection state (1 byte). */
val WIRELESS_CONNECTION_STATUS_UUID: Uuid = Uuid.parse("e081fec3-f757-4449-b9c9-bfa83133f7fc")
// endregion
// region Network Service
/** Service for enabling/disabling networking and wireless. */
val NETWORK_SERVICE_UUID: Uuid = Uuid.parse("ef6d6610-b8af-49e0-9eca-ab343513641c")
/** Read/notify: overall NetworkManager state (1 byte). */
val NETWORK_STATUS_UUID: Uuid = Uuid.parse("ef6d6611-b8af-49e0-9eca-ab343513641c")
// endregion
// region Protocol framing
/** Maximum ATT payload per packet when MTU negotiation is unavailable. */
const val MAX_PACKET_SIZE = 20
/** JSON stream terminator — marks the end of a reassembled message. */
const val STREAM_TERMINATOR = '\n'
/** Scan + connect timeout in milliseconds. */
const val SCAN_TIMEOUT_MS = 10_000L
/** Maximum time to wait for a command response. */
const val RESPONSE_TIMEOUT_MS = 15_000L
/** Settle time after subscribing to notifications before sending commands. */
const val SUBSCRIPTION_SETTLE_MS = 300L
// endregion
// region Wireless Commander command codes
/** Request the list of visible WiFi networks. */
const val CMD_GET_NETWORKS = 0
/** Connect to a network using SSID + password. */
const val CMD_CONNECT = 1
/** Connect to a hidden network using SSID + password. */
const val CMD_CONNECT_HIDDEN = 2
/** Trigger a fresh WiFi scan. */
const val CMD_SCAN = 4
// endregion
// region Response error codes
const val RESPONSE_SUCCESS = 0
const val RESPONSE_INVALID_COMMAND = 1
const val RESPONSE_INVALID_PARAMETER = 2
const val RESPONSE_NETWORK_MANAGER_UNAVAILABLE = 3
const val RESPONSE_WIRELESS_UNAVAILABLE = 4
const val RESPONSE_NETWORKING_DISABLED = 5
const val RESPONSE_WIRELESS_DISABLED = 6
const val RESPONSE_UNKNOWN = 7
// endregion
}

View file

@ -0,0 +1,257 @@
/*
* 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.feature.wifiprovision
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.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.feature.wifiprovision.domain.NymeaWifiService
import org.meshtastic.feature.wifiprovision.model.ProvisionResult
import org.meshtastic.feature.wifiprovision.model.WifiNetwork
// ---------------------------------------------------------------------------
// UI State
// ---------------------------------------------------------------------------
data class WifiProvisionUiState(
val phase: Phase = Phase.Idle,
val networks: List<WifiNetwork> = emptyList(),
val error: WifiProvisionError? = null,
/** Name of the BLE device we connected to, shown in the DeviceFound confirmation. */
val deviceName: String? = null,
/** Provisioning outcome shown as inline status (matches web flasher pattern). */
val provisionStatus: ProvisionStatus = ProvisionStatus.Idle,
) {
enum class Phase {
/** No operation running — initial state before BLE connect. */
Idle,
/** Scanning BLE for a nymea device. */
ConnectingBle,
/** BLE device found and connected; waiting for user to proceed. */
DeviceFound,
/** Fetching visible WiFi networks from the device. */
LoadingNetworks,
/** Connected and networks loaded — the main configuration screen. */
Connected,
/** Sending WiFi credentials to the device. */
Provisioning,
}
enum class ProvisionStatus {
Idle,
Success,
Failed,
}
}
/**
* Typed error categories for the WiFi provisioning flow.
*
* Formatted into user-visible strings in the UI layer using string resources, keeping the ViewModel free of
* locale-specific text.
*/
sealed interface WifiProvisionError {
/** Detail message from the underlying exception (language-agnostic, typically from the BLE stack). */
val detail: String
/** BLE connection to the provisioning device failed. */
data class ConnectFailed(override val detail: String) : WifiProvisionError
/** WiFi network scan on the device failed. */
data class ScanFailed(override val detail: String) : WifiProvisionError
/** Sending WiFi credentials to the device failed. */
data class ProvisionFailed(override val detail: String) : WifiProvisionError
}
// ---------------------------------------------------------------------------
// ViewModel
// ---------------------------------------------------------------------------
/**
* ViewModel for the WiFi provisioning flow.
*
* Uses [Factory] scope so a fresh [NymeaWifiService] (and its own [BleConnectionFactory]-backed
* [org.meshtastic.core.ble.BleConnection]) is created for each provisioning session.
*/
@Factory
class WifiProvisionViewModel(
private val bleScanner: BleScanner,
private val bleConnectionFactory: BleConnectionFactory,
) : ViewModel() {
private val _uiState = MutableStateFlow(WifiProvisionUiState())
val uiState: StateFlow<WifiProvisionUiState> = _uiState.asStateFlow()
/** Lazily-created service; reset on [reset]. */
private var service: NymeaWifiService? = null
// region Public actions (called from UI)
/**
* Scan for the nearest nymea-networkmanager device and connect to it. Pauses at the
* [WifiProvisionUiState.Phase.DeviceFound] phase so the user can confirm before proceeding this is the Android
* analog of the web flasher's native BLE pairing dialog.
*
* @param address Optional MAC address to target a specific device.
*/
fun connectToDevice(address: String? = null) {
_uiState.update { it.copy(phase = WifiProvisionUiState.Phase.ConnectingBle, error = null) }
viewModelScope.launch {
val nymeaService = NymeaWifiService(bleScanner, bleConnectionFactory)
service = nymeaService
nymeaService
.connect(address)
.onSuccess { deviceName ->
Logger.i { "$TAG: BLE connected to: $deviceName" }
_uiState.update { it.copy(phase = WifiProvisionUiState.Phase.DeviceFound, deviceName = deviceName) }
}
.onFailure { e ->
Logger.e(e) { "$TAG: BLE connect failed" }
_uiState.update {
it.copy(
phase = WifiProvisionUiState.Phase.Idle,
error = WifiProvisionError.ConnectFailed(e.message ?: "Unknown error"),
)
}
}
}
}
/** Called when the user confirms they want to scan networks after device discovery. */
fun scanNetworks() {
val nymeaService =
service
?: run {
connectToDevice()
return
}
viewModelScope.launch { loadNetworks(nymeaService) }
}
/**
* Send WiFi credentials to the device.
*
* @param ssid The target network SSID.
* @param password The network password (empty string for open networks).
*/
fun provisionWifi(ssid: String, password: String) {
if (ssid.isBlank()) return
val nymeaService = service ?: return
_uiState.update {
it.copy(
phase = WifiProvisionUiState.Phase.Provisioning,
error = null,
provisionStatus = WifiProvisionUiState.ProvisionStatus.Idle,
)
}
viewModelScope.launch {
when (val result = nymeaService.provision(ssid, password)) {
is ProvisionResult.Success -> {
Logger.i { "$TAG: Provisioned successfully" }
_uiState.update {
it.copy(
phase = WifiProvisionUiState.Phase.Connected,
provisionStatus = WifiProvisionUiState.ProvisionStatus.Success,
)
}
}
is ProvisionResult.Failure -> {
Logger.w { "$TAG: Provision failed: ${result.message}" }
_uiState.update {
it.copy(
phase = WifiProvisionUiState.Phase.Connected,
provisionStatus = WifiProvisionUiState.ProvisionStatus.Failed,
error = WifiProvisionError.ProvisionFailed(result.message),
)
}
}
}
}
}
/** Disconnect and close any active BLE connection. */
fun disconnect() {
viewModelScope.launch {
service?.close()
service = null
_uiState.value = WifiProvisionUiState()
}
}
// endregion
override fun onCleared() {
super.onCleared()
service?.cancel()
}
// region Private helpers
private suspend fun loadNetworks(nymeaService: NymeaWifiService) {
_uiState.update { it.copy(phase = WifiProvisionUiState.Phase.LoadingNetworks) }
nymeaService
.scanNetworks()
.onSuccess { networks ->
_uiState.update {
it.copy(phase = WifiProvisionUiState.Phase.Connected, networks = deduplicateBySsid(networks))
}
}
.onFailure { e ->
Logger.e(e) { "$TAG: scanNetworks failed" }
_uiState.update {
it.copy(
phase = WifiProvisionUiState.Phase.Connected,
error = WifiProvisionError.ScanFailed(e.message ?: "Unknown error"),
)
}
}
}
// endregion
companion object {
private const val TAG = "WifiProvisionViewModel"
/**
* Deduplicate networks by SSID, keeping the entry with the strongest signal for each. Since we only send SSID
* (not BSSID) to the device, showing duplicates is confusing.
*/
internal fun deduplicateBySsid(networks: List<WifiNetwork>): List<WifiNetwork> = networks
.groupBy { it.ssid }
.map { (_, entries) -> entries.maxBy { it.signalStrength } }
.sortedByDescending { it.signalStrength }
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.feature.wifiprovision.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.feature.wifiprovision")
class FeatureWifiProvisionModule

View file

@ -0,0 +1,80 @@
/*
* 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.feature.wifiprovision.domain
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.MAX_PACKET_SIZE
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.STREAM_TERMINATOR
/**
* Codec for the nymea-networkmanager BLE framing protocol.
*
* The protocol transfers JSON over BLE using packets capped at [MAX_PACKET_SIZE] bytes (20). A complete message is
* terminated by a newline character (`\n`) at the end of the final packet.
*
* **Sending:** call [encode] to split a compact JSON string into an ordered list of byte-array packets, each
* [maxPacketSize] bytes. The last packet always ends with `\n`.
*
* **Receiving:** feed incoming BLE notification bytes into [Reassembler]. It accumulates UTF-8 chunks and emits a
* complete JSON string once it sees the `\n` terminator.
*/
internal object NymeaPacketCodec {
/**
* Encodes [json] (without trailing newline) into a list of BLE packets, each [maxPacketSize] bytes. The `\n`
* terminator is appended before chunking so it lands inside the final packet.
*/
fun encode(json: String, maxPacketSize: Int = MAX_PACKET_SIZE): List<ByteArray> {
val payload = (json + STREAM_TERMINATOR).encodeToByteArray()
val packets = mutableListOf<ByteArray>()
var offset = 0
while (offset < payload.size) {
val end = minOf(offset + maxPacketSize, payload.size)
packets += payload.copyOfRange(offset, end)
offset = end
}
return packets
}
/**
* Stateful reassembler for inbound BLE notification packets.
*
* Feed each raw notification into [feed]. When a packet ending with `\n` is received the accumulated UTF-8 string
* (minus the terminator) is returned; otherwise `null` is returned and the partial data is buffered.
*
* Not thread-safe callers must serialise access (e.g., collect in a single coroutine).
*/
class Reassembler {
private val buffer = StringBuilder()
/** Feed the next BLE notification payload. Returns the complete JSON string or `null`. */
fun feed(bytes: ByteArray): String? {
buffer.append(bytes.decodeToString())
return if (buffer.endsWith(STREAM_TERMINATOR)) {
val message = buffer.dropLast(1).toString()
buffer.clear()
message
} else {
null
}
}
/** Discard any partial data accumulated so far. */
fun reset() {
buffer.clear()
}
}
}

View file

@ -0,0 +1,96 @@
/*
* 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.feature.wifiprovision.domain
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* kotlinx.serialization models for the nymea-networkmanager JSON-over-BLE protocol.
*
* All messages are compact JSON objects terminated with a newline (`\n`) and chunked into 20-byte BLE
* notification/write packets.
*
* Reference: https://github.com/nymea/nymea-networkmanager#bluetooth-gatt-profile
*/
// ---------------------------------------------------------------------------
// Shared JSON codec — lenient so unknown fields are silently ignored
// ---------------------------------------------------------------------------
internal val NymeaJson = Json {
ignoreUnknownKeys = true
isLenient = true
}
// ---------------------------------------------------------------------------
// Commands (app → device)
// ---------------------------------------------------------------------------
/** A command with no parameters (e.g. GetNetworks, TriggerScan). */
@Serializable internal data class NymeaSimpleCommand(@SerialName("c") val command: Int)
/** The parameter payload for the Connect / ConnectHidden commands. */
@Serializable
internal data class NymeaConnectParams(
/** SSID (nymea key: `e`). */
@SerialName("e") val ssid: String,
/** Password (nymea key: `p`). */
@SerialName("p") val password: String,
)
/** A command that carries a [NymeaConnectParams] payload. */
@Serializable
internal data class NymeaConnectCommand(
@SerialName("c") val command: Int,
@SerialName("p") val params: NymeaConnectParams,
)
// ---------------------------------------------------------------------------
// Responses (device → app)
// ---------------------------------------------------------------------------
/** Generic response — present in every reply from the device. */
@Serializable
internal data class NymeaResponse(
/** Echo of the command code. */
@SerialName("c") val command: Int = -1,
/** 0 = success; non-zero = error code. */
@SerialName("r") val responseCode: Int = 0,
)
/** One entry in the GetNetworks (`c=0`) response payload. */
@Serializable
internal data class NymeaNetworkEntry(
/** SSID (nymea key: `e`). */
@SerialName("e") val ssid: String,
/** BSSID / MAC address (nymea key: `m`). */
@SerialName("m") val bssid: String = "",
/** Signal strength in dBm (nymea key: `s`). */
@SerialName("s") val signalStrength: Int = 0,
/** 0 = open, 1 = protected (nymea key: `p`). */
@SerialName("p") val protection: Int = 0,
)
/** Full GetNetworks response including the network list. */
@Serializable
internal data class NymeaNetworksResponse(
@SerialName("c") val command: Int = -1,
@SerialName("r") val responseCode: Int = 0,
@SerialName("p") val networks: List<NymeaNetworkEntry> = emptyList(),
)

View file

@ -0,0 +1,256 @@
/*
* 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.feature.wifiprovision.domain
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.encodeToString
import org.meshtastic.core.ble.BleCharacteristic
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.feature.wifiprovision.NymeaBleConstants
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT_HIDDEN
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_GET_NETWORKS
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_SCAN
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_SUCCESS
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_TIMEOUT_MS
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SCAN_TIMEOUT_MS
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SUBSCRIPTION_SETTLE_MS
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_COMMANDER_UUID
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_SERVICE_UUID
import org.meshtastic.feature.wifiprovision.model.ProvisionResult
import org.meshtastic.feature.wifiprovision.model.WifiNetwork
import kotlin.time.Duration.Companion.milliseconds
/**
* GATT client for the nymea-networkmanager WiFi provisioning profile.
*
* Responsibilities:
* - Scan for a device advertising [WIRELESS_SERVICE_UUID].
* - Connect and subscribe to the Commander Response characteristic.
* - Send JSON commands (chunked into 20-byte BLE packets) via the Wireless Commander characteristic.
* - Reassemble newline-terminated JSON responses from notification packets.
* - Parse the nymea JSON protocol into typed Kotlin results.
*
* Lifecycle: create once per provisioning session, call [connect], use [scanNetworks] / [provision], then [close].
*/
class NymeaWifiService(
private val scanner: BleScanner,
connectionFactory: BleConnectionFactory,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
private val serviceScope = CoroutineScope(SupervisorJob() + dispatcher)
private val bleConnection = connectionFactory.create(serviceScope, TAG)
private val commanderChar = BleCharacteristic(WIRELESS_COMMANDER_UUID)
private val responseChar = BleCharacteristic(COMMANDER_RESPONSE_UUID)
/** Unbounded channel — the observer coroutine feeds complete JSON strings here. */
private val responseChannel = Channel<String>(Channel.UNLIMITED)
private val reassembler = NymeaPacketCodec.Reassembler()
// region Public API
/**
* Scan for a device advertising the nymea wireless service and connect to it.
*
* @param address Optional MAC address filter. If null, the first advertising device is used.
* @return The discovered device's advertised name on success.
* @throws IllegalStateException if no device is found within [SCAN_TIMEOUT_MS].
*/
suspend fun connect(address: String? = null): Result<String> = runCatching {
Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" }
val device =
withTimeout(SCAN_TIMEOUT_MS) {
scanner
.scan(
timeout = SCAN_TIMEOUT_MS.milliseconds,
serviceUuid = WIRELESS_SERVICE_UUID,
address = address,
)
.first()
}
val deviceName = device.name ?: device.address
Logger.i { "$TAG: Found device: ${device.name} @ ${device.address}" }
val state = bleConnection.connectAndAwait(device, SCAN_TIMEOUT_MS)
check(state is BleConnectionState.Connected) { "Failed to connect to ${device.address} — final state: $state" }
Logger.i { "$TAG: Connected. Discovering wireless service…" }
bleConnection.profile(WIRELESS_SERVICE_UUID) { service ->
val subscribed = CompletableDeferred<Unit>()
service
.observe(responseChar)
.onEach { bytes ->
val message = reassembler.feed(bytes)
if (message != null) {
Logger.d { "$TAG: ← $message" }
responseChannel.trySend(message)
}
if (!subscribed.isCompleted) subscribed.complete(Unit)
}
.catch { e ->
Logger.e(e) { "$TAG: Error in response characteristic subscription" }
if (!subscribed.isCompleted) subscribed.completeExceptionally(e)
}
.launchIn(this)
delay(SUBSCRIPTION_SETTLE_MS)
if (!subscribed.isCompleted) subscribed.complete(Unit)
subscribed.await()
Logger.i { "$TAG: Wireless service ready" }
}
deviceName
}
/**
* Trigger a fresh WiFi scan on the device, then return the list of visible networks.
*
* Sends: CMD_SCAN (4), waits for ack, then CMD_GET_NETWORKS (0).
*/
suspend fun scanNetworks(): Result<List<WifiNetwork>> = runCatching {
// Trigger scan
sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN)))
val scanAck = NymeaJson.decodeFromString<NymeaResponse>(waitForResponse())
if (scanAck.responseCode != RESPONSE_SUCCESS) {
error("Scan command failed: ${nymeaErrorMessage(scanAck.responseCode)}")
}
// Fetch results
sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_GET_NETWORKS)))
val networksResponse = NymeaJson.decodeFromString<NymeaNetworksResponse>(waitForResponse())
if (networksResponse.responseCode != RESPONSE_SUCCESS) {
error("GetNetworks failed: ${nymeaErrorMessage(networksResponse.responseCode)}")
}
networksResponse.networks.map { entry ->
WifiNetwork(
ssid = entry.ssid,
bssid = entry.bssid,
signalStrength = entry.signalStrength,
isProtected = entry.protection != 0,
)
}
}
/**
* Provision the device with the given WiFi credentials.
*
* Sends CMD_CONNECT (1) or CMD_CONNECT_HIDDEN (2) with the SSID and password. The response error code is mapped to
* a [ProvisionResult].
*
* @param ssid The target network SSID.
* @param password The network password. Pass an empty string for open networks.
* @param hidden Set to `true` to target a hidden (non-broadcasting) network.
*/
suspend fun provision(ssid: String, password: String, hidden: Boolean = false): ProvisionResult {
val cmd = if (hidden) CMD_CONNECT_HIDDEN else CMD_CONNECT
val json =
NymeaJson.encodeToString(
NymeaConnectCommand(command = cmd, params = NymeaConnectParams(ssid = ssid, password = password)),
)
return runCatching {
sendCommand(json)
val response = NymeaJson.decodeFromString<NymeaResponse>(waitForResponse())
if (response.responseCode == RESPONSE_SUCCESS) {
ProvisionResult.Success
} else {
ProvisionResult.Failure(response.responseCode, nymeaErrorMessage(response.responseCode))
}
}
.getOrElse { e ->
Logger.e(e) { "$TAG: Provision failed" }
ProvisionResult.Failure(-1, e.message ?: "Unknown error")
}
}
/** Disconnect and cancel the service scope. */
suspend fun close() {
bleConnection.disconnect()
reassembler.reset()
serviceScope.cancel()
}
/**
* Synchronous teardown cancels the service scope (and its child BLE connection) without suspending.
*
* Use this from `ViewModel.onCleared()` where `viewModelScope` is already cancelled and launching a new coroutine
* is not possible.
*/
fun cancel() {
reassembler.reset()
serviceScope.cancel()
}
// endregion
// region Internal helpers
/** Encode [json] into ≤20-byte packets and write each one WITH_RESPONSE to the commander characteristic. */
private suspend fun sendCommand(json: String) {
Logger.d { "$TAG: → $json" }
val packets = NymeaPacketCodec.encode(json)
bleConnection.profile(WIRELESS_SERVICE_UUID) { service ->
for (packet in packets) {
service.write(commanderChar, packet, BleWriteType.WITH_RESPONSE)
}
}
}
/** Wait up to [RESPONSE_TIMEOUT_MS] for a complete JSON response from the notification channel. */
private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT_MS) { responseChannel.receive() }
private fun nymeaErrorMessage(code: Int): String = when (code) {
NymeaBleConstants.RESPONSE_INVALID_COMMAND -> "Invalid command"
NymeaBleConstants.RESPONSE_INVALID_PARAMETER -> "Invalid parameter"
NymeaBleConstants.RESPONSE_NETWORK_MANAGER_UNAVAILABLE -> "NetworkManager not available"
NymeaBleConstants.RESPONSE_WIRELESS_UNAVAILABLE -> "Wireless adapter not available"
NymeaBleConstants.RESPONSE_NETWORKING_DISABLED -> "Networking disabled"
NymeaBleConstants.RESPONSE_WIRELESS_DISABLED -> "Wireless disabled"
else -> "Unknown error (code $code)"
}
// endregion
companion object {
private const val TAG = "NymeaWifiService"
}
}

View file

@ -0,0 +1,36 @@
/*
* 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.feature.wifiprovision.model
/** A WiFi access point returned by the nymea GetNetworks command. */
data class WifiNetwork(
/** ESSID / network name. */
val ssid: String,
/** MAC address of the access point. */
val bssid: String,
/** Signal strength [0-100] %. */
val signalStrength: Int,
/** Whether the network requires a password. */
val isProtected: Boolean,
)
/** Result of a WiFi provisioning attempt. */
sealed interface ProvisionResult {
data object Success : ProvisionResult
data class Failure(val errorCode: Int, val message: String) : ProvisionResult
}

View file

@ -0,0 +1,39 @@
/*
* 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.feature.wifiprovision.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.WifiProvisionRoutes
import org.meshtastic.feature.wifiprovision.ui.WifiProvisionScreen
/**
* Registers the WiFi provisioning graph entries into the host navigation provider.
*
* Both the graph sentinel ([WifiProvisionRoutes.WifiProvisionGraph]) and the primary screen
* ([WifiProvisionRoutes.WifiProvision]) navigate to the same composable so that the feature can be reached via either a
* top-level push or a deep-link graph push.
*/
fun EntryProviderScope<NavKey>.wifiProvisionGraph(backStack: NavBackStack<NavKey>) {
entry<WifiProvisionRoutes.WifiProvisionGraph> {
WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() })
}
entry<WifiProvisionRoutes.WifiProvision> { key ->
WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }, address = key.address)
}
}

View file

@ -0,0 +1,101 @@
/*
* 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.feature.wifiprovision.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.wifi_provision_sending_credentials
import org.meshtastic.core.resources.wifi_provision_status_applied
import org.meshtastic.core.resources.wifi_provision_status_failed
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus
/** Inline status card matching the web flasher's colored status feedback. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun ProvisionStatusCard(provisionStatus: ProvisionStatus, isProvisioning: Boolean) {
val colors = statusCardColors(provisionStatus, isProvisioning)
Card(
colors = CardDefaults.cardColors(containerColor = colors.first),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
StatusIcon(provisionStatus = provisionStatus, isProvisioning = isProvisioning, tint = colors.second)
Text(
text = statusText(provisionStatus, isProvisioning),
style = MaterialTheme.typography.bodyMediumEmphasized,
color = colors.second,
)
}
}
}
/** Resolve container + content color pair for the provision status card. */
@Composable
private fun statusCardColors(provisionStatus: ProvisionStatus, isProvisioning: Boolean): Pair<Color, Color> = when {
isProvisioning -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer
provisionStatus == ProvisionStatus.Success ->
MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
provisionStatus == ProvisionStatus.Failed ->
MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
else -> MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurface
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StatusIcon(provisionStatus: ProvisionStatus, isProvisioning: Boolean, tint: Color) {
when {
isProvisioning -> LoadingIndicator(modifier = Modifier.size(20.dp), color = tint)
provisionStatus == ProvisionStatus.Success ->
Icon(Icons.Rounded.CheckCircle, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint)
provisionStatus == ProvisionStatus.Failed ->
Icon(Icons.Rounded.Error, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint)
}
}
@Composable
private fun statusText(provisionStatus: ProvisionStatus, isProvisioning: Boolean): String = when {
isProvisioning -> stringResource(Res.string.wifi_provision_sending_credentials)
provisionStatus == ProvisionStatus.Success -> stringResource(Res.string.wifi_provision_status_applied)
provisionStatus == ProvisionStatus.Failed -> stringResource(Res.string.wifi_provision_status_failed)
else -> ""
}

View file

@ -0,0 +1,348 @@
/*
* 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/>.
*/
@file:Suppress("TooManyFunctions", "MagicNumber")
package org.meshtastic.feature.wifiprovision.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus
import org.meshtastic.feature.wifiprovision.model.WifiNetwork
// ---------------------------------------------------------------------------
// Sample data for previews
// ---------------------------------------------------------------------------
private val sampleNetworks =
listOf(
WifiNetwork(ssid = "Meshtastic-HQ", bssid = "AA:BB:CC:DD:EE:01", signalStrength = 92, isProtected = true),
WifiNetwork(ssid = "CoffeeShop-Free", bssid = "AA:BB:CC:DD:EE:02", signalStrength = 74, isProtected = false),
WifiNetwork(ssid = "OffGrid-5G", bssid = "AA:BB:CC:DD:EE:03", signalStrength = 58, isProtected = true),
WifiNetwork(ssid = "Neighbor-Net", bssid = "AA:BB:CC:DD:EE:04", signalStrength = 31, isProtected = true),
)
private val edgeCaseNetworks =
listOf(
WifiNetwork(
ssid = "My Super Long WiFi Network Name That Goes On And On Forever",
bssid = "AA:BB:CC:DD:EE:10",
signalStrength = 85,
isProtected = true,
),
WifiNetwork(ssid = "x", bssid = "AA:BB:CC:DD:EE:11", signalStrength = 99, isProtected = false),
WifiNetwork(
ssid = "Hidden-char \u200B\u200B",
bssid = "AA:BB:CC:DD:EE:12",
signalStrength = 42,
isProtected = true,
),
)
private val manyNetworks =
(1..20).map { i ->
WifiNetwork(
ssid = "Network-$i",
bssid = "AA:BB:CC:DD:EE:${i.toString().padStart(2, '0')}",
signalStrength = (100 - i * 4).coerceAtLeast(5),
isProtected = i % 3 != 0,
)
}
private val noOp: () -> Unit = {}
private val noOpProvision: (String, String) -> Unit = { _, _ -> }
// ---------------------------------------------------------------------------
// Phase 1: BLE scanning
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun ScanningBlePreview() {
AppTheme { Surface(Modifier.fillMaxSize()) { ScanningBleContent() } }
}
// ---------------------------------------------------------------------------
// Phase 2: Device found confirmation
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun DeviceFoundPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
DeviceFoundContent(deviceName = "mpwrd-nm-A1B2", onProceed = noOp, onCancel = noOp)
}
}
}
@PreviewLightDark
@Composable
private fun DeviceFoundNoNamePreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) { DeviceFoundContent(deviceName = null, onProceed = noOp, onCancel = noOp) }
}
}
// ---------------------------------------------------------------------------
// Phase 3: WiFi network scanning
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun ScanningNetworksPreview() {
AppTheme { Surface(Modifier.fillMaxSize()) { ScanningNetworksContent() } }
}
// ---------------------------------------------------------------------------
// Phase 4: Connected — main configuration screen variants
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun ConnectedWithNetworksPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Idle,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
onProvision = noOpProvision,
onDisconnect = noOp,
)
}
}
}
@PreviewLightDark
@Composable
private fun ConnectedEmptyNetworksPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
ConnectedContent(
networks = emptyList(),
provisionStatus = ProvisionStatus.Idle,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
onProvision = noOpProvision,
onDisconnect = noOp,
)
}
}
}
@PreviewLightDark
@Composable
private fun ConnectedScanningPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Idle,
isProvisioning = false,
isScanning = true,
onScanNetworks = noOp,
onProvision = noOpProvision,
onDisconnect = noOp,
)
}
}
}
@PreviewLightDark
@Composable
private fun ConnectedProvisioningPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Idle,
isProvisioning = true,
isScanning = false,
onScanNetworks = noOp,
onProvision = noOpProvision,
onDisconnect = noOp,
)
}
}
}
@PreviewLightDark
@Composable
private fun ConnectedSuccessPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Success,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
onProvision = noOpProvision,
onDisconnect = noOp,
)
}
}
}
@PreviewLightDark
@Composable
private fun ConnectedFailedPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Failed,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
onProvision = noOpProvision,
onDisconnect = noOp,
)
}
}
}
// ---------------------------------------------------------------------------
// Edge-case previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun ConnectedLongSsidPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
ConnectedContent(
networks = edgeCaseNetworks,
provisionStatus = ProvisionStatus.Idle,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
onProvision = noOpProvision,
onDisconnect = noOp,
)
}
}
}
@PreviewLightDark
@Composable
private fun ConnectedManyNetworksPreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
ConnectedContent(
networks = manyNetworks,
provisionStatus = ProvisionStatus.Idle,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
onProvision = noOpProvision,
onDisconnect = noOp,
)
}
}
}
@PreviewLightDark
@Composable
private fun DeviceFoundLongNamePreview() {
AppTheme {
Surface(Modifier.fillMaxSize()) {
DeviceFoundContent(
deviceName = "mpwrd-nm-A1B2C3D4E5F6-extra-long-identifier",
onProceed = noOp,
onCancel = noOp,
)
}
}
}
// ---------------------------------------------------------------------------
// Standalone component previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun ProvisionStatusCardProvisioningPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
ProvisionStatusCard(provisionStatus = ProvisionStatus.Idle, isProvisioning = true)
}
}
}
}
@PreviewLightDark
@Composable
private fun ProvisionStatusCardSuccessPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
ProvisionStatusCard(provisionStatus = ProvisionStatus.Success, isProvisioning = false)
}
}
}
}
@PreviewLightDark
@Composable
private fun ProvisionStatusCardFailedPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
ProvisionStatusCard(provisionStatus = ProvisionStatus.Failed, isProvisioning = false)
}
}
}
}
@PreviewLightDark
@Composable
private fun NetworkRowPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth()) {
NetworkRow(network = sampleNetworks[0], isSelected = false, onClick = noOp)
NetworkRow(network = sampleNetworks[1], isSelected = true, onClick = noOp)
}
}
}
}
@PreviewLightDark
@Composable
private fun NetworkRowLongSsidPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.fillMaxWidth()) {
NetworkRow(network = edgeCaseNetworks[0], isSelected = false, onClick = noOp)
NetworkRow(network = edgeCaseNetworks[1], isSelected = true, onClick = noOp)
}
}
}
}

View file

@ -0,0 +1,497 @@
/*
* 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.feature.wifiprovision.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.apply
import org.meshtastic.core.resources.back
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.hide_password
import org.meshtastic.core.resources.password
import org.meshtastic.core.resources.show_password
import org.meshtastic.core.resources.wifi_provision_available_networks
import org.meshtastic.core.resources.wifi_provision_connect_failed
import org.meshtastic.core.resources.wifi_provision_description
import org.meshtastic.core.resources.wifi_provision_device_found
import org.meshtastic.core.resources.wifi_provision_device_found_detail
import org.meshtastic.core.resources.wifi_provision_no_networks
import org.meshtastic.core.resources.wifi_provision_scan_failed
import org.meshtastic.core.resources.wifi_provision_scan_networks
import org.meshtastic.core.resources.wifi_provision_scanning_ble
import org.meshtastic.core.resources.wifi_provision_scanning_wifi
import org.meshtastic.core.resources.wifi_provision_sending_credentials
import org.meshtastic.core.resources.wifi_provision_signal_strength
import org.meshtastic.core.resources.wifi_provision_ssid_label
import org.meshtastic.core.resources.wifi_provision_ssid_placeholder
import org.meshtastic.core.resources.wifi_provisioning
import org.meshtastic.feature.wifiprovision.WifiProvisionError
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.Phase
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus
import org.meshtastic.feature.wifiprovision.WifiProvisionViewModel
import org.meshtastic.feature.wifiprovision.model.WifiNetwork
private const val NETWORK_LIST_MAX_HEIGHT_DP = 240
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod")
@Composable
fun WifiProvisionScreen(
onNavigateUp: () -> Unit,
address: String? = null,
viewModel: WifiProvisionViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val errorMessage =
uiState.error?.let { error ->
when (error) {
is WifiProvisionError.ConnectFailed ->
stringResource(Res.string.wifi_provision_connect_failed, error.detail)
is WifiProvisionError.ScanFailed -> stringResource(Res.string.wifi_provision_scan_failed, error.detail)
is WifiProvisionError.ProvisionFailed -> error.detail
}
}
LaunchedEffect(uiState.error) { errorMessage?.let { snackbarHostState.showSnackbar(it) } }
LaunchedEffect(Unit) { viewModel.connectToDevice(address) }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text(stringResource(Res.string.wifi_provisioning)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize().animateContentSize()) {
// Indeterminate progress bar for active operations
if (uiState.phase.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
Spacer(Modifier.height(4.dp))
}
Crossfade(targetState = screenKey(uiState), label = "wifi_provision") { key ->
when (key) {
ScreenKey.ConnectingBle -> ScanningBleContent()
ScreenKey.DeviceFound ->
DeviceFoundContent(
deviceName = uiState.deviceName,
onProceed = viewModel::scanNetworks,
onCancel = onNavigateUp,
)
ScreenKey.LoadingNetworks -> ScanningNetworksContent()
ScreenKey.Connected ->
ConnectedContent(
networks = uiState.networks,
provisionStatus = uiState.provisionStatus,
isProvisioning = uiState.phase == Phase.Provisioning,
isScanning = uiState.phase == Phase.LoadingNetworks,
onScanNetworks = viewModel::scanNetworks,
onProvision = viewModel::provisionWifi,
onDisconnect = {
viewModel.disconnect()
onNavigateUp()
},
)
}
}
}
}
}
// ---------------------------------------------------------------------------
// Screen-key helper for Crossfade
// ---------------------------------------------------------------------------
private enum class ScreenKey {
ConnectingBle,
DeviceFound,
LoadingNetworks,
Connected,
}
private fun screenKey(state: WifiProvisionUiState): ScreenKey = when (state.phase) {
Phase.Idle,
Phase.ConnectingBle,
-> ScreenKey.ConnectingBle
Phase.DeviceFound -> ScreenKey.DeviceFound
Phase.LoadingNetworks -> if (state.networks.isEmpty()) ScreenKey.LoadingNetworks else ScreenKey.Connected
Phase.Connected,
Phase.Provisioning,
-> ScreenKey.Connected
}
private val Phase.isLoading: Boolean
get() = this == Phase.ConnectingBle || this == Phase.LoadingNetworks || this == Phase.Provisioning
// ---------------------------------------------------------------------------
// Sub-composables
// ---------------------------------------------------------------------------
/** BLE scanning spinner — shown while searching for a device. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun ScanningBleContent() {
CenteredStatusContent {
LoadingIndicator(modifier = Modifier.size(48.dp))
Spacer(Modifier.height(24.dp))
Text(stringResource(Res.string.wifi_provision_scanning_ble), style = MaterialTheme.typography.bodyLarge)
}
}
/**
* Confirmation step shown after BLE device discovery the Android analog of the web flasher's native BLE pairing
* prompt. Gives the user a clear "device found" moment before proceeding.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun DeviceFoundContent(deviceName: String?, onProceed: () -> Unit, onCancel: () -> Unit) {
CenteredStatusContent {
Icon(
Icons.Rounded.Bluetooth,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(Res.string.wifi_provision_device_found),
style = MaterialTheme.typography.headlineSmallEmphasized,
textAlign = TextAlign.Center,
)
if (deviceName != null) {
Spacer(Modifier.height(4.dp))
Text(
deviceName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(Res.string.wifi_provision_device_found_detail),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) }
Button(onClick = onProceed) { Text(stringResource(Res.string.wifi_provision_scan_networks)) }
}
}
}
/** Network scanning spinner — shown during the initial scan when no networks are loaded yet. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun ScanningNetworksContent() {
CenteredStatusContent {
LoadingIndicator(modifier = Modifier.size(48.dp))
Spacer(Modifier.height(24.dp))
Text(stringResource(Res.string.wifi_provision_scanning_wifi), style = MaterialTheme.typography.bodyLarge)
}
}
/**
* Main configuration screen shown after BLE connection mirrors the web flasher's connected state. All controls (scan
* button, network list, SSID/password fields, Apply, status) are on one screen.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "LongParameterList")
@Composable
internal fun ConnectedContent(
networks: List<WifiNetwork>,
provisionStatus: ProvisionStatus,
isProvisioning: Boolean,
isScanning: Boolean,
onScanNetworks: () -> Unit,
onProvision: (ssid: String, password: String) -> Unit,
onDisconnect: () -> Unit,
) {
var ssid by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
LaunchedEffect(provisionStatus) {
if (provisionStatus == ProvisionStatus.Success) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
Column(
modifier =
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(Res.string.wifi_provision_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Scan button — FilledTonalButton for prominent secondary action
FilledTonalButton(
onClick = onScanNetworks,
enabled = !isScanning && !isProvisioning,
modifier = Modifier.fillMaxWidth(),
) {
if (isScanning) {
LoadingIndicator(modifier = Modifier.size(18.dp))
} else {
Icon(Icons.Rounded.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
}
Spacer(Modifier.width(8.dp))
Text(
if (isScanning) {
stringResource(Res.string.wifi_provision_scanning_wifi)
} else {
stringResource(Res.string.wifi_provision_scan_networks)
},
)
}
// Network list (scrollable, capped height) — animated entrance
AnimatedVisibility(
visible = networks.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column {
Text(
stringResource(Res.string.wifi_provision_available_networks),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Card(
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
) {
LazyColumn(modifier = Modifier.heightIn(max = NETWORK_LIST_MAX_HEIGHT_DP.dp)) {
items(networks, key = { it.ssid }) { network ->
NetworkRow(
network = network,
isSelected = network.ssid == ssid,
onClick = { ssid = network.ssid },
)
}
}
}
}
}
AnimatedVisibility(visible = networks.isEmpty() && !isScanning, enter = fadeIn(), exit = fadeOut()) {
Text(
stringResource(Res.string.wifi_provision_no_networks),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
// SSID input
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text(stringResource(Res.string.wifi_provision_ssid_label)) },
placeholder = { Text(stringResource(Res.string.wifi_provision_ssid_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Password input
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.password)) },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility,
contentDescription =
if (passwordVisible) {
stringResource(Res.string.hide_password)
} else {
stringResource(Res.string.show_password)
},
)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onProvision(ssid, password) }),
modifier = Modifier.fillMaxWidth(),
)
// Inline provision status (matches web flasher's status chip) — animated entrance
AnimatedVisibility(
visible = provisionStatus != ProvisionStatus.Idle || isProvisioning,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
ProvisionStatusCard(provisionStatus = provisionStatus, isProvisioning = isProvisioning)
}
// Action buttons — cancel left, primary action right (app convention)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onDisconnect) { Text(stringResource(Res.string.cancel)) }
Button(
onClick = { onProvision(ssid, password) },
enabled = ssid.isNotBlank() && !isProvisioning,
modifier = Modifier.weight(1f),
) {
if (isProvisioning) {
LoadingIndicator(modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.wifi_provision_sending_credentials))
} else {
Icon(Icons.Rounded.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.apply))
}
}
}
}
}
@Composable
internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () -> Unit) {
val containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
ListItem(
headlineContent = { Text(network.ssid) },
supportingContent = { Text(stringResource(Res.string.wifi_provision_signal_strength, network.signalStrength)) },
leadingContent = {
Icon(Icons.Rounded.Wifi, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
},
trailingContent = {
if (network.isProtected) {
Icon(
Icons.Rounded.Lock,
contentDescription = stringResource(Res.string.password),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
colors = ListItemDefaults.colors(containerColor = containerColor),
modifier = Modifier.clickable(onClick = onClick),
)
}
// ---------------------------------------------------------------------------
// Shared layout wrapper for centered status screens
// ---------------------------------------------------------------------------
@Composable
private fun CenteredStatusContent(content: @Composable () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
content()
}
}

View file

@ -0,0 +1,100 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.wifiprovision
import org.meshtastic.feature.wifiprovision.model.WifiNetwork
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Tests for [WifiProvisionViewModel.deduplicateBySsid]. */
class DeduplicateBySsidTest {
private fun network(ssid: String, signal: Int, bssid: String = "00:00:00:00:00:00") =
WifiNetwork(ssid = ssid, bssid = bssid, signalStrength = signal, isProtected = true)
@Test
fun `empty list returns empty`() {
val result = WifiProvisionViewModel.deduplicateBySsid(emptyList())
assertTrue(result.isEmpty())
}
@Test
fun `single network is returned unchanged`() {
val input = listOf(network("HomeWifi", 80))
val result = WifiProvisionViewModel.deduplicateBySsid(input)
assertEquals(1, result.size)
assertEquals("HomeWifi", result[0].ssid)
assertEquals(80, result[0].signalStrength)
}
@Test
fun `duplicate SSIDs keep strongest signal`() {
val input =
listOf(
network("HomeWifi", 50, bssid = "AA:BB:CC:DD:EE:01"),
network("HomeWifi", 90, bssid = "AA:BB:CC:DD:EE:02"),
network("HomeWifi", 70, bssid = "AA:BB:CC:DD:EE:03"),
)
val result = WifiProvisionViewModel.deduplicateBySsid(input)
assertEquals(1, result.size)
assertEquals(90, result[0].signalStrength)
assertEquals("AA:BB:CC:DD:EE:02", result[0].bssid)
}
@Test
fun `mixed duplicates and unique networks are all handled`() {
val input =
listOf(
network("Alpha", 40),
network("Beta", 80),
network("Alpha", 60),
network("Gamma", 30),
network("Beta", 50),
)
val result = WifiProvisionViewModel.deduplicateBySsid(input)
assertEquals(3, result.size)
// Should be sorted by signal strength descending
assertEquals("Beta", result[0].ssid)
assertEquals(80, result[0].signalStrength)
assertEquals("Alpha", result[1].ssid)
assertEquals(60, result[1].signalStrength)
assertEquals("Gamma", result[2].ssid)
assertEquals(30, result[2].signalStrength)
}
@Test
fun `result is sorted by signal strength descending`() {
val input = listOf(network("Weak", 10), network("Strong", 95), network("Medium", 55))
val result = WifiProvisionViewModel.deduplicateBySsid(input)
assertEquals(listOf(95, 55, 10), result.map { it.signalStrength })
}
@Test
fun `preserves isProtected from strongest entry`() {
val input =
listOf(
WifiNetwork(ssid = "Net", bssid = "01", signalStrength = 30, isProtected = false),
WifiNetwork(ssid = "Net", bssid = "02", signalStrength = 90, isProtected = true),
)
val result = WifiProvisionViewModel.deduplicateBySsid(input)
assertEquals(1, result.size)
assertTrue(result[0].isProtected, "Should keep isProtected from the strongest-signal entry")
}
}

View file

@ -0,0 +1,325 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.wifiprovision
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.testing.FakeBleConnection
import org.meshtastic.core.testing.FakeBleConnectionFactory
import org.meshtastic.core.testing.FakeBleDevice
import org.meshtastic.core.testing.FakeBleScanner
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.Phase
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Tests for [WifiProvisionViewModel] covering the full state machine: BLE connect, device found, scan networks,
* provisioning, disconnect, and error paths.
*
* The ViewModel creates [NymeaWifiService] internally with the injected [BleScanner] and [BleConnectionFactory], so we
* drive the flow end-to-end via BLE fakes.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class WifiProvisionViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var scanner: FakeBleScanner
private lateinit var connection: FakeBleConnection
private lateinit var viewModel: WifiProvisionViewModel
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
scanner = FakeBleScanner()
connection = FakeBleConnection()
viewModel =
WifiProvisionViewModel(bleScanner = scanner, bleConnectionFactory = FakeBleConnectionFactory(connection))
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
// -----------------------------------------------------------------------
// Initial state
// -----------------------------------------------------------------------
@Test
fun `initial state is Idle with empty data`() {
val state = viewModel.uiState.value
assertEquals(Phase.Idle, state.phase)
assertTrue(state.networks.isEmpty())
assertNull(state.error)
assertNull(state.deviceName)
assertEquals(ProvisionStatus.Idle, state.provisionStatus)
}
// -----------------------------------------------------------------------
// connectToDevice
// -----------------------------------------------------------------------
@Test
fun `connectToDevice transitions to ConnectingBle immediately`() = runTest {
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF", name = "mpwrd-nm-1234"))
viewModel.connectToDevice()
// After one dispatcher step, should be in ConnectingBle
assertEquals(Phase.ConnectingBle, viewModel.uiState.value.phase)
}
@Test
fun `connectToDevice transitions to DeviceFound on success`() = runTest {
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF", name = "mpwrd-nm-1234"))
viewModel.connectToDevice()
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.DeviceFound, state.phase)
assertEquals("mpwrd-nm-1234", state.deviceName)
assertNull(state.error)
}
@Test
fun `connectToDevice uses device address when name is null`() = runTest {
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF", name = null))
viewModel.connectToDevice()
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.DeviceFound, state.phase)
assertEquals("AA:BB:CC:DD:EE:FF", state.deviceName)
}
@Test
fun `connectToDevice sets error and returns to Idle on BLE connect failure`() = runTest {
connection.failNextN = 1
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.Idle, state.phase)
assertIs<WifiProvisionError.ConnectFailed>(state.error)
}
@Test
fun `connectToDevice sets error when connection throws exception`() = runTest {
connection.connectException = RuntimeException("BLE unavailable")
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.Idle, state.phase)
val error = assertIs<WifiProvisionError.ConnectFailed>(state.error)
assertTrue(error.detail.contains("BLE unavailable"))
}
// -----------------------------------------------------------------------
// scanNetworks
// -----------------------------------------------------------------------
@Test
fun `scanNetworks transitions to LoadingNetworks then Connected with results`() = runTest {
// First connect
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
assertEquals(Phase.DeviceFound, viewModel.uiState.value.phase)
// Enqueue nymea responses: scan ack + networks response
emitNymeaResponse("""{"c":4,"r":0}""")
emitNymeaResponse("""{"c":0,"r":0,"p":[{"e":"TestNet","m":"AA:BB","s":80,"p":1}]}""")
viewModel.scanNetworks()
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.Connected, state.phase)
assertEquals(1, state.networks.size)
assertEquals("TestNet", state.networks[0].ssid)
assertEquals(80, state.networks[0].signalStrength)
assertTrue(state.networks[0].isProtected)
}
@Test
fun `scanNetworks deduplicates networks by SSID`() = runTest {
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
emitNymeaResponse("""{"c":4,"r":0}""")
emitNymeaResponse(
"""{"c":0,"r":0,"p":[
{"e":"Dup","m":"01","s":30,"p":1},
{"e":"Dup","m":"02","s":90,"p":1},
{"e":"Unique","m":"03","s":60,"p":0}
]}""",
)
viewModel.scanNetworks()
advanceUntilIdle()
val networks = viewModel.uiState.value.networks
assertEquals(2, networks.size, "Duplicates should be merged")
assertEquals("Dup", networks[0].ssid)
assertEquals(90, networks[0].signalStrength, "Should keep strongest signal")
}
@Test
fun `scanNetworks reconnects if no service exists`() = runTest {
// Don't connect first — scanNetworks should trigger connectToDevice
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.scanNetworks()
advanceUntilIdle()
// Should have connected (DeviceFound) via the reconnect path
assertEquals(Phase.DeviceFound, viewModel.uiState.value.phase)
}
// -----------------------------------------------------------------------
// provisionWifi
// -----------------------------------------------------------------------
@Test
fun `provisionWifi transitions to Provisioning then Connected with Success`() = runTest {
// Connect and scan first
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
emitNymeaResponse("""{"c":4,"r":0}""")
emitNymeaResponse("""{"c":0,"r":0,"p":[{"e":"Net","m":"01","s":80,"p":1}]}""")
viewModel.scanNetworks()
advanceUntilIdle()
// Now provision — enqueue success response
emitNymeaResponse("""{"c":1,"r":0}""")
viewModel.provisionWifi("Net", "password123")
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.Connected, state.phase)
assertEquals(ProvisionStatus.Success, state.provisionStatus)
}
@Test
fun `provisionWifi sets Failed status on error response`() = runTest {
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
emitNymeaResponse("""{"c":4,"r":0}""")
emitNymeaResponse("""{"c":0,"r":0,"p":[]}""")
viewModel.scanNetworks()
advanceUntilIdle()
// Provision with error code 3 (NetworkManager unavailable)
emitNymeaResponse("""{"c":1,"r":3}""")
viewModel.provisionWifi("Net", "pass")
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.Connected, state.phase)
assertEquals(ProvisionStatus.Failed, state.provisionStatus)
assertIs<WifiProvisionError.ProvisionFailed>(state.error)
}
@Test
fun `provisionWifi ignores blank SSID`() = runTest {
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
val phaseBefore = viewModel.uiState.value.phase
viewModel.provisionWifi(" ", "pass")
advanceUntilIdle()
// Phase should not change — blank SSID is a no-op
assertEquals(phaseBefore, viewModel.uiState.value.phase)
}
@Test
fun `provisionWifi no-ops when service is null`() = runTest {
// Don't connect — service is null
viewModel.provisionWifi("Net", "pass")
advanceUntilIdle()
assertEquals(Phase.Idle, viewModel.uiState.value.phase)
}
// -----------------------------------------------------------------------
// disconnect
// -----------------------------------------------------------------------
@Test
fun `disconnect resets state to initial`() = runTest {
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
assertEquals(Phase.DeviceFound, viewModel.uiState.value.phase)
viewModel.disconnect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.Idle, state.phase)
assertTrue(state.networks.isEmpty())
assertNull(state.deviceName)
assertEquals(ProvisionStatus.Idle, state.provisionStatus)
}
@Test
fun `disconnect calls BLE disconnect`() = runTest {
scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF"))
viewModel.connectToDevice()
advanceUntilIdle()
viewModel.disconnect()
advanceUntilIdle()
assertTrue(connection.disconnectCalls >= 1, "BLE disconnect should be called")
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/**
* Emit a complete nymea JSON response on the Commander Response characteristic. Uses newline-terminated encoding
* matching [NymeaPacketCodec].
*/
private fun emitNymeaResponse(json: String) {
connection.service.emitNotification(COMMANDER_RESPONSE_UUID, (json + "\n").encodeToByteArray())
}
}

View file

@ -0,0 +1,168 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.wifiprovision.domain
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class NymeaPacketCodecTest {
// -----------------------------------------------------------------------
// encode()
// -----------------------------------------------------------------------
@Test
fun `encode appends newline terminator`() {
val packets = NymeaPacketCodec.encode("{}")
val reassembled = packets.joinToString("") { it.decodeToString() }
assertTrue(reassembled.endsWith("\n"), "Encoded payload must end with newline")
}
@Test
fun `encode short message fits in single packet`() {
val packets = NymeaPacketCodec.encode("{\"c\":4}")
assertEquals(1, packets.size, "Short JSON should fit in a single packet")
assertEquals("{\"c\":4}\n", packets[0].decodeToString())
}
@Test
fun `encode long message splits across multiple packets`() {
// 20-byte max packet size (default). Use a payload that exceeds it.
val json = "A".repeat(50)
val packets = NymeaPacketCodec.encode(json, maxPacketSize = 20)
assertTrue(packets.size > 1, "Long payload should be split")
packets.forEach { packet -> assertTrue(packet.size <= 20, "Each packet must be ≤ maxPacketSize") }
// Reassemble and verify content
val reassembled = packets.joinToString("") { it.decodeToString() }
assertEquals(json + "\n", reassembled)
}
@Test
fun `encode boundary payload exactly fills packets`() {
// 19 chars + 1 newline = 20 bytes = exactly 1 packet at maxPacketSize=20
val json = "A".repeat(19)
val packets = NymeaPacketCodec.encode(json, maxPacketSize = 20)
assertEquals(1, packets.size)
assertEquals(20, packets[0].size)
}
@Test
fun `encode boundary payload one byte over splits into two packets`() {
// 20 chars + 1 newline = 21 bytes → 2 packets at maxPacketSize=20
val json = "A".repeat(20)
val packets = NymeaPacketCodec.encode(json, maxPacketSize = 20)
assertEquals(2, packets.size)
assertEquals(20, packets[0].size)
assertEquals(1, packets[1].size)
}
@Test
fun `encode empty string produces single packet with just newline`() {
val packets = NymeaPacketCodec.encode("")
assertEquals(1, packets.size)
assertEquals("\n", packets[0].decodeToString())
}
@Test
fun `encode custom maxPacketSize is respected`() {
val json = "ABCDEFGHIJ" // 10 chars + 1 newline = 11 bytes
val packets = NymeaPacketCodec.encode(json, maxPacketSize = 4)
assertEquals(3, packets.size) // 4 + 4 + 3
packets.forEach { assertTrue(it.size <= 4) }
assertEquals(json + "\n", packets.joinToString("") { it.decodeToString() })
}
// -----------------------------------------------------------------------
// Reassembler
// -----------------------------------------------------------------------
@Test
fun `reassembler returns complete message on single feed with terminator`() {
val reassembler = NymeaPacketCodec.Reassembler()
val result = reassembler.feed("{\"c\":4}\n".encodeToByteArray())
assertEquals("{\"c\":4}", result)
}
@Test
fun `reassembler buffers partial data and returns null`() {
val reassembler = NymeaPacketCodec.Reassembler()
assertNull(reassembler.feed("{\"c\":".encodeToByteArray()))
assertNull(reassembler.feed("4}".encodeToByteArray()))
}
@Test
fun `reassembler completes when terminator arrives in later chunk`() {
val reassembler = NymeaPacketCodec.Reassembler()
assertNull(reassembler.feed("{\"c\":".encodeToByteArray()))
assertNull(reassembler.feed("4}".encodeToByteArray()))
val result = reassembler.feed("\n".encodeToByteArray())
assertEquals("{\"c\":4}", result)
}
@Test
fun `reassembler handles multiple messages sequentially`() {
val reassembler = NymeaPacketCodec.Reassembler()
val first = reassembler.feed("first\n".encodeToByteArray())
assertEquals("first", first)
val second = reassembler.feed("second\n".encodeToByteArray())
assertEquals("second", second)
}
@Test
fun `reassembler reset clears buffered data`() {
val reassembler = NymeaPacketCodec.Reassembler()
assertNull(reassembler.feed("partial".encodeToByteArray()))
reassembler.reset()
// After reset, the partial data is gone — new message starts fresh
val result = reassembler.feed("fresh\n".encodeToByteArray())
assertEquals("fresh", result)
}
@Test
fun `encode and reassembler round-trip`() {
val json = """{"c":1,"p":{"e":"MyNetwork","p":"secret123"}}"""
val packets = NymeaPacketCodec.encode(json)
val reassembler = NymeaPacketCodec.Reassembler()
var result: String? = null
for (packet in packets) {
result = reassembler.feed(packet)
}
assertEquals(json, result)
}
@Test
fun `encode and reassembler round-trip with small packet size`() {
val json = """{"c":0,"r":0,"p":[{"e":"TestNet","m":"AA:BB","s":85,"p":1}]}"""
val packets = NymeaPacketCodec.encode(json, maxPacketSize = 8)
assertTrue(packets.size > 1, "Should require multiple packets with small MTU")
val reassembler = NymeaPacketCodec.Reassembler()
var result: String? = null
for (packet in packets) {
result = reassembler.feed(packet)
}
assertEquals(json, result)
}
}

View file

@ -0,0 +1,145 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.wifiprovision.domain
import kotlinx.serialization.encodeToString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Tests for the nymea JSON protocol serialization models. */
class NymeaProtocolTest {
// -----------------------------------------------------------------------
// NymeaSimpleCommand
// -----------------------------------------------------------------------
@Test
fun `simple command serializes to compact JSON`() {
val json = NymeaJson.encodeToString(NymeaSimpleCommand(command = 4))
assertEquals("""{"c":4}""", json)
}
@Test
fun `simple command round-trips`() {
val original = NymeaSimpleCommand(command = 0)
val json = NymeaJson.encodeToString(original)
val decoded = NymeaJson.decodeFromString<NymeaSimpleCommand>(json)
assertEquals(original, decoded)
}
// -----------------------------------------------------------------------
// NymeaConnectCommand
// -----------------------------------------------------------------------
@Test
fun `connect command serializes with nested params`() {
val cmd = NymeaConnectCommand(command = 1, params = NymeaConnectParams(ssid = "TestNet", password = "pass123"))
val json = NymeaJson.encodeToString(cmd)
assertTrue(json.contains("\"c\":1"))
assertTrue(json.contains("\"e\":\"TestNet\""))
assertTrue(json.contains("\"p\":\"pass123\""))
}
@Test
fun `connect command with empty password`() {
val cmd = NymeaConnectCommand(command = 1, params = NymeaConnectParams(ssid = "OpenNet", password = ""))
val json = NymeaJson.encodeToString(cmd)
assertTrue(json.contains("\"p\":\"\""))
}
@Test
fun `connect command round-trips`() {
val original =
NymeaConnectCommand(command = 2, params = NymeaConnectParams(ssid = "Hidden", password = "secret"))
val json = NymeaJson.encodeToString(original)
val decoded = NymeaJson.decodeFromString<NymeaConnectCommand>(json)
assertEquals(original, decoded)
}
// -----------------------------------------------------------------------
// NymeaResponse
// -----------------------------------------------------------------------
@Test
fun `response deserializes success`() {
val response = NymeaJson.decodeFromString<NymeaResponse>("""{"c":4,"r":0}""")
assertEquals(4, response.command)
assertEquals(0, response.responseCode)
}
@Test
fun `response deserializes error code`() {
val response = NymeaJson.decodeFromString<NymeaResponse>("""{"c":1,"r":3}""")
assertEquals(1, response.command)
assertEquals(3, response.responseCode)
}
@Test
fun `response ignores unknown keys`() {
val response = NymeaJson.decodeFromString<NymeaResponse>("""{"c":0,"r":0,"extra":"field"}""")
assertEquals(0, response.responseCode)
}
// -----------------------------------------------------------------------
// NymeaNetworksResponse
// -----------------------------------------------------------------------
@Test
fun `networks response deserializes network list`() {
val json =
"""
{
"c": 0,
"r": 0,
"p": [
{"e":"HomeWifi","m":"AA:BB:CC:DD:EE:01","s":85,"p":1},
{"e":"OpenNet","m":"AA:BB:CC:DD:EE:02","s":60,"p":0}
]
}
"""
.trimIndent()
val response = NymeaJson.decodeFromString<NymeaNetworksResponse>(json)
assertEquals(0, response.responseCode)
assertEquals(2, response.networks.size)
assertEquals("HomeWifi", response.networks[0].ssid)
assertEquals(85, response.networks[0].signalStrength)
assertEquals(1, response.networks[0].protection)
assertEquals("OpenNet", response.networks[1].ssid)
assertEquals(0, response.networks[1].protection)
}
@Test
fun `networks response deserializes empty list`() {
val json = """{"c":0,"r":0,"p":[]}"""
val response = NymeaJson.decodeFromString<NymeaNetworksResponse>(json)
assertTrue(response.networks.isEmpty())
}
@Test
fun `networks response uses defaults for missing fields`() {
val json = """{"c":0,"r":0,"p":[{"e":"Minimal"}]}"""
val response = NymeaJson.decodeFromString<NymeaNetworksResponse>(json)
val entry = response.networks[0]
assertEquals("Minimal", entry.ssid)
assertEquals("", entry.bssid)
assertEquals(0, entry.signalStrength)
assertEquals(0, entry.protection)
}
}

View file

@ -0,0 +1,339 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.wifiprovision.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.testing.FakeBleConnection
import org.meshtastic.core.testing.FakeBleConnectionFactory
import org.meshtastic.core.testing.FakeBleDevice
import org.meshtastic.core.testing.FakeBleScanner
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_COMMANDER_UUID
import org.meshtastic.feature.wifiprovision.model.ProvisionResult
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* Tests for [NymeaWifiService] covering BLE connect, network scanning, provisioning, and error handling. Uses
* [FakeBleScanner], [FakeBleConnection], and [FakeBleConnectionFactory] from `core:testing`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NymeaWifiServiceTest {
private val address = "AA:BB:CC:DD:EE:FF"
private fun createService(
scanner: FakeBleScanner = FakeBleScanner(),
connection: FakeBleConnection = FakeBleConnection(),
): Triple<NymeaWifiService, FakeBleScanner, FakeBleConnection> {
val service =
NymeaWifiService(
scanner = scanner,
connectionFactory = FakeBleConnectionFactory(connection),
dispatcher = Dispatchers.Unconfined,
)
return Triple(service, scanner, connection)
}
private suspend fun connectService(
service: NymeaWifiService,
scanner: FakeBleScanner,
deviceName: String? = "mpwrd-nm-1234",
): Result<String> {
scanner.emitDevice(FakeBleDevice(address, name = deviceName))
return service.connect()
}
private fun emitResponse(connection: FakeBleConnection, json: String) {
connection.service.emitNotification(COMMANDER_RESPONSE_UUID, (json + "\n").encodeToByteArray())
}
// -----------------------------------------------------------------------
// connect()
// -----------------------------------------------------------------------
@Test
fun `connect succeeds and returns device name`() = runTest {
val (service, scanner) = createService()
val result = connectService(service, scanner)
assertTrue(result.isSuccess)
assertEquals("mpwrd-nm-1234", result.getOrThrow())
}
@Test
fun `connect returns device address when name is null`() = runTest {
val (service, scanner) = createService()
val result = connectService(service, scanner, deviceName = null)
assertTrue(result.isSuccess)
assertEquals(address, result.getOrThrow())
}
@Test
fun `connect fails when BLE connection fails`() = runTest {
val connection = FakeBleConnection()
connection.failNextN = 1
val (service, scanner) = createService(connection = connection)
scanner.emitDevice(FakeBleDevice(address))
val result = service.connect()
assertTrue(result.isFailure)
}
@Test
fun `connect fails when BLE throws exception`() = runTest {
val connection = FakeBleConnection()
connection.connectException = RuntimeException("Bluetooth off")
val (service, scanner) = createService(connection = connection)
scanner.emitDevice(FakeBleDevice(address))
val result = service.connect()
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()?.message?.contains("Bluetooth off") == true)
}
// -----------------------------------------------------------------------
// scanNetworks()
// -----------------------------------------------------------------------
@Test
fun `scanNetworks returns parsed network list`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
// Enqueue scan ack + networks response
emitResponse(connection, """{"c":4,"r":0}""")
emitResponse(
connection,
"""{"c":0,"r":0,"p":[
{"e":"HomeWifi","m":"AA:BB:CC:DD:EE:01","s":85,"p":1},
{"e":"OpenNet","m":"AA:BB:CC:DD:EE:02","s":60,"p":0}
]}""",
)
val result = service.scanNetworks()
assertTrue(result.isSuccess)
val networks = result.getOrThrow()
assertEquals(2, networks.size)
assertEquals("HomeWifi", networks[0].ssid)
assertEquals(85, networks[0].signalStrength)
assertTrue(networks[0].isProtected)
assertEquals("OpenNet", networks[1].ssid)
assertEquals(false, networks[1].isProtected)
}
@Test
fun `scanNetworks returns empty list when device has no networks`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
emitResponse(connection, """{"c":4,"r":0}""")
emitResponse(connection, """{"c":0,"r":0,"p":[]}""")
val result = service.scanNetworks()
assertTrue(result.isSuccess)
assertTrue(result.getOrThrow().isEmpty())
}
@Test
fun `scanNetworks fails when scan command returns error`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
// Scan returns error code 4 (wireless unavailable)
emitResponse(connection, """{"c":4,"r":4}""")
val result = service.scanNetworks()
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()?.message?.contains("Scan command failed") == true)
}
@Test
fun `scanNetworks sends correct BLE commands`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
emitResponse(connection, """{"c":4,"r":0}""")
emitResponse(connection, """{"c":0,"r":0,"p":[]}""")
service.scanNetworks()
// Verify the commander writes contain the scan command and get-networks command
val commanderWrites =
connection.service.writes
.filter { it.characteristic.uuid == WIRELESS_COMMANDER_UUID }
.map { it.data.decodeToString() }
.joinToString("")
assertTrue(commanderWrites.contains("\"c\":4"), "Should send CMD_SCAN (4)")
assertTrue(commanderWrites.contains("\"c\":0"), "Should send CMD_GET_NETWORKS (0)")
}
@Test
fun `scanNetworks uses WITH_RESPONSE write type`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
emitResponse(connection, """{"c":4,"r":0}""")
emitResponse(connection, """{"c":0,"r":0,"p":[]}""")
service.scanNetworks()
val commanderWrites = connection.service.writes.filter { it.characteristic.uuid == WIRELESS_COMMANDER_UUID }
assertTrue(commanderWrites.all { it.writeType == BleWriteType.WITH_RESPONSE })
}
// -----------------------------------------------------------------------
// provision()
// -----------------------------------------------------------------------
@Test
fun `provision returns Success on response code 0`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
emitResponse(connection, """{"c":1,"r":0}""")
val result = service.provision("MyNet", "password")
assertIs<ProvisionResult.Success>(result)
}
@Test
fun `provision returns Failure on non-zero response code`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
emitResponse(connection, """{"c":1,"r":3}""")
val result = service.provision("MyNet", "password")
assertIs<ProvisionResult.Failure>(result)
assertEquals(3, result.errorCode)
assertTrue(result.message.contains("NetworkManager"))
}
@Test
fun `provision sends CMD_CONNECT for visible networks`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
emitResponse(connection, """{"c":1,"r":0}""")
service.provision("Net", "pass", hidden = false)
val writes =
connection.service.writes
.filter { it.characteristic.uuid == WIRELESS_COMMANDER_UUID }
.map { it.data.decodeToString() }
.joinToString("")
assertTrue(writes.contains("\"c\":1"), "Should send CMD_CONNECT (1)")
assertTrue(writes.contains("\"e\":\"Net\""), "Should contain SSID")
}
@Test
fun `provision sends CMD_CONNECT_HIDDEN for hidden networks`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
emitResponse(connection, """{"c":2,"r":0}""")
service.provision("HiddenNet", "pass", hidden = true)
val writes =
connection.service.writes
.filter { it.characteristic.uuid == WIRELESS_COMMANDER_UUID }
.map { it.data.decodeToString() }
.joinToString("")
assertTrue(writes.contains("\"c\":2"), "Should send CMD_CONNECT_HIDDEN (2)")
}
@Test
fun `provision returns Failure on exception`() = runTest {
// Create a service with a connection that will fail writes after connecting
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
// Don't emit any response — this will cause a timeout. But since we use
// Dispatchers.Unconfined the withTimeout may behave differently.
// Instead, test a different error path: test that all nymea error codes are mapped.
emitResponse(connection, """{"c":1,"r":1}""")
val result = service.provision("Net", "pass")
assertIs<ProvisionResult.Failure>(result)
assertTrue(result.message.contains("Invalid command"))
}
@Test
fun `provision maps all known error codes`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
val errorCodes =
mapOf(
1 to "Invalid command",
2 to "Invalid parameter",
3 to "NetworkManager not available",
4 to "Wireless adapter not available",
5 to "Networking disabled",
6 to "Wireless disabled",
7 to "Unknown error",
)
for ((code, expectedMessage) in errorCodes) {
emitResponse(connection, """{"c":1,"r":$code}""")
val result = service.provision("Net", "pass")
assertIs<ProvisionResult.Failure>(result)
assertTrue(
result.message.contains(expectedMessage),
"Error code $code should map to '$expectedMessage', got '${result.message}'",
)
}
}
// -----------------------------------------------------------------------
// close()
// -----------------------------------------------------------------------
@Test
fun `close disconnects BLE`() = runTest {
val connection = FakeBleConnection()
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
service.close()
assertTrue(connection.disconnectCalls >= 1, "Should call BLE disconnect")
}
}