apps\iot\src\main\java\cn\nfarms\iot\MainActivity.kt¶
源码路径:
apps\iot\src\main\java\cn\nfarms\iot\MainActivity.kt· 249 行
package cn.nfarms.iot
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import cn.nfarms.core.ui.BrandErr
import cn.nfarms.core.ui.BrandOk
import cn.nfarms.core.ui.BrandWarn
import cn.nfarms.core.ui.CardBox
import cn.nfarms.core.ui.KV
import cn.nfarms.core.ui.Pill
import cn.nfarms.core.ui.Section
import cn.nfarms.core.ui.nfarmsColorScheme
import cn.nfarms.iot.device.LinkState
import cn.nfarms.iot.ota.OtaVerdict
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val container = (application as IotApp).container
setContent {
MaterialTheme(colorScheme = nfarmsColorScheme(isSystemInDarkTheme())) {
Surface(Modifier.fillMaxSize()) { Root(container) }
}
}
}
}
@Composable
private fun Root(container: Container) {
val vm: IotViewModel = viewModel(factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = IotViewModel(container) as T
})
val ui by vm.ui.collectAsStateWithLifecycle()
val snack = remember { SnackbarHostState() }
val dev = ui.dev
LaunchedEffect(dev.message) {
dev.message?.let { snack.showSnackbar(it); vm.dismissMessage() }
}
Scaffold(snackbarHost = { SnackbarHost(snack) }) { pad ->
Column(
Modifier.fillMaxSize().padding(pad).verticalScroll(rememberScrollState()).padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// ---------------- 连接 ----------------
CardBox {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Section("设备连接", "传输层:${dev.transportName}")
}
Pill(
dev.link.label,
when (dev.link) {
LinkState.Ready -> BrandOk
LinkState.Failed -> BrandErr
LinkState.Upgrading -> BrandWarn
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { vm.scan() }, enabled = dev.link != LinkState.Scanning) {
Text("扫描")
}
OutlinedButton(onClick = { vm.disconnect() }, enabled = dev.link == LinkState.Ready) {
Text("断开")
}
OutlinedButton(onClick = { vm.readStatus() }, enabled = dev.link == LinkState.Ready) {
Text("读状态")
}
}
if (dev.devices.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
for (d in dev.devices) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${d.name} (${d.rssi} dBm)", modifier = Modifier.weight(1f), fontSize = 13.sp)
Button(onClick = { vm.connect(d.id) }, enabled = dev.link != LinkState.Ready) {
Text("连接")
}
}
}
}
Spacer(Modifier.height(4.dp))
Text(
"链路统计:收到 ${dev.rxFrames} 帧 / 模拟丢弃 ${dev.droppedFrames} 帧 / 重试 ${dev.retries} 次",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ---------------- 状态与控制 ----------------
CardBox {
Section("状态", "温度 / 开关 / 模式 —— 每 2 秒轮询一次")
KV("温度", "%.1f °C".format(dev.status.temperatureC))
KV("固件版本", dev.status.version.ifBlank { "(未知)" }, valueColor = BrandOk)
KV("模式", dev.status.mode.toString())
Row(verticalAlignment = Alignment.CenterVertically) {
Text("电源", modifier = Modifier.weight(1f))
Switch(
checked = dev.status.power,
onCheckedChange = { vm.setPower(it) },
enabled = dev.link == LinkState.Ready,
)
}
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (m in 1..3) {
OutlinedButton(
onClick = { vm.setMode(m) },
enabled = dev.link == LinkState.Ready,
) { Text("模式 $m") }
}
}
}
// ---------------- OTA ----------------
CardBox {
Section("固件升级", "先验签 → 再传 → 最后生效")
val pkg = ui.pkg
if (pkg != null) {
KV("目标版本", pkg.version)
KV("机型", pkg.target)
KV("包大小", "${pkg.payloadB64.length * 3 / 4 / 1024} KB")
KV("SHA-256", pkg.sha256.take(32) + "…", mono = true)
}
if (dev.otaProgress >= 0f) {
Spacer(Modifier.height(6.dp))
LinearProgressIndicator(
progress = { dev.otaProgress },
modifier = Modifier.fillMaxWidth(),
)
}
when (val v = dev.otaVerdict) {
OtaVerdict.Ok -> {
Spacer(Modifier.height(4.dp))
Pill("验签通过", BrandOk)
}
is OtaVerdict.Rejected -> {
Spacer(Modifier.height(4.dp))
Pill("拒绝 · ${v.stage}", BrandErr)
Text(
v.why,
style = MaterialTheme.typography.bodySmall,
color = BrandErr,
)
}
null -> Unit
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { vm.startOta() }, enabled = dev.link == LinkState.Ready) {
Text("升级到 1.5.0")
}
OutlinedButton(onClick = { vm.resetGoodPackage() }) { Text("换回好包") }
}
Spacer(Modifier.height(6.dp))
Text(
"点下面三个按钮,验证「拒绝路径」真的会拒绝:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(onClick = { vm.tryBadPackage("content") }) { Text("改内容") }
OutlinedButton(onClick = { vm.tryBadPackage("key") }) { Text("换密钥签") }
OutlinedButton(onClick = { vm.tryBadPackage("downgrade") }) { Text("版本倒退") }
}
}
// ---------------- 日志 ----------------
CardBox {
Section("协议日志", "帧收发与重试都在这")
if (dev.log.isEmpty()) {
Text(
"空。先扫描并连接。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// 只显示**最后 10 行**:日志区在一个可滚动的外层 Column 里,
// 嵌套滚动不可靠(内层 ScrollState 的 maxValue 在外层还没测量完时是 0,
// 自动滚动于是滚了个空)。显示尾部既简单又永远能看见新行。
for (line in dev.log.takeLast(10)) {
Text(
line,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// ---------------- 说明 ----------------
CardBox {
Section("为什么 BLE 不放在 Rust 里", null)
Text(
"扫描/连接/写特征值全部受 Android 的权限与后台限制管辖" +
"(Android 12+ 的 BLUETOOTH_SCAN/CONNECT、Android 14 的 connectedDevice 前台服务类型、" +
"GATT 回调线程与串行写)。这些是框架的事,和协议无关。" +
"所以分界划在「字节进 / 字节出」:协议编解码、状态机、重传逻辑都在 Kotlin 里,可以脱离真机测试。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}