varroa/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt
Kayos 19ab72d8ea v7.3: reliable device_id with SSH fallback
- Add JSch SSH library dependency
- Persistent device_id cache in DataStore (not just memory)
- Load cached device_id on app start
- Only fetch if missing or 'unknown'
- 30-second timeout for /api/1/info endpoint
- SSH fallback to root@192.168.0.10:22 when API fails
- Retry device_id fetch after successful landmark poll
- Fallback order: DataStore cache → API → SSH
- Build signed APK at /root/.openclaw/workspace/projects/varroa-v7.3.apk
2026-03-11 13:50:15 -07:00

326 lines
12 KiB
Kotlin

package com.adamaps.varroa.api
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.util.Log
import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.BeeDetection
import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.GnssData
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.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"
) {
companion object {
private const val TAG = "VarroaBeeAPI"
}
private var client = buildClient(null)
private var fastClient = buildFastClient(null)
private var deviceInfoClient = buildDeviceInfoClient(null)
private val gson = Gson()
// Connection state
var isConnected: Boolean = false
private set
fun updateUrl(url: String) {
val oldUrl = apiUrl
apiUrl = url.trimEnd('/')
Log.d(TAG, "URL updated from $oldUrl to $apiUrl")
}
/**
* Bind to a specific network (e.g., unvalidated WiFi for Bee AP).
* This is the preferred method when using NetworkStateMonitor.
*/
fun bindToNetwork(network: Network) {
Log.i(TAG, "Binding OkHttpClients to network: $network")
client = buildClient(network)
fastClient = buildFastClient(network)
deviceInfoClient = buildDeviceInfoClient(network)
Log.d(TAG, "Network binding complete - all clients updated")
}
/**
* Legacy binding method - finds WiFi network automatically.
* Prefer bindToNetwork() with explicit network from NetworkStateMonitor.
*/
fun bindToWifiNetwork(context: Context) {
val net = getWifiNetwork(context)
Log.d(TAG, "Legacy binding to WiFi: $net")
client = buildClient(net)
fastClient = buildFastClient(net)
deviceInfoClient = buildDeviceInfoClient(net)
}
private fun buildClient(net: Network?): OkHttpClient {
val b = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
net?.let { b.socketFactory(it.socketFactory) }
return b.build()
}
private fun buildFastClient(net: Network?): OkHttpClient {
val b = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
net?.let { b.socketFactory(it.socketFactory) }
return b.build()
}
private fun buildDeviceInfoClient(net: Network?): OkHttpClient {
val b = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
net?.let { b.socketFactory(it.socketFactory) }
return b.build()
}
@Suppress("DEPRECATION")
private fun getWifiNetwork(context: Context): Network? {
return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val allWifi = cm.allNetworks.filter { n ->
cm.getNetworkCapabilities(n)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}
// prefer unvalidated wifi (Bee AP has no internet)
allWifi.firstOrNull { n ->
cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == false
} ?: allWifi.firstOrNull()
} catch (e: Exception) { null }
}
private suspend fun getRaw(path: String): ApiResult<String> = withContext(Dispatchers.IO) {
val fullUrl = "$apiUrl$path"
Log.d(TAG, "HTTP GET request to: $fullUrl")
try {
val req = Request.Builder().url(fullUrl).get().build()
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) {
isConnected = true
Log.d(TAG, "HTTP ${resp.code} OK - response length: ${body.length} chars")
ApiResult.Success(body)
} else {
Log.w(TAG, "HTTP ${resp.code} ${resp.message} from $fullUrl")
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
Log.e(TAG, "HTTP request failed to $fullUrl", e)
ApiResult.Error(e.message ?: "Unknown error")
}
}
private suspend fun getRawWithDeviceInfoClient(path: String): ApiResult<String> = withContext(Dispatchers.IO) {
val fullUrl = "$apiUrl$path"
Log.d(TAG, "HTTP GET request (device info client) to: $fullUrl")
try {
val req = Request.Builder().url(fullUrl).get().build()
deviceInfoClient.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) {
isConnected = true
Log.d(TAG, "HTTP ${resp.code} OK (device info) - response length: ${body.length} chars")
ApiResult.Success(body)
} else {
Log.w(TAG, "HTTP ${resp.code} ${resp.message} from $fullUrl (device info)")
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
Log.e(TAG, "HTTP request failed to $fullUrl (device info)", e)
ApiResult.Error(e.message ?: "Unknown error")
}
}
private suspend fun getBytes(path: String): ApiResult<ByteArray> = withContext(Dispatchers.IO) {
try {
val req = Request.Builder().url("$apiUrl$path").get().build()
client.newCall(req).execute().use { resp ->
val bytes = resp.body?.bytes() ?: ByteArray(0)
if (resp.isSuccessful && bytes.isNotEmpty()) {
isConnected = true
ApiResult.Success(bytes)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
}
}
/**
* Check if Bee is reachable.
* Updates internal connection state.
*/
suspend fun ping(): Boolean {
return when (getRaw("/api/1/info")) {
is ApiResult.Success -> {
isConnected = true
Log.i(TAG, "Ping successful")
true
}
is ApiResult.Error -> {
isConnected = false
Log.w(TAG, "Ping failed")
false
}
}
}
/**
* Get the currently configured URL.
*/
fun getApiUrl(): String = apiUrl
/**
* Force offline state (for exponential backoff scenarios).
*/
fun setOffline() {
isConnected = false
}
suspend fun getLandmarks(): ApiResult<List<BeeDetection>> = withContext(Dispatchers.IO) {
Log.d(TAG, "getLandmarks() called")
when (val r = getRaw("/api/1/landmarks/last/200")) {
is ApiResult.Success -> try {
Log.d(TAG, "Raw landmarks response received - parsing JSON...")
val type = object : TypeToken<List<BeeDetection>>() {}.type
val list: List<BeeDetection> = gson.fromJson(r.data, type)
isConnected = true
Log.i(TAG, "Landmarks parsed successfully: ${list.size} detections")
ApiResult.Success(list)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse landmarks JSON", e)
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> {
Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})")
// Connection failed, mark as offline
if (r.message.contains("timeout", ignoreCase = true) ||
r.message.contains("connect", ignoreCase = true) ||
r.message.contains("refused", ignoreCase = true) ||
r.message.contains("unreachable", ignoreCase = true)) {
Log.d(TAG, "Network error detected - marking as offline")
isConnected = false
}
r
}
}
}
suspend fun getGnss(): ApiResult<GnssData> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/gnssConcise/latestValid")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, GnssData::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun getDeviceInfo(): ApiResult<BeeDeviceInfo> = withContext(Dispatchers.IO) {
when (val r = getRawWithDeviceInfoClient("/api/1/info")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, BeeDeviceInfo::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
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.
* The caller is responsible for trying fallback endpoints.
*/
suspend fun getCameraFrame(endpoint: String): ApiResult<ByteArray> {
return getBytes(endpoint)
}
/**
* Try multiple camera endpoints in order, return first success.
*/
suspend fun getCameraFrameAuto(configured: String): Pair<String, ApiResult<ByteArray>> {
val candidates = listOf(
configured,
"/api/1/camera/frame",
"/api/1/camera/snapshot",
"/api/1/preview",
"/api/1/frame"
).distinct()
for (ep in candidates) {
val r = getCameraFrame(ep)
if (r is ApiResult.Success) return ep to r
}
return configured to ApiResult.Error("No camera endpoint responded")
}
}