feat: Avtex Smart Sleep Engine - initial release for Android and Home Assistant
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
package com.example.avtex.engine
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.avtex.AvtexApplication
|
||||
import com.example.avtex.MainActivity
|
||||
import com.example.avtex.hac.HacClient
|
||||
import com.google.android.gms.location.ActivityRecognition
|
||||
import com.google.android.gms.location.SleepSegmentRequest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class SleepTrackingService : Service() {
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private lateinit var significantMotionTrigger: SignificantMotionTrigger
|
||||
private lateinit var hacClient: HacClient
|
||||
|
||||
private var screenReceiver: BroadcastReceiver? = null
|
||||
private var inactivityJob: Job? = null
|
||||
private var sleepApiPendingIntent: PendingIntent? = null
|
||||
|
||||
private var currentState: SleepState = SleepState.AWAKE
|
||||
private var stateChangeTimestamp: Long = System.currentTimeMillis()
|
||||
|
||||
private val settingsRepository get() = AvtexApplication.instance.settingsRepository
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "SleepTrackingService onCreate")
|
||||
hacClient = HacClient(this)
|
||||
|
||||
significantMotionTrigger = SignificantMotionTrigger(this) {
|
||||
handleWakeupDetected(source = "significant_motion")
|
||||
}
|
||||
|
||||
registerScreenReceiver()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action ?: ACTION_START_TRACKING
|
||||
Log.d(TAG, "onStartCommand action: $action")
|
||||
|
||||
when (action) {
|
||||
ACTION_START_TRACKING -> {
|
||||
startForegroundServiceWithNotification()
|
||||
initializeTracking()
|
||||
}
|
||||
ACTION_STOP_TRACKING -> {
|
||||
stopTracking()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
ACTION_MANUAL_TOGGLE -> {
|
||||
val newState = if (currentState == SleepState.AWAKE) SleepState.ASLEEP else SleepState.AWAKE
|
||||
transitionToState(newState, source = "manual_toggle", confidence = 100)
|
||||
}
|
||||
ACTION_TEST_HAC -> {
|
||||
serviceScope.launch {
|
||||
val settings = settingsRepository.getSettings()
|
||||
val isAsleep = currentState == SleepState.ASLEEP
|
||||
settingsRepository.addLog("HAC_TEST", "Отправка тестового запроса в Home Assistant...")
|
||||
val result = hacClient.sendSleepEvent(settings, isAsleep, source = "manual_test")
|
||||
result.onSuccess { msg ->
|
||||
settingsRepository.addLog("HAC_TEST", "Тест успешен: $msg", isImportant = true)
|
||||
settingsRepository.updateHacStatus("Тест успешен: $msg")
|
||||
}.onFailure { err ->
|
||||
settingsRepository.addLog("HAC_TEST", "Ошибка теста: ${err.localizedMessage}", isImportant = true)
|
||||
settingsRepository.updateHacStatus("Ошибка: ${err.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startForegroundServiceWithNotification() {
|
||||
val notification = buildNotification(
|
||||
title = "Avtex: Мониторинг активности",
|
||||
text = "Статус: ${if (currentState == SleepState.ASLEEP) "Спит 🌙" else "Бодрствует ☀️"}"
|
||||
)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun initializeTracking() {
|
||||
serviceScope.launch {
|
||||
val settings = settingsRepository.getSettings()
|
||||
currentState = if (settings.currentState == "ASLEEP") SleepState.ASLEEP else SleepState.AWAKE
|
||||
stateChangeTimestamp = settings.lastStateChangeMillis
|
||||
settingsRepository.updateTrackingActive(true)
|
||||
settingsRepository.addLog("Engine", "Служба мониторинга сна запущена", isImportant = true)
|
||||
|
||||
updateNotification()
|
||||
subscribeToSleepApi()
|
||||
|
||||
if (currentState == SleepState.ASLEEP) {
|
||||
significantMotionTrigger.arm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTracking() {
|
||||
serviceScope.launch {
|
||||
settingsRepository.updateTrackingActive(false)
|
||||
settingsRepository.addLog("Engine", "Служба мониторинга остановлена", isImportant = true)
|
||||
significantMotionTrigger.disarm()
|
||||
unsubscribeFromSleepApi()
|
||||
inactivityJob?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerScreenReceiver() {
|
||||
screenReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
Log.d(TAG, "Screen OFF event received")
|
||||
handleScreenOff()
|
||||
}
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
Log.d(TAG, "Screen ON event received")
|
||||
}
|
||||
Intent.ACTION_USER_PRESENT -> {
|
||||
Log.d(TAG, "User unlocked device")
|
||||
handleWakeupDetected(source = "screen_unlocked")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_USER_PRESENT)
|
||||
}
|
||||
registerReceiver(screenReceiver, filter)
|
||||
}
|
||||
|
||||
private fun handleScreenOff() {
|
||||
if (currentState == SleepState.AWAKE) {
|
||||
inactivityJob?.cancel()
|
||||
inactivityJob = serviceScope.launch {
|
||||
val settings = settingsRepository.getSettings()
|
||||
val minutes = settings.inactivityMinutes
|
||||
Log.d(TAG, "Starting inactivity countdown: $minutes minutes")
|
||||
settingsRepository.addLog("Engine", "Экран выключен. Ожидание сна ($minutes мин)...")
|
||||
delay(minutes * 60 * 1000L)
|
||||
|
||||
// If still awake and not interrupted, assume sleep
|
||||
if (currentState == SleepState.AWAKE) {
|
||||
settingsRepository.addLog("Engine", "Время покоя истекло ($minutes мин). Переход в режим сна.", isImportant = true)
|
||||
transitionToState(SleepState.ASLEEP, source = "inactivity_timer", confidence = 90)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWakeupDetected(source: String) {
|
||||
inactivityJob?.cancel()
|
||||
if (currentState == SleepState.ASLEEP) {
|
||||
Log.d(TAG, "Wakeup detected from source: $source")
|
||||
transitionToState(SleepState.AWAKE, source = source, confidence = 100)
|
||||
}
|
||||
}
|
||||
|
||||
private fun transitionToState(newState: SleepState, source: String, confidence: Int) {
|
||||
if (currentState == newState) return
|
||||
|
||||
currentState = newState
|
||||
stateChangeTimestamp = System.currentTimeMillis()
|
||||
|
||||
serviceScope.launch {
|
||||
val isAsleep = (newState == SleepState.ASLEEP)
|
||||
val stateName = if (isAsleep) "ASLEEP" else "AWAKE"
|
||||
settingsRepository.updateCurrentState(stateName, stateChangeTimestamp)
|
||||
|
||||
if (isAsleep) {
|
||||
// When falling asleep, arm significant motion trigger for instant wake detection
|
||||
val armed = significantMotionTrigger.arm()
|
||||
settingsRepository.addLog(
|
||||
"Engine",
|
||||
"Зафиксировано засыпание ($source). Датчик движения взведен: $armed",
|
||||
isImportant = true
|
||||
)
|
||||
} else {
|
||||
// When waking up, disarm trigger
|
||||
significantMotionTrigger.disarm()
|
||||
settingsRepository.addLog(
|
||||
"Engine",
|
||||
"Зафиксировано пробуждение ($source).",
|
||||
isImportant = true
|
||||
)
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
|
||||
// Acquire temporary WakeLock to ensure HTTP request reaches HAC on OnePlus
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Avtex:HacTransmissionWakeLock")
|
||||
wakeLock.acquire(15000L) // max 15 seconds
|
||||
|
||||
try {
|
||||
val settings = settingsRepository.getSettings()
|
||||
val result = hacClient.sendSleepEvent(settings, isAsleep, source, confidence)
|
||||
result.onSuccess { msg ->
|
||||
Log.d(TAG, "HAC send success: $msg")
|
||||
settingsRepository.addLog("HAC", "Отправлено в Home Assistant: $msg")
|
||||
settingsRepository.updateHacStatus("Отправлено в ${formatTime(stateChangeTimestamp)}: $msg")
|
||||
}.onFailure { err ->
|
||||
Log.e(TAG, "HAC send error", err)
|
||||
settingsRepository.addLog("HAC", "Ошибка отправки в Home Assistant: ${err.localizedMessage}", isImportant = true)
|
||||
settingsRepository.updateHacStatus("Ошибка: ${err.localizedMessage}")
|
||||
}
|
||||
} finally {
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToSleepApi() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "ACTIVITY_RECOGNITION permission not granted for Sleep API")
|
||||
settingsRepository.addLog("SleepAPI", "Нет разрешения на активность. Используются аппаратные датчики движения.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val intent = Intent(this, SleepReceiver::class.java).apply {
|
||||
action = SleepReceiver.ACTION_SLEEP_API_EVENT
|
||||
}
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
sleepApiPendingIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_SLEEP_API, intent, flags)
|
||||
|
||||
val request = SleepSegmentRequest.getDefaultSleepSegmentRequest()
|
||||
val task = ActivityRecognition.getClient(this).requestSleepSegmentUpdates(sleepApiPendingIntent!!, request)
|
||||
task.addOnSuccessListener {
|
||||
Log.d(TAG, "Successfully subscribed to Google Sleep API")
|
||||
settingsRepository.addLog("SleepAPI", "Google Sleep API успешно подключен")
|
||||
}
|
||||
task.addOnFailureListener { e ->
|
||||
Log.e(TAG, "Failed to subscribe to Google Sleep API", e)
|
||||
settingsRepository.addLog("SleepAPI", "Не удалось подключить Google Sleep API: ${e.localizedMessage}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception subscribing to Sleep API", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsubscribeFromSleepApi() {
|
||||
sleepApiPendingIntent?.let { pi ->
|
||||
try {
|
||||
ActivityRecognition.getClient(this).removeSleepSegmentUpdates(pi)
|
||||
Log.d(TAG, "Unsubscribed from Sleep API")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error unsubscribing from Sleep API", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
val openAppIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, openAppIntent, flags)
|
||||
|
||||
return NotificationCompat.Builder(this, AvtexApplication.CHANNEL_ID_TRACKING)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_charging)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
val stateText = if (currentState == SleepState.ASLEEP) "Спит 🌙" else "Бодрствует ☀️"
|
||||
val timeFormatted = formatTime(stateChangeTimestamp)
|
||||
val notification = buildNotification(
|
||||
title = "Avtex: $stateText",
|
||||
text = "Состояние с $timeFormatted"
|
||||
)
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun formatTime(millis: Long): String {
|
||||
return SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(millis))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "SleepTrackingService onDestroy")
|
||||
screenReceiver?.let {
|
||||
unregisterReceiver(it)
|
||||
screenReceiver = null
|
||||
}
|
||||
significantMotionTrigger.disarm()
|
||||
unsubscribeFromSleepApi()
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SleepTrackingService"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val REQUEST_CODE_SLEEP_API = 2001
|
||||
|
||||
const val ACTION_START_TRACKING = "com.example.avtex.ACTION_START_TRACKING"
|
||||
const val ACTION_STOP_TRACKING = "com.example.avtex.ACTION_STOP_TRACKING"
|
||||
const val ACTION_MANUAL_TOGGLE = "com.example.avtex.ACTION_MANUAL_TOGGLE"
|
||||
const val ACTION_TEST_HAC = "com.example.avtex.ACTION_TEST_HAC"
|
||||
|
||||
@Volatile
|
||||
private var instance: SleepTrackingService? = null
|
||||
|
||||
fun onSleepClassifyReceived(context: Context, confidence: Int, motion: Int, light: Int) {
|
||||
// If confidence is high and device is in stationary motion
|
||||
val repo = AvtexApplication.instance.settingsRepository
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val settings = repo.getSettings()
|
||||
if (settings.isTrackingActive && settings.currentState == "AWAKE") {
|
||||
if (confidence >= settings.confidenceThreshold && motion <= 2) {
|
||||
Log.d(TAG, "SleepClassify indicates sleep: confidence=$confidence, motion=$motion")
|
||||
// Send intent to service to transition state
|
||||
val intent = Intent(context, SleepTrackingService::class.java).apply {
|
||||
action = ACTION_MANUAL_TOGGLE // or specific intent
|
||||
}
|
||||
// Only trigger if actually confident
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSleepSegmentReceived(context: Context, start: Long, end: Long, status: Int) {
|
||||
Log.d(TAG, "SleepSegment completed: start=$start, end=$end, status=$status")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user