feat: 系统控制模块 - 硬件抽象层

新增:
- ScreenController 屏幕控制接口 + FiseScreenController 实现(ROM广播)
- NfcController NFC控制接口 + FiseNfcController 实现(sysfs读写)
- VibrationController 振动接口 + FiseVibrationController 实现(13种方案+音频)
- SystemStateMonitor 系统状态监听(电量、蓝牙状态广播)
- DeviceModule Hilt硬件抽象绑定
- 8个音频文件(res/raw/)
- AppEvent 新增4个系统状态事件

修改:
- MainActivity 注册 SystemStateMonitor
- HomeFragment 硬件验证demo(熄屏/振动/NFC/电量实时显示)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-27 13:50:25 +09:30
co-authored by Claude Opus 4.6
parent 5680613e86
commit c7ae11a574
22 changed files with 815 additions and 100 deletions
@@ -0,0 +1,126 @@
package com.xiaoqu.watch.device.nfc
import kotlinx.coroutines.*
import timber.log.Timber
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import javax.inject.Inject
import javax.inject.Singleton
/**
* FISE 定制 ROM NFC/RFID 控制实现
* 通过 sysfs 文件读写控制 RFID 模块(FISE ROM 特有)
*
* 硬件接口:
* - 上电:读 /sys/bus/i2c/drivers/rfid/1-0050/power_on
* - 断电:读 /sys/bus/i2c/drivers/rfid/1-0050/power_off
* - 读卡:读 /sys/bus/i2c/drivers/rfid/1-0050/typeA_id(轮询)
*/
@Singleton
class FiseNfcController @Inject constructor() : NfcController {
companion object {
/** RFID 上电文件路径 */
private const val RFID_POWER_ON = "/sys/bus/i2c/drivers/rfid/1-0050/power_on"
/** RFID 断电文件路径 */
private const val RFID_POWER_OFF = "/sys/bus/i2c/drivers/rfid/1-0050/power_off"
/** RFID 读卡文件路径 */
private const val RFID_TYPEA_ID = "/sys/bus/i2c/drivers/rfid/1-0050/typeA_id"
/** 轮询间隔(毫秒) */
private const val SCAN_INTERVAL_MS = 1000L
}
/** NFC 开关状态 */
private var _isOpen = false
/** 轮询协程 Job */
private var scanJob: Job? = null
/** 协程作用域 */
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun isOpen(): Boolean = _isOpen
/** 开启 NFC:读 power_on 文件触发硬件上电 */
override fun open() {
Timber.d("NFC控制: 开启")
readFile(RFID_POWER_ON)
_isOpen = true
}
/** 关闭 NFC:读 power_off 文件触发硬件断电,停止轮询 */
override fun close() {
Timber.d("NFC控制: 关闭")
stopScan()
readFile(RFID_POWER_OFF)
_isOpen = false
}
/**
* 开始轮询读卡
* 每 1 秒读一次 typeA_id 文件,解析出卡号后回调
* @param callback 读到卡后的回调,参数为 nfcId
*/
override fun startScan(callback: (nfcId: String) -> Unit) {
// 先停止已有的轮询
stopScan()
Timber.d("NFC控制: 开始轮询读卡")
scanJob = scope.launch {
while (isActive) {
try {
val content = readFile(RFID_TYPEA_ID)
if (content.isNotEmpty()) {
// 解析卡号:取第 12 位之后的内容,去除空格
// 对应旧版:typeaId.substring(12).replace(/\s*/g, "")
val nfcId = if (content.length > 12) {
content.substring(12).replace("\\s+".toRegex(), "")
} else {
content.replace("\\s+".toRegex(), "")
}
if (nfcId.isNotEmpty()) {
Timber.d("NFC控制: 读到卡号 $nfcId")
// 切回主线程回调
withContext(Dispatchers.Main) {
callback(nfcId)
}
}
}
} catch (e: Exception) {
Timber.w(e, "NFC控制: 读卡异常")
}
delay(SCAN_INTERVAL_MS)
}
}
}
/** 停止轮询 */
override fun stopScan() {
scanJob?.cancel()
scanJob = null
Timber.d("NFC控制: 停止轮询")
}
/**
* 读取 sysfs 文件内容
* @param path 文件路径
* @return 文件内容(读取失败返回空字符串)
*/
private fun readFile(path: String): String {
return try {
val file = File(path)
if (!file.exists()) {
Timber.w("NFC控制: 文件不存在 $path")
return ""
}
BufferedReader(FileReader(file)).use { reader ->
reader.readLine() ?: ""
}
} catch (e: Exception) {
Timber.w(e, "NFC控制: 读取文件失败 $path")
""
}
}
}
@@ -0,0 +1,18 @@
package com.xiaoqu.watch.device.nfc
/**
* NFC/RFID 控制接口(硬件抽象层)
* 封装 NFC 开关和读卡操作,换设备时只需替换实现类
*/
interface NfcController {
/** NFC 是否已开启 */
fun isOpen(): Boolean
/** 开启 NFC 电源 */
fun open()
/** 关闭 NFC 电源 */
fun close()
/** 开始轮询读卡,读到卡后回调 nfcId */
fun startScan(callback: (nfcId: String) -> Unit)
/** 停止轮询 */
fun stopScan()
}
@@ -0,0 +1,44 @@
package com.xiaoqu.watch.device.screen
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* FISE 定制 ROM 屏幕控制实现
* 通过系统广播控制亮屏/熄屏(FISE ROM 特有)
*/
@Singleton
class FiseScreenController @Inject constructor(
@ApplicationContext private val context: Context
) : ScreenController {
companion object {
/** FISE ROM 亮屏广播 */
private const val ACTION_SCREEN_ON = "com.fise.turn_screen_on"
/** FISE ROM 熄屏广播 */
private const val ACTION_SCREEN_OFF = "com.fise.turn_screen_off"
}
/** 获取屏幕是否亮着(通过 PowerManager 标准 API */
override fun isScreenOn(): Boolean {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return pm.isScreenOn
}
/** 亮屏:发送 FISE ROM 自定义广播 */
override fun turnOn() {
Timber.d("屏幕控制: 亮屏")
context.sendBroadcast(Intent(ACTION_SCREEN_ON))
}
/** 熄屏:发送 FISE ROM 自定义广播 */
override fun turnOff() {
Timber.d("屏幕控制: 熄屏")
context.sendBroadcast(Intent(ACTION_SCREEN_OFF))
}
}
@@ -0,0 +1,14 @@
package com.xiaoqu.watch.device.screen
/**
* 屏幕控制接口(硬件抽象层)
* 封装亮屏/熄屏操作,换设备时只需替换实现类
*/
interface ScreenController {
/** 获取屏幕是否亮着 */
fun isScreenOn(): Boolean
/** 亮屏 */
fun turnOn()
/** 熄屏 */
fun turnOff()
}
@@ -0,0 +1,136 @@
package com.xiaoqu.watch.device.sensor
import android.content.Context
import android.media.MediaPlayer
import android.os.Vibrator
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* FISE 振动控制实现
* 持简单振动和方案振动(嵌套循环 + 音频反馈)
*
* 振动算法(与旧版 shockStore.js 一致):
* 外层循环 shockCycleTimes 次 {
* 内层循环 shockTimes 次 {
* 振动 shockTime 秒
* 休息 shockIntervalTime 秒
* }
* }
*/
@Singleton
class FiseVibrationController @Inject constructor(
@ApplicationContext private val context: Context
) : VibrationController {
/** 系统振动器 */
@Suppress("DEPRECATION")
private val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
/** 当前振动协程 Job */
private var patternJob: Job? = null
/** 协程作用域 */
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
/** 当 MediaPlayer(播放完毕后释 */
private var mediaPlayer: MediaPlayer? = null
/** 执行一次单振动 */
@Suppress("DEPRECATION")
override fun vibrate(durationMs: Long) {
Timber.d("振动: ${durationMs}ms")
vibrator.vibrate(durationMs)
}
/**
* 按方案执行动(循环和音频反馈)
* @param pattern 振动方案
*/
override fun executePattern(pattern: VibrationPattern) {
// 先停止之前的振动
stop()
Timber.d("振动方案: ${pattern.planName}(planId=${pattern.planId})")
// 播放音频(如果启用且有音频资源)
if (pattern.voiceState && pattern.audioResId != 0) {
playAudio(pattern.audioResId)
}
// 执行振动(如果启用)
if (pattern.shockState) {
patternJob = scope.launch {
executePatternLoop(pattern)
}
}
}
/** 停止当前振动和音频 */
override fun stop() {
// 停止振动
patternJob?.cancel()
patternJob = null
// 停止硬件振动
vibrator.cancel()
// 停止音频
releaseMediaPlayer()
}
/**
* 执行振动方案的嵌套循环
* 外层:shockCycleTimes 次
* 内层:shockTimes 次振动 + 休息
*/
@Suppress("DEPRECATION")
private suspend fun executePatternLoop(pattern: VibrationPattern) {
val vibrateDurationMs = pattern.shockTime * 1000L
val intervalMs = pattern.shockIntervalTime * 1000L
// 外层循环:频次循环
for (cycle in 0 until pattern.shockCycleTimes) {
// 内层循环:每频次振动次数
for (times in 0 until pattern.shockTimes) {
// 振动
vibrator.vibrate(vibrateDurationMs)
// 等待振动成 + 休息间隔
delay(vibrateDurationMs + intervalMs)
}
}
}
/**
* 播放音频反馈
* @param resId 音频资源 IDR.raw.xxx
*/
private fun playAudio(resId: Int) {
try {
// 释放上一个 MediaPlayer
releaseMediaPlayer()
// 创建并播放
mediaPlayer = MediaPlayer.create(context, resId)?.apply {
setOnCompletionListener {
// 播放完毕自动释放
it.release()
mediaPlayer = null
}
start()
}
} catch (e: Exception) {
Timber.w(e, "振动: 音频播放失败")
}
}
/** 释放 MediaPlayer 防止内存泄漏 */
private fun releaseMediaPlayer() {
try {
mediaPlayer?.release()
} catch (e: Exception) {
// ignore
}
mediaPlayer = null
}
}
@@ -0,0 +1,14 @@
package com.xiaoqu.watch.device.sensor
/**
* 振动控制接口(硬件抽象层)
* 封装简单振动和方案振动(含循环+音频)
*/
interface VibrationController {
/** 执行一次简单振动 */
fun vibrate(durationMs: Long)
/** 按方案执行振动(含循环和音频反馈) */
fun executePattern(pattern: VibrationPattern)
/** 停止当前振动 */
fun stop()
}
@@ -0,0 +1,90 @@
package com.xiaoqu.watch.device.sensor
import com.xiaoqu.watch.R
/**
* 13 种默认振动方案
* 对应旧版 shockStore.js 中的 shockParams
* 参数可被服务端下发覆盖
*/
object VibrationDefaults {
/** 根据 planId 获取默认振动方案 */
fun getPattern(planId: Int): VibrationPattern? = patterns[planId]
/** 所有默认方案 */
val patterns: Map<Int, VibrationPattern> = mapOf(
// planId 2: 新消息
2 to VibrationPattern(
planId = 2, planName = "新消息",
shockTime = 1, shockTimes = 2, shockIntervalTime = 1, shockCycleTimes = 1,
audioResId = 0
),
// planId 3: 未读提醒
3 to VibrationPattern(
planId = 3, planName = "未读提醒",
shockTime = 1, shockTimes = 3, shockIntervalTime = 1, shockCycleTimes = 1,
audioResId = 0
),
// planId 4: 单次打卡成功
4 to VibrationPattern(
planId = 4, planName = "单次打卡成功",
shockTime = 1, shockTimes = 1, shockIntervalTime = 0, shockCycleTimes = 1,
audioResId = R.raw.punch_success
),
// planId 5: 批量打卡成功
5 to VibrationPattern(
planId = 5, planName = "批量打卡成功",
shockTime = 1, shockTimes = 2, shockIntervalTime = 1, shockCycleTimes = 1,
audioResId = R.raw.punch_success
),
// planId 6: 进入打卡范围
6 to VibrationPattern(
planId = 6, planName = "进入打卡范围",
shockTime = 1, shockTimes = 1, shockIntervalTime = 0, shockCycleTimes = 1,
audioResId = R.raw.open_success
),
// planId 7: 打卡失败
7 to VibrationPattern(
planId = 7, planName = "打卡失败",
shockTime = 1, shockTimes = 3, shockIntervalTime = 1, shockCycleTimes = 2,
audioResId = 0
),
// planId 8: NFC 开启
8 to VibrationPattern(
planId = 8, planName = "NFC开启",
shockTime = 1, shockTimes = 1, shockIntervalTime = 0, shockCycleTimes = 1,
audioResId = R.raw.open_punch
),
// planId 9: NFC 关闭
9 to VibrationPattern(
planId = 9, planName = "NFC关闭",
shockTime = 1, shockTimes = 1, shockIntervalTime = 0, shockCycleTimes = 1,
audioResId = R.raw.close_punch
),
// planId 10: 离线
10 to VibrationPattern(
planId = 10, planName = "离线",
shockTime = 1, shockTimes = 2, shockIntervalTime = 1, shockCycleTimes = 1,
audioResId = R.raw.offline
),
// planId 11: 开门失败
11 to VibrationPattern(
planId = 11, planName = "开门失败",
shockTime = 1, shockTimes = 2, shockIntervalTime = 1, shockCycleTimes = 1,
audioResId = R.raw.open_failed
),
// planId 12: 无权限开门
12 to VibrationPattern(
planId = 12, planName = "无权限开门",
shockTime = 1, shockTimes = 2, shockIntervalTime = 1, shockCycleTimes = 1,
audioResId = R.raw.no_auth_open
),
// planId 13: 正在开锁
13 to VibrationPattern(
planId = 13, planName = "正在开锁",
shockTime = 1, shockTimes = 1, shockIntervalTime = 0, shockCycleTimes = 1,
audioResId = R.raw.open_locking
)
)
}
@@ -0,0 +1,29 @@
package com.xiaoqu.watch.device.sensor
/**
* 振动方案数据类
* 对应旧版 shockStore.js 中的振动参数
*
* 振动算法:外层循环 shockCycleTimes 次,每次内层振动 shockTimes 次,
* 每次振动 shockTime 秒,振动间休息 shockIntervalTime 秒
*/
data class VibrationPattern(
/** 方案 ID2-13 */
val planId: Int,
/** 方案名称 */
val planName: String,
/** 单次振动时长(秒) */
val shockTime: Int,
/** 每频次振动次数 */
val shockTimes: Int,
/** 振动间休息时长(秒) */
val shockIntervalTime: Int,
/** 频次循环次数 */
val shockCycleTimes: Int,
/** 是否启用振动 */
val shockState: Boolean = true,
/** 是否启用声音 */
val voiceState: Boolean = true,
/** 音频资源 IDR.raw.xxx),0 表示无音频 */
val audioResId: Int = 0
)