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:
Kayos 2026-03-11 13:50:15 -07:00
parent 0cc11c5158
commit 19ab72d8ea
4 changed files with 145 additions and 13 deletions

View file

@ -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)
}

View file

@ -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.

View file

@ -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
}
}
}

View file

@ -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 -> {