From 19ab72d8ea384f95bef46e1007bf3b60bf8fd0d6 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 11 Mar 2026 13:50:15 -0700 Subject: [PATCH] v7.3: reliable device_id with SSH fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/build.gradle.kts | 6 +- .../com/adamaps/varroa/api/BeeApiClient.kt | 85 ++++++++++++++++++- .../adamaps/varroa/data/SettingsDataStore.kt | 14 ++- .../varroa/service/BeeCollectorService.kt | 53 ++++++++++-- 4 files changed, 145 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb69638..4bbe081 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt index 2e9f7c4..8c4e9ed 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -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 = 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 = withContext(Dispatchers.IO) { try { val req = Request.Builder().url("$apiUrl$path").get().build() @@ -207,7 +245,7 @@ class BeeApiClient( } suspend fun getDeviceInfo(): ApiResult = 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 = 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. diff --git a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt index 103dac2..0cfc0d3 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -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 = 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 } } } diff --git a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt index cf2f2ad..9708f7d 100644 --- a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt +++ b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt @@ -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 -> {