diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c81551b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+# Gradle
+.gradle/
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+# Android Studio / IntelliJ
+.idea/
+*.iml
+*.ipr
+*.iws
+captures/
+.externalNativeBuild/
+.cxx/
+
+# 本地环境配置(不进入版本库)
+local.properties
+
+# 密钥与签名(M7-04:SecretKey/SystemCode/keystore 一律不入库)
+*.jks
+*.keystore
+secrets.properties
+keystore.properties
+
+# 日志与临时文件
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..53eb0c2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+# 监测数据不落地平台 · 安卓客户端
+
+外业监测数据「不落地」采集与上传工具:现场用手机通过蓝牙连接振弦式读数仪(IE-1000)与综合采集仪(VM208),解析采集数据并直接上传至院方监测平台,配合离线队列保证弱网/断网可用。
+
+> 本文件为 2026-09-03 重建版(基线文件曾随工作树清理丢失,按任务记录复刻)。
+
+## 技术基线
+
+| 项 | 选型 |
+|---|---|
+| 语言 / UI | Kotlin 2.0.21、View 体系 + Material 3 |
+| 架构 | MVVM(ViewModel + StateFlow/LiveData) |
+| 网络 | Retrofit 2.11 + OkHttp 4.12 + kotlinx.serialization 1.7.3 |
+| 本地存储 | Room 2.6.1(离线优先队列)+ DataStore 1.1.1 + EncryptedSharedPreferences |
+| 依赖注入 | Hilt 2.52 |
+| 蓝牙 | Android 原生 BLE API;协议解析独立纯 Kotlin 模块 |
+| 构建 | AGP 8.7.3 / Gradle 8.10.2 / JDK 17 / minSdk 26 / targetSdk 34 |
+
+## 模块结构
+
+```
+app 业务壳:导航、主题、权限、配置注入(M1–M6 页面挂载点)
+core
+ ├ common AppResult / UiState / 日志门面(纯 Kotlin 优先)
+ ├ network 鉴权三要素拦截器、ApiEnvelope 统一解析、Retrofit 装配
+ ├ database Room AppDatabase + 上传队列(离线优先参考实现)
+ └ ble BLE 扫描(按名称前缀过滤)、GATT 连接封装骨架
+ble-protocol 纯 Kotlin JVM:IE-1000 / VM208 帧解析(零 Android 依赖,可独立单测)
+```
+
+依赖方向:`app → core:* + ble-protocol`;`core:{network,database,ble} → core:common`;`ble-protocol` 零依赖。
+
+## 快速开始
+
+1. Android Studio(Ladybug+,JDK 17)打开工程根目录,等待 Gradle Sync;
+2. 服务器地址、SecretKey、SystemCode 通过 `app` 壳的配置仓储注入(见 `core:network` 的 `NetworkConfig`),**一律不写入代码库**;
+3. 运行:`./gradlew :app:assembleDebug`;协议单测:`./gradlew :ble-protocol:test`。
+
+## 配置说明
+
+| 配置项 | 说明 | 注入点 |
+|---|---|---|
+| baseUrl | 平台接口地址(测试环境见接口文档 1.2.4) | ConfigRepository → NetworkConfig |
+| SecretKey | 供应商调用密钥(会定期更换,必须可配置) | 同上 |
+| SystemCode | 供应商编码(与 SecretKey 一一对应) | 同上 |
+| Token | 登录后颁发,登录接口外必带 | TokenProvider(DataStore) |
+
+## 文档索引
+
+- `docs/安卓客户端功能明细表.md` —— 36 个功能点、模块映射、优先级、接口覆盖对照
+- `docs/安卓客户端开发计划表.md` —— 6 阶段 12 周迭代计划、人日估算、里程碑、风险
+- `docs/BLE-IE1000.txt` —— IE-1000 / VM208 蓝牙协议原始说明
+- 接口规范 V4.5 —— 上海城建勘测院《监测数据不落地系统接口文档》(docs/ 下 docx)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..34391d3
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,74 @@
+// app 业务壳:导航、主题、权限、配置注入;M1–M6 页面经 FeatureMount 挂载
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+}
+
+android {
+ namespace = "com.stec.cmd"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.stec.cmd"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.1.0-S0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ // core 基础层 + 协议解析(业务模块只允许经 core 层访问能力)
+ implementation(project(":core:common"))
+ implementation(project(":core:network"))
+ implementation(project(":core:database"))
+ implementation(project(":core:ble"))
+ implementation(project(":ble-protocol"))
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.activity)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.androidx.splashscreen)
+ implementation(libs.google.material)
+
+ implementation(libs.androidx.lifecycle.runtime)
+ implementation(libs.androidx.lifecycle.viewmodel)
+ implementation(libs.androidx.lifecycle.livedata)
+
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.security.crypto)
+
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..a3af3c3
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,12 @@
+# app 壳混淆规则(release)
+# S0 骨架期保持最小规则集,S5 发布阶段随业务实体补全
+
+# kotlinx.serialization:保留序列化器生成逻辑(S1 业务 DTO 接入后按类型名细化)
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.AnnotationsKt
+
+# OkHttp / Retrofit
+-dontwarn okhttp3.**
+-dontwarn okio.**
+-dontwarn javax.annotation.**
+-keepattributes Signature, Exceptions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3b341b1
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/stec/cmd/CollectFragment.kt b/app/src/main/kotlin/com/stec/cmd/CollectFragment.kt
new file mode 100644
index 0000000..5c64630
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/CollectFragment.kt
@@ -0,0 +1,7 @@
+package com.stec.cmd
+
+import dagger.hilt.android.AndroidEntryPoint
+
+/** 采集占位(M4+M5 数据采集,S2/S3 挂载扫描与采集页)。 */
+@AndroidEntryPoint
+class CollectFragment : PlaceholderFragment()
diff --git a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt
new file mode 100644
index 0000000..ae100ed
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt
@@ -0,0 +1,41 @@
+package com.stec.cmd
+
+/**
+ * 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
+ *
+ * S1+ 挂载业务页面的方式:把对应 [MountItem.fragmentClass] 从占位 Fragment
+ * 换成业务 Fragment 即可,导航壳(MainActivity)零改动;新增 Tab 时扩展 [Tab]。
+ * 挂载映射依据《安卓客户端功能明细表》:
+ * 首页=M2、任务=M3、采集=M4+M5、统计=M6、我的=M1。
+ */
+object FeatureMount {
+
+ /** 底部导航 Tab(itemId 与 res/menu/bottom_nav_menu.xml 一一对应)。 */
+ enum class Tab(val itemId: Int, val labelRes: Int) {
+ HOME(R.id.nav_home, R.string.tab_home),
+ TASK(R.id.nav_task, R.string.tab_task),
+ COLLECT(R.id.nav_collect, R.string.tab_collect),
+ STATS(R.id.nav_stats, R.string.tab_stats),
+ MINE(R.id.nav_mine, R.string.tab_mine),
+ }
+
+ /** 单个 Tab 的挂载项。 */
+ data class MountItem(
+ val tab: Tab,
+ val fragmentClass: Class,
+ val moduleTag: String,
+ )
+
+ /** 挂载注册表:S1+ 替换 fragmentClass 即完成业务页挂载。 */
+ val mounts: List = listOf(
+ MountItem(Tab.HOME, HomeFragment::class.java, "M2 项目工作台"),
+ MountItem(Tab.TASK, TaskFragment::class.java, "M3 任务管理"),
+ MountItem(Tab.COLLECT, CollectFragment::class.java, "M4+M5 数据采集"),
+ MountItem(Tab.STATS, StatsFragment::class.java, "M6 统计与上传"),
+ MountItem(Tab.MINE, MineFragment::class.java, "M1 我的"),
+ )
+
+ /** 按 menu itemId 取挂载项;未知 id 返回 null(容错)。 */
+ fun byItemId(itemId: Int): MountItem? =
+ mounts.firstOrNull { it.tab.itemId == itemId }
+}
diff --git a/app/src/main/kotlin/com/stec/cmd/HomeFragment.kt b/app/src/main/kotlin/com/stec/cmd/HomeFragment.kt
new file mode 100644
index 0000000..03c4acc
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/HomeFragment.kt
@@ -0,0 +1,7 @@
+package com.stec.cmd
+
+import dagger.hilt.android.AndroidEntryPoint
+
+/** 首页占位(M2 项目工作台,S1 挂载项目列表)。 */
+@AndroidEntryPoint
+class HomeFragment : PlaceholderFragment()
diff --git a/app/src/main/kotlin/com/stec/cmd/MainActivity.kt b/app/src/main/kotlin/com/stec/cmd/MainActivity.kt
new file mode 100644
index 0000000..41e2f96
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/MainActivity.kt
@@ -0,0 +1,59 @@
+package com.stec.cmd
+
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.commit
+import com.stec.cmd.databinding.ActivityMainBinding
+import dagger.hilt.android.AndroidEntryPoint
+
+/**
+ * 单 Activity 导航壳:BottomNavigationView 5 Tab 切换 Fragment。
+ * show/hide 复用实例以保留各 Tab 视图状态;S1 可迁移 Navigation 组件。
+ */
+@AndroidEntryPoint
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMainBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ binding.bottomNav.setOnItemSelectedListener(::selectTab)
+ if (savedInstanceState == null) {
+ binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId
+ }
+ }
+
+ private fun selectTab(item: MenuItem): Boolean {
+ val mount = FeatureMount.byItemId(item.itemId) ?: return false
+ switchTo(mount)
+ return true
+ }
+
+ private fun switchTo(mount: FeatureMount.MountItem) {
+ val tag = mount.tab.name
+ val existing = supportFragmentManager.findFragmentByTag(tag)
+ val target: Fragment = existing ?: createFragment(mount)
+
+ supportFragmentManager.commit {
+ setReorderingAllowed(true)
+ supportFragmentManager.fragments.forEach { if (it !== target) hide(it) }
+ if (existing == null) add(R.id.fragment_container, target, tag)
+ show(target)
+ }
+ supportActionBar?.title = getString(mount.tab.labelRes)
+ }
+
+ /** 实例化挂载项声明的 Fragment;占位 Fragment 支持模块名参数。 */
+ private fun createFragment(mount: FeatureMount.MountItem): Fragment {
+ val fragment = mount.fragmentClass.getDeclaredConstructor().newInstance()
+ if (fragment is PlaceholderFragment) {
+ fragment.arguments = PlaceholderFragment.args(mount.moduleTag)
+ }
+ return fragment
+ }
+}
diff --git a/app/src/main/kotlin/com/stec/cmd/MineFragment.kt b/app/src/main/kotlin/com/stec/cmd/MineFragment.kt
new file mode 100644
index 0000000..39e7945
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/MineFragment.kt
@@ -0,0 +1,7 @@
+package com.stec.cmd
+
+import dagger.hilt.android.AndroidEntryPoint
+
+/** 我的占位(M1 登录与配置,S1 挂载登录/配置页)。 */
+@AndroidEntryPoint
+class MineFragment : PlaceholderFragment()
diff --git a/app/src/main/kotlin/com/stec/cmd/PlaceholderFragment.kt b/app/src/main/kotlin/com/stec/cmd/PlaceholderFragment.kt
new file mode 100644
index 0000000..4dc9fa8
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/PlaceholderFragment.kt
@@ -0,0 +1,45 @@
+package com.stec.cmd
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import com.stec.cmd.databinding.FragmentPlaceholderBinding
+
+/**
+ * S0 占位页基类:显示模块编号与挂载说明,S1+ 由各业务 Fragment 替换。
+ */
+open class PlaceholderFragment : Fragment() {
+
+ private var _binding: FragmentPlaceholderBinding? = null
+ private val binding get() = checkNotNull(_binding)
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = FragmentPlaceholderBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val moduleTag = arguments?.getString(ARG_MODULE_TAG).orEmpty()
+ binding.moduleTag.text = moduleTag
+ binding.hint.text = getString(R.string.placeholder_hint)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ companion object {
+ const val ARG_MODULE_TAG = "module_tag"
+
+ fun args(moduleTag: String): Bundle = Bundle().apply {
+ putString(ARG_MODULE_TAG, moduleTag)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/stec/cmd/StatsFragment.kt b/app/src/main/kotlin/com/stec/cmd/StatsFragment.kt
new file mode 100644
index 0000000..7b22d31
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/StatsFragment.kt
@@ -0,0 +1,7 @@
+package com.stec.cmd
+
+import dagger.hilt.android.AndroidEntryPoint
+
+/** 统计占位(M6 统计与上传,S4 挂载统计表与文件上传)。 */
+@AndroidEntryPoint
+class StatsFragment : PlaceholderFragment()
diff --git a/app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt b/app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt
new file mode 100644
index 0000000..723bb2c
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt
@@ -0,0 +1,31 @@
+package com.stec.cmd
+
+import android.app.Application
+import android.util.Log
+import com.stec.cmd.core.common.AppLog
+import dagger.hilt.android.HiltAndroidApp
+
+/**
+ * 应用入口:装配 Hilt 图并安装日志实现。
+ * core:common 的 AppLog 门面不依赖 android.util.Log,LogCat sink 在壳层注入。
+ */
+@HiltAndroidApp
+class SteCmdApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ AppLog.install { level, tag, message, throwable ->
+ when (level) {
+ AppLog.Level.DEBUG -> Log.d(tag, message, throwable)
+ AppLog.Level.INFO -> Log.i(tag, message, throwable)
+ AppLog.Level.WARN -> Log.w(tag, message, throwable)
+ AppLog.Level.ERROR -> Log.e(tag, message, throwable)
+ }
+ }
+ AppLog.i(TAG, "监测数据不落地平台客户端启动(S0 骨架)")
+ }
+
+ private companion object {
+ const val TAG = "SteCmd"
+ }
+}
diff --git a/app/src/main/kotlin/com/stec/cmd/TaskFragment.kt b/app/src/main/kotlin/com/stec/cmd/TaskFragment.kt
new file mode 100644
index 0000000..557a73e
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/TaskFragment.kt
@@ -0,0 +1,7 @@
+package com.stec.cmd
+
+import dagger.hilt.android.AndroidEntryPoint
+
+/** 任务占位(M3 任务管理,S2 挂载任务列表)。 */
+@AndroidEntryPoint
+class TaskFragment : PlaceholderFragment()
diff --git a/app/src/main/kotlin/com/stec/cmd/config/AppModule.kt b/app/src/main/kotlin/com/stec/cmd/config/AppModule.kt
new file mode 100644
index 0000000..df3de3e
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/config/AppModule.kt
@@ -0,0 +1,49 @@
+package com.stec.cmd.config
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import com.stec.cmd.core.network.NetworkConfig
+import com.stec.cmd.core.network.TokenProvider
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+private val Context.configDataStore: DataStore by preferencesDataStore(
+ name = "stec_cmd_config",
+)
+
+/**
+ * app 壳 Hilt 装配:
+ * - DataStore 实例;
+ * - [ConfigRepository] 绑定为 core:network 的 [NetworkConfig] / [TokenProvider],
+ * 补全 core:network NetworkModule 的依赖图(密钥值由用户配置注入,不入库)。
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideConfigDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.configDataStore
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class ConfigBindModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindNetworkConfig(impl: ConfigRepository): NetworkConfig
+
+ @Binds
+ @Singleton
+ abstract fun bindTokenProvider(impl: ConfigRepository): TokenProvider
+}
diff --git a/app/src/main/kotlin/com/stec/cmd/config/ConfigRepository.kt b/app/src/main/kotlin/com/stec/cmd/config/ConfigRepository.kt
new file mode 100644
index 0000000..a832901
--- /dev/null
+++ b/app/src/main/kotlin/com/stec/cmd/config/ConfigRepository.kt
@@ -0,0 +1,127 @@
+package com.stec.cmd.config
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.stringPreferencesKey
+import com.stec.cmd.core.common.AppLog
+import com.stec.cmd.core.network.NetworkConfig
+import com.stec.cmd.core.network.TokenProvider
+import java.io.IOException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * 平台配置仓储(M1-06 前置、M7-04 落地):
+ * baseUrl / SecretKey / SystemCode / Token 经 DataStore 持久化 + 内存快照。
+ *
+ * **默认值全空,密钥不进代码库**:S1 在「我的」页面提供配置 UI;
+ * 未配置时 [NetworkConfig] 各字段为空串,AuthInterceptor 将带空三要素,
+ * 业务请求由 S1 的配置检查(未配置 → 跳转配置页)拦截。
+ *
+ * 拦截器在 OkHttp 后台线程同步读快照(runBlocking 只在冷启动首读时短暂阻塞,
+ * 之后 [snapshotStateFlow] 已被预热,直接返回缓存值)。
+ */
+@Singleton
+class ConfigRepository @Inject constructor(
+ private val dataStore: DataStore,
+) : NetworkConfig, TokenProvider {
+
+ data class Snapshot(
+ val baseUrl: String = "",
+ val secretKey: String = "",
+ val systemCode: String = "",
+ val token: String = "",
+ ) {
+ /** 三要素 + 地址是否已配置完整。 */
+ val isReady: Boolean
+ get() = baseUrl.isNotBlank() && secretKey.isNotBlank() && systemCode.isNotBlank()
+ }
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ private val snapshotFlow = MutableStateFlow(Snapshot())
+
+ /** 配置快照流:S1 配置页/业务层订阅。 */
+ val snapshotStateFlow: StateFlow = snapshotFlow.asStateFlow()
+
+ init {
+ scope.launch {
+ dataStore.data
+ .catch { e ->
+ if (e is IOException) emit(emptyPreferences()) else throw e
+ }
+ .collect { prefs ->
+ snapshotFlow.value = Snapshot(
+ baseUrl = prefs[KEY_BASE_URL].orEmpty(),
+ secretKey = prefs[KEY_SECRET_KEY].orEmpty(),
+ systemCode = prefs[KEY_SYSTEM_CODE].orEmpty(),
+ token = prefs[KEY_TOKEN].orEmpty(),
+ )
+ }
+ }
+ }
+
+ // ---- NetworkConfig ----
+
+ override val baseUrl: String get() = snapshot().baseUrl
+
+ override val secretKey: String get() = snapshot().secretKey
+
+ override val systemCode: String get() = snapshot().systemCode
+
+ override val tokenProvider: TokenProvider get() = this
+
+ // ---- TokenProvider ----
+
+ override fun currentToken(): String = snapshot().token
+
+ // ---- 写入(S1 配置页 / 登录流程调用)----
+
+ /** 保存三要素与服务器地址。 */
+ suspend fun updateConnection(baseUrl: String, secretKey: String, systemCode: String) {
+ dataStore.edit { prefs ->
+ prefs[KEY_BASE_URL] = baseUrl.trim().trimEnd('/')
+ prefs[KEY_SECRET_KEY] = secretKey.trim()
+ prefs[KEY_SYSTEM_CODE] = systemCode.trim()
+ }
+ AppLog.i(TAG, "连接配置已更新")
+ }
+
+ /** 登录成功保存 Token;登出传空串。 */
+ suspend fun updateToken(token: String) {
+ dataStore.edit { it[KEY_TOKEN] = token }
+ }
+
+ /** 阻塞读取当前快照(拦截器线程安全:值来自内存缓存)。 */
+ private fun snapshot(): Snapshot =
+ if (snapshotFlow.value != Snapshot()) {
+ snapshotFlow.value
+ } else {
+ // 冷启动首读:预热缓存
+ runBlocking { snapshotFlow.first() }
+ }
+
+ private companion object {
+ const val TAG = "ConfigRepository"
+ val KEY_BASE_URL = stringPreferencesKey("base_url")
+ val KEY_SECRET_KEY = stringPreferencesKey("secret_key")
+ val KEY_SYSTEM_CODE = stringPreferencesKey("system_code")
+ val KEY_TOKEN = stringPreferencesKey("token")
+ }
+}
+
+/** 只读 Token 快照接口的窄化视图(给不需要完整 NetworkConfig 的组件用)。 */
+val ConfigRepository.tokenFlow: Flow get() = snapshotStateFlow
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..29d23ec
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..c7e3b93
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_collect.xml b/app/src/main/res/drawable/ic_nav_collect.xml
new file mode 100644
index 0000000..6f963d4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_collect.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_home.xml b/app/src/main/res/drawable/ic_nav_home.xml
new file mode 100644
index 0000000..46fa310
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_home.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_mine.xml b/app/src/main/res/drawable/ic_nav_mine.xml
new file mode 100644
index 0000000..49bb764
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_mine.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_stats.xml b/app/src/main/res/drawable/ic_nav_stats.xml
new file mode 100644
index 0000000..cd85688
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_stats.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_task.xml b/app/src/main/res/drawable/ic_nav_task.xml
new file mode 100644
index 0000000..f0a9319
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_task.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..7d635f5
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_placeholder.xml b/app/src/main/res/layout/fragment_placeholder.xml
new file mode 100644
index 0000000..b78aa28
--- /dev/null
+++ b/app/src/main/res/layout/fragment_placeholder.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml
new file mode 100644
index 0000000..3a49d81
--- /dev/null
+++ b/app/src/main/res/menu/bottom_nav_menu.xml
@@ -0,0 +1,23 @@
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..41eebb3
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+
+ #1E6FD9
+ #FFFFFF
+ #F7F9FC
+ #FFFFFF
+ #1E6FD9
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..876da65
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,12 @@
+
+
+ 监测数据不落地
+
+ 首页
+ 任务
+ 采集
+ 统计
+ 我的
+
+ 占位页 —— 业务功能按 S1–S4 计划经 FeatureMount 挂载
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..911078a
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..836a8b2
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+ 106.15.183.20
+
+
diff --git a/ble-protocol/build.gradle.kts b/ble-protocol/build.gradle.kts
new file mode 100644
index 0000000..e936a52
--- /dev/null
+++ b/ble-protocol/build.gradle.kts
@@ -0,0 +1,13 @@
+// ble-protocol:纯 Kotlin JVM 模块,零第三方依赖(测试框架除外)
+// 职责:IE-1000 / VM208 蓝牙帧的解析与编码,脱离 Android 可独立单测
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+
+dependencies {
+ testImplementation(libs.junit)
+}
diff --git a/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/ie1000/Ie1000FrameParser.kt b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/ie1000/Ie1000FrameParser.kt
new file mode 100644
index 0000000..b0bf100
--- /dev/null
+++ b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/ie1000/Ie1000FrameParser.kt
@@ -0,0 +1,85 @@
+package com.stec.cmd.protocol.ie1000
+
+/**
+ * IE-1000 Notify 帧解析器(协议原文见 docs/BLE-IE1000.txt)。
+ *
+ * 帧格式(固定 12 字节):
+ * ```
+ * 'Y'(0x59) P0 P1 P2 P3 'T'(0x54) T0 T1 T2 T3 0x0D 0x0A
+ * ```
+ * 其中压力 4 字节、温度 4 字节均为小端 IEEE-754 float。
+ * 协议示例:`59 D8 BE 50 41 54 00 00 E8 41 0D 0A` → 压力 13.04、温度 29.0。
+ *
+ * BLE Notify 每次回调的字节数不定,解析器内部维护字节缓冲,
+ * [feed] 可按任意分片反复调用,按 `0D 0A` 定界切帧;
+ * 切出但不合规格式的帧(长度/帧头不符)整体丢弃后继续重同步。
+ */
+class Ie1000FrameParser {
+
+ private val buffer = ArrayDeque()
+
+ /** 追加收到的字节,返回本轮累计解析出的完整读数列表。 */
+ fun feed(data: ByteArray): List {
+ for (b in data) buffer.addLast(b)
+ val readings = mutableListOf()
+ while (true) {
+ val frame = nextFrame() ?: break
+ parseFrame(frame)?.let(readings::add)
+ }
+ return readings
+ }
+
+ /** 清空内部缓冲(断连或采集会话重置时调用)。 */
+ fun reset() {
+ buffer.clear()
+ }
+
+ /** 从缓冲头部取出一条以 `0D 0A` 结尾的帧(含结束符);不足一条返回 null。 */
+ private fun nextFrame(): List? {
+ var index = 0
+ var end = -1
+ while (index + 1 < buffer.size) {
+ if (buffer.elementAt(index) == CR && buffer.elementAt(index + 1) == LF) {
+ end = index
+ break
+ }
+ index++
+ }
+ if (end == -1) return null
+ val frame = ArrayList(end + 2)
+ repeat(end + 2) { frame.add(buffer.removeFirst()) }
+ return frame
+ }
+
+ private fun parseFrame(frame: List): Ie1000Reading? {
+ if (frame.size != FRAME_LENGTH) return null
+ if (frame[0] != HEADER_PRESSURE || frame[5] != HEADER_TEMPERATURE) return null
+ val pressure = Float.fromBits(frame.leIntAt(offset = 1))
+ val temperature = Float.fromBits(frame.leIntAt(offset = 6))
+ return Ie1000Reading(
+ pressure = pressure,
+ temperature = temperature,
+ receivedAtMillis = System.currentTimeMillis(),
+ )
+ }
+
+ /** 小端序读取 4 字节为 Int(位模式,交由 [Float.fromBits] 解释)。 */
+ private fun List.leIntAt(offset: Int): Int =
+ (this[offset].toInt() and 0xFF) or
+ ((this[offset + 1].toInt() and 0xFF) shl 8) or
+ ((this[offset + 2].toInt() and 0xFF) shl 16) or
+ ((this[offset + 3].toInt() and 0xFF) shl 24)
+
+ companion object {
+ const val FRAME_LENGTH = 12
+
+ /** 压力段帧头 'Y'。 */
+ val HEADER_PRESSURE: Byte = 'Y'.code.toByte()
+
+ /** 温度段帧头 'T'。 */
+ val HEADER_TEMPERATURE: Byte = 'T'.code.toByte()
+
+ private const val CR: Byte = 0x0D
+ private const val LF: Byte = 0x0A
+ }
+}
diff --git a/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/ie1000/Ie1000Reading.kt b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/ie1000/Ie1000Reading.kt
new file mode 100644
index 0000000..5fe4c71
--- /dev/null
+++ b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/ie1000/Ie1000Reading.kt
@@ -0,0 +1,14 @@
+package com.stec.cmd.protocol.ie1000
+
+/**
+ * IE-1000 振弦式读数仪单帧采集读数。
+ *
+ * @property pressure 压力值(float 原值,量程单位依设备标定)
+ * @property temperature 温度值(℃)
+ * @property receivedAtMillis 本机接收时刻(epoch 毫秒)
+ */
+data class Ie1000Reading(
+ val pressure: Float,
+ val temperature: Float,
+ val receivedAtMillis: Long,
+)
diff --git a/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208CommandEncoder.kt b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208CommandEncoder.kt
new file mode 100644
index 0000000..1149837
--- /dev/null
+++ b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208CommandEncoder.kt
@@ -0,0 +1,46 @@
+package com.stec.cmd.protocol.vm208
+
+/**
+ * VM208 下行命令编码器(写 UUID,协议 6.1)。
+ *
+ * 激励设置命令格式:`0F 0F 06 0A XX XX 0D 0A`,
+ * 其中 `XX XX` 为设置值的大端 16 位表示。
+ * 例:设置 100(0x0064)→ 整条命令 `0F 0F 06 0A 00 64 0D 0A`;
+ * 设备通过 Notify 原样回显该命令作为应答。
+ */
+object Vm208CommandEncoder {
+
+ /**
+ * 编码激励设置命令。
+ *
+ * @param excitation 激励设置值,须在 0..65535
+ * @return 8 字节完整命令帧
+ * @throws IllegalArgumentException 超出 16 位范围
+ */
+ fun encodeExcitation(excitation: Int): ByteArray {
+ require(excitation in 0..0xFFFF) { "激励值超出 16 位范围: $excitation" }
+ return byteArrayOf(
+ 0x0F, 0x0F, 0x06, 0x0A,
+ ((excitation shr 8) and 0xFF).toByte(),
+ (excitation and 0xFF).toByte(),
+ 0x0D, 0x0A,
+ )
+ }
+
+ /**
+ * 解析设备回显的激励命令应答帧。
+ *
+ * @return 应答中的激励值;帧长或帧头不符返回 null
+ */
+ fun decodeExcitationAck(frame: ByteArray): Int? {
+ if (frame.size != COMMAND_LENGTH) return null
+ if (frame[0] != 0x0F.toByte() || frame[1] != 0x0F.toByte()) return null
+ if (frame[2] != 0x06.toByte() || frame[3] != 0x0A.toByte()) return null
+ if (frame[6] != 0x0D.toByte() || frame[7] != 0x0A.toByte()) return null
+ val high = frame[4].toInt() and 0xFF
+ val low = frame[5].toInt() and 0xFF
+ return (high shl 8) or low
+ }
+
+ const val COMMAND_LENGTH = 8
+}
diff --git a/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParser.kt b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParser.kt
new file mode 100644
index 0000000..03cfe64
--- /dev/null
+++ b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParser.kt
@@ -0,0 +1,114 @@
+package com.stec.cmd.protocol.vm208
+
+/**
+ * VM208 Notify 帧解析器(协议原文见 docs/BLE-IE1000.txt)。
+ *
+ * 振弦数据报文格式(ASCII 字段 + 固定结束符):
+ * ```
+ * CC F12345 T334 E14039 SG AA 0x0D 0x0A
+ * ```
+ * - `CC` 报文头、`AA` 报文尾、`0D 0A` 结束符号;
+ * - `F12345`:频率原值 12345 → 12345/10 = 1234.5 Hz;
+ * - `T334`:温度原值 334 → 334/10 = 33.4 ℃;
+ * - `E14039`:电压原值 14039 → 14039/100 = 140 V;
+ * - `SG`:S 后跟 1 位质量字母(G 好 / Q 一般 / N 没元件)。
+ *
+ * 与 [com.stec.cmd.protocol.ie1000.Ie1000FrameParser] 相同的缓冲策略:
+ * [feed] 支持任意分片,按 `0D 0A` 切帧,字段缺失或非数字的脏帧整体丢弃。
+ */
+class Vm208FrameParser {
+
+ private val buffer = ArrayDeque()
+
+ /** 追加收到的字节,返回本轮累计解析出的完整读数列表。 */
+ fun feed(data: ByteArray): List {
+ for (b in data) buffer.addLast(b)
+ val readings = mutableListOf()
+ while (true) {
+ val frame = nextFrame() ?: break
+ parseFrame(frame)?.let(readings::add)
+ }
+ return readings
+ }
+
+ /** 清空内部缓冲(断连或采集会话重置时调用)。 */
+ fun reset() {
+ buffer.clear()
+ }
+
+ private fun nextFrame(): List? {
+ var index = 0
+ var end = -1
+ while (index + 1 < buffer.size) {
+ if (buffer.elementAt(index) == CR && buffer.elementAt(index + 1) == LF) {
+ end = index
+ break
+ }
+ index++
+ }
+ if (end == -1) return null
+ val frame = ArrayList(end + 2)
+ repeat(end + 2) { frame.add(buffer.removeFirst()) }
+ return frame
+ }
+
+ private fun parseFrame(frame: List): Vm208Reading? {
+ if (frame.size < MIN_FRAME_LENGTH) return null
+ if (frame[0] != HEADER_CC || frame[frame.size - 3] != TAIL_AA) return null
+ val text = frame.toByteArray().toString(Charsets.US_ASCII)
+ val frequency = fieldOf(text, FIELD_FREQUENCY)?.div(SCALE_FREQUENCY) ?: return null
+ val temperature = fieldOf(text, FIELD_TEMPERATURE)?.div(SCALE_TEMPERATURE) ?: return null
+ val voltage = fieldOf(text, FIELD_VOLTAGE)?.div(SCALE_VOLTAGE) ?: return null
+ val qualityCode = qualityCodeOf(text) ?: return null
+ return Vm208Reading(
+ frequency = frequency,
+ temperature = temperature,
+ voltage = voltage,
+ quality = Vm208Quality.fromCode(qualityCode),
+ receivedAtMillis = System.currentTimeMillis(),
+ )
+ }
+
+ /** 取 `X` 字段后直到下一字母或报文尾的 ASCII 内容并按整数解析;缺失/非法返回 null。 */
+ private fun fieldOf(text: String, field: Char): Double? =
+ fieldRaw(text, field)?.toDoubleOrNull()
+
+ private fun fieldRaw(text: String, field: Char): String? {
+ val start = text.indexOf(field)
+ if (start < 0) return null
+ var end = start + 1
+ while (end < text.length && text[end].isDigit()) end++
+ if (end == start + 1) return null
+ return text.substring(start + 1, end)
+ }
+
+ /** 取 S 质量字段后的 1 个字母(G/Q/N,其余交由 UNKNOWN 兜底);缺字段返回 null。 */
+ private fun qualityCodeOf(text: String): Char? {
+ val start = text.indexOf(FIELD_QUALITY)
+ if (start < 0 || start + 1 >= text.length) return null
+ return text[start + 1]
+ }
+
+ companion object {
+ /** 最短帧:CC F1 T1 E1 Sx AA CR LF = 9 字节。 */
+ const val MIN_FRAME_LENGTH = 9
+
+ /** 报文头 'C''C' 的首字节。 */
+ val HEADER_CC: Byte = 'C'.code.toByte()
+
+ /** 报文尾 'A''A' 的首字节(其后为 0D 0A 结束符)。 */
+ val TAIL_AA: Byte = 'A'.code.toByte()
+
+ private const val FIELD_FREQUENCY = 'F'
+ private const val FIELD_TEMPERATURE = 'T'
+ private const val FIELD_VOLTAGE = 'E'
+ private const val FIELD_QUALITY = 'S'
+
+ private const val SCALE_FREQUENCY = 10.0
+ private const val SCALE_TEMPERATURE = 10.0
+ private const val SCALE_VOLTAGE = 100.0
+
+ private const val CR: Byte = 0x0D
+ private const val LF: Byte = 0x0A
+ }
+}
diff --git a/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208Reading.kt b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208Reading.kt
new file mode 100644
index 0000000..87aedb9
--- /dev/null
+++ b/ble-protocol/src/main/kotlin/com/stec/cmd/protocol/vm208/Vm208Reading.kt
@@ -0,0 +1,38 @@
+package com.stec.cmd.protocol.vm208
+
+/** VM208 帧内 S 字段携带的元件质量状态。 */
+enum class Vm208Quality(val code: Char) {
+ /** 元件质量好。 */
+ GOOD('G'),
+
+ /** 元件一般。 */
+ FAIR('Q'),
+
+ /** 没有元件。 */
+ NONE('N'),
+
+ /** 非约定字母的兜底,避免脏帧导致崩溃。 */
+ UNKNOWN('?');
+
+ companion object {
+ fun fromCode(code: Char): Vm208Quality =
+ entries.firstOrNull { it.code == code } ?: UNKNOWN
+ }
+}
+
+/**
+ * VM208 综合采集仪单帧采集读数。
+ *
+ * @property frequency 频率(Hz,帧内原值 / 10)
+ * @property temperature 温度(℃,帧内原值 / 10)
+ * @property voltage 电压(V,帧内原值 / 100)
+ * @property quality 元件质量状态
+ * @property receivedAtMillis 本机接收时刻(epoch 毫秒)
+ */
+data class Vm208Reading(
+ val frequency: Double,
+ val temperature: Double,
+ val voltage: Double,
+ val quality: Vm208Quality,
+ val receivedAtMillis: Long,
+)
diff --git a/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/ie1000/Ie1000FrameParserTest.kt b/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/ie1000/Ie1000FrameParserTest.kt
new file mode 100644
index 0000000..f3b2442
--- /dev/null
+++ b/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/ie1000/Ie1000FrameParserTest.kt
@@ -0,0 +1,67 @@
+package com.stec.cmd.protocol.ie1000
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** 用协议文档示例字节做冒烟验证。 */
+class Ie1000FrameParserTest {
+
+ private val parser = Ie1000FrameParser()
+
+ /**
+ * 协议示例帧:59 D8 BE 50 41 54 00 00 E8 41 0D 0A。
+ * 压力段小端 float 0x4150BED8 = 13.0465927…(文档标注 13.04 为截断写法);
+ * 温度段 0x41E80000 = 29.0 精确。
+ */
+ private val sample = byteArrayOf(
+ 0x59.toByte(), 0xD8.toByte(), 0xBE.toByte(), 0x50.toByte(), 0x41.toByte(),
+ 0x54.toByte(), 0x00, 0x00, 0xE8.toByte(), 0x41.toByte(),
+ 0x0D, 0x0A,
+ )
+
+ @Test
+ fun `整帧一次喂入解析出协议示例读数`() {
+ val readings = parser.feed(sample)
+
+ assertEquals(1, readings.size)
+ assertEquals(13.0466f, readings[0].pressure, 0.001f)
+ assertEquals(29.0f, readings[0].temperature, 0.001f)
+ }
+
+ @Test
+ fun `逐字节分片喂入仍可切帧(残包容错)`() {
+ val readings = sample.flatMap { listOf(it) }.map { parser.feed(byteArrayOf(it)) }
+ .reduce { acc, list -> acc + list }
+
+ assertEquals(1, readings.size)
+ assertEquals(13.0466f, readings[0].pressure, 0.001f)
+ }
+
+ @Test
+ fun `粘包两帧一次解析`() {
+ val doubled = sample + sample
+
+ val readings = parser.feed(doubled)
+
+ assertEquals(2, readings.size)
+ }
+
+ @Test
+ fun `脏帧被丢弃后继续重同步`() {
+ val dirty = byteArrayOf(0x01, 0x02, 0x0D, 0x0A) // 长度不足的垃圾帧
+ val readings = parser.feed(dirty + sample)
+
+ assertEquals(1, readings.size)
+ assertEquals(13.0466f, readings[0].pressure, 0.001f)
+ }
+
+ @Test
+ fun `帧头错误的完整帧被丢弃`() {
+ val badHeader = sample.copyOf().also { it[0] = 0x58 } // 'X' 而非 'Y'
+
+ val readings = parser.feed(badHeader)
+
+ assertTrue(readings.isEmpty())
+ }
+}
diff --git a/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParserTest.kt b/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParserTest.kt
new file mode 100644
index 0000000..77359b3
--- /dev/null
+++ b/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParserTest.kt
@@ -0,0 +1,90 @@
+package com.stec.cmd.protocol.vm208
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/** 用协议文档示例帧做冒烟验证。 */
+class Vm208FrameParserTest {
+
+ private val parser = Vm208FrameParser()
+
+ /**
+ * 协议示例报文:CC F12345 T334 E14039 SG AA 0D 0A
+ * → 频率 1234.5 Hz、温度 33.4 ℃、电压 140 V、质量 GOOD。
+ */
+ private fun sampleFrame(): ByteArray =
+ "CC F12345 T334 E14039 SG AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
+
+ @Test
+ fun `整帧解析出协议示例读数`() {
+ val readings = parser.feed(sampleFrame())
+
+ assertEquals(1, readings.size)
+ assertEquals(1234.5, readings[0].frequency, 1e-9)
+ assertEquals(33.4, readings[0].temperature, 1e-9)
+ assertEquals(140.0, readings[0].voltage, 1e-9)
+ assertEquals(Vm208Quality.GOOD, readings[0].quality)
+ }
+
+ @Test
+ fun `分片喂入仍可切帧`() {
+ val frame = sampleFrame()
+ val mid = frame.size / 2
+ val readings = parser.feed(frame.copyOfRange(0, mid)) + parser.feed(frame.copyOfRange(mid, frame.size))
+
+ assertEquals(1, readings.size)
+ assertEquals(Vm208Quality.GOOD, readings[0].quality)
+ }
+
+ @Test
+ fun `状态字母映射 Q 与 N`() {
+ val qFrame = "CC F12345 T334 E14039 SQ AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
+ val nFrame = "CC F12345 T334 E14039 SN AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
+
+ val readings = parser.feed(qFrame + nFrame)
+
+ assertEquals(2, readings.size)
+ assertEquals(Vm208Quality.FAIR, readings[0].quality)
+ assertEquals(Vm208Quality.NONE, readings[1].quality)
+ }
+
+ @Test
+ fun `未知状态字母映射 UNKNOWN 而非崩溃`() {
+ val xFrame = "CC F12345 T334 E14039 SX AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
+
+ val readings = parser.feed(xFrame)
+
+ assertEquals(1, readings.size)
+ assertEquals(Vm208Quality.UNKNOWN, readings[0].quality)
+ }
+
+ @Test
+ fun `缺字段脏帧被丢弃`() {
+ val dirty = "CC F12345 AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
+
+ val readings = parser.feed(dirty + sampleFrame())
+
+ assertEquals(1, readings.size)
+ }
+
+ @Test
+ fun `激励命令编码与应答回解析`() {
+ val command = Vm208CommandEncoder.encodeExcitation(100)
+
+ assertEquals(8, command.size)
+ assertEquals("0F0F060A00640D0A", command.joinToString("") { "%02X".format(it) })
+ assertEquals(100, Vm208CommandEncoder.decodeExcitationAck(command))
+ assertNull(Vm208CommandEncoder.decodeExcitationAck(command.copyOf(7)))
+ }
+
+ @Test
+ fun `激励值越界抛出`() {
+ try {
+ Vm208CommandEncoder.encodeExcitation(0x10000)
+ throw AssertionError("应抛出 IllegalArgumentException")
+ } catch (expected: IllegalArgumentException) {
+ // 预期路径
+ }
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..c97dfc9
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,10 @@
+// 根构建脚本:只声明插件版本(apply false),各模块按需 apply
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.jvm) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.hilt) apply false
+}
diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts
new file mode 100644
index 0000000..34269ea
--- /dev/null
+++ b/core/ble/build.gradle.kts
@@ -0,0 +1,33 @@
+// core:ble 蓝牙层:设备扫描(名称前缀过滤)+ GATT 连接封装骨架
+// 权限申请由 app 壳负责(Android 12L 分版见 app 模块),本模块假定权限已授予
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace = "com.stec.cmd.core.ble"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(project(":core:common"))
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.kotlinx.coroutines.android)
+
+ testImplementation(libs.junit)
+}
diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt
new file mode 100644
index 0000000..facd8de
--- /dev/null
+++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt
@@ -0,0 +1,191 @@
+package com.stec.cmd.core.ble
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresPermission
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeout
+
+/** 连接状态机(骨架期最小集,自动重连策略 S3 扩展)。 */
+sealed interface GattState {
+ data object Connecting : GattState
+ data object Connected : GattState
+ data object ServicesDiscovered : GattState
+ data class Disconnected(val status: Int) : GattState
+}
+
+/**
+ * 单设备 GATT 连接会话骨架。
+ *
+ * 用法(S2/S3 业务侧):
+ * ```
+ * val session = connector.connect(scanned)
+ * session.state.filter { it is GattState.ServicesDiscovered }.first()
+ * session.notifyBytes(BleUuids.profileOf(scanned.type)).collect { bytes ->
+ * val readings = parser.feed(bytes)
+ * }
+ * ```
+ */
+@SuppressLint("MissingPermission")
+class BleSession internal constructor(
+ context: Context,
+ address: String,
+) {
+ private val bluetoothManager =
+ context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+
+ private val gatt: BluetoothGatt? =
+ bluetoothManager.adapter?.getRemoteDevice(address)?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ it.connectGatt(context, false, callback, BluetoothGatt.TRANSPORT_LE)
+ } else {
+ it.connectGatt(context, false, callback)
+ }
+ }
+
+ private val _state = MutableStateFlow(GattState.Connecting)
+
+ /** 连接状态流;Disconnected 后会话即终结。 */
+ val state: StateFlow = _state
+
+ /** 等待服务发现完成(连接后调用一次)。 */
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ suspend fun awaitServicesDiscovered(timeoutMillis: Long = DEFAULT_TIMEOUT) {
+ withTimeout(timeoutMillis) {
+ state.filter { it != GattState.Connecting && it != GattState.Connected }.first()
+ }
+ }
+
+ /**
+ * 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。
+ * 每帧 `0D 0A` 定界由 ble-protocol 解析器负责,本流按 Notify 回调原样发射。
+ */
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ fun notifyBytes(profile: BleUuids.Profile): Flow {
+ val gatt = checkNotNull(gatt) { "GATT 未初始化" }
+ val characteristic = checkNotNull(
+ gatt.getService(profile.service)?.getCharacteristic(profile.notifyCharacteristic),
+ ) { "Notify 特征不存在: ${profile.notifyCharacteristic}" }
+ return callbackFlow {
+ val listener = object : BluetoothGattCallback() {
+ @Deprecated("API 33 起走带 characteristic 参数的新回调;骨架期两版共存")
+ override fun onCharacteristicChanged(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ ) {
+ if (characteristic.uuid == profile.notifyCharacteristic) {
+ trySend(characteristic.value ?: ByteArray(0))
+ }
+ }
+ }
+ check(gatt.setCharacteristicNotification(characteristic, true)) {
+ "开启 Notify 失败: ${profile.notifyCharacteristic}"
+ }
+ gatt.writeDescriptor(
+ characteristic.getDescriptor(DESCRIPTOR_CCCD)?.apply {
+ value = ENABLE_NOTIFICATION_VALUE
+ },
+ )
+ // 单回调实例仅做字节监听(连接生命周期由主 callback 管理)
+ awaitClose {
+ runCatching {
+ gatt.setCharacteristicNotification(characteristic, false)
+ }
+ }
+ }
+ }
+
+ /** 写特征(VM208 激励命令下发)。返回是否写入成功。 */
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ suspend fun write(profile: BleUuids.Profile, data: ByteArray, timeoutMillis: Long = DEFAULT_TIMEOUT): Boolean {
+ val gatt = checkNotNull(gatt) { "GATT 未初始化" }
+ val characteristic = gatt.getService(profile.service)
+ ?.getCharacteristic(profile.writeCharacteristic)
+ ?: return false
+ val done = CompletableDeferred()
+ writeListener.completeWith = done
+ characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+ characteristic.value = data
+ if (!gatt.writeCharacteristic(characteristic)) {
+ done.complete(false)
+ }
+ return withTimeout(timeoutMillis) { done.await() }
+ }
+
+ /** 主动断开并释放 GATT 资源。 */
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ fun disconnect() {
+ gatt?.disconnect()
+ }
+
+ private val callback = object : BluetoothGattCallback() {
+ override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
+ when (newState) {
+ BluetoothProfile.STATE_CONNECTED -> {
+ _state.value = GattState.Connected
+ gatt.discoverServices()
+ }
+
+ BluetoothProfile.STATE_DISCONNECTED -> {
+ _state.value = GattState.Disconnected(status)
+ gatt.close()
+ }
+ }
+ }
+
+ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ _state.value = GattState.ServicesDiscovered
+ } else {
+ _state.value = GattState.Disconnected(status)
+ }
+ }
+
+ override fun onCharacteristicWrite(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ status: Int,
+ ) {
+ writeListener.completeWith.complete(status == BluetoothGatt.GATT_SUCCESS)
+ }
+ }
+
+ /** write 挂起桥:把主 callback 的 onCharacteristicWrite 结果转给调用协程。 */
+ private val writeListener = object {
+ lateinit var completeWith: CompletableDeferred
+ }
+
+ private companion object {
+ const val DEFAULT_TIMEOUT = 10_000L
+
+ /** Client Characteristic Config 描述符(Notify 开关)。 */
+ val DESCRIPTOR_CCCD: java.util.UUID = java.util.UUID.fromString(
+ "00002902-0000-1000-8000-00805f9b34fb",
+ )
+ val ENABLE_NOTIFICATION_VALUE = byteArrayOf(0x01, 0x00)
+ }
+}
+
+/**
+ * GATT 连接入口:按扫描结果建立会话。
+ * S0 骨架不引 Hilt,由 app/S3 按需构造或包装。
+ */
+class BleConnector(private val context: Context) {
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ fun connect(scanned: ScannedDevice): BleSession = BleSession(context, scanned.address)
+}
diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleScanner.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleScanner.kt
new file mode 100644
index 0000000..65dd098
--- /dev/null
+++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleScanner.kt
@@ -0,0 +1,86 @@
+package com.stec.cmd.core.ble
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresPermission
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+
+/** 一次扫描命中:已按名称前缀过滤为目标设备。 */
+data class ScannedDevice(
+ val name: String,
+ val address: String,
+ val rssi: Int,
+ val type: DeviceType,
+)
+
+/** 扫描失败(硬件不可用/扫描过频等),code 为 ScanCallback.SCAN_FAILED_* 常量。 */
+class BleScanFailed(val code: Int) : Exception("BLE 扫描失败: code=$code")
+
+/**
+ * BLE 扫描器:按设备名前缀过滤 IE-1000 / VM208。
+ *
+ * 前缀过滤在内存中做(广播名偶有截断,[ScanFilter.setDeviceNamePrefix]
+ * 在部分机型不可靠),全量回调后按 [DeviceType.fromDeviceName] 筛选。
+ * 权限由 app 壳申请(12L+ 为 BLUETOOTH_SCAN/CONNECT,旧版为 SCAN + 定位)。
+ */
+class BleScanner(context: Context) {
+
+ private val bluetoothManager =
+ context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+
+ /**
+ * 开始扫描,命中流自动去重(同地址只保留最近一次)。
+ * 收集端取消或调用 [close] 时停止扫描。
+ */
+ @SuppressLint("MissingPermission")
+ @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
+ fun scan(
+ types: Set = DeviceType.entries.toSet(),
+ ): Flow {
+ val scanner = bluetoothManager.adapter?.bluetoothLeScanner
+ ?: throw BleScanFailed(UNAVAILABLE_ADAPTER)
+ return callbackFlow {
+ val settings = ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .build()
+ val callback = object : ScanCallback() {
+ private val seen = HashMap()
+
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ val name = result.scanRecord?.deviceName ?: result.device?.name
+ val type = DeviceType.fromDeviceName(name) ?: return
+ if (type !in types) return
+ val device = ScannedDevice(
+ name = name,
+ address = result.device.address,
+ rssi = result.rssi,
+ type = type,
+ )
+ // 同地址覆盖发射:UI 层按 address 收敛列表
+ seen[device.address] = device
+ trySend(device)
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ close(BleScanFailed(errorCode))
+ }
+ }
+ scanner.startScan(null, settings, callback)
+ awaitClose {
+ runCatching { scanner.stopScan(callback) }
+ }
+ }
+ }
+
+ private companion object {
+ const val UNAVAILABLE_ADAPTER = -100
+ }
+}
diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleUuids.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleUuids.kt
new file mode 100644
index 0000000..c9ecdbd
--- /dev/null
+++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleUuids.kt
@@ -0,0 +1,42 @@
+package com.stec.cmd.core.ble
+
+import java.util.UUID
+
+/**
+ * 两类设备的 GATT UUID 汇总(协议原文 docs/BLE-IE1000.txt)。
+ *
+ * ⚠ VM208 服务 UUID 原文档仅 31 位十六进制(`0000fee2000010a0800000602f9b3698`),
+ * 不足标准 128 位(32 位)。此处按末位补 0 占位:
+ * **S2 真机联调时须用 nRF Connect 实测校准**,确认后再更新本文件。
+ */
+object BleUuids {
+
+ /** 单设备的 GATT 档案:服务 + 写特征 + Notify 特征。 */
+ data class Profile(
+ val service: UUID,
+ val writeCharacteristic: UUID,
+ val notifyCharacteristic: UUID,
+ )
+
+ /** IE-1000 振弦式读数仪。 */
+ val IE1000: Profile = Profile(
+ service = uuid("0000fee6-0000-2000-6000-006012345678"),
+ writeCharacteristic = uuid("FE000000-0000-0000-0000-000000000580"),
+ notifyCharacteristic = uuid("FE000000-0000-0000-0000-000000000581"),
+ )
+
+ /** VM208 综合采集仪(服务 UUID 见类注释⚠)。 */
+ val VM208: Profile = Profile(
+ service = uuid("0000fee2-0000-10a0-8000-0602f9b36980"),
+ writeCharacteristic = uuid("a44bc439-abfd-45a2-4254-2d4d31129700"),
+ notifyCharacteristic = uuid("a44bc439-abfd-45a2-4254-2d4d31129701"),
+ )
+
+ /** 按设备类型取档案。 */
+ fun profileOf(type: DeviceType): Profile = when (type) {
+ DeviceType.IE1000 -> IE1000
+ DeviceType.VM208 -> VM208
+ }
+
+ private fun uuid(value: String): UUID = UUID.fromString(value)
+}
diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/DeviceType.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/DeviceType.kt
new file mode 100644
index 0000000..c6e9733
--- /dev/null
+++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/DeviceType.kt
@@ -0,0 +1,22 @@
+package com.stec.cmd.core.ble
+
+/**
+ * 平台支持的采集设备类型(协议原文见 docs/BLE-IE1000.txt)。
+ *
+ * 蓝牙名以**前缀**匹配:
+ * - IE-1000 振弦式读数仪,广播名 `IE-1000`;
+ * - VM208 综合采集仪,广播名 `VM208 xxxxxx`(带序列号后缀)。
+ */
+enum class DeviceType(val namePrefix: String) {
+ IE1000("IE-1000"),
+ VM208("VM208");
+
+ companion object {
+
+ /** 按广播名前缀识别设备类型;无法识别返回 null(外场杂散设备直接忽略)。 */
+ fun fromDeviceName(name: String?): DeviceType? {
+ if (name.isNullOrEmpty()) return null
+ return entries.firstOrNull { name.startsWith(it.namePrefix) }
+ }
+ }
+}
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
new file mode 100644
index 0000000..7d82d49
--- /dev/null
+++ b/core/common/build.gradle.kts
@@ -0,0 +1,30 @@
+// core:common 通用基础层:AppResult / UiState / 日志门面
+// 代码保持纯 Kotlin(不引 Android API),便于 JVM 单测与被所有 core 模块复用
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace = "com.stec.cmd.core.common"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ api(libs.kotlinx.coroutines.core)
+
+ testImplementation(libs.junit)
+}
diff --git a/core/common/src/main/kotlin/com/stec/cmd/core/common/AppLog.kt b/core/common/src/main/kotlin/com/stec/cmd/core/common/AppLog.kt
new file mode 100644
index 0000000..cdaa6c0
--- /dev/null
+++ b/core/common/src/main/kotlin/com/stec/cmd/core/common/AppLog.kt
@@ -0,0 +1,36 @@
+package com.stec.cmd.core.common
+
+/**
+ * 轻量日志门面:core 层不依赖 android.util.Log,
+ * app 壳启动时注入 LogCat 实现(AppLog.sink = ...),测试环境可不注入或注入内存实现。
+ */
+object AppLog {
+
+ enum class Level { DEBUG, INFO, WARN, ERROR }
+
+ fun interface Sink {
+ fun log(level: Level, tag: String, message: String, throwable: Throwable?)
+ }
+
+ @Volatile
+ private var sink: Sink? = null
+
+ /** app 启动时调用一次;传 null 恢复为静默(适用于单元测试)。 */
+ fun install(sink: Sink?) {
+ this.sink = sink
+ }
+
+ fun d(tag: String, message: String) = dispatch(Level.DEBUG, tag, message, null)
+
+ fun i(tag: String, message: String) = dispatch(Level.INFO, tag, message, null)
+
+ fun w(tag: String, message: String, throwable: Throwable? = null) =
+ dispatch(Level.WARN, tag, message, throwable)
+
+ fun e(tag: String, message: String, throwable: Throwable? = null) =
+ dispatch(Level.ERROR, tag, message, throwable)
+
+ private fun dispatch(level: Level, tag: String, message: String, throwable: Throwable?) {
+ sink?.log(level, tag, message, throwable)
+ }
+}
diff --git a/core/common/src/main/kotlin/com/stec/cmd/core/common/AppResult.kt b/core/common/src/main/kotlin/com/stec/cmd/core/common/AppResult.kt
new file mode 100644
index 0000000..046af76
--- /dev/null
+++ b/core/common/src/main/kotlin/com/stec/cmd/core/common/AppResult.kt
@@ -0,0 +1,48 @@
+package com.stec.cmd.core.common
+
+/**
+ * 全工程统一的结果封装:UI 层与数据层之间只通过 [AppResult] 传递成败,
+ * 具体业务异常(网络/数据库/协议)由各 core 模块定义并在此统一消费。
+ */
+sealed interface AppResult {
+
+ /** 成功,携带业务数据。 */
+ data class Success(val data: T) : AppResult
+
+ /** 失败,携带原始异常与可选的人类可读说明。 */
+ data class Error(
+ val error: Throwable,
+ val message: String? = error.message,
+ ) : AppResult
+
+ /** 加载中(首屏/刷新语义由 UI 层区分)。 */
+ data object Loading : AppResult
+
+ companion object {
+ /** 同步块结果捕获:块内抛出的任意异常转为 [Error]。 */
+ inline fun of(block: () -> T): AppResult = try {
+ Success(block())
+ } catch (t: Throwable) {
+ Error(t)
+ }
+ }
+}
+
+/** 成功值映射,失败/加载态原样透传。 */
+inline fun AppResult.map(transform: (T) -> R): AppResult = when (this) {
+ is AppResult.Success -> AppResult.Success(transform(data))
+ is AppResult.Error -> this
+ is AppResult.Loading -> this
+}
+
+/** 成功时的副作用钩子,返回值不变。 */
+inline fun AppResult.onSuccess(block: (T) -> Unit): AppResult {
+ if (this is AppResult.Success) block(data)
+ return this
+}
+
+/** 失败时的副作用钩子,返回值不变。 */
+inline fun AppResult.onError(block: (AppResult.Error) -> Unit): AppResult {
+ if (this is AppResult.Error) block(this)
+ return this
+}
diff --git a/core/common/src/main/kotlin/com/stec/cmd/core/common/UiState.kt b/core/common/src/main/kotlin/com/stec/cmd/core/common/UiState.kt
new file mode 100644
index 0000000..14841aa
--- /dev/null
+++ b/core/common/src/main/kotlin/com/stec/cmd/core/common/UiState.kt
@@ -0,0 +1,28 @@
+package com.stec.cmd.core.common
+
+/**
+ * 页面级视图状态:MVVM 中 ViewModel 对 UI 暴露的最小状态机。
+ * 与 [AppResult] 解耦——AppResult 描述一次调用,UiState 描述一个页面。
+ */
+sealed interface UiState {
+
+ /** 初始空闲态(尚未发起加载)。 */
+ data object Idle : UiState
+
+ /** 加载中。 */
+ data object Loading : UiState
+
+ /** 内容就绪。 */
+ data class Success(val data: T) : UiState
+
+ /**
+ * 失败态。
+ *
+ * @property message 用户可读的错误说明
+ * @property retryable 是否允许重试(429/403 等限流场景由业务层定)
+ */
+ data class Error(
+ val message: String,
+ val retryable: Boolean = true,
+ ) : UiState
+}
diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts
new file mode 100644
index 0000000..d58a19c
--- /dev/null
+++ b/core/database/build.gradle.kts
@@ -0,0 +1,38 @@
+// core:database 本地存储:Room AppDatabase + 上传队列(离线优先参考实现)
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+}
+
+android {
+ namespace = "com.stec.cmd.core.database"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(project(":core:common"))
+
+ api(libs.androidx.room.runtime)
+ api(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
+
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+
+ testImplementation(libs.junit)
+}
diff --git a/core/database/src/main/kotlin/com/stec/cmd/core/database/AppDatabase.kt b/core/database/src/main/kotlin/com/stec/cmd/core/database/AppDatabase.kt
new file mode 100644
index 0000000..a891329
--- /dev/null
+++ b/core/database/src/main/kotlin/com/stec/cmd/core/database/AppDatabase.kt
@@ -0,0 +1,27 @@
+package com.stec.cmd.core.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+/**
+ * 应用数据库骨架。
+ *
+ * S0 仅注册上传队列一张表作为离线优先参考实现;
+ * S1+ 新业务实体(任务缓存、测点、统计数据等)在 entities 中追加并升 version。
+ * 骨架期 exportSchema=false(无迁移需求);开启迁移管理时再导出 schema。
+ */
+@Database(
+ entities = [
+ UploadQueueEntity::class,
+ ],
+ version = 1,
+ exportSchema = false,
+)
+abstract class AppDatabase : RoomDatabase() {
+
+ abstract fun uploadQueueDao(): UploadQueueDao
+
+ companion object {
+ const val DATABASE_NAME = "stec-cmd.db"
+ }
+}
diff --git a/core/database/src/main/kotlin/com/stec/cmd/core/database/DatabaseModule.kt b/core/database/src/main/kotlin/com/stec/cmd/core/database/DatabaseModule.kt
new file mode 100644
index 0000000..326c546
--- /dev/null
+++ b/core/database/src/main/kotlin/com/stec/cmd/core/database/DatabaseModule.kt
@@ -0,0 +1,32 @@
+package com.stec.cmd.core.database
+
+import android.content.Context
+import androidx.room.Room
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+/** 数据库 Hilt 装配:全工程共享一个 AppDatabase。 */
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun provideAppDatabase(
+ @ApplicationContext context: Context,
+ ): AppDatabase = Room.databaseBuilder(
+ context,
+ AppDatabase::class.java,
+ AppDatabase.DATABASE_NAME,
+ ).build()
+
+ @Provides
+ @Singleton
+ fun provideUploadQueueDao(
+ database: AppDatabase,
+ ): UploadQueueDao = database.uploadQueueDao()
+}
diff --git a/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueDao.kt b/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueDao.kt
new file mode 100644
index 0000000..95a5d28
--- /dev/null
+++ b/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueDao.kt
@@ -0,0 +1,54 @@
+package com.stec.cmd.core.database
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 上传队列 DAO:演示离线优先的入队/取待传/回写状态闭环。
+ * S1+ 各数据类型 DAO 照此挂载到 [AppDatabase]。
+ */
+@Dao
+interface UploadQueueDao {
+
+ /** 入队一条待上传数据。 */
+ @Insert
+ suspend fun enqueue(entity: UploadQueueEntity): Long
+
+ /** 按入队顺序观察待上传(含失败待重试)队列。 */
+ @Query(
+ "SELECT * FROM upload_queue " +
+ "WHERE status IN (:statuses) ORDER BY created_at ASC",
+ )
+ fun observeByStatuses(vararg statuses: Int): Flow>
+
+ /** 一次性取待上传列表(上传调度器使用)。 */
+ @Query(
+ "SELECT * FROM upload_queue " +
+ "WHERE status IN (:statuses) ORDER BY created_at ASC LIMIT :limit",
+ )
+ suspend fun takePending(statuses: List, limit: Int = DEFAULT_BATCH): List
+
+ /** 标记上传中。 */
+ @Query("UPDATE upload_queue SET status = :status WHERE id = :id")
+ suspend fun updateStatus(id: Long, status: Int)
+
+ /** 标记成功并记录时间。 */
+ @Query("UPDATE upload_queue SET status = :status, uploaded_at = :uploadedAt WHERE id = :id")
+ suspend fun markSuccess(id: Long, status: Int, uploadedAt: Long)
+
+ /** 失败重试:状态回待传并累计重试次数。 */
+ @Query(
+ "UPDATE upload_queue SET status = :status, retry_count = retry_count + 1 WHERE id = :id",
+ )
+ suspend fun markRetry(id: Long, status: Int)
+
+ /** 清理已成功且早于截止时间的记录(队列瘦身)。 */
+ @Query("DELETE FROM upload_queue WHERE status = :status AND uploaded_at IS NOT NULL AND uploaded_at < :before")
+ suspend fun purgeSuccessBefore(status: Int, before: Long): Int
+
+ companion object {
+ private const val DEFAULT_BATCH = 50
+ }
+}
diff --git a/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueEntity.kt b/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueEntity.kt
new file mode 100644
index 0000000..c983ddc
--- /dev/null
+++ b/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueEntity.kt
@@ -0,0 +1,67 @@
+package com.stec.cmd.core.database
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * 采集数据上传队列(离线优先参考实现,对应功能明细 M4-07)。
+ *
+ * 设计口径:所有采集数据先入本队列再尝试上传,网络失败留在队列内自动补传;
+ * S1+ 各数据类型(水平位移/测斜/轴力/水位…)照此模式挂载自己的实体,
+ * payloadJson 统一存接口文档约定的上传 JSON 结构。
+ */
+@Entity(tableName = "upload_queue")
+data class UploadQueueEntity(
+ /** 自增主键。 */
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "id")
+ val id: Long = 0L,
+
+ /** 设备类型:IE1000 / VM208(见 core:ble DeviceType)。 */
+ @ColumnInfo(name = "device_type")
+ val deviceType: String,
+
+ /** 设备蓝牙标识(蓝牙名或 MAC)。 */
+ @ColumnInfo(name = "device_id")
+ val deviceId: String,
+
+ /** 业务数据类型(水平位移/测斜/轴力/水位等,S1+ 扩展枚举)。 */
+ @ColumnInfo(name = "data_type")
+ val dataType: String,
+
+ /** 按接口文档组装好的上传 JSON。 */
+ @ColumnInfo(name = "payload_json")
+ val payloadJson: String,
+
+ /** 队列状态,见 [UploadStatus]。 */
+ @ColumnInfo(name = "status")
+ val status: Int = UploadStatus.PENDING,
+
+ /** 已重试次数(429/403 限流退避依据)。 */
+ @ColumnInfo(name = "retry_count")
+ val retryCount: Int = 0,
+
+ /** 入队时间(epoch 毫秒)。 */
+ @ColumnInfo(name = "created_at")
+ val createdAt: Long,
+
+ /** 上传成功时间(epoch 毫秒),未上传为 null。 */
+ @ColumnInfo(name = "uploaded_at")
+ val uploadedAt: Long? = null,
+)
+
+/** 队列状态常量(S0 用 Int 避免 Room TypeConverter 样板,S1 可按需替换为枚举)。 */
+object UploadStatus {
+ /** 待上传。 */
+ const val PENDING = 0
+
+ /** 上传中。 */
+ const val UPLOADING = 1
+
+ /** 上传成功(保留一段时间后由清理任务删除)。 */
+ const val SUCCESS = 2
+
+ /** 失败待重试。 */
+ const val FAILED = 3
+}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
new file mode 100644
index 0000000..1782f0e
--- /dev/null
+++ b/core/network/build.gradle.kts
@@ -0,0 +1,48 @@
+// core:network 网络框架:鉴权三要素拦截器、统一响应解析、Retrofit/OkHttp 装配
+// NetworkConfig 的实现由 app 壳提供(配置不进代码库,见 M7-04)
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+}
+
+android {
+ namespace = "com.stec.cmd.core.network"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ buildFeatures {
+ // NetworkModule 依据 BuildConfig.DEBUG 决定是否装配 BODY 级日志拦截器
+ buildConfig = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(project(":core:common"))
+
+ // 网络能力对外暴露(业务模块直接声明 Retrofit Service)
+ api(libs.retrofit)
+ api(libs.okhttp)
+ api(libs.kotlinx.serialization.json)
+ implementation(libs.retrofit.converter.kotlinx)
+ implementation(libs.okhttp.logging)
+
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+
+ testImplementation(libs.junit)
+}
diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiEnvelope.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiEnvelope.kt
new file mode 100644
index 0000000..292d2a8
--- /dev/null
+++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiEnvelope.kt
@@ -0,0 +1,42 @@
+package com.stec.cmd.core.network
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * 平台统一响应包裹(接口文档 1.1 返回状态值约定)。
+ *
+ * - code 为**字符串**:"200" 成功、"500" 失败、"429" 频繁请求超限、"403" 连续报错超限;
+ * - msg:执行成功/失败提示信息;
+ * - data:业务数据,失败时为 null。
+ */
+@Serializable
+data class ApiEnvelope(
+ val code: String,
+ @SerialName("msg") val message: String? = null,
+ val data: T? = null,
+) {
+ /** 是否业务成功(code = "200")。 */
+ val isSuccess: Boolean get() = code == CODE_SUCCESS
+
+ /** 取业务数据;失败或无数据时抛出对应 [ApiError]。 */
+ fun bodyOrThrow(): T {
+ if (!isSuccess) throw ApiError.fromCode(code, message)
+ @Suppress("UNCHECKED_CAST")
+ return data as? T ?: throw ApiError.EmptyBodyError(message)
+ }
+
+ companion object {
+ /** 接口请求成功。 */
+ const val CODE_SUCCESS = "200"
+
+ /** 接口请求失败。 */
+ const val CODE_SERVER_ERROR = "500"
+
+ /** 同一条数据一分钟内频繁请求,超过允许最大次数。 */
+ const val CODE_TOO_MANY_REQUESTS = "429"
+
+ /** 同一条数据连续报错,超过允许出错最大次数。 */
+ const val CODE_FORBIDDEN = "403"
+ }
+}
diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiError.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiError.kt
new file mode 100644
index 0000000..69ad29c
--- /dev/null
+++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiError.kt
@@ -0,0 +1,54 @@
+package com.stec.cmd.core.network
+
+/**
+ * 平台接口错误体系:UI 层只需捕获 [ApiError] 并按子类给出提示。
+ *
+ * 语义对齐接口文档 1.1:
+ * - 500 → [ServerError] 业务失败;
+ * - 429 → [TooManyRequests] 一分钟内同一条数据频繁请求超限;
+ * - 403 → [Locked] 同一条数据连续报错超限(平台锁定,非 HTTP 403 权限语义);
+ * - HTTP 层失败(超时/断网)→ [NetworkError]。
+ */
+sealed class ApiError(
+ message: String?,
+ cause: Throwable? = null,
+) : Exception(message, cause) {
+
+ /** 服务器业务失败(响应 code = 500)。 */
+ class ServerError(
+ val code: String,
+ message: String?,
+ ) : ApiError(message)
+
+ /** 请求过频(响应 code = 429)。 */
+ class TooManyRequests(
+ message: String?,
+ ) : ApiError(message ?: DEFAULT_TOO_MANY_MESSAGE)
+
+ /** 连续报错被平台锁定(响应 code = 403)。 */
+ class Locked(
+ message: String?,
+ ) : ApiError(message ?: DEFAULT_LOCKED_MESSAGE)
+
+ /** 成功码但 data 缺失(服务端契约异常)。 */
+ class EmptyBodyError(
+ message: String?,
+ ) : ApiError(message ?: "响应成功但缺少数据")
+
+ /** 网络/超时等传输层失败。 */
+ class NetworkError(
+ cause: Throwable,
+ ) : ApiError(cause.message, cause)
+
+ companion object {
+ private const val DEFAULT_TOO_MANY_MESSAGE = "操作过于频繁,请稍后再试"
+ private const val DEFAULT_LOCKED_MESSAGE = "连续失败次数过多,请稍后再试"
+
+ /** 按响应体 code 映射业务错误。 */
+ fun fromCode(code: String, message: String?): ApiError = when (code) {
+ ApiEnvelope.CODE_TOO_MANY_REQUESTS -> TooManyRequests(message)
+ ApiEnvelope.CODE_FORBIDDEN -> Locked(message)
+ else -> ServerError(code, message)
+ }
+ }
+}
diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/AuthInterceptor.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/AuthInterceptor.kt
new file mode 100644
index 0000000..1b0440c
--- /dev/null
+++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/AuthInterceptor.kt
@@ -0,0 +1,37 @@
+package com.stec.cmd.core.network
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+
+/**
+ * 鉴权三要素拦截器(接口文档 1.2):为每个请求注入请求头。
+ *
+ * - `SecretKey`:供应商密钥,必带;
+ * - `SystemCode`:供应商编码,必带;
+ * - `Token`:登录后颁发;[TokenProvider.currentToken] 为 null 时跳过,
+ * 登录/验证码等未登录接口天然不携带。
+ */
+class AuthInterceptor @Inject constructor(
+ private val config: NetworkConfig,
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request().newBuilder()
+ .header(HEADER_SECRET_KEY, config.secretKey)
+ .header(HEADER_SYSTEM_CODE, config.systemCode)
+ .apply {
+ config.tokenProvider.currentToken()?.let { token ->
+ header(HEADER_TOKEN, token)
+ }
+ }
+ .build()
+ return chain.proceed(request)
+ }
+
+ private companion object {
+ const val HEADER_SECRET_KEY = "SecretKey"
+ const val HEADER_SYSTEM_CODE = "SystemCode"
+ const val HEADER_TOKEN = "Token"
+ }
+}
diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkConfig.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkConfig.kt
new file mode 100644
index 0000000..c925456
--- /dev/null
+++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkConfig.kt
@@ -0,0 +1,38 @@
+package com.stec.cmd.core.network
+
+/**
+ * 网络配置抽象:baseUrl 与鉴权三要素的提供者。
+ *
+ * 接口文档 1.2 约定:
+ * - SecretKey:供应商调用密钥,会定期更换,必须做成配置项(M7-04);
+ * - SystemCode:供应商编码,与 SecretKey 一一对应;
+ * - Token:登录后颁发,除登录接口外必须携带。
+ *
+ * 实现由 app 壳注入(Hilt @Binds),值来自加密配置存储,禁止硬编码进代码库。
+ */
+interface NetworkConfig {
+
+ /** 平台接口基地址(例:https://jcd.stec.p-q.co/)。 */
+ val baseUrl: String
+
+ /** 供应商调用密钥。 */
+ val secretKey: String
+
+ /** 供应商编码。 */
+ val systemCode: String
+
+ /** Token 快照提供者。 */
+ val tokenProvider: TokenProvider
+}
+
+/**
+ * Token 快照提供者。
+ *
+ * 拦截器在 OkHttp 后台线程同步读取,实现方应返回内存缓存值
+ * (app 侧登录/登出/刷新时更新缓存 + 持久化),避免每次请求读盘。
+ */
+interface TokenProvider {
+
+ /** 当前 Token;未登录返回 null(此时请求不携带 Token 头)。 */
+ fun currentToken(): String?
+}
diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt
new file mode 100644
index 0000000..3d3a1c8
--- /dev/null
+++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt
@@ -0,0 +1,68 @@
+package com.stec.cmd.core.network
+
+import dagger.Module
+import dagger.Provides
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.kotlinx.serialization.asConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+/**
+ * 网络层 Hilt 装配。
+ *
+ * [NetworkConfig] 的实现由 app 壳通过 @Binds 提供(配置不进代码库);
+ * Retrofit Service 接口在 S1+ 各业务模块中声明后直接注入使用。
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideJson(): Json = Json {
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ encodeDefaults = true
+ }
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(
+ config: NetworkConfig,
+ ): OkHttpClient {
+ val builder = OkHttpClient.Builder()
+ .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .addInterceptor(AuthInterceptor(config))
+
+ // 外业调试期保留 BODY 级日志;发布构建由 proguard 移除该拦截器装配
+ if (BuildConfig.DEBUG) {
+ val logging = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+ builder.addInterceptor(logging)
+ }
+ return builder.build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(
+ client: OkHttpClient,
+ json: Json,
+ config: NetworkConfig,
+ ): Retrofit = Retrofit.Builder()
+ .baseUrl(config.baseUrl)
+ .client(client)
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
+ .build()
+
+ private const val CONNECT_TIMEOUT_SECONDS = 15L
+ private const val READ_TIMEOUT_SECONDS = 30L
+ private const val WRITE_TIMEOUT_SECONDS = 30L
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..3c09ed3
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,12 @@
+# Gradle 守护进程 JVM 参数
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# 官方代码风格,避免平台差异
+org.gradle.kotlin.dsl.skipMetadataVersionCheck=false
+
+# AndroidX
+android.useAndroidX=true
+# 每个模块独立 R 类,加速构建
+android.nonTransitiveRClass=true
+
+# Kotlin 代码风格
+kotlin.code.style=official
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..42f9119
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,99 @@
+# 版本目录(Version Catalog):全工程唯一版本事实源
+# 选型原则:全部采用相互兼容的已知稳定版(AGP 8.7.3 需 Gradle ≥ 8.9,wrapper 为 8.10.2)
+
+[versions]
+# 构建链
+agp = "8.7.3"
+kotlin = "2.0.21"
+ksp = "2.0.21-1.0.28"
+
+# 依赖注入
+hilt = "2.52"
+
+# 持久化
+room = "2.6.1"
+datastore = "1.1.1"
+securityCrypto = "1.1.0-alpha06"
+
+# 网络
+retrofit = "2.11.0"
+okhttp = "4.12.0"
+kotlinxSerialization = "1.7.3"
+
+# 协程与生命周期
+coroutines = "1.9.0"
+lifecycle = "2.8.7"
+
+# AndroidX UI
+coreKtx = "1.13.1"
+appcompat = "1.7.0"
+activity = "1.9.3"
+fragment = "1.8.5"
+navigation = "2.8.4"
+material = "1.12.0"
+splashscreen = "1.0.1"
+
+# 测试
+junit = "4.13.2"
+androidxJunit = "1.2.1"
+espresso = "3.6.1"
+
+[libraries]
+# AndroidX 基础
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
+androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
+androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
+
+# 生命周期
+androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
+
+# 导航
+androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
+androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
+
+# Room
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+
+# DataStore + 加密存储
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
+
+# Material
+google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+
+# Hilt
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
+
+# 网络
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-converter-kotlinx = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+
+# 序列化
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
+
+# 协程
+kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+
+# 测试
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..df97d72
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..d95bf61
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..640d686
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..07d1f5a
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,32 @@
+// 监测数据不落地平台 · 安卓客户端 —— 工程模块注册与仓库配置
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ // 依赖仓库统一在此声明,模块级 build 文件禁止再配 repositories
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "SteCmdAndroid"
+
+include(":app")
+include(":core:common")
+include(":core:network")
+include(":core:database")
+include(":core:ble")
+include(":ble-protocol")