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
|
@Singleton
|
||||||
class FiseVibrationController @Inject constructor(
|
class FiseVibrationController @Inject constructor(
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context,
|
||||||
|
private val configManager: VibrationConfigManager
|
||||||
) : VibrationController {
|
) : VibrationController {
|
||||||
|
|
||||||
/** 系统振动器 */
|
/** 系统振动器 */
|
||||||
@@ -47,18 +48,17 @@ class FiseVibrationController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按方案执行<EFBFBD><EFBFBD>动(<EFBFBD><EFBFBD><EFBFBD>循环和音频反馈)
|
* 按方案执行振动(不检查开关,直接执行,内部/测试用途)
|
||||||
* @param pattern 振动方案
|
* @param pattern 振动方案
|
||||||
*/
|
*/
|
||||||
override fun executePattern(pattern: VibrationPattern) {
|
override fun executePattern(pattern: VibrationPattern) {
|
||||||
// 先停止之前的振动
|
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
Timber.d("振动方案: ${pattern.planName}(planId=${pattern.planId})")
|
Timber.d("振动方案: ${pattern.planName}(planId=${pattern.planId})")
|
||||||
|
|
||||||
// 播放音频(如果启用且有音频资源)
|
// 播放音频(如果启用且有音频资源)
|
||||||
if (pattern.voiceState && pattern.audioResId != 0) {
|
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() {
|
override fun stop() {
|
||||||
// 停止振动<E68CAF><E58AA8>程
|
// 停止振动<E68CAF><E58AA8>程
|
||||||
@@ -105,13 +140,15 @@ class FiseVibrationController @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* 播放音频反馈
|
* 播放音频反馈
|
||||||
* @param resId 音频资源 ID(R.raw.xxx)
|
* @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 {
|
try {
|
||||||
// 释放上一个 MediaPlayer
|
// 释放上一个 MediaPlayer
|
||||||
releaseMediaPlayer()
|
releaseMediaPlayer()
|
||||||
// 创建并播放
|
// 创建并播放
|
||||||
mediaPlayer = MediaPlayer.create(context, resId)?.apply {
|
mediaPlayer = MediaPlayer.create(context, resId)?.apply {
|
||||||
|
setVolume(volume, volume) // 左右声道同音量
|
||||||
setOnCompletionListener {
|
setOnCompletionListener {
|
||||||
// 播放完毕自动释放
|
// 播放完毕自动释放
|
||||||
it.release()
|
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 {
|
interface VibrationController {
|
||||||
/** 执行一次简单振动 */
|
/** 执行一次简单振动 */
|
||||||
fun vibrate(durationMs: Long)
|
fun vibrate(durationMs: Long)
|
||||||
/** 按方案执行振动(含循环和音频反馈) */
|
/** 按方案执行振动(含循环和音频反馈,不检查开关,直接执行) */
|
||||||
fun executePattern(pattern: VibrationPattern)
|
fun executePattern(pattern: VibrationPattern)
|
||||||
|
/**
|
||||||
|
* 按 planId 执行振动(推荐使用)
|
||||||
|
* 内部完成:获取方案(优先服务端参数)→ 双层开关检查 → 音量控制
|
||||||
|
* 调用方无需关心参数来源和开关逻辑
|
||||||
|
*/
|
||||||
|
fun executeByPlanId(planId: Int)
|
||||||
/** 停止当前振动 */
|
/** 停止当前振动 */
|
||||||
fun stop()
|
fun stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.xiaoqu.watch.service.manager
|
|||||||
import com.xiaoqu.watch.data.task.TaskStatistics
|
import com.xiaoqu.watch.data.task.TaskStatistics
|
||||||
import com.xiaoqu.watch.device.screen.ScreenController
|
import com.xiaoqu.watch.device.screen.ScreenController
|
||||||
import com.xiaoqu.watch.device.sensor.VibrationController
|
import com.xiaoqu.watch.device.sensor.VibrationController
|
||||||
import com.xiaoqu.watch.device.sensor.VibrationDefaults
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -131,10 +130,7 @@ class NotificationManager @Inject constructor(
|
|||||||
addTaskIds(taskIds)
|
addTaskIds(taskIds)
|
||||||
|
|
||||||
// 震动 + 亮屏(只在首条时触发,暂存的合并后不重复震动)
|
// 震动 + 亮屏(只在首条时触发,暂存的合并后不重复震动)
|
||||||
val pattern = VibrationDefaults.getPattern(PLAN_NEW_MESSAGE)
|
vibrationController.executeByPlanId(PLAN_NEW_MESSAGE)
|
||||||
if (pattern != null) {
|
|
||||||
vibrationController.executePattern(pattern)
|
|
||||||
}
|
|
||||||
screenController.turnOn()
|
screenController.turnOn()
|
||||||
|
|
||||||
// 通知 HomeFragment 更新红点
|
// 通知 HomeFragment 更新红点
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
@Inject lateinit var taskApi: TaskApi
|
@Inject lateinit var taskApi: TaskApi
|
||||||
@Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager
|
@Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager
|
||||||
@Inject lateinit var notificationManager: com.xiaoqu.watch.service.manager.NotificationManager
|
@Inject lateinit var notificationManager: com.xiaoqu.watch.service.manager.NotificationManager
|
||||||
|
@Inject lateinit var vibrationConfigManager: com.xiaoqu.watch.device.sensor.VibrationConfigManager
|
||||||
|
|
||||||
/** 考勤打卡 ViewModel */
|
/** 考勤打卡 ViewModel */
|
||||||
private val punchViewModel: PunchViewModel by viewModels()
|
private val punchViewModel: PunchViewModel by viewModels()
|
||||||
@@ -508,6 +509,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
Timber.d("首页: 收到上下班状态推送")
|
Timber.d("首页: 收到上下班状态推送")
|
||||||
handleMqttWorkState(event.rawJson)
|
handleMqttWorkState(event.rawJson)
|
||||||
}
|
}
|
||||||
|
7 -> {
|
||||||
|
// 震动方案参数下发
|
||||||
|
Timber.d("首页: 收到震动方案数据")
|
||||||
|
handleMqttShockParams(event.rawJson)
|
||||||
|
}
|
||||||
|
8 -> {
|
||||||
|
// 用户配置下发(震动/语音开关+音量)
|
||||||
|
Timber.d("首页: 收到用户配置")
|
||||||
|
handleMqttUserConfig(event.rawJson)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -625,4 +636,32 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
navigateToNewTasks()
|
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.screen.ScreenController
|
||||||
import com.xiaoqu.watch.device.sensor.AccelerometerWakeController
|
import com.xiaoqu.watch.device.sensor.AccelerometerWakeController
|
||||||
import com.xiaoqu.watch.device.sensor.VibrationController
|
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.ApiResult
|
||||||
import com.xiaoqu.watch.network.api.PunchApi
|
import com.xiaoqu.watch.network.api.PunchApi
|
||||||
import com.xiaoqu.watch.network.safeApiCall
|
import com.xiaoqu.watch.network.safeApiCall
|
||||||
@@ -253,12 +252,9 @@ class PunchViewModel @Inject constructor(
|
|||||||
// accelerometerWake.resume()
|
// accelerometerWake.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 播放震动+音效反馈 */
|
/** 播放震动+音效反馈(通过 planId,内部处理双层开关+音量) */
|
||||||
private fun playFeedback(planId: Int) {
|
private fun playFeedback(planId: Int) {
|
||||||
val pattern = VibrationDefaults.getPattern(planId)
|
vibrationController.executeByPlanId(planId)
|
||||||
if (pattern != null) {
|
|
||||||
vibrationController.executePattern(pattern)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
|||||||
Reference in New Issue
Block a user