kotlin
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
}
android {
namespace = "com.wifihealth.monitor"
compileSdk = 34
defaultConfig {
applicationId = "com.wifihealth.monitor"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
aaptOptions {
noCompress("tflite", "lite")
}
}
buildFeatures {
viewBinding = true
buildConfig = true
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("no.nordicsemi.android:ble:2.7.2")
implementation("org.tensorflow:tensorflow-lite:2.14.0")
implementation("org.tensorflow:tensorflow-lite-gpu:2.14.0")
implementation("org.tensorflow:tensorflow-lite-support:0.4.4")
implementation("com.github.wendykierp:JTransforms:3.1")
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
implementation("androidx.room:room-runtime:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.jakewharton.timber:timber:5.0.1")
}
kotlin
package com.wifihealth.monitor
import android.bluetooth.*
import android.content.Context
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import no.nordicsemi.android.ble.BleManager
import no.nordicsemi.android.ble.data.Data
import timber.log.Timber
import java.util.*
class Esp32BleManager(
context: Context,
private val onCsiDataReceived: (CsiFrame) -> Unit
) : BleManager(context) {
companion object {
val CSI_SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
val CSI_NOTIFY_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361b26a8")
val CSI_WRITE_CHAR_UUID: UUID = UUID.fromString("1c95d5e3-d8f7-413a-bf3d-7a2e5d3be1a0")
const val CMD_START_CSI = 0x01
const val CMD_STOP_CSI = 0x02
const val CMD_SET_CONFIG = 0x03
}
private var csiNotifyCharacteristic: BluetoothGattCharacteristic? = null
private var csiWriteCharacteristic: BluetoothGattCharacteristic? = null
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
sealed class ConnectionState {
object Disconnected : ConnectionState()
object Connecting : ConnectionState()
object Connected : ConnectionState()
data class Error(val message: String) : ConnectionState()
}
override fun getMinLogPriority() = Log.VERBOSE
override fun initialize() {
setNotificationCallback(csiNotifyCharacteristic)
.with { _, data ->
parseCsiData(data)?.let { csiFrame ->
onCsiDataReceived(csiFrame)
}
}
beginAtomicRequestQueue()
.add(enableNotifications(csiNotifyCharacteristic)
.fail { _, status ->
Timber.e("启用CSI通知失败: $status")
}
)
.done { _connectionState.value = ConnectionState.Connected }
.enqueue()
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
val service = gatt.getService(CSI_SERVICE_UUID)
return service?.let {
csiNotifyCharacteristic = it.getCharacteristic(CSI_NOTIFY_CHAR_UUID)
csiWriteCharacteristic = it.getCharacteristic(CSI_WRITE_CHAR_UUID)
val notifyProps = csiNotifyCharacteristic?.properties ?: 0
val hasNotify = (notifyProps and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0
hasNotify && csiWriteCharacteristic != null
} ?: false
}
override fun onServicesInvalidated() {
csiNotifyCharacteristic = null
csiWriteCharacteristic = null
}
fun sendCommand(command: Byte, payload: ByteArray? = null) {
val data = payload?.let { byteArrayOf(command, *it) } ?: byteArrayOf(command)
csiWriteCharacteristic?.let { char ->
writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
.enqueue()
}
}
private fun parseCsiData(data: Data): CsiFrame? {
return try {
val bytes = data.value ?: return null
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val timestamp = buffer.long
val sequence = buffer.short.toInt() and 0xFFFF
val subcarrierCount = buffer.short.toInt() and 0xFFFF
val amplitudes = FloatArray(subcarrierCount)
val phases = FloatArray(subcarrierCount)
for (i in 0 until subcarrierCount) {
val real = buffer.float
val imag = buffer.float
amplitudes[i] = kotlin.math.sqrt(real * real + imag * imag)
phases[i] = kotlin.math.atan2(imag, real)
}
CsiFrame(timestamp, sequence, amplitudes, phases)
} catch (e: Exception) {
Timber.e(e, "CSI数据解析失败")
null
}
}
fun startCsiCollection() = sendCommand(CMD_START_CSI)
fun stopCsiCollection() = sendCommand(CMD_STOP_CSI)
}
data class CsiFrame(
val timestamp: Long,
val sequenceNumber: Int,
val amplitudes: FloatArray,
val phases: FloatArray
)
kotlin
package com.wifihealth.monitor
import kotlin.math.*
class CsiDataProcessor {
companion object {
const val CSI_SAMPLING_RATE = 500
const val FFT_SIZE = 1024
const val HISTORY_SIZE = 5000
}
private val csiHistory = ArrayDeque<CsiFrame>()
private val lock = Any()
private val amplitudeBuffer = mutableListOf<FloatArray>()
private val phaseBuffer = mutableListOf<FloatArray>()
fun processNewFrame(frame: CsiFrame): ProcessedCsiData? {
synchronized(lock) {
if (!validateFrame(frame)) {
return null
}
csiHistory.addLast(frame)
if (csiHistory.size > HISTORY_SIZE) {
csiHistory.removeFirst()
}
val sanitizedPhases = sanitizePhase(frame.phases)
val selectedSubcarrier = selectBestSubcarrier(frame.amplitudes, sanitizedPhases)
return ProcessedCsiData(
timestamp = frame.timestamp,
amplitude = frame.amplitudes[selectedSubcarrier],
phase = sanitizedPhases[selectedSubcarrier],
selectedSubcarrierIndex = selectedSubcarrier,
allAmplitudes = frame.amplitudes.copyOf(),
allPhases = sanitizedPhases.copyOf()
)
}
}
private fun validateFrame(frame: CsiFrame): Boolean {
val validAmplitudes = frame.amplitudes.count { it > 0 && !it.isNaN() }
val validRatio = validAmplitudes.toFloat() / frame.amplitudes.size
if (validRatio < 0.8) return false
val median = frame.amplitudes.sorted().let {
if (it.size % 2 == 0) (it[it.size / 2 - 1] + it[it.size / 2]) / 2
else it[it.size / 2]
}
val mad = frame.amplitudes.map { abs(it - median) }.sorted().let {
if (it.size % 2 == 0) (it[it.size / 2 - 1] + it[it.size / 2]) / 2
else it[it.size / 2]
}
val threshold = 3.0 * 1.4826 * mad
val outlierCount = frame.amplitudes.count { abs(it - median) > threshold }
return outlierCount < frame.amplitudes.size * 0.1
}
private fun sanitizePhase(rawPhases: FloatArray): FloatArray {
val n = rawPhases.size
val sanitized = FloatArray(n)
val unwrapped = FloatArray(n)
unwrapped[0] = rawPhases[0]
var cumulativeOffset = 0f
for (i in 1 until n) {
var diff = rawPhases[i] - rawPhases[i - 1] + cumulativeOffset
while (diff > PI) {
diff -= 2 * PI.toFloat()
cumulativeOffset -= 2 * PI.toFloat()
}
while (diff < -PI) {
diff += 2 * PI.toFloat()
cumulativeOffset += 2 * PI.toFloat()
}
unwrapped[i] = rawPhases[i] + cumulativeOffset
}
val subcarrierIndices = FloatArray(n) { (it - n / 2).toFloat() }
var sumX = 0f; var sumY = 0f
var sumXY = 0f; var sumX2 = 0f
for (i in 0 until n) {
sumX += subcarrierIndices[i]
sumY += unwrapped[i]
sumXY += subcarrierIndices[i] * unwrapped[i]
sumX2 += subcarrierIndices[i] * subcarrierIndices[i]
}
val slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
val intercept = (sumY - slope * sumX) / n
for (i in 0 until n) {
sanitized[i] = unwrapped[i] - (slope * subcarrierIndices[i] + intercept)
}
return sanitized
}
private fun selectBestSubcarrier(
amplitudes: FloatArray,
phases: FloatArray
): Int {
val variances = phases.map { abs(it) }
val startIdx = amplitudes.size / 4
val endIdx = amplitudes.size * 3 / 4
var bestIdx = startIdx
var maxVariance = 0f
for (i in startIdx until endIdx) {
if (variances[i] > maxVariance) {
maxVariance = variances[i]
bestIdx = i
}
}
return bestIdx
}
fun getRecentSequence(count: Int): List<ProcessedCsiData> {
synchronized(lock) {
return csiHistory.takeLast(count).map { frame ->
val sanitizedPhases = sanitizePhase(frame.phases)
val bestSubcarrier = selectBestSubcarrier(frame.amplitudes, sanitizedPhases)
ProcessedCsiData(
timestamp = frame.timestamp,
amplitude = frame.amplitudes[bestSubcarrier],
phase = sanitizedPhases[bestSubcarrier],
selectedSubcarrierIndex = bestSubcarrier,
allAmplitudes = frame.amplitudes.copyOf(),
allPhases = sanitizedPhases.copyOf()
)
}
}
}
}
data class ProcessedCsiData(
val timestamp: Long,
val amplitude: Float,
val phase: Float,
val selectedSubcarrierIndex: Int,
val allAmplitudes: FloatArray,
val allPhases: FloatArray
)
kotlin
package com.wifihealth.monitor
import org.jtransforms.fft.DoubleFFT_1D
import kotlin.math.*
class SignalProcessor {
companion object {
const val SAMPLING_RATE = 500.0
const val BREATH_MIN = 0.1
const val BREATH_MAX = 0.5
const val HEART_MIN = 0.8
const val HEART_MAX = 2.5
}
fun bandpassFilter(
signal: DoubleArray,
lowFreq: Double,
highFreq: Double
): DoubleArray {
val n = signal.size
val fft = DoubleFFT_1D(n.toLong())
val complex = DoubleArray(n * 2)
for (i in signal.indices) {
complex[2 * i] = signal[i]
complex[2 * i + 1] = 0.0
}
fft.complexForward(complex)
val freqResolution = SAMPLING_RATE / n
for (k in 0 until n / 2) {
val freq = k * freqResolution
val shouldKeep = freq >= lowFreq && freq <= highFreq
if (!shouldKeep) {
complex[2 * k] = 0.0
complex[2 * k + 1] = 0.0
if (k > 0 && k < n / 2) {
complex[2 * (n - k)] = 0.0
complex[2 * (n - k) + 1] = 0.0
}
}
}
fft.complexInverse(complex, true)
return DoubleArray(n) { complex[2 * it] }
}
fun computePSD(signal: DoubleArray): Pair<DoubleArray, DoubleArray> {
val n = signal.size
val fft = DoubleFFT_1D(n.toLong())
val complex = DoubleArray(n * 2)
for (i in signal.indices) {
complex[2 * i] = signal[i]
}
fft.complexForward(complex)
val psd = DoubleArray(n / 2 + 1)
val freqs = DoubleArray(n / 2 + 1)
psd[0] = complex[0] * complex[0] / (n * n)
freqs[0] = 0.0
for (k in 1 until n / 2) {
val real = complex[2 * k]
val imag = complex[2 * k + 1]
psd[k] = 2 * (real * real + imag * imag) / (n * n)
freqs[k] = k * SAMPLING_RATE / n
}
if (n % 2 == 0) {
val k = n / 2
val real = complex[2 * k]
psd[k] = real * real / (n * n)
freqs[k] = SAMPLING_RATE / 2
}
return Pair(freqs, psd)
}
fun computeSpectrogram(
signal: DoubleArray,
windowSize: Int = 256,
hopSize: Int = 128
): Array<DoubleArray> {
val numFrames = (signal.size - windowSize) / hopSize + 1
val spectrogram = Array(numFrames) { DoubleArray(windowSize / 2 + 1) }
val window = DoubleArray(windowSize) { i ->
0.54 - 0.46 * cos(2 * PI * i / (windowSize - 1))
}
val fft = DoubleFFT_1D(windowSize.toLong())
for (frameIdx in 0 until numFrames) {
val start = frameIdx * hopSize
val frame = DoubleArray(windowSize * 2)
for (i in 0 until windowSize) {
frame[2 * i] = signal[start + i] * window[i]
frame[2 * i + 1] = 0.0
}
fft.complexForward(frame)
for (k in 0..windowSize / 2) {
val real = frame[2 * k]
val imag = frame[2 * k + 1]
spectrogram[frameIdx][k] = 10 * log10(real * real + imag * imag + 1e-10)
}
}
return spectrogram
}
fun movingAverage(signal: DoubleArray, windowSize: Int): DoubleArray {
val result = DoubleArray(signal.size)
var sum = 0.0
for (i in signal.indices) {
sum += signal[i]
if (i >= windowSize) sum -= signal[i - windowSize]
result[i] = sum / min(i + 1, windowSize)
}
return result
}
}
kotlin
package com.wifihealth.monitor
import kotlin.math.*
class VitalSignExtractor(
private val signalProcessor: SignalProcessor
) {
fun extractVitalSigns(
phaseSequence: DoubleArray,
windowSeconds: Int = 30
): VitalSignsResult {
val breathSignal = signalProcessor.bandpassFilter(
phaseSequence,
SignalProcessor.BREATH_MIN,
SignalProcessor.BREATH_MAX
)
val breathRate = estimateDominantFrequency(
breathSignal,
SignalProcessor.BREATH_MIN,
SignalProcessor.BREATH_MAX
)
val residualSignal = removeBreathHarmonics(phaseSequence, breathRate)
val heartSignal = signalProcessor.bandpassFilter(
residualSignal,
SignalProcessor.HEART_MIN,
SignalProcessor.HEART_MAX
)
val heartRate = estimateDominantFrequency(
heartSignal,
SignalProcessor.HEART_MIN,
SignalProcessor.HEART_MAX
)
val breathSNR = computeSNR(breathSignal)
val heartSNR = computeSNR(heartSignal)
return VitalSignsResult(
breathRate = breathRate * 60,
heartRate = heartRate * 60,
breathConfidence = min(1.0, breathSNR / 10.0),
heartConfidence = min(1.0, heartSNR / 10.0),
breathWaveform = breathSignal.takeLast(500).toList(),
heartWaveform = heartSignal.takeLast(500).toList()
)
}
private fun estimateDominantFrequency(
signal: DoubleArray,
minFreq: Double,
maxFreq: Double
): Double {
val (freqs, psd) = signalProcessor.computePSD(signal)
var maxPower = 0.0
var dominantFreq = (minFreq + maxFreq) / 2
for (i in freqs.indices) {
if (freqs[i] >= minFreq && freqs[i] <= maxFreq) {
if (psd[i] > maxPower) {
maxPower = psd[i]
dominantFreq = freqs[i]
}
}
}
return dominantFreq
}
private fun removeBreathHarmonics(
signal: DoubleArray,
breathFreq: Double
): DoubleArray {
var result = signal.copyOf()
for (harmonic in 1..3) {
val notchFreq = breathFreq * harmonic
if (notchFreq > SignalProcessor.HEART_MAX) break
result = applyNotchFilter(result, notchFreq, 0.02)
}
return result
}
private fun applyNotchFilter(
signal: DoubleArray,
notchFreq: Double,
bandwidth: Double
): DoubleArray {
val n = signal.size
val fft = org.jtransforms.fft.DoubleFFT_1D(n.toLong())
val complex = DoubleArray(n * 2)
for (i in signal.indices) {
complex[2 * i] = signal[i]
}
fft.complexForward(complex)
val freqRes = SignalProcessor.SAMPLING_RATE / n
val notchBin = (notchFreq / freqRes).toInt()
val widthBins = ((bandwidth * notchFreq) / freqRes).toInt().coerceAtLeast(1)
for (k in max(0, notchBin - widthBins)..min(n / 2, notchBin + widthBins)) {
complex[2 * k] = 0.0
complex[2 * k + 1] = 0.0
if (k > 0) {
complex[2 * (n - k)] = 0.0
complex[2 * (n - k) + 1] = 0.0
}
}
fft.complexInverse(complex, true)
return DoubleArray(n) { complex[2 * it] }
}
private fun computeSNR(signal: DoubleArray): Double {
val mean = signal.average()
val variance = signal.map { (it - mean) * (it - mean) }.average()
return 10 * log10(variance + 1e-10)
}
}
data class VitalSignsResult(
val breathRate: Double,
val heartRate: Double,
val breathConfidence: Double,
val heartConfidence: Double,
val breathWaveform: List<Double>,
val heartWaveform: List<Double>
)
kotlin
package com.wifihealth.monitor
import android.content.Context
import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.support.common.FileUtil
import java.nio.MappedByteBuffer
class FallDetector(context: Context) {
companion object {
const val INPUT_HEIGHT = 64
const val INPUT_WIDTH = 64
const val INPUT_CHANNELS = 1
const val CONFIRMATION_FRAMES = 3
const val FALL_THRESHOLD = 0.85
}
private val interpreter: Interpreter
private val inputBuffer = Array(1) { Array(INPUT_HEIGHT) {
Array(INPUT_WIDTH) { FloatArray(INPUT_CHANNELS) }
}}
private val predictionHistory = ArrayDeque<Float>()
private var consecutiveFallFrames = 0
init {
val modelBuffer: MappedByteBuffer = FileUtil.loadMappedFile(
context,
"fall_detection.tflite"
)
val options = Interpreter.Options().apply {
setNumThreads(4)
useNNAPI = true
}
interpreter = Interpreter(modelBuffer, options)
}
fun processFrame(
spectrogram: Array<DoubleArray>,
signalProcessor: SignalProcessor
): FallDetectionResult {
normalizeSpectrogram(spectrogram)
val output = Array(1) { FloatArray(4) }
interpreter.run(inputBuffer, output)
val probs = output[0]
val fallProb = probs[3]
predictionHistory.addLast(fallProb)
if (predictionHistory.size > 10) {
predictionHistory.removeFirst()
}
val smoothedProb = predictionHistory.average().toFloat()
val isFallDetected = when {
smoothedProb > FALL_THRESHOLD -> {
consecutiveFallFrames++
consecutiveFallFrames >= CONFIRMATION_FRAMES
}
else -> {
consecutiveFallFrames = 0
false
}
}
return FallDetectionResult(
isFallDetected = isFallDetected,
fallProbability = smoothedProb,
activityClass = probs.indices.maxByOrNull { probs[it] } ?: 0,
allProbabilities = probs.toList(),
confidence = smoothedProb
)
}
private fun normalizeSpectrogram(spec: Array<DoubleArray>) {
var minVal = Double.POSITIVE_INFINITY
var maxVal = Double.NEGATIVE_INFINITY
for (row in spec) {
for (v in row) {
if (v < minVal) minVal = v
if (v > maxVal) maxVal = v
}
}
val range = maxVal - minVal
val srcH = spec.size
val srcW = if (srcH > 0) spec[0].size else 0
for (y in 0 until INPUT_HEIGHT) {
for (x in 0 until INPUT_WIDTH) {
val srcY = y * srcH / INPUT_HEIGHT
val srcX = x * srcW / INPUT_WIDTH
val value = if (srcY < srcH && srcX < srcW && range > 0) {
((spec[srcY][srcX] - minVal) / range).toFloat()
} else {
0f
}
inputBuffer[0][y][x][0] = value
}
}
}
fun close() {
interpreter.close()
}
}
data class FallDetectionResult(
val isFallDetected: Boolean,
val fallProbability: Float,
val activityClass: Int,
val allProbabilities: List<Float>,
val confidence: Float
)
kotlin
package com.wifihealth.monitor
import android.app.*
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.*
class AlertManager(private val context: Context) {
companion object {
const val CHANNEL_ID_FALL = "fall_alert"
const val CHANNEL_ID_VITAL = "vital_sign_alert"
const val NOTIFICATION_ID_FALL = 1001
const val NOTIFICATION_ID_HEART = 1002
const val NOTIFICATION_ID_BREATH = 1003
const val HEART_RATE_MIN = 50
const val HEART_RATE_MAX = 120
const val BREATH_RATE_MIN = 8
const val BREATH_RATE_MAX = 30
}
private val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var alertDialog: AlertDialog? = null
init {
createNotificationChannels()
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val fallChannel = NotificationChannel(
CHANNEL_ID_FALL,
"跌倒紧急报警",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "检测到疑似跌倒时的紧急通知"
setSound(
Uri.parse("android.resource://${context.packageName}/raw/emergency_alert"),
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
enableVibration(true)
vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
enableLights(true)
lightColor = 0xFFFF0000.toInt()
}
val vitalChannel = NotificationChannel(
CHANNEL_ID_VITAL,
"生命体征预警",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "心率或呼吸频率异常提醒"
enableVibration(true)
}
notificationManager.createNotificationChannels(listOf(fallChannel, vitalChannel))
}
}
fun triggerFallAlert(
location: String = "卫生间",
confidence: Float
) {
vibrateEmergency()
showFullScreenAlert(location, confidence)
val intent = Intent(context, EmergencyActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
putExtra("alert_type", "fall")
putExtra("location", location)
putExtra("confidence", confidence)
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID_FALL)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle("🚨 检测到疑似跌倒")
.setContentText("位置: $location | 置信度: ${(confidence * 100).toInt()}%")
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setFullScreenIntent(pendingIntent, true)
.setAutoCancel(false)
.setOngoing(true)
.addAction(
android.R.drawable.ic_delete,
"误报解除",
createDismissAction("fall")
)
.addAction(
android.R.drawable.ic_menu_call,
"立即求助",
createEmergencyCallAction()
)
.build()
notificationManager.notify(NOTIFICATION_ID_FALL, notification)
scope.launch {
delay(30000)
if (alertDialog?.isShowing == true) {
makeEmergencyCall()
}
}
}
fun triggerHeartRateAlert(heartRate: Double, isTooLow: Boolean) {
val type = if (isTooLow) "心率过缓" else "心率过快"
val notification = NotificationCompat.Builder(context, CHANNEL_ID_VITAL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("⚠️ $type")
.setContentText("当前心率: ${heartRate.toInt()} BPM")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
notificationManager.notify(NOTIFICATION_ID_HEART, notification)
}
fun triggerBreathAlert(breathRate: Double, isApnea: Boolean = false) {
val title = if (isApnea) "🚨 检测到呼吸暂停" else "⚠️ 呼吸频率异常"
val notification = NotificationCompat.Builder(context, CHANNEL_ID_VITAL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText("当前呼吸: ${breathRate.toInt()} 次/分钟")
.setPriority(
if (isApnea) NotificationCompat.PRIORITY_MAX
else NotificationCompat.PRIORITY_HIGH
)
.build()
notificationManager.notify(NOTIFICATION_ID_BREATH, notification)
}
private fun showFullScreenAlert(location: String, confidence: Float) {
val dialogIntent = Intent(context, EmergencyActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
putExtra("show_alert", true)
putExtra("location", location)
putExtra("confidence", confidence)
}
context.startActivity(dialogIntent)
}
private fun vibrateEmergency() {
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE)
as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createWaveform(
longArrayOf(0, 500, 200, 500, 200, 500),
-1
)
)
}
}
private fun createDismissAction(type: String): PendingIntent {
val intent = Intent(context, AlertDismissReceiver::class.java).apply {
action = "DISMISS_ALERT"
putExtra("type", type)
}
return PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createEmergencyCallAction(): PendingIntent {
val intent = Intent(Intent.ACTION_CALL).apply {
data = Uri.parse("tel:120")
}
return PendingIntent.getActivity(
context, 1, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun makeEmergencyCall() {
}
fun dismissAllAlerts() {
notificationManager.cancelAll()
alertDialog?.dismiss()
alertDialog = null
}
}
class EmergencyActivity : Activity() {
}
kotlin
package com.wifihealth.monitor
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.*
import com.github.mikephil.charting.charts.LineChart
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var bleManager: Esp32BleManager
private lateinit var csiProcessor: CsiDataProcessor
private lateinit var signalProcessor: SignalProcessor
private lateinit var vitalExtractor: VitalSignExtractor
private lateinit var fallDetector: FallDetector
private lateinit var alertManager: AlertManager
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initComponents()
setupUI()
startMonitoring()
}
private fun initComponents() {
csiProcessor = CsiDataProcessor()
signalProcessor = SignalProcessor()
vitalExtractor = VitalSignExtractor(signalProcessor)
fallDetector = FallDetector(this)
alertManager = AlertManager(this)
bleManager = Esp32BleManager(this) { csiFrame ->
processCsiFrame(csiFrame)
}
}
private fun setupUI() {
setupHeartChart(binding.chartHeart)
setupBreathChart(binding.chartBreath)
setupCsiChart(binding.chartCsi)
lifecycleScope.launch {
bleManager.connectionState.collect { state ->
updateConnectionUI(state)
}
}
lifecycleScope.launch {
uiState.collect { state ->
updateVitalDisplay(state)
}
}
binding.btnConnect.setOnClickListener { scanAndConnect() }
binding.btnStart.setOnClickListener { bleManager.startCsiCollection() }
binding.btnStop.setOnClickListener { bleManager.stopCsiCollection() }
}
private fun processCsiFrame(frame: CsiFrame) {
scope.launch(Dispatchers.Default) {
val processed = csiProcessor.processNewFrame(frame) ?: return@launch
val recentData = csiProcessor.getRecentSequence(
SignalProcessor.SAMPLING_RATE.toInt() * 30
)
if (recentData.size < 100) return@launch
val phaseSequence = recentData.map { it.phase.toDouble() }.toDoubleArray()
if (frame.sequenceNumber % (5 * 500) == 0) {
val vitals = vitalExtractor.extractVitalSigns(phaseSequence)
checkVitalAlerts(vitals)
withContext(Dispatchers.Main) {
_uiState.update {
it.copy(
heartRate = vitals.heartRate,
breathRate = vitals.breathRate,
heartConfidence = vitals.heartConfidence,
breathConfidence = vitals.breathConfidence,
heartWaveform = vitals.heartWaveform,
breathWaveform = vitals.breathWaveform
)
}
}
}
if (frame.sequenceNumber % 500 == 0) {
val amplitudeSequence = recentData.map { it.amplitude.toDouble() }.toDoubleArray()
val spectrogram = signalProcessor.computeSpectrogram(amplitudeSequence)
val fallResult = fallDetector.processFrame(spectrogram, signalProcessor)
if (fallResult.isFallDetected) {
withContext(Dispatchers.Main) {
alertManager.triggerFallAlert(
confidence = fallResult.confidence
)
}
}
}
withContext(Dispatchers.Main) {
updateCsiWaveform(processed)
}
}
}
private fun checkVitalAlerts(vitals: VitalSignsResult) {
when {
vitals.heartRate < AlertManager.HEART_RATE_MIN -> {
alertManager.triggerHeartRateAlert(vitals.heartRate, isTooLow = true)
}
vitals.heartRate > AlertManager.HEART_RATE_MAX -> {
alertManager.triggerHeartRateAlert(vitals.heartRate, isTooLow = false)
}
}
when {
vitals.breathRate < AlertManager.BREATH_RATE_MIN -> {
alertManager.triggerBreathAlert(vitals.breathRate)
}
vitals.breathRate > AlertManager.BREATH_RATE_MAX -> {
alertManager.triggerBreathAlert(vitals.breathRate)
}
}
if (vitals.breathRate < 3 && vitals.breathConfidence > 0.7) {
alertManager.triggerBreathAlert(vitals.breathRate, isApnea = true)
}
}
private fun scanAndConnect() {
}
override fun onDestroy() {
super.onDestroy()
fallDetector.close()
scope.cancel()
}
}
data class UiState(
val isConnected: Boolean = false,
val isMonitoring: Boolean = false,
val heartRate: Double = 0.0,
val breathRate: Double = 0.0,
val heartConfidence: Double = 0.0,
val breathConfidence: Double = 0.0,
val heartWaveform: List<Double> = emptyList(),
val breathWaveform: List<Double> = emptyList(),
val csiAmplitude: List<Float> = emptyList()
)