- 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
326 lines
12 KiB
Kotlin
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")
|
|
}
|
|
}
|