Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
01b031cec3 feat: adacam migration — update IP, add pairing, bearer auth, wifi/ssh config, remove tunnel and cmd
- Changed default API URL from 192.168.0.10 to 10.77.0.1
- Added bearer token authentication for POST endpoints
- Added /pair endpoint support for device pairing
- Added GET/POST /api/1/ssh/status and /api/1/ssh/toggle
- Added WiFi config via /api/1/wifi/connect
- Removed JSch dependency (no more SSH from app)
- Removed /api/1/cmd endpoint references (CVE)
- Added PairResponse, SshStatus, WifiStatus models
- Added deviceSerial, apiToken, isPaired to settings
- Added deriveApiToken() function for token derivation
- Updated SettingsViewModel with pairing/wifi/ssh methods
- Updated SettingsScreen with pairing UI, WiFi config, SSH toggle
- Updated DeviceStatusScreen with SSH toggle card
2026-03-14 11:50:00 -07:00
8 changed files with 822 additions and 133 deletions

View file

@ -76,8 +76,6 @@ dependencies {
ksp(libs.room.compiler) ksp(libs.room.compiler)
// WorkManager (background uploads) // WorkManager (background uploads)
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
// SSH connectivity for device_id fallback
implementation("com.jcraft:jsch:0.1.55")
// QR Code scanning // QR Code scanning
implementation("com.google.zxing:core:3.5.2") implementation("com.google.zxing:core:3.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0") implementation("com.journeyapps:zxing-android-embedded:4.3.0")

View file

@ -11,24 +11,22 @@ import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssData import com.adamaps.varroa.data.GnssData
import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.PairResponse
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig import com.adamaps.varroa.data.WifiConfig
import com.adamaps.varroa.data.WifiStatus
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import com.jcraft.jsch.JSch
import com.jcraft.jsch.Session
import java.io.ByteArrayOutputStream
class BeeApiClient( class BeeApiClient(
private var apiUrl: String = "http://192.168.0.10:5000" private var apiUrl: String = "http://10.77.0.1:5000"
) { ) {
companion object { companion object {
private const val TAG = "VarroaBeeAPI" private const val TAG = "VarroaBeeAPI"
@ -43,6 +41,9 @@ class BeeApiClient(
var isConnected: Boolean = false var isConnected: Boolean = false
private set private set
// Bearer token for authenticated endpoints
var apiToken: String = ""
fun updateUrl(url: String) { fun updateUrl(url: String) {
val oldUrl = apiUrl val oldUrl = apiUrl
apiUrl = url.trimEnd('/') apiUrl = url.trimEnd('/')
@ -50,7 +51,7 @@ class BeeApiClient(
} }
/** /**
* Bind to a specific network (e.g., unvalidated WiFi for Bee AP). * Bind to a specific network (e.g., unvalidated WiFi for AdaCam AP).
* This is the preferred method when using NetworkStateMonitor. * This is the preferred method when using NetworkStateMonitor.
*/ */
fun bindToNetwork(network: Network) { fun bindToNetwork(network: Network) {
@ -107,7 +108,7 @@ class BeeApiClient(
val allWifi = cm.allNetworks.filter { n -> val allWifi = cm.allNetworks.filter { n ->
cm.getNetworkCapabilities(n)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true cm.getNetworkCapabilities(n)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
} }
// prefer unvalidated wifi (Bee AP has no internet) // prefer unvalidated wifi (AdaCam AP has no internet)
allWifi.firstOrNull { n -> allWifi.firstOrNull { n ->
cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == false cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == false
} ?: allWifi.firstOrNull() } ?: allWifi.firstOrNull()
@ -178,7 +179,7 @@ class BeeApiClient(
} }
/** /**
* Check if Bee is reachable. * Check if AdaCam is reachable.
* Updates internal connection state. * Updates internal connection state.
*/ */
suspend fun ping(): Boolean { suspend fun ping(): Boolean {
@ -208,6 +209,25 @@ class BeeApiClient(
isConnected = false isConnected = false
} }
// ── Pairing API ───────────────────────────────────────────────────────────
/**
* Pair with AdaCam device. Unauthenticated endpoint.
* Returns serial, version, and connection info.
*/
suspend fun pair(): ApiResult<PairResponse> = withContext(Dispatchers.IO) {
when (val r = getRaw("/pair")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, PairResponse::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
// ── Landmarks API ─────────────────────────────────────────────────────────
suspend fun getLandmarks(): ApiResult<List<BeeDetection>> = withContext(Dispatchers.IO) { suspend fun getLandmarks(): ApiResult<List<BeeDetection>> = withContext(Dispatchers.IO) {
Log.d(TAG, "getLandmarks() called - fetching ALL landmarks") Log.d(TAG, "getLandmarks() called - fetching ALL landmarks")
@ -260,7 +280,7 @@ class BeeApiClient(
} }
} }
// ── v7.7 Settings API endpoints ─────────────────────────────────────────── // ── WiFi API ──────────────────────────────────────────────────────────────
suspend fun getWifiConfig(): ApiResult<WifiConfig> = withContext(Dispatchers.IO) { suspend fun getWifiConfig(): ApiResult<WifiConfig> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifi/status")) { when (val r = getRaw("/api/1/wifi/status")) {
@ -273,6 +293,17 @@ class BeeApiClient(
} }
} }
suspend fun getWifiStatus(): ApiResult<WifiStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifi/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, WifiStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun setWifiConfig(ssid: String, password: String): ApiResult<String> = withContext(Dispatchers.IO) { suspend fun setWifiConfig(ssid: String, password: String): ApiResult<String> = withContext(Dispatchers.IO) {
try { try {
val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password)) val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password))
@ -282,6 +313,7 @@ class BeeApiClient(
) )
val request = Request.Builder() val request = Request.Builder()
.url("$apiUrl/api/1/wifi/connect") .url("$apiUrl/api/1/wifi/connect")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody) .post(requestBody)
.build() .build()
@ -298,6 +330,47 @@ class BeeApiClient(
} }
} }
// ── SSH API ───────────────────────────────────────────────────────────────
suspend fun getSshStatus(): ApiResult<SshStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/ssh/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, SshStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun setSshEnabled(enabled: Boolean): ApiResult<String> = withContext(Dispatchers.IO) {
try {
val jsonBody = gson.toJson(mapOf("enable" to enabled))
val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
val request = Request.Builder()
.url("$apiUrl/api/1/ssh/toggle")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody)
.build()
client.newCall(request).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) {
ApiResult.Success(body)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
}
}
// ── Storage & GNSS Status ─────────────────────────────────────────────────
suspend fun getStorageStatus(): ApiResult<StorageStatus> = withContext(Dispatchers.IO) { suspend fun getStorageStatus(): ApiResult<StorageStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/storage/usage")) { when (val r = getRaw("/api/1/storage/usage")) {
is ApiResult.Success -> try { is ApiResult.Success -> try {
@ -341,6 +414,7 @@ class BeeApiClient(
) )
val request = Request.Builder() val request = Request.Builder()
.url("$apiUrl/api/1/config/uploadMode") .url("$apiUrl/api/1/config/uploadMode")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody) .post(requestBody)
.build() .build()
@ -357,50 +431,7 @@ class BeeApiClient(
} }
} }
// ── End v7.7 Settings API ───────────────────────────────────────────────── // ── Camera API ────────────────────────────────────────────────────────────
suspend fun getDeviceIdViaSsh(): ApiResult<String> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Attempting SSH connection to root@192.168.0.10:22")
val jsch = JSch()
val session = jsch.getSession("root", "192.168.0.10", 22)
session.setConfig("StrictHostKeyChecking", "no")
session.connect(10000) // 10 second timeout
val channel = session.openChannel("exec")
val execChannel = channel as com.jcraft.jsch.ChannelExec
// Command to find device_id from various locations
val command = "cat /data/registration/device_id 2>/dev/null || cat /opt/dashcam/config/device_id 2>/dev/null || grep device_id /data/persist/*.conf 2>/dev/null | head -1 | cut -d= -f2"
execChannel.setCommand(command)
val outputStream = ByteArrayOutputStream()
execChannel.outputStream = outputStream
execChannel.connect(5000) // 5 second timeout for command execution
// Wait for command completion
while (!execChannel.isClosed) {
Thread.sleep(100)
}
execChannel.disconnect()
session.disconnect()
val output = outputStream.toString().trim()
Log.d(TAG, "SSH command output: '$output'")
if (output.isNotEmpty() && !output.contains("No such file") && !output.contains("not found")) {
Log.i(TAG, "Device ID retrieved via SSH: $output")
ApiResult.Success(output)
} else {
Log.w(TAG, "SSH command succeeded but no device_id found")
ApiResult.Error("No device_id found via SSH")
}
} catch (e: Exception) {
Log.e(TAG, "SSH connection failed", e)
ApiResult.Error("SSH error: ${e.message}")
}
}
/** /**
* Try the given endpoint; returns raw image bytes. * Try the given endpoint; returns raw image bytes.
@ -429,9 +460,9 @@ class BeeApiClient(
} }
/** /**
* Fetch detection image from Bee device. * Fetch detection image from AdaCam device.
* *
* @param detectionId The landmark/detection ID from the Bee API * @param detectionId The landmark/detection ID from the API
* @return ApiResult containing image bytes (JPEG) or error * @return ApiResult containing image bytes (JPEG) or error
*/ */
suspend fun getDetectionImage(detectionId: Long): ApiResult<ByteArray> = withContext(Dispatchers.IO) { suspend fun getDetectionImage(detectionId: Long): ApiResult<ByteArray> = withContext(Dispatchers.IO) {
@ -452,7 +483,7 @@ class BeeApiClient(
} }
/** /**
* Attempt to delete landmarks from Bee device after successful upload. * Attempt to delete landmarks from AdaCam device after successful upload.
* This tries various potential DELETE endpoints. * This tries various potential DELETE endpoints.
* *
* @param landmarkIds List of landmark IDs to delete * @param landmarkIds List of landmark IDs to delete
@ -471,24 +502,13 @@ class BeeApiClient(
"/api/1/landmarks/delete", "/api/1/landmarks/delete",
"/api/1/landmarks/clear", "/api/1/landmarks/clear",
"/api/1/landmarks/cleanup", "/api/1/landmarks/cleanup",
"/api/1/landmarks/remove", "/api/1/landmarks/remove"
"/api/1/cmd" // As a last resort using the cmd endpoint
) )
for (endpoint in endpoints) { for (endpoint in endpoints) {
Log.d(TAG, "Trying cleanup endpoint: $endpoint") Log.d(TAG, "Trying cleanup endpoint: $endpoint")
val result = when (endpoint) { val result = tryCleanupEndpoint(endpoint, landmarkIds)
"/api/1/cmd" -> {
// Use the cmd endpoint to try deleting landmarks via system commands
Log.d(TAG, "Attempting cleanup via cmd endpoint...")
tryCleanupViaCmd(landmarkIds)
}
else -> {
// Try standard DELETE/POST requests
tryCleanupEndpoint(endpoint, landmarkIds)
}
}
when (result) { when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
@ -502,8 +522,8 @@ class BeeApiClient(
} }
} }
Log.w(TAG, "All cleanup endpoints failed - Bee may not support landmark deletion") Log.w(TAG, "All cleanup endpoints failed - AdaCam may not support landmark deletion")
return@withContext ApiResult.Error("No working DELETE endpoint found - cleanup not supported by Bee device") return@withContext ApiResult.Error("No working DELETE endpoint found - cleanup not supported by device")
} }
private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) { private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) {
@ -518,10 +538,15 @@ class BeeApiClient(
for (method in listOf("DELETE", "POST")) { for (method in listOf("DELETE", "POST")) {
Log.d(TAG, "Trying $method $endpoint") Log.d(TAG, "Trying $method $endpoint")
val request = Request.Builder() val requestBuilder = Request.Builder()
.url("$apiUrl$endpoint") .url("$apiUrl$endpoint")
.method(method, if (method == "DELETE") null else requestBody) .addHeader("Authorization", "Bearer $apiToken")
.build()
val request = if (method == "DELETE") {
requestBuilder.delete(requestBody).build()
} else {
requestBuilder.post(requestBody).build()
}
client.newCall(request).execute().use { resp -> client.newCall(request).execute().use { resp ->
val body = resp.body?.string() ?: "" val body = resp.body?.string() ?: ""
@ -540,50 +565,4 @@ class BeeApiClient(
return@withContext ApiResult.Error("Exception: ${e.message}") return@withContext ApiResult.Error("Exception: ${e.message}")
} }
} }
private suspend fun tryCleanupViaCmd(landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) {
try {
// Try to find where landmarks are stored and delete them via filesystem commands
val commands = listOf(
"find /data -name '*landmark*' -type f -ls",
"find /tmp -name '*landmark*' -type f -ls",
"ls -la /data/recording/",
"redis-cli KEYS '*landmark*'",
"redis-cli KEYS '*detection*'"
)
for (cmd in commands) {
Log.d(TAG, "Trying cmd: $cmd")
val jsonBody = gson.toJson(mapOf("cmd" to cmd))
val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
val request = Request.Builder()
.url("$apiUrl/api/1/cmd")
.post(requestBody)
.build()
client.newCall(request).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) {
Log.d(TAG, "Cmd '$cmd' result: $body")
// For now, just log the results to understand the data structure
if (body.contains("landmark") || body.contains("detection")) {
Log.i(TAG, "Found potential landmark storage: $body")
}
}
}
}
// Note: We're not actually deleting anything via cmd yet, just exploring
Log.w(TAG, "Cleanup via cmd endpoint: exploration complete, actual deletion not implemented yet")
return@withContext ApiResult.Error("Cleanup via cmd: exploration only, deletion not yet implemented")
} catch (e: Exception) {
Log.e(TAG, "Failed to explore via cmd endpoint", e)
return@withContext ApiResult.Error("Cmd exploration failed: ${e.message}")
}
}
} }

View file

@ -47,6 +47,21 @@ data class BeeDeviceInfo(
@SerializedName("ssid") val ssid: String? = null @SerializedName("ssid") val ssid: String? = null
) )
// ── Pairing API ───────────────────────────────────────────────────────────────
data class PairResponse(
@SerializedName("serial") val serial: String,
@SerializedName("version") val version: String,
@SerializedName("ap_ip") val apIp: String,
@SerializedName("api_port") val apiPort: Int
)
// ── SSH API ───────────────────────────────────────────────────────────────────
data class SshStatus(
@SerializedName("active") val active: Boolean
)
// ── ADAMaps ingest ──────────────────────────────────────────────────────────── // ── ADAMaps ingest ────────────────────────────────────────────────────────────
data class AdaMapsDetection( data class AdaMapsDetection(
@ -93,7 +108,15 @@ data class WifiConfig(
@SerializedName("ssid") val ssid: String? = null, @SerializedName("ssid") val ssid: String? = null,
@SerializedName("password") val password: String? = null, @SerializedName("password") val password: String? = null,
@SerializedName("connected") val connected: Boolean? = null, @SerializedName("connected") val connected: Boolean? = null,
@SerializedName("ip") val ip: String? = null @SerializedName("ip") val ip: String? = null,
@SerializedName("state") val state: String? = null
)
data class WifiStatus(
@SerializedName("ssid") val ssid: String? = null,
@SerializedName("ip") val ip: String? = null,
@SerializedName("state") val state: String? = null,
@SerializedName("connected") val connected: Boolean? = null
) )
data class StorageStatus( data class StorageStatus(

View file

@ -10,11 +10,12 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.security.MessageDigest
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings")
data class VarroaSettings( data class VarroaSettings(
val beeApiUrl: String = "http://192.168.0.10:5000", val beeApiUrl: String = "http://10.77.0.1:5000",
val adamapsApiUrl: String = "https://api.adamaps.org", val adamapsApiUrl: String = "https://api.adamaps.org",
val adamapsApiKey: String = "***REMOVED***", val adamapsApiKey: String = "***REMOVED***",
val pollIntervalSeconds: Int = 30, val pollIntervalSeconds: Int = 30,
@ -22,9 +23,23 @@ data class VarroaSettings(
val cameraRefreshSeconds: Int = 30, val cameraRefreshSeconds: Int = 30,
val forwardingEnabled: Boolean = true, val forwardingEnabled: Boolean = true,
val cachedDeviceId: String = "unknown", val cachedDeviceId: String = "unknown",
val walletAddress: String = "" val walletAddress: String = "",
// AdaCam pairing
val deviceSerial: String = "",
val apiToken: String = "",
val isPaired: Boolean = false
) )
/**
* Derive the API token from the device serial.
* Token = first 32 chars of SHA-256("adacam-api-{serial}-token")
*/
fun deriveApiToken(serial: String): String {
val input = "adacam-api-$serial-token"
val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }.substring(0, 32)
}
class SettingsDataStore(private val context: Context) { class SettingsDataStore(private val context: Context) {
companion object { companion object {
@ -37,11 +52,15 @@ class SettingsDataStore(private val context: Context) {
private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled") private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled")
private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id") private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id")
private val KEY_WALLET_ADDRESS = stringPreferencesKey("wallet_address") private val KEY_WALLET_ADDRESS = stringPreferencesKey("wallet_address")
// AdaCam pairing keys
private val KEY_DEVICE_SERIAL = stringPreferencesKey("device_serial")
private val KEY_API_TOKEN = stringPreferencesKey("api_token")
private val KEY_IS_PAIRED = booleanPreferencesKey("is_paired")
} }
val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs -> val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
VarroaSettings( VarroaSettings(
beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000", beeApiUrl = prefs[KEY_BEE_URL] ?: "http://10.77.0.1:5000",
adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org",
adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***", adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***",
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
@ -49,7 +68,10 @@ class SettingsDataStore(private val context: Context) {
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,
forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true, forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true,
cachedDeviceId = prefs[KEY_CACHED_DEVICE_ID] ?: "unknown", cachedDeviceId = prefs[KEY_CACHED_DEVICE_ID] ?: "unknown",
walletAddress = prefs[KEY_WALLET_ADDRESS] ?: "" walletAddress = prefs[KEY_WALLET_ADDRESS] ?: "",
deviceSerial = prefs[KEY_DEVICE_SERIAL] ?: "",
apiToken = prefs[KEY_API_TOKEN] ?: "",
isPaired = prefs[KEY_IS_PAIRED] ?: false
) )
} }
@ -64,6 +86,9 @@ class SettingsDataStore(private val context: Context) {
prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled
prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId
prefs[KEY_WALLET_ADDRESS] = s.walletAddress prefs[KEY_WALLET_ADDRESS] = s.walletAddress
prefs[KEY_DEVICE_SERIAL] = s.deviceSerial
prefs[KEY_API_TOKEN] = s.apiToken
prefs[KEY_IS_PAIRED] = s.isPaired
} }
} }
@ -72,4 +97,28 @@ class SettingsDataStore(private val context: Context) {
prefs[KEY_CACHED_DEVICE_ID] = deviceId prefs[KEY_CACHED_DEVICE_ID] = deviceId
} }
} }
/**
* Store pairing data after successful pairing with AdaCam.
* Derives and stores the API token from the serial.
*/
suspend fun savePairing(serial: String) {
val token = deriveApiToken(serial)
context.dataStore.edit { prefs ->
prefs[KEY_DEVICE_SERIAL] = serial
prefs[KEY_API_TOKEN] = token
prefs[KEY_IS_PAIRED] = true
}
}
/**
* Clear pairing data (for re-pairing or reset).
*/
suspend fun clearPairing() {
context.dataStore.edit { prefs ->
prefs[KEY_DEVICE_SERIAL] = ""
prefs[KEY_API_TOKEN] = ""
prefs[KEY_IS_PAIRED] = false
}
}
} }

View file

@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -22,6 +23,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig import com.adamaps.varroa.data.WifiConfig
import com.adamaps.varroa.ui.theme.* import com.adamaps.varroa.ui.theme.*
@ -36,6 +38,7 @@ fun DeviceStatusScreen(
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
val wifiResult by vm.wifiSaveResult.collectAsState() val wifiResult by vm.wifiSaveResult.collectAsState()
val uploadResult by vm.uploadModeResult.collectAsState() val uploadResult by vm.uploadModeResult.collectAsState()
val sshResult by vm.sshToggleResult.collectAsState()
// Refresh on first load // Refresh on first load
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -56,6 +59,12 @@ fun DeviceStatusScreen(
vm.clearUploadModeResult() vm.clearUploadModeResult()
} }
} }
LaunchedEffect(sshResult) {
sshResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearSshToggleResult()
}
}
Scaffold( Scaffold(
containerColor = Background, containerColor = Background,
@ -119,6 +128,9 @@ fun DeviceStatusScreen(
// GPS/GNSS Status // GPS/GNSS Status
GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock) GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock)
// SSH Status
SshStatusCard(state.sshStatus, vm)
// Upload Mode // Upload Mode
UploadModeCard(state.deviceInfo?.uploadMode, vm) UploadModeCard(state.deviceInfo?.uploadMode, vm)
@ -460,6 +472,55 @@ private fun UploadModeCard(currentMode: String?, vm: DeviceStatusViewModel) {
} }
} }
@Composable
private fun SshStatusCard(ssh: SshStatus?, vm: DeviceStatusViewModel) {
val isActive = ssh?.active ?: false
StatusCard("SSH ACCESS") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Enable SSH",
color = OnSurface,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
Text(
"SSH access over home WiFi",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
Switch(
checked = isActive,
onCheckedChange = { vm.toggleSsh(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
checkedTrackColor = Amber.copy(alpha = 0.5f),
uncheckedThumbColor = Color.Gray,
uncheckedTrackColor = SurfaceVariant
)
)
}
if (isActive) {
Spacer(Modifier.height(8.dp))
Text(
"SSH enabled. Connect via:\nssh root@<device-ip>",
color = Color.Green,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
}
}
}
@Composable @Composable
private fun PluginsCard(plugins: List<BeePlugin>) { private fun PluginsCard(plugins: List<BeePlugin>) {
StatusCard("PLUGINS") { StatusCard("PLUGINS") {

View file

@ -11,8 +11,11 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -24,6 +27,8 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -44,6 +49,14 @@ fun SettingsScreen(
) { ) {
val currentSettings by vm.settings.collectAsState() val currentSettings by vm.settings.collectAsState()
val saved by vm.saved.collectAsState() val saved by vm.saved.collectAsState()
val isPaired by vm.isPaired.collectAsState()
val deviceSerial by vm.deviceSerial.collectAsState()
val pairingInProgress by vm.pairingInProgress.collectAsState()
val pairingResult by vm.pairingResult.collectAsState()
val sshStatus by vm.sshStatus.collectAsState()
val sshToggleResult by vm.sshToggleResult.collectAsState()
val wifiStatus by vm.wifiStatus.collectAsState()
val wifiConnectResult by vm.wifiConnectResult.collectAsState()
// Local edit state — initialized from current settings // Local edit state — initialized from current settings
var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) } var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) }
@ -62,6 +75,24 @@ fun SettingsScreen(
vm.clearSaved() vm.clearSaved()
} }
} }
LaunchedEffect(pairingResult) {
pairingResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearPairingResult()
}
}
LaunchedEffect(sshToggleResult) {
sshToggleResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearSshResult()
}
}
LaunchedEffect(wifiConnectResult) {
wifiConnectResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearWifiResult()
}
}
Scaffold( Scaffold(
containerColor = Background, containerColor = Background,
@ -86,7 +117,7 @@ fun SettingsScreen(
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
vm.save( vm.save(
VarroaSettings( currentSettings.copy(
beeApiUrl = beeApiUrl.trim(), beeApiUrl = beeApiUrl.trim(),
adamapsApiUrl = adamapsApiUrl.trim(), adamapsApiUrl = adamapsApiUrl.trim(),
adamapsApiKey = adamapsApiKey.trim(), adamapsApiKey = adamapsApiKey.trim(),
@ -111,6 +142,15 @@ fun SettingsScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Device Pairing section
PairingSection(
isPaired = isPaired,
deviceSerial = deviceSerial,
pairingInProgress = pairingInProgress,
onPair = { vm.pairDevice() },
onClearPairing = { vm.clearPairing() }
)
// Device Status navigation card // Device Status navigation card
Card( Card(
onClick = onNavigateToDeviceStatus, onClick = onNavigateToDeviceStatus,
@ -157,12 +197,30 @@ fun SettingsScreen(
} }
} }
SettingsSection("BEE DEVICE") { // WiFi Config section (only show if paired)
if (isPaired) {
WifiConfigSection(
wifiStatus = wifiStatus,
onConnect = { ssid, password -> vm.connectWifi(ssid, password) },
onRefresh = { vm.refreshWifiStatus() }
)
}
// SSH section (only show if paired)
if (isPaired) {
SshSection(
sshStatus = sshStatus,
onToggle = { enabled -> vm.toggleSsh(enabled) },
onRefresh = { vm.refreshSshStatus() }
)
}
SettingsSection("ADACAM DEVICE") {
SettingsField( SettingsField(
label = "Bee API URL", label = "AdaCam API URL",
value = beeApiUrl, value = beeApiUrl,
onValueChange = { beeApiUrl = it }, onValueChange = { beeApiUrl = it },
hint = "http://192.168.0.10:5000" hint = "http://10.77.0.1:5000"
) )
} }
@ -227,6 +285,265 @@ fun SettingsScreen(
} }
} }
@Composable
private fun PairingSection(
isPaired: Boolean,
deviceSerial: String,
pairingInProgress: Boolean,
onPair: () -> Unit,
onClearPairing: () -> Unit
) {
SettingsSection("DEVICE PAIRING") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isPaired) Icons.Default.Link else Icons.Default.LinkOff,
contentDescription = null,
tint = if (isPaired) Color.Green else Color.Gray,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Column {
Text(
if (isPaired) "Paired" else "Not Paired",
color = if (isPaired) Color.Green else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
if (isPaired && deviceSerial.isNotBlank()) {
Text(
"Serial: $deviceSerial",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
}
Spacer(Modifier.height(12.dp))
if (!isPaired) {
Text(
"Connect your phone to the AdaCam WiFi network (adacam-XXXXXX), then tap Pair.",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onPair,
enabled = !pairingInProgress,
colors = ButtonDefaults.buttonColors(containerColor = Amber),
modifier = Modifier.fillMaxWidth()
) {
if (pairingInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = Background,
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
}
Text(
if (pairingInProgress) "Pairing..." else "Pair with AdaCam",
color = Background
)
}
} else {
OutlinedButton(
onClick = onClearPairing,
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.Red),
modifier = Modifier.fillMaxWidth()
) {
Text("Clear Pairing", fontSize = 12.sp)
}
}
}
}
@Composable
private fun WifiConfigSection(
wifiStatus: com.adamaps.varroa.data.WifiStatus?,
onConnect: (String, String) -> Unit,
onRefresh: () -> Unit
) {
var ssid by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
SettingsSection("HOME WIFI NETWORK") {
// Current status
if (wifiStatus != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Wifi,
contentDescription = null,
tint = if (wifiStatus.connected == true) Color.Green else Color.Yellow,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(8.dp))
Column {
Text(
if (wifiStatus.connected == true) "Connected" else "Disconnected",
color = if (wifiStatus.connected == true) Color.Green else Color.Yellow,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
if (wifiStatus.ssid != null && wifiStatus.connected == true) {
Text(
wifiStatus.ssid,
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
if (wifiStatus.ip != null && wifiStatus.connected == true) {
Text(
"IP: ${wifiStatus.ip}",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
IconButton(onClick = onRefresh) {
Icon(
Icons.Default.ChevronRight,
contentDescription = "Refresh",
tint = Amber
)
}
}
Spacer(Modifier.height(12.dp))
Divider(color = SurfaceVariant)
Spacer(Modifier.height(12.dp))
}
Text(
"Configure home WiFi for internet access",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text("SSID", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber,
unfocusedLabelColor = Color.Gray,
cursorColor = Amber,
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber,
unfocusedLabelColor = Color.Gray,
cursorColor = Amber,
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { onConnect(ssid, password) },
enabled = ssid.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Amber),
modifier = Modifier.fillMaxWidth()
) {
Text("Connect", color = Background)
}
}
}
@Composable
private fun SshSection(
sshStatus: com.adamaps.varroa.data.SshStatus?,
onToggle: (Boolean) -> Unit,
onRefresh: () -> Unit
) {
val isActive = sshStatus?.active ?: false
SettingsSection("SSH ACCESS") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Enable SSH",
color = OnSurface,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
Text(
"SSH over home WiFi network",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
Switch(
checked = isActive,
onCheckedChange = { onToggle(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
checkedTrackColor = Amber.copy(alpha = 0.5f),
uncheckedThumbColor = Color.Gray,
uncheckedTrackColor = SurfaceVariant
)
)
}
if (isActive) {
Spacer(Modifier.height(8.dp))
Text(
"SSH enabled. Connect via:\nssh root@<device-ip>",
color = Color.Green,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
}
}
}
@Composable @Composable
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Card( Card(

View file

@ -10,6 +10,7 @@ import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.SettingsDataStore import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig import com.adamaps.varroa.data.WifiConfig
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -25,6 +26,7 @@ data class DeviceStatusState(
val wifiConfig: WifiConfig? = null, val wifiConfig: WifiConfig? = null,
val storageStatus: StorageStatus? = null, val storageStatus: StorageStatus? = null,
val gnssStatus: GnssStatus? = null, val gnssStatus: GnssStatus? = null,
val sshStatus: SshStatus? = null,
val plugins: List<BeePlugin> = emptyList(), val plugins: List<BeePlugin> = emptyList(),
val error: String? = null val error: String? = null
) )
@ -47,6 +49,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
private val _uploadModeResult = MutableStateFlow<String?>(null) private val _uploadModeResult = MutableStateFlow<String?>(null)
val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow() val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow()
private val _sshToggleResult = MutableStateFlow<String?>(null)
val sshToggleResult: StateFlow<String?> = _sshToggleResult.asStateFlow()
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
@ -54,6 +59,10 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
try { try {
val settings = store.settings.first() val settings = store.settings.first()
val client = BeeApiClient(settings.beeApiUrl) val client = BeeApiClient(settings.beeApiUrl)
// Set auth token if paired
if (settings.apiToken.isNotBlank()) {
client.apiToken = settings.apiToken
}
beeClient = client beeClient = client
// Fetch all status in parallel // Fetch all status in parallel
@ -62,12 +71,14 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
val storageResult = client.getStorageStatus() val storageResult = client.getStorageStatus()
val gnssResult = client.getGnssStatus() val gnssResult = client.getGnssStatus()
val pluginsResult = client.getPlugins() val pluginsResult = client.getPlugins()
val sshResult = client.getSshStatus()
val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data
val wifi = (wifiResult as? ApiResult.Success)?.data val wifi = (wifiResult as? ApiResult.Success)?.data
val storage = (storageResult as? ApiResult.Success)?.data val storage = (storageResult as? ApiResult.Success)?.data
val gnss = (gnssResult as? ApiResult.Success)?.data val gnss = (gnssResult as? ApiResult.Success)?.data
val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList() val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList()
val ssh = (sshResult as? ApiResult.Success)?.data
val isConnected = deviceInfo != null val isConnected = deviceInfo != null
@ -78,8 +89,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
wifiConfig = wifi, wifiConfig = wifi,
storageStatus = storage, storageStatus = storage,
gnssStatus = gnss, gnssStatus = gnss,
sshStatus = ssh,
plugins = plugins, plugins = plugins,
error = if (!isConnected) "Cannot connect to Bee device" else null error = if (!isConnected) "Cannot connect to AdaCam device" else null
) )
Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}") Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}")
@ -129,6 +141,24 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
fun toggleSsh(enabled: Boolean) {
viewModelScope.launch {
val client = beeClient ?: return@launch
_sshToggleResult.value = null
when (val result = client.setSshEnabled(enabled)) {
is ApiResult.Success -> {
_sshToggleResult.value = if (enabled) "SSH enabled" else "SSH disabled"
_state.value = _state.value.copy(sshStatus = SshStatus(active = enabled))
}
is ApiResult.Error -> {
_sshToggleResult.value = "Failed: ${result.message}"
refresh()
}
}
}
}
fun clearWifiResult() { fun clearWifiResult() {
_wifiSaveResult.value = null _wifiSaveResult.value = null
} }
@ -136,4 +166,8 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
fun clearUploadModeResult() { fun clearUploadModeResult() {
_uploadModeResult.value = null _uploadModeResult.value = null
} }
fun clearSshToggleResult() {
_sshToggleResult.value = null
}
} }

View file

@ -1,30 +1,97 @@
package com.adamaps.varroa.viewmodel package com.adamaps.varroa.viewmodel
import android.app.Application import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.adamaps.varroa.api.BeeApiClient
import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.SettingsDataStore import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.data.VarroaSettings
import com.adamaps.varroa.data.WifiStatus
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SettingsViewModel(app: Application) : AndroidViewModel(app) { class SettingsViewModel(app: Application) : AndroidViewModel(app) {
companion object {
private const val TAG = "SettingsVM"
}
private val store = SettingsDataStore(app) private val store = SettingsDataStore(app)
private var beeClient: BeeApiClient? = null
val settings: StateFlow<VarroaSettings> = store.settings val settings: StateFlow<VarroaSettings> = store.settings
.stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings()) .stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings())
// Derived observables from settings
val isPaired: StateFlow<Boolean> = store.settings
.map { it.isPaired }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val deviceSerial: StateFlow<String> = store.settings
.map { it.deviceSerial }
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
private val _saved = MutableStateFlow(false) private val _saved = MutableStateFlow(false)
val saved: StateFlow<Boolean> = _saved.asStateFlow() val saved: StateFlow<Boolean> = _saved.asStateFlow()
// Pairing state
private val _pairingInProgress = MutableStateFlow(false)
val pairingInProgress: StateFlow<Boolean> = _pairingInProgress.asStateFlow()
private val _pairingResult = MutableStateFlow<String?>(null)
val pairingResult: StateFlow<String?> = _pairingResult.asStateFlow()
// SSH state
private val _sshStatus = MutableStateFlow<SshStatus?>(null)
val sshStatus: StateFlow<SshStatus?> = _sshStatus.asStateFlow()
private val _sshToggleResult = MutableStateFlow<String?>(null)
val sshToggleResult: StateFlow<String?> = _sshToggleResult.asStateFlow()
// WiFi state
private val _wifiStatus = MutableStateFlow<WifiStatus?>(null)
val wifiStatus: StateFlow<WifiStatus?> = _wifiStatus.asStateFlow()
private val _wifiConnectResult = MutableStateFlow<String?>(null)
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
init {
// Initialize BeeApiClient with stored settings and token
viewModelScope.launch {
val s = store.settings.first()
val client = BeeApiClient(s.beeApiUrl)
if (s.apiToken.isNotBlank()) {
client.apiToken = s.apiToken
}
beeClient = client
// Fetch initial statuses if paired
if (s.isPaired) {
refreshSshStatus()
refreshWifiStatus()
}
}
}
fun save(s: VarroaSettings) { fun save(s: VarroaSettings) {
viewModelScope.launch { viewModelScope.launch {
store.save(s) store.save(s)
// Update client URL and token if changed
beeClient?.updateUrl(s.beeApiUrl)
if (s.apiToken.isNotBlank()) {
beeClient?.apiToken = s.apiToken
}
_saved.value = true _saved.value = true
} }
} }
@ -32,4 +99,165 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun clearSaved() { fun clearSaved() {
_saved.value = false _saved.value = false
} }
// ── Pairing ───────────────────────────────────────────────────────────────
/**
* Pair with the AdaCam device.
* Fetches serial from /pair endpoint, derives token, stores both.
*/
fun pairDevice() {
viewModelScope.launch {
_pairingInProgress.value = true
_pairingResult.value = null
try {
val s = store.settings.first()
val client = BeeApiClient(s.beeApiUrl)
beeClient = client
when (val result = client.pair()) {
is ApiResult.Success -> {
val serial = result.data.serial
Log.i(TAG, "Pairing successful: serial=$serial")
// Store pairing data (derives token automatically)
store.savePairing(serial)
// Update client with new token
val newSettings = store.settings.first()
client.apiToken = newSettings.apiToken
_pairingResult.value = "Paired successfully! Serial: $serial"
// Fetch statuses now that we're paired
refreshSshStatus()
refreshWifiStatus()
}
is ApiResult.Error -> {
Log.e(TAG, "Pairing failed: ${result.message}")
_pairingResult.value = "Pairing failed: ${result.message}"
}
}
} catch (e: Exception) {
Log.e(TAG, "Pairing exception", e)
_pairingResult.value = "Pairing error: ${e.message}"
} finally {
_pairingInProgress.value = false
}
}
}
/**
* Clear pairing data (for re-pairing).
*/
fun clearPairing() {
viewModelScope.launch {
store.clearPairing()
beeClient?.apiToken = ""
_sshStatus.value = null
_wifiStatus.value = null
_pairingResult.value = "Pairing cleared"
}
}
fun clearPairingResult() {
_pairingResult.value = null
}
// ── WiFi ──────────────────────────────────────────────────────────────────
/**
* Refresh WiFi status from device.
*/
fun refreshWifiStatus() {
viewModelScope.launch {
val client = beeClient ?: return@launch
when (val result = client.getWifiStatus()) {
is ApiResult.Success -> {
_wifiStatus.value = result.data
}
is ApiResult.Error -> {
Log.w(TAG, "Failed to get WiFi status: ${result.message}")
}
}
}
}
/**
* Connect device to a home WiFi network.
*/
fun connectWifi(ssid: String, password: String) {
viewModelScope.launch {
val client = beeClient ?: run {
_wifiConnectResult.value = "Not connected to device"
return@launch
}
_wifiConnectResult.value = null
when (val result = client.setWifiConfig(ssid, password)) {
is ApiResult.Success -> {
_wifiConnectResult.value = "WiFi config sent. Connecting to $ssid..."
// Refresh status after a short delay
kotlinx.coroutines.delay(3000)
refreshWifiStatus()
}
is ApiResult.Error -> {
_wifiConnectResult.value = "Failed: ${result.message}"
}
}
}
}
fun clearWifiResult() {
_wifiConnectResult.value = null
}
// ── SSH ───────────────────────────────────────────────────────────────────
/**
* Refresh SSH status from device.
*/
fun refreshSshStatus() {
viewModelScope.launch {
val client = beeClient ?: return@launch
when (val result = client.getSshStatus()) {
is ApiResult.Success -> {
_sshStatus.value = result.data
}
is ApiResult.Error -> {
Log.w(TAG, "Failed to get SSH status: ${result.message}")
}
}
}
}
/**
* Toggle SSH on/off on the device.
*/
fun toggleSsh(enabled: Boolean) {
viewModelScope.launch {
val client = beeClient ?: run {
_sshToggleResult.value = "Not connected to device"
return@launch
}
_sshToggleResult.value = null
when (val result = client.setSshEnabled(enabled)) {
is ApiResult.Success -> {
_sshToggleResult.value = if (enabled) "SSH enabled" else "SSH disabled"
_sshStatus.value = SshStatus(active = enabled)
}
is ApiResult.Error -> {
_sshToggleResult.value = "Failed: ${result.message}"
// Refresh to get actual state
refreshSshStatus()
}
}
}
}
fun clearSshResult() {
_sshToggleResult.value = null
}
} }