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
This commit is contained in:
parent
0cc11c5158
commit
19ab72d8ea
4 changed files with 145 additions and 13 deletions
|
|
@ -13,8 +13,8 @@ android {
|
|||
applicationId = "com.adamaps.varroa"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 8
|
||||
versionName = "1.7.1"
|
||||
versionCode = 9
|
||||
versionName = "1.7.3"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
|
@ -76,5 +76,7 @@ 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")
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ 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"
|
||||
|
|
@ -28,6 +31,7 @@ class BeeApiClient(
|
|||
|
||||
private var client = buildClient(null)
|
||||
private var fastClient = buildFastClient(null)
|
||||
private var deviceInfoClient = buildDeviceInfoClient(null)
|
||||
private val gson = Gson()
|
||||
|
||||
// Connection state
|
||||
|
|
@ -48,7 +52,8 @@ class BeeApiClient(
|
|||
Log.i(TAG, "Binding OkHttpClients to network: $network")
|
||||
client = buildClient(network)
|
||||
fastClient = buildFastClient(network)
|
||||
Log.d(TAG, "Network binding complete - both clients updated")
|
||||
deviceInfoClient = buildDeviceInfoClient(network)
|
||||
Log.d(TAG, "Network binding complete - all clients updated")
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,6 +65,7 @@ class BeeApiClient(
|
|||
Log.d(TAG, "Legacy binding to WiFi: $net")
|
||||
client = buildClient(net)
|
||||
fastClient = buildFastClient(net)
|
||||
deviceInfoClient = buildDeviceInfoClient(net)
|
||||
}
|
||||
|
||||
private fun buildClient(net: Network?): OkHttpClient {
|
||||
|
|
@ -80,6 +86,15 @@ class BeeApiClient(
|
|||
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 {
|
||||
|
|
@ -117,6 +132,29 @@ class BeeApiClient(
|
|||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
@ -207,7 +245,7 @@ class BeeApiClient(
|
|||
}
|
||||
|
||||
suspend fun getDeviceInfo(): ApiResult<BeeDeviceInfo> = withContext(Dispatchers.IO) {
|
||||
when (val r = getRaw("/api/1/info")) {
|
||||
when (val r = getRawWithDeviceInfoClient("/api/1/info")) {
|
||||
is ApiResult.Success -> try {
|
||||
ApiResult.Success(gson.fromJson(r.data, BeeDeviceInfo::class.java))
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -217,6 +255,49 @@ class BeeApiClient(
|
|||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ data class VarroaSettings(
|
|||
val pollIntervalSeconds: Int = 30,
|
||||
val cameraEndpoint: String = "/api/1/camera/frame",
|
||||
val cameraRefreshSeconds: Int = 30,
|
||||
val forwardingEnabled: Boolean = true
|
||||
val forwardingEnabled: Boolean = true,
|
||||
val cachedDeviceId: String = "unknown"
|
||||
)
|
||||
|
||||
class SettingsDataStore(private val context: Context) {
|
||||
|
|
@ -33,6 +34,7 @@ class SettingsDataStore(private val context: Context) {
|
|||
private val KEY_CAMERA_ENDPOINT = stringPreferencesKey("camera_endpoint")
|
||||
private val KEY_CAMERA_REFRESH = intPreferencesKey("camera_refresh_seconds")
|
||||
private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled")
|
||||
private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id")
|
||||
}
|
||||
|
||||
val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
|
||||
|
|
@ -43,7 +45,8 @@ class SettingsDataStore(private val context: Context) {
|
|||
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
|
||||
cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame",
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +59,13 @@ class SettingsDataStore(private val context: Context) {
|
|||
prefs[KEY_CAMERA_ENDPOINT] = s.cameraEndpoint
|
||||
prefs[KEY_CAMERA_REFRESH] = s.cameraRefreshSeconds
|
||||
prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled
|
||||
prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateCachedDeviceId(deviceId: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_CACHED_DEVICE_ID] = deviceId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,18 +138,26 @@ class BeeCollectorService : LifecycleService() {
|
|||
lifecycleScope.launch {
|
||||
Log.d(TAG, "Loading settings from DataStore...")
|
||||
val settings = settingsStore.settings.first()
|
||||
Log.d(TAG, "Settings loaded - beeApiUrl: ${settings.beeApiUrl}, pollInterval: ${settings.pollIntervalSeconds}s")
|
||||
Log.d(TAG, "Settings loaded - beeApiUrl: ${settings.beeApiUrl}, pollInterval: ${settings.pollIntervalSeconds}s, cachedDeviceId: ${settings.cachedDeviceId}")
|
||||
|
||||
beeClient.updateUrl(settings.beeApiUrl)
|
||||
Log.d(TAG, "BeeApiClient URL updated")
|
||||
|
||||
// Load cached device ID
|
||||
_currentDeviceId.value = settings.cachedDeviceId
|
||||
Log.i(TAG, "Loaded cached device ID: ${settings.cachedDeviceId}")
|
||||
|
||||
// Bind to Bee network (unvalidated WiFi)
|
||||
Log.d(TAG, "Attempting to bind to Bee network...")
|
||||
bindToBeeNetwork()
|
||||
|
||||
// Fetch device ID once
|
||||
Log.d(TAG, "Fetching device ID from Bee...")
|
||||
fetchDeviceId()
|
||||
// Fetch device ID once if cache is unknown
|
||||
if (settings.cachedDeviceId == "unknown") {
|
||||
Log.d(TAG, "Cached device ID is unknown - fetching from Bee...")
|
||||
fetchDeviceId()
|
||||
} else {
|
||||
Log.d(TAG, "Using cached device ID: ${settings.cachedDeviceId}")
|
||||
}
|
||||
|
||||
// Start polling loop
|
||||
Log.i(TAG, "Starting poll loop with ${settings.pollIntervalSeconds}s interval")
|
||||
|
|
@ -188,11 +196,36 @@ class BeeCollectorService : LifecycleService() {
|
|||
when (val result = beeClient.getDeviceInfo()) {
|
||||
is ApiResult.Success -> {
|
||||
val deviceId = result.data.deviceId ?: result.data.serial ?: "unknown"
|
||||
_currentDeviceId.value = deviceId
|
||||
Log.i(TAG, "Device ID retrieved: $deviceId (from ${if (result.data.deviceId != null) "deviceId" else if (result.data.serial != null) "serial" else "fallback"})")
|
||||
if (deviceId != "unknown") {
|
||||
_currentDeviceId.value = deviceId
|
||||
// Update persistent cache
|
||||
settingsStore.updateCachedDeviceId(deviceId)
|
||||
Log.i(TAG, "Device ID retrieved via API: $deviceId (from ${if (result.data.deviceId != null) "deviceId" else if (result.data.serial != null) "serial" else "fallback"})")
|
||||
} else {
|
||||
Log.w(TAG, "API returned unknown device ID, trying SSH fallback...")
|
||||
fetchDeviceIdViaSsh()
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.e(TAG, "Failed to get device ID: ${result.message}, code: ${result.code}")
|
||||
Log.e(TAG, "Failed to get device ID via API: ${result.message}, code: ${result.code}")
|
||||
Log.i(TAG, "Trying SSH fallback...")
|
||||
fetchDeviceIdViaSsh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchDeviceIdViaSsh() {
|
||||
Log.d(TAG, "Attempting SSH fallback for device ID...")
|
||||
when (val result = beeClient.getDeviceIdViaSsh()) {
|
||||
is ApiResult.Success -> {
|
||||
val deviceId = result.data
|
||||
_currentDeviceId.value = deviceId
|
||||
// Update persistent cache
|
||||
settingsStore.updateCachedDeviceId(deviceId)
|
||||
Log.i(TAG, "Device ID retrieved via SSH: $deviceId")
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.e(TAG, "Failed to get device ID via SSH: ${result.message}")
|
||||
_currentDeviceId.value = "unknown"
|
||||
}
|
||||
}
|
||||
|
|
@ -253,6 +286,12 @@ class BeeCollectorService : LifecycleService() {
|
|||
Log.d(TAG, "No new detections to store")
|
||||
}
|
||||
|
||||
// Retry device_id fetch after successful landmark poll if cached is unknown
|
||||
if (_currentDeviceId.value == "unknown") {
|
||||
Log.i(TAG, "Device ID is unknown after successful landmark poll - retrying fetch...")
|
||||
fetchDeviceId()
|
||||
}
|
||||
|
||||
updateNotification(_sessionCollected.value)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue