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)
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue