feat: 蓝牙扫描与iBeacon模块
核心功能: - BleScanner: BLE扫描封装,SCAN_MODE_LOW_POWER低功耗 - IBeaconParser: 从ScanRecord解析UUID/Major/Minor/TxPower - BluetoothScanManager: 状态机管理常规/特殊双模式 - 常规模式:周期扫描→上报API→检查新任务 - 特殊模式:RSSI采样→均值判定→任务匹配→唤醒屏幕 - 设备过滤:名称含RFstar + 功率0<p<=100 集成: - HomeFragment: MQTT type=4更新蓝牙参数,type=5控制启停 - 解绑时停止扫描 - 电量同步给扫描管理器 新增6文件,修改2文件。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package com.xiaoqu.watch.data.bluetooth
|
||||
|
||||
/**
|
||||
* 蓝牙扫描参数(服务端通过 MQTT type=4 下发,本地有默认值兜底)
|
||||
* 对应旧版 bluetoothStore.js 的 dictParams
|
||||
*/
|
||||
data class BleScanParams(
|
||||
/** 常规模式扫描轮数 */
|
||||
val bluetoothRoutineCycle: Int = 1,
|
||||
/** 常规模式每轮扫描时间(秒) */
|
||||
val bluetoothRoutineCycleTime: Int = 20,
|
||||
/** 常规模式采样次数 */
|
||||
val bluetoothRoutineCycleTimes: Int = 1,
|
||||
/** 特殊模式扫描轮数 */
|
||||
val bluetoothSpecialCycle: Int = 5,
|
||||
/** 特殊模式采样次数(也是信标移除阈值) */
|
||||
val bluetoothSpecialCycleTimes: Int = 5,
|
||||
/** RSSI 信号强度阈值(平均值 >= 此值判定为在范围内) */
|
||||
val bluetoothSign: Int = 1,
|
||||
/** 特殊模式→常规模式超时(秒) */
|
||||
val specialToRoutineTime: Int = 3
|
||||
) {
|
||||
/** 常规模式扫描时长(毫秒) */
|
||||
val routineScanMs: Long
|
||||
get() = ((bluetoothRoutineCycle - bluetoothRoutineCycleTime).coerceAtLeast(1)) * 1000L
|
||||
|
||||
/** 常规模式休息时长(毫秒) */
|
||||
val routineRestMs: Long
|
||||
get() = bluetoothRoutineCycleTime * 1000L
|
||||
|
||||
/** 特殊模式扫描时长(毫秒) */
|
||||
val specialScanMs: Long
|
||||
get() = ((bluetoothSpecialCycle - bluetoothSpecialCycleTimes).coerceAtLeast(1)) * 1000L
|
||||
|
||||
/** 特殊模式休息时长(毫秒) */
|
||||
val specialRestMs: Long
|
||||
get() = bluetoothSpecialCycleTimes * 1000L
|
||||
|
||||
/** 特殊→常规超时(毫秒) */
|
||||
val specialTimeoutMs: Long
|
||||
get() = specialToRoutineTime * 1000L
|
||||
|
||||
companion object {
|
||||
/** 从 MQTT JSON 解析参数,缺失字段用默认值 */
|
||||
fun fromJson(json: org.json.JSONObject): BleScanParams {
|
||||
return BleScanParams(
|
||||
bluetoothRoutineCycle = json.optInt("bluetoothRoutineCycle", 1),
|
||||
bluetoothRoutineCycleTime = json.optInt("bluetoothRoutineCycleTime", 20),
|
||||
bluetoothRoutineCycleTimes = json.optInt("bluetoothRoutineCycleTimes", 1),
|
||||
bluetoothSpecialCycle = json.optInt("bluetoothSpecialCycle", 5),
|
||||
bluetoothSpecialCycleTimes = json.optInt("bluetoothSpecialCycleTimes", 5),
|
||||
bluetoothSign = json.optInt("bluetoothSign", 1),
|
||||
specialToRoutineTime = json.optInt("specialToRoutineTime", 3)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.xiaoqu.watch.data.bluetooth
|
||||
|
||||
/**
|
||||
* iBeacon 设备(BLE 扫描结果解析后)
|
||||
* 用于上报服务端和特殊模式 RSSI 追踪
|
||||
*/
|
||||
data class IBeaconDevice(
|
||||
/** 蓝牙 MAC 地址 */
|
||||
val mac: String,
|
||||
/** 信号强度 (dBm, 负值, 如 -65) */
|
||||
val rssi: Int,
|
||||
/** iBeacon UUID */
|
||||
val uuid: String,
|
||||
/** iBeacon Major */
|
||||
val major: Int,
|
||||
/** iBeacon Minor */
|
||||
val minor: Int,
|
||||
/** 发射功率 (dBm) */
|
||||
val txPower: Int,
|
||||
/** 设备名称 */
|
||||
val name: String
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.xiaoqu.watch.device.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.BluetoothLeScanner
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import com.xiaoqu.watch.data.bluetooth.IBeaconDevice
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* BLE 扫描封装(硬件抽象层)
|
||||
*
|
||||
* 使用 BluetoothLeScanner 扫描 BLE 设备,通过 IBeaconParser 过滤解析 iBeacon。
|
||||
* 扫描模式固定使用 SCAN_MODE_LOW_POWER(CLAUDE.md 功耗要求)。
|
||||
*/
|
||||
@Singleton
|
||||
class BleScanner @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private var bluetoothAdapter: BluetoothAdapter? = null
|
||||
private var leScanner: BluetoothLeScanner? = null
|
||||
private var currentCallback: ScanCallback? = null
|
||||
|
||||
/** 初始<E5889D><E5A78B>蓝牙适配器 */
|
||||
private fun ensureAdapter(): Boolean {
|
||||
if (bluetoothAdapter == null) {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
|
||||
bluetoothAdapter = manager?.adapter
|
||||
}
|
||||
if (leScanner == null) {
|
||||
leScanner = bluetoothAdapter?.bluetoothLeScanner
|
||||
}
|
||||
return leScanner != null
|
||||
}
|
||||
|
||||
/** 蓝牙是否可用(适配器存在且已开启) */
|
||||
fun isAvailable(): Boolean {
|
||||
ensureAdapter()
|
||||
return bluetoothAdapter?.isEnabled == true && leScanner != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次 BLE 扫描(挂起函数)
|
||||
* @param durationMs 扫描时长(毫秒)
|
||||
* @return 扫描到的 iBeacon 设备列表
|
||||
*/
|
||||
suspend fun scanOnce(durationMs: Long): List<IBeaconDevice> {
|
||||
if (!isAvailable()) {
|
||||
Timber.w("BLE扫描: 蓝牙不可用")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val devices = mutableListOf<IBeaconDevice>()
|
||||
val seenMacs = mutableSetOf<String>() // MAC 去重
|
||||
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = IBeaconParser.parse(result) ?: return
|
||||
// 同一次扫描内 MAC 去重,保留最新 RSSI
|
||||
if (device.mac !in seenMacs) {
|
||||
seenMacs.add(device.mac)
|
||||
devices.add(device)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Timber.w("BLE扫描失败: errorCode=$errorCode")
|
||||
stopCurrentScan()
|
||||
if (cont.isActive) cont.resume(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
currentCallback = callback
|
||||
|
||||
// 低功耗扫描模式
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
||||
.build()
|
||||
|
||||
try {
|
||||
leScanner?.startScan(null, settings, callback)
|
||||
Timber.d("BLE扫描: 开始 (${durationMs}ms)")
|
||||
} catch (e: SecurityException) {
|
||||
Timber.w(e, "BLE扫描: 权限不足")
|
||||
if (cont.isActive) cont.resume(emptyList())
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
// <20><><EFBFBD>时后停止扫描并返回结果
|
||||
cont.invokeOnCancellation { stopCurrentScan() }
|
||||
|
||||
// 用协程 delay 实现超时(由调用方管理)
|
||||
// 这里立即返回,调用方用 withTimeout 或手动 delay + resume
|
||||
// 改为:注册定时停止
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
stopCurrentScan()
|
||||
if (cont.isActive) {
|
||||
Timber.d("BLE扫描: 完成,发现 ${devices.size} 个 iBeacon")
|
||||
cont.resume(devices)
|
||||
}
|
||||
}, durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止当前扫描 */
|
||||
fun stopCurrentScan() {
|
||||
try {
|
||||
currentCallback?.let { leScanner?.stopScan(it) }
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "BLE扫描: 停止异常")
|
||||
}
|
||||
currentCallback = null
|
||||
}
|
||||
|
||||
/** 强制停止所有扫描(生命周期结束时调用) */
|
||||
fun shutdown() {
|
||||
stopCurrentScan()
|
||||
leScanner = null
|
||||
bluetoothAdapter = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.xiaoqu.watch.device.bluetooth
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import com.xiaoqu.watch.data.bluetooth.IBeaconDevice
|
||||
|
||||
/**
|
||||
* iBeacon 广播数据解析器
|
||||
*
|
||||
* iBeacon 标准广播格式(Manufacturer Specific Data):
|
||||
* - Company ID: 0x004C (Apple)
|
||||
* - Beacon Type: 0x02, 0x15
|
||||
* - UUID: 16 bytes
|
||||
* - Major: 2 bytes (big-endian)
|
||||
* - Minor: 2 bytes (big-endian)
|
||||
* - TX Power: 1 byte (signed)
|
||||
*
|
||||
* 过滤规则(来自旧版 blueUtils.js):
|
||||
* - 设备名包含 "RFstar"
|
||||
* - 0 < txPower <= 100
|
||||
* - UUID/Major/Minor 均有效
|
||||
*/
|
||||
object IBeaconParser {
|
||||
|
||||
/** iBeacon 标识:Company ID (0x4C00 little-endian) + Type (0x0215) */
|
||||
private const val IBEACON_COMPANY_ID = 0x004C
|
||||
private const val IBEACON_TYPE_BYTE1 = 0x02.toByte()
|
||||
private const val IBEACON_TYPE_BYTE2 = 0x15.toByte()
|
||||
|
||||
/** 设备名过滤关键字 */
|
||||
private const val DEVICE_NAME_FILTER = "RFstar"
|
||||
|
||||
/**
|
||||
* 从 BLE 扫描结果解析 iBeacon 设备
|
||||
* @return 解析成功且通过过滤返回 IBeaconDevice,否则 null
|
||||
*/
|
||||
fun parse(scanResult: ScanResult): IBeaconDevice? {
|
||||
val record = scanResult.scanRecord ?: return null
|
||||
val deviceName = record.deviceName ?: scanResult.device?.name ?: ""
|
||||
|
||||
// 过滤:设备名必须包含 RFstar
|
||||
if (!deviceName.contains(DEVICE_NAME_FILTER, ignoreCase = true)) return null
|
||||
|
||||
// 从 Manufacturer Specific Data 中解析 iBeacon
|
||||
val mfgData = record.getManufacturerSpecificData(IBEACON_COMPANY_ID)
|
||||
?: return null
|
||||
|
||||
// 检查 iBeacon 类型标识(前 2 字节)
|
||||
if (mfgData.size < 23) return null
|
||||
if (mfgData[0] != IBEACON_TYPE_BYTE1 || mfgData[1] != IBEACON_TYPE_BYTE2) return null
|
||||
|
||||
// 解析 UUID(字节 2-17)
|
||||
val uuid = buildString {
|
||||
for (i in 2..17) {
|
||||
append(String.format("%02X", mfgData[i]))
|
||||
if (i == 5 || i == 7 || i == 9 || i == 11) append("-")
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 Major(字节 18-19,big-endian)
|
||||
val major = ((mfgData[18].toInt() and 0xFF) shl 8) or (mfgData[19].toInt() and 0xFF)
|
||||
|
||||
// 解析 Minor(字节 20-21,big-endian)
|
||||
val minor = ((mfgData[20].toInt() and 0xFF) shl 8) or (mfgData[21].toInt() and 0xFF)
|
||||
|
||||
// 解析 TX Power(字节 22,signed byte)
|
||||
val txPower = mfgData[22].toInt()
|
||||
|
||||
// 过滤:功率有效且 UUID/Major/Minor 非空
|
||||
if (txPower <= 0 || txPower > 100) return null
|
||||
if (uuid.isBlank() || major == 0 && minor == 0) return null
|
||||
|
||||
val mac = scanResult.device?.address ?: return null
|
||||
|
||||
return IBeaconDevice(
|
||||
mac = mac,
|
||||
rssi = scanResult.rssi,
|
||||
uuid = uuid,
|
||||
major = major,
|
||||
minor = minor,
|
||||
txPower = txPower,
|
||||
name = deviceName
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.google.gson.GsonBuilder
|
||||
import com.xiaoqu.watch.network.EnvConfig
|
||||
import com.xiaoqu.watch.network.SignatureInterceptor
|
||||
import com.xiaoqu.watch.network.UnbindInterceptor
|
||||
import com.xiaoqu.watch.network.api.BluetoothApi
|
||||
import com.xiaoqu.watch.network.api.CommonApi
|
||||
import com.xiaoqu.watch.network.api.PunchApi
|
||||
import com.xiaoqu.watch.network.api.TaskApi
|
||||
@@ -76,4 +77,10 @@ object NetworkModule {
|
||||
fun providePunchApi(retrofit: Retrofit): PunchApi {
|
||||
return retrofit.create(PunchApi::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBluetoothApi(retrofit: Retrofit): BluetoothApi {
|
||||
return retrofit.create(BluetoothApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.xiaoqu.watch.network.api
|
||||
|
||||
import com.xiaoqu.watch.network.ApiResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* 蓝牙扫描相关 API
|
||||
*/
|
||||
interface BluetoothApi {
|
||||
|
||||
/** 上报蓝牙信标扫描结果,返回匹配的任务列表 */
|
||||
@POST("watchTask/bluetoothScanTask")
|
||||
suspend fun uploadScanResult(@Body params: HashMap<String, Any>): ApiResponse<List<Any>>
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package com.xiaoqu.watch.service.manager
|
||||
|
||||
import com.xiaoqu.watch.data.bluetooth.BleScanParams
|
||||
import com.xiaoqu.watch.data.bluetooth.IBeaconDevice
|
||||
import com.xiaoqu.watch.device.bluetooth.BleScanner
|
||||
import com.xiaoqu.watch.device.screen.ScreenController
|
||||
import com.xiaoqu.watch.event.AppEvent
|
||||
import com.xiaoqu.watch.event.EventBus
|
||||
import com.xiaoqu.watch.network.ApiResult
|
||||
import com.xiaoqu.watch.network.api.BluetoothApi
|
||||
import com.xiaoqu.watch.network.safeApiCall
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 蓝牙扫描管理器(核心状态机)
|
||||
*
|
||||
* 管理常规/特殊双模式扫描:
|
||||
* - 常规模式:周期性扫描 → 上报服务端 → 检查新任务
|
||||
* - 特殊模式:精确 RSSI 采样 → 均值判定 → 触发任务通知
|
||||
*
|
||||
* 生命周期:
|
||||
* - 上班(WorkStateChanged true) → start()
|
||||
* - 下班/解绑 → stop()
|
||||
* - MQTT type=4 → updateParams() → 重启
|
||||
*/
|
||||
@Singleton
|
||||
class BluetoothScanManager @Inject constructor(
|
||||
private val bleScanner: BleScanner,
|
||||
private val bluetoothApi: BluetoothApi,
|
||||
private val screenController: ScreenController,
|
||||
private val eventBus: EventBus
|
||||
) {
|
||||
/** 扫描状态 */
|
||||
enum class State { STOPPED, ROUTINE, TRANSITIONING, SPECIAL }
|
||||
|
||||
/** 当前状态 */
|
||||
var state = State.STOPPED
|
||||
private set
|
||||
|
||||
/** 扫描参数(可被 MQTT 更新) */
|
||||
var params = BleScanParams()
|
||||
private set
|
||||
|
||||
/** 上次 API 返回的任务列表(用于对比新任务) */
|
||||
private var lastTaskIds = mutableSetOf<String>()
|
||||
|
||||
/** 协程作用域(applicationScope,不跟随 ViewModel) */
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
/** 常规模式协程 */
|
||||
private var routineJob: Job? = null
|
||||
/** 特殊模式协程 */
|
||||
private var specialJob: Job? = null
|
||||
|
||||
/** 当前电量(由外部更新) */
|
||||
var currentPower: Int = 100
|
||||
/** 当前网络类型(由外部更新) */
|
||||
var currentNetworkType: String = ""
|
||||
|
||||
/**
|
||||
* 启动蓝牙扫描(常规模式)
|
||||
* 调用前确保蓝牙可用
|
||||
*/
|
||||
fun start() {
|
||||
if (state != State.STOPPED) {
|
||||
Timber.d("蓝牙扫描: 已在运行,忽略 start()")
|
||||
return
|
||||
}
|
||||
if (!bleScanner.isAvailable()) {
|
||||
Timber.w("蓝牙扫描: 蓝牙不可用,无法启动")
|
||||
return
|
||||
}
|
||||
Timber.d("蓝牙扫描: 启动常规模式")
|
||||
startRoutineLoop()
|
||||
}
|
||||
|
||||
/** 停止所有扫描 */
|
||||
fun stop() {
|
||||
Timber.d("蓝牙扫描: 停止 (当前状态=$state)")
|
||||
routineJob?.cancel()
|
||||
specialJob?.cancel()
|
||||
bleScanner.stopCurrentScan()
|
||||
state = State.STOPPED
|
||||
lastTaskIds.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新扫描参数(MQTT type=4)
|
||||
* 如果正在扫描,自动重启
|
||||
*/
|
||||
fun updateParams(newParams: BleScanParams) {
|
||||
Timber.d("蓝牙扫描: 参数更新 $newParams")
|
||||
params = newParams
|
||||
if (state != State.STOPPED) {
|
||||
// 停止 → 延迟 500ms → 重启(对齐旧版 index.vue 行为)
|
||||
stop()
|
||||
scope.launch {
|
||||
delay(500)
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 常规模式 =====
|
||||
|
||||
/** 启动常规扫描循环 */
|
||||
private fun startRoutineLoop() {
|
||||
routineJob = scope.launch {
|
||||
state = State.ROUTINE
|
||||
Timber.d("蓝牙扫描: 进入常规循环 (扫描${params.routineScanMs}ms, 休息${params.routineRestMs}ms)")
|
||||
|
||||
while (isActive && state == State.ROUTINE) {
|
||||
// 1. 扫描一轮
|
||||
val devices = bleScanner.scanOnce(params.routineScanMs)
|
||||
|
||||
// 2. 上报服务端
|
||||
if (devices.isNotEmpty()) {
|
||||
val newTasks = uploadAndCheckTasks(devices)
|
||||
if (newTasks != null) {
|
||||
// 3. 有新任务 → 切换特殊模式
|
||||
transitionToSpecial(newTasks)
|
||||
return@launch // 退出常规循环
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 休息
|
||||
delay(params.routineRestMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报设备并检查新任务
|
||||
* @return 有新任务返回任务列表,无新任务或失败返回 null
|
||||
*/
|
||||
private suspend fun uploadAndCheckTasks(devices: List<IBeaconDevice>): List<Map<String, Any>>? {
|
||||
// 构造请求体
|
||||
val deviceList = devices.map { device ->
|
||||
hashMapOf<String, Any>(
|
||||
"mac" to device.mac,
|
||||
"rssi" to device.rssi
|
||||
)
|
||||
}
|
||||
val requestParams = hashMapOf<String, Any>(
|
||||
"devices" to deviceList,
|
||||
"power" to currentPower,
|
||||
"networkType" to currentNetworkType
|
||||
)
|
||||
|
||||
val result = safeApiCall { bluetoothApi.uploadScanResult(requestParams) }
|
||||
|
||||
if (result !is ApiResult.Success || result.data == null) {
|
||||
return null // API 失败,静默继续
|
||||
}
|
||||
|
||||
// 解析任务列表
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val tasks = result.data as? List<Map<String, Any>> ?: return null
|
||||
if (tasks.isEmpty()) return null
|
||||
|
||||
// 对比新旧任务
|
||||
val currentIds = tasks.mapNotNull { it["id"]?.toString() }.toSet()
|
||||
val newIds = currentIds - lastTaskIds
|
||||
|
||||
if (newIds.isEmpty()) {
|
||||
// 无新任务
|
||||
lastTaskIds = currentIds.toMutableSet()
|
||||
return null
|
||||
}
|
||||
|
||||
Timber.d("蓝牙扫描: 发现 ${newIds.size} 个新任务")
|
||||
lastTaskIds = currentIds.toMutableSet()
|
||||
return tasks
|
||||
}
|
||||
|
||||
// ===== 特殊模式 =====
|
||||
|
||||
/** 信标追踪器 */
|
||||
private data class BeaconTracker(
|
||||
val mac: String,
|
||||
val tasks: List<Map<String, Any>>,
|
||||
val rssiSamples: MutableList<Int> = mutableListOf(),
|
||||
var missCount: Int = 0
|
||||
)
|
||||
|
||||
/** 切换到特殊模式(1s 延迟) */
|
||||
private fun transitionToSpecial(tasks: List<Map<String, Any>>) {
|
||||
state = State.TRANSITIONING
|
||||
bleScanner.stopCurrentScan()
|
||||
Timber.d("蓝牙扫描: 准备进入特殊模式 (1s 延迟)")
|
||||
|
||||
specialJob = scope.launch {
|
||||
delay(1000)
|
||||
if (state == State.TRANSITIONING) {
|
||||
startSpecialMode(tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动特殊模式 RSSI 采样 */
|
||||
private suspend fun startSpecialMode(tasks: List<Map<String, Any>>) {
|
||||
state = State.SPECIAL
|
||||
Timber.d("蓝牙扫描: 进入特殊模式 (${tasks.size} 个任务)")
|
||||
|
||||
// 按 MAC 分组建立追踪器
|
||||
val trackers = mutableMapOf<String, BeaconTracker>()
|
||||
for (task in tasks) {
|
||||
val mac = task["mac"]?.toString() ?: continue
|
||||
val tracker = trackers.getOrPut(mac) { BeaconTracker(mac, mutableListOf()) }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(tracker.tasks as MutableList<Map<String, Any>>).add(task)
|
||||
}
|
||||
|
||||
// 超时协程
|
||||
val timeoutJob = scope.launch {
|
||||
delay(params.specialTimeoutMs)
|
||||
Timber.d("蓝牙扫描: 特殊模式超时")
|
||||
}
|
||||
|
||||
// 采样循环
|
||||
while (state == State.SPECIAL && !timeoutJob.isCompleted) {
|
||||
val devices = bleScanner.scanOnce(params.specialScanMs)
|
||||
|
||||
// 更新 RSSI
|
||||
val deviceByMac = devices.associateBy { it.mac }
|
||||
val toRemove = mutableListOf<String>()
|
||||
|
||||
for ((mac, tracker) in trackers) {
|
||||
val device = deviceByMac[mac]
|
||||
if (device != null) {
|
||||
tracker.missCount = 0
|
||||
tracker.rssiSamples.add(device.rssi)
|
||||
} else {
|
||||
tracker.missCount++
|
||||
}
|
||||
// 连续 N 次未检测到 → 移除
|
||||
if (tracker.missCount >= params.bluetoothSpecialCycleTimes) {
|
||||
toRemove.add(mac)
|
||||
}
|
||||
}
|
||||
toRemove.forEach { trackers.remove(it) }
|
||||
|
||||
// 所有信标消失
|
||||
if (trackers.isEmpty()) {
|
||||
Timber.d("蓝牙扫描: 特殊模式 - 信标全部消失")
|
||||
timeoutJob.cancel()
|
||||
onSpecialComplete(emptyList())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有信标达标
|
||||
val matched = mutableListOf<Map<String, Any>>()
|
||||
for ((_, tracker) in trackers) {
|
||||
if (tracker.rssiSamples.size >= params.bluetoothSpecialCycleTimes) {
|
||||
// 取最后 N 个样本算平均
|
||||
val samples = tracker.rssiSamples.takeLast(params.bluetoothSpecialCycleTimes)
|
||||
val average = samples.sum().toDouble() / samples.size
|
||||
if (average >= params.bluetoothSign) {
|
||||
matched.addAll(tracker.tasks)
|
||||
Timber.d("蓝牙扫描: 信标 ${tracker.mac} 达标 (RSSI均值=$average, 阈值=${params.bluetoothSign})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.isNotEmpty()) {
|
||||
Timber.d("蓝牙扫描: 特殊模式 - ${matched.size} 个任务匹配")
|
||||
timeoutJob.cancel()
|
||||
onSpecialComplete(matched)
|
||||
return
|
||||
}
|
||||
|
||||
delay(params.specialRestMs)
|
||||
}
|
||||
|
||||
// 超时退出
|
||||
if (state == State.SPECIAL) {
|
||||
Timber.d("蓝牙扫描: 特殊模式超时退出")
|
||||
onSpecialComplete(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** 特殊模式结束处理 */
|
||||
private fun onSpecialComplete(matchedTasks: List<Map<String, Any>>) {
|
||||
if (matchedTasks.isNotEmpty()) {
|
||||
// 唤醒屏幕
|
||||
screenController.turnOn()
|
||||
// 通知任务模块
|
||||
scope.launch {
|
||||
eventBus.emit(AppEvent.PunchTaskListRefresh)
|
||||
}
|
||||
Timber.d("蓝牙扫描: 已通知任务模块刷新")
|
||||
}
|
||||
// 恢复常规模式
|
||||
startRoutineLoop()
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
@Inject lateinit var userPrefs: UserPrefs
|
||||
@Inject lateinit var eventBus: EventBus
|
||||
@Inject lateinit var taskApi: TaskApi
|
||||
@Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager
|
||||
|
||||
/** 考勤打卡 ViewModel */
|
||||
private val punchViewModel: PunchViewModel by viewModels()
|
||||
@@ -381,6 +382,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
// 电量变化
|
||||
is AppEvent.BatteryChanged -> {
|
||||
statusBar.updateBattery(event.level, event.isCharging)
|
||||
bluetoothScanManager.currentPower = event.level
|
||||
}
|
||||
// 蓝牙状态变化
|
||||
is AppEvent.BluetoothStateChanged -> {
|
||||
@@ -395,8 +397,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
fetchStatistics()
|
||||
}
|
||||
3 -> {
|
||||
// 解绑 → 清除数据 → 跳绑定页
|
||||
// 解绑 → 停止蓝牙 → 清除数据 → 跳绑定页
|
||||
Timber.d("首页: 收到解绑消息")
|
||||
bluetoothScanManager.stop()
|
||||
userPrefs.clear()
|
||||
findNavController().navigate(R.id.action_home_to_bind)
|
||||
}
|
||||
@@ -431,6 +434,12 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
if (action == 0 || action == 1) {
|
||||
val isWorking = action == 1
|
||||
punchViewModel.handleWorkStateChange(isWorking)
|
||||
// 上班 → 启动蓝牙扫描;下班 → 停止蓝牙扫描
|
||||
if (isWorking) {
|
||||
bluetoothScanManager.start()
|
||||
} else {
|
||||
bluetoothScanManager.stop()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "解析 MQTT 工作状态消息失败")
|
||||
@@ -439,16 +448,21 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
|
||||
/**
|
||||
* 处理 MQTT type=4 系统参数变更
|
||||
* 从中提取 nfcOpenTime 更新 NFC 超时时间
|
||||
* 更新 NFC 超时时间 + 蓝牙扫描参数
|
||||
*/
|
||||
private fun handleMqttParamsUpdate(rawJson: String) {
|
||||
try {
|
||||
val json = org.json.JSONObject(rawJson)
|
||||
// NFC 超时
|
||||
val nfcOpenTime = json.optInt("nfcOpenTime", -1)
|
||||
if (nfcOpenTime > 0) {
|
||||
punchViewModel.nfcTimeoutMs = nfcOpenTime * 1000L
|
||||
Timber.d("首页: NFC超时更新为 ${nfcOpenTime}s")
|
||||
}
|
||||
// 蓝牙扫描参数
|
||||
val bleParams = com.xiaoqu.watch.data.bluetooth.BleScanParams.fromJson(json)
|
||||
bluetoothScanManager.updateParams(bleParams)
|
||||
Timber.d("首页: 蓝牙扫描参数已更新")
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "解析 MQTT 参数变更消息失败")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user