Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 01b031cec3 |
8 changed files with 822 additions and 133 deletions
|
|
@ -76,8 +76,6 @@ dependencies {
|
|||
ksp(libs.room.compiler)
|
||||
// WorkManager (background uploads)
|
||||
implementation(libs.work.runtime.ktx)
|
||||
// SSH connectivity for device_id fallback
|
||||
implementation("com.jcraft:jsch:0.1.55")
|
||||
// QR Code scanning
|
||||
implementation("com.google.zxing:core:3.5.2")
|
||||
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||
|
|
|
|||
|
|
@ -11,24 +11,22 @@ import com.adamaps.varroa.data.BeeDeviceInfo
|
|||
import com.adamaps.varroa.data.BeePlugin
|
||||
import com.adamaps.varroa.data.GnssData
|
||||
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.WifiConfig
|
||||
import com.adamaps.varroa.data.WifiStatus
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
import com.jcraft.jsch.JSch
|
||||
import com.jcraft.jsch.Session
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class BeeApiClient(
|
||||
private var apiUrl: String = "http://192.168.0.10:5000"
|
||||
private var apiUrl: String = "http://10.77.0.1:5000"
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "VarroaBeeAPI"
|
||||
|
|
@ -43,6 +41,9 @@ class BeeApiClient(
|
|||
var isConnected: Boolean = false
|
||||
private set
|
||||
|
||||
// Bearer token for authenticated endpoints
|
||||
var apiToken: String = ""
|
||||
|
||||
fun updateUrl(url: String) {
|
||||
val oldUrl = apiUrl
|
||||
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.
|
||||
*/
|
||||
fun bindToNetwork(network: Network) {
|
||||
|
|
@ -107,7 +108,7 @@ class BeeApiClient(
|
|||
val allWifi = cm.allNetworks.filter { n ->
|
||||
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 ->
|
||||
cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == false
|
||||
} ?: allWifi.firstOrNull()
|
||||
|
|
@ -178,7 +179,7 @@ class BeeApiClient(
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if Bee is reachable.
|
||||
* Check if AdaCam is reachable.
|
||||
* Updates internal connection state.
|
||||
*/
|
||||
suspend fun ping(): Boolean {
|
||||
|
|
@ -208,6 +209,25 @@ class BeeApiClient(
|
|||
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) {
|
||||
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) {
|
||||
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) {
|
||||
try {
|
||||
val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password))
|
||||
|
|
@ -282,6 +313,7 @@ class BeeApiClient(
|
|||
)
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/api/1/wifi/connect")
|
||||
.addHeader("Authorization", "Bearer $apiToken")
|
||||
.post(requestBody)
|
||||
.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) {
|
||||
when (val r = getRaw("/api/1/storage/usage")) {
|
||||
is ApiResult.Success -> try {
|
||||
|
|
@ -341,6 +414,7 @@ class BeeApiClient(
|
|||
)
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/api/1/config/uploadMode")
|
||||
.addHeader("Authorization", "Bearer $apiToken")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
|
|
@ -357,50 +431,7 @@ class BeeApiClient(
|
|||
}
|
||||
}
|
||||
|
||||
// ── End v7.7 Settings 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}")
|
||||
}
|
||||
}
|
||||
// ── Camera API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @param landmarkIds List of landmark IDs to delete
|
||||
|
|
@ -471,24 +502,13 @@ class BeeApiClient(
|
|||
"/api/1/landmarks/delete",
|
||||
"/api/1/landmarks/clear",
|
||||
"/api/1/landmarks/cleanup",
|
||||
"/api/1/landmarks/remove",
|
||||
"/api/1/cmd" // As a last resort using the cmd endpoint
|
||||
"/api/1/landmarks/remove"
|
||||
)
|
||||
|
||||
for (endpoint in endpoints) {
|
||||
Log.d(TAG, "Trying cleanup endpoint: $endpoint")
|
||||
|
||||
val result = when (endpoint) {
|
||||
"/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)
|
||||
}
|
||||
}
|
||||
val result = tryCleanupEndpoint(endpoint, landmarkIds)
|
||||
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
|
|
@ -502,8 +522,8 @@ class BeeApiClient(
|
|||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "All cleanup endpoints failed - Bee may not support landmark deletion")
|
||||
return@withContext ApiResult.Error("No working DELETE endpoint found - cleanup not supported by Bee device")
|
||||
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 device")
|
||||
}
|
||||
|
||||
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")) {
|
||||
Log.d(TAG, "Trying $method $endpoint")
|
||||
|
||||
val request = Request.Builder()
|
||||
val requestBuilder = Request.Builder()
|
||||
.url("$apiUrl$endpoint")
|
||||
.method(method, if (method == "DELETE") null else requestBody)
|
||||
.build()
|
||||
.addHeader("Authorization", "Bearer $apiToken")
|
||||
|
||||
val request = if (method == "DELETE") {
|
||||
requestBuilder.delete(requestBody).build()
|
||||
} else {
|
||||
requestBuilder.post(requestBody).build()
|
||||
}
|
||||
|
||||
client.newCall(request).execute().use { resp ->
|
||||
val body = resp.body?.string() ?: ""
|
||||
|
|
@ -540,50 +565,4 @@ class BeeApiClient(
|
|||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,21 @@ data class BeeDeviceInfo(
|
|||
@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 ────────────────────────────────────────────────────────────
|
||||
|
||||
data class AdaMapsDetection(
|
||||
|
|
@ -93,7 +108,15 @@ data class WifiConfig(
|
|||
@SerializedName("ssid") val ssid: String? = null,
|
||||
@SerializedName("password") val password: String? = 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(
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.security.MessageDigest
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings")
|
||||
|
||||
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 adamapsApiKey: String = "***REMOVED***",
|
||||
val pollIntervalSeconds: Int = 30,
|
||||
|
|
@ -22,9 +23,23 @@ data class VarroaSettings(
|
|||
val cameraRefreshSeconds: Int = 30,
|
||||
val forwardingEnabled: Boolean = true,
|
||||
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) {
|
||||
|
||||
companion object {
|
||||
|
|
@ -37,11 +52,15 @@ class SettingsDataStore(private val context: Context) {
|
|||
private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled")
|
||||
private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id")
|
||||
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 ->
|
||||
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",
|
||||
adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***",
|
||||
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
|
||||
|
|
@ -49,7 +68,10 @@ class SettingsDataStore(private val context: Context) {
|
|||
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,
|
||||
forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true,
|
||||
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_CACHED_DEVICE_ID] = s.cachedDeviceId
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.CheckCircle
|
|||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.BeePlugin
|
||||
import com.adamaps.varroa.data.GnssStatus
|
||||
import com.adamaps.varroa.data.SshStatus
|
||||
import com.adamaps.varroa.data.StorageStatus
|
||||
import com.adamaps.varroa.data.WifiConfig
|
||||
import com.adamaps.varroa.ui.theme.*
|
||||
|
|
@ -36,6 +38,7 @@ fun DeviceStatusScreen(
|
|||
val state by vm.state.collectAsState()
|
||||
val wifiResult by vm.wifiSaveResult.collectAsState()
|
||||
val uploadResult by vm.uploadModeResult.collectAsState()
|
||||
val sshResult by vm.sshToggleResult.collectAsState()
|
||||
|
||||
// Refresh on first load
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -56,6 +59,12 @@ fun DeviceStatusScreen(
|
|||
vm.clearUploadModeResult()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(sshResult) {
|
||||
sshResult?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
vm.clearSshToggleResult()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Background,
|
||||
|
|
@ -119,6 +128,9 @@ fun DeviceStatusScreen(
|
|||
// GPS/GNSS Status
|
||||
GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock)
|
||||
|
||||
// SSH Status
|
||||
SshStatusCard(state.sshStatus, vm)
|
||||
|
||||
// Upload Mode
|
||||
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
|
||||
private fun PluginsCard(plugins: List<BeePlugin>) {
|
||||
StatusCard("PLUGINS") {
|
||||
|
|
|
|||
|
|
@ -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.ChevronRight
|
||||
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.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.FontWeight
|
||||
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.compose.ui.unit.sp
|
||||
|
|
@ -44,6 +49,14 @@ fun SettingsScreen(
|
|||
) {
|
||||
val currentSettings by vm.settings.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
|
||||
var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) }
|
||||
|
|
@ -62,6 +75,24 @@ fun SettingsScreen(
|
|||
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(
|
||||
containerColor = Background,
|
||||
|
|
@ -86,7 +117,7 @@ fun SettingsScreen(
|
|||
actions = {
|
||||
IconButton(onClick = {
|
||||
vm.save(
|
||||
VarroaSettings(
|
||||
currentSettings.copy(
|
||||
beeApiUrl = beeApiUrl.trim(),
|
||||
adamapsApiUrl = adamapsApiUrl.trim(),
|
||||
adamapsApiKey = adamapsApiKey.trim(),
|
||||
|
|
@ -111,6 +142,15 @@ fun SettingsScreen(
|
|||
.padding(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
|
||||
Card(
|
||||
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(
|
||||
label = "Bee API URL",
|
||||
label = "AdaCam API URL",
|
||||
value = beeApiUrl,
|
||||
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
|
||||
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.adamaps.varroa.data.BeeDeviceInfo
|
|||
import com.adamaps.varroa.data.BeePlugin
|
||||
import com.adamaps.varroa.data.GnssStatus
|
||||
import com.adamaps.varroa.data.SettingsDataStore
|
||||
import com.adamaps.varroa.data.SshStatus
|
||||
import com.adamaps.varroa.data.StorageStatus
|
||||
import com.adamaps.varroa.data.WifiConfig
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -25,6 +26,7 @@ data class DeviceStatusState(
|
|||
val wifiConfig: WifiConfig? = null,
|
||||
val storageStatus: StorageStatus? = null,
|
||||
val gnssStatus: GnssStatus? = null,
|
||||
val sshStatus: SshStatus? = null,
|
||||
val plugins: List<BeePlugin> = emptyList(),
|
||||
val error: String? = null
|
||||
)
|
||||
|
|
@ -47,6 +49,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
|||
private val _uploadModeResult = MutableStateFlow<String?>(null)
|
||||
val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow()
|
||||
|
||||
private val _sshToggleResult = MutableStateFlow<String?>(null)
|
||||
val sshToggleResult: StateFlow<String?> = _sshToggleResult.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
|
|
@ -54,6 +59,10 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
|||
try {
|
||||
val settings = store.settings.first()
|
||||
val client = BeeApiClient(settings.beeApiUrl)
|
||||
// Set auth token if paired
|
||||
if (settings.apiToken.isNotBlank()) {
|
||||
client.apiToken = settings.apiToken
|
||||
}
|
||||
beeClient = client
|
||||
|
||||
// Fetch all status in parallel
|
||||
|
|
@ -62,12 +71,14 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
|||
val storageResult = client.getStorageStatus()
|
||||
val gnssResult = client.getGnssStatus()
|
||||
val pluginsResult = client.getPlugins()
|
||||
val sshResult = client.getSshStatus()
|
||||
|
||||
val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data
|
||||
val wifi = (wifiResult as? ApiResult.Success)?.data
|
||||
val storage = (storageResult as? ApiResult.Success)?.data
|
||||
val gnss = (gnssResult as? ApiResult.Success)?.data
|
||||
val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList()
|
||||
val ssh = (sshResult as? ApiResult.Success)?.data
|
||||
|
||||
val isConnected = deviceInfo != null
|
||||
|
||||
|
|
@ -78,8 +89,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
|||
wifiConfig = wifi,
|
||||
storageStatus = storage,
|
||||
gnssStatus = gnss,
|
||||
sshStatus = ssh,
|
||||
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}")
|
||||
|
|
@ -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() {
|
||||
_wifiSaveResult.value = null
|
||||
}
|
||||
|
|
@ -136,4 +166,8 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
|||
fun clearUploadModeResult() {
|
||||
_uploadModeResult.value = null
|
||||
}
|
||||
|
||||
fun clearSshToggleResult() {
|
||||
_sshToggleResult.value = null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,97 @@
|
|||
package com.adamaps.varroa.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
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.SshStatus
|
||||
import com.adamaps.varroa.data.VarroaSettings
|
||||
import com.adamaps.varroa.data.WifiStatus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SettingsVM"
|
||||
}
|
||||
|
||||
private val store = SettingsDataStore(app)
|
||||
private var beeClient: BeeApiClient? = null
|
||||
|
||||
val settings: StateFlow<VarroaSettings> = store.settings
|
||||
.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)
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -32,4 +99,165 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||
fun clearSaved() {
|
||||
_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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue