From 6e8c93fc4647da821b637a176017e97473c8e6dd Mon Sep 17 00:00:00 2001 From: dongliang Date: Thu, 30 Apr 2026 19:38:40 +0930 Subject: [PATCH] =?UTF-8?q?feat(vibration):=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E4=B8=8B=E5=8F=91+=E5=8F=8C=E5=B1=82?= =?UTF-8?q?=E5=BC=80=E5=85=B3+=E9=9F=B3=E9=87=8F=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REQ-20260430-0037 新增 VibrationConfigManager: - MQTT type=7 → 更新振动方案参数(覆盖默认值) - MQTT type=8 → 更新用户配置(震动开关/语音开关/音量) - 线程安全(ConcurrentHashMap + @Volatile) - 内存存储不持久化(MQTT重连后服务端重新下发) VibrationController 新增 executeByPlanId(planId): - 内部完成:获取方案(优先服务端参数)→ 双层开关 → 音量控制 - 调用方只传 planId,不关心参数来源和开关逻辑 - PunchViewModel/NotificationManager 调用简化为一行 双层开关逻辑: - 系统级:方案自身 shockState/voiceState(type=7下发) - 用户级:全局 userShockEnabled/userVoiceEnabled(type=8下发) - 两层都开启才执行 音量控制: - MediaPlayer.setVolume(volume, volume) - volume = voiceValue / 100(服务端下发0~100) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../device/sensor/FiseVibrationController.kt | 47 ++++++- .../device/sensor/VibrationConfigManager.kt | 120 ++++++++++++++++++ .../device/sensor/VibrationController.kt | 8 +- .../service/manager/NotificationManager.kt | 6 +- .../com/xiaoqu/watch/ui/home/HomeFragment.kt | 39 ++++++ .../xiaoqu/watch/ui/punch/PunchViewModel.kt | 8 +- 6 files changed, 211 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/xiaoqu/watch/device/sensor/VibrationConfigManager.kt diff --git a/app/src/main/java/com/xiaoqu/watch/device/sensor/FiseVibrationController.kt b/app/src/main/java/com/xiaoqu/watch/device/sensor/FiseVibrationController.kt index 79a72ef..8a3264c 100644 --- a/app/src/main/java/com/xiaoqu/watch/device/sensor/FiseVibrationController.kt +++ b/app/src/main/java/com/xiaoqu/watch/device/sensor/FiseVibrationController.kt @@ -23,7 +23,8 @@ import javax.inject.Singleton */ @Singleton class FiseVibrationController @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val configManager: VibrationConfigManager ) : VibrationController { /** 系统振动器 */ @@ -47,18 +48,17 @@ class FiseVibrationController @Inject constructor( } /** - * 按方案执行��动(���循环和音频反馈) + * 按方案执行振动(不检查开关,直接执行,内部/测试用途) * @param pattern 振动方案 */ override fun executePattern(pattern: VibrationPattern) { - // 先停止之前的振动 stop() Timber.d("振动方案: ${pattern.planName}(planId=${pattern.planId})") // 播放音频(如果启用且有音频资源) if (pattern.voiceState && pattern.audioResId != 0) { - playAudio(pattern.audioResId) + playAudio(pattern.audioResId, configManager.voiceVolume) } // 执行振动(如果启用) @@ -69,6 +69,41 @@ class FiseVibrationController @Inject constructor( } } + /** + * 按 planId 执行振动(推荐对外调用方式) + * 内部完成:获取方案(优先服务端参数)→ 双层开关检查 → 音量控制 + * + * 双层开关: + * - 系统级:方案自身的 shockState/voiceState(MQTT type=7 下发) + * - 用户级:全局的 userShockEnabled/userVoiceEnabled(MQTT type=8 下发) + * - 两层都开启才执行 + */ + override fun executeByPlanId(planId: Int) { + val pattern = configManager.getPattern(planId) ?: run { + Timber.w("振动: 未找到方案 planId=%d", planId) + return + } + stop() + + val shockOk = pattern.shockState && configManager.userShockEnabled + val voiceOk = pattern.voiceState && configManager.userVoiceEnabled && pattern.audioResId != 0 + + Timber.d("振动方案: %s(planId=%d) shock=%b voice=%b volume=%.2f", + pattern.planName, pattern.planId, shockOk, voiceOk, configManager.voiceVolume) + + // 振动:系统级开关(方案自身)+ 用户级开关(全局) + if (shockOk) { + patternJob = scope.launch { + executePatternLoop(pattern) + } + } + + // 音频:系统级开关 + 用户级开关 + 有音频文件 + if (voiceOk) { + playAudio(pattern.audioResId, configManager.voiceVolume) + } + } + /** 停止当前振动和音频 */ override fun stop() { // 停止振动��程 @@ -105,13 +140,15 @@ class FiseVibrationController @Inject constructor( /** * 播放音频反馈 * @param resId 音频资源 ID(R.raw.xxx) + * @param volume 音量 0~1.0(来自服务端 voiceValue/100) */ - private fun playAudio(resId: Int) { + private fun playAudio(resId: Int, volume: Float) { try { // 释放上一个 MediaPlayer releaseMediaPlayer() // 创建并播放 mediaPlayer = MediaPlayer.create(context, resId)?.apply { + setVolume(volume, volume) // 左右声道同音量 setOnCompletionListener { // 播放完毕自动释放 it.release() diff --git a/app/src/main/java/com/xiaoqu/watch/device/sensor/VibrationConfigManager.kt b/app/src/main/java/com/xiaoqu/watch/device/sensor/VibrationConfigManager.kt new file mode 100644 index 0000000..43884f0 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/device/sensor/VibrationConfigManager.kt @@ -0,0 +1,120 @@ +package com.xiaoqu.watch.device.sensor + +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 震动参数管理器 + * 存储服务端通过 MQTT 下发的振动方案和用户配置,供 FiseVibrationController 使用。 + * + * 数据来源: + * - MQTT type=7 → updateShockParams():覆盖振动方案参数 + * - MQTT type=8 → updateUserConfig():更新用户级开关和音量 + * + * 不持久化:MQTT 重连后服务端会重新下发,内存存储即可。 + * + * 线程安全:serverParams 用 ConcurrentHashMap,用户配置用 @Volatile。 + */ +@Singleton +class VibrationConfigManager @Inject constructor() { + + /** 服务端下发的振动方案(planId → 参数),覆盖 VibrationDefaults */ + private val serverParams = ConcurrentHashMap() + + /** 用户级震动开关(MQTT type=8 下发,默认开启) */ + @Volatile + var userShockEnabled = true + + /** 用户级语音开关(MQTT type=8 下发,默认开启) */ + @Volatile + var userVoiceEnabled = true + + /** 用户级音量 0~1.0(MQTT type=8 下发 voiceValue/100,默认 0.5) */ + @Volatile + var voiceVolume = 0.5f + + /** + * 获取振动方案:优先服务端参数,没有则用默认 + * @param planId 方案 ID(2-13) + * @return 振动方案,不存在返回 null + */ + fun getPattern(planId: Int): VibrationPattern? { + return serverParams[planId] ?: VibrationDefaults.getPattern(planId) + } + + /** + * MQTT type=7:更新振动方案参数 + * + * JSON 格式: + * ```json + * { + * "2": { "planName":"新消息", "shockTime":1, "shockTimes":2, + * "shockIntervalTime":1, "shockCycleTimes":1, "shockState":1, "voiceState":1 }, + * "3": { ... }, + * ... + * } + * ``` + * + * 注意:服务端不下发 audioResId,从 VibrationDefaults 补上(音频映射是客户端固定的)。 + * shockState/voiceState 服务端为 Int(0/1),需转 Boolean。 + */ + fun updateShockParams(shockSet: JSONObject) { + try { + val keys = shockSet.keys() + var count = 0 + while (keys.hasNext()) { + val key = keys.next() + val planId = key.toIntOrNull() ?: continue + val obj = shockSet.optJSONObject(key) ?: continue + + // audioResId 从默认方案获取(服务端不下发此字段) + val defaultPattern = VibrationDefaults.getPattern(planId) + val audioResId = defaultPattern?.audioResId ?: 0 + + serverParams[planId] = VibrationPattern( + planId = planId, + planName = obj.optString("planName", ""), + shockTime = obj.optInt("shockTime", 1), + shockTimes = obj.optInt("shockTimes", 1), + shockIntervalTime = obj.optInt("shockIntervalTime", 0), + shockCycleTimes = obj.optInt("shockCycleTimes", 1), + shockState = obj.optInt("shockState", 1) == 1, + voiceState = obj.optInt("voiceState", 1) == 1, + audioResId = audioResId + ) + count++ + } + Timber.d("VibrationConfig: 更新 %d 个振动方案", count) + } catch (e: Exception) { + Timber.w(e, "VibrationConfig: 振动方案解析异常") + } + } + + /** + * MQTT type=8:更新用户配置 + * + * JSON 格式: + * ```json + * { "imei":"xxx", "shockState":1, "voiceState":1, "voiceValue":50, "blueWorkState":1 } + * ``` + * + * shockState/voiceState: 0=关闭, 1=开启 + * voiceValue: 0~100 整数,转为 0~1.0 + * blueWorkState: 不处理(属于蓝牙模块) + */ + fun updateUserConfig(json: JSONObject) { + try { + userShockEnabled = json.optInt("shockState", 1) == 1 + userVoiceEnabled = json.optInt("voiceState", 1) == 1 + val rawVolume = json.optInt("voiceValue", 50).coerceIn(0, 100) + voiceVolume = rawVolume / 100f + Timber.d("VibrationConfig: 用户配置更新 shock=%b voice=%b volume=%.2f", + userShockEnabled, userVoiceEnabled, voiceVolume) + } catch (e: Exception) { + Timber.w(e, "VibrationConfig: 用户配置解析异常") + } + } +} diff --git a/app/src/main/java/com/xiaoqu/watch/device/sensor/VibrationController.kt b/app/src/main/java/com/xiaoqu/watch/device/sensor/VibrationController.kt index 8d6a330..9e4d648 100644 --- a/app/src/main/java/com/xiaoqu/watch/device/sensor/VibrationController.kt +++ b/app/src/main/java/com/xiaoqu/watch/device/sensor/VibrationController.kt @@ -7,8 +7,14 @@ package com.xiaoqu.watch.device.sensor interface VibrationController { /** 执行一次简单振动 */ fun vibrate(durationMs: Long) - /** 按方案执行振动(含循环和音频反馈) */ + /** 按方案执行振动(含循环和音频反馈,不检查开关,直接执行) */ fun executePattern(pattern: VibrationPattern) + /** + * 按 planId 执行振动(推荐使用) + * 内部完成:获取方案(优先服务端参数)→ 双层开关检查 → 音量控制 + * 调用方无需关心参数来源和开关逻辑 + */ + fun executeByPlanId(planId: Int) /** 停止当前振动 */ fun stop() } diff --git a/app/src/main/java/com/xiaoqu/watch/service/manager/NotificationManager.kt b/app/src/main/java/com/xiaoqu/watch/service/manager/NotificationManager.kt index 3423a37..5f01749 100644 --- a/app/src/main/java/com/xiaoqu/watch/service/manager/NotificationManager.kt +++ b/app/src/main/java/com/xiaoqu/watch/service/manager/NotificationManager.kt @@ -3,7 +3,6 @@ package com.xiaoqu.watch.service.manager import com.xiaoqu.watch.data.task.TaskStatistics import com.xiaoqu.watch.device.screen.ScreenController import com.xiaoqu.watch.device.sensor.VibrationController -import com.xiaoqu.watch.device.sensor.VibrationDefaults import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -131,10 +130,7 @@ class NotificationManager @Inject constructor( addTaskIds(taskIds) // 震动 + 亮屏(只在首条时触发,暂存的合并后不重复震动) - val pattern = VibrationDefaults.getPattern(PLAN_NEW_MESSAGE) - if (pattern != null) { - vibrationController.executePattern(pattern) - } + vibrationController.executeByPlanId(PLAN_NEW_MESSAGE) screenController.turnOn() // 通知 HomeFragment 更新红点 diff --git a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt index ab2f45b..acb1dfc 100644 --- a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt @@ -49,6 +49,7 @@ class HomeFragment : BaseFragment() { @Inject lateinit var taskApi: TaskApi @Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager @Inject lateinit var notificationManager: com.xiaoqu.watch.service.manager.NotificationManager + @Inject lateinit var vibrationConfigManager: com.xiaoqu.watch.device.sensor.VibrationConfigManager /** 考勤打卡 ViewModel */ private val punchViewModel: PunchViewModel by viewModels() @@ -508,6 +509,16 @@ class HomeFragment : BaseFragment() { Timber.d("首页: 收到上下班状态推送") handleMqttWorkState(event.rawJson) } + 7 -> { + // 震动方案参数下发 + Timber.d("首页: 收到震动方案数据") + handleMqttShockParams(event.rawJson) + } + 8 -> { + // 用户配置下发(震动/语音开关+音量) + Timber.d("首页: 收到用户配置") + handleMqttUserConfig(event.rawJson) + } } } else -> {} @@ -625,4 +636,32 @@ class HomeFragment : BaseFragment() { navigateToNewTasks() } } + + /** + * 处理 MQTT type=7 震动方案参数下发 + * 服务端下发所有振动方案参数,覆盖客户端默认值 + */ + private fun handleMqttShockParams(rawJson: String) { + try { + val json = org.json.JSONObject(rawJson) + val shockSet = json.optJSONObject("shockSet") + if (shockSet != null) { + vibrationConfigManager.updateShockParams(shockSet) + } + } catch (e: Exception) { + Timber.w(e, "解析 MQTT 震动方案消息失败") + } + } + + /** + * 处理 MQTT type=8 用户配置下发 + * 更新震动开关、语音开关、音量等用户配置 + */ + private fun handleMqttUserConfig(rawJson: String) { + try { + vibrationConfigManager.updateUserConfig(org.json.JSONObject(rawJson)) + } catch (e: Exception) { + Timber.w(e, "解析 MQTT 用户配置消息失败") + } + } } diff --git a/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt b/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt index 1312124..2c7ddf6 100644 --- a/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt +++ b/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt @@ -6,7 +6,6 @@ import com.xiaoqu.watch.device.nfc.NfcController import com.xiaoqu.watch.device.screen.ScreenController import com.xiaoqu.watch.device.sensor.AccelerometerWakeController import com.xiaoqu.watch.device.sensor.VibrationController -import com.xiaoqu.watch.device.sensor.VibrationDefaults import com.xiaoqu.watch.network.ApiResult import com.xiaoqu.watch.network.api.PunchApi import com.xiaoqu.watch.network.safeApiCall @@ -253,12 +252,9 @@ class PunchViewModel @Inject constructor( // accelerometerWake.resume() } - /** 播放震动+音效反馈 */ + /** 播放震动+音效反馈(通过 planId,内部处理双层开关+音量) */ private fun playFeedback(planId: Int) { - val pattern = VibrationDefaults.getPattern(planId) - if (pattern != null) { - vibrationController.executePattern(pattern) - } + vibrationController.executeByPlanId(planId) } override fun onCleared() {