feat: adacam migration — update IP, pairing, bearer auth, wifi/ssh config, remove JSch and cmd

- 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)
This commit is contained in:
Kayos 2026-03-14 11:56:46 -07:00
parent 0a5ded8feb
commit 08a88f8218
5 changed files with 430 additions and 8 deletions

View file

@ -77,7 +77,7 @@ dependencies {
// WorkManager (background uploads) // WorkManager (background uploads)
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
// SSH connectivity for device_id fallback // SSH connectivity for device_id fallback
implementation("com.jcraft:jsch:0.1.55")
// QR Code scanning // QR Code scanning
implementation("com.google.zxing:core:3.5.2") implementation("com.google.zxing:core:3.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0") implementation("com.journeyapps:zxing-android-embedded:4.3.0")

View file

@ -122,6 +122,21 @@ data class BeePlugin(
@SerializedName("running") val running: Boolean? = null @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 ─────────────────────────────────────────────────── // ── Bee Settings API models ───────────────────────────────────────────────────
data class WifiClientSettings( data class WifiClientSettings(

View file

@ -10,11 +10,12 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.security.MessageDigest
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings")
data class VarroaSettings( 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 adamapsApiUrl: String = "https://api.adamaps.org",
val adamapsApiKey: String = "***REMOVED***", val adamapsApiKey: String = "***REMOVED***",
val pollIntervalSeconds: Int = 30, val pollIntervalSeconds: Int = 30,
@ -22,9 +23,23 @@ data class VarroaSettings(
val cameraRefreshSeconds: Int = 30, val cameraRefreshSeconds: Int = 30,
val forwardingEnabled: Boolean = true, val forwardingEnabled: Boolean = true,
val cachedDeviceId: String = "unknown", 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) { class SettingsDataStore(private val context: Context) {
companion object { companion object {
@ -37,11 +52,15 @@ class SettingsDataStore(private val context: Context) {
private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled") private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled")
private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id") private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id")
private val KEY_WALLET_ADDRESS = stringPreferencesKey("wallet_address") 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<VarroaSettings> = context.dataStore.data.map { prefs -> val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
VarroaSettings( 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", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org",
adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***", adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***",
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
@ -49,7 +68,10 @@ class SettingsDataStore(private val context: Context) {
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, 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", 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_FORWARDING_ENABLED] = s.forwardingEnabled
prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId
prefs[KEY_WALLET_ADDRESS] = s.walletAddress 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 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
}
}
} }

View file

@ -27,6 +27,9 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.data.VarroaSettings
import com.adamaps.varroa.ui.theme.* import com.adamaps.varroa.ui.theme.*
@ -54,6 +57,19 @@ fun SettingsScreen(
var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) } var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) }
var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) } 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 // Show snackbar on save
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(saved) { LaunchedEffect(saved) {
@ -157,15 +173,129 @@ fun SettingsScreen(
} }
} }
SettingsSection("BEE DEVICE") { SettingsSection("ADACAM DEVICE") {
SettingsField( SettingsField(
label = "Bee API URL", label = "AdaCam API URL",
value = beeApiUrl, value = beeApiUrl,
onValueChange = { beeApiUrl = it }, 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@<device-ip>" 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") { SettingsSection("ADAMAPS") {
SettingsField( SettingsField(
label = "ADAMaps API URL", label = "ADAMaps API URL",

View file

@ -1,30 +1,97 @@
package com.adamaps.varroa.viewmodel package com.adamaps.varroa.viewmodel
import android.app.Application import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.SettingsDataStore
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.data.VarroaSettings
import com.adamaps.varroa.data.WifiStatus
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SettingsViewModel(app: Application) : AndroidViewModel(app) { class SettingsViewModel(app: Application) : AndroidViewModel(app) {
companion object {
private const val TAG = "SettingsVM"
}
private val store = SettingsDataStore(app) private val store = SettingsDataStore(app)
private var beeClient: BeeApiClient? = null
val settings: StateFlow<VarroaSettings> = store.settings val settings: StateFlow<VarroaSettings> = store.settings
.stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings()) .stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings())
// Derived observables from settings
val isPaired: StateFlow<Boolean> = store.settings
.map { it.isPaired }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val deviceSerial: StateFlow<String> = store.settings
.map { it.deviceSerial }
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
private val _saved = MutableStateFlow(false) private val _saved = MutableStateFlow(false)
val saved: StateFlow<Boolean> = _saved.asStateFlow() val saved: StateFlow<Boolean> = _saved.asStateFlow()
// Pairing state
private val _pairingInProgress = MutableStateFlow(false)
val pairingInProgress: StateFlow<Boolean> = _pairingInProgress.asStateFlow()
private val _pairingResult = MutableStateFlow<String?>(null)
val pairingResult: StateFlow<String?> = _pairingResult.asStateFlow()
// SSH state
private val _sshStatus = MutableStateFlow<SshStatus?>(null)
val sshStatus: StateFlow<SshStatus?> = _sshStatus.asStateFlow()
private val _sshToggleResult = MutableStateFlow<String?>(null)
val sshToggleResult: StateFlow<String?> = _sshToggleResult.asStateFlow()
// WiFi state
private val _wifiStatus = MutableStateFlow<WifiStatus?>(null)
val wifiStatus: StateFlow<WifiStatus?> = _wifiStatus.asStateFlow()
private val _wifiConnectResult = MutableStateFlow<String?>(null)
val wifiConnectResult: StateFlow<String?> = _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) { fun save(s: VarroaSettings) {
viewModelScope.launch { viewModelScope.launch {
store.save(s) 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 _saved.value = true
} }
} }
@ -32,4 +99,165 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun clearSaved() { fun clearSaved() {
_saved.value = false _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
}
} }