diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..ea8dfe7 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +app/build/ diff --git a/app/BUILD_STATUS.txt b/app/BUILD_STATUS.txt new file mode 100644 index 0000000..73bb222 --- /dev/null +++ b/app/BUILD_STATUS.txt @@ -0,0 +1,6 @@ +FallGuard project status + +Source project: created. +Local APK build: NOT run in this environment because Android SDK / Gradle are not installed and outbound DNS/network access is unavailable. +APK build workflow: .github/workflows/android.yml +Expected debug APK output after a successful build: app/build/outputs/apk/debug/app-debug.apk diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts new file mode 100644 index 0000000..c2375b6 --- /dev/null +++ b/app/app/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.zhouyi.elife" + compileSdk = 35 + + defaultConfig { + applicationId = "com.zhouyi.elife" + minSdk = 24 + targetSdk = 35 + versionCode = 2 + versionName = "1.1.0" + } + + buildTypes { + debug { + // Keep the requested package name com.zhouyi.elife even for debug APK. + versionNameSuffix = "-debug" + } + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.10.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro new file mode 100644 index 0000000..8b35c5f --- /dev/null +++ b/app/app/proguard-rules.pro @@ -0,0 +1 @@ +# No custom ProGuard rules are required for the current app. diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1a1dc35 --- /dev/null +++ b/app/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/app/app/src/main/java/com/zhouyi/elife/MainActivity.kt b/app/app/src/main/java/com/zhouyi/elife/MainActivity.kt new file mode 100644 index 0000000..def2ae0 --- /dev/null +++ b/app/app/src/main/java/com/zhouyi/elife/MainActivity.kt @@ -0,0 +1,2710 @@ +package com.zhouyi.elife + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.os.Build +import android.os.Bundle +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +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.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Air +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.DoorFront +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Thermostat +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// ───────────────────────── 配色 ───────────────────────── +private val PageBackground = Color(0xFFF3F6FC) +private val Ink = Color(0xFF19243B) +private val Muted = Color(0xFF78849A) +private val Blue = Color(0xFF526DFF) +private val Purple = Color(0xFF8B61E8) +private val Green = Color(0xFF159568) +private val Red = Color(0xFFE34D59) +private val Amber = Color(0xFFC07A15) +private val Hairline = Color(0xFFE8ECF5) +private val BrandGradient = listOf(Color(0xFF6D83FF), Color(0xFF4659E8)) + +private const val DEFAULT_SYSTEM_PROMPT = + "你是「智能生活」家居助手,运行在用户手机上,可以通过工具读取设备状态、控制门上灯/室内灯/风扇、修改自动化规则。" + + "始终使用简体中文,表达自然、简短、准确。需要实时数据时先调用 get_device_status,不得编造设备状态。" + + "只有用户明确提出控制或修改要求时才调用对应控制工具;执行后根据工具返回结果说明实际执行情况。" + + "当工具权限关闭时只能查询状态,不得声称已经控制设备。心率是模拟数据,不是医疗测量结果。" + + "设备报告疑似摔倒时,优先提醒用户立即确认人员情况;只有用户明确要求清除警报且确认安全时,才调用 clear_fall_alarm。" + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + createFallNotificationChannel() + + setContent { + MaterialTheme( + colorScheme = lightColorScheme( + primary = Blue, + secondary = Color(0xFF748BFF), + background = PageBackground, + surface = Color.White, + onPrimary = Color.White, + onBackground = Ink, + onSurface = Ink + ) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = PageBackground + ) { + SmartLifeScreen( + onFallDetected = { fallStatus -> + showFallSystemNotification(fallStatus) + } + ) + } + } + } + + // Android 13+ requires explicit notification permission. + if (Build.VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_NOTIFICATION_PERMISSION) + } + } + + private fun createFallNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + FALL_NOTIFICATION_CHANNEL_ID, + "摔倒警报", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "智能生活检测到疑似摔倒时发出提醒" + enableVibration(true) + vibrationPattern = longArrayOf(0L, 500L, 250L, 500L, 250L, 700L) + setSound( + android.provider.Settings.System.DEFAULT_NOTIFICATION_URI, + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + } + getSystemService(NotificationManager::class.java) + ?.createNotificationChannel(channel) + } + } + + private fun showFallSystemNotification(status: SmartStatus) { + if (Build.VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } + + val openAppIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + FALL_NOTIFICATION_ID, + openAppIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val detail = "设备检测到疑似摔倒,请立即确认人员情况。累计记录:${status.fallCount} 次。" + val notification = NotificationCompat.Builder(this, FALL_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentTitle("智能生活:检测到摔倒!") + .setContentText("请立即确认人员情况。") + .setStyle(NotificationCompat.BigTextStyle().bigText(detail)) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setAutoCancel(true) + .setOnlyAlertOnce(false) + .setContentIntent(pendingIntent) + .build() + + try { + getSystemService(NotificationManager::class.java) + ?.notify(FALL_NOTIFICATION_ID, notification) + } catch (_: SecurityException) { + // The notification permission may be disabled by the user or system. + } + } + + companion object { + private const val REQUEST_NOTIFICATION_PERMISSION = 4701 + private const val FALL_NOTIFICATION_CHANNEL_ID = "smartlife_fall_alerts" + private const val FALL_NOTIFICATION_ID = 1001 + } +} + +// ───────────────────────── 数据模型 ───────────────────────── +private data class SmartStatus( + val temperature: Double? = null, + val humidity: Double? = null, + val pir: Boolean = false, + val doorLight: Boolean = false, + val roomLight: Boolean = false, + val fan: Boolean = false, + val fanAuto: Boolean = true, + val pirAuto: Boolean = true, + val fanTempThreshold: Float = 28f, + val fanHumidityThreshold: Float = 75f, + val lightTimeout: Int = 30, + val doorTimeout: Int = 8, + val rfidLast: String = "—", + val rfidAuthorized: Boolean = false, + val sensorOk: Boolean = false, + val fanAvailable: Boolean = false, + val roomLightAvailable: Boolean = false, + val pinStatus: String = "等待读取设备状态", + val uptime: Long = 0, + val fallen: Boolean = false, + val fallCount: Int = 0, + val fallSource: String = "", + val heartRateBpm: Int? = null, + val heartRateSimulated: Boolean = true, + val slaveConnected: Boolean = false, + val slaveIp: String = "", + val slaveTransport: String = "UDP/Wi-Fi", + val slaveFallAlarm: Boolean = false, + val slaveFallCount: Int = 0, + val wifiMode: String = "STA", + val ipAddress: String = "", + val apSsid: String = "QHZXManagement", + val apPassword: String = "QHZX2026" +) + +private data class LocalEvent( + val time: String, + val title: String, + val detail: String, + val alert: Boolean +) + +/** role: user / assistant / tool / error */ +private data class ChatMsg(val role: String, val text: String) + +private data class AiConfig( + val baseUrl: String = "https://api.openai.com/v1", + val apiKey: String = "", + val model: String = "gpt-4o-mini", + val systemPrompt: String = DEFAULT_SYSTEM_PROMPT, + val allowControl: Boolean = true +) + +private enum class AppTab(val label: String, val icon: ImageVector) { + Home("首页", Icons.Filled.Home), + Auto("自动化", Icons.Filled.Tune), + Ai("AI 管家", Icons.Filled.AutoAwesome), + Settings("设置", Icons.Filled.Settings) +} + +// ───────────────────────── 配置持久化 ───────────────────────── +private class AppPrefs(context: Context) { + private val sp = context.getSharedPreferences("smartlife_prefs", Context.MODE_PRIVATE) + private val keyAlias = "smartlife_ai_key_aes_gcm" + + var host: String + get() = sp.getString("host", "").orEmpty() + set(v) { sp.edit().putString("host", v).apply() } + + var autoConnect: Boolean + get() = sp.getBoolean("auto_connect", false) + set(v) { sp.edit().putBoolean("auto_connect", v).apply() } + + fun loadAi(): AiConfig { + val d = AiConfig() + val storedKey = sp.getString("ai_api_key", "").orEmpty() + val apiKey = decryptApiKey(storedKey) + // 兼容旧版本明文配置:首次读取后尝试迁移到 Android Keystore 加密格式。 + if (storedKey.isNotBlank() && !storedKey.startsWith("enc:") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + sp.edit().putString("ai_api_key", encryptApiKey(storedKey)).apply() + } + return AiConfig( + baseUrl = sp.getString("ai_base_url", d.baseUrl).orEmpty().ifBlank { d.baseUrl }, + apiKey = apiKey, + model = sp.getString("ai_model", d.model).orEmpty().ifBlank { d.model }, + systemPrompt = sp.getString("ai_system_prompt", d.systemPrompt).orEmpty().ifBlank { d.systemPrompt }, + allowControl = sp.getBoolean("ai_allow_control", true) + ) + } + + fun saveAi(c: AiConfig) { + sp.edit() + .putString("ai_base_url", c.baseUrl.trim()) + .putString("ai_api_key", encryptApiKey(c.apiKey.trim())) + .putString("ai_model", c.model.trim()) + .putString("ai_system_prompt", c.systemPrompt) + .putBoolean("ai_allow_control", c.allowControl) + .apply() + } + + private fun encryptApiKey(value: String): String { + if (value.isBlank() || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return value + return try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val payload = cipher.iv + encrypted + "enc:" + Base64.encodeToString(payload, Base64.NO_WRAP) + } catch (_: Exception) { + value + } + } + + private fun decryptApiKey(value: String): String { + if (!value.startsWith("enc:")) return value + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return "" + return try { + val payload = Base64.decode(value.removePrefix("enc:"), Base64.NO_WRAP) + if (payload.size <= 12) return "" + val iv = payload.copyOfRange(0, 12) + val encrypted = payload.copyOfRange(12, payload.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(128, iv)) + String(cipher.doFinal(encrypted), Charsets.UTF_8) + } catch (_: Exception) { + "" + } + } + + private fun getOrCreateSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val existing = keyStore.getKey(keyAlias, null) as? SecretKey + if (existing != null) return existing + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + generator.init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build() + ) + return generator.generateKey() + } + + fun clearAll() { + sp.edit().clear().apply() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + deleteEntry(keyAlias) + } + } catch (_: Exception) { + // 清理偏好设置成功即可;Keystore 清理失败不影响应用继续运行。 + } + } + } +} + +// ───────────────────────── 主界面 ───────────────────────── +@Composable +private fun SmartLifeScreen( + onFallDetected: (SmartStatus) -> Unit +) { + val context = LocalContext.current + val prefs = remember { AppPrefs(context.applicationContext) } + + var tabIndex by rememberSaveable { mutableStateOf(0) } + var host by rememberSaveable { mutableStateOf(prefs.host) } + var showFallDialog by rememberSaveable { mutableStateOf(false) } + var lastObservedFallen by remember { mutableStateOf(false) } + var monitoring by rememberSaveable { mutableStateOf(prefs.autoConnect && prefs.host.isNotBlank()) } + var connected by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(SmartStatus()) } + var errorMessage by remember { mutableStateOf(null) } + var infoMessage by remember { mutableStateOf("填写设备 IP 地址以连接智能生活主控") } + var lastUpdated by remember { mutableStateOf("") } + var refreshToken by remember { mutableIntStateOf(0) } + var configLoadedHost by remember { mutableStateOf("") } + + var pirAuto by rememberSaveable { mutableStateOf(true) } + var fanAuto by rememberSaveable { mutableStateOf(true) } + var fanTempThreshold by rememberSaveable { mutableStateOf(28f) } + var fanHumidityThreshold by rememberSaveable { mutableStateOf(75f) } + var settingsSaving by remember { mutableStateOf(false) } + + var aiConfig by remember { mutableStateOf(prefs.loadAi()) } + var aiBusy by remember { mutableStateOf(false) } + var aiTesting by remember { mutableStateOf(false) } + var aiTestMessage by remember { mutableStateOf("") } + var showClearDialog by remember { mutableStateOf(false) } + val chat = remember { mutableStateListOf() } + val apiMessages = remember { mutableListOf() } + + val history = remember { mutableStateListOf() } + val scope = rememberCoroutineScope() + val normalizedHost = remember(host) { + host.trim() + .removePrefix("http://") + .removePrefix("https://") + .trimEnd('/') + } + + // ── 设备状态轮询 ── + LaunchedEffect(monitoring, normalizedHost, refreshToken) { + if (!monitoring || normalizedHost.isBlank()) { + connected = false + return@LaunchedEffect + } + + while (isActive) { + try { + val json = withContext(Dispatchers.IO) { + SmartLifeApi.getJson(normalizedHost, "/api/status") + } + val fresh = SmartLifeApi.parseStatus(json) + val wasFallen = lastObservedFallen + status = fresh + connected = true + errorMessage = null + infoMessage = "设备数据同步正常" + lastUpdated = nowClock() + + if (configLoadedHost != normalizedHost) { + pirAuto = fresh.pirAuto + fanAuto = fresh.fanAuto + fanTempThreshold = fresh.fanTempThreshold + fanHumidityThreshold = fresh.fanHumidityThreshold + configLoadedHost = normalizedHost + } + + if (fresh.fallen && !wasFallen) { + showFallDialog = true + onFallDetected(fresh) + addHistory( + history, + LocalEvent(nowClock(), "检测到疑似摔倒", "主控设备报告摔倒状态,请及时确认人员情况。", true) + ) + } else if (!fresh.fallen && wasFallen) { + addHistory( + history, + LocalEvent(nowClock(), "摔倒警报已清除", "设备当前已清除摔倒标记。", false) + ) + } + lastObservedFallen = fresh.fallen + } catch (e: Exception) { + connected = false + errorMessage = when (e) { + is java.net.SocketTimeoutException -> "连接超时,请检查 Wi-Fi 和设备 IP。" + else -> "暂时无法连接,请确认主控已运行并连接同一 Wi-Fi。" + } + infoMessage = "正在等待设备响应" + } + delay(if (connected) 1_000L else 2_500L) + } + } + + fun runDeviceRequest(path: String, successMessage: String) { + if (normalizedHost.isBlank()) { + errorMessage = "请先填写设备 IP 地址。" + return + } + scope.launch { + try { + settingsSaving = true + val json = withContext(Dispatchers.IO) { + SmartLifeApi.getJson(normalizedHost, path) + } + val fresh = SmartLifeApi.parseStatus(json) + status = fresh + if (path == "/api/reset") { + lastObservedFallen = fresh.fallen + showFallDialog = false + } + connected = true + errorMessage = null + infoMessage = successMessage + lastUpdated = nowClock() + refreshToken++ + } catch (_: Exception) { + connected = false + errorMessage = "操作没有完成,请检查设备连接后重试。" + } finally { + settingsSaving = false + } + } + } + + fun toggleDevice(device: String, enabled: Boolean) { + runDeviceRequest( + "/api/control?device=$device&state=${if (enabled) 1 else 0}", + "设备控制指令已发送" + ) + if (device == "fan") { + fanAuto = false + } else if (device == "room_light") { + pirAuto = false + } + } + + fun saveAutomation() { + val temperature = String.format(Locale.US, "%.1f", fanTempThreshold) + val humidity = String.format(Locale.US, "%.1f", fanHumidityThreshold) + val lt = status.lightTimeout.takeIf { it > 0 } ?: 30 + val dt = status.doorTimeout.takeIf { it > 0 } ?: 8 + val path = "/api/settings?pir_auto=${if (pirAuto) 1 else 0}" + + "&fan_auto=${if (fanAuto) 1 else 0}" + + "&fan_temp=$temperature" + + "&fan_humidity=$humidity&light_timeout=$lt&door_timeout=$dt" + runDeviceRequest(path, "自动化规则已保存") + configLoadedHost = normalizedHost + } + + // ── AI 工具执行 ── + suspend fun callDevice(path: String): SmartStatus { + val json = withContext(Dispatchers.IO) { SmartLifeApi.getJson(normalizedHost, path) } + val fresh = SmartLifeApi.parseStatus(json) + status = fresh + connected = true + errorMessage = null + lastUpdated = nowClock() + return fresh + } + + suspend fun executeAiTool(name: String, args: JSONObject): String { + if (normalizedHost.isBlank()) return toolResult(false, "尚未配置设备 IP,无法访问设备。", null) + if (!aiConfig.allowControl && name != "get_device_status") { + return toolResult(false, "AI 控制权限当前已关闭,只允许读取设备状态。", null) + } + return try { + when (name) { + "get_device_status" -> { + val s = callDevice("/api/status") + toolResult(true, "已读取最新状态", s) + } + "control_device" -> { + val device = args.optString("device") + if (device !in listOf("door_light", "room_light", "fan")) { + toolResult(false, "未知设备:$device", null) + } else { + val on = args.optBoolean("state", false) + val s = callDevice("/api/control?device=$device&state=${if (on) 1 else 0}") + if (device == "fan") fanAuto = false + if (device == "room_light") pirAuto = false + addHistory(history, LocalEvent(nowClock(), "AI 控制设备", "${deviceName(device)} → ${if (on) "开启" else "关闭"}", false)) + toolResult(true, "指令已发送:${deviceName(device)} ${if (on) "开启" else "关闭"}", s) + } + } + "update_automation" -> { + // 先读取设备端最新规则,避免只修改一个字段时覆盖其他字段。 + val current = callDevice("/api/status") + val p = if (args.has("pir_auto")) args.optBoolean("pir_auto") else current.pirAuto + val f = if (args.has("fan_auto")) args.optBoolean("fan_auto") else current.fanAuto + val t = (if (args.has("fan_temp_threshold")) args.optDouble("fan_temp_threshold") else current.fanTempThreshold.toDouble()) + .coerceIn(18.0, 35.0) + val h = (if (args.has("fan_humidity_threshold")) args.optDouble("fan_humidity_threshold") else current.fanHumidityThreshold.toDouble()) + .coerceIn(40.0, 90.0) + val lt = current.lightTimeout.takeIf { it > 0 } ?: 30 + val dt = current.doorTimeout.takeIf { it > 0 } ?: 8 + val path = "/api/settings?pir_auto=${if (p) 1 else 0}&fan_auto=${if (f) 1 else 0}" + + "&fan_temp=${String.format(Locale.US, "%.1f", t)}" + + "&fan_humidity=${String.format(Locale.US, "%.1f", h)}" + + "&light_timeout=$lt&door_timeout=$dt" + val s = callDevice(path) + pirAuto = s.pirAuto + fanAuto = s.fanAuto + fanTempThreshold = s.fanTempThreshold + fanHumidityThreshold = s.fanHumidityThreshold + configLoadedHost = normalizedHost + addHistory(history, LocalEvent(nowClock(), "AI 更新自动化", "人体感应=${if (p) "开" else "关"},风扇自动=${if (f) "开" else "关"},${t.toInt()}°C / ${h.toInt()}%", false)) + toolResult(true, "自动化规则已更新", s) + } + "clear_fall_alarm" -> { + val current = callDevice("/api/status") + if (!current.fallen) { + toolResult(true, "设备当前没有待清除的摔倒标记", current) + } else { + val s = callDevice("/api/reset") + lastObservedFallen = s.fallen + showFallDialog = false + addHistory(history, LocalEvent(nowClock(), "AI 清除摔倒警报", "已按用户要求清除设备上的摔倒标记。", false)) + toolResult(true, "摔倒标记已清除", s) + } + } + else -> toolResult(false, "未知工具:$name", null) + } + } catch (e: Exception) { + toolResult(false, "设备请求失败:${e.message ?: "未知错误"}", null) + } + } + + fun sendChat(text: String) { + val input = text.trim() + if (aiBusy || input.isEmpty()) return + val cfg = aiConfig + if (cfg.apiKey.isBlank()) { + chat.add(ChatMsg("error", "还没有填写 API Key,请先到「设置」页配置。")) + return + } + if (cfg.baseUrl.isBlank() || cfg.model.isBlank()) { + chat.add(ChatMsg("error", "请先检查 AI 接口地址和模型名称。")) + return + } + + val previousMessageCount = apiMessages.size + chat.add(ChatMsg("user", input)) + apiMessages.add(JSONObject().put("role", "user").put("content", input)) + aiBusy = true + scope.launch { + try { + val toolsArr = aiTools(cfg.allowControl) + var toolRounds = 0 + var finished = false + + while (!finished) { + val systemContent = buildString { + append(cfg.systemPrompt) + append("\n\n当前时间:").append(nowClock()) + append("\n设备连接状态:").append(if (connected) "在线" else "未连接") + append("\n最近一次设备状态(可能已过期,需要实时数据时调用 get_device_status):") + append(if (connected) status.toAiJson() else "设备未连接或尚未读取") + append("\n工具权限:").append( + if (cfg.allowControl) "允许查询和控制" else "仅允许查询设备状态,不允许控制或修改规则" + ) + } + + val msgs = JSONArray() + msgs.put(JSONObject().put("role", "system").put("content", systemContent)) + apiMessages.forEach { msgs.put(it) } + + val toolsNow = if (toolRounds < 6) toolsArr else null + val reply = withContext(Dispatchers.IO) { + AiClient.chat(cfg, msgs, toolsNow) + } + apiMessages.add(reply) + + val calls = reply.optJSONArray("tool_calls") + if (calls != null && calls.length() > 0) { + if (toolRounds >= 6) { + chat.add(ChatMsg("error", "本轮工具调用次数已达到安全上限,请把任务拆分后再试。")) + finished = true + continue + } + toolRounds++ + for (i in 0 until calls.length()) { + val call = calls.getJSONObject(i) + val fn = call.optJSONObject("function") ?: JSONObject() + val name = fn.optString("name") + val args = try { + JSONObject(fn.optString("arguments", "{}").ifBlank { "{}" }) + } catch (_: Exception) { + JSONObject() + } + chat.add(ChatMsg("tool", "正在执行:${toolLabel(name, args)}")) + val result = executeAiTool(name, args) + apiMessages.add( + JSONObject() + .put("role", "tool") + .put("tool_call_id", call.optString("id")) + .put("content", result) + ) + } + } else { + val content = if (reply.isNull("content")) "" else reply.optString("content") + chat.add(ChatMsg("assistant", content.ifBlank { "(已完成)" })) + finished = true + } + } + } catch (e: Exception) { + // 保留本地可见的用户消息,但回滚未完成的 API 工具上下文,避免污染下一轮请求。 + while (apiMessages.size > previousMessageCount) { + apiMessages.removeAt(apiMessages.lastIndex) + } + val detail = e.message?.take(220).orEmpty().ifBlank { "请检查网络、API Key、接口地址和模型是否支持工具调用。" } + chat.add(ChatMsg("error", "AI 请求没有完成:$detail")) + } finally { + while (apiMessages.size > 40) apiMessages.removeAt(0) + while (apiMessages.isNotEmpty() && apiMessages[0].optString("role") != "user") { + apiMessages.removeAt(0) + } + aiBusy = false + } + } + } + + fun testAiConnection(candidate: AiConfig) { + if (candidate.apiKey.isBlank()) { + aiTestMessage = "请先填写 API Key。" + return + } + if (candidate.baseUrl.isBlank() || candidate.model.isBlank()) { + aiTestMessage = "接口地址和模型名称不能为空。" + return + } + if (aiTesting) return + aiTesting = true + aiTestMessage = "正在测试当前配置……" + scope.launch { + try { + val messages = JSONArray() + .put(JSONObject().put("role", "system").put("content", "你是连接测试助手。请只回复:连接成功。")) + .put(JSONObject().put("role", "user").put("content", "连接测试")) + val reply = withContext(Dispatchers.IO) { + AiClient.chat(candidate, messages, null) + } + val content = reply.optString("content").ifBlank { "服务端已响应" } + aiTestMessage = "连接成功:${content.take(70)}" + } catch (e: Exception) { + aiTestMessage = "连接失败:${e.message?.take(150) ?: "请检查配置和网络"}" + } finally { + aiTesting = false + } + } + } + + // ── 页面骨架 ── + val imeOpen = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + + Scaffold( + containerColor = PageBackground, + bottomBar = { + if (!(tabIndex == AppTab.Ai.ordinal && imeOpen)) { + BottomBar(selected = tabIndex, onSelect = { tabIndex = it }) + } + } + ) { inner -> + Box(modifier = Modifier.fillMaxSize().padding(inner)) { + when (tabIndex) { + 0 -> ScrollPage { + AppHeader(connected = connected, monitoring = monitoring) + OverviewCard( + connected = connected, + status = status, + updatedAt = lastUpdated, + onRefresh = { if (monitoring) refreshToken++ } + ) + if (status.fallen || status.fallCount > 0) { + SafetyCard( + fallen = status.fallen, + count = status.fallCount, + connected = connected, + onReset = { runDeviceRequest("/api/reset", "摔倒状态已清除") } + ) + } + EnvironmentCard(status = status) + QuickControlRow( + status = status, + connected = connected, + onToggle = { device, on -> toggleDevice(device, on) } + ) + HeartRateCard(status = status) + AssistantEntryCard( + configured = aiConfig.apiKey.isNotBlank(), + controlEnabled = aiConfig.allowControl, + connected = connected, + onClick = { tabIndex = AppTab.Ai.ordinal } + ) + RfidCard(status = status) + if (status.pinStatus.isNotBlank()) { + val warn = status.pinStatus.contains("不可用") || status.pinStatus.contains("失败") + NoticeCard( + title = if (warn) "硬件引脚提示" else "设备状态", + message = status.pinStatus, + warning = warn + ) + } + FooterText() + } + + 1 -> ScrollPage { + PageTitle("自动化", "设备手动控制与自动化规则") + AutomationSummaryCard( + connected = connected, + pirAuto = pirAuto, + fanAuto = fanAuto, + temperatureThreshold = fanTempThreshold, + humidityThreshold = fanHumidityThreshold + ) + if (!connected) { + NoticeCard( + title = "设备未连接", + message = "连接主控后才能控制设备和保存规则,请到「设置」页填写设备 IP。", + warning = true + ) + } + AutomationCard( + connected = connected, + pirAuto = pirAuto, + onPirAutoChange = { pirAuto = it }, + fanAuto = fanAuto, + onFanAutoChange = { fanAuto = it }, + temperatureThreshold = fanTempThreshold, + onTemperatureThresholdChange = { fanTempThreshold = it }, + humidityThreshold = fanHumidityThreshold, + onHumidityThresholdChange = { fanHumidityThreshold = it }, + lightTimeout = status.lightTimeout, + saving = settingsSaving, + onSave = { saveAutomation() } + ) + SectionHeading("设备控制", "手动操作会退出对应设备的自动模式") + DeviceControlCard( + title = "门上灯", + description = if (status.rfidAuthorized) "已识别授权卡 · 自动点亮" else "P7 · 授权 UID 匹配时自动点亮", + icon = Icons.Filled.DoorFront, + accent = Purple, + checked = status.doorLight, + enabled = connected, + onCheckedChange = { toggleDevice("door_light", it) } + ) + DeviceControlCard( + title = "室内照明总开关", + description = if (status.roomLightAvailable) "P13 / P14 两路联动 · 一键同时开关" else "灯组引脚未全部就绪,请查看硬件提示", + icon = Icons.Filled.Lightbulb, + accent = Blue, + checked = status.roomLight, + enabled = connected && status.roomLightAvailable, + onCheckedChange = { toggleDevice("room_light", it) } + ) + DeviceControlCard( + title = "风扇 / 电机", + description = when { + !status.fanAvailable -> "P5/P6 电机输出当前不可用,请查看引脚提示" + status.fanAuto -> "自动模式 · 达到温湿度条件时启动" + else -> "手动模式 · 点击开关控制" + }, + icon = Icons.Filled.Air, + accent = Color(0xFF138FBA), + checked = status.fan, + enabled = connected && status.fanAvailable, + onCheckedChange = { toggleDevice("fan", it) }, + trailingText = if (status.fanAuto) "自动" else "手动" + ) + HistoryCard(history = history) + FooterText() + } + + 2 -> AiPage( + config = aiConfig, + chat = chat, + busy = aiBusy, + connected = connected, + onSend = { sendChat(it) }, + onClear = { chat.clear(); apiMessages.clear() }, + onGoSettings = { tabIndex = AppTab.Settings.ordinal } + ) + + else -> ScrollPage { + PageTitle("设置", "连接你的设备,配置 AI 服务与应用偏好") + SectionHeading("设备连接", "填写主控 IP,应用会按设置自动同步状态") + ConnectionCard( + host = host, + onHostChange = { host = it; prefs.host = it }, + monitoring = monitoring, + connected = connected, + infoMessage = infoMessage, + errorMessage = errorMessage, + onConnect = { + configLoadedHost = "" + monitoring = true + prefs.autoConnect = true + connected = false + errorMessage = null + infoMessage = "正在连接智能生活主控……" + refreshToken++ + }, + onPause = { + monitoring = false + prefs.autoConnect = false + connected = false + infoMessage = "已暂停轮询" + }, + onRefresh = { refreshToken++ }, + wifiMode = status.wifiMode, + apSsid = status.apSsid, + apPassword = status.apPassword, + apAddress = if (status.ipAddress.isNotBlank() && status.wifiMode == "AP") status.ipAddress else "192.168.4.1", + onUseApAddress = { + val addr = if (status.ipAddress.isNotBlank() && status.wifiMode == "AP") status.ipAddress else "192.168.4.1" + host = addr + prefs.host = addr + } + ) + SectionHeading("AI 服务", "自定义 API Key、模型与工具权限") + AiConfigCard( + config = aiConfig, + testing = aiTesting, + testMessage = aiTestMessage, + onTest = { candidate -> testAiConnection(candidate) }, + onSave = { newCfg -> + prefs.saveAi(newCfg) + aiConfig = newCfg + aiTestMessage = "配置已保存到本机。" + } + ) + SectionHeading("数据管理", "清除本机保存的连接和 AI 配置") + DataCard(onClear = { showClearDialog = true }) + FooterText() + } + } + } + } + + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { Text("清除所有本地配置?", fontWeight = FontWeight.Bold) }, + text = { Text("将删除已保存的设备 IP、API Key 和 AI 设置,并清空聊天记录。设备上的规则不受影响。") }, + confirmButton = { + Button( + onClick = { + prefs.clearAll() + host = "" + monitoring = false + connected = false + aiConfig = AiConfig() + aiTestMessage = "" + chat.clear() + apiMessages.clear() + showClearDialog = false + }, + colors = ButtonDefaults.buttonColors(containerColor = Red) + ) { Text("清除") } + }, + dismissButton = { + TextButton(onClick = { showClearDialog = false }) { Text("取消", color = Muted) } + } + ) + } + + if (showFallDialog) { + AlertDialog( + onDismissRequest = { showFallDialog = false }, + icon = { + Icon( + imageVector = Icons.Filled.WarningAmber, + contentDescription = null, + tint = Red, + modifier = Modifier.size(42.dp) + ) + }, + title = { + Text(text = "检测到疑似摔倒!", color = Red, fontWeight = FontWeight.Bold) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("智能生活主机/从机已上报疑似摔倒状态。请立即查看人员情况,确认是否需要帮助。") + Text(text = "累计记录:${status.fallCount} 次", color = Muted, fontSize = 13.sp) + if (status.fallSource.isNotBlank()) { + Text("报警来源:${status.fallSource}", color = Muted, fontSize = 12.sp) + } + Text( + text = "系统通知也已尝试发送。关闭此弹窗不会清除设备上的摔倒状态。", + color = Muted, + fontSize = 12.sp + ) + } + }, + confirmButton = { + Button( + onClick = { showFallDialog = false }, + colors = ButtonDefaults.buttonColors(containerColor = Red) + ) { Text("我知道了,立即确认") } + }, + dismissButton = { + TextButton(onClick = { showFallDialog = false }) { Text("稍后处理", color = Muted) } + } + ) + } +} + +// ───────────────────────── 通用组件 ───────────────────────── +@Composable +private fun ScrollPage(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content + ) +} + +@Composable +private fun BottomBar(selected: Int, onSelect: (Int) -> Unit) { + NavigationBar(containerColor = Color.White, tonalElevation = 8.dp) { + AppTab.values().forEachIndexed { index, tab -> + NavigationBarItem( + selected = index == selected, + onClick = { onSelect(index) }, + icon = { Icon(tab.icon, contentDescription = tab.label, modifier = Modifier.size(23.dp)) }, + label = { Text(tab.label, fontSize = 11.sp, fontWeight = FontWeight.Medium) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Blue, + selectedTextColor = Blue, + indicatorColor = Color(0xFFE8EDFF), + unselectedIconColor = Muted, + unselectedTextColor = Muted + ) + ) + } + } +} + +@Composable +private fun AppCard( + modifier: Modifier = Modifier, + containerColor: Color = Color.White, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(25.dp), + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke(1.dp, Hairline), + content = content + ) +} + +@Composable +private fun CardTitle( + icon: ImageVector, + accent: Color, + title: String, + subtitle: String, + trailing: (@Composable () -> Unit)? = null +) { + Row(verticalAlignment = Alignment.CenterVertically) { + MiniIcon(icon, accent) + Spacer(Modifier.width(11.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, color = Ink, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text(subtitle, color = Muted, fontSize = 11.sp) + } + trailing?.invoke() + } +} + +@Composable +private fun PageTitle(title: String, subtitle: String) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(top = 4.dp)) { + Text(title, color = Ink, fontSize = 26.sp, fontWeight = FontWeight.Bold) + Text(subtitle, color = Muted, fontSize = 13.sp) + } +} + +@Composable +private fun SectionHeading(title: String, subtitle: String) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Text(subtitle, color = Muted, fontSize = 12.sp) + } +} + +@Composable +private fun MiniIcon(icon: ImageVector, tint: Color) { + Box( + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(13.dp)).background(tint.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp)) + } +} + +@Composable +private fun FooterText() { + Text( + text = "智能生活 · 局域网控制 · 设备状态每秒同步", + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + textAlign = TextAlign.Center, + color = Muted, + fontSize = 11.sp + ) +} + +@Composable +private fun AppHeader(connected: Boolean, monitoring: Boolean) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(50.dp) + .clip(RoundedCornerShape(17.dp)) + .background(Brush.linearGradient(BrandGradient)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.Home, contentDescription = null, tint = Color.White, modifier = Modifier.size(27.dp)) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text("智能生活", color = Ink, fontSize = 23.sp, fontWeight = FontWeight.Bold) + Text("Smart Life · 家居自动化", color = Muted, fontSize = 12.sp) + } + Surface( + shape = CircleShape, + color = if (connected) Color(0xFFE5F6ED) else Color(0xFFE9EDF5) + ) { + Row( + modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(7.dp).clip(CircleShape).background( + if (connected) Green else Color(0xFF929DB0) + ) + ) + Spacer(Modifier.width(6.dp)) + Text( + when { connected -> "在线"; monitoring -> "重连中"; else -> "未连接" }, + color = if (connected) Green else Muted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} + +// ───────────────────────── 首页 ───────────────────────── +@Composable +private fun OverviewCard( + connected: Boolean, + status: SmartStatus, + updatedAt: String, + onRefresh: () -> Unit +) { + val gradient = when { + status.fallen -> listOf(Color(0xFFEC5967), Color(0xFFC82F4A)) + connected -> listOf(Color(0xFF5277FF), Color(0xFF7C5CE7)) + else -> listOf(Color(0xFF8190AA), Color(0xFF5D6A80)) + } + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Box(modifier = Modifier.fillMaxWidth().background(Brush.linearGradient(gradient))) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 46.dp, y = (-46).dp) + .size(150.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.09f)) + ) + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .offset(x = (-36).dp, y = 40.dp) + .size(110.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.07f)) + ) + Column( + modifier = Modifier.fillMaxWidth().padding(21.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface(color = Color.White.copy(alpha = 0.18f), shape = RoundedCornerShape(15.dp)) { + Box(modifier = Modifier.padding(12.dp), contentAlignment = Alignment.Center) { + Icon( + if (status.fallen) Icons.Filled.WarningAmber else Icons.Filled.CheckCircle, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(29.dp) + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + when { + status.fallen -> "安全警报" + connected && status.pir -> "检测到人员活动" + connected -> "家居系统运行中" + else -> "等待主控连接" + }, + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(3.dp)) + Text( + when { + status.fallen -> "设备报告疑似摔倒,请尽快确认" + connected && status.pir -> "人体传感器已触发照明自动化" + connected -> "灯光、温湿度与 RFID 自动化已就绪" + else -> "到「设置」填写设备 IP 后即可监测" + }, + color = Color.White.copy(alpha = 0.88f), + fontSize = 12.sp + ) + } + OutlinedButton( + onClick = onRefresh, + enabled = connected, + shape = CircleShape, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.55f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White, + disabledContentColor = Color.White.copy(alpha = 0.45f) + ), + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(42.dp) + ) { + Icon(Icons.Filled.Refresh, contentDescription = "刷新", modifier = Modifier.size(19.dp)) + } + } + Divider(color = Color.White.copy(alpha = 0.24f)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text("人体感应 P0", color = Color.White.copy(alpha = 0.75f), fontSize = 11.sp) + Spacer(Modifier.height(4.dp)) + Text( + if (status.pir) "有人经过" else "区域空闲", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + Column(horizontalAlignment = Alignment.End) { + Text("最近同步", color = Color.White.copy(alpha = 0.75f), fontSize = 11.sp) + Spacer(Modifier.height(4.dp)) + Text(updatedAt.ifBlank { "暂无数据" }, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + } + } + } + } + } +} + +@Composable +private fun EnvironmentCard(status: SmartStatus) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + EnvironmentMetric( + modifier = Modifier.weight(1f), + title = "室内温度", + value = status.temperature?.let { String.format(Locale.getDefault(), "%.1f°", it) } ?: "--", + unit = "C", + subtitle = "SHT20 · 风扇阈值 ${status.fanTempThreshold.toInt()}°C", + icon = Icons.Filled.Thermostat, + accent = Color(0xFFE38A34), + fraction = (((status.temperature ?: 0.0) - 10.0) / 30.0).toFloat().coerceIn(0f, 1f) + ) + EnvironmentMetric( + modifier = Modifier.weight(1f), + title = "相对湿度", + value = status.humidity?.let { String.format(Locale.getDefault(), "%.0f", it) } ?: "--", + unit = "%", + subtitle = "SHT20 · 风扇阈值 ${status.fanHumidityThreshold.toInt()}%", + icon = Icons.Filled.Air, + accent = Color(0xFF258EC0), + fraction = (((status.humidity ?: 0.0)) / 100.0).toFloat().coerceIn(0f, 1f) + ) + } +} + +@Composable +private fun EnvironmentMetric( + modifier: Modifier, + title: String, + value: String, + unit: String, + subtitle: String, + icon: ImageVector, + accent: Color, + fraction: Float +) { + AppCard(modifier = modifier) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MiniIcon(icon, accent) + Spacer(Modifier.width(8.dp)) + Text(title, color = Muted, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Row(verticalAlignment = Alignment.Bottom) { + Text(value, color = Ink, fontSize = 31.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.width(3.dp)) + Text(unit, color = Muted, fontSize = 13.sp, modifier = Modifier.padding(bottom = 5.dp)) + } + Box( + modifier = Modifier.fillMaxWidth().height(5.dp).clip(CircleShape).background(accent.copy(alpha = 0.14f)) + ) { + Box( + modifier = Modifier.fillMaxWidth(fraction).fillMaxHeight().clip(CircleShape).background(accent) + ) + } + Text(subtitle, color = Muted, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun QuickControlRow( + status: SmartStatus, + connected: Boolean, + onToggle: (String, Boolean) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + SectionHeading("快捷控制", "点按切换开关,规则设置在「自动化」") + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + QuickTile( + modifier = Modifier.weight(1f), + title = "门上灯", + icon = Icons.Filled.DoorFront, + accent = Purple, + active = status.doorLight, + enabled = connected, + onClick = { onToggle("door_light", !status.doorLight) } + ) + QuickTile( + modifier = Modifier.weight(1f), + title = "室内灯", + icon = Icons.Filled.Lightbulb, + accent = Blue, + active = status.roomLight, + enabled = connected && status.roomLightAvailable, + onClick = { onToggle("room_light", !status.roomLight) } + ) + QuickTile( + modifier = Modifier.weight(1f), + title = "风扇", + icon = Icons.Filled.Air, + accent = Color(0xFF138FBA), + active = status.fan, + enabled = connected && status.fanAvailable, + onClick = { onToggle("fan", !status.fan) } + ) + } + } +} + +@Composable +private fun QuickTile( + modifier: Modifier, + title: String, + icon: ImageVector, + accent: Color, + active: Boolean, + enabled: Boolean, + onClick: () -> Unit +) { + val shape = RoundedCornerShape(22.dp) + val brush: Brush = if (active) { + Brush.linearGradient(listOf(accent, accent.copy(alpha = 0.72f))) + } else { + SolidColor(Color.White) + } + Box( + modifier = modifier + .height(104.dp) + .alpha(if (enabled) 1f else 0.5f) + .clip(shape) + .background(brush) + .border(1.dp, if (active) Color.Transparent else Hairline, shape) + .clickable(enabled = enabled, onClick = onClick) + ) { + Column( + modifier = Modifier.fillMaxSize().padding(14.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(if (active) Color.White.copy(alpha = 0.22f) else accent.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = null, tint = if (active) Color.White else accent, modifier = Modifier.size(19.dp)) + } + Column { + Text(title, color = if (active) Color.White else Ink, fontSize = 14.sp, fontWeight = FontWeight.Bold) + Text( + if (active) "已开启" else "已关闭", + color = if (active) Color.White.copy(alpha = 0.85f) else Muted, + fontSize = 11.sp + ) + } + } + } +} + +@Composable +private fun AssistantEntryCard( + configured: Boolean, + controlEnabled: Boolean, + connected: Boolean, + onClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(23.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke(1.dp, Hairline), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().background( + Brush.horizontalGradient(listOf(Color(0xFFF4F1FF), Color(0xFFF0F4FF))) + ).padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(46.dp).clip(RoundedCornerShape(15.dp)) + .background(Brush.linearGradient(listOf(Purple, Blue))), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp)) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text("AI 智能管家", color = Ink, fontSize = 15.sp, fontWeight = FontWeight.Bold) + Text( + when { + !configured -> "填写 API Key 后即可开始对话" + controlEnabled && connected -> "可查询状态,也可通过工具控制设备" + controlEnabled -> "AI 已配置,等待设备连接" + else -> "只读查询模式已开启" + }, + color = Muted, + fontSize = 11.sp, + lineHeight = 15.sp + ) + } + Surface(color = Color.White.copy(alpha = 0.88f), shape = CircleShape) { + Icon(Icons.Filled.Send, contentDescription = "打开 AI 管家", tint = Blue, modifier = Modifier.padding(10.dp).size(17.dp)) + } + } + } +} + +@Composable +private fun AutomationSummaryCard( + connected: Boolean, + pirAuto: Boolean, + fanAuto: Boolean, + temperatureThreshold: Float, + humidityThreshold: Float +) { + val enabledCount = (if (pirAuto) 1 else 0) + (if (fanAuto) 1 else 0) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth().background( + Brush.linearGradient(listOf(Color(0xFF536FFF), Color(0xFF765CE7))) + ).padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("自动化控制中心", color = Color.White, fontSize = 19.sp, fontWeight = FontWeight.Bold) + Text("让设备根据环境自动运行", color = Color.White.copy(alpha = 0.82f), fontSize = 11.sp) + } + Surface(color = Color.White.copy(alpha = 0.18f), shape = RoundedCornerShape(11.dp)) { + Text( + if (connected) "设备在线" else "未连接", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + Divider(color = Color.White.copy(alpha = 0.22f)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("已启用规则", color = Color.White.copy(alpha = 0.76f), fontSize = 10.sp) + Text("$enabledCount / 2", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) + } + Column(modifier = Modifier.weight(1f)) { + Text("风扇启动条件", color = Color.White.copy(alpha = 0.76f), fontSize = 10.sp) + Text("${temperatureThreshold.toInt()}°C · ${humidityThreshold.toInt()}%", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + } + Text( + if (enabledCount == 2) "人体感应照明和温湿度风扇均已开启。" + else if (enabledCount == 1) "当前有 1 项自动化规则正在运行。" + else "自动化规则已全部关闭,可在下方重新启用。", + color = Color.White.copy(alpha = 0.88f), + fontSize = 11.sp + ) + } + } +} + +@Composable +private fun HeartRateCard(status: SmartStatus) { + AppCard { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + CardTitle( + icon = Icons.Filled.Favorite, + accent = Color(0xFFE0526A), + title = "心率监测从机", + subtitle = "Wi-Fi UDP 双板通信 · 心率为模拟数据", + trailing = { + Surface( + color = if (status.slaveConnected) Color(0xFFE5F6ED) else Color(0xFFFFF3DF), + shape = RoundedCornerShape(8.dp) + ) { + Text( + if (status.slaveConnected) "从机在线" else "从机离线", + modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp), + color = if (status.slaveConnected) Green else Amber, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + ) + Divider(color = Color(0xFFF0F2F7)) + Row(verticalAlignment = Alignment.Bottom) { + Text( + if (status.slaveConnected && status.heartRateBpm != null) status.heartRateBpm.toString() else "--", + color = Ink, + fontSize = 38.sp, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.width(7.dp)) + Text("BPM", color = Muted, fontSize = 12.sp, modifier = Modifier.padding(bottom = 8.dp)) + Spacer(Modifier.weight(1f)) + Text(if (status.heartRateSimulated) "模拟数据" else "传感器数据", color = Amber, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + } + Text( + if (status.slaveConnected) { + "从机 IP:${status.slaveIp.ifBlank { "已发现" }} · UDP/Wi-Fi 在线。心率是模拟值,不是实际人体测量。" + } else { + "等待第二块掌控板连接同一 Wi-Fi,并通过 UDP 上报模拟心率。路由器不可用时,从机会尝试加入 QHZXManagement。" + }, + color = Muted, + fontSize = 11.sp, + lineHeight = 16.sp + ) + if (status.slaveFallAlarm) { + Text("从机摔倒报警已触发", color = Red, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun RfidCard(status: SmartStatus) { + AppCard { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MiniIcon(Icons.Filled.Sensors, if (status.rfidAuthorized) Green else Blue) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("RFID 门禁", color = Ink, fontSize = 15.sp, fontWeight = FontWeight.Bold) + Text("最近识别卡号:${status.rfidLast}", color = Muted, fontSize = 12.sp) + Text( + if (status.rfidAuthorized) "授权卡匹配成功,门上灯已触发" else "授权 UID 在主机 main.py 中配置", + color = if (status.rfidAuthorized) Green else Muted, + fontSize = 11.sp + ) + } + Box( + modifier = Modifier.size(10.dp).clip(CircleShape).background(if (status.rfidAuthorized) Green else Color(0xFFD2D8E3)) + ) + } + } +} + +@Composable +private fun SafetyCard( + fallen: Boolean, + count: Int, + connected: Boolean, + onReset: () -> Unit +) { + AppCard(containerColor = if (fallen) Color(0xFFFFF0F1) else Color.White) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.WarningAmber, contentDescription = null, tint = if (fallen) Red else Amber) + Spacer(Modifier.width(8.dp)) + Text("安全监测", color = Ink, fontWeight = FontWeight.Bold, fontSize = 15.sp) + Spacer(Modifier.weight(1f)) + Text("累计 $count 次", color = Muted, fontSize = 11.sp) + } + Text( + if (fallen) "检测到疑似摔倒,请确认现场安全。" else "当前没有活动警报。", + color = if (fallen) Red else Muted, + fontSize = 12.sp + ) + if (fallen) { + OutlinedButton(onClick = onReset, enabled = connected, modifier = Modifier.fillMaxWidth()) { + Text("清除当前摔倒标记") + } + } + } + } +} + +@Composable +private fun NoticeCard(title: String, message: String, warning: Boolean) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = if (warning) Color(0xFFFFF6E7) else Color(0xFFEFF3FF) + ) + ) { + Row(modifier = Modifier.padding(14.dp), verticalAlignment = Alignment.Top) { + Icon( + if (warning) Icons.Filled.WarningAmber else Icons.Filled.CheckCircle, + contentDescription = null, + tint = if (warning) Amber else Blue, + modifier = Modifier.size(19.dp) + ) + Spacer(Modifier.width(9.dp)) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, color = Ink, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text(message, color = Muted, fontSize = 11.sp, lineHeight = 16.sp) + } + } + } +} + +// ───────────────────────── 自动化页 ───────────────────────── +@Composable +private fun DeviceControlCard( + title: String, + description: String, + icon: ImageVector, + accent: Color, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, + trailingText: String? = null +) { + AppCard { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MiniIcon(icon, accent) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(title, color = Ink, fontSize = 15.sp, fontWeight = FontWeight.Bold) + if (trailingText != null) { + Spacer(Modifier.width(7.dp)) + Surface( + color = if (trailingText == "自动") Color(0xFFE8EDFF) else Color(0xFFF0F2F6), + shape = RoundedCornerShape(7.dp) + ) { + Text( + trailingText, + modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp), + color = if (trailingText == "自动") Blue else Muted, + fontSize = 10.sp + ) + } + } + } + Text(description, color = Muted, fontSize = 11.sp, lineHeight = 15.sp) + } + Spacer(Modifier.width(8.dp)) + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + } + } +} + +@Composable +private fun RuleRow( + icon: ImageVector, + accent: Color, + title: String, + desc: String, + checked: Boolean, + enabled: Boolean, + onChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(Color(0xFFF7F9FD)) + .padding(horizontal = 12.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MiniIcon(icon, accent) + Spacer(Modifier.width(11.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, color = Ink, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Text(desc, color = Muted, fontSize = 11.sp, lineHeight = 15.sp) + } + Switch(checked = checked, onCheckedChange = onChange, enabled = enabled) + } +} + +@Composable +private fun ThresholdSlider( + label: String, + valueText: String, + value: Float, + range: ClosedFloatingPointRange, + accent: Color, + enabled: Boolean, + minLabel: String, + maxLabel: String, + onChange: (Float) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, color = Ink, fontSize = 13.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + Surface(color = accent.copy(alpha = 0.12f), shape = RoundedCornerShape(9.dp)) { + Text( + valueText, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + color = accent, + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) + } + } + Slider( + value = value, + onValueChange = onChange, + valueRange = range, + enabled = enabled, + colors = SliderDefaults.colors( + thumbColor = accent, + activeTrackColor = accent, + inactiveTrackColor = accent.copy(alpha = 0.18f) + ) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(minLabel, color = Muted, fontSize = 10.sp) + Text(maxLabel, color = Muted, fontSize = 10.sp) + } + } +} + +@Composable +private fun AutomationCard( + connected: Boolean, + pirAuto: Boolean, + onPirAutoChange: (Boolean) -> Unit, + fanAuto: Boolean, + onFanAutoChange: (Boolean) -> Unit, + temperatureThreshold: Float, + onTemperatureThresholdChange: (Float) -> Unit, + humidityThreshold: Float, + onHumidityThresholdChange: (Float) -> Unit, + lightTimeout: Int, + saving: Boolean, + onSave: () -> Unit +) { + AppCard { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + CardTitle(Icons.Filled.Tune, Blue, "自动化规则", "调整触发条件,保存后同步到主控") + + Surface(color = Color(0xFFF7F8FD), shape = RoundedCornerShape(18.dp)) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + RuleRow( + icon = Icons.Filled.Sensors, + accent = Color(0xFFE38A34), + title = "人体感应自动照明", + desc = "P0 触发两路照明联动,超时后同时关闭", + checked = pirAuto, + enabled = connected, + onChange = onPirAutoChange + ) + Divider(color = Hairline, modifier = Modifier.padding(horizontal = 4.dp)) + RuleRow( + icon = Icons.Filled.Air, + accent = Color(0xFF138FBA), + title = "温湿度自动风扇", + desc = "温度或湿度达到任一阈值时启动", + checked = fanAuto, + enabled = connected, + onChange = onFanAutoChange + ) + } + } + + ThresholdSlider( + label = "温度阈值", + valueText = "${temperatureThreshold.toInt()} °C", + value = temperatureThreshold, + range = 18f..35f, + accent = Color(0xFFE38A34), + enabled = connected && fanAuto, + minLabel = "18 °C", + maxLabel = "35 °C", + onChange = onTemperatureThresholdChange + ) + ThresholdSlider( + label = "湿度阈值", + valueText = "${humidityThreshold.toInt()} %", + value = humidityThreshold, + range = 40f..90f, + accent = Color(0xFF258EC0), + enabled = connected && fanAuto, + minLabel = "40 %", + maxLabel = "90 %", + onChange = onHumidityThresholdChange + ) + + Surface(color = Color(0xFFEFF3FF), shape = RoundedCornerShape(16.dp)) { + Column( + modifier = Modifier.fillMaxWidth().padding(13.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text("当前规则预览", color = Blue, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text( + if (pirAuto) "• 有人经过 → 两路灯点亮,无人 ${lightTimeout.takeIf { it > 0 } ?: 30} 秒后关闭" + else "• 人体感应照明:已关闭", + color = Ink, fontSize = 11.sp, lineHeight = 16.sp + ) + Text( + if (fanAuto) "• 温度 ≥ ${temperatureThreshold.toInt()}°C 或湿度 ≥ ${humidityThreshold.toInt()}% → 风扇启动" + else "• 风扇自动模式:已关闭(手动控制)", + color = Ink, fontSize = 11.sp, lineHeight = 16.sp + ) + Text( + "• 防频繁启停:温度低 2°C 且湿度低 5% 才自动关闭", + color = Muted, fontSize = 11.sp, lineHeight = 16.sp + ) + } + } + + Button( + onClick = onSave, + enabled = connected && !saving, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(15.dp) + ) { + Text(if (saving) "正在保存…" else "保存自动化规则", fontWeight = FontWeight.SemiBold) + } + } + } +} + +@Composable +private fun HistoryCard(history: List) { + AppCard { + Column(modifier = Modifier.padding(17.dp), verticalArrangement = Arrangement.spacedBy(11.dp)) { + CardTitle( + icon = Icons.Filled.History, + accent = Blue, + title = "运行记录", + subtitle = "摔倒状态与 AI 操作", + trailing = { Text("${history.size} 条", color = Muted, fontSize = 11.sp) } + ) + Divider(color = Color(0xFFF0F2F7)) + if (history.isEmpty()) { + Text("暂时没有事件。摔倒状态变化、AI 操作会记录在这里。", color = Muted, fontSize = 12.sp, lineHeight = 17.sp) + } else { + history.take(8).forEach { event -> + Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) { + Icon( + if (event.alert) Icons.Filled.WarningAmber else Icons.Filled.CheckCircle, + contentDescription = null, + tint = if (event.alert) Red else Green, + modifier = Modifier.padding(top = 2.dp).size(19.dp) + ) + Spacer(Modifier.width(9.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(event.title, color = Ink, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text(event.detail, color = Muted, fontSize = 11.sp, lineHeight = 15.sp) + } + Spacer(Modifier.width(7.dp)) + Text(event.time, color = Muted, fontSize = 10.sp) + } + } + } + } + } +} + +// ───────────────────────── AI 页 ───────────────────────── +@Composable +private fun AiPage( + config: AiConfig, + chat: List, + busy: Boolean, + connected: Boolean, + onSend: (String) -> Unit, + onClear: () -> Unit, + onGoSettings: () -> Unit +) { + var input by remember { mutableStateOf("") } + val listState = rememberLazyListState() + val total = chat.size + if (busy) 1 else 0 + + LaunchedEffect(total) { + if (total > 0) listState.animateScrollToItem(total - 1) + } + + Column(modifier = Modifier.fillMaxSize().imePadding()) { + // 顶部栏 + Row( + modifier = Modifier.fillMaxWidth().padding(start = 18.dp, end = 10.dp, top = 14.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(15.dp)) + .background(Brush.linearGradient(listOf(Purple, Blue))), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp)) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text("AI 管家", color = Ink, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Text( + "${config.model} · " + when { + config.apiKey.isBlank() -> "未配置 Key" + config.allowControl && connected -> "设备在线 · 可调用控制工具" + config.allowControl -> "等待设备连接 · 工具已启用" + connected -> "设备在线 · 只读查询模式" + else -> "只读查询模式" + }, + color = Muted, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + IconButton(onClick = onClear, enabled = chat.isNotEmpty()) { + Icon(Icons.Filled.DeleteOutline, contentDescription = "清空对话", tint = if (chat.isNotEmpty()) Muted else Color(0xFFCBD2DF)) + } + } + + if (config.apiKey.isNotBlank()) { + Surface( + modifier = Modifier.padding(horizontal = 18.dp).padding(bottom = 7.dp).fillMaxWidth(), + color = if (config.allowControl) Color(0xFFEAF7F0) else Color(0xFFEFF3FF), + shape = RoundedCornerShape(14.dp) + ) { + Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Bolt, contentDescription = null, tint = if (config.allowControl) Green else Blue, modifier = Modifier.size(17.dp)) + Spacer(Modifier.width(8.dp)) + Text( + if (config.allowControl) "工具已启用:AI 可查询状态、控制设备和修改自动化规则" else "只读模式:AI 可以查询状态,不会执行控制操作", + color = if (config.allowControl) Green else Blue, + fontSize = 11.sp, + lineHeight = 15.sp + ) + } + } + } + + if (config.apiKey.isBlank()) { + Surface( + modifier = Modifier.padding(horizontal = 18.dp).padding(bottom = 6.dp).fillMaxWidth().clickable { onGoSettings() }, + color = Color(0xFFFFF6E7), + shape = RoundedCornerShape(16.dp) + ) { + Row(modifier = Modifier.padding(13.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Lock, contentDescription = null, tint = Amber, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(9.dp)) + Text("还没有填写 API Key,点这里前往「设置」配置", color = Amber, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } + } + } + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (chat.isEmpty() && !busy) { + AiEmptyState(onPick = onSend) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(chat) { msg -> ChatBubble(msg) } + if (busy) { + item { + Row(modifier = Modifier.fillMaxWidth()) { + Surface( + color = Color.White, + shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp), + border = BorderStroke(1.dp, Hairline) + ) { + Text("思考中…", modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), color = Muted, fontSize = 13.sp) + } + } + } + } + } + } + } + + // 输入栏 + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom + ) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("例如:把室内灯打开", fontSize = 14.sp) }, + maxLines = 4, + shape = RoundedCornerShape(22.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + enabled = !busy + ) + Spacer(Modifier.width(10.dp)) + val canSend = input.isNotBlank() && !busy + Box( + modifier = Modifier + .padding(bottom = 4.dp) + .size(50.dp) + .alpha(if (canSend) 1f else 0.45f) + .clip(CircleShape) + .background(Brush.linearGradient(BrandGradient)) + .clickable(enabled = canSend) { + val text = input + input = "" + onSend(text) + }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.Send, contentDescription = "发送", tint = Color.White, modifier = Modifier.size(22.dp)) + } + } + } +} + +@Composable +private fun AiEmptyState(onPick: (String) -> Unit) { + val suggestions = listOf( + "现在室内温度和湿度怎么样?", + "把室内灯打开", + "温度超过 30 度就自动开风扇", + "关闭风扇,并关掉门上灯" + ) + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(Brush.linearGradient(listOf(Purple.copy(alpha = 0.22f), Blue.copy(alpha = 0.22f)))), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Blue, modifier = Modifier.size(36.dp)) + } + Spacer(Modifier.height(14.dp)) + Text("我是你的家居管家", color = Ink, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Text("可以查询状态、控制灯光风扇、调整自动化规则", color = Muted, fontSize = 12.sp, textAlign = TextAlign.Center) + Spacer(Modifier.height(20.dp)) + suggestions.forEach { text -> + Surface( + modifier = Modifier.padding(vertical = 4.dp).fillMaxWidth().clickable { onPick(text) }, + color = Color.White, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, Hairline) + ) { + Row(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Purple, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(9.dp)) + Text(text, color = Ink, fontSize = 13.sp) + } + } + } + } +} + +@Composable +private fun ChatBubble(msg: ChatMsg) { + when (msg.role) { + "user" -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .clip(RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp)) + .background(Brush.linearGradient(BrandGradient)) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Text(msg.text, color = Color.White, fontSize = 14.sp, lineHeight = 20.sp) + } + } + + "tool" -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Surface(color = Color(0xFFE8EDFF), shape = RoundedCornerShape(12.dp)) { + Row(modifier = Modifier.padding(horizontal = 11.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Bolt, contentDescription = null, tint = Blue, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(5.dp)) + Text(msg.text, color = Blue, fontSize = 11.sp, fontWeight = FontWeight.Medium) + } + } + } + + "error" -> Row(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier.widthIn(max = 320.dp), + color = Color(0xFFFFF0F1), + shape = RoundedCornerShape(16.dp) + ) { + Text(msg.text, modifier = Modifier.padding(12.dp), color = Red, fontSize = 12.sp, lineHeight = 17.sp) + } + } + + else -> Row(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier.widthIn(max = 320.dp), + color = Color.White, + shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp), + border = BorderStroke(1.dp, Hairline) + ) { + Text(msg.text, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), color = Ink, fontSize = 14.sp, lineHeight = 21.sp) + } + } + } +} + +// ───────────────────────── 设置页 ───────────────────────── +@Composable +private fun ConnectionCard( + host: String, + onHostChange: (String) -> Unit, + monitoring: Boolean, + connected: Boolean, + infoMessage: String, + errorMessage: String?, + onConnect: () -> Unit, + onPause: () -> Unit, + onRefresh: () -> Unit, + wifiMode: String, + apSsid: String, + apPassword: String, + apAddress: String, + onUseApAddress: () -> Unit +) { + AppCard { + Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + CardTitle(Icons.Filled.Router, Blue, "主控连接", "手机 HTTP API · 主从机 UDP/Wi-Fi") + OutlinedTextField( + value = host, + onValueChange = onHostChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("设备 IP 地址") }, + placeholder = { Text("例如:192.168.1.100") }, + leadingIcon = { Icon(Icons.Filled.Wifi, contentDescription = null, tint = Muted) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), + shape = RoundedCornerShape(14.dp) + ) + Text( + "手机连接主机所在 Wi-Fi;主机和从机也连接同一 Wi-Fi,通过 UDP 自动发现并同步心率与摔倒状态。IP 会自动保存,下次打开自动连接。", + color = Muted, + fontSize = 11.sp, + lineHeight = 15.sp + ) + if (wifiMode == "AP") { + Text( + "主机已切换到管理热点:$apSsid · 密码:$apPassword · 地址:$apAddress", + color = Amber, + fontSize = 11.sp, + lineHeight = 16.sp + ) + } else { + Text( + "如果路由器 Wi-Fi 连接失败,主机将广播 $apSsid(密码 $apPassword)。手机手动连接该热点后,可使用 192.168.4.1。", + color = Muted, + fontSize = 11.sp, + lineHeight = 16.sp + ) + } + OutlinedButton( + onClick = onUseApAddress, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("填入备用热点地址 $apAddress") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + if (monitoring) { + OutlinedButton( + onClick = onPause, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(14.dp) + ) { + Icon(Icons.Filled.WifiOff, contentDescription = null, modifier = Modifier.size(17.dp)) + Spacer(Modifier.width(6.dp)) + Text("暂停") + } + } else { + Button( + onClick = onConnect, + enabled = host.trim().isNotEmpty(), + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(14.dp) + ) { + Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(17.dp)) + Spacer(Modifier.width(6.dp)) + Text("连接并监测") + } + } + OutlinedButton( + onClick = onRefresh, + enabled = monitoring, + modifier = Modifier.height(48.dp), + shape = RoundedCornerShape(14.dp) + ) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(17.dp)) + Spacer(Modifier.width(5.dp)) + Text("刷新") + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(if (connected) Green else if (monitoring) Amber else Color(0xFF9AA5B8))) + Spacer(Modifier.width(7.dp)) + Text(errorMessage ?: infoMessage, color = if (errorMessage != null) Red else Muted, fontSize = 11.sp) + } + } + } +} + +@Composable +private fun ProviderChip(label: String, onClick: () -> Unit) { + Surface( + modifier = Modifier.clickable { onClick() }, + color = Color(0xFFE8EDFF), + shape = RoundedCornerShape(10.dp) + ) { + Text(label, modifier = Modifier.padding(horizontal = 11.dp, vertical = 6.dp), color = Blue, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun AiConfigCard( + config: AiConfig, + testing: Boolean, + testMessage: String, + onTest: (AiConfig) -> Unit, + onSave: (AiConfig) -> Unit +) { + var draft by remember(config) { mutableStateOf(config) } + var showKey by remember { mutableStateOf(false) } + var savedHint by remember { mutableStateOf(false) } + + LaunchedEffect(savedHint) { + if (savedHint) { + delay(2_000L) + savedHint = false + } + } + + AppCard { + Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + CardTitle(Icons.Filled.AutoAwesome, Purple, "AI 配置", "兼容 OpenAI 接口的任意服务") + + Text("快速选择服务商", color = Muted, fontSize = 11.sp) + Row(horizontalArrangement = Arrangement.spacedBy(7.dp), modifier = Modifier.fillMaxWidth()) { + ProviderChip("OpenAI") { + draft = draft.copy(baseUrl = "https://api.openai.com/v1", model = "gpt-4o-mini") + } + ProviderChip("DeepSeek") { + draft = draft.copy(baseUrl = "https://api.deepseek.com/v1", model = "deepseek-chat") + } + ProviderChip("通义千问") { + draft = draft.copy(baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1", model = "qwen-plus") + } + } + + OutlinedTextField( + value = draft.baseUrl, + onValueChange = { draft = draft.copy(baseUrl = it) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("接口地址 Base URL") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + shape = RoundedCornerShape(14.dp) + ) + OutlinedTextField( + value = draft.apiKey, + onValueChange = { draft = draft.copy(apiKey = it) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("API Key") }, + placeholder = { Text("sk-...") }, + leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null, tint = Muted) }, + trailingIcon = { + IconButton(onClick = { showKey = !showKey }) { + Icon( + if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (showKey) "隐藏" else "显示", + tint = Muted + ) + } + }, + singleLine = true, + visualTransformation = if (showKey) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + shape = RoundedCornerShape(14.dp) + ) + OutlinedTextField( + value = draft.model, + onValueChange = { draft = draft.copy(model = it) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("模型名称") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = RoundedCornerShape(14.dp) + ) + + RuleRow( + icon = Icons.Filled.Bolt, + accent = Purple, + title = "允许 AI 控制设备", + desc = "开启后 AI 可调用工具开关灯/风扇、改规则、清除警报", + checked = draft.allowControl, + enabled = true, + onChange = { draft = draft.copy(allowControl = it) } + ) + + OutlinedTextField( + value = draft.systemPrompt, + onValueChange = { draft = draft.copy(systemPrompt = it) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 110.dp), + label = { Text("系统提示词") }, + maxLines = 8, + shape = RoundedCornerShape(14.dp) + ) + TextButton(onClick = { draft = draft.copy(systemPrompt = DEFAULT_SYSTEM_PROMPT) }) { + Text("恢复默认提示词", fontSize = 12.sp) + } + + Text( + "Android 6.0 及以上会尝试使用 Android Keystore(AES-GCM)加密 API Key;若设备 Keystore 不可用则回退到应用本地存储。AI 对话内容和设备状态会发送到你填写的服务商接口。", + color = Muted, + fontSize = 11.sp, + lineHeight = 16.sp + ) + if (testMessage.isNotBlank()) { + Surface( + color = when { + testMessage.startsWith("连接成功") -> Color(0xFFEAF7F0) + testMessage.startsWith("正在") -> Color(0xFFEFF3FF) + else -> Color(0xFFFFF2E9) + }, + shape = RoundedCornerShape(12.dp) + ) { + Text( + testMessage, + modifier = Modifier.fillMaxWidth().padding(11.dp), + color = when { + testMessage.startsWith("连接成功") -> Green + testMessage.startsWith("正在") -> Blue + else -> Amber + }, + fontSize = 11.sp, + lineHeight = 16.sp + ) + } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(9.dp)) { + OutlinedButton( + onClick = { onTest(draft) }, + enabled = !testing, + modifier = Modifier.weight(1f).height(50.dp), + shape = RoundedCornerShape(15.dp) + ) { + Text(if (testing) "测试中…" else "测试连接", fontWeight = FontWeight.SemiBold) + } + Button( + onClick = { + onSave(draft) + savedHint = true + }, + modifier = Modifier.weight(1.25f).height(50.dp), + shape = RoundedCornerShape(15.dp) + ) { + Text(if (savedHint) "已保存 ✓" else "保存配置", fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +@Composable +private fun DataCard(onClear: () -> Unit) { + AppCard { + Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + CardTitle(Icons.Filled.DeleteOutline, Red, "本地数据", "设备 IP、API Key 与 AI 设置") + OutlinedButton( + onClick = onClear, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Red) + ) { + Text("清除所有本地配置") + } + } + } +} + +// ───────────────────────── 设备 API ───────────────────────── +private object SmartLifeApi { + fun getJson(host: String, path: String): JSONObject { + val connection = (URL("http://$host$path").openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 2_500 + readTimeout = 2_500 + useCaches = false + setRequestProperty("Accept", "application/json") + } + try { + val code = connection.responseCode + val stream = if (code in 200..299) connection.inputStream else connection.errorStream + val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + if (code !in 200..299) throw IOException("HTTP $code: $body") + return JSONObject(body) + } finally { + connection.disconnect() + } + } + + fun parseStatus(json: JSONObject): SmartStatus { + fun doubleOrNull(key: String): Double? = + if (json.isNull(key) || !json.has(key)) null + else json.optDouble(key).takeIf { !it.isNaN() && !it.isInfinite() } + return SmartStatus( + temperature = doubleOrNull("temperature"), + humidity = doubleOrNull("humidity"), + pir = json.optBoolean("pir", false), + doorLight = json.optBoolean("door_light", false), + roomLight = json.optBoolean( + "room_light", + json.optBoolean("light13", false) || json.optBoolean("light14", false) + ), + fan = json.optBoolean("fan", false), + fanAuto = json.optBoolean("fan_auto", true), + pirAuto = json.optBoolean("pir_auto", true), + fanTempThreshold = json.optDouble("fan_temp_threshold", 28.0).toFloat(), + fanHumidityThreshold = json.optDouble("fan_humidity_threshold", 75.0).toFloat(), + lightTimeout = json.optInt("light_timeout", 30), + doorTimeout = json.optInt("door_timeout", 8), + rfidLast = json.optString("rfid_last", "—"), + rfidAuthorized = json.optBoolean("rfid_authorized", false), + sensorOk = json.optBoolean("sensor_ok", false), + fanAvailable = json.optBoolean("fan_available", false), + roomLightAvailable = json.optBoolean("room_light_available", false), + pinStatus = json.optString("pin_status", ""), + uptime = json.optLong("uptime", 0L), + fallen = json.optBoolean("fallen", false), + fallCount = json.optInt("fall_count", 0), + fallSource = json.optString("fall_source", ""), + heartRateBpm = if (json.isNull("heart_rate_bpm") || !json.has("heart_rate_bpm")) null else json.optInt("heart_rate_bpm"), + heartRateSimulated = json.optBoolean("heart_rate_simulated", true), + slaveConnected = json.optBoolean("slave_connected", false), + slaveIp = json.optString("slave_ip", ""), + slaveTransport = json.optString("slave_transport", "UDP/Wi-Fi"), + slaveFallAlarm = json.optBoolean("slave_fall_alarm", false), + slaveFallCount = json.optInt("slave_fall_count", 0), + wifiMode = json.optString("wifi_mode", "STA"), + ipAddress = json.optString("ip_address", ""), + apSsid = json.optString("ap_ssid", "QHZXManagement"), + apPassword = json.optString("ap_password", "QHZX2026") + ) + } +} + +// ───────────────────────── AI 接口(OpenAI 兼容 / Function Calling) ───────────────────────── +private object AiClient { + /** 返回 choices[0].message;若包含 tool_calls 由调用方负责执行。 */ + fun chat(cfg: AiConfig, messages: JSONArray, tools: JSONArray?): JSONObject { + val base = cfg.baseUrl.trim().trimEnd('/') + val url = if (base.endsWith("/chat/completions")) base else "$base/chat/completions" + val body = JSONObject() + .put("model", cfg.model.trim()) + .put("messages", messages) + .put("temperature", 0.3) + if (tools != null && tools.length() > 0) { + body.put("tools", tools) + body.put("tool_choice", "auto") + } + + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 15_000 + readTimeout = 60_000 + doOutput = true + useCaches = false + setRequestProperty("Content-Type", "application/json; charset=utf-8") + setRequestProperty("Accept", "application/json") + setRequestProperty("Authorization", "Bearer ${cfg.apiKey.trim()}") + } + try { + connection.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) } + val code = connection.responseCode + val stream = if (code in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + if (code !in 200..299) { + val detail = try { + JSONObject(text).optJSONObject("error")?.optString("message").orEmpty() + } catch (_: Exception) { + text.take(200) + } + throw IOException("HTTP $code ${detail.ifBlank { text.take(200) }}") + } + val choices = JSONObject(text).getJSONArray("choices") + return choices.getJSONObject(0).getJSONObject("message") + } finally { + connection.disconnect() + } + } +} + +private fun aiTools(allowControl: Boolean): JSONArray { + fun fn(name: String, description: String, parameters: String): JSONObject = + JSONObject() + .put("type", "function") + .put( + "function", + JSONObject() + .put("name", name) + .put("description", description) + .put("parameters", JSONObject(parameters)) + ) + + val tools = JSONArray().put( + fn( + "get_device_status", + "读取最新设备状态:温湿度、人体感应、灯和风扇状态、自动化参数、摔倒报警、从机连接和模拟心率。", + """{"type":"object","properties":{}}""" + ) + ) + + if (allowControl) { + tools.put( + fn( + "control_device", + "开关指定设备。door_light=门上灯,room_light=室内照明总开关(P13/P14两路灯),fan=风扇/电机。手动控制风扇或室内灯会退出对应自动模式。", + """{"type":"object","properties":{"device":{"type":"string","enum":["door_light","room_light","fan"]},"state":{"type":"boolean","description":"true=开启,false=关闭"}},"required":["device","state"],"additionalProperties":false}""" + ) + ) + tools.put( + fn( + "update_automation", + "修改自动化规则并保存到设备。只传需要修改的字段,未提供字段保持原值。温度范围18-35摄氏度,湿度范围40-90%。", + """{"type":"object","properties":{"pir_auto":{"type":"boolean","description":"人体感应自动照明开关"},"fan_auto":{"type":"boolean","description":"温湿度自动风扇开关"},"fan_temp_threshold":{"type":"number","description":"风扇温度阈值,摄氏度,范围18-35"},"fan_humidity_threshold":{"type":"number","description":"风扇湿度阈值,百分比,范围40-90"}},"additionalProperties":false}""" + ) + ) + tools.put( + fn( + "clear_fall_alarm", + "清除设备上的摔倒警报标记。仅当用户明确要求清除并确认人员安全后调用。", + """{"type":"object","properties":{},"additionalProperties":false}""" + ) + ) + } + return tools +} + +private fun deviceName(device: String): String = when (device) { + "door_light" -> "门上灯" + "room_light" -> "室内照明" + "fan" -> "风扇" + else -> device +} + +private fun toolLabel(name: String, args: JSONObject): String = when (name) { + "get_device_status" -> "读取设备状态" + "control_device" -> "${deviceName(args.optString("device"))} → ${if (args.optBoolean("state")) "开启" else "关闭"}" + "update_automation" -> "更新自动化规则" + "clear_fall_alarm" -> "清除摔倒警报" + else -> "调用工具 $name" +} + +private fun toolResult(ok: Boolean, message: String, status: SmartStatus?): String { + val obj = JSONObject().put("ok", ok).put("message", message) + if (status != null) obj.put("status", JSONObject(status.toAiJson())) + return obj.toString() +} + +private fun SmartStatus.toAiJson(): String { + val o = JSONObject() + o.put("temperature_c", temperature ?: JSONObject.NULL) + o.put("humidity_percent", humidity ?: JSONObject.NULL) + o.put("someone_detected", pir) + o.put("door_light_on", doorLight) + o.put("room_light_on", roomLight) + o.put("fan_on", fan) + o.put("pir_auto_lighting", pirAuto) + o.put("fan_auto_mode", fanAuto) + o.put("fan_temp_threshold_c", fanTempThreshold.toDouble()) + o.put("fan_humidity_threshold_percent", fanHumidityThreshold.toDouble()) + o.put("fallen", fallen) + o.put("fall_count", fallCount) + o.put("heart_rate_bpm_simulated", heartRateBpm ?: JSONObject.NULL) + o.put("heart_rate_slave_online", slaveConnected) + o.put("heart_rate_is_simulated", heartRateSimulated) + o.put("slave_ip", slaveIp) + o.put("slave_fall_alarm", slaveFallAlarm) + o.put("slave_fall_count", slaveFallCount) + o.put("fan_available", fanAvailable) + o.put("room_light_available", roomLightAvailable) + o.put("rfid_authorized", rfidAuthorized) + o.put("last_rfid_uid", rfidLast) + o.put("wifi_mode", wifiMode) + o.put("ip_address", ipAddress) + return o.toString() +} + +private fun addHistory(list: MutableList, event: LocalEvent) { + list.add(0, event) + while (list.size > 20) list.removeAt(list.lastIndex) +} + +private fun nowClock(): String = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) diff --git a/app/app/src/main/res/drawable/ic_launcher.xml b/app/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..18d2891 --- /dev/null +++ b/app/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/app/src/main/res/values/styles.xml b/app/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..d56052f --- /dev/null +++ b/app/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1b4481a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false +} diff --git a/app/gradle.properties b/app/gradle.properties new file mode 100644 index 0000000..96e3009 --- /dev/null +++ b/app/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +org.gradle.parallel=true diff --git a/app/gradle/wrapper/gradle-wrapper.properties b/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..40fc883 --- /dev/null +++ b/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=manual-wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=manual-wrapper/dists diff --git a/app/gradlew b/app/gradlew new file mode 100644 index 0000000..911512f --- /dev/null +++ b/app/gradlew @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Small wrapper bootstrap that downloads Gradle when it is not already cached. +set -euo pipefail +GRADLE_VERSION="8.9" +GRADLE_USER_HOME="${GRADLE_USER_HOME:-$HOME/.gradle}" +INSTALL_ROOT="$GRADLE_USER_HOME/manual-wrapper/gradle-$GRADLE_VERSION" +GRADLE_BIN="$INSTALL_ROOT/gradle-$GRADLE_VERSION/bin/gradle" +if [[ ! -x "$GRADLE_BIN" ]]; then + mkdir -p "$INSTALL_ROOT" + ARCHIVE="$INSTALL_ROOT/gradle-$GRADLE_VERSION-bin.zip" + URL="https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zip" + echo "Gradle $GRADLE_VERSION 尚未安装,正在下载……" + if command -v curl >/dev/null 2>&1; then + curl --fail --location --retry 3 "$URL" --output "$ARCHIVE" + elif command -v wget >/dev/null 2>&1; then + wget -O "$ARCHIVE" "$URL" + else + echo "需要 curl 或 wget 才能下载 Gradle。" >&2 + exit 1 + fi + unzip -q -o "$ARCHIVE" -d "$INSTALL_ROOT" + rm -f "$ARCHIVE" +fi +exec "$GRADLE_BIN" "$@" diff --git a/app/gradlew.bat b/app/gradlew.bat new file mode 100644 index 0000000..1fcdf9d --- /dev/null +++ b/app/gradlew.bat @@ -0,0 +1,12 @@ +@echo off +setlocal +set "GRADLE_VERSION=8.9" +set "INSTALL_ROOT=%USERPROFILE%\.gradle\manual-wrapper\gradle-%GRADLE_VERSION%" +set "GRADLE_BIN=%INSTALL_ROOT%\gradle-%GRADLE_VERSION%\bin\gradle.bat" +if not exist "%GRADLE_BIN%" ( + echo Gradle %GRADLE_VERSION% 尚未安装,正在下载…… + powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $root='%INSTALL_ROOT%'; New-Item -ItemType Directory -Force -Path $root | Out-Null; $zip=Join-Path $root 'gradle-%GRADLE_VERSION%-bin.zip'; Invoke-WebRequest -Uri 'https://services.gradle.org/distributions/gradle-%GRADLE_VERSION%-bin.zip' -OutFile $zip; Expand-Archive -Path $zip -DestinationPath $root -Force; Remove-Item $zip -Force" + if errorlevel 1 exit /b 1 +) +call "%GRADLE_BIN%" %* +exit /b %ERRORLEVEL% diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts new file mode 100644 index 0000000..4dac0bc --- /dev/null +++ b/app/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "SmartLife" +include(":app")