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 60d2f693d1
commit d11f6b62d1
5 changed files with 430 additions and 8 deletions

View file

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

View file

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

View file

@ -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<Preferences> 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<VarroaSettings> = 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
}
}
}

View file

@ -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@<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") {
SettingsField(
label = "ADAMaps API URL",

View file

@ -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<VarroaSettings> = store.settings
.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)
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) {
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
}
}