feat(vibration): 服务端参数下发+双层开关+音量控制
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
}
|
||||
|
||||
/**
|
||||
* 按方案执行<EFBFBD><EFBFBD>动(<EFBFBD><EFBFBD><EFBFBD>循环和音频反馈)
|
||||
* 按方案执行振动(不检查开关,直接执行,内部/测试用途)
|
||||
* @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() {
|
||||
// 停止振动<E68CAF><E58AA8>程
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Int, VibrationPattern>()
|
||||
|
||||
/** 用户级震动开关(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: 用户配置解析异常")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 更新红点
|
||||
|
||||
@@ -49,6 +49,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
@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<FragmentHomeBinding>() {
|
||||
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<FragmentHomeBinding>() {
|
||||
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 用户配置消息失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user