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:
dongliang
2026-04-30 19:38:40 +09:30
parent d6a8f4acf9
commit 6e8c93fc46
6 changed files with 211 additions and 17 deletions

View File

@@ -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/voiceStateMQTT type=7 下发)
* - 用户级:全局的 userShockEnabled/userVoiceEnabledMQTT 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 音频资源 IDR.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()

View File

@@ -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.0MQTT type=8 下发 voiceValue/100默认 0.5 */
@Volatile
var voiceVolume = 0.5f
/**
* 获取振动方案:优先服务端参数,没有则用默认
* @param planId 方案 ID2-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: 用户配置解析异常")
}
}
}

View File

@@ -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()
}

View File

@@ -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 更新红点

View File

@@ -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 用户配置消息失败")
}
}
}

View File

@@ -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() {