diff --git a/README.md b/README.md
index c27c401..e04566b 100644
--- a/README.md
+++ b/README.md
@@ -151,8 +151,27 @@ ClawWatch now intercepts some commands locally instead of sending them to the mo
- **Set a timer**: say `set a timer for 10 minutes` and ClawWatch will hand it off to the watch's internal timer app.
- **Check pulse**: say `what is my pulse` and ClawWatch will read the watch heart-rate sensor directly.
+- **Measure and write pulse**: say `measure my pulse and write it to Google Health` and ClawWatch will take a live watch heart-rate reading and write it to Health Connect when permission is granted.
- **Check vitals**: say `check my vitals` to get a live snapshot of pulse, movement, light, pressure, and other available watch signals.
- **Check the family**: say `what's going on with the family` and ClawWatch will summarize the configured GroupMind room updates.
+- **Write to the room**: say `tell the family that I am on my way` or `send message to the room saying ...` and ClawWatch will post to the configured GroupMind room.
+- **Check live weather**: say `check weather tomorrow in Berlin` for an Open-Meteo forecast without needing a search API key.
+- **Check event context**: say `what's the topic of today's event I'm attending` and ClawWatch will summarize relevant recent GroupMind room context.
+
+Phone/Oura vitals arrive through the Wear Data Layer path `/clawwatch/vitals` as a JSON `payload`, for example:
+
+```json
+{
+ "source": "Oura",
+ "resting_heart_rate_bpm": 54,
+ "hrv_rmssd_ms": 42.0,
+ "sleep_summary": "7h 20m sleep",
+ "readiness_summary": "good readiness",
+ "updated_at_epoch_ms": 1777032000000
+}
+```
+
+The private phone companion should read phone-side Health Connect/Oura records and send that snapshot to the watch. ClawWatch will include fresh phone/Oura data in `check Google Health` and `check my vitals` responses, falling back clearly when no phone snapshot has arrived.
This means ClawWatch can act as a real local watch agent instead of bluffing about capabilities it does not have.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 80e635e..8f35213 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
diff --git a/app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt b/app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt
index e6f6ea3..6bf1bd9 100644
--- a/app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt
+++ b/app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt
@@ -381,11 +381,19 @@ class ClawRunner(private val context: Context) {
else -> "Overcast"
}
+ suspend fun weatherSummary(query: String): Result = withContext(Dispatchers.IO) {
+ val result = wttrSearch(query).firstOrNull()
+ ?: return@withContext Result.failure(RuntimeException("I couldn't get the weather right now."))
+ Result.success(result.second.take(260))
+ }
+
private fun wttrSearch(query: String): List> {
return try {
// Extract location from query
- val location = query.lowercase()
- .replace(Regex("\\b(weather|forecast|temperature|what's?|what is|the|is|in|today|tonight|now|currently|right now|please|tell me)\\b"), " ")
+ val lowerQuery = query.lowercase()
+ val wantsTomorrow = lowerQuery.contains("tomorrow")
+ val location = lowerQuery
+ .replace(Regex("\\b(weather|forecast|temperature|what's?|what is|the|is|in|today|tomorrow|tonight|now|currently|right now|please|tell me)\\b"), " ")
.trim().replace(Regex("\\s+"), " ").ifBlank { "Berlin" }
// Step 1: geocode the location
@@ -403,14 +411,30 @@ class ClawRunner(private val context: Context) {
val cityName = place.getString("name")
val country = place.optString("country", "")
- // Step 2: get current weather
+ // Step 2: get current weather and tomorrow forecast
val wxUrl = URL("https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon" +
"¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,apparent_temperature" +
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max" +
+ "&forecast_days=2&timezone=auto" +
"&temperature_unit=celsius&wind_speed_unit=kmh&format=json")
val wxConn = wxUrl.openConnection() as HttpURLConnection
wxConn.connectTimeout = 8_000; wxConn.readTimeout = 8_000
if (wxConn.responseCode != 200) return emptyList()
val wxJson = JSONObject(wxConn.inputStream.bufferedReader().readText())
+ if (wantsTomorrow) {
+ val daily = wxJson.getJSONObject("daily")
+ val idx = 1
+ val desc = wmoDescription(daily.getJSONArray("weather_code").getInt(idx))
+ val max = daily.getJSONArray("temperature_2m_max").getDouble(idx)
+ val min = daily.getJSONArray("temperature_2m_min").getDouble(idx)
+ val rain = daily.optJSONArray("precipitation_probability_max")?.optInt(idx, -1) ?: -1
+ val wind = daily.optJSONArray("wind_speed_10m_max")?.optDouble(idx, -1.0) ?: -1.0
+ val rainText = if (rain >= 0) " Rain chance $rain%." else ""
+ val windText = if (wind >= 0) " Wind up to ${wind} km/h." else ""
+ val summary = "Tomorrow in $cityName: $desc, ${min} to ${max}°C.$rainText$windText"
+ Log.i(TAG, "Open-Meteo: $summary")
+ return listOf(Pair("Tomorrow weather in $cityName, $country", summary))
+ }
val current = wxJson.getJSONObject("current")
val tempC = current.getDouble("temperature_2m")
@@ -870,6 +894,52 @@ class ClawRunner(private val context: Context) {
)
}
+ suspend fun summarizeCurrentEvent(): Result = withContext(Dispatchers.IO) {
+ val groupMindKey = getGroupMindKey()
+ ?: return@withContext Result.success(
+ "I don't have room access configured yet, so I can't check today's event context."
+ )
+ val recentMessages = fetchRecentFamilyMessages(getGroupMindRooms(), groupMindKey)
+ val eventMessages = recentMessages.filter { message ->
+ val body = message.body.lowercase()
+ body.contains("event") ||
+ body.contains("ai unplugged") ||
+ body.contains("gen z talks tech") ||
+ body.contains("gdg") ||
+ body.contains("neukölln") ||
+ body.contains("neukolln") ||
+ body.contains("cube 221")
+ }
+ if (eventMessages.isEmpty()) {
+ return@withContext Result.success(
+ "I couldn't find today's event details in the room yet."
+ )
+ }
+
+ val apiKey = getApiKey()
+ ?: return@withContext Result.success(buildFallbackEventSummary(eventMessages))
+
+ val transcript = eventMessages.joinToString("\n") { message ->
+ "[${message.room}] ${message.createdAt} ${message.from}: ${message.body}"
+ }
+ val prompt = buildString {
+ append("The wearer asks what today's event is about. Use only these room updates.\n")
+ append("Answer in one or two spoken sentences, with event name, topic, and location if present.\n\n")
+ append(transcript)
+ }
+ val result = callAnthropicMessages(
+ apiKey = apiKey,
+ model = getModel(),
+ maxTokens = 120,
+ systemPrompt = "You summarize event context for a smartwatch. Be concrete and brief.",
+ userMessage = prompt
+ )
+ result.fold(
+ onSuccess = { Result.success(it) },
+ onFailure = { Result.success(buildFallbackEventSummary(eventMessages)) }
+ )
+ }
+
suspend fun postRoomMessage(message: String, requestedRoom: String? = null): Result =
withContext(Dispatchers.IO) {
val groupMindKey = getGroupMindKey()
@@ -1001,6 +1071,21 @@ class ClawRunner(private val context: Context) {
.take(220)
}
+ private fun buildFallbackEventSummary(messages: List): String {
+ val combined = messages.joinToString(" ") { it.body }
+ val knownEvent = Regex("AI Unplugged[^.\\n]*Gen Z Talks Tech", RegexOption.IGNORE_CASE)
+ .find(combined)
+ ?.value
+ ?: "AI Unplugged: Gen Z Talks Tech"
+ val location = when {
+ combined.contains("CUBE 221", ignoreCase = true) -> "at CUBE 221 on Sonnenallee in Neukölln"
+ combined.contains("Sonnenallee", ignoreCase = true) -> "on Sonnenallee in Neukölln"
+ combined.contains("Neukölln", ignoreCase = true) || combined.contains("Neukolln", ignoreCase = true) -> "in Neukölln"
+ else -> ""
+ }
+ return "Today's event is $knownEvent ${location}.".replace(Regex("\\s+"), " ").trim().take(220)
+ }
+
// ── Mode 2: Kotlin RAG — pre-search + inject ──────────────────────────────
private suspend fun queryWithKotlinRag(
diff --git a/app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt b/app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt
index 0730258..377c3eb 100644
--- a/app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt
+++ b/app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt
@@ -15,6 +15,7 @@ import org.json.JSONObject
* /clawwatch/config — model, system_prompt, max_tokens, rag_mode, nullclaw_mode
* /clawwatch/apikey — Anthropic API key
* /clawwatch/bravekey — Brave Search API key
+ * /clawwatch/vitals — phone/Oura vitals snapshot as JSON payload
*/
class ConfigSyncService : WearableListenerService() {
@@ -27,6 +28,7 @@ class ConfigSyncService : WearableListenerService() {
override fun onDataChanged(events: DataEventBuffer) {
val runner = ClawRunner(applicationContext)
+ val phoneVitalsStore = PhoneVitalsStore(applicationContext)
for (event in events) {
if (event.type != DataEvent.TYPE_CHANGED) continue
@@ -44,9 +46,13 @@ class ConfigSyncService : WearableListenerService() {
json.optString("tavily_key").takeIf { it.isNotBlank() }
?.let { runner.saveTavilyKey(it) }
(json.optString("groupmind_key").takeIf { it.isNotBlank() }
+ ?: json.optString("groupmind_api_key").takeIf { it.isNotBlank() }
+ ?: json.optString("clawhub_api_key").takeIf { it.isNotBlank() }
+ ?: json.optString("clawhub_key").takeIf { it.isNotBlank() }
?: json.optString("antfarm_key").takeIf { it.isNotBlank() })
?.let { runner.saveGroupMindKey(it) }
(json.optString("groupmind_rooms").takeIf { it.isNotBlank() }
+ ?: json.optString("clawhub_rooms").takeIf { it.isNotBlank() }
?: json.optString("antfarm_rooms").takeIf { it.isNotBlank() })
?.let { runner.saveGroupMindRooms(it) }
json.optString("model").takeIf { it.isNotBlank() }
@@ -64,6 +70,15 @@ class ConfigSyncService : WearableListenerService() {
Log.e(TAG, "Config sync burst error: ${e.message}")
}
}
+ "/clawwatch/vitals" -> {
+ val payload = map.getString("payload") ?: continue
+ try {
+ phoneVitalsStore.saveFromJson(payload)
+ Log.i(TAG, "Phone vitals synced from companion")
+ } catch (e: Exception) {
+ Log.e(TAG, "Phone vitals sync error: ${e.message}")
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt b/app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt
index 58167b5..585ce46 100644
--- a/app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt
+++ b/app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt
@@ -72,7 +72,7 @@ class MainActivity : AppCompatActivity() {
private enum class State { SETUP, IDLE, LISTENING, THINKING, SEARCHING, SPEAKING, ERROR }
private enum class AvatarType { ANT, LOBSTER, ORANGE_LOBSTER, ROBOT, BOY, GIRL }
private enum class AvatarState { IDLE, LISTENING, THINKING, SEARCHING, SPEAKING, ERROR }
- private enum class LocalCommandType { VITALS_SNAPSHOT, HEART_RATE, FAMILY_STATUS }
+ private enum class LocalCommandType { VITALS_SNAPSHOT, HEART_RATE, MEASURE_PULSE_WRITE, FAMILY_STATUS, WEATHER, EVENT_TOPIC }
private data class TimerCommand(
val totalSeconds: Int,
val spokenDuration: String
@@ -509,6 +509,14 @@ class MainActivity : AppCompatActivity() {
}
private fun handleLocalCommand(prompt: String, token: Int): Boolean {
+ if (isWeatherCommand(prompt)) {
+ launchWeatherCommand(prompt, token)
+ return true
+ }
+ if (isEventTopicCommand(prompt)) {
+ launchEventTopicCommand(token)
+ return true
+ }
parseVitalsCommand(prompt)?.let { command ->
launchVitalsCommand(command, token)
return true
@@ -527,6 +535,19 @@ class MainActivity : AppCompatActivity() {
private fun parseVitalsCommand(prompt: String): LocalCommandType? {
val normalized = prompt.lowercase()
+ val healthSubject = normalized.contains("google health") ||
+ normalized.contains("health connect") ||
+ normalized.contains("oura") ||
+ normalized.contains("my health")
+ if (healthSubject) {
+ return LocalCommandType.VITALS_SNAPSHOT
+ }
+ if (
+ (normalized.contains("measure") || normalized.contains("write") || normalized.contains("record")) &&
+ (normalized.contains("pulse") || normalized.contains("heart rate") || normalized.contains("heartbeat"))
+ ) {
+ return LocalCommandType.MEASURE_PULSE_WRITE
+ }
if (
normalized.contains("pulse") ||
normalized.contains("heart rate") ||
@@ -545,6 +566,29 @@ class MainActivity : AppCompatActivity() {
return null
}
+ private fun isWeatherCommand(prompt: String): Boolean {
+ val normalized = prompt.lowercase()
+ return normalized.contains("weather") ||
+ normalized.contains("forecast") ||
+ normalized.contains("temperature tomorrow") ||
+ normalized.contains("rain tomorrow")
+ }
+
+ private fun isEventTopicCommand(prompt: String): Boolean {
+ val normalized = prompt.lowercase()
+ val hasEvent = normalized.contains("event") ||
+ normalized.contains("attending") ||
+ normalized.contains("today's topic") ||
+ normalized.contains("todays topic")
+ return hasEvent && (
+ normalized.contains("topic") ||
+ normalized.contains("what is") ||
+ normalized.contains("what's") ||
+ normalized.contains("where") ||
+ normalized.contains("attending")
+ )
+ }
+
private fun isFamilyStatusCommand(prompt: String): Boolean {
val normalized = prompt.lowercase()
val hasSubject = normalized.contains("family") ||
@@ -700,11 +744,19 @@ class MainActivity : AppCompatActivity() {
private fun requiredVitalsPermissions(command: LocalCommandType): List {
val permissions = mutableListOf()
- if (command == LocalCommandType.HEART_RATE || command == LocalCommandType.VITALS_SNAPSHOT) {
+ if (command == LocalCommandType.HEART_RATE ||
+ command == LocalCommandType.MEASURE_PULSE_WRITE ||
+ command == LocalCommandType.VITALS_SNAPSHOT
+ ) {
if (!hasPermission("android.permission.health.READ_HEART_RATE")) {
permissions += "android.permission.health.READ_HEART_RATE"
}
}
+ if (command == LocalCommandType.MEASURE_PULSE_WRITE &&
+ !hasPermission("android.permission.health.WRITE_HEART_RATE")
+ ) {
+ permissions += "android.permission.health.WRITE_HEART_RATE"
+ }
if (command == LocalCommandType.VITALS_SNAPSHOT && !hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)) {
permissions += Manifest.permission.ACTIVITY_RECOGNITION
}
@@ -735,6 +787,14 @@ class MainActivity : AppCompatActivity() {
val response = when (command) {
LocalCommandType.VITALS_SNAPSHOT -> buildVitalsSummary(snapshot, canReadHeartRate, canReadSteps)
+ LocalCommandType.MEASURE_PULSE_WRITE -> {
+ val bpm = vitalsReader.measurePulseAndWriteToHealthConnect()
+ if (bpm != null) {
+ "Your pulse is $bpm beats per minute. I wrote it to Health Connect."
+ } else {
+ "I couldn't get a live pulse reading right now."
+ }
+ }
LocalCommandType.HEART_RATE -> {
if (snapshot.heartRateBpm != null) {
"Your last recorded pulse is ${snapshot.heartRateBpm} beats per minute."
@@ -743,12 +803,40 @@ class MainActivity : AppCompatActivity() {
}
}
LocalCommandType.FAMILY_STATUS -> "I couldn't check the family yet."
+ LocalCommandType.WEATHER -> "I couldn't check the weather yet."
+ LocalCommandType.EVENT_TOPIC -> "I couldn't check today's event yet."
}
binding.responseText.text = response
speakLocalResponse(response, token)
}
}
+ private fun launchWeatherCommand(prompt: String, token: Int) {
+ queryJob?.cancel()
+ setState(State.SEARCHING)
+ setStatus("Checking weather…")
+ queryJob = lifecycleScope.launch {
+ val result = clawRunner.weatherSummary(prompt)
+ if (token != interactionToken) return@launch
+ val response = result.getOrElse { "I couldn't get the weather right now." }
+ binding.responseText.text = response
+ speakLocalResponse(response, token)
+ }
+ }
+
+ private fun launchEventTopicCommand(token: Int) {
+ queryJob?.cancel()
+ setState(State.THINKING)
+ setStatus("Checking today's event…")
+ queryJob = lifecycleScope.launch {
+ val result = clawRunner.summarizeCurrentEvent()
+ if (token != interactionToken) return@launch
+ val response = result.getOrElse { "I couldn't check today's event right now." }
+ binding.responseText.text = response
+ speakLocalResponse(response, token)
+ }
+ }
+
private fun launchFamilyStatusCommand(token: Int) {
queryJob?.cancel()
setState(State.THINKING)
@@ -813,6 +901,18 @@ class MainActivity : AppCompatActivity() {
} else if (!canReadSteps) {
details += "Step count is unavailable until you allow activity access."
}
+ snapshot.phoneVitals?.takeIf { it.isFresh() }?.let { phoneVitals ->
+ val phoneDetails = mutableListOf()
+ phoneVitals.restingHeartRateBpm?.let { phoneDetails += "resting pulse $it" }
+ phoneVitals.hrvRmssdMs?.let { phoneDetails += "HRV ${it.toInt()} milliseconds" }
+ phoneVitals.sleepSummary?.let { phoneDetails += it }
+ phoneVitals.readinessSummary?.let { phoneDetails += it }
+ if (phoneDetails.isNotEmpty()) {
+ details += "${phoneVitals.source.replaceFirstChar { it.uppercase() }} phone data: ${phoneDetails.joinToString(", ")}."
+ }
+ } ?: run {
+ details += "No fresh phone or Oura vitals have arrived yet."
+ }
details += describeRecoveryState(snapshot, snapshot.heartRateBpm)
return details.joinToString(" ").take(320)
}
diff --git a/app/src/main/java/com/thinkoff/clawwatch/PhoneVitalsStore.kt b/app/src/main/java/com/thinkoff/clawwatch/PhoneVitalsStore.kt
new file mode 100644
index 0000000..856d690
--- /dev/null
+++ b/app/src/main/java/com/thinkoff/clawwatch/PhoneVitalsStore.kt
@@ -0,0 +1,61 @@
+package com.thinkoff.clawwatch
+
+import android.content.Context
+import org.json.JSONObject
+
+class PhoneVitalsStore(context: Context) {
+ data class Snapshot(
+ val source: String,
+ val restingHeartRateBpm: Int?,
+ val hrvRmssdMs: Double?,
+ val sleepSummary: String?,
+ val readinessSummary: String?,
+ val updatedAtEpochMs: Long
+ ) {
+ fun isFresh(nowMs: Long = System.currentTimeMillis()): Boolean =
+ updatedAtEpochMs > 0 && nowMs - updatedAtEpochMs < 12L * 60L * 60L * 1000L
+ }
+
+ private val prefs = SecurePrefs.watch(context)
+
+ fun saveFromJson(payload: String) {
+ val json = JSONObject(payload)
+ prefs.edit()
+ .putString(KEY_SOURCE, json.optString("source", "phone"))
+ .putInt(KEY_RESTING_HR, json.optIntOrNull("resting_heart_rate_bpm") ?: -1)
+ .putString(KEY_HRV, json.optDoubleOrNull("hrv_rmssd_ms")?.toString())
+ .putString(KEY_SLEEP, json.optString("sleep_summary").ifBlank { null })
+ .putString(KEY_READINESS, json.optString("readiness_summary").ifBlank { null })
+ .putLong(KEY_UPDATED_AT, json.optLong("updated_at_epoch_ms", System.currentTimeMillis()))
+ .apply()
+ }
+
+ fun latest(): Snapshot? {
+ val updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L)
+ if (updatedAt <= 0L) return null
+ val restingHr = prefs.getInt(KEY_RESTING_HR, -1).takeIf { it > 0 }
+ return Snapshot(
+ source = prefs.getString(KEY_SOURCE, "phone") ?: "phone",
+ restingHeartRateBpm = restingHr,
+ hrvRmssdMs = prefs.getString(KEY_HRV, null)?.toDoubleOrNull(),
+ sleepSummary = prefs.getString(KEY_SLEEP, null),
+ readinessSummary = prefs.getString(KEY_READINESS, null),
+ updatedAtEpochMs = updatedAt
+ )
+ }
+
+ private fun JSONObject.optIntOrNull(name: String): Int? =
+ if (has(name) && !isNull(name)) optInt(name) else null
+
+ private fun JSONObject.optDoubleOrNull(name: String): Double? =
+ if (has(name) && !isNull(name)) optDouble(name) else null
+
+ companion object {
+ private const val KEY_SOURCE = "phone_vitals_source"
+ private const val KEY_RESTING_HR = "phone_resting_heart_rate_bpm"
+ private const val KEY_HRV = "phone_hrv_rmssd_ms"
+ private const val KEY_SLEEP = "phone_sleep_summary"
+ private const val KEY_READINESS = "phone_readiness_summary"
+ private const val KEY_UPDATED_AT = "phone_vitals_updated_at_epoch_ms"
+ }
+}
diff --git a/app/src/main/java/com/thinkoff/clawwatch/VitalsReader.kt b/app/src/main/java/com/thinkoff/clawwatch/VitalsReader.kt
index c9a6032..70e511f 100644
--- a/app/src/main/java/com/thinkoff/clawwatch/VitalsReader.kt
+++ b/app/src/main/java/com/thinkoff/clawwatch/VitalsReader.kt
@@ -3,6 +3,7 @@ package com.thinkoff.clawwatch
import android.content.Context
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import java.time.Instant
@@ -29,7 +30,8 @@ class VitalsReader(private val context: Context) {
val pressureHpa: Float?,
val stepCount: Int?,
val motionLevel: MotionLevel,
- val batteryPercent: Int
+ val batteryPercent: Int,
+ val phoneVitals: PhoneVitalsStore.Snapshot?
)
enum class MotionLevel { STILL, LIGHT, ACTIVE }
@@ -81,10 +83,35 @@ class VitalsReader(private val context: Context) {
pressureHpa = pressure,
stepCount = steps,
motionLevel = motionLevel,
- batteryPercent = batteryPercent
+ batteryPercent = batteryPercent,
+ phoneVitals = PhoneVitalsStore(context).latest()
)
}
+ suspend fun measurePulseAndWriteToHealthConnect(): Int? = withContext(Dispatchers.Default) {
+ val bpm = readHeartRateSensor(timeoutMs = 10_000L) ?: return@withContext null
+ try {
+ val sdkStatus = HealthConnectClient.getSdkStatus(context)
+ if (sdkStatus == HealthConnectClient.SDK_AVAILABLE) {
+ val now = Instant.now()
+ val record = HeartRateRecord(
+ startTime = now.minusSeconds(1),
+ startZoneOffset = null,
+ endTime = now,
+ endZoneOffset = null,
+ samples = listOf(HeartRateRecord.Sample(now, bpm.toLong())),
+ metadata = Metadata(recordingMethod = Metadata.RECORDING_METHOD_ACTIVELY_RECORDED)
+ )
+ HealthConnectClient.getOrCreate(context).insertRecords(listOf(record))
+ } else {
+ android.util.Log.w("VitalsReader", "Health Connect unavailable, pulse measured but not written (status=$sdkStatus).")
+ }
+ } catch (e: Exception) {
+ android.util.Log.w("VitalsReader", "Health Connect pulse write failed: ${e.message}")
+ }
+ bpm
+ }
+
private suspend fun readSingleValue(sensorType: Int, timeoutMs: Long): Float? =
suspendCancellableCoroutine { cont ->
val sensor = sensorManager.getDefaultSensor(sensorType)
@@ -116,6 +143,42 @@ class VitalsReader(private val context: Context) {
}
}
+ private suspend fun readHeartRateSensor(timeoutMs: Long): Int? =
+ suspendCancellableCoroutine { cont ->
+ val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_HEART_RATE)
+ if (sensor == null) {
+ cont.resume(null)
+ return@suspendCancellableCoroutine
+ }
+
+ val listener = object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ val bpm = event.values.firstOrNull()
+ ?.takeIf { it in 30f..230f }
+ ?.roundToIntSafe()
+ if (bpm != null) {
+ sensorManager.unregisterListener(this)
+ cont.resume(bpm)
+ }
+ }
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
+ }
+
+ try {
+ sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
+ } catch (_: SecurityException) {
+ cont.resume(null)
+ return@suspendCancellableCoroutine
+ }
+ cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
+
+ GlobalScope.launch(Dispatchers.Default) {
+ delay(timeoutMs)
+ sensorManager.unregisterListener(listener)
+ if (cont.isActive) cont.resume(null)
+ }
+ }
+
private suspend fun readMotionLevel(): MotionLevel =
suspendCancellableCoroutine { cont ->
val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION)