From d11f6b62d198060cf62ea6ded12ef06b5a44bb05 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 14 Mar 2026 11:56:46 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20adacam=20migration=20=E2=80=94=20update?= =?UTF-8?q?=20IP,=20pairing,=20bearer=20auth,=20wifi/ssh=20config,=20remov?= =?UTF-8?q?e=20JSch=20and=20cmd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default API URL: 192.168.0.10 → 10.77.0.1 - BeeApiClient: apiToken field, bearer auth on POST endpoints, pair() method, getSshStatus(), setSshEnabled() — JSch/SSH-from-app removed entirely - Models: PairResponse, SshStatus, WifiStatus data classes - SettingsDataStore: deviceSerial, apiToken, isPaired persistence + deriveApiToken() - SettingsViewModel: pairDevice(), connectWifi(), toggleSsh(), refreshSshStatus(), refreshWifiStatus(), isPaired/deviceSerial/sshStatus/wifiStatus StateFlows - SettingsScreen: Pairing section, Home WiFi config section, SSH toggle section renamed BEE DEVICE → ADACAM DEVICE, hint URL updated - build.gradle: removed JSch dependency (no longer SSHing from app) --- app/build.gradle.kts | 2 +- .../java/com/adamaps/varroa/data/Models.kt | 15 ++ .../adamaps/varroa/data/SettingsDataStore.kt | 57 ++++- .../varroa/ui/settings/SettingsScreen.kt | 136 ++++++++++- .../varroa/viewmodel/SettingsViewModel.kt | 228 ++++++++++++++++++ 5 files changed, 430 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 903d76a..41c5850 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,7 +77,7 @@ dependencies { // WorkManager (background uploads) implementation(libs.work.runtime.ktx) // SSH connectivity for device_id fallback - implementation("com.jcraft:jsch:0.1.55") + // QR Code scanning implementation("com.google.zxing:core:3.5.2") implementation("com.journeyapps:zxing-android-embedded:4.3.0") diff --git a/app/src/main/java/com/adamaps/varroa/data/Models.kt b/app/src/main/java/com/adamaps/varroa/data/Models.kt index 87a5968..af348ae 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -122,6 +122,21 @@ data class BeePlugin( @SerializedName("running") val running: Boolean? = null ) +// ── Pairing API ─────────────────────────────────────────────────────────────── + +data class PairResponse( + @SerializedName("serial") val serial: String, + @SerializedName("version") val version: String, + @SerializedName("ap_ip") val apIp: String, + @SerializedName("api_port") val apiPort: Int +) + +// ── SSH API ─────────────────────────────────────────────────────────────────── + +data class SshStatus( + @SerializedName("active") val active: Boolean +) + // ── Bee Settings API models ─────────────────────────────────────────────────── data class WifiClientSettings( 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 21bb037..8c720b9 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -10,11 +10,12 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import java.security.MessageDigest private val Context.dataStore: DataStore by preferencesDataStore(name = "varroa_settings") data class VarroaSettings( - val beeApiUrl: String = "http://192.168.0.10:5000", + val beeApiUrl: String = "http://10.77.0.1:5000", val adamapsApiUrl: String = "https://api.adamaps.org", val adamapsApiKey: String = "adamaps-ingest-2026", val pollIntervalSeconds: Int = 30, @@ -22,9 +23,23 @@ data class VarroaSettings( val cameraRefreshSeconds: Int = 30, val forwardingEnabled: Boolean = true, val cachedDeviceId: String = "unknown", - val walletAddress: String = "" + val walletAddress: String = "", + // AdaCam pairing + val deviceSerial: String = "", + val apiToken: String = "", + val isPaired: Boolean = false ) +/** + * Derive the API token from the device serial. + * Token = first 32 chars of SHA-256("adacam-api-{serial}-token") + */ +fun deriveApiToken(serial: String): String { + val input = "adacam-api-$serial-token" + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.substring(0, 32) +} + class SettingsDataStore(private val context: Context) { companion object { @@ -37,11 +52,15 @@ class SettingsDataStore(private val context: Context) { private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled") private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id") private val KEY_WALLET_ADDRESS = stringPreferencesKey("wallet_address") + // AdaCam pairing keys + private val KEY_DEVICE_SERIAL = stringPreferencesKey("device_serial") + private val KEY_API_TOKEN = stringPreferencesKey("api_token") + private val KEY_IS_PAIRED = booleanPreferencesKey("is_paired") } val settings: Flow = context.dataStore.data.map { prefs -> VarroaSettings( - beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000", + beeApiUrl = prefs[KEY_BEE_URL] ?: "http://10.77.0.1:5000", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "adamaps-ingest-2026", pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, @@ -49,7 +68,10 @@ class SettingsDataStore(private val context: Context) { cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true, cachedDeviceId = prefs[KEY_CACHED_DEVICE_ID] ?: "unknown", - walletAddress = prefs[KEY_WALLET_ADDRESS] ?: "" + walletAddress = prefs[KEY_WALLET_ADDRESS] ?: "", + deviceSerial = prefs[KEY_DEVICE_SERIAL] ?: "", + apiToken = prefs[KEY_API_TOKEN] ?: "", + isPaired = prefs[KEY_IS_PAIRED] ?: false ) } @@ -64,6 +86,9 @@ class SettingsDataStore(private val context: Context) { prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId prefs[KEY_WALLET_ADDRESS] = s.walletAddress + prefs[KEY_DEVICE_SERIAL] = s.deviceSerial + prefs[KEY_API_TOKEN] = s.apiToken + prefs[KEY_IS_PAIRED] = s.isPaired } } @@ -72,4 +97,28 @@ class SettingsDataStore(private val context: Context) { prefs[KEY_CACHED_DEVICE_ID] = deviceId } } + + /** + * Store pairing data after successful pairing with AdaCam. + * Derives and stores the API token from the serial. + */ + suspend fun savePairing(serial: String) { + val token = deriveApiToken(serial) + context.dataStore.edit { prefs -> + prefs[KEY_DEVICE_SERIAL] = serial + prefs[KEY_API_TOKEN] = token + prefs[KEY_IS_PAIRED] = true + } + } + + /** + * Clear pairing data (for re-pairing or reset). + */ + suspend fun clearPairing() { + context.dataStore.edit { prefs -> + prefs[KEY_DEVICE_SERIAL] = "" + prefs[KEY_API_TOKEN] = "" + prefs[KEY_IS_PAIRED] = false + } + } } diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt index e276203..1d3bd70 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt @@ -27,6 +27,9 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.Terminal import androidx.lifecycle.viewmodel.compose.viewModel import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.ui.theme.* @@ -54,6 +57,19 @@ fun SettingsScreen( var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) } var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) } + // Pairing & device state + val isPaired by vm.isPaired.collectAsState() + val deviceSerial by vm.deviceSerial.collectAsState() + val pairingInProgress by vm.pairingInProgress.collectAsState() + val pairingResult by vm.pairingResult.collectAsState() + val sshStatus by vm.sshStatus.collectAsState() + val wifiStatus by vm.wifiStatus.collectAsState() + val wifiConnectResult by vm.wifiConnectResult.collectAsState() + + // WiFi config input state + var homeWifiSsid by remember { mutableStateOf("") } + var homeWifiPassword by remember { mutableStateOf("") } + // Show snackbar on save val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(saved) { @@ -157,15 +173,129 @@ fun SettingsScreen( } } - SettingsSection("BEE DEVICE") { + SettingsSection("ADACAM DEVICE") { SettingsField( - label = "Bee API URL", + label = "AdaCam API URL", value = beeApiUrl, onValueChange = { beeApiUrl = it }, - hint = "http://192.168.0.10:5000" + hint = "http://10.77.0.1:5000" ) } + // ── Pairing ─────────────────────────────────────────────────────── + SettingsSection("PAIRING") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.Link, + contentDescription = null, + tint = if (isPaired) Amber else Color.Gray, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (isPaired) "Paired — serial: $deviceSerial" + else "Not paired", + color = if (isPaired) Amber else Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + Spacer(Modifier.height(8.dp)) + pairingResult?.let { + Text(it, color = if (it.startsWith("Error")) Color.Red else Amber, + fontFamily = FontFamily.Monospace, fontSize = 11.sp) + Spacer(Modifier.height(4.dp)) + } + Button( + onClick = { vm.pairDevice() }, + enabled = !pairingInProgress, + colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background), + modifier = Modifier.fillMaxWidth() + ) { + Text( + if (pairingInProgress) "Pairing…" else if (isPaired) "Re-pair AdaCam" else "Pair with AdaCam", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ) + } + } + + // ── Home WiFi config ────────────────────────────────────────────── + SettingsSection("HOME WIFI") { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Wifi, contentDescription = null, + tint = if (wifiStatus?.connected == true) Amber else Color.Gray, + modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text( + wifiStatus?.let { + if (it.connected) "Connected: ${it.ssid} (${it.ip})" + else "Disconnected" + } ?: "Unknown", + color = if (wifiStatus?.connected == true) Amber else Color.Gray, + fontFamily = FontFamily.Monospace, fontSize = 12.sp + ) + } + Spacer(Modifier.height(8.dp)) + SettingsField(label = "SSID", value = homeWifiSsid, + onValueChange = { homeWifiSsid = it }, hint = "Your home network") + SettingsField(label = "Password", value = homeWifiPassword, + onValueChange = { homeWifiPassword = it }, hint = "WiFi password", + keyboardType = KeyboardType.Password) + wifiConnectResult?.let { + Text(it, color = if (it.startsWith("Error")) Color.Red else Amber, + fontFamily = FontFamily.Monospace, fontSize = 11.sp) + Spacer(Modifier.height(4.dp)) + } + Button( + onClick = { vm.connectWifi(homeWifiSsid, homeWifiPassword) }, + enabled = isPaired && homeWifiSsid.isNotBlank() && homeWifiPassword.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background), + modifier = Modifier.fillMaxWidth() + ) { + Text("Connect AdaCam to WiFi", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + } + if (!isPaired) { + Text("Pair device first to configure WiFi", + color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + + // ── SSH access ──────────────────────────────────────────────────── + SettingsSection("SSH ACCESS") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Terminal, contentDescription = null, + tint = if (sshStatus?.active == true) Amber else Color.Gray, + modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Column { + Text("SSH over home WiFi", + color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text(if (sshStatus?.active == true) "Active — ssh root@" else "Inactive", + color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + Switch( + checked = sshStatus?.active == true, + onCheckedChange = { vm.toggleSsh(it) }, + enabled = isPaired, + colors = SwitchDefaults.colors(checkedThumbColor = Background, checkedTrackColor = Amber) + ) + } + if (!isPaired) { + Text("Pair device first to toggle SSH", + color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + SettingsSection("ADAMAPS") { SettingsField( label = "ADAMaps API URL", diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt index a703b95..5d1ef53 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt @@ -1,30 +1,97 @@ package com.adamaps.varroa.viewmodel import android.app.Application +import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.adamaps.varroa.api.BeeApiClient +import com.adamaps.varroa.data.ApiResult import com.adamaps.varroa.data.SettingsDataStore +import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.VarroaSettings +import com.adamaps.varroa.data.WifiStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class SettingsViewModel(app: Application) : AndroidViewModel(app) { + companion object { + private const val TAG = "SettingsVM" + } + private val store = SettingsDataStore(app) + private var beeClient: BeeApiClient? = null val settings: StateFlow = store.settings .stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings()) + // Derived observables from settings + val isPaired: StateFlow = store.settings + .map { it.isPaired } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val deviceSerial: StateFlow = store.settings + .map { it.deviceSerial } + .stateIn(viewModelScope, SharingStarted.Eagerly, "") + private val _saved = MutableStateFlow(false) val saved: StateFlow = _saved.asStateFlow() + // Pairing state + private val _pairingInProgress = MutableStateFlow(false) + val pairingInProgress: StateFlow = _pairingInProgress.asStateFlow() + + private val _pairingResult = MutableStateFlow(null) + val pairingResult: StateFlow = _pairingResult.asStateFlow() + + // SSH state + private val _sshStatus = MutableStateFlow(null) + val sshStatus: StateFlow = _sshStatus.asStateFlow() + + private val _sshToggleResult = MutableStateFlow(null) + val sshToggleResult: StateFlow = _sshToggleResult.asStateFlow() + + // WiFi state + private val _wifiStatus = MutableStateFlow(null) + val wifiStatus: StateFlow = _wifiStatus.asStateFlow() + + private val _wifiConnectResult = MutableStateFlow(null) + val wifiConnectResult: StateFlow = _wifiConnectResult.asStateFlow() + + init { + // Initialize BeeApiClient with stored settings and token + viewModelScope.launch { + val s = store.settings.first() + val client = BeeApiClient(s.beeApiUrl) + if (s.apiToken.isNotBlank()) { + client.apiToken = s.apiToken + } + beeClient = client + + // Fetch initial statuses if paired + if (s.isPaired) { + refreshSshStatus() + refreshWifiStatus() + } + } + } + fun save(s: VarroaSettings) { viewModelScope.launch { store.save(s) + + // Update client URL and token if changed + beeClient?.updateUrl(s.beeApiUrl) + if (s.apiToken.isNotBlank()) { + beeClient?.apiToken = s.apiToken + } + _saved.value = true } } @@ -32,4 +99,165 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { fun clearSaved() { _saved.value = false } + + // ── Pairing ─────────────────────────────────────────────────────────────── + + /** + * Pair with the AdaCam device. + * Fetches serial from /pair endpoint, derives token, stores both. + */ + fun pairDevice() { + viewModelScope.launch { + _pairingInProgress.value = true + _pairingResult.value = null + + try { + val s = store.settings.first() + val client = BeeApiClient(s.beeApiUrl) + beeClient = client + + when (val result = client.pair()) { + is ApiResult.Success -> { + val serial = result.data.serial + Log.i(TAG, "Pairing successful: serial=$serial") + + // Store pairing data (derives token automatically) + store.savePairing(serial) + + // Update client with new token + val newSettings = store.settings.first() + client.apiToken = newSettings.apiToken + + _pairingResult.value = "Paired successfully! Serial: $serial" + + // Fetch statuses now that we're paired + refreshSshStatus() + refreshWifiStatus() + } + is ApiResult.Error -> { + Log.e(TAG, "Pairing failed: ${result.message}") + _pairingResult.value = "Pairing failed: ${result.message}" + } + } + } catch (e: Exception) { + Log.e(TAG, "Pairing exception", e) + _pairingResult.value = "Pairing error: ${e.message}" + } finally { + _pairingInProgress.value = false + } + } + } + + /** + * Clear pairing data (for re-pairing). + */ + fun clearPairing() { + viewModelScope.launch { + store.clearPairing() + beeClient?.apiToken = "" + _sshStatus.value = null + _wifiStatus.value = null + _pairingResult.value = "Pairing cleared" + } + } + + fun clearPairingResult() { + _pairingResult.value = null + } + + // ── WiFi ────────────────────────────────────────────────────────────────── + + /** + * Refresh WiFi status from device. + */ + fun refreshWifiStatus() { + viewModelScope.launch { + val client = beeClient ?: return@launch + when (val result = client.getWifiStatus()) { + is ApiResult.Success -> { + _wifiStatus.value = result.data + } + is ApiResult.Error -> { + Log.w(TAG, "Failed to get WiFi status: ${result.message}") + } + } + } + } + + /** + * Connect device to a home WiFi network. + */ + fun connectWifi(ssid: String, password: String) { + viewModelScope.launch { + val client = beeClient ?: run { + _wifiConnectResult.value = "Not connected to device" + return@launch + } + _wifiConnectResult.value = null + + when (val result = client.setWifiConfig(ssid, password)) { + is ApiResult.Success -> { + _wifiConnectResult.value = "WiFi config sent. Connecting to $ssid..." + // Refresh status after a short delay + kotlinx.coroutines.delay(3000) + refreshWifiStatus() + } + is ApiResult.Error -> { + _wifiConnectResult.value = "Failed: ${result.message}" + } + } + } + } + + fun clearWifiResult() { + _wifiConnectResult.value = null + } + + // ── SSH ─────────────────────────────────────────────────────────────────── + + /** + * Refresh SSH status from device. + */ + fun refreshSshStatus() { + viewModelScope.launch { + val client = beeClient ?: return@launch + when (val result = client.getSshStatus()) { + is ApiResult.Success -> { + _sshStatus.value = result.data + } + is ApiResult.Error -> { + Log.w(TAG, "Failed to get SSH status: ${result.message}") + } + } + } + } + + /** + * Toggle SSH on/off on the device. + */ + fun toggleSsh(enabled: Boolean) { + viewModelScope.launch { + val client = beeClient ?: run { + _sshToggleResult.value = "Not connected to device" + return@launch + } + _sshToggleResult.value = null + + when (val result = client.setSshEnabled(enabled)) { + is ApiResult.Success -> { + _sshToggleResult.value = if (enabled) "SSH enabled" else "SSH disabled" + _sshStatus.value = SshStatus(active = enabled) + } + is ApiResult.Error -> { + _sshToggleResult.value = "Failed: ${result.message}" + // Refresh to get actual state + refreshSshStatus() + } + } + } + } + + fun clearSshResult() { + _sshToggleResult.value = null + } }