diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b88cb22 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + - name: Unit tests + run: ./gradlew --no-daemon testDebugUnitTest + - name: Build debug APK + run: ./gradlew --no-daemon :app:assembleDebug + - name: Upload test APK + uses: actions/upload-artifact@v4 + with: + name: pulseloop-debug-apk + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + retention-days: 14 diff --git a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt index 9ac7bbc..d8a189d 100644 --- a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt +++ b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt @@ -49,6 +49,7 @@ class CoachSummaryCoordinator( is PulseEvent.ActivityUpdate, is PulseEvent.HeartRateSample, is PulseEvent.Spo2Result, + is PulseEvent.BloodSugarSample, is PulseEvent.HistoryMeasurement -> { pendingToday = true scheduleRefresh() diff --git a/app/src/main/java/com/pulseloop/data/DataRepairs.kt b/app/src/main/java/com/pulseloop/data/DataRepairs.kt index 27b5275..5601ea4 100644 --- a/app/src/main/java/com/pulseloop/data/DataRepairs.kt +++ b/app/src/main/java/com/pulseloop/data/DataRepairs.kt @@ -23,14 +23,9 @@ object DataRepairs { * window are additionally self-healed by `applyActivityBucket` on the next sync. Today's * row is out of scope — the live cumulative total re-ratchets on the next update. * - * No sleep counterpart is needed: sleep_sessions AND sleep_stage_blocks are cleared and - * rebuilt from the ring on every connect (see EventPersistenceSubscriber's CONNECTED - * handling), so rows keyed by the old start-of-day grouping — sessions split across - * midnight and stage blocks filed under what is now a different night's id — disappear - * on the first sync after this update. The same reasoning retires iOS's one-time - * `migrateSleepSessionSegmentsIfNeeded` (PR #83): a waking day whose nap was merged into that - * morning's night re-splits into distinct sessions on the next sync via - * EventPersistenceSubscriber.reconcileWakingDay, and demo days re-split on the next reseed. + * Complete ring sleep records independently replace their waking-day session and remove any + * overlapping legacy midnight-split parents. Connection events must not clear real sleep: + * replacement history is asynchronous and may be empty or consumed. */ suspend fun runIfNeeded(context: Context, db: PulseLoopDatabase = PulseLoopDatabase.getInstance(context)) { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index ffd4447..7ded19a 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -40,7 +40,7 @@ import com.pulseloop.data.entity.* BatterySampleEntity::class, CoachNotificationRecordEntity::class, ], - version = 12, + version = 16, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -205,6 +205,150 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** v12 → v13: index replayed sensor-history identity without deleting valid collisions. */ + private val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + // Feature APKs briefly used version 8 for this index before main assigned v8 to + // battery history. Keep the migration valid for both upgrade lineages. + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `battery_samples` ( + `id` TEXT NOT NULL, + `percent` INTEGER NOT NULL, + `timestamp` INTEGER NOT NULL, + `createdAt` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_battery_samples_timestamp` ON `battery_samples` (`timestamp`)") + adoptStableMeasurementIdentities(db) + } + } + + /** v13 → v14: replace the pre-review unique identity index without dropping rows. */ + private val MIGRATION_13_14 = object : Migration(13, 14) { + override fun migrate(db: SupportSQLiteDatabase) { + adoptStableMeasurementIdentities(db) + } + } + + /** v14 → v15: re-run identity adoption now that it also covers HRV rows stored as 'live'. + * Same reasoning as v13 → v14: a test APK that already reached v14 ran the pre-fix version + * of [adoptStableMeasurementIdentities] and would otherwise keep its un-keyed HRV rows, + * which double the HRV series on the next re-sync. The function is idempotent, so re-running + * it is free for anyone whose rows are already adopted. */ + private val MIGRATION_14_15 = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + adoptStableMeasurementIdentities(db) + } + } + + /** + * v15 → v16: delete the duplicate rows the pre-identity code accumulated. + * + * Before measurements had stable ids, a ring's history *replay* was persisted with a fresh + * random id every time, so each re-sync appended another row at a slot already stored — on + * a real Colmi that meant HRV, stress and temperature growing by one row per slot per sync, + * forever (nothing prunes this table). [adoptStableMeasurementIdentities] stops the growth + * by giving one row per slot the canonical `history::` id that later syncs + * upsert onto, but it deliberately leaves the already-accumulated copies in place. They are + * not harmless: `dailyAggregates`/`hourlyAggregates` compute `AVG(value)` over raw rows with + * no `sourceRaw` filter, so a slot replayed more often than its neighbours drags the average + * toward its value. + * + * Deleting is restricted to rows that are **provably redundant**: a non-canonical row is + * removed only when a canonical row exists for the same `(kindRaw, timestamp)` *and* holds + * the same `value`. A row whose value differs is a distinct reading and is always kept, so + * this can never destroy information — every deleted row's (kind, timestamp, value) is still + * represented by the canonical row that survives. + * + * **Interruption safety.** This is deliberately one statement. SQLite applies a single + * `DELETE` atomically via its journal, so a process kill mid-migration can only leave the + * table fully cleaned or wholly untouched — never half-deleted. Room additionally runs + * migrations inside the transaction `SQLiteOpenHelper` opens around `onUpgrade`, so the + * schema-version bump and this delete commit together: an interrupted upgrade rolls back to + * v15 and simply re-runs on the next launch. The statement is also idempotent — once the + * redundant rows are gone it matches nothing — so re-running after a rollback is a no-op. + */ + private val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + DELETE FROM `measurements` + WHERE `id` NOT LIKE 'history:%' + AND EXISTS ( + SELECT 1 FROM `measurements` AS `canonical` + WHERE `canonical`.`id` LIKE 'history:%' + AND `canonical`.`kindRaw` = `measurements`.`kindRaw` + AND `canonical`.`timestamp` = `measurements`.`timestamp` + AND `canonical`.`value` = `measurements`.`value` + ) + """.trimIndent() + ) + } + } + + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { + db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") + db.execSQL( + "UPDATE `measurements` SET `sourceRaw` = 'live' " + + "WHERE `sourceRaw` = 'colmi' AND `kindRaw` IN ('HRV', 'TEMPERATURE')" + ) + listOf( + "HEART_RATE" to "hr", + "SPO2" to "spo2", + "STRESS" to "stress", + "FATIGUE" to "fatigue", + "HRV" to "hrv", + "TEMPERATURE" to "temp", + "BLOOD_PRESSURE_SYSTOLIC" to "bp_sys", + "BLOOD_PRESSURE_DIASTOLIC" to "bp_dia", + "BLOOD_SUGAR" to "glucose", + "RESPIRATORY_RATE" to "resp_rate", + "VO2MAX" to "vo2max", + ).forEach { (kind, key) -> + adoptStableMeasurementIdentity(db, kind = kind, source = "history", key = key) + } + adoptStableMeasurementIdentity(db, kind = "STRESS", source = "colmi", key = "stress") + adoptStableMeasurementIdentity(db, kind = "TEMPERATURE", source = "live", key = "temp") + // HRV needs the same 'live' pass as TEMPERATURE above, for the same reason: Colmi's + // HRV *history* used to persist as an `HrvSample` — random id, sourceRaw 'live' — and + // now decodes to a `HistoryMeasurement`, which keys on `history:hrv:`. + // Without re-keying the old rows they don't collide with the new ones, so a re-sync + // writes a second row at every timestamp already stored, and `range()` (which filters + // on kindRaw + timestamp, never sourceRaw) returns both — doubling the HRV series. + adoptStableMeasurementIdentity(db, kind = "HRV", source = "live", key = "hrv") + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_measurements_kindRaw_timestamp_sourceRaw` " + + "ON `measurements` (`kindRaw`, `timestamp`, `sourceRaw`)" + ) + } + + private fun adoptStableMeasurementIdentity( + db: SupportSQLiteDatabase, + kind: String, + source: String, + key: String, + ) { + db.execSQL( + """ + UPDATE `measurements` + SET `id` = 'history:$key:' || `timestamp` + WHERE `kindRaw` = '$kind' AND `sourceRaw` = '$source' + AND `rowid` IN ( + SELECT MIN(`rowid`) FROM `measurements` + WHERE `kindRaw` = '$kind' AND `sourceRaw` = '$source' + GROUP BY `timestamp` + ) + AND NOT EXISTS ( + SELECT 1 FROM `measurements` AS `existing` + WHERE `existing`.`id` = 'history:$key:' || `measurements`.`timestamp` + ) + """.trimIndent() + ) + } + fun getInstance(context: Context): PulseLoopDatabase = INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( @@ -212,7 +356,22 @@ abstract class PulseLoopDatabase : RoomDatabase() { PulseLoopDatabase::class.java, "pulseloop.db" ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12) + .addMigrations( + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8, + MIGRATION_8_9, + MIGRATION_9_10, + MIGRATION_10_11, + MIGRATION_11_12, + MIGRATION_12_13, + MIGRATION_13_14, + MIGRATION_14_15, + MIGRATION_15_16, + ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and // coach conversation on any future version bump that misses a diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index d07e4b0..e31f124 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -41,6 +41,10 @@ interface DeviceDao { @Dao interface MeasurementDao { + /** Invalidates after committed measurement inserts; consumers can debounce bursty history. */ + @Query("SELECT COUNT(*) FROM measurements") + fun changeFlow(): Flow + @Query("SELECT * FROM measurements WHERE kindRaw = :kind AND timestamp BETWEEN :start AND :end ORDER BY timestamp ASC") suspend fun range(kind: String, start: Long, end: Long): List @@ -63,16 +67,8 @@ interface MeasurementDao { @Insert suspend fun insert(measurement: MeasurementEntity) - /** Look up an already-persisted history row at an exact timestamp — the identity a ring's - * history replay is deduped on (a ring re-sends the same log every re-sync, with - * deterministic per-record epochs). */ - @Query("SELECT * FROM measurements WHERE kindRaw = :kind AND timestamp = :timestamp AND sourceRaw = 'history' LIMIT 1") - suspend fun findHistoryAt(kind: String, timestamp: Long): MeasurementEntity? - - /** Update a row's value in place — used when a re-synced history sample revises an existing - * one (the ring can refine an averaged block) without creating a duplicate row. */ - @Query("UPDATE measurements SET value = :value WHERE id = :id") - suspend fun updateValue(id: String, value: Double) + @Upsert + suspend fun upsert(measurement: MeasurementEntity) @Query("DELETE FROM measurements WHERE sourceRaw = 'demo'") suspend fun clearDemo() @@ -215,6 +211,9 @@ interface SleepSessionDao { @Query("SELECT * FROM sleep_sessions WHERE date = :day AND sourceRaw != 'demo' LIMIT 1") suspend fun ringByDay(day: Long): SleepSessionEntity? + @Query("SELECT * FROM sleep_sessions WHERE sourceRaw != 'demo' AND startAt < :end AND endAt > :start") + suspend fun ringOverlapping(start: Long, end: Long): List + /** All synced (non-demo) sessions for a waking day, earliest first — the reconcile target. */ @Query("SELECT * FROM sleep_sessions WHERE date = :day AND sourceRaw != 'demo' ORDER BY startAt ASC") suspend fun ringAllByDay(day: Long): List diff --git a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt index 1a82d6c..f33a2b9 100644 --- a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt @@ -45,7 +45,12 @@ data class DeviceEntity( */ @Entity( tableName = "measurements", - indices = [Index("timestamp"), Index("activitySessionId"), Index("kindRaw")], + indices = [ + Index("timestamp"), + Index("activitySessionId"), + Index("kindRaw"), + Index(value = ["kindRaw", "timestamp", "sourceRaw"]), + ], ) data class MeasurementEntity( @PrimaryKey val id: String = java.util.UUID.randomUUID().toString(), diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt index 06c59e8..1ae6810 100644 --- a/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt +++ b/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt @@ -11,6 +11,8 @@ import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.* import com.pulseloop.MainActivity import com.pulseloop.coach.config.CoachSleepSyncGate @@ -21,12 +23,16 @@ import com.pulseloop.data.entity.CoachNotificationRecordEntity import com.pulseloop.ring.PulseEvent import com.pulseloop.ring.PulseEventBus import com.pulseloop.ring.RingBLEClient +import com.pulseloop.ring.RingConnectionState import com.pulseloop.service.loadPersistedMeasurementSettings import com.pulseloop.service.loadPersistedUserProfile import com.pulseloop.settings.ApiKeyStore import kotlinx.coroutines.async +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.* import java.time.LocalDateTime @@ -264,8 +270,9 @@ class CoachNotificationWorker( awaitSyncDone() return } + if (isAppForeground()) return - val bleClient = RingBLEClient(applicationContext) + val bleClient = RingBLEClient(applicationContext, transientOwner = true) if (!bleClient.hasPermissions()) { // destroy(), not just drop the reference: the client's init-started connection // watchdog would otherwise keep firing into permission-less connect attempts. @@ -288,16 +295,33 @@ class CoachNotificationWorker( engine?.runStartup() } bleClient.connectLastKnown() + while (!doneSignal.isCompleted && !isAppForeground()) delay(500) + if (isAppForeground()) { + doneSignal.cancel() + return@withTimeoutOrNull + } doneSignal.await() } } finally { // destroy(), not disconnect(): the client's connection watchdog (started in init) // survives disconnect() and re-attaches the ring ~15s after the worker exits — // re-firing onConnected → a full runStartup, then holding the ring with no UI. - bleClient.destroy() + val releasedConnection = bleClient.destroy() + if (releasedConnection && !isAppForeground()) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged( + RingConnectionState.DISCONNECTED, + null, + ) + ) + } } } + private suspend fun isAppForeground(): Boolean = withContext(Dispatchers.Main.immediate) { + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + /** Give an in-flight sync (driven by whoever owns the live link) a bounded chance to finish. */ private suspend fun awaitSyncDone() { withTimeoutOrNull(SYNC_WAIT_TIMEOUT_MS) { diff --git a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt index a304a65..e23a4ba 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt @@ -174,7 +174,11 @@ object ColmiDecoder { val stress = v[i].toInt() if (stress == 0) return@mapNotNull null val ts = base.plusMinutes((minutesInPrevious + (i - startIndex) * slotMin).toLong()).toInstant() - RingDecodedEvent.StressSample(value = stress, _timestamp = ts) + RingDecodedEvent.StressSample( + value = stress, + _timestamp = ts, + isHistory = true, + ) } } @@ -190,7 +194,11 @@ object ColmiDecoder { val hrv = v[i].toInt() if (hrv == 0) return@mapNotNull null val ts = base.plusMinutes((minutesInPrevious + (i - startIndex) * slotMin).toLong()).toInstant() - RingDecodedEvent.HrvSample(value = hrv, _timestamp = ts) + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.HRV, + value = hrv.toDouble(), + _timestamp = ts, + ) } } @@ -325,7 +333,8 @@ object ColmiDecoder { if (raw > 0) { events.add(RingDecodedEvent.TemperatureSample( celsius = raw.toDouble() / 100.0, - _timestamp = dayStart.plusMinutes((slot * interval).toLong()).toInstant() + _timestamp = dayStart.plusMinutes((slot * interval).toLong()).toInstant(), + isHistory = true, )) } slot++ @@ -385,7 +394,8 @@ object ColmiDecoder { if (raw > 0) { events.add(RingDecodedEvent.TemperatureSample( celsius = raw.toDouble() / 10.0 + 20.0, - _timestamp = dayStart.plusMinutes((s * timeSpan).toLong()).toInstant() + _timestamp = dayStart.plusMinutes((s * timeSpan).toLong()).toInstant(), + isHistory = true, )) } } diff --git a/app/src/main/java/com/pulseloop/ring/JringDriver.kt b/app/src/main/java/com/pulseloop/ring/JringDriver.kt index 57dfe91..08e6ea4 100644 --- a/app/src/main/java/com/pulseloop/ring/JringDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/JringDriver.kt @@ -82,8 +82,9 @@ class JringDriver(private val writer: RingCommandWriter) : WearableDriver { */ class JringSyncEngine( private val writer: RingCommandWriter?, - private val clock: JringClock, + private val clock: JringClock = JringClock(), ) : RingSyncEngine { + override val supportsCombinedMeasurement: Boolean = true private val encoder = RingEncoder /** The ring's self-reported feature bits (0x20 reply), or `null` if it never answered. diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index 80804b4..19f7c7e 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -1,15 +1,27 @@ package com.pulseloop.ring +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch /** * Ported from [PulseEvent] in PulseEventBus.swift. * Typed events published on the bus for subscribers to consume. */ sealed class PulseEvent { - data class DeviceStateChanged(val state: RingConnectionState, val address: String?, val firmware: String? = null, val name: String? = null) : PulseEvent() + data class DeviceStateChanged( + val state: RingConnectionState, + val address: String?, + val firmware: String? = null, + val name: String? = null, + val deviceType: RingDeviceType? = null, + ) : PulseEvent() /** Emitted on connect once the active wearable's type + capabilities are known (iOS #49 adds * the exact catalog model + advertised name so persistence can stamp them on the device). */ data class DeviceIdentified( @@ -37,11 +49,24 @@ sealed class PulseEvent { data class Spo2Result(val value: Int, val timestamp: java.time.Instant) : PulseEvent() /** The ring ended a live-SpO₂ run (error or natural finish) — no more results coming. */ data class Spo2Complete(val timestamp: java.time.Instant) : PulseEvent() + /** A live measurement command was refused (not worn, sensor busy, unsupported). */ + data class MeasurementRejected(val mode: Int) : PulseEvent() + data class BloodPressureSample( + val systolic: Int, + val diastolic: Int, + val timestamp: java.time.Instant, + val isHistory: Boolean = false, + ) : PulseEvent() + data class BloodSugarSample(val mgdl: Double, val timestamp: java.time.Instant) : PulseEvent() data class HistoryMeasurement(val kind: MeasurementKind, val value: Double, val timestamp: java.time.Instant) : PulseEvent() - data class StressSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() + data class StressSample(val value: Int, val timestamp: java.time.Instant, val isHistory: Boolean = false) : PulseEvent() data class HrvSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() - data class TemperatureSample(val celsius: Double, val timestamp: java.time.Instant) : PulseEvent() - data class SleepTimeline(val timestamp: java.time.Instant, val stages: List) : PulseEvent() + data class TemperatureSample(val celsius: Double, val timestamp: java.time.Instant, val isHistory: Boolean = false) : PulseEvent() + data class SleepTimeline( + val timestamp: java.time.Instant, + val stages: List, + val completeSession: Boolean = false, + ) : PulseEvent() data class SyncProgress(val stage: String) : PulseEvent() data class FirmwareVersion(val version: Int?) : PulseEvent() /** The ring's on-finger / skin-contact state changed. `worn == false` ⇒ an optical spot @@ -60,13 +85,35 @@ enum class PacketDirection { INCOMING, OUTGOING } object PulseEventBus { private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 256) val events: SharedFlow = _events.asSharedFlow() + private val pending = Channel(capacity = Channel.UNLIMITED) + private val dispatchScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + init { + dispatchScope.launch { + // The bus is process-long and single-drained: an uncaught throw here would kill the + // dispatcher and silently stop every subscriber for the rest of the process (the + // SupervisorJob does not restart it). Isolate each emit so one bad event can't do that. + for (event in pending) { + try { + _events.emit(event) + } catch (ce: CancellationException) { + throw ce + } catch (t: Throwable) { + android.util.Log.e("PulseEventBus", "Dropped ${event.javaClass.simpleName} on emit failure", t) + } + } + } + } suspend fun publish(event: PulseEvent) { - _events.emit(event) + pending.send(event) } /** Non-suspending publish for use from non-coroutine contexts (callbacks). */ fun publishBlocking(event: PulseEvent) { - _events.tryEmit(event) + val result = pending.trySend(event) + if (result.isFailure) { + android.util.Log.e("PulseEventBus", "Pulse event queue rejected ${event.javaClass.simpleName}", result.exceptionOrNull()) + } } } diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 21af7d3..effe7d9 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.util.UUID /** @@ -29,7 +30,10 @@ import java.util.UUID * Adding a new wearable = append one entry. */ @SuppressLint("MissingPermission") -class RingBLEClient(private val context: Context) { +class RingBLEClient( + private val context: Context, + private val transientOwner: Boolean = false, +) { /** Registry of supported wearables. Adding a wearable = append one entry. * @@ -43,6 +47,7 @@ class RingBLEClient(private val context: Context) { * could never shadow a hypothetical `TK5x`-named LuckRing sibling. */ private val coordinators: List = listOf( JringCoordinator, + YCBTCoordinator, ColmiCoordinator, ColmiSmartHealthCoordinator, LuckRingCoordinator, @@ -66,6 +71,8 @@ class RingBLEClient(private val context: Context) { val activeWearableModelID: String? = null, val activeCapabilities: Set = emptySet(), val firmwareVersion: String? = null, + /** Bounded, persistent-for-this-process BLE trace for hardware diagnosis. */ + val diagnostics: List = emptyList(), ) data class DiscoveredRing( @@ -114,6 +121,7 @@ class RingBLEClient(private val context: Context) { private var commandChar: BluetoothGattCharacteristic? = null private var notifyChars: MutableMap = mutableMapOf() private var batteryChar: BluetoothGattCharacteristic? = null + private var subscriptionGate: SubscriptionSetupGate? = null // MARK: Active driver/engine @@ -124,8 +132,12 @@ class RingBLEClient(private val context: Context) { private var activeAdvertisedName: String? = null // Set while a "Forget" is waiting for the ring's UNBOND_ACK (0x4B) before teardown. - private var forgetPending = false + private val forgetLock = Any() + @Volatile private var forgetPending = false + @Volatile private var forgetFinalizing = false + @Volatile private var forgetGeneration = 0L private var forgetJob: Job? = null + private var forgetCompletion: CompletableDeferred? = null // MARK: GATT operation serialization // @@ -185,6 +197,8 @@ class RingBLEClient(private val context: Context) { @Volatile private var lastActivityAt: Long = 0L private var connectingStartedAt: Long = 0L private var watchdogJob: Job? = null + private var ownershipRetryJob: Job? = null + @Volatile private var watchdogReconnectPaused = false // MARK: Service-discovery gate // @@ -259,6 +273,7 @@ class RingBLEClient(private val context: Context) { // Pairing/connecting a ring is an explicit user intent — clear any stay-off flag from a // prior Disconnect so [connectLastKnown] isn't suppressed for this or the next session. prefs.edit().remove(USER_DISCONNECTED_KEY).apply() + watchdogReconnectPaused = false resetReconnectBackoff() // Normally discovery's name-derived family wins over the carousel choice @@ -297,6 +312,11 @@ class RingBLEClient(private val context: Context) { * (official: maxReconnect = 10); the cap resets on user action or app foreground. */ fun connectLastKnown() { + watchdogReconnectPaused = false + connectLastKnownInternal() + } + + private fun connectLastKnownInternal() { if (!bluetoothAdapter.isEnabled) return // Honor a user-initiated Disconnect: stay off until the user reconnects ([userConnect]) // or pairs a new ring ([connectTo]). Every auto-reconnect path — foreground @@ -394,6 +414,7 @@ class RingBLEClient(private val context: Context) { private fun connectionWatchdogTick() { if (!bluetoothAdapter.isEnabled || !hasPermissions()) return + if (watchdogReconnectPaused) return // Time out a hung CONNECTING attempt FIRST — before the last-known-ring guard below — // so it also covers a first-ever pairing (no stored ring yet). The official QRing app // arms this timeout on every connect (mTimeoutRunnable). Without it, a first pair that @@ -440,13 +461,13 @@ class RingBLEClient(private val context: Context) { } RingConnectionState.DISCONNECTED, RingConnectionState.FAILED, - RingConnectionState.IDLE -> connectLastKnown() + RingConnectionState.IDLE -> connectLastKnownInternal() // CONNECTING is handled above (before the last-known-ring guard). else -> { // SCANNING: a pairing scan proceeds untouched, but a reconnect scan that // ran a full watchdog interval without sighting the ring moves on to the // next attempt (connectLastKnown counts the miss and alternates strategy). - if (reconnectScanPending) connectLastKnown() + if (reconnectScanPending) connectLastKnownInternal() } } } @@ -454,7 +475,7 @@ class RingBLEClient(private val context: Context) { /** Hard reset: drop the (possibly zombie) GATT and start a fresh connection. */ private fun forceReconnect() { // beginConnect (via connectLastKnown) closes the stale GATT before opening a new one. - connectLastKnown() + connectLastKnownInternal() } /** @@ -465,12 +486,15 @@ class RingBLEClient(private val context: Context) { private fun failConnectAttempt(reason: String) { val gatt = bluetoothGatt bluetoothGatt = null + activeDriver?.connectionDidEnd() writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null + subscriptionGate = null resetOpQueue() if (gatt != null) { try { gatt.disconnect() } catch (_: Exception) {} closeGattQuietly(gatt) } + releaseConnectionOwnership() connectingStartedAt = 0 updateState { copy(connectionState = RingConnectionState.FAILED, lastError = reason) } PulseEventBus.publishBlocking( @@ -487,10 +511,17 @@ class RingBLEClient(private val context: Context) { * toggled). Keeps the stored identity so [connectLastKnown] can reconnect later. */ fun disconnect() { + // Lifecycle/worker teardown is intentional. Leave genuine link-loss callbacks eligible + // for watchdog recovery, but do not let this client undo an explicit transient teardown. + watchdogReconnectPaused = true + ownershipRetryJob?.cancel(); ownershipRetryJob = null + val shouldPublishDisconnect = + _state.value.connectionState != RingConnectionState.DISCONNECTED stopKeepalive() scanner?.stopScan(scanCallback) val gatt = bluetoothGatt bluetoothGatt = null + activeDriver?.connectionDidEnd() writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null resetOpQueue() if (gatt != null) { @@ -500,9 +531,17 @@ class RingBLEClient(private val context: Context) { scope.launch { delay(GATT_CLOSE_DELAY_MS) closeGattQuietly(gatt) + releaseConnectionOwnership() } + } else { + releaseConnectionOwnership() } updateState { copy(connectionState = RingConnectionState.DISCONNECTED) } + if (shouldPublishDisconnect) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) + ) + } } /** @@ -553,6 +592,27 @@ class RingBLEClient(private val context: Context) { * to an unconditional teardown if the ring is offline or never acks. */ fun forget() { + startForget() + } + + suspend fun forgetAndWait() { + startForget().await() + } + + private fun startForget(): CompletableDeferred { + ownershipRetryJob?.cancel(); ownershipRetryJob = null + val completion: CompletableDeferred + val forgetRequestGeneration: Long + synchronized(forgetLock) { + val inFlight = forgetCompletion + if ((forgetPending || forgetFinalizing) && inFlight != null) return inFlight + completion = CompletableDeferred() + forgetCompletion = completion + forgetGeneration++ + forgetPending = true + forgetFinalizing = false + forgetRequestGeneration = forgetGeneration + } // Clear the known-ring id up front so the watchdog/auto-reconnect can't grab // the ring back during or after the unbind window. prefs.edit() @@ -565,52 +625,73 @@ class RingBLEClient(private val context: Context) { val gatt = bluetoothGatt if (gatt != null && writeChar != null && _state.value.connectionState == RingConnectionState.CONNECTED) { - forgetPending = true enqueueWrite(RingEncoder.makeUnbindCommand()) // 0x4B 05 00 01 forgetJob?.cancel() forgetJob = scope.launch { delay(UNBIND_ACK_TIMEOUT_MS) - if (forgetPending) { + if (synchronized(forgetLock) { forgetPending && !forgetFinalizing }) { Log.w("RingBLEClient", "Unbind ACK not received in ${UNBIND_ACK_TIMEOUT_MS}ms — forcing teardown") - finalizeForget() + finalizeForget(expectedGeneration = forgetRequestGeneration) } } } else { - finalizeForget() + finalizeForget(expectedGeneration = forgetRequestGeneration) } + return completion } /** Tear down the link: clear the GATT cache, remove any OS bond, close the GATT. */ - private fun finalizeForget() { - forgetPending = false - forgetJob?.cancel(); forgetJob = null - stopKeepalive() - scanner?.stopScan(scanCallback) - bluetoothGatt?.let { gatt -> - try { gatt::class.java.getMethod("refresh").invoke(gatt) } catch (_: Exception) {} - try { gatt.device::class.java.getMethod("removeBond").invoke(gatt.device) } catch (_: Exception) {} - gatt.disconnect() - gatt.close() + private fun finalizeForget(expectedGeneration: Long? = null) { + val completion = synchronized(forgetLock) { + if (expectedGeneration != null && expectedGeneration != forgetGeneration) return + if (forgetFinalizing) return + forgetFinalizing = true + forgetGeneration++ + forgetJob?.cancel(); forgetJob = null + forgetCompletion } - bluetoothGatt = null - writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null - resetOpQueue() - prefs.edit() - .remove(LAST_PERIPHERAL_KEY) - .remove(LAST_DEVICE_TYPE_KEY) - .remove(LAST_WEARABLE_MODEL_KEY) - .remove(USER_DISCONNECTED_KEY) - .apply() - activeAdvertisedName = null - updateState { - copy( - connectionState = RingConnectionState.IDLE, - activeDeviceType = null, - activeWearableModelID = null, - activeCapabilities = emptySet(), - ) + try { + stopKeepalive() + scanner?.stopScan(scanCallback) + activeDriver?.connectionDidEnd() + bluetoothGatt?.let { gatt -> + try { gatt::class.java.getMethod("refresh").invoke(gatt) } catch (_: Exception) {} + try { gatt.device::class.java.getMethod("removeBond").invoke(gatt.device) } catch (_: Exception) {} + try { gatt.disconnect() } catch (_: Exception) {} + try { gatt.close() } catch (_: Exception) {} + } + bluetoothGatt = null + releaseConnectionOwnership() + writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null + resetOpQueue() + prefs.edit() + .remove(LAST_PERIPHERAL_KEY) + .remove(LAST_DEVICE_TYPE_KEY) + .remove(LAST_WEARABLE_MODEL_KEY) + .remove(USER_DISCONNECTED_KEY) + .apply() + activeAdvertisedName = null + updateState { + copy( + connectionState = RingConnectionState.IDLE, + activeDeviceType = null, + activeWearableModelID = null, + activeCapabilities = emptySet(), + ) + } + PulseEventBus.publishBlocking(PulseEvent.DeviceForgotten) + } finally { + synchronized(forgetLock) { + if (forgetCompletion === completion) forgetCompletion = null + forgetPending = false + forgetFinalizing = false + } + completion?.complete(Unit) } - PulseEventBus.publishBlocking(PulseEvent.DeviceForgotten) + } + + private fun acceptsCallback(generation: Long): Boolean = synchronized(forgetLock) { + !forgetPending && generation == forgetGeneration } fun enqueueWrite(data: ByteArray) { @@ -649,17 +730,35 @@ class RingBLEClient(private val context: Context) { selectedModelID: String? = null, advertisedName: String? = null, ) { + if (watchdogReconnectPaused) return + if (synchronized(forgetLock) { forgetPending || forgetFinalizing }) return + if (!claimConnectionOwnership()) { + Log.i("RingBLEClient", "Connection skipped: another PulseLoop BLE client owns GATT") + updateState { copy(connectionState = RingConnectionState.DISCONNECTED) } + if (!transientOwner) { + ownershipRetryJob?.cancel() + ownershipRetryJob = scope.launch { + delay(PROCESS_OWNER_RETRY_MS) + ownershipRetryJob = null + beginConnect(target, deviceType, selectedModelID, advertisedName) + } + } + return + } + ownershipRetryJob = null scanner?.stopScan(scanCallback) // Close any stale GATT from a previous (now-dead) connection before opening a new // one. Reconnect attempts after an idle drop would otherwise leak GATT clients and // can collide with the orphaned handle. A fresh GATT mirrors the proven // force-close-and-reopen recovery path. bluetoothGatt?.let { old -> + activeDriver?.connectionDidEnd() try { old.disconnect() } catch (_: Exception) {} closeGattQuietly(old) // refresh + close, official-app teardown discipline } bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null + subscriptionGate = null resetOpQueue() serviceDiscoveryStarted.set(false) val coordinator = coordinators.firstOrNull { it.deviceType == deviceType } ?: JringCoordinator @@ -672,7 +771,8 @@ class RingBLEClient(private val context: Context) { family = coordinator.deviceType, )?.id installDriver(coordinator) - updateState { copy(activeWearableModelID = resolvedModelID) } + updateState { copy(activeWearableModelID = resolvedModelID, diagnostics = emptyList()) } + recordDiagnostic("connect ${advertisedName ?: "unknown"}") connectingStartedAt = System.currentTimeMillis() updateState { copy(connectionState = RingConnectionState.CONNECTING) } // Mirror the attempt to the persisted state so the Today/Settings views show @@ -775,13 +875,22 @@ class RingBLEClient(private val context: Context) { } private fun installDriver(coordinator: WearableCoordinator) { + synchronized(forgetLock) { + forgetGeneration++ + forgetPending = false + forgetFinalizing = false + } val driver = coordinator.makeDriver { enqueueWrite(it) } activeCoordinator = coordinator activeDriver = driver + driver.connectionDidStart() + subscriptionGate = SubscriptionSetupGate( + notifyUUIDs = driver.notifyUUIDs, + requiredSubscriptions = driver.requiredSubscriptionsBeforeConnected, + ) activeSyncEngine = driver.makeSyncEngine() - // Capability-gated bonding: the engine fires this only when the ring's device-support - // reply advertises supportBlePair, matching every model that sets the bit (not just - // R09). See docs/qring-ble-adoption.md and bondActiveDevice()'s KDoc. + // The engine requests bonding from the device-support bit; bondActiveDevice applies the + // hardware-validated per-model allowlist before showing any OS pairing prompt. activeSyncEngine?.setOnBondRequested { bondActiveDevice() } updateState { copy( @@ -801,8 +910,16 @@ class RingBLEClient(private val context: Context) { private fun refineActiveCapabilities(reported: Set) { val coordinator = activeCoordinator ?: return val granted = reported.intersect(coordinator.bitmapGatedCapabilities) - if (granted.isEmpty()) return + if (granted.isEmpty() || granted.all { it in _state.value.activeCapabilities }) return updateState { copy(activeCapabilities = activeCapabilities + granted) } + PulseEventBus.publishBlocking( + PulseEvent.DeviceIdentified( + deviceType = coordinator.deviceType, + wearableModelID = _state.value.activeWearableModelID, + advertisedName = activeAdvertisedName ?: connectingName, + capabilities = _state.value.activeCapabilities, + ) + ) } private fun enqueueOp(op: GattOp) { @@ -816,13 +933,29 @@ class RingBLEClient(private val context: Context) { * op was issued) retiring the WRONG op: only complete when the callback corresponds * to what is actually in flight. */ + private fun retireOp(matches: (GattOp) -> Boolean = { true }): Boolean { + return synchronized(opLock) { + val current = inFlightOp ?: return@synchronized false + if (!matches(current)) return@synchronized false + inFlightOp = null + true + } + } + private fun completeOp(matches: (GattOp) -> Boolean = { true }) { + if (retireOp(matches)) pumpOps() + } + + /** Place a protocol-ready handshake ahead of reads/optional CCCDs already in the queue. */ + private fun prependCommandWrites(commands: List) { + val driver = activeDriver ?: return + val ops = commands.map { command -> + val framed = driver.frame(command) + GattOp.CommandWrite(framed, driver.usesCommandChannel(framed)) + } synchronized(opLock) { - val current = inFlightOp ?: return - if (!matches(current)) return - inFlightOp = null + for (op in ops.asReversed()) opQueue.addFirst(op) } - pumpOps() } /** Drop everything queued or in flight (connection reset / teardown). Any pending @@ -913,6 +1046,10 @@ class RingBLEClient(private val context: Context) { RingDecodedEvent.CommandAck(commandId = if (op.data.isNotEmpty()) op.data[0].toUByte() else 0u)) ) } + if (op.attempts == 0) { + val prefix = op.data.take(4).joinToString("") { "%02x".format(it.toInt() and 0xFF) } + recordDiagnostic("write issue $prefix len=${op.data.size}") + } gatt.writeCharacteristic(target) } } @@ -930,8 +1067,20 @@ class RingBLEClient(private val context: Context) { // command then silence" failure. Time the op out and unblock the queue. scope.launch { delay(OP_TIMEOUT_MS) - if (inFlightOp === op) { + // Read under opLock: inFlightOp is written on the binder thread under the same + // lock, and without it a completion landing right at the timeout could be + // invisible here and trigger a spurious reconnect for an op that already retired. + val stillInFlight = synchronized(opLock) { inFlightOp === op } + if (stillInFlight) { Log.w("RingBLEClient", "GATT op ACK timed out — unblocking queue: ${opLabel(op)}") + // Android does not identify a write callback beyond its characteristic. + // Two successive protocol writes use the same characteristic, so a late + // callback for a timed-out write could otherwise retire its successor. + // Reset the link instead of issuing another ambiguous command write. + if (op is GattOp.CommandWrite) { + recoverWedgedLink() + return@launch + } // A lost completion callback leaves the framework slot busy: don't just // free our slot and pump the next op into a still-busy stack (that is the // spin-and-drop loop). Escalate to a reconnect once enough pile up. @@ -946,10 +1095,22 @@ class RingBLEClient(private val context: Context) { // characteristic that went away). Retry a couple of times after a short // pause before giving up — dropping outright could lose a CCCD write that // gates CONNECTED, or a queued factory-reset/unbind command. - synchronized(opLock) { if (inFlightOp === op) inFlightOp = null } - if (op.attempts < MAX_OP_ATTEMPTS - 1) { - op.attempts++ - synchronized(opLock) { opQueue.addFirst(op) } + var retrying = false + val stillCurrent = synchronized(opLock) { + if (inFlightOp !== op || bluetoothGatt !== gatt) { + false + } else { + inFlightOp = null + if (op.attempts < MAX_OP_ATTEMPTS - 1) { + op.attempts++ + opQueue.addFirst(op) + retrying = true + } + true + } + } + if (!stillCurrent) return + if (retrying) { Log.w("RingBLEClient", "GATT op rejected at issue — retrying (${op.attempts}/$MAX_OP_ATTEMPTS): ${opLabel(op)}") scope.launch { delay(OP_RETRY_DELAY_MS) @@ -958,6 +1119,12 @@ class RingBLEClient(private val context: Context) { return } Log.w("RingBLEClient", "GATT op dropped after $MAX_OP_ATTEMPTS attempts: ${opLabel(op)}") + if (op is GattOp.DescriptorWrite && + subscriptionGate?.isRequired(op.descriptor.characteristic.uuid.toString()) == true + ) { + failConnectAttempt("Could not enable required ring indication") + return + } // The slot may be wedged — escalate to a reconnect rather than pump the next op into // the same busy stack (the tear-down aborts the loop). if (noteOpFailureAndMaybeRecover()) return @@ -988,7 +1155,15 @@ class RingBLEClient(private val context: Context) { } private inline fun updateState(crossinline update: BLEState.() -> BLEState) { - _state.value = _state.value.update() + _state.update { it.update() } + } + + @Synchronized + private fun recordDiagnostic(message: String) { + Log.i("RingBLEClient", message) + _state.update { current -> + current.copy(diagnostics = (current.diagnostics + message).takeLast(6)) + } } // MARK: Scan callback @@ -1047,6 +1222,7 @@ class RingBLEClient(private val context: Context) { private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + recordDiagnostic("state status=$status new=$newState") when (newState) { BluetoothProfile.STATE_CONNECTED -> { if (status == BluetoothGatt.GATT_SUCCESS) { @@ -1057,12 +1233,19 @@ class RingBLEClient(private val context: Context) { // caused frequent disconnects. Instead we bond later, and only when the // ring asks: the Colmi engine reads the device-support bitfield during // startup and, if supportBlePair is set, calls back into - // bondActiveDevice() — well after discovery, matching the official QRing - // app exactly (bonds post-discovery, gated on supportBlePair alone, no - // per-model allowlist). Rings that don't advertise the bit (jring 56ff) - // are never bonded. See docs/qring-ble-adoption.md §Pairing. - // Request a high-priority connection interval, matching the official app - // (BluetoothLeService.requestConnectionPriority(1) on connect). + // bondActiveDevice() well after discovery. The callback still applies the + // hardware-validated model allowlist; supportBlePair alone is insufficient. + // YCBT firmware is not QRing firmware. Cheap BE94 controllers have been + // observed terminating Android links after aggressive connection-parameter + // and MTU requests. CoreBluetooth's working YCBT path makes neither request, + // and the frame assembler already handles fragmentation, so use the + // conservative default link for this family. + if (activeCoordinator?.deviceType == RingDeviceType.YCBT) { + recordDiagnostic("YCBT default MTU/priority") + startServiceDiscovery(gatt) + return + } + // QRing/Jring path: request the parameters used by their Android clients. gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) // Request a larger MTU (helps history-sync throughput), then discover // services ONLY after the MTU exchange completes — never overlap the two @@ -1080,39 +1263,28 @@ class RingBLEClient(private val context: Context) { } } else { updateState { copy(lastError = "GATT connect failed: $status") } - handleDisconnect(gatt) + handleDisconnect(gatt, status) } } BluetoothProfile.STATE_DISCONNECTED -> { - handleDisconnect(gatt) + handleDisconnect(gatt, status) } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - if (status != BluetoothGatt.GATT_SUCCESS) return + if (gatt !== bluetoothGatt) return // stale callback from a superseded connection + recordDiagnostic("services status=$status count=${gatt.services.size}") + if (status != BluetoothGatt.GATT_SUCCESS) { + failConnectAttempt("Service discovery failed (GATT $status)") + return + } // Log all discovered service UUIDs for diagnostics val serviceUuids = gatt.services.map { it.uuid.toString() } android.util.Log.i("RingBLEClient", "Services: ${serviceUuids.joinToString(", ")}") - // Store service list in a log event for export - scope.launch(Dispatchers.IO) { - try { - val db = com.pulseloop.data.PulseLoopDatabase.getInstance(context.applicationContext) - val device = db.deviceDao().current() - if (device != null) { - // Replace (never append) the diagnostic suffix: appending once per - // discovery grew the row by one "|services:…" per reconnect — a - // user export showed ~98 of them. - db.deviceDao().upsert(device.copy( - capabilitiesRaw = device.capabilitiesRaw.substringBefore("|services:") + - "|services:" + serviceUuids.joinToString(","), - updatedAt = System.currentTimeMillis() - )) - } - } catch (_: Exception) {} - } + recordDiagnostic("services ${serviceUuids.joinToString(",")}") // Post-connect re-route (issue #29): a Colmi R11 that advertised the generic name // "SMART_RING" with no Colmi service UUID in its advertisement gets classified as @@ -1183,6 +1355,8 @@ class RingBLEClient(private val context: Context) { // ring becomes usable as soon as possible instead of waiting behind firmware/ // battery reads. (This is the R10 fix: its 0x180A DIS firmware read used to be // issued ahead of the CCCD write and silently dropped it, so it never connected.) + val subscriptionGate = subscriptionGate ?: return + val ringDescriptorOps = mutableListOf>() for (service in gatt.services) { val svcUuid = service.uuid.toString() val isRingSvc = driver.serviceUUIDs.any { it == svcUuid } @@ -1191,22 +1365,41 @@ class RingBLEClient(private val context: Context) { for (ch in service.characteristics) { val uuid = ch.uuid.toString() - // Not mutually exclusive: YCBT's command characteristic (be940001) is - // simultaneously the write target AND a notify source (command replies), so - // it must both be recorded as writeChar and get its CCCD enabled below. + // A characteristic may be both writable and notifiable. YCBT's BE94-0001 + // command channel is exactly that; the old mutually-exclusive `when` stored + // it as writeChar but silently skipped its CCCD, losing every command reply. if (uuid == driver.writeUUID) writeChar = ch if (uuid == driver.commandUUID) commandChar = ch if (driver.notifyUUIDs.any { it == uuid }) { notifyChars[ch.uuid] = ch - gatt.setCharacteristicNotification(ch, true) - ch.getDescriptor(CCCD_UUID)?.let { desc -> - enqueueOp(GattOp.DescriptorWrite(desc, cccdEnableValue(ch))) + val localEnabled = gatt.setCharacteristicNotification(ch, true) + val descriptor = ch.getDescriptor(CCCD_UUID) + subscriptionGate.observeCharacteristic( + uuid = uuid, + localEnabled = localEnabled, + hasCccd = descriptor != null, + ) + if (localEnabled && descriptor != null) { + val cccdValue = when (subscriptionGate.modeFor(uuid)) { + SubscriptionMode.NOTIFICATION -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + SubscriptionMode.INDICATION -> BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + } + ringDescriptorOps += descriptor to cccdValue } } if (uuid == driver.batteryCharUUID) batteryChar = ch } } + subscriptionGate.topologyFailure()?.let { failure -> + recordDiagnostic(failure) + failConnectAttempt(failure) + return + } + for ((descriptor, value) in ringDescriptorOps) { + enqueueOp(GattOp.DescriptorWrite(descriptor, value)) + } + // Standard BLE health services — blood pressure (0x1810) + glucose (0x1808). val bpServiceUuid = java.util.UUID.fromString("00001810-0000-1000-8000-00805f9b34fb") val bpMeasureUuid = java.util.UUID.fromString("00002a35-0000-1000-8000-00805f9b34fb") @@ -1274,8 +1467,16 @@ class RingBLEClient(private val context: Context) { override fun onCharacteristicWrite( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { + recordDiagnostic("write ${characteristic.uuid.toString().take(8)} status=$status") if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // GATT ACK — link is alive + if (status != BluetoothGatt.GATT_SUCCESS) { + updateState { copy(lastError = "Ring command write failed (GATT $status)") } + // Do not advance into the next command: the failed operation may be one of the + // required post-subscription handshake writes, and callbacks share a channel. + recoverWedgedLink() + return + } resetOpFailures() // a real completion — the stack is responsive again completeOp { it is GattOp.CommandWrite } } @@ -1283,9 +1484,11 @@ class RingBLEClient(private val context: Context) { override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { + if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // inbound notify — link is alive val value = characteristic.value ?: return val uuid = characteristic.uuid.toString() + val callbackForgetGeneration = forgetGeneration // Standard BLE health services — read before the ring-service guard if (uuid.startsWith("00002a35")) { @@ -1293,8 +1496,15 @@ class RingBLEClient(private val context: Context) { if (value.size >= 7) { val systolic = decodeSFLOAT(value[1], value[2]) val diastolic = decodeSFLOAT(value[3], value[4]) - PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, systolic, java.time.Instant.now())) - PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, diastolic, java.time.Instant.now())) + if (acceptsCallback(callbackForgetGeneration)) { + PulseEventBus.publishBlocking( + PulseEvent.BloodPressureSample( + systolic = systolic.toInt(), + diastolic = diastolic.toInt(), + timestamp = java.time.Instant.now(), + ) + ) + } } return } @@ -1302,7 +1512,9 @@ class RingBLEClient(private val context: Context) { // Glucose Measurement — IEEE 11073 SFLOAT in kg/L → mg/dL if (value.size >= 12) { val glucoseKgL = decodeSFLOAT(value[10], value[11]) - PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, glucoseKgL * 100000.0, java.time.Instant.now())) + if (acceptsCallback(callbackForgetGeneration)) { + PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, glucoseKgL * 100000.0, java.time.Instant.now())) + } } return } @@ -1312,53 +1524,85 @@ class RingBLEClient(private val context: Context) { // Raw seam: reply payloads the decoded-event stream doesn't carry // (e.g. Colmi pref-read replies seeding the measurement config). - if (!forgetPending) activeSyncEngine?.handleRawNotify(value) + if (acceptsCallback(callbackForgetGeneration)) { + activeSyncEngine?.handleRawNotify(value) + } val decodedEvents = driver.ingest(value, characteristic.uuid.toString()) - // Log once per physical BLE notification, not once per decoded event — a single - // reassembled buffer (e.g. a "full history" sleep reply) can decode into many - // events (one per day), which used to re-log the same bytes N times and made a - // single notification look like N separate retransmissions in diagnostics. - if (!forgetPending) { - decodedEvents.firstOrNull()?.let { first -> - PulseEventBus.publishBlocking( - PulseEvent.RawPacket(PacketDirection.INCOMING, value, first) - ) + if (acceptsCallback(callbackForgetGeneration)) { + val diagnostic = decodedEvents.firstOrNull() ?: RingDecodedEvent.Unknown( + commandId = value.firstOrNull()?.toUByte() ?: 0u, + raw = value, + ) + PulseEventBus.publishBlocking( + PulseEvent.RawPacket(PacketDirection.INCOMING, value, diagnostic) + ) + for (decoded in decodedEvents) { + if (!acceptsCallback(callbackForgetGeneration)) break + if (decoded is RingDecodedEvent.SupportFunctions) { + refineActiveCapabilities(decoded.capabilities) + } + for (event in RingEventBridge.eventsFor(decoded)) { + if (!acceptsCallback(callbackForgetGeneration)) break + PulseEventBus.publishBlocking(event) + } + if (acceptsCallback(callbackForgetGeneration)) activeSyncEngine?.handle(decoded) } + return } - for (decoded in decodedEvents) { - // A forget is in flight: don't persist any more data or re-publish a - // "connected" device state (which would re-create the row we're clearing). - // Just watch for the ring's unbind ack (6 = UNBOND_ACK, 3 = ACK_CANCEL). - if (forgetPending) { - if (decoded is RingDecodedEvent.BindNotify && - (decoded.action == 6 || decoded.action == 3)) { - finalizeForget() + val shouldFinalizeForget = synchronized(forgetLock) { + forgetPending && callbackForgetGeneration == forgetGeneration && + decodedEvents.any { decoded -> + decoded is RingDecodedEvent.BindNotify && + (decoded.action == 6 || decoded.action == 3) } - continue - } - for (event in RingEventBridge.eventsFor(decoded)) { - PulseEventBus.publishBlocking(event) - } - if (decoded is RingDecodedEvent.SupportFunctions) refineActiveCapabilities(decoded.capabilities) - activeSyncEngine?.handle(decoded) } + if (shouldFinalizeForget) finalizeForget(expectedGeneration = callbackForgetGeneration) + } override fun onDescriptorWrite( gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { + recordDiagnostic("notify ${descriptor.characteristic.uuid.toString().take(8)} status=$status") if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // descriptor ACK — link is alive resetOpFailures() - // Retire the in-flight op before any early return, so the queue drains. - completeOp { it is GattOp.DescriptorWrite && it.descriptor === descriptor } + // Retire without pumping: the final required CCCD may need to prepend an immediate + // vendor handshake ahead of reads and optional descriptors already in the queue. + if (!retireOp { it is GattOp.DescriptorWrite && it.descriptor === descriptor }) return + + val driver = activeDriver + val channelUuid = descriptor.characteristic.uuid.toString() + val isRingChannel = driver?.notifyUUIDs?.any { it == channelUuid } == true + val gate = subscriptionGate + + if (status != BluetoothGatt.GATT_SUCCESS) { + val failure = "Could not enable ring notifications (GATT $status, ${channelUuid.substringBefore('-')})" + if (isRingChannel && gate?.isRequired(channelUuid) == true) { + failConnectAttempt(failure) + } else { + updateState { copy(lastError = failure) } + pumpOps() + } + return + } - // Notification enabled — fire onConnected once at least one notify is live - val driver = activeDriver ?: return - val ch = descriptor.characteristic - if (!driver.notifyUUIDs.any { it == ch.uuid.toString() }) return - if (_state.value.connectionState == RingConnectionState.CONNECTED) return + if (!isRingChannel || driver == null || gate == null) { + pumpOps() + return + } + gate.descriptorWritten(channelUuid, successful = true) + if (!gate.isReady || _state.value.connectionState == RingConnectionState.CONNECTED) { + pumpOps() + return + } + + val immediateCommands = driver.immediatePostSubscriptionCommands() + if (immediateCommands.isNotEmpty()) { + recordDiagnostic("vendor handshake queued") + prependCommandWrites(immediateCommands) + } updateState { copy(connectionState = RingConnectionState.CONNECTED) } lastActivityAt = System.currentTimeMillis() // fresh link — start the staleness clock @@ -1384,7 +1628,10 @@ class RingBLEClient(private val context: Context) { PulseEventBus.publishBlocking( PulseEvent.DeviceStateChanged( - RingConnectionState.CONNECTED, device.address, name = device.name ?: connectingName + RingConnectionState.CONNECTED, + device.address, + name = device.name ?: connectingName, + deviceType = activeCoordinator?.deviceType, ) ) activeCoordinator?.let { coord -> @@ -1397,8 +1644,11 @@ class RingBLEClient(private val context: Context) { ) ) } - readBattery() + if (activeCoordinator?.deviceType != RingDeviceType.YCBT) { + readBattery() + } + pumpOps() scope.launch { onConnected?.invoke() } } @@ -1436,7 +1686,7 @@ class RingBLEClient(private val context: Context) { } } - private fun handleDisconnect(gatt: BluetoothGatt) { + private fun handleDisconnect(gatt: BluetoothGatt, status: Int = BluetoothGatt.GATT_SUCCESS) { // Ignore late callbacks from a GATT we already superseded during a reconnect // (we close the old handle in beginConnect). Acting on them would clobber the // CONNECTING state of the fresh attempt with a spurious DISCONNECTED. @@ -1452,6 +1702,7 @@ class RingBLEClient(private val context: Context) { } resetOpQueue() stopKeepalive() + activeDriver?.connectionDidEnd() // Release the dead client immediately (the link is already down, so no // disconnect/delay needed): refresh the GATT cache and close, matching the @@ -1460,19 +1711,34 @@ class RingBLEClient(private val context: Context) { // and always starts from a fresh connectGatt. bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null + subscriptionGate = null closeGattQuietly(gatt) + releaseConnectionOwnership() PulseEventBus.publishBlocking( PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) ) - updateState { copy(connectionState = RingConnectionState.DISCONNECTED) } + val requestedByUser = prefs.getBoolean(USER_DISCONNECTED_KEY, false) + updateState { + copy( + connectionState = RingConnectionState.DISCONNECTED, + lastError = when { + requestedByUser -> lastError + status == BluetoothGatt.GATT_SUCCESS -> null + else -> "Ring disconnected (GATT $status)" + }, + ) + } } - fun destroy() { + /** Tear down this client. Returns true only if it owned PulseLoop's process-wide GATT slot. */ + fun destroy(): Boolean { watchdogJob?.cancel() + ownershipRetryJob?.cancel(); ownershipRetryJob = null stopKeepalive() scanner?.stopScan(scanCallback) + activeDriver?.connectionDidEnd() // The scope is about to die, so the graceful delayed close in disconnect() // would never run — tear the GATT down synchronously instead. bluetoothGatt?.let { gatt -> @@ -1480,10 +1746,20 @@ class RingBLEClient(private val context: Context) { closeGattQuietly(gatt) } bluetoothGatt = null + val releasedConnection = releaseConnectionOwnership() scope.cancel() updateState { copy(connectionState = RingConnectionState.DISCONNECTED) } + return releasedConnection } + private fun claimConnectionOwnership(): Boolean { + val owner = processConnectionOwner.get() + return owner === this || (owner == null && processConnectionOwner.compareAndSet(null, this)) + } + + private fun releaseConnectionOwnership(): Boolean = + processConnectionOwner.compareAndSet(this, null) + companion object { private const val LAST_PERIPHERAL_KEY = "ring.lastPeripheralIdentifier" private const val LAST_DEVICE_TYPE_KEY = "ring.lastDeviceType" @@ -1491,6 +1767,11 @@ class RingBLEClient(private val context: Context) { /** Set when the user taps Disconnect; suppresses every auto-reconnect path * (foreground, watchdog, background worker) until the user reconnects or re-pairs. */ private const val USER_DISCONNECTED_KEY = "ring.userDisconnected" + /** A ring has one command stream; never let this process open competing GATT clients. */ + private val processConnectionOwner = + java.util.concurrent.atomic.AtomicReference(null) + /** Foreground retry delay while a short-lived background client yields ownership. */ + private const val PROCESS_OWNER_RETRY_MS = 1_000L /** How often the liveness watchdog runs. */ private const val WATCHDOG_INTERVAL_MS = 15_000L /** No GATT activity for this long while CONNECTED ⇒ zombie link ⇒ reconnect. diff --git a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt index 159bb23..484d20e 100644 --- a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt +++ b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt @@ -91,6 +91,8 @@ sealed class RingDecodedEvent { is WearingStatus -> this._timestamp is TimingHistoryFrame -> Instant.EPOCH is MeasurementRejected -> Instant.EPOCH + is BloodPressureSample -> this._timestamp + is BloodSugarSample -> this._timestamp is Unknown -> Instant.EPOCH } @@ -162,7 +164,9 @@ sealed class RingDecodedEvent { data class SleepTimeline( val _timestamp: Instant, - val stages: List + val stages: List, + /** True when this event is the ring's complete authoritative session, not one packet. */ + val completeSession: Boolean = false, ) : RingDecodedEvent() { override val kind = "sleep_timeline" override val confidence = DecodeConfidence.KNOWN @@ -262,12 +266,12 @@ sealed class RingDecodedEvent { /** * The ring **refused** to start the spot measurement we asked for (YCBT `03 2f` answered with a * non-zero status). `mode` is the measurement mode we started — the reply itself carries only a - * status byte, so the mode comes from the start `YCBTDriver` remembers sending. Produces no - * `PulseEvent`: it is a verdict on a command, not data — `RingSyncCoordinator` reads it off the - * raw-packet feed and aborts the matching in-flight measurement. + * status byte, so the mode comes from the start `YCBTDriver` remembers sending. `RingEventBridge` + * maps it to `PulseEvent.MeasurementRejected`, which `RingSyncCoordinator` consumes to abort the + * matching in-flight measurement. */ data class MeasurementRejected( - val mode: UByte + val mode: Int ) : RingDecodedEvent() { override val kind = "measurement_rejected" override val confidence = DecodeConfidence.KNOWN @@ -276,7 +280,8 @@ sealed class RingDecodedEvent { data class StressSample( val value: Int, - val _timestamp: Instant + val _timestamp: Instant, + val isHistory: Boolean = false, ) : RingDecodedEvent() { override val kind = "stress_sample" override val confidence = DecodeConfidence.KNOWN @@ -294,7 +299,8 @@ sealed class RingDecodedEvent { data class TemperatureSample( val celsius: Double, - val _timestamp: Instant + val _timestamp: Instant, + val isHistory: Boolean = false, ) : RingDecodedEvent() { override val kind = "temperature_sample" override val confidence = DecodeConfidence.KNOWN @@ -357,6 +363,26 @@ sealed class RingDecodedEvent { override val debugJSON = "{}" } + data class BloodPressureSample( + val systolic: Int, + val diastolic: Int, + val _timestamp: Instant, + val isHistory: Boolean = false, + ) : RingDecodedEvent() { + override val kind = "blood_pressure_sample" + override val confidence = DecodeConfidence.KNOWN + override val debugJSON = """{"sys":$systolic,"dia":$diastolic}""" + } + + data class BloodSugarSample( + val mgdl: Double, + val _timestamp: Instant, + ) : RingDecodedEvent() { + override val kind = "blood_sugar_sample" + override val confidence = DecodeConfidence.KNOWN + override val debugJSON = """{"mgdl":$mgdl}""" + } + data class Unknown( val commandId: UByte, val raw: ByteArray diff --git a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt index 3893a3a..2c0c70a 100644 --- a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt +++ b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt @@ -9,9 +9,13 @@ import java.time.temporal.ChronoUnit */ object RingEventBridge { private val hrRange = 30..220 + val spo2Range = 70..100 private val stressRange = 1..100 private val hrvRange = 1..300 private val temperatureRange = 30.0..45.0 + private val systolicRange = 60..250 + private val diastolicRange = 30..150 + private val bloodSugarRange = 20.0..600.0 private const val maxBucketSteps = 5000 private const val maxBucketDistance = 6000 @@ -36,7 +40,7 @@ object RingEventBridge { listOf(PulseEvent.Spo2Result(decoded.value, decoded._timestamp)) is RingDecodedEvent.HistoryMeasurement -> { - if (decoded.kind_field == MeasurementKind.HEART_RATE && decoded.value.toInt() !in hrRange) emptyList() + if (!isPlausibleHistoryMeasurement(decoded.kind_field, decoded.value)) emptyList() // A ring's on-device log can still hold records stamped under a previous clock — e.g. // a jring that logged against a UTC RTC before the app started setting it to local // time. Those decode hours into the future. Drop anything outside the history horizon @@ -47,7 +51,7 @@ object RingEventBridge { is RingDecodedEvent.StressSample -> { if (decoded.value !in stressRange) emptyList() - else listOf(PulseEvent.StressSample(decoded.value, decoded._timestamp)) + else listOf(PulseEvent.StressSample(decoded.value, decoded._timestamp, decoded.isHistory)) } is RingDecodedEvent.HrvSample -> { @@ -57,7 +61,7 @@ object RingEventBridge { is RingDecodedEvent.TemperatureSample -> { if (decoded.celsius !in temperatureRange) emptyList() - else listOf(PulseEvent.TemperatureSample(decoded.celsius, decoded._timestamp)) + else listOf(PulseEvent.TemperatureSample(decoded.celsius, decoded._timestamp, decoded.isHistory)) } is RingDecodedEvent.HistorySyncProgress -> @@ -68,7 +72,7 @@ object RingEventBridge { is RingDecodedEvent.SleepTimeline -> { if (!isWithinHistoryWindow(decoded._timestamp, now) || decoded.stages.isEmpty()) emptyList() - else listOf(PulseEvent.SleepTimeline(decoded._timestamp, decoded.stages)) + else listOf(PulseEvent.SleepTimeline(decoded._timestamp, decoded.stages, decoded.completeSession)) } is RingDecodedEvent.Battery -> { @@ -111,8 +115,41 @@ object RingEventBridge { // separate HistoryMeasurement events. Produces no PulseEvent itself. is RingDecodedEvent.MeasurementRejected -> - emptyList() // A verdict on a command, not data — RingSyncCoordinator reads it off the - // raw-packet feed (SpotMeasurementGate) to fast-fail the named measurement + listOf(PulseEvent.MeasurementRejected(decoded.mode)) + + is RingDecodedEvent.BloodPressureSample -> { + if (decoded.systolic in systolicRange && decoded.diastolic in diastolicRange) { + listOf( + PulseEvent.BloodPressureSample( + decoded.systolic, + decoded.diastolic, + decoded._timestamp, + decoded.isHistory, + ) + ) + } else emptyList() + } + + is RingDecodedEvent.BloodSugarSample -> { + if (decoded.mgdl in bloodSugarRange) listOf(PulseEvent.BloodSugarSample(decoded.mgdl, decoded._timestamp)) + else emptyList() + } + } + + private fun isPlausibleHistoryMeasurement(kind: MeasurementKind, value: Double): Boolean { + if (!value.isFinite()) return false + return when (kind) { + MeasurementKind.HEART_RATE -> value.toInt() in hrRange + MeasurementKind.SPO2 -> value.toInt() in spo2Range + MeasurementKind.STRESS, MeasurementKind.FATIGUE -> value.toInt() in stressRange + MeasurementKind.HRV -> value.toInt() in hrvRange + MeasurementKind.TEMPERATURE -> value in temperatureRange + MeasurementKind.BLOOD_PRESSURE_SYSTOLIC -> value.toInt() in systolicRange + MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> value.toInt() in diastolicRange + MeasurementKind.BLOOD_SUGAR -> value in bloodSugarRange + MeasurementKind.RESPIRATORY_RATE -> value.toInt() in 5..60 + MeasurementKind.VO2MAX -> value.toInt() in 1..100 + } } /** Shared plausibility window: within the last ~8 days (the history horizon) and no more diff --git a/app/src/main/java/com/pulseloop/ring/SubscriptionSetupGate.kt b/app/src/main/java/com/pulseloop/ring/SubscriptionSetupGate.kt new file mode 100644 index 0000000..a39fca1 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/SubscriptionSetupGate.kt @@ -0,0 +1,48 @@ +package com.pulseloop.ring + +/** Pure readiness policy for GATT notification setup. Android objects stay in RingBLEClient. */ +internal class SubscriptionSetupGate( + notifyUUIDs: List, + requiredSubscriptions: List, +) { + private data class Observed(val localEnabled: Boolean, val hasCccd: Boolean) + + private val notifyUUIDs = notifyUUIDs.map(String::lowercase).toSet() + private val required = requiredSubscriptions.associateBy { it.uuid.lowercase() } + private val observed = mutableMapOf() + private val completed = mutableSetOf() + + fun observeCharacteristic(uuid: String, localEnabled: Boolean, hasCccd: Boolean) { + val key = uuid.lowercase() + if (key in notifyUUIDs) observed[key] = Observed(localEnabled, hasCccd) + } + + fun modeFor(uuid: String): SubscriptionMode = + required[uuid.lowercase()]?.mode ?: SubscriptionMode.NOTIFICATION + + fun isRequired(uuid: String): Boolean = uuid.lowercase() in required + + fun descriptorWritten(uuid: String, successful: Boolean) { + val key = uuid.lowercase() + if (successful && key in notifyUUIDs) completed += key + } + + val isReady: Boolean + get() = if (required.isEmpty()) { + completed.isNotEmpty() + } else { + required.keys.all { it in completed } + } + + /** Validate declared topology after service discovery, before any partial setup can connect. */ + fun topologyFailure(): String? { + if (required.isEmpty()) return null + val unavailable = required.keys.filter { uuid -> + val channel = observed[uuid] + channel == null || !channel.localEnabled || !channel.hasCccd + } + if (unavailable.isEmpty()) return null + val channels = unavailable.joinToString { it.substringBefore('-').uppercase() } + return "Required ring indication channel unavailable: $channels" + } +} diff --git a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt index 3a97537..10849dd 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt @@ -27,23 +27,21 @@ enum class WearableCapability(val key: String) { // Interaction capabilities MANUAL_HEART_RATE("manualHeartRate"), MANUAL_SPO2("manualSpo2"), + MANUAL_BLOOD_PRESSURE("manualBloodPressure"), + MANUAL_HRV("manualHrv"), REALTIME_HEART_RATE("realtimeHeartRate"), REALTIME_STEPS("realtimeSteps"), FIND_DEVICE("findDevice"), POWER_OFF("powerOff"), FACTORY_RESET("factoryReset"), + SPO2_HISTORY("spo2History"), // Configurable all-day measurement: the device exposes a settable HR sampling interval and // per-vital monitoring toggles (Colmi `0x16` + prefs). The generic jring has no such control, // so it never declares this and the Measurement settings section stays hidden for it. MEASUREMENT_INTERVAL("measurementInterval"), - // YCBT (TK5 / SmartHealth-Colmi): the all-day SpO2 log is a separate query (`05 1A`) from the - // spot-measurement gate below, and on-demand HRV/BP are their own SupportFunction bits distinct - // from the trend/history capability. - SPO2_HISTORY("spo2History"), - MANUAL_HRV("manualHrv"), - MANUAL_BLOOD_PRESSURE("manualBloodPressure"), + // YCBT history-only metric. VO2MAX("vo2max"); companion object { @@ -75,5 +73,7 @@ enum class RingDeviceType(val displayName: String) { // Moyoung "Da Rings" (com.moyoung.ring). Notably the CRP-firmware Colmi R11: it advertises the // generic "SMART_RING" name with no service UUID, so it's classified JRING at scan and only // reveals its `fdda` service post-connect (issue #29, zaggash's ring). See CRPCoordinator. - CRP("Colmi / Moyoung ring (CRP)"); + CRP("Colmi / Moyoung ring (CRP)"), + // Hardware-validated SmartHealth R10M path, kept separate from the broader YCBT families. + YCBT("YCBT / SmartHealth ring"); } diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index f612c1c..6511129 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -8,6 +8,13 @@ fun interface RingCommandWriter { fun enqueue(command: ByteArray) } +enum class SubscriptionMode { NOTIFICATION, INDICATION } + +data class RequiredSubscription( + val uuid: String, + val mode: SubscriptionMode, +) + /** * Ported from [WearableDriver] in WearableDriver.swift. * Connection + protocol handler for one wearable family. @@ -20,6 +27,13 @@ interface WearableDriver { val batteryServiceUUID: String? get() = null val batteryCharUUID: String? get() = null + /** Channels that must be subscribed successfully before this driver is usable. Empty keeps + * legacy first-notify readiness for devices whose additional channels are optional. */ + val requiredSubscriptionsBeforeConnected: List get() = emptyList() + + /** Commands that must be placed directly after the final required CCCD write. */ + fun immediatePostSubscriptionCommands(): List = emptyList() + /** Apply outbound framing. jring: identity. Colmi: pad to 15 + checksum. */ fun frame(command: ByteArray): ByteArray @@ -31,6 +45,10 @@ interface WearableDriver { /** Build the per-device sync engine. */ fun makeSyncEngine(): RingSyncEngine + + /** Reset connection-scoped protocol state. */ + fun connectionDidStart() {} + fun connectionDidEnd() {} } /** @@ -92,10 +110,23 @@ data class UserProfileValues( * Per-device orchestration of command flows. */ interface RingSyncEngine { + /** True only for protocols with one native command that returns a combined vitals packet. + * Capability bits such as manual BP/glucose do not imply this transport feature. */ + val supportsCombinedMeasurement: Boolean get() = false + fun runStartup() fun handle(event: RingDecodedEvent) - /** On-demand, standalone sleep fetch — request just the sleep record without running the + /** User-requested refresh. Existing families historically replay their startup sync. */ + fun refresh() = runStartup() + + /** Legacy sleep query action. Existing families historically replay their startup sync. */ + fun querySleep() = runStartup() + + /** Refresh the recent vital series after a live workout without replaying all history. */ + fun syncVitalsHistory() {} + + /** On-screen, standalone sleep fetch — request just the sleep record without running the * whole history pipeline (which buries sleep behind activity/HR/stress/SpO₂ and can lose it * to a watchdog stage-skip). Mirrors the official QRing app, which fires a dedicated sleep * request when its sleep screen opens. No-op on devices whose history sync isn't staged this @@ -106,6 +137,10 @@ interface RingSyncEngine { fun measureHeartRateSpot() { startHeartRate() } fun startSpO2() fun stopSpO2() + fun startHRV() {} + fun stopHRV() {} + fun startBloodPressure() {} + fun stopBloodPressure() {} /** Combined measurement: HR + systolic + diastolic + SpO₂ + fatigue + stress + blood sugar + HRV. No-op if unsupported. */ fun startCombinedMeasurement() {} fun stopCombinedMeasurement() {} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt new file mode 100644 index 0000000..7ad101c --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt @@ -0,0 +1,81 @@ +package com.pulseloop.ring + +import com.pulseloop.wearables.WearableModel + +/** + * Ported from ColmiSmartHealthCoordinator.swift / TK5Coordinator.swift. + * Coordinator for YCBT rings (SmartHealth app) — R10M and siblings. + */ + +object YCBTCoordinator : WearableCoordinator { + override val deviceType = RingDeviceType.YCBT + + + override fun matches(name: String?, advertisement: AdvertisementInfo): Boolean { + // Disqualify QRing service outright — those belong to ColmiDriver. + val qringServices = listOf(ColmiUUIDs.SERVICE_V1, ColmiUUIDs.SERVICE_V2) + if (advertisement.serviceUUIDs.any { it in qringServices }) return false + + val model = WearableModel.modelForAdvertisedName(name) + if (model != null) { + return model.family == deviceType && isSmartHealthName(name) + } + + // Only the R10M is claimed on name alone. This coordinator sits ahead of + // ColmiSmartHealthCoordinator/TK5Coordinator in the registry, so it must NOT match the + // other YCBT-family prefixes (TK5/T50/SR0x/R0x) by name — an uncataloged `TK5_xxxx` unit + // still relies on TK5Coordinator's own name/manufacturer fallback and must fall through. + // `YCBTUUIDs.SERVICE` (be940000) is the R10M's proprietary service and is not advertised by + // TK5 or the SmartHealth-Colmi rings, so it stays a safe positive signal here. + val hasYcbtService = advertisement.serviceUUIDs.contains(YCBTUUIDs.SERVICE) + val hasKnownName = isSmartHealthName(name) + return hasYcbtService || hasKnownName + } + + private fun isSmartHealthName(name: String?): Boolean { + if (name == null) return false + val normalized = name.trim().uppercase() + return Regex("^R10M(?:[ _-][0-9A-Z]+)?$").matches(normalized) + } + + override val capabilities = setOf( + WearableCapability.HEART_RATE, + WearableCapability.SPO2, + WearableCapability.STEPS, + WearableCapability.SLEEP, + WearableCapability.REM_SLEEP, + WearableCapability.BATTERY, + WearableCapability.MANUAL_HEART_RATE, + WearableCapability.MANUAL_SPO2, + WearableCapability.REALTIME_HEART_RATE, + WearableCapability.REALTIME_STEPS, + WearableCapability.MEASUREMENT_INTERVAL, + ) + + override val bitmapGatedCapabilities = setOf( + WearableCapability.TEMPERATURE, + WearableCapability.BLOOD_PRESSURE, + WearableCapability.MANUAL_BLOOD_PRESSURE, + WearableCapability.STRESS, + WearableCapability.FATIGUE, + WearableCapability.BLOOD_SUGAR, + WearableCapability.HRV, + WearableCapability.MANUAL_HRV, + WearableCapability.FIND_DEVICE, + ) + + override val iconSystemName = "circle.circle.fill" + + override fun makeDriver(writer: RingCommandWriter): WearableDriver { + return YCBTDriver( + writer, + YCBTFamilyProfile( + baselineCapabilities = capabilities, + bitmapGatedCapabilities = bitmapGatedCapabilities, + ), + ) + } +} + +/** Hex extension matching iOS hexString. */ +fun ByteArray.toHexString(): String = joinToString("") { String.format("%02x", it) } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTCoordinators.kt b/app/src/main/java/com/pulseloop/ring/YCBTCoordinators.kt index b3df916..6d4bd3a 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTCoordinators.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTCoordinators.kt @@ -72,7 +72,15 @@ object TK5Coordinator : WearableCoordinator { override val iconSystemName = "circle.circle.fill" - override fun makeDriver(writer: RingCommandWriter): WearableDriver = YCBTDriver(writer) + override fun makeDriver(writer: RingCommandWriter): WearableDriver = YCBTDriver( + writer, + YCBTFamilyProfile( + baselineCapabilities = capabilities, + bitmapGatedCapabilities = bitmapGatedCapabilities, + queryChipSchemeAtStartup = true, + supportsBloodPressureMonitor = true, + ), + ) } /** @@ -165,5 +173,13 @@ object ColmiSmartHealthCoordinator : WearableCoordinator { override val iconSystemName = "circle.circle.fill" - override fun makeDriver(writer: RingCommandWriter): WearableDriver = YCBTDriver(writer) + override fun makeDriver(writer: RingCommandWriter): WearableDriver = YCBTDriver( + writer, + YCBTFamilyProfile( + baselineCapabilities = capabilities, + bitmapGatedCapabilities = bitmapGatedCapabilities, + queryChipSchemeAtStartup = true, + supportsBloodPressureMonitor = true, + ), + ) } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt index 48505a7..e373ace 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt @@ -3,233 +3,166 @@ package com.pulseloop.ring import java.time.Instant /** - * Ported from YCBTDecoder.swift (iOS #82). - * Decodes inbound YCBT frames into the shared [RingDecodedEvent]. Frames arrive already - * reassembled ([YCBTFrameAssembler]) and CRC-validated ([YCBTFrame]), from either the command - * channel (be940001) or the async stream (be940003); this decoder dispatches on `(type, cmd)` - * regardless of channel. - * - * **Health-group (`0x05`) frames never reach here** — the driver routes them into - * [YCBTHistoryTransfer], which reassembles a whole transfer before [YCBTHealthRecords] cuts it - * into records. History records are packed back-to-back and chopped at arbitrary frame - * boundaries, so decoding one per-frame loses every record that straddles two. + * Ported from YCBTDecoder.swift. + * Decodes inbound YCBT frames into the shared RingDecodedEvent. */ -object YCBTDecoder { - /** Plausibility gate for a live SpO2 sample — the one metric this decoder must self-gate. */ - private val spo2Range = 35..100 - /** - * Decode one validated frame into the events it carries. - * - * @param startedMode the mode of the `03 2f` **start** still awaiting its reply, or null if the - * last live-measurement command sent was a stop (or none was). [YCBTDriver] supplies this, - * since the ring's reply carries a status but not a mode. - */ - fun decode(frame: YCBTFrame, now: Instant = Instant.now(), startedMode: UByte? = null): List = - when { - frame.type == YCBTGroup.REAL -> decodeRealStream(frame, now) - // Auto-ACKed by YCBTDriver *before* this decode runs — the ring retransmits until it is. - frame.type == YCBTGroup.DEV_CONTROL -> decodeDevControlPush(frame.cmd, frame.payload, now) - frame.type == YCBTGroup.GET -> decodeGetReply(frame) - frame.type == YCBTGroup.APP_CONTROL && frame.cmd == YCBTCommand.LIVE_MEASUREMENT -> - decodeMeasurementStartReply(frame.payload, startedMode) - frame.type == YCBTGroup.SETTING && frame.cmd == YCBTSettingKey.SET_TIME -> - listOf(RingDecodedEvent.TimeSyncAck(now)) - else -> listOf(RingDecodedEvent.CommandAck(frame.cmd)) +class YCBTDecoder { + + fun decode(frame: YCBTFrame, now: Instant = Instant.now(), startedMode: Int? = null): List { + return when (frame.type) { + YCBTGroup.REAL -> decodeRealStream(frame, now) + YCBTGroup.DEV_CONTROL -> decodeDevControlPush(frame.cmd, payload = frame.payload, now = now) + YCBTGroup.GET -> decodeGetReply(frame) + YCBTGroup.APP_CONTROL -> if (frame.cmd == YCBTCommand.LIVE_MEASUREMENT) { + decodeMeasurementStartReply(frame.payload, startedMode = startedMode) + } else { + listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } + YCBTGroup.SETTING -> if (frame.cmd == YCBTSettingKey.SET_TIME) { + listOf(RingDecodedEvent.TimeSyncAck(_timestamp = now)) + } else { + listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } + else -> listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) } - - // MARK: - AppControl replies (group 0x03) - - /** - * The ring's answer to `03 2f {enable, mode}` — one status byte. `0x00` is "started"; anything - * else is the firmware declining. Surfaced as [RingDecodedEvent.MeasurementRejected] so the - * in-flight spot measurement can fail immediately instead of polling a stream the ring already - * told us it will never send. - * - * Two things keep a *stray* refusal from cancelling the wrong measurement: - * 1. `startedMode` is null unless a start is actually outstanding — a rejected **stop** is just - * an ack, and a duplicate/late reply finds the mode already cleared. - * 2. The mode travels with the event, so `RingSyncCoordinator` can check it against the - * measurement it is actually running before failing anything. - */ - private fun decodeMeasurementStartReply(p: List, startedMode: UByte?): List { - val ack = listOf(RingDecodedEvent.CommandAck(YCBTCommand.LIVE_MEASUREMENT)) - if (p.size != 1 || startedMode == null) return ack - val status = p[0] - if (YCBTMeasurementMode.isAccepted(status)) return ack - return listOf(RingDecodedEvent.MeasurementRejected(startedMode)) } - // MARK: - Async live stream (be940003, group 0x06) + private fun decodeMeasurementStartReply(payload: ByteArray, startedMode: Int?): List { + val ack: List = listOf(RingDecodedEvent.CommandAck(commandId = YCBTCommand.LIVE_MEASUREMENT.toUByte())) + if (payload.size != 1) return ack + val status = payload[0].toInt() and 0xFF + if (YCBTMeasurementMode.isAccepted(status) || startedMode == null) return ack + return listOf(RingDecodedEvent.MeasurementRejected(mode = startedMode)) + } private fun decodeRealStream(frame: YCBTFrame, now: Instant): List { val p = frame.payload return when (frame.cmd) { YCBTCommand.LIVE_STATUS -> { - // Cumulative day totals. steps verified against capture; distance/calories are the - // adjacent u16s — UNVERIFIED (capture-inferred), but the app's activity update - // uses max() so an over-read can't corrupt the day. - if (p.size < 2) listOf(RingDecodedEvent.CommandAck(frame.cmd)) - else listOf(RingDecodedEvent.ActivityUpdate(now, YCBTBytes.u16(p, 0), YCBTBytes.u16(p, 2), YCBTBytes.u16(p, 4))) + if (p.size < 6) return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + listOf(RingDecodedEvent.ActivityUpdate( + _timestamp = now, + steps = YCBTBytes.u16(p, 0), + distanceMeters = YCBTBytes.u16(p, 2), + calories = YCBTBytes.u16(p, 4), + )) } - YCBTCommand.LIVE_HEART_RATE -> { - // 1-byte live bpm. Verified (climbed 82->86 across the capture). - val bpm = p.firstOrNull() - if (bpm == null) listOf(RingDecodedEvent.CommandAck(frame.cmd)) - else listOf(RingDecodedEvent.HeartRateSample(bpm.toInt(), now)) + val bpm = (p.firstOrNull()?.toInt() ?: return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte()))) and 0xFF + return listOf(RingDecodedEvent.HeartRateSample(bpm = bpm, _timestamp = now)) } - YCBTCommand.LIVE_SPO2 -> { - // 1-byte live SpO2 % from the mode-0x02 (red-LED) stream. Gate to a plausible range - // so a warm-up 0 isn't surfaced as a reading. - val spo2 = p.firstOrNull() - if (spo2 == null || spo2.toInt() !in spo2Range) listOf(RingDecodedEvent.CommandAck(frame.cmd)) - else listOf(RingDecodedEvent.Spo2Result(spo2.toInt(), now)) + val spo2 = (p.firstOrNull()?.toInt() ?: return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte()))) and 0xFF + if (spo2 in RingEventBridge.spo2Range) { + return listOf(RingDecodedEvent.Spo2Result(value = spo2, _timestamp = now)) + } + return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) } - - YCBTCommand.LIVE_VITALS -> decodeLiveVitals(p, frame.cmd, now) - + YCBTCommand.LIVE_VITALS -> decodeLiveVitals(p, cmd = frame.cmd, now = now) YCBTCommand.LIVE_BATTERY -> { - // `06 15` battery push: `[chargingStatus][percent]`. Sent unprompted on - // charge/level changes, so battery stays fresh without polling `02 00`. - if (p.size < 2) listOf(RingDecodedEvent.CommandAck(frame.cmd)) - else listOf(RingDecodedEvent.Battery(p[1].toInt())) + if (p.size < 2) return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + listOf(RingDecodedEvent.Battery(percent = p[1].toInt() and 0xFF)) } - YCBTCommand.LIVE_WEARING_STATUS -> { - // `06 13`: `[ts:u32 2000-epoch][status]`. UNVERIFIED polarity — nonzero taken as worn. - if (p.size < 5) listOf(RingDecodedEvent.CommandAck(frame.cmd)) - else listOf(RingDecodedEvent.WearingStatus(p[4].toUInt() != 0u, YCBTBytes.date(YCBTBytes.u32(p, 0)))) + if (p.size < 5) return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + listOf(RingDecodedEvent.WearingStatus( + worn = p[4].toInt() and 0xFF != 0, + _timestamp = YCBTBytes.date(YCBTBytes.u32(p, 0)) + )) } - - else -> listOf(RingDecodedEvent.CommandAck(frame.cmd)) + else -> listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) } } - // MARK: - Command channel (be940001, group 0x02) + private fun decodeLiveVitals(p: ByteArray, cmd: Int, now: Instant): List { + val events = mutableListOf() + if (p.size >= 2 && p[0].toInt() and 0xFF > 0 && p[1].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.BloodPressureSample(systolic = p[0].toInt() and 0xFF, diastolic = p[1].toInt() and 0xFF, _timestamp = now)) + } + if (p.size >= 3 && p[2].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HeartRateSample(bpm = p[2].toInt() and 0xFF, _timestamp = now)) + } + if (p.size >= 4 && p[3].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HrvSample(value = p[3].toInt() and 0xFF, _timestamp = now)) + } + if (p.size >= 5 && (p[4].toInt() and 0xFF) in RingEventBridge.spo2Range) { + events.add(RingDecodedEvent.Spo2Result(value = p[4].toInt() and 0xFF, _timestamp = now)) + } + if (p.size >= 7 && p[5].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.TemperatureSample( + celsius = YCBTHealthRecords.composite(p[5].toInt() and 0xFF, p[6].toInt() and 0xFF), + _timestamp = now + )) + } + return if (events.isEmpty()) listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) else events + } - private fun decodeGetReply(frame: YCBTFrame): List = when (frame.cmd) { - YCBTCommand.GET_DEVICE_INFO -> decodeDeviceInfo(frame.payload) - YCBTCommand.GET_SUPPORT_FUNCTION -> decodeSupportFunction(frame.payload) - YCBTCommand.GET_CHIP_SCHEME -> decodeChipScheme(frame.payload) - else -> listOf(RingDecodedEvent.CommandAck(frame.cmd)) + private fun decodeGetReply(frame: YCBTFrame): List { + return when (frame.cmd) { + YCBTCommand.GET_DEVICE_INFO -> decodeDeviceInfo(frame.payload) + YCBTCommand.GET_SUPPORT_FUNCTION -> decodeSupportFunction(frame.payload) + YCBTCommand.GET_CHIP_SCHEME -> decodeChipScheme(frame.payload) + else -> listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } } - /** - * `02 00` GetDeviceInfo reply: deviceId u16 @0, firmware sub-version @2 and main-version @3 - * (formatted "main.sub" by the vendor app, e.g. main 1 / sub 5 -> "1.05"), battery **state** - * @4 and battery **percent** @5. Battery is in-band on this reply — the ring exposes no - * standard battery service. - * - * The firmware string is deliberately not surfaced as a [RingDecodedEvent.FirmwareVersion] - * here — that event is `Int?` on Android (jring's firmware is a bare numeric version), and - * reformatting "main.sub" into an Int would misrepresent it. A YCBT ring that also exposes the - * standard DIS `0x2A26`/`0x2A28` characteristics still gets its firmware string via - * `RingBLEClient`'s existing generic read path. - */ - private fun decodeDeviceInfo(p: List): List { - val events = mutableListOf(RingDecodedEvent.Status(address = null)) - if (p.size >= 6) events.add(RingDecodedEvent.Battery(p[5].toInt())) + private fun decodeDeviceInfo(p: ByteArray): List { + val major = p.getOrNull(3)?.toInt()?.and(0xFF) + val minor = p.getOrNull(2)?.toInt()?.and(0xFF) + val firmware = if (major != null && minor != null) String.format("%d.%02d", major, minor) else null + val events = mutableListOf(RingDecodedEvent.Status(address = null, firmware = firmware)) + if (p.size >= 6) { + events.add(RingDecodedEvent.Battery(percent = p[5].toInt() and 0xFF)) + } return events } - /** `02 01` GetSupportFunction reply — the firmware's own capability bitmap. */ - private fun decodeSupportFunction(p: List): List = - listOf(RingDecodedEvent.SupportFunctions(YCBTSupportFunction.capabilities(p))) - - /** `02 1b` GetChipScheme reply — diagnostic only. */ - private fun decodeChipScheme(p: List): List = - listOf(RingDecodedEvent.ChipScheme(YCBTChipScheme.value(p))) + private fun decodeSupportFunction(p: ByteArray): List { + val caps = YCBTSupportFunction.capabilities(p) + return listOf(RingDecodedEvent.SupportFunctions(caps)) + } - /** - * `06 03` — the live feed for **both** the BP and the HRV spot measurements: - * `[SBP@0][DBP@1][hr@2]` then, if long enough, `[hrv@3][spo2@4][tempInt@5][tempFrac@6]` - * - * There are **not** two frame shapes here to disambiguate: the offsets are fixed, and the mode - * just decides which of them the ring fills (BP mode fills @0/@1 and zeroes @3; HRV mode the - * reverse). Each field is emitted iff it carries a value — which also recovers the HR that the - * BP sweep measures. - * - * Emitted as [RingDecodedEvent.HistoryMeasurement] (upsert-by-timestamp), matching this - * codebase's existing convention for jring's combined-sensor packet (`ColmiDecoder`/ - * `RingDecoder`'s `0x24`/BP handling) rather than introducing a separate append-only "live - * sample" event class iOS has but Android's persistence layer doesn't distinguish. - */ - private fun decodeLiveVitals(p: List, cmd: UByte, now: Instant): List { - val events = mutableListOf() - if (p.size >= 2 && p[0] > 0u && p[1] > 0u) { - events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, p[0].toDouble(), now)) - events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, p[1].toDouble(), now)) - } - if (p.size >= 3 && p[2] > 0u) { - events.add(RingDecodedEvent.HeartRateSample(p[2].toInt(), now)) - } - if (p.size >= 4 && p[3] > 0u) { - events.add(RingDecodedEvent.HrvSample(p[3].toInt(), now)) - } - if (p.size >= 5 && p[4].toInt() in spo2Range) { - events.add(RingDecodedEvent.Spo2Result(p[4].toInt(), now)) - } - if (p.size >= 7 && p[5] > 0u) { - events.add(RingDecodedEvent.TemperatureSample(YCBTHealthRecords.composite(p[5], p[6]), now)) - } - return events.ifEmpty { listOf(RingDecodedEvent.CommandAck(cmd)) } + private fun decodeChipScheme(p: ByteArray): List { + val scheme = YCBTChipScheme.value(p) + return listOf(RingDecodedEvent.ChipScheme(value = scheme)) } - /** The ring's DevControl pushes. Only the measurement ones carry data PulseLoop has a home for. */ - private fun decodeDevControlPush(cmd: UByte, payload: List, now: Instant): List = - when (cmd) { + private fun decodeDevControlPush(cmd: Int, payload: ByteArray, now: Instant): List { + return when (cmd) { YCBTDevControl.MEASUREMENT_STATUS -> { val events = measurementStatusEvents(payload, now) - events.ifEmpty { listOf(RingDecodedEvent.CommandAck(cmd)) } + if (events.isEmpty()) listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) else events } YCBTDevControl.MEASUREMENT_RESULT -> { - // `04 0e`: `[measureType][result]`, no value. SmartHealth reacts to a success by - // re-reading history, which is where the reading actually lands — PulseLoop's - // periodic re-sync already does that. - listOf(RingDecodedEvent.CommandAck(cmd)) + // SmartHealth acknowledges this push but its proprietary unpackParseData layout + // is not available in the decompile. Do not infer mode/result fields from bytes. + listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) } - else -> listOf(RingDecodedEvent.CommandAck(cmd)) + else -> listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) } + } - /** - * `04 13` MeasurStatusAndResults — the ring's live "measurement in progress / done" push, the - * counterpart of the `03 2f` start we sent: `[type@0][state@1]` then that type's value(s). - */ - private fun measurementStatusEvents(p: List, now: Instant): List { + private fun measurementStatusEvents(p: ByteArray, now: Instant): List { if (p.size < 3) return emptyList() - val value = p[2] - val fraction = if (p.size >= 4) p[3] else 0u.toUByte() - - return when (p[0]) { - YCBTMeasurementMode.HEART_RATE -> - if (value > 0u) listOf(RingDecodedEvent.HeartRateSample(value.toInt(), now)) else emptyList() - - YCBTMeasurementMode.BLOOD_PRESSURE -> { - if (value <= 0u || fraction <= 0u) emptyList() - else listOf( - RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, value.toDouble(), now), - RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, fraction.toDouble(), now), - ) - } - - YCBTMeasurementMode.SPO2 -> - if (value.toInt() in spo2Range) listOf(RingDecodedEvent.Spo2Result(value.toInt(), now)) else emptyList() - - YCBTMeasurementMode.TEMPERATURE -> - if (value > 0u) listOf(RingDecodedEvent.TemperatureSample(YCBTHealthRecords.composite(value, fraction), now)) else emptyList() - + val value = p[2].toInt() and 0xFF + val fraction = if (p.size >= 4) p[3].toInt() and 0xFF else 0 + return when (p[0].toInt() and 0xFF) { + YCBTMeasurementMode.HEART_RATE -> if (value > 0) listOf(RingDecodedEvent.HeartRateSample(bpm = value, _timestamp = now)) else emptyList() + YCBTMeasurementMode.BLOOD_PRESSURE -> if (value > 0 && fraction > 0) listOf( + RingDecodedEvent.BloodPressureSample(systolic = value, diastolic = fraction, _timestamp = now) + ) else emptyList() + YCBTMeasurementMode.SPO2 -> if (value in RingEventBridge.spo2Range) listOf(RingDecodedEvent.Spo2Result(value = value, _timestamp = now)) else emptyList() + YCBTMeasurementMode.TEMPERATURE -> if (value > 0) listOf( + RingDecodedEvent.TemperatureSample(celsius = YCBTHealthRecords.composite(value, fraction), _timestamp = now) + ) else emptyList() YCBTMeasurementMode.BLOOD_SUGAR -> { - // Tenths of mmol/L, as everywhere else in this SDK (int * 10 + frac). - val tenths = value.toInt() * 10 + fraction.toInt() - if (tenths <= 0) emptyList() - else listOf(RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, YCBTHealthRecords.bloodSugarMgdl(tenths), now)) + val tenths = value * 10 + fraction + if (tenths > 0) listOf(RingDecodedEvent.BloodSugarSample(mgdl = YCBTHealthRecords.bloodSugarMgdl(tenths), _timestamp = now)) else emptyList() } - - // Respiratory rate (3), uric acid (6), ketone (7), blood fat (9): no live event exists - // for any of them, and PulseLoop can't start those measurements in the first place — - // only a ring-initiated one could land here. + // Live HRV values are evidenced on Real 06/03. No captured 04/13 HRV value + // layout exists, so keep status payload tails diagnostic-only rather than guessing. + YCBTMeasurementMode.HRV -> emptyList() else -> emptyList() } } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt index dc554e5..91dda64 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt @@ -1,123 +1,206 @@ package com.pulseloop.ring /** - * Ported from YCBTDriver.swift (iOS #82). - * YCBT driver. Owns the length-prefixed CRC16 framing and the split-channel topology: the command - * characteristic `be940001` is *both* the write target and a notify source (command replies), - * while `be940003` carries the async live/history stream. The standard `180D`/`2A37` Heart Rate - * characteristic is deliberately left unsubscribed: on the TK5 it emits a cached resting HR - * periodically even when the ring is off the finger (observed ~87 bpm), which would override a - * real on-demand measurement. The vendor app never subscribes it either — live HR comes solely - * from the proprietary `06 01` stream, which reflects actual finger contact. - * - * A fresh driver is built per connection ([RingBLEClient.installDriver] calls - * `coordinator.makeDriver` on every connect), so no explicit reconnect-reset hook is needed — the - * assembler/transfer/pending-reply state below all start clean. + * Ported from YCBTDriver.swift. + * YCBT driver. Owns the length-prefixed CRC16 framing and the split-channel topology. */ -class YCBTDriver(private val writer: RingCommandWriter?) : WearableDriver { - private val decoder = YCBTDecoder - /** GATT fragments -> whole logical frames. A history data frame regularly exceeds `MTU-3` and - * is split across notifications. */ + +data class YCBTFamilyProfile( + val baselineCapabilities: Set, + val bitmapGatedCapabilities: Set, + val queryChipSchemeAtStartup: Boolean = false, + val supportsBloodPressureMonitor: Boolean = false, +) + +class YCBTDriver( + private val writer: RingCommandWriter, + private val profile: YCBTFamilyProfile = YCBTFamilyProfile( + baselineCapabilities = YCBTCoordinator.capabilities, + bitmapGatedCapabilities = YCBTCoordinator.bitmapGatedCapabilities, + ), +) : WearableDriver { + private val decoder = YCBTDecoder() + private val encoder = YCBTEncoder() private val assembler = YCBTFrameAssembler() - /** The history state machine. Owned here because only the driver sees frames (the sync engine - * sees decoded events); handed to the engine so `runStartup` can seed the queue. */ - private val transfer = YCBTHistoryTransfer(writer) - - /** - * The `03 2f` commands still owed a reply, oldest first — the mode for a **start**, `null` for - * a **stop** (a rejected stop cancels nothing, so it names no measurement). - * - * The ring answers a live-measurement command with a bare status byte and **no mode**, so a - * refusal is anonymous on the wire. This driver is the one place that sees both directions — - * [frame] for every outbound command, [ingest] for every inbound frame — so it is the only - * place that can pair the two up. - * - * It has to be a **queue**, not one slot: framing happens when a command is *enqueued* (on the - * way into the serialized write queue), not when it reaches the wire, and every spot - * measurement ends with a stop immediately followed by a restart during a workout — both still - * owed replies. One serialized write queue and one ring means replies come back in the order - * the commands went out, so FIFO pairing is exact. - */ - private val pendingMeasurementReplies = ArrayDeque() - - /** A ring that stops answering `03 2f` must not grow the queue without bound. */ - private val maxPendingMeasurementReplies = 8 - - // MARK: BLE topology - - override val serviceUUIDs: List = listOf(YCBTUUIDs.SERVICE) - override val writeUUID: String = YCBTUUIDs.COMMAND - override val notifyUUIDs: List = listOf(YCBTUUIDs.COMMAND, YCBTUUIDs.STREAM) - override val batteryServiceUUID: String? = null // battery is in-band (GetDeviceInfo 02 00, payload[5]) + private var syncEngine: RingSyncEngine? = null + private val transfer: YCBTHistoryTransfer = YCBTHistoryTransfer( + writer = writer, + onOutOfBandEvents = ::handleOutOfBandEvents, + ) + private val pendingMeasurementReplies = PendingMeasurementReplies() + // Written on the GATT thread (updateCapabilities / connectionDidStart/End) and read on the + // watchdog thread via handleOutOfBandEvents → isSupported. Single-reference assignment, so + // @Volatile is enough to publish it across threads without a lock. + @Volatile + private var capabilities = profile.baselineCapabilities + + override val serviceUUIDs = listOf(YCBTUUIDs.SERVICE) + override val writeUUID = YCBTUUIDs.COMMAND + override val notifyUUIDs = listOf(YCBTUUIDs.COMMAND, YCBTUUIDs.STREAM) + override val commandUUID = YCBTUUIDs.COMMAND + override val batteryServiceUUID: String? = null override val batteryCharUUID: String? = null + override val requiredSubscriptionsBeforeConnected = listOf( + RequiredSubscription(YCBTUUIDs.COMMAND, SubscriptionMode.INDICATION), + RequiredSubscription(YCBTUUIDs.STREAM, SubscriptionMode.INDICATION), + ) - // MARK: Framing + override fun immediatePostSubscriptionCommands(): List = + encoder.postSubscriptionHandshake() override fun frame(command: ByteArray): ByteArray { - // Every outbound command passes through here exactly once, which is what makes this the - // seam that can watch for live-measurement commands (see pendingMeasurementReplies). - val logical = command.map { it.toUByte() } + val logical = command noteLiveMeasurementCommand(logical) return YCBTFrame.frame(logical) } - /** - * Queue one entry per outbound `03 2f {enable, mode}`: the mode for a start, `null` for a - * stop. A stop is queued too, and that is the point — its reply is byte-for-byte - * indistinguishable from a start's, so a stop we didn't queue would have its reply consumed by - * the next start in line. - */ - private fun noteLiveMeasurementCommand(logical: List) { - if (logical.size < 4 || logical[0] != YCBTGroup.APP_CONTROL || logical[1] != YCBTCommand.LIVE_MEASUREMENT) return - pendingMeasurementReplies.addLast(if (logical[2] == 1u.toUByte()) logical[3] else null) - if (pendingMeasurementReplies.size > maxPendingMeasurementReplies) { - pendingMeasurementReplies.removeFirst() + override fun usesCommandChannel(frame: ByteArray): Boolean = false + + private fun noteLiveMeasurementCommand(logical: ByteArray) { + if (logical.size >= 4 && + (logical[0].toInt() and 0xFF) == YCBTGroup.APP_CONTROL && + (logical[1].toInt() and 0xFF) == YCBTCommand.LIVE_MEASUREMENT) { + pendingMeasurementReplies.record( + if (logical[2].toInt() and 0xFF == 1) logical[3].toInt() and 0xFF else null + ) } } - // MARK: Inbound decode + override fun connectionDidStart() { + assembler.reset() + transfer.cancel() + pendingMeasurementReplies.clear() + capabilities = profile.baselineCapabilities + } + + override fun connectionDidEnd() { + assembler.reset() + transfer.cancel() + pendingMeasurementReplies.clear() + capabilities = profile.baselineCapabilities + } - /** - * Every notification goes through the assembler first. Health-group frames drive the history - * transfer; DevControl pushes must be acknowledged; everything else is a stateless decode. - */ override fun ingest(data: ByteArray, from: String): List { val events = mutableListOf() for (logical in assembler.append(data, from)) { val frame = YCBTFrame.validating(logical) if (frame == null) { - events.add(RingDecodedEvent.Unknown(logical.firstOrNull()?.toUByte() ?: 0u, logical)) + events.add(RingDecodedEvent.Unknown(commandId = logical.firstOrNull()?.toUByte() ?: 0u, raw = logical)) continue } - when { - frame.type == YCBTGroup.HEALTH -> - events.addAll(transfer.handle(frame.cmd, frame.payload)) - frame.type == YCBTGroup.DEV_CONTROL -> { + val decoded = when (frame.type) { + YCBTGroup.HEALTH -> { + transfer.handle(cmd = frame.cmd, payload = frame.payload) + } + YCBTGroup.DEV_CONTROL -> { acknowledgePush(frame) - events.addAll(decoder.decode(frame)) + decoder.decode(frame).also { decoded -> + // A real measurement value proves that mode's start succeeded even if its + // command reply was lost. Preserve unrelated pending correlations. + if (frame.cmd == YCBTDevControl.MEASUREMENT_STATUS && + decoded.any { it !is RingDecodedEvent.CommandAck }) { + frame.payload.firstOrNull()?.let { + pendingMeasurementReplies.discardStartedMode(it.toInt() and 0xFF) + } + } + } } - frame.type == YCBTGroup.APP_CONTROL && frame.cmd == YCBTCommand.LIVE_MEASUREMENT -> { - // The verdict on the *oldest* 03 2f still owed one. - val startedMode = if (pendingMeasurementReplies.isEmpty()) null else pendingMeasurementReplies.removeFirst() - events.addAll(decoder.decode(frame, startedMode = startedMode)) + YCBTGroup.APP_CONTROL -> if (frame.cmd == YCBTCommand.LIVE_MEASUREMENT) { + val startedMode = pendingMeasurementReplies.consume()?.startedMode + decoder.decode(frame, startedMode = startedMode) + } else { + decoder.decode(frame) } - else -> events.addAll(decoder.decode(frame)) + else -> decoder.decode(frame) } + updateCapabilities(decoded) + events.addAll(decoded.filter(::isSupported)) } return events } - /** - * The ring **retransmits an unacknowledged DevControl push** until the app answers - * `04 {00}`, so the ACK goes out before the frame is even decoded. - * - * A 1-byte `0xFB..0xFF` payload is an *error* frame, not a push — those are dropped without a - * reply, since ACKing one would answer a rejection as though it were a push the ring never sent. - */ private fun acknowledgePush(frame: YCBTFrame) { if (YCBTFrameError.detect(frame.payload) != null) return - writer?.enqueue(YCBTDevControl.ack(frame.cmd).toRawByteArray()) + writer.enqueue(YCBTDevControl.ack(key = frame.cmd)) + } + + override fun makeSyncEngine(): RingSyncEngine { + return YCBTSyncEngine(writer = writer, transfer = transfer, profile = profile).also { syncEngine = it } + } + + private fun updateCapabilities(events: List) { + val support = events.filterIsInstance().lastOrNull() ?: return + capabilities = profile.baselineCapabilities + + support.capabilities.intersect(profile.bitmapGatedCapabilities) + } + + private fun isSupported(event: RingDecodedEvent): Boolean = when (event) { + is RingDecodedEvent.BloodPressureSample -> WearableCapability.BLOOD_PRESSURE in capabilities + is RingDecodedEvent.BloodSugarSample -> WearableCapability.BLOOD_SUGAR in capabilities + is RingDecodedEvent.HrvSample -> WearableCapability.HRV in capabilities + is RingDecodedEvent.StressSample -> WearableCapability.STRESS in capabilities + is RingDecodedEvent.TemperatureSample -> WearableCapability.TEMPERATURE in capabilities + is RingDecodedEvent.HistoryMeasurement -> when (event.kind_field) { + MeasurementKind.HEART_RATE, MeasurementKind.SPO2 -> true + MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, + MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> WearableCapability.BLOOD_PRESSURE in capabilities + MeasurementKind.BLOOD_SUGAR -> WearableCapability.BLOOD_SUGAR in capabilities + MeasurementKind.HRV -> WearableCapability.HRV in capabilities + MeasurementKind.STRESS -> WearableCapability.STRESS in capabilities + MeasurementKind.FATIGUE -> WearableCapability.FATIGUE in capabilities + MeasurementKind.TEMPERATURE -> WearableCapability.TEMPERATURE in capabilities + // These have no support-function bits. Valid history values are data-gated in the UI. + MeasurementKind.RESPIRATORY_RATE, MeasurementKind.VO2MAX -> true + } + else -> true + } + + private fun handleOutOfBandEvents(events: List) { + for (event in events.filter(::isSupported)) { + syncEngine?.handle(event) + for (pulseEvent in RingEventBridge.eventsFor(event)) { + PulseEventBus.publishBlocking(pulseEvent) + } + } + } +} + +/** GATT writes and replies arrive on different threads; keep FIFO pairing atomic. */ +internal class PendingMeasurementReplies { + data class Reply(val startedMode: Int?) + + private val replies = ArrayDeque() + + @Synchronized + fun record(startedMode: Int?) { + replies.addLast(Reply(startedMode)) + if (replies.size > MAX_PENDING) replies.removeFirst() } - override fun makeSyncEngine(): RingSyncEngine = YCBTSyncEngine(writer, transfer) + @Synchronized + fun consume(): Reply? = if (replies.isEmpty()) null else replies.removeFirst() + + @Synchronized + fun discardStartedMode(mode: Int) { + val retained = ArrayDeque() + var discarded = false + while (replies.isNotEmpty()) { + val reply = replies.removeFirst() + if (!discarded && reply.startedMode == mode) { + discarded = true + } else { + retained.addLast(reply) + } + } + replies.addAll(retained) + } + + @Synchronized + fun clear() { + replies.clear() + } + + private companion object { + const val MAX_PENDING = 8 + } } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt index 2ae9018..fbabb62 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt @@ -1,118 +1,201 @@ package com.pulseloop.ring import java.time.Instant +import java.util.TimeZone /** - * Ported from YCBTEncoder.swift (iOS #82). - * Builds *logical* YCBT commands — `[type, cmd, payload...]` without the length field or CRC, - * which `YCBTDriver.frame(_)` appends. - * - * The connect handshake is **parameterized**, not a captured byte replay: it mirrors the order the - * SmartHealth app actually runs (`HomeFragment.getCompile` -> `syncSettingData`), with every - * payload built from the SDK's own definitions and the user's real settings. The Setting-group - * builders live in [YCBTSettingsEncoder], shared with the other YCBT families. + * Ported from YCBTEncoder.swift and YCBTSettingsEncoder.swift. + * Builds *logical* YCBT commands — [type, cmd, payload…] without the length field or CRC, + * which YCBTDriver.frame appends. */ -object YCBTEncoder { - /** Set the ring clock (`01 00`), including the Mon=0 weekday byte. */ - fun setTime(instant: Instant = Instant.now()): List = YCBTSettingsEncoder.setTime(instant) - - /** - * The connect handshake, in the SmartHealth app's own order: clock -> device interrogation -> - * locale -> all-day monitors -> user profile -> live-status stream. - * - * **Never add these to it** — each was once here, and each was a different kind of wrong: - * - **No `05 xx`.** The Health group is the *history* protocol: [YCBTHistoryTransfer] owns - * those queries and a stray one here would race it. Worse, `05 40..4E` are the Health - * **Delete** opcodes — they erase the ring's stored log. The five `01 xx {enable, interval}` - * monitors below are what actually makes the ring *record* between syncs. - * - **No `04 xx`.** Group 4 is DevControl, the *device->app* push channel. The app's only - * legitimate `04` write is an ACK for a push it received (`YCBTDriver.acknowledgePush`). - */ + +object YCBTSettingKey { + const val SET_TIME: Int = 0x00 + const val USER_INFO: Int = 0x03 + const val UNITS: Int = 0x04 + const val HEART_MONITOR: Int = 0x0c + const val LANGUAGE: Int = 0x12 + const val BLOOD_PRESSURE_MONITOR: Int = 0x1c + const val TEMPERATURE_MONITOR: Int = 0x20 + const val BLOOD_OXYGEN_MONITOR: Int = 0x26 + const val HRV_MONITOR: Int = 0x45 +} + +class YCBTSettingsEncoder { + companion object { + const val MINIMUM_INTERVAL_MINUTES = 30 + const val DEFAULT_INTERVAL_MINUTES = 60 + + fun clampInterval(minutes: Int): Int { + if (minutes <= 0) return DEFAULT_INTERVAL_MINUTES + return minOf(255, maxOf(MINIMUM_INTERVAL_MINUTES, minutes)) + } + } + + /** 01 00 + [year:u16 LE][month][day][hour][min][sec][weekday]. Weekday is Mon=0 … Sun=6. */ + fun setTime(date: Instant = Instant.now(), timeZone: TimeZone = TimeZone.getDefault()): ByteArray { + val calendar = java.util.Calendar.getInstance(timeZone).apply { time = java.util.Date(date.toEpochMilli()) } + val year = calendar.get(java.util.Calendar.YEAR) + val gregorianWeekday = calendar.get(java.util.Calendar.DAY_OF_WEEK) // 1=Sun, 2=Mon... + val weekday = if (gregorianWeekday == 1) 6 else gregorianWeekday - 2 + return byteArrayOf( + YCBTGroup.SETTING.toByte(), YCBTSettingKey.SET_TIME.toByte(), + (year and 0xFF).toByte(), ((year shr 8) and 0xFF).toByte(), + (calendar.get(java.util.Calendar.MONTH) + 1).toByte(), + calendar.get(java.util.Calendar.DAY_OF_MONTH).toByte(), + calendar.get(java.util.Calendar.HOUR_OF_DAY).toByte(), + calendar.get(java.util.Calendar.MINUTE).toByte(), + calendar.get(java.util.Calendar.SECOND).toByte(), + weekday.toByte(), + ) + } + + /** 01 03 + [heightCm][weightKg][sex][age]. */ + fun userInfo(profile: UserProfileValues): ByteArray { + return byteArrayOf( + YCBTGroup.SETTING.toByte(), YCBTSettingKey.USER_INFO.toByte(), + profile.heightCm.toByte(), profile.weightKg.toByte(), + (if (profile.gender == 0x01u.toUByte()) 1 else 0).toByte(), + profile.age.toByte(), + ) + } + + /** 01 04 + [distance][weight][temp][timeFormat][bloodSugar][uricAcid]. 0 = metric everywhere. */ + fun units(metric: Boolean, is24Hour: Boolean = true): ByteArray { + val imperial = if (metric) 0 else 1 + return byteArrayOf( + YCBTGroup.SETTING.toByte(), YCBTSettingKey.UNITS.toByte(), + imperial.toByte(), imperial.toByte(), imperial.toByte(), + (if (is24Hour) 0 else 1).toByte(), + 0, 0, + ) + } + + /** 01 12 + [languageCode]. */ + fun language(code: Int = 0): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.LANGUAGE.toByte(), code.toByte()) + } + + /** The five background samplers, each {enable, intervalMinutes}. */ + fun monitorCommands(settings: MeasurementSettings): List { + val interval = clampInterval(settings.hrIntervalMinutes).toByte() + return listOf( + heartMonitor(enabled = settings.hrEnabled, intervalMinutes = interval), + bloodPressureMonitor(enabled = settings.hrEnabled, intervalMinutes = interval), + temperatureMonitor(enabled = settings.temperatureEnabled, intervalMinutes = interval), + bloodOxygenMonitor(enabled = settings.spo2Enabled, intervalMinutes = interval), + hrvMonitor(enabled = settings.hrvEnabled, intervalMinutes = interval), + ) + } + + private fun heartMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.HEART_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes) + } + + private fun bloodPressureMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.BLOOD_PRESSURE_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes) + } + + private fun temperatureMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.TEMPERATURE_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes) + } + + private fun bloodOxygenMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.BLOOD_OXYGEN_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes) + } + + private fun hrvMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.HRV_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes, 0, 0, 0) + } +} + +class YCBTEncoder { + private val settings = YCBTSettingsEncoder() + + fun setTime( + date: Instant = Instant.now(), + timeZone: TimeZone = TimeZone.getDefault(), + ): ByteArray = settings.setTime(date, timeZone) + fun startupSequence( - instant: Instant = Instant.now(), measurement: MeasurementSettings = MeasurementSettings.ALL_ON_DEFAULT, - profile: UserProfileValues = UserProfileValues(metric = true, gender = 0x02u, age = 0u, heightCm = 0u, weightKg = 0u), - languageCode: UByte = 0u, + profile: UserProfileValues = UserProfileValues(metric = true, gender = 0x02u, age = 25u, heightCm = 175u, weightKg = 70u), + languageCode: Int = 0, is24Hour: Boolean = true, - ): List> { - val seq = mutableListOf>() - seq.add(setTime(instant)) - // Device interrogation. The 2-byte tags are cosmetic (the firmware ignores the payload of - // a Get) but we keep the app's exact bytes: they cost nothing and keep a byte-diff against - // a capture clean. - seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, listOf(0x47u, 0x43u))) - seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_SUPPORT_FUNCTION, listOf(0x47u, 0x46u))) - seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_CHIP_SCHEME, emptyList())) - seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_NAME, listOf(0x47u, 0x50u))) - seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_USER_CONFIG, listOf(0x43u, 0x46u))) - seq.add(YCBTSettingsEncoder.language(languageCode)) - seq.add(YCBTSettingsEncoder.units(metric = profile.metric, is24Hour = is24Hour)) - seq.addAll(YCBTSettingsEncoder.monitorCommands(measurement)) - seq.add(YCBTSettingsEncoder.userInfo(profile)) + capabilities: Set = YCBTCoordinator.capabilities, + queryChipScheme: Boolean = false, + supportsBloodPressureMonitor: Boolean = false, + ): List { + val seq = mutableListOf() + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, byteArrayOf(0x47, 0x43))) + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_SUPPORT_FUNCTION, byteArrayOf(0x47, 0x46))) + // R10M closes an otherwise healthy connection with HCI 0x13 on this informational query. + if (queryChipScheme) { + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_CHIP_SCHEME, byteArrayOf())) + } + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_USER_CONFIG, byteArrayOf(0x43, 0x46))) + seq.add(settings.language(languageCode)) + seq.add(settings.units(metric = profile.metric, is24Hour = is24Hour)) + seq.addAll(monitorCommands(measurement, capabilities, supportsBloodPressureMonitor)) + seq.add(settings.userInfo(profile)) seq.add(enableLiveStatus()) return seq } - /** Re-push the all-day monitors without the rest of the handshake (the live "Save" path). */ - fun monitorCommands(measurement: MeasurementSettings): List> = - YCBTSettingsEncoder.monitorCommands(measurement) - - /** Push the user's real height/weight/sex/age (`01 03`). */ - fun userInfo(profile: UserProfileValues): List = YCBTSettingsEncoder.userInfo(profile) - - /** Read device info (`02 00`) — battery and firmware come back in the reply. */ - fun deviceInfoRequest(): List = logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, listOf(0x47u, 0x43u)) - - /** - * Enable the ring's **live status auto-push** (`03 09 01 00 02`). Once sent, the ring streams - * `06 00` status frames (current step count / distance / calories) on be940003 continuously - * while connected. Without it the app only sees the one-time history dump, so today's live - * step count never updates. (`03 09 00 00 02` disables it.) - */ - fun enableLiveStatus(): List = logical(YCBTGroup.APP_CONTROL, YCBTCommand.LIVE_STATUS_PUSH, listOf(0x01u, 0x00u, 0x02u)) - - // MARK: - Health history - - /** Ask for one history type: `05 `, empty payload. */ - fun healthHistoryRequest(type: YCBTHistoryType): List = YCBTHealthCommand.historyRequest(type) - - /** The mandatory end-of-transfer ACK: `05 80 {00}` accepted / `{04}` CRC failure. */ - fun historyBlockAck(status: UByte): List = YCBTHealthCommand.historyBlockAck(status) - - // MARK: - Live actions - - /** - * Live measurement start/stop via `03 2f` with a `[enable:1][mode:1]` payload. The **mode byte - * selects the sensor**: 0x00 heart rate (green LED) -> `06 01` stream, 0x01 blood pressure -> - * `06 03`, 0x02 SpO2 (red/IR LED) -> `06 02`, 0x0a HRV -> `06 03`. Using the wrong mode lights - * the wrong LED and yields no reading, so each metric must use its own start. - * - * **The stop echoes its own mode** — it is not mode-agnostic. Stopping an SpO2 sweep with mode - * 0 tells the ring to stop *heart rate*, leaving the SpO2 sweep running. - */ - fun heartRateStart(): List = liveMeasurement(enable = true, mode = YCBTMeasurementMode.HEART_RATE) - fun heartRateStop(): List = liveMeasurement(enable = false, mode = YCBTMeasurementMode.HEART_RATE) - fun spo2Start(): List = liveMeasurement(enable = true, mode = YCBTMeasurementMode.SPO2) - fun spo2Stop(): List = liveMeasurement(enable = false, mode = YCBTMeasurementMode.SPO2) - fun hrvStart(): List = liveMeasurement(enable = true, mode = YCBTMeasurementMode.HRV) - fun hrvStop(): List = liveMeasurement(enable = false, mode = YCBTMeasurementMode.HRV) - fun bloodPressureStart(): List = liveMeasurement(enable = true, mode = YCBTMeasurementMode.BLOOD_PRESSURE) - fun bloodPressureStop(): List = liveMeasurement(enable = false, mode = YCBTMeasurementMode.BLOOD_PRESSURE) - - /** - * Find device — make the ring buzz (`03 00`), with the exact three payload bytes SmartHealth's - * own "find ring" button sends (`appFindDevice(1, 5, 2)`). - * - * **UNVERIFIED:** the SDK never names those three arguments, so replaying the app's literal - * values is the only way to be sure of the ring's response. - */ - fun findDevice(): List = logical(YCBTGroup.APP_CONTROL, YCBTCommand.FIND_DEVICE, listOf(0x01u, 0x05u, 0x02u)) - - // MARK: - Helpers - - private fun liveMeasurement(enable: Boolean, mode: UByte): List = - logical(YCBTGroup.APP_CONTROL, YCBTCommand.LIVE_MEASUREMENT, listOf(if (enable) 1u else 0u, mode)) - - private fun logical(group: UByte, cmd: UByte, payload: List): List = - listOf(group, cmd) + payload + /** Exact current SmartHealth sequence immediately after both indication CCCDs complete. */ + fun postSubscriptionHandshake(date: Instant = Instant.now()): List = + listOf(deviceNameRequest(), setTime(date)) + + fun monitorCommands( + measurement: MeasurementSettings, + capabilities: Set = YCBTCoordinator.capabilities, + supportsBloodPressureMonitor: Boolean = false, + ): List = settings.monitorCommands(measurement).filter { command -> + when (command[1].toInt() and 0xFF) { + YCBTSettingKey.HEART_MONITOR -> WearableCapability.HEART_RATE in capabilities + YCBTSettingKey.BLOOD_PRESSURE_MONITOR -> + supportsBloodPressureMonitor && WearableCapability.BLOOD_PRESSURE in capabilities + YCBTSettingKey.TEMPERATURE_MONITOR -> WearableCapability.TEMPERATURE in capabilities + YCBTSettingKey.BLOOD_OXYGEN_MONITOR -> WearableCapability.SPO2 in capabilities + YCBTSettingKey.HRV_MONITOR -> WearableCapability.HRV in capabilities + else -> false + } + } + + fun userInfo(profile: UserProfileValues): ByteArray = settings.userInfo(profile) + + fun deviceInfoRequest(): ByteArray = + logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, byteArrayOf(0x47, 0x43)) + + fun deviceNameRequest(): ByteArray = + logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_NAME, byteArrayOf(0x47, 0x50)) + + fun enableLiveStatus(): ByteArray = + logical(YCBTGroup.APP_CONTROL, YCBTCommand.LIVE_STATUS_PUSH, byteArrayOf(0x01, 0x00, 0x02)) + + fun healthHistoryRequest(type: YCBTHistoryType): ByteArray = + YCBTHealthCommand.historyRequest(type) + + fun historyBlockAck(status: Int): ByteArray = + YCBTHealthCommand.historyBlockAck(status) + + fun heartRateStart(): ByteArray = liveMeasurement(enable = true, mode = YCBTMeasurementMode.HEART_RATE) + fun heartRateStop(): ByteArray = liveMeasurement(enable = false, mode = YCBTMeasurementMode.HEART_RATE) + fun spo2Start(): ByteArray = liveMeasurement(enable = true, mode = YCBTMeasurementMode.SPO2) + fun spo2Stop(): ByteArray = liveMeasurement(enable = false, mode = YCBTMeasurementMode.SPO2) + fun hrvStart(): ByteArray = liveMeasurement(enable = true, mode = YCBTMeasurementMode.HRV) + fun hrvStop(): ByteArray = liveMeasurement(enable = false, mode = YCBTMeasurementMode.HRV) + fun bloodPressureStart(): ByteArray = liveMeasurement(enable = true, mode = YCBTMeasurementMode.BLOOD_PRESSURE) + fun bloodPressureStop(): ByteArray = liveMeasurement(enable = false, mode = YCBTMeasurementMode.BLOOD_PRESSURE) + + fun findDevice(): ByteArray = + logical(YCBTGroup.APP_CONTROL, YCBTCommand.FIND_DEVICE, byteArrayOf(0x01, 0x05, 0x02)) + + private fun liveMeasurement(enable: Boolean, mode: Int): ByteArray { + return logical(YCBTGroup.APP_CONTROL, YCBTCommand.LIVE_MEASUREMENT, byteArrayOf(if (enable) 1 else 0, mode.toByte())) + } + + private fun logical(group: Int, cmd: Int, payload: ByteArray): ByteArray { + return byteArrayOf(group.toByte(), cmd.toByte()) + payload + } } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt index 89a3bc3..28943cb 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt @@ -1,180 +1,172 @@ package com.pulseloop.ring +import java.time.Instant + /** - * Ported from YCBTHealthRecords.swift (iOS #82). - * Pure buffer->events decoders for the YCBT health-history record types. - * - * **These run over the fully reassembled transfer buffer, never over a single frame.** The ring - * concatenates fixed-size records and then chops the stream at frame boundaries wherever they - * happen to fall, so a record routinely straddles two data frames. - * - * **Layering:** a decoder here only drops the ring's *"no sample"* fillers (a zero value in a slot - * the firmware never leaves blank when it has a reading). Plausibility ranges live in exactly one - * place, [RingEventBridge]. + * Ported from YCBTHealthRecords.swift. + * Pure buffer→events decoders for the YCBT health-history record types. */ + object YCBTHealthRecords { - /** Decode a completed transfer. The stride comes from the same [YCBTHistoryType] table the - * transfer machine drives the queue from, so the two cannot disagree. */ - fun decode(buffer: List, type: YCBTHistoryType): List = when (type) { - YCBTHistoryType.SPORT -> sport(buffer) - YCBTHistoryType.SLEEP -> sleep(buffer) - YCBTHistoryType.HEART -> heartRate(buffer) - YCBTHistoryType.BLOOD -> bloodPressure(buffer) - YCBTHistoryType.ALL -> combinedVitals(buffer) - YCBTHistoryType.SPO2 -> spo2(buffer) - YCBTHistoryType.TEMPERATURE -> temperature(buffer) - YCBTHistoryType.COMPREHENSIVE -> comprehensive(buffer) - YCBTHistoryType.BODY_DATA -> bodyData(buffer) - else -> emptyList() + private const val TEMPERATURE_FILLER: Int = 15 + private const val MAX_SLEEP_SESSION_MINUTES = 24 * 60 + + fun decode(buffer: ByteArray, type: YCBTHistoryType): List { + return when (type) { + YCBTHistoryType.SPORT -> sport(buffer) + YCBTHistoryType.SLEEP -> sleep(buffer) + YCBTHistoryType.HEART -> heartRate(buffer) + YCBTHistoryType.BLOOD -> bloodPressure(buffer) + YCBTHistoryType.ALL -> combinedVitals(buffer) + YCBTHistoryType.SPO2 -> spo2(buffer) + YCBTHistoryType.TEMPERATURE -> temperature(buffer) + YCBTHistoryType.COMPREHENSIVE -> comprehensive(buffer) + YCBTHistoryType.BODY_DATA -> bodyData(buffer) + else -> emptyList() + } } - // MARK: - Sport (query 0x02, 14-byte records) - - /** - * `[start:u32][end:u32][steps:u16@8][distanceMeters:u16@10][calories:u16@12]`. - * - * These are **interval** buckets (each covers start->end), not a running total, so they ride - * [RingDecodedEvent.ActivityBucket]: upsert by start epoch, day total = sum of distinct - * buckets. The All record's step field is the opposite (a cumulative daily counter -> - * [RingDecodedEvent.ActivityUpdate], a per-day max ratchet); the queue asks for sport *before* - * all, so the cumulative counter always has the last word on a day's total. - * - * Calories are deliberately dropped: [RingDecodedEvent.ActivityBucket] has no calorie channel. - */ - fun sport(buffer: List): List = records(buffer, 14).mapNotNull { r -> - val steps = YCBTBytes.u16(r, 8) - val distance = YCBTBytes.u16(r, 10) - if (steps <= 0 && distance <= 0) null - else RingDecodedEvent.ActivityBucket(YCBTBytes.date(YCBTBytes.u32(r, 0)), steps, distance) + // MARK: Sport (query 0x02, 14-byte records) + + fun sport(buffer: ByteArray): List { + return records(buffer, 14).mapNotNull { r -> + val steps = YCBTBytes.u16(r, 8) + val distance = YCBTBytes.u16(r, 10) + if (steps <= 0 && distance <= 0) return@mapNotNull null + RingDecodedEvent.ActivityBucket( + _timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0)), + steps = steps, + distanceMeters = distance, + ) + } } - // MARK: - Heart rate (query 0x06, 6-byte records) - - /** `[ts:u32][mode:1][hr:1]`. `hr == 0` is an unworn sample, not a reading. */ - fun heartRate(buffer: List): List = records(buffer, 6).mapNotNull { r -> - val hr = r[5] - if (hr <= 0u) null - else RingDecodedEvent.HistoryMeasurement(MeasurementKind.HEART_RATE, hr.toDouble(), YCBTBytes.date(YCBTBytes.u32(r, 0))) + // MARK: Heart rate (query 0x06, 6-byte records) + + fun heartRate(buffer: ByteArray): List { + return records(buffer, 6).mapNotNull { r -> + val hr = r[5].toInt() and 0xFF + if (hr == 0) return@mapNotNull null + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.HEART_RATE, + value = hr.toDouble(), + _timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0)), + ) + } } - // MARK: - Blood pressure (query 0x08, 8-byte records) + // MARK: Blood pressure (query 0x08, 8-byte records) - /** - * `[ts:u32][isInflated@4][systolic@5][diastolic@6][heartRate@7]`. - * - * `isInflated` flags the ring's own cuff-style sweep; it doesn't gate validity, so it isn't read. - */ - fun bloodPressure(buffer: List): List { + fun bloodPressure(buffer: ByteArray): List { val events = mutableListOf() for (r in records(buffer, 8)) { val ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) - events.addAll(bloodPressureEvents(r[5], r[6], ts)) - if (r[7] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.HEART_RATE, r[7].toDouble(), ts)) + events.addAll(bloodPressureEvents(systolic = r[5].toInt() and 0xFF, diastolic = r[6].toInt() and 0xFF, timestamp = ts)) + if (r[7].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.HEART_RATE, + value = (r[7].toInt() and 0xFF).toDouble(), + _timestamp = ts, + )) + } } return events } - // MARK: - Combined vitals (query 0x09, 20-byte records) - - /** - * The ring's per-interval "All" record: - * `[ts:u32][steps:u16@4][hr@6][sys@7][dia@8][spo2@9][resp@10][hrv@11][cvrr@12][tempInt@13]` - * `[tempFrac@14][bodyFatInt@15][bodyFatFrac@16][bloodSugar@17]`. - * - * HR at @6 is deliberately not emitted: the paired heart-rate history carries the same samples - * at the same epochs. Body fat at @15-16 has no [MeasurementKind] and is skipped. cvrr @12 - * likewise. - * - * Steps are a **cumulative daily counter**, so they go out as [RingDecodedEvent.ActivityUpdate] - * (a per-day max ratchet) — distance/calories are zeroed so max() leaves live-status values - * intact. - */ - fun combinedVitals(buffer: List): List { + // MARK: Combined vitals (query 0x09, 20-byte records) + + fun combinedVitals(buffer: ByteArray): List { val events = mutableListOf() for (r in records(buffer, 20)) { val ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) - events.add(RingDecodedEvent.ActivityUpdate(ts, YCBTBytes.u16(r, 4), 0, 0)) - events.addAll(bloodPressureEvents(r[7], r[8], ts)) - if (r[9] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.SPO2, r[9].toDouble(), ts)) - if (r[10] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.RESPIRATORY_RATE, r[10].toDouble(), ts)) - if (r[11] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.HRV, r[11].toDouble(), ts)) - events.addAll(temperatureEvents(r[13], r[14], ts)) - if (r[17] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, bloodSugarMgdl(r[17].toInt()), ts)) + // 0x09 is vitals history, not the activity source of truth. Its adjacent step field + // can lag/reset differently and arrives late in the refresh pipeline; routing it as + // a live cumulative ActivityUpdate made today's steps jump to stale values. Activity + // comes from 0x02 sport buckets plus 0x06/00 live status instead. + events.addAll(bloodPressureEvents(systolic = r[7].toInt() and 0xFF, diastolic = r[8].toInt() and 0xFF, timestamp = ts)) + if (r[9].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.SPO2, value = (r[9].toInt() and 0xFF).toDouble(), _timestamp = ts)) + } + if (r[10].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.RESPIRATORY_RATE, value = (r[10].toInt() and 0xFF).toDouble(), _timestamp = ts)) + } + if (r[11].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.HRV, value = (r[11].toInt() and 0xFF).toDouble(), _timestamp = ts)) + } + events.addAll(temperatureEvents(integer = r[13].toInt() and 0xFF, fraction = r[14].toInt() and 0xFF, timestamp = ts)) + if (r[17].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.BLOOD_SUGAR, + value = bloodSugarMgdl(r[17].toInt() and 0xFF), + _timestamp = ts, + )) + } } return events } - // MARK: - SpO2 (query 0x1A, 6-byte records) + // MARK: SpO₂ (query 0x1A, 6-byte records) - /** `[ts:u32][type@4][value@5]`. `type` distinguishes automatic all-day sampling from a spot reading. */ - fun spo2(buffer: List): List = records(buffer, 6).mapNotNull { r -> - if (r[5] <= 0u) null - else RingDecodedEvent.HistoryMeasurement(MeasurementKind.SPO2, r[5].toDouble(), YCBTBytes.date(YCBTBytes.u32(r, 0))) + fun spo2(buffer: ByteArray): List { + return records(buffer, 6).mapNotNull { r -> + if (r[5].toInt() and 0xFF == 0) return@mapNotNull null + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.SPO2, + value = (r[5].toInt() and 0xFF).toDouble(), + _timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0)), + ) + } } - // MARK: - Temperature (query 0x1E, 7-byte records) + // MARK: Temperature (query 0x1E, 7-byte records) - /** `[ts:u32][type@4][int@5][frac@6]` — advances 7 bytes per record. Value is `int.frac` (°C). */ - fun temperature(buffer: List): List = records(buffer, 7).flatMap { r -> - temperatureEvents(r[5], r[6], YCBTBytes.date(YCBTBytes.u32(r, 0))) + fun temperature(buffer: ByteArray): List { + return records(buffer, 7).flatMap { r -> + temperatureEvents(integer = r[5].toInt() and 0xFF, fraction = r[6].toInt() and 0xFF, timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0))) + } } - // MARK: - Comprehensive (query 0x2F, 44-byte records) - - /** - * The ring's "lab panel" sweep. Only blood sugar is decoded — - * `[ts:u32][bloodSugarModel@4][int@5][frac@6]`. Uric acid, ketones and the lipid fractions have - * no [MeasurementKind], so they are left on the floor. - */ - fun comprehensive(buffer: List): List = records(buffer, 44).mapNotNull { r -> - val tenths = r[5].toInt() * 10 + r[6].toInt() - if (tenths <= 0) null - else RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, bloodSugarMgdl(tenths), YCBTBytes.date(YCBTBytes.u32(r, 0))) + // MARK: Comprehensive (query 0x2F, 44-byte records) + + fun comprehensive(buffer: ByteArray): List { + return records(buffer, 44).mapNotNull { r -> + val tenths = (r[5].toInt() and 0xFF) * 10 + (r[6].toInt() and 0xFF) + if (tenths <= 0) return@mapNotNull null + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.BLOOD_SUGAR, + value = bloodSugarMgdl(tenths), + _timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0)), + ) + } } - // MARK: - Body data (query 0x33, 28-byte records) - - /** - * `[ts:u32][loadIdx i/f@4-5][hrv i/f@6-7][pressure i/f@8-9][body i/f@10-11][sympathetic - * i/f@12-13][sdnn:u16@14][vo2max@16][pnn50@17][rmssd:u16@18][lf:u16@20][hf:u16@22][lfHf@24]`. - * - * The SDK's `pressure` is the **stress** score and `body` the **fatigue** score. Those two - * scores go through [score] (digit-concatenated, the app's 1..100 scale) while HRV goes through - * [composite] (milliseconds). - */ - fun bodyData(buffer: List): List { + // MARK: Body data (query 0x33, 28-byte records) + + fun bodyData(buffer: ByteArray): List { val events = mutableListOf() for (r in records(buffer, 28)) { val ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) - if (r[6] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.HRV, composite(r[6], r[7]), ts)) - if (r[8] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.STRESS, score(r[8], r[9]), ts)) - if (r[10] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.FATIGUE, score(r[10], r[11]), ts)) - if (r.size > 16 && r[16] > 0u) events.add(RingDecodedEvent.HistoryMeasurement(MeasurementKind.VO2MAX, r[16].toDouble(), ts)) + if (r[6].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.HRV, value = composite(r[6].toInt() and 0xFF, r[7].toInt() and 0xFF), _timestamp = ts)) + } + if (r[8].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.STRESS, value = score(r[8].toInt() and 0xFF, r[9].toInt() and 0xFF), _timestamp = ts)) + } + if (r[10].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.FATIGUE, value = score(r[10].toInt() and 0xFF, r[11].toInt() and 0xFF), _timestamp = ts)) + } + if (r.size > 16 && r[16].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.VO2MAX, value = (r[16].toInt() and 0xFF).toDouble(), _timestamp = ts)) + } } return events } - // MARK: - Sleep (query 0x04, variable-length sessions) - - /** - * Sleep is the one variable-length type. The buffer holds **back-to-back sessions**, each a - * 20-byte header followed by 8-byte stage segments: - * - * header: `[flags:2][recordLen:u16@2][start:u32@4][end:u32@8][counts/totals@12..19]` - * segment: `[tag:1][segStart:u32 LE][len:u24 LE]` - * - * Stage classification is `tag and 0x0F`: 1 deep, 2 light, 3 REM, 4 awake, 5 nap. An unknown - * tag must be skipped, never terminal — breaking out of the loop on one lets a single nap - * segment truncate the rest of the night. - * - * Segments are also **deduplicated by start time within the session** — some firmware repeats - * a segment inside one session, and because the timeline is laid out positionally from the - * session start, a repeat would both inflate that stage's minutes and shift every later block. - */ - fun sleep(buffer: List): List { + // MARK: Sleep (variable-length sessions) + + fun sleep(buffer: ByteArray): List { val headerLength = 20 val segmentLength = 8 - val events = mutableListOf() var cursor = 0 while (cursor + headerLength <= buffer.size) { @@ -185,103 +177,89 @@ object YCBTHealthRecords { val segmentCount = minOf(declared, available) val stages = mutableListOf() - var sessionStart: java.time.Instant? = null + var sessionStart: Instant? = null val seenStarts = mutableSetOf() for (index in 0 until segmentCount) { val offset = segmentsStart + index * segmentLength - val stage = sleepStage(buffer[offset]) ?: continue // e.g. padding — skip, don't stop - val segmentStart = YCBTBytes.u32(buffer, offset + 1).toInt() - if (!seenStarts.add(segmentStart)) continue // firmware repeat — count once + val stage = sleepStage(buffer[offset].toInt() and 0xFF) ?: continue + val segmentStart = YCBTBytes.u32(buffer, offset + 1) + if (!seenStarts.add(segmentStart)) continue val segmentSeconds = YCBTBytes.u24(buffer, offset + 5) - if (sessionStart == null) sessionStart = YCBTBytes.date(segmentStart.toLong()) - val minutes = Math.round(segmentSeconds / 60.0).toInt() - repeat(maxOf(1, minutes)) { stages.add(stage) } + if (sessionStart == null) sessionStart = YCBTBytes.date(segmentStart) + val remaining = MAX_SLEEP_SESSION_MINUTES - stages.size + if (remaining <= 0) break + val minutes = kotlin.math.round(segmentSeconds / 60.0).toInt().coerceIn(1, remaining) + repeat(minutes) { stages.add(stage) } } - - val start = sessionStart - if (start != null && stages.isNotEmpty()) { - events.add(RingDecodedEvent.SleepTimeline(start, stages)) + if (sessionStart != null && stages.isNotEmpty()) { + events.add( + RingDecodedEvent.SleepTimeline( + _timestamp = sessionStart, + stages = stages, + completeSession = true, + ) + ) } - // Advance to the end of the segments consumed — a bogus recordLen still moves the - // cursor by at least the header, so this can't spin. cursor = segmentsStart + segmentCount * segmentLength } return events } - /** `tag and 0x0F` -> shared stage. 5 = nap/daytime sleep, which has no dedicated bucket. */ - private fun sleepStage(tag: UByte): SleepStage? = when ((tag.toInt() and 0x0f)) { - 1 -> SleepStage.DEEP - 2 -> SleepStage.LIGHT - 3 -> SleepStage.REM - 4 -> SleepStage.AWAKE - 5 -> SleepStage.UNKNOWN - else -> null + private fun sleepStage(tag: Int): SleepStage? { + return when (tag and 0x0f) { + 1 -> SleepStage.DEEP + 2 -> SleepStage.LIGHT + 3 -> SleepStage.REM + 4 -> SleepStage.AWAKE + 5 -> SleepStage.UNKNOWN + else -> null + } } - // MARK: - Shared field decoding + // MARK: Shared field decoding - /** Systolic/diastolic as two upserting history rows. Both bytes are zero on a record the ring - * never ran a BP sweep for; the plausible *range* is the bridge's business, not ours. */ - private fun bloodPressureEvents(systolic: UByte, diastolic: UByte, timestamp: java.time.Instant): List { - if (systolic <= 0u || diastolic <= 0u) return emptyList() + private fun bloodPressureEvents(systolic: Int, diastolic: Int, timestamp: Instant): List { + if (systolic <= 0 || diastolic <= 0) return emptyList() return listOf( - RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, systolic.toDouble(), timestamp), - RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, diastolic.toDouble(), timestamp), + RingDecodedEvent.BloodPressureSample( + systolic = systolic, + diastolic = diastolic, + _timestamp = timestamp, + isHistory = true, + ), ) } - /** The ring's "no temperature sample" fraction marker — a sentinel, not a fraction that happens to be 15. */ - private const val TEMPERATURE_FILLER: Int = 15 + private fun temperatureEvents(integer: Int, fraction: Int, timestamp: Instant): List { + if (integer <= 0 || fraction == TEMPERATURE_FILLER) return emptyList() + return listOf(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.TEMPERATURE, value = composite(integer, fraction), _timestamp = timestamp)) + } - /** - * Temperature from an int/fraction pair, shared by the dedicated record and the All record. - * Two fillers, not one: `int = 0, frac = 15` AND `int = 36, frac = 15` are both the "never - * measured" marker — 36.15 C would otherwise sail through the plausibility gate and be - * upserted on every future sync (the ring replays its whole log). - */ - private fun temperatureEvents(integer: UByte, fraction: UByte, timestamp: java.time.Instant): List { - if (integer <= 0u || fraction.toInt() == TEMPERATURE_FILLER) return emptyList() - return listOf(RingDecodedEvent.HistoryMeasurement(MeasurementKind.TEMPERATURE, composite(integer, fraction), timestamp)) + /** String-concatenated composite: integer and fraction digits concatenated with a decimal point. */ + fun composite(integer: Int, fraction: Int): Double { + return "$integer.$fraction".toDoubleOrNull() ?: integer.toDouble() } - /** - * The SDK never adds an integer and its fraction *numerically* — it **string-concatenates** - * them (`int.frac`). The fraction's scale is therefore implied by its digit count: 5 -> .5, - * 50 -> .5, 25 -> .25. - */ - fun composite(integer: UByte, fraction: UByte): Double = - "${integer}.${fraction}".toDoubleOrNull() ?: integer.toDouble() - - /** - * Stress / fatigue are the one pair that is **not** the decimal composite. The ring scores - * them 0-10 with one decimal, and the app displays that x10 on a 1..100 scale: bytes `(5, 3)` - * are the **53** the app puts on screen, not 5.3. - * - * HRV in the same record is deliberately *not* one of these: it is milliseconds, so it keeps - * the decimal composite (`45, 6` -> 45.6 ms, not 456). - */ - fun score(integer: UByte, fraction: UByte): Double = - "$integer$fraction".toDoubleOrNull() ?: integer.toDouble() - - /** mg/dL per mmol/L — the standard glucose molar-mass factor. */ + /** UNVERIFIED: digit-concatenated score inferred for stress/fatigue on a 1…100 scale. */ + fun score(integer: Int, fraction: Int): Double { + return "$integer$fraction".toDoubleOrNull() ?: integer.toDouble() + } + + // UNVERIFIED: hardware payloads look like tenths of mmol/L; no vendor ground truth yet. const val MGDL_PER_MMOL = 18.016 - /** - * Blood sugar arrives as **tenths of a mmol/L**, not whole mmol/L. PulseLoop persists - * `.bloodSugar` in mg/dL, hence the conversion. **UNVERIFIED on hardware.** - */ - fun bloodSugarMgdl(tenthsOfMmol: Int): Double = tenthsOfMmol / 10.0 * MGDL_PER_MMOL + fun bloodSugarMgdl(tenthsOfMmol: Int): Double { + return tenthsOfMmol / 10.0 * MGDL_PER_MMOL + } - // MARK: - Helpers + // MARK: Helpers - /** Slice the reassembled buffer into fixed-size records, dropping a short trailing remainder. */ - private fun records(buffer: List, size: Int): List> { + private fun records(buffer: ByteArray, size: Int): List { if (size <= 0) return emptyList() - val out = mutableListOf>() + val out = mutableListOf() var i = 0 while (i + size <= buffer.size) { - out.add(buffer.subList(i, i + size)) + out.add(buffer.copyOfRange(i, i + size)) i += size } return out diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt index cae82d4..2af2ea9 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt @@ -1,122 +1,114 @@ package com.pulseloop.ring -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* +import java.time.Instant /** - * Ported from YCBTHistoryTransfer.swift (iOS #82). + * Ported from YCBTHistoryTransfer.swift. * The YCBT history state machine — protocol-driven, not timer-driven. - * - * Per type the ring answers a `05 ` request with: - * header `05 ` payload >= 10 -> `[recordCount:u16][totalPackets:u32][totalBytes:u32]` - * payload <= 9 -> nothing stored for this type - * data `05 ` N frames whose payloads **concatenate** into one buffer - * terminal `05 80` `[totalPackets:u16][totalBytes:u16][crc16:u16]` over that buffer - * - * and then **waits for an ACK** (`05 80 {00}` accepted / `{04}` CRC failure) before releasing the - * next type. The ring does not release the next type until it arrives, so we ACK before we parse - * — a slow decode can't stall the ring. - * - * **Completion is the ring's terminal block, never a timer.** The watchdog below is a *safety net - * only*: it never ACKs (an ACK without a verified terminal block claims data we don't hold) and is - * never a completion signal — it just abandons a type the ring has gone silent on. */ + class YCBTHistoryTransfer( private val writer: RingCommandWriter?, - private val inactivityMs: Long = 10_000, - private val absoluteCapMs: Long = 30_000, -) { - private sealed class State { - object Idle : State() - /** Query written; waiting for the header (or a "no data" / error reply). */ - data class RequestSent(val historyType: YCBTHistoryType) : State() - /** Header seen; accumulating data frames until the terminal block. */ - data class Receiving(val historyType: YCBTHistoryType) : State() - - val type: YCBTHistoryType? get() = when (this) { - is Idle -> null - is RequestSent -> historyType - is Receiving -> historyType + private val inactivitySeconds: Double = 10.0, + private val absoluteCapSeconds: Double = 30.0, + private val onOutOfBandEvents: (List) -> Unit = { events -> + for (event in events) { + for (pulseEvent in RingEventBridge.eventsFor(event)) { + PulseEventBus.publishBlocking(pulseEvent) + } } - } - + }, +) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private var state: State = State.Idle - private val queue = mutableListOf() - private var buffer = mutableListOf() - /** A CRC mismatch buys the type exactly one re-request; a second failure gives up on it. */ + private enum class State { + IDLE, REQUEST_SENT, RECEIVING + } + + private var state = State.IDLE + private var currentType: YCBTHistoryType? = null + private var queue: MutableList = mutableListOf() + private var buffer: ByteArray = ByteArray(0) private var retriedCurrentType = false - /** Types the firmware answered `0xFB`/`0xFC` for — never asked again this session. */ - private val unsupported = mutableSetOf() - - private val defaultBufferCap = 64 * 1024 - private var bufferCap = defaultBufferCap - - // MARK: - Driving the queue - - /** True while a type is being requested or received. */ - val isActive: Boolean get() = state != State.Idle - - /** - * Seed the queue and request the first type. Types the ring already rejected this session are - * skipped. - * - * **A transfer already in flight wins.** There are multiple callers (the connect handshake, - * the post-workout vitals backfill, the periodic pass), and a second `start` would abandon the - * in-flight type mid-dump: the ring keeps streaming its data frames regardless, so they would - * land in the *new* type's buffer and fail its terminal CRC. - */ + private var unsupported: MutableSet = mutableSetOf() + private var bufferCap = DEFAULT_BUFFER_CAP + private var expectedPackets: Int? = null + private var expectedBytes: Int? = null + private var watchdogJob: Job? = null + private var typeDeadline: Long? = null + + companion object { + private const val DEFAULT_BUFFER_CAP = 64 * 1024 + private const val MAX_BUFFER_CAP = 512 * 1024 + } + + @get:Synchronized + val isActive: Boolean get() = state != State.IDLE + + @Synchronized fun start(types: List) { if (isActive) return - queue.clear() - queue.addAll(types.filter { !unsupported.contains(it.queryKey) }) - publishOutOfBand(advance()) + queue = types.filter { !unsupported.contains(it.queryKey) }.toMutableList() + advance() } - /** Abandon any in-flight transfer (disconnect / teardown). */ + /** Add newly discovered capability-gated types without disturbing an active block. */ + @Synchronized + fun append(types: List) { + val additions = types.distinct().filter { type -> + !unsupported.contains(type.queryKey) && type != currentType && type !in queue + } + if (additions.isEmpty()) return + if (state == State.IDLE) { + queue = additions.toMutableList() + advance() + } else { + queue.addAll(additions) + } + } + + @Synchronized fun cancel() { cancelWatchdog() - state = State.Idle + state = State.IDLE + currentType = null queue.clear() - buffer.clear() + buffer = ByteArray(0) } - /** Request the next type, or report completion when the queue drains. */ private fun advance(): List { cancelWatchdog() - buffer = mutableListOf() - bufferCap = defaultBufferCap + buffer = ByteArray(0) + expectedPackets = null + expectedBytes = null + bufferCap = DEFAULT_BUFFER_CAP retriedCurrentType = false if (queue.isEmpty()) { - state = State.Idle + state = State.IDLE + currentType = null return listOf(RingDecodedEvent.HistorySyncFinished) } - sendQuery(queue.removeAt(0)) + val next = queue.removeAt(0) + sendQuery(next) return emptyList() } - /** Write `05 ` and arm the stall watchdog. Also used for the single CRC retry. */ private fun sendQuery(type: YCBTHistoryType) { - state = State.RequestSent(type) - typeDeadlineAtMs = System.currentTimeMillis() + absoluteCapMs - writer?.enqueue(YCBTHealthCommand.historyRequest(type).toRawByteArray()) + state = State.REQUEST_SENT + currentType = type + typeDeadline = System.currentTimeMillis() + (absoluteCapSeconds * 1000).toLong() + writer?.enqueue(YCBTHealthCommand.historyRequest(type)) armWatchdog(type) } - // MARK: - Inbound + /** Feed every validated Health-group (type == 0x05) frame here. */ + @Synchronized + fun handle(cmd: Int, payload: ByteArray): List { + if (state == State.IDLE) return emptyList() + val type = currentType ?: return emptyList() - /** Feed every validated Health-group (`type == 0x05`) frame here. */ - fun handle(cmd: UByte, payload: List): List { - val type = state.type ?: return emptyList() - - // A 1-byte 0xFB..0xFF payload is a rejection, not data. - val error = YCBTFrameError.detect(payload) - if (error != null) { + YCBTFrameError.detect(payload)?.let { error -> if (error.isPermanent) unsupported.add(type.queryKey) return advance() } @@ -129,85 +121,89 @@ class YCBTHistoryTransfer( emptyList() } YCBTHealth.TERMINAL_BLOCK -> handleTerminal(type, payload) - else -> emptyList() // a frame for some other type — not ours to interpret + else -> emptyList() } } - /** Header: `[recordCount:u16][totalPackets:u32][totalBytes:u32]`. <= 9 bytes = "no stored data". */ - private fun handleHeader(type: YCBTHistoryType, payload: List): List { + private fun handleHeader(type: YCBTHistoryType, payload: ByteArray): List { if (payload.size < YCBTHealth.HEADER_PAYLOAD_LENGTH) return advance() + expectedPackets = YCBTBytes.u16(payload, 2) val totalBytes = YCBTBytes.u32(payload, 6) - buffer = mutableListOf() - bufferCap = maxOf(totalBytes.toInt(), defaultBufferCap) - state = State.Receiving(type) + expectedBytes = totalBytes + buffer = ByteArray(0) + bufferCap = totalBytes.coerceIn(0, MAX_BUFFER_CAP) + state = State.RECEIVING armWatchdog(type) - return listOf(RingDecodedEvent.HistorySyncProgress("Syncing ${type.label}...")) + return listOf(RingDecodedEvent.HistorySyncProgress(stage = "Syncing ${type.label}…")) } - /** Data frames concatenate. Accepted even if the header was missed — the terminal CRC is the - * real integrity check, and it will fail us into the retry path rather than persisting a - * misaligned buffer. */ - private fun appendData(payload: List) { - if (buffer.size + payload.size > bufferCap) return - buffer.addAll(payload) + private fun appendData(payload: ByteArray) { + if (buffer.size + payload.size <= bufferCap) { + buffer += payload + } } - /** Terminal: verify the CRC16 over everything accumulated, ACK, then decode. Order matters — - * we ACK first, and the ring gates the next type on it. */ - private fun handleTerminal(type: YCBTHistoryType, payload: List): List { - if (state is State.RequestSent && buffer.isEmpty()) return emptyList() + private fun handleTerminal(type: YCBTHistoryType, payload: ByteArray): List { + if (state == State.REQUEST_SENT && buffer.isEmpty()) return emptyList() if (payload.size < YCBTHealth.TERMINAL_PAYLOAD_LENGTH) return advance() + val packets = YCBTBytes.u16(payload, 0) + val bytes = YCBTBytes.u16(payload, 2) + val terminalMatchesHeader = packets == expectedPackets && bytes == expectedBytes + val terminalMatchesBuffer = bytes == buffer.size + if (!terminalMatchesHeader || !terminalMatchesBuffer) { + writer?.enqueue(YCBTHealthCommand.historyBlockAck(status = YCBTHealth.ACK_CRC_FAILURE)) + return retryOrSkip(type) + } val expected = YCBTBytes.u16(payload, 4) val matches = YCBTFrame.crc16(buffer) == expected - writer?.enqueue( - YCBTHealthCommand.historyBlockAck(if (matches) YCBTHealth.ACK_ACCEPTED else YCBTHealth.ACK_CRC_FAILURE).toRawByteArray() - ) + writer?.enqueue(YCBTHealthCommand.historyBlockAck(status = if (matches) YCBTHealth.ACK_ACCEPTED else YCBTHealth.ACK_CRC_FAILURE)) if (!matches) return retryOrSkip(type) return YCBTHealthRecords.decode(buffer, type) + advance() } - /** One re-request per type on a corrupt transfer; if that also fails, drop the type. */ private fun retryOrSkip(type: YCBTHistoryType): List { if (retriedCurrentType) return advance() retriedCurrentType = true - buffer = mutableListOf() + buffer = ByteArray(0) sendQuery(type) return emptyList() } - // MARK: - Stall watchdog (safety net) + // MARK: Stall watchdog (safety net) - private var watchdogJob: Job? = null - private var typeDeadlineAtMs: Long? = null - - /** Fires only on silence: the type is declared stalled and skipped. Must never ACK and must - * never stand in for completion. */ private fun armWatchdog(type: YCBTHistoryType) { watchdogJob?.cancel() - val deadline = typeDeadlineAtMs ?: (System.currentTimeMillis() + absoluteCapMs) - val fireAt = minOf(System.currentTimeMillis() + inactivityMs, deadline) + val deadline = typeDeadline ?: (System.currentTimeMillis() + (absoluteCapSeconds * 1000).toLong()) + val fireAt = minOf(System.currentTimeMillis() + (inactivitySeconds * 1000).toLong(), deadline) val delayMs = maxOf(0, fireAt - System.currentTimeMillis()) watchdogJob = scope.launch { delay(delayMs) - if (state.type == type) { - publishOutOfBand(advance()) - } + watchdogFired(type) + } + } + + private fun watchdogFired(type: YCBTHistoryType) { + // Advance the state machine under the lock, then publish OUTSIDE it. `onOutOfBandEvents` + // re-enters the sync engine (which in turn calls back into this transfer's `append`), so + // holding the transfer monitor while publishing inverts the engine→transfer lock order the + // GATT-callback path uses and can deadlock. The `handle()` path already returns its events + // to the caller for exactly this reason; the watchdog path must do the same. + val events = synchronized(this) { + if (state == State.IDLE || currentType != type) return + advance() } + publishOutOfBand(events) } private fun cancelWatchdog() { watchdogJob?.cancel() watchdogJob = null - typeDeadlineAtMs = null + typeDeadline = null } - /** `handle` returns its events to the driver, which publishes them. `start` and the watchdog - * have no such return channel, so the one event they can produce — completion — is published - * here. */ private fun publishOutOfBand(events: List) { - if (events.none { it is RingDecodedEvent.HistorySyncFinished }) return - PulseEventBus.publishBlocking(PulseEvent.SyncProgress("done")) + onOutOfBandEvents(events) } } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt index 26d4e6d..0581ce2 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt @@ -1,434 +1,326 @@ package com.pulseloop.ring import java.time.Instant -import java.time.ZoneId +import java.time.ZoneOffset +import java.util.TimeZone +import java.util.UUID /** - * Ported from YCBTProtocol.swift (iOS #82). + * Ported from YCBTProtocol.swift. + * Yucheng YCBT protocol primitives — GATT topology, framing, byte/epoch helpers, opcodes, + * and the health-record type table. This is the wire language the R10M speaks. * - * Yucheng **YCBT** protocol primitives — GATT topology, framing, byte/epoch helpers, opcodes, and - * the health-record type table. This is the wire language the TK5 speaks and, byte-identically, - * the SmartHealth-flavoured Colmi rings. Everything here is deliberately device-agnostic: what - * varies between families is their advertised identity and which capability bits they claim, both - * of which live in their coordinator ([TK5Coordinator]/[ColmiSmartHealthCoordinator]), so a second - * family reuses this file verbatim. - * - * Ground truth is the decompiled vendor SDK (`com.yucheng.ycbtsdk`, v4.0.10, shipped inside the - * SmartHealth Android app `com.zhuoting.healthyucheng`): `CMD.java` (opcodes), `Constants.java` - * (16-bit dataTypes), `DataUnpack.java` (record parsing) and `YCBTClientImpl.java` (framing, queue, - * history assembly) — decompiled directly into `decompiled-smarthealth/` in this repo, not - * transcribed from iOS. **BLE connection-lifecycle behavior (GATT open, MTU, bonding, CCCD - * indicate-vs-notify) is verified separately against the same vendor SDK's `gatt/BleHelper.java`, - * not ported from iOS's CoreBluetooth calls** — see `RingBLEClient`'s YCBT-specific notes. - * - * **Wire format** (both the command channel `be940001` and the async stream `be940003`): - * `[type:1][cmd:1][len:2 LE][payload:N][crc16:2 LE]` - * where `len` is the *total* frame length (header + payload + crc) and the CRC is - * **CRC16/CCITT-FALSE** (poly 0x1021, init 0xFFFF, no reflection) over every byte before it. - * A command's 16-bit `dataType` in the SDK is exactly `(type shl 8) or cmd`. + * Wire format (both command channel be940001 and async stream be940003): + * [type:1][cmd:1][len:2 LE][payload:N][crc16:2 LE] + * where len is the *total* frame length (header + payload + crc) and the CRC is + * CRC16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflection) over every byte before it. */ -/** Convert a logical `[UByte]` command/payload to the `ByteArray` [RingCommandWriter.enqueue] expects. */ -fun List.toRawByteArray(): ByteArray = map { it.toByte() }.toByteArray() object YCBTUUIDs { - /** Primary protocol service. */ const val SERVICE = "be940000-7333-be46-b7ae-689e71722bd5" - /** Command channel — the app writes here AND receives command replies here (write + indicate). */ const val COMMAND = "be940001-7333-be46-b7ae-689e71722bd5" - /** Async stream — live HR / steps / SpO2 and downloaded history records (indicate). */ const val STREAM = "be940003-7333-be46-b7ae-689e71722bd5" } -/** One validated, unframed YCBT frame. */ -data class YCBTFrame(val type: UByte, val cmd: UByte, val payload: List) { +class YCBTFrame( + val type: Int, + val cmd: Int, + val payload: ByteArray, +) { companion object { /** Parse and CRC-validate one inbound frame. Returns null on a short frame or CRC mismatch. */ fun validating(data: ByteArray): YCBTFrame? { - val bytes = data.map { it.toUByte() } + val bytes = data if (bytes.size < 6) return null - val declared = bytes[2].toInt() or (bytes[3].toInt() shl 8) + val declared = (bytes[2].toInt() and 0xFF) or ((bytes[3].toInt() and 0xFF) shl 8) if (declared != bytes.size) return null - val crcGiven = bytes[bytes.size - 2].toInt() or (bytes[bytes.size - 1].toInt() shl 8) - if (crc16(bytes.subList(0, bytes.size - 2)) != crcGiven) return null - return YCBTFrame(bytes[0], bytes[1], bytes.subList(4, bytes.size - 2)) + val crcGiven = (bytes[bytes.size - 2].toInt() and 0xFF) or ((bytes[bytes.size - 1].toInt() and 0xFF) shl 8) + if (crc16(bytes, 0, bytes.size - 2) != crcGiven) return null + return YCBTFrame( + type = bytes[0].toInt() and 0xFF, + cmd = bytes[1].toInt() and 0xFF, + payload = bytes.copyOfRange(4, bytes.size - 2) + ) } - /** - * Build a framed packet from a logical command `[type, cmd, payload...]`: insert the total - * length field after the two header bytes and append the little-endian CRC16. - */ - fun frame(logical: List): ByteArray { - if (logical.size < 2) return logical.map { it.toByte() }.toByteArray() - val total = logical.size + 4 // + 2-byte length field + 2-byte CRC - val out = mutableListOf(logical[0], logical[1], (total and 0xff).toUByte(), ((total shr 8) and 0xff).toUByte()) - out.addAll(logical.subList(2, logical.size)) - val crc = crc16(out) - out.add((crc and 0xff).toUByte()) - out.add(((crc shr 8) and 0xff).toUByte()) - return out.map { it.toByte() }.toByteArray() + /** Build a framed packet from a logical command [type, cmd, payload…]: insert the total-length + * field after the two header bytes and append the little-endian CRC16. */ + fun frame(logical: ByteArray): ByteArray { + if (logical.size < 2) return logical.copyOf() + val total = logical.size + 4 // + 2-byte length field + 2-byte CRC + val out = ByteArray(total) + out[0] = logical[0] + out[1] = logical[1] + out[2] = (total and 0xFF).toByte() + out[3] = ((total shr 8) and 0xFF).toByte() + System.arraycopy(logical, 2, out, 4, logical.size - 2) + val crc = crc16(out, 0, out.size - 2) + out[out.size - 2] = (crc and 0xFF).toByte() + out[out.size - 1] = ((crc shr 8) and 0xFF).toByte() + return out } /** CRC16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no input/output reflection, no final xor). */ - fun crc16(bytes: List): Int { + fun crc16(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size): Int { var crc = 0xFFFF - for (b in bytes) { - crc = crc xor (b.toInt() shl 8) + for (i in offset until offset + length) { + val b = bytes[i].toInt() and 0xFF + crc = crc xor (b shl 8) repeat(8) { - crc = if ((crc and 0x8000) != 0) (crc shl 1) xor 0x1021 else (crc shl 1) + crc = if ((crc and 0x8000) != 0) ((crc shl 1) xor 0x1021) and 0xFFFF else (crc shl 1) and 0xFFFF } - crc = crc and 0xFFFF } - return crc + return crc and 0xFFFF } + + fun crc16(bytes: ByteArray): Int = crc16(bytes, 0, bytes.size) } } -/** - * Little-endian + epoch helpers. YCBT timestamps are **seconds since 2000-01-01 UTC**, not the - * Unix epoch (confirmed against the iOS capture's wall-clock time). - */ +/** Little-endian + epoch helpers. YCBT timestamps encode local wall-clock seconds since 2000. */ object YCBTBytes { - /** Seconds between 1970-01-01 and 2000-01-01 (the YCBT epoch offset). */ - const val EPOCH_OFFSET_SECONDS = 946_684_800L + const val EPOCH_OFFSET = 946_684_800L - fun u16(b: List, i: Int): Int { - if (b.size < i + 2) return 0 - return b[i].toInt() or (b[i + 1].toInt() shl 8) + fun u16(bytes: ByteArray, i: Int): Int { + if (bytes.size < i + 2) return 0 + return (bytes[i].toInt() and 0xFF) or ((bytes[i + 1].toInt() and 0xFF) shl 8) } - /** - * 3-byte little-endian. Sleep segment durations are u24 (`DataUnpack` reads bytes 5,6,7 of an - * 8-byte segment), so a u16 read truncates any segment longer than 18h12m. - */ - fun u24(b: List, i: Int): Int { - if (b.size < i + 3) return 0 - return b[i].toInt() or (b[i + 1].toInt() shl 8) or (b[i + 2].toInt() shl 16) + fun u24(bytes: ByteArray, i: Int): Int { + if (bytes.size < i + 3) return 0 + return (bytes[i].toInt() and 0xFF) or ((bytes[i + 1].toInt() and 0xFF) shl 8) or ((bytes[i + 2].toInt() and 0xFF) shl 16) } - fun u32(b: List, i: Int): Long { - if (b.size < i + 4) return 0 - return b[i].toLong() or (b[i + 1].toLong() shl 8) or (b[i + 2].toLong() shl 16) or (b[i + 3].toLong() shl 24) + fun u32(bytes: ByteArray, i: Int): Int { + if (bytes.size < i + 4) return 0 + return (bytes[i].toInt() and 0xFF) or ((bytes[i + 1].toInt() and 0xFF) shl 8) or + ((bytes[i + 2].toInt() and 0xFF) shl 16) or ((bytes[i + 3].toInt() and 0xFF) shl 24) } - /** - * Convert a ring timestamp (2000-epoch seconds) to an [Instant]. The ring has no timezone - * concept — its clock is set from local wall-clock fields (see `YCBTSettingsEncoder.setTime`) - * and ticks in local time, so decoding must un-apply the device's UTC offset to recover the - * true absolute instant. Uses the *current* offset as an approximation of the offset in effect - * when the timestamp was recorded — correct for same-session syncs, only wrong across a DST - * transition that happens between recording and syncing. - */ - fun date(ringSeconds: Long, zone: ZoneId = ZoneId.systemDefault()): Instant { - val offset = zone.rules.getOffset(Instant.now()).totalSeconds - return Instant.ofEpochSecond(ringSeconds + EPOCH_OFFSET_SECONDS - offset) + /** Convert ring seconds (2000-epoch) to an Instant. The ring clock is local wall-clock; decoding + * must un-apply the device's UTC offset to recover the true absolute instant. */ + fun date(ringSeconds: Int, timeZone: TimeZone = TimeZone.getDefault()): Instant { + val localDateTime = Instant.ofEpochSecond(ringSeconds.toLong() + EPOCH_OFFSET) + .atOffset(ZoneOffset.UTC) + .toLocalDateTime() + // A fall-back overlap is inherently ambiguous because the ring stores no UTC offset. + // java.time deliberately chooses the earlier valid offset, giving us a deterministic + // policy that matches normal LocalDateTime.atZone behavior rather than today's offset. + return localDateTime.atZone(timeZone.toZoneId()).toInstant() } - /** Convert an [Instant] to ring seconds (2000-epoch), the inverse of [date]. */ - fun ringSeconds(instant: Instant, zone: ZoneId = ZoneId.systemDefault()): Long { - val offset = zone.rules.getOffset(instant).totalSeconds - return instant.epochSecond - EPOCH_OFFSET_SECONDS + offset + /** Convert an Instant to ring seconds (2000-epoch), the inverse of date(). */ + fun ringSeconds(date: Instant, timeZone: TimeZone = TimeZone.getDefault()): Int { + val offset = timeZone.getOffset(date.toEpochMilli()) / 1000 + return (date.epochSecond - EPOCH_OFFSET + offset).toInt() } } -/** Frame `type` byte — the command group. `Constants.DATATYPE` splits every opcode this way. */ +/** Frame type byte — the command group. */ object YCBTGroup { - const val SETTING: UByte = 0x01u // clock, user info, units, monitor enables - const val GET: UByte = 0x02u // device info, support bitmap, name, user config - const val APP_CONTROL: UByte = 0x03u // live-measurement start/stop, live-status push - const val DEV_CONTROL: UByte = 0x04u // device->app pushes (find phone, SOS, measurement done) - const val HEALTH: UByte = 0x05u // history: queries, data frames, terminal block - const val REAL: UByte = 0x06u // device->app realtime stream + const val SETTING: Int = 0x01 + const val GET: Int = 0x02 + const val APP_CONTROL: Int = 0x03 + const val DEV_CONTROL: Int = 0x04 + const val HEALTH: Int = 0x05 + const val REAL: Int = 0x06 } -/** - * The `cmd` bytes we act on, by group ([YCBTGroup]). The Setting-group keys live in - * [YCBTSettingKey] and the Health-group history keys in [YCBTHistoryType]. - */ +/** The cmd bytes we act on, by group. */ object YCBTCommand { // Group 0x02 (Get) - const val GET_DEVICE_INFO: UByte = 0x00u // battery @payload[5], state @[4], firmware "[3].[2]" - const val GET_SUPPORT_FUNCTION: UByte = 0x01u // capability bitmap (see YCBTSupportFunction) - const val GET_DEVICE_NAME: UByte = 0x03u - const val GET_USER_CONFIG: UByte = 0x07u - const val GET_CHIP_SCHEME: UByte = 0x1bu // JieLi vs Nordic + const val GET_DEVICE_INFO: Int = 0x00 + const val GET_SUPPORT_FUNCTION: Int = 0x01 + const val GET_DEVICE_NAME: Int = 0x03 + const val GET_USER_CONFIG: Int = 0x07 + const val GET_CHIP_SCHEME: Int = 0x1b // Group 0x03 (AppControl) - const val FIND_DEVICE: UByte = 0x00u // make the ring buzz - const val LIVE_MEASUREMENT: UByte = 0x2fu // [enable, mode] — mode picks the sensor/LED - const val LIVE_STATUS_PUSH: UByte = 0x09u // enable the ring's continuous 06 00 status stream - - // Group 0x06 (Real — async stream on be940003) - const val LIVE_STATUS: UByte = 0x00u // steps + distance/calories, repeated - const val LIVE_HEART_RATE: UByte = 0x01u // 1-byte bpm - const val LIVE_SPO2: UByte = 0x02u // 1-byte SpO2 % - const val LIVE_VITALS: UByte = 0x03u // SBP/DBP/hr/hrv/spo2/temp - const val LIVE_WEARING_STATUS: UByte = 0x13u // [ts:u32][worn] - const val LIVE_BATTERY: UByte = 0x15u // [chargingStatus][percent] + const val FIND_DEVICE: Int = 0x00 + const val LIVE_MEASUREMENT: Int = 0x2f + const val LIVE_STATUS_PUSH: Int = 0x09 + + // Group 0x06 (Real — async stream) + const val LIVE_STATUS: Int = 0x00 + const val LIVE_HEART_RATE: Int = 0x01 + const val LIVE_SPO2: Int = 0x02 + const val LIVE_VITALS: Int = 0x03 + const val LIVE_WEARING_STATUS: Int = 0x13 + const val LIVE_BATTERY: Int = 0x15 } -/** - * One health-history record type: the query key we write, the ack key its data frames carry back, - * and the fixed record stride the reassembled buffer is sliced at. This table is the single source - * of truth for both [YCBTHistoryTransfer] (which types to ask for, which frames belong to which - * type) and [YCBTHealthRecords] (how to cut the buffer) — the two must never disagree. - */ +/** One health-history record type: query key, ack key, record stride, and label. */ data class YCBTHistoryType( - /** `05 ` with an empty payload asks the ring for every stored record of this type. */ - val queryKey: UByte, - /** The `cmd` the ring's data frames carry (a *different* key from the query). */ - val ackKey: UByte, - /** Fixed record size in the reassembled buffer. Null => variable-length (sleep). */ + val queryKey: Int, + val ackKey: Int, val recordStride: Int?, - /** Human label for the sync-progress UI ("Syncing sleep..."). */ val label: String, ) { companion object { - val SPORT = YCBTHistoryType(0x02u, 0x11u, 14, "activity") - val SLEEP = YCBTHistoryType(0x04u, 0x13u, null, "sleep") - val HEART = YCBTHistoryType(0x06u, 0x15u, 6, "heart rate") - val BLOOD = YCBTHistoryType(0x08u, 0x17u, 8, "blood pressure") - val ALL = YCBTHistoryType(0x09u, 0x18u, 20, "vitals") - val SPO2 = YCBTHistoryType(0x1au, 0x22u, 6, "blood oxygen") - val TEMPERATURE = YCBTHistoryType(0x1eu, 0x26u, 7, "temperature") - val COMPREHENSIVE = YCBTHistoryType(0x2fu, 0x30u, 44, "metabolic") - val BODY_DATA = YCBTHistoryType(0x33u, 0x34u, 28, "body data") - - /** - * Every type the SDK's `DataSyncUtils` can request, in its own ascending-key sync order — - * and every type [YCBTHealthRecords] decodes. Both YCBT families query the whole catalog, - * whatever their capability set says: a type the ring doesn't implement answers with a - * no-data header or a `0xFC` (unsupported key), which [YCBTHistoryTransfer] skips — - * permanently, for `0xFC`. - */ + val SPORT = YCBTHistoryType(0x02, 0x11, 14, "activity") + val SLEEP = YCBTHistoryType(0x04, 0x13, null, "sleep") + val HEART = YCBTHistoryType(0x06, 0x15, 6, "heart rate") + val BLOOD = YCBTHistoryType(0x08, 0x17, 8, "blood pressure") + val ALL = YCBTHistoryType(0x09, 0x18, 20, "vitals") + val SPO2 = YCBTHistoryType(0x1a, 0x22, 6, "blood oxygen") + val TEMPERATURE = YCBTHistoryType(0x1e, 0x26, 7, "temperature") + val COMPREHENSIVE = YCBTHistoryType(0x2f, 0x30, 44, "metabolic") + val BODY_DATA = YCBTHistoryType(0x33, 0x34, 28, "body data") + val CATALOG: List = listOf( - SPORT, SLEEP, HEART, BLOOD, ALL, SPO2, TEMPERATURE, COMPREHENSIVE, BODY_DATA, + SPORT, SLEEP, HEART, BLOOD, ALL, SPO2, TEMPERATURE, COMPREHENSIVE, BODY_DATA ) } } -/** - * The measurement-mode byte — one table shared by the two commands that must agree on it: the - * **`03 2f` start/stop** payload we write (`{enable, mode}`) and the **`04 13` status/result push** - * the ring answers with (`[type][state]...`). - */ +/** The measurement-mode byte shared by start/stop and status/result push. */ object YCBTMeasurementMode { - const val HEART_RATE: UByte = 0x00u - const val BLOOD_PRESSURE: UByte = 0x01u - const val SPO2: UByte = 0x02u - const val RESPIRATORY_RATE: UByte = 0x03u - const val TEMPERATURE: UByte = 0x04u - const val BLOOD_SUGAR: UByte = 0x05u - const val URIC_ACID: UByte = 0x06u - const val BLOOD_KETONE: UByte = 0x07u - const val BLOOD_FAT: UByte = 0x09u - const val HRV: UByte = 0x0au - const val STRESS: UByte = 0x0cu - - /** - * The ring's **verdict** on a `03 2f` start. The reply is a single status byte — `0x00` = - * accepted, non-zero = "I will not run that" — and it does not echo the mode. - */ - fun isAccepted(status: UByte): Boolean = status == 0x00u.toUByte() + const val HEART_RATE: Int = 0x00 + const val BLOOD_PRESSURE: Int = 0x01 + const val SPO2: Int = 0x02 + const val TEMPERATURE: Int = 0x04 + const val BLOOD_SUGAR: Int = 0x05 + const val URIC_ACID: Int = 0x06 + const val BLOOD_FAT: Int = 0x09 + const val HRV: Int = 0x0a + const val STRESS: Int = 0x0c + + fun isAccepted(status: Int): Boolean = status == 0x00 } -/** - * Group 4 (**DevControl**) — the ring->app push channel: measurement progress/results, SOS, - * find-phone, sedentary reminders. The app never *initiates* a `04 xx`; the only `04` frame it - * writes is the ACK below. - */ +/** Group 4 (DevControl) — ring→app push keys. */ object YCBTDevControl { - const val FIND_PHONE: UByte = 0x00u - const val SOS: UByte = 0x05u - const val MEASUREMENT_RESULT: UByte = 0x0eu // [measureType][result] - const val MEASUREMENT_STATUS: UByte = 0x13u // [type][state] + the value for that type - const val SEDENTARY_REMINDER: UByte = 0x16u - const val SOS_CALL: UByte = 0x17u - - /** - * `04 {00}` — the mandatory push ACK. **The ring retransmits a push until it arrives**, - * so we send it before we even parse the payload. - */ - fun ack(key: UByte): List = listOf(YCBTGroup.DEV_CONTROL, key, 0x00u) - - /** `result` byte of a `04 0e` MeasurementResult push: 1 = success, else failed/cancelled. */ - const val RESULT_SUCCESS: UByte = 0x01u + const val FIND_PHONE: Int = 0x00 + const val SOS: Int = 0x05 + const val MEASUREMENT_RESULT: Int = 0x0e + const val MEASUREMENT_STATUS: Int = 0x13 + const val SEDENTARY_REMINDER: Int = 0x16 + const val SOS_CALL: Int = 0x17 + + fun ack(key: Int): ByteArray = byteArrayOf(YCBTGroup.DEV_CONTROL.toByte(), key.toByte(), 0x00) + + const val RESULT_SUCCESS: Int = 0x01 } -/** The Health group's two control keys and the ACK status bytes. */ +/** Health-group control keys and ACK status bytes. */ object YCBTHealth { - /** - * `05 80` — inbound it terminates a transfer (`[totalPackets:u16][totalBytes:u16][crc16:u16]`); - * outbound it is the mandatory block ACK. - */ - const val TERMINAL_BLOCK: UByte = 0x80u - /** Reassembled buffer matched the terminal frame's CRC16. */ - const val ACK_ACCEPTED: UByte = 0x00u - /** CRC mismatch — the ring may re-send. */ - const val ACK_CRC_FAILURE: UByte = 0x04u - /** A header frame carries `[recordCount:u16][totalPackets:u32][totalBytes:u32]`. */ - const val HEADER_PAYLOAD_LENGTH = 10 - /** The terminal block's payload length. */ - const val TERMINAL_PAYLOAD_LENGTH = 6 + const val TERMINAL_BLOCK: Int = 0x80 + const val ACK_ACCEPTED: Int = 0x00 + const val ACK_CRC_FAILURE: Int = 0x04 + const val HEADER_PAYLOAD_LENGTH: Int = 10 + const val TERMINAL_PAYLOAD_LENGTH: Int = 6 } -/** - * Logical (unframed) Health-group commands. Shared so the transfer machine and a family's encoder - * can never drift apart on the exact bytes. - */ +/** Logical (unframed) Health-group commands. */ object YCBTHealthCommand { - /** Ask for one history type: `05 ` with an **empty** payload. */ - fun historyRequest(type: YCBTHistoryType): List = listOf(YCBTGroup.HEALTH, type.queryKey) + fun historyRequest(type: YCBTHistoryType): ByteArray = + byteArrayOf(YCBTGroup.HEALTH.toByte(), type.queryKey.toByte()) - /** The mandatory end-of-transfer ACK: `05 80 {status}`. */ - fun historyBlockAck(status: UByte): List = listOf(YCBTGroup.HEALTH, YCBTHealth.TERMINAL_BLOCK, status) + fun historyBlockAck(status: Int): ByteArray = + byteArrayOf(YCBTGroup.HEALTH.toByte(), YCBTHealth.TERMINAL_BLOCK.toByte(), status.toByte()) } -/** - * Device-side rejection of a command. The SDK's `isError` treats **any** 1-byte response payload - * in `0xFB..0xFF` as an error status rather than data — for *every* group. - */ -enum class YCBTFrameError(val rawValue: UByte) { - UNSUPPORTED_COMMAND(0xfbu), // the group byte isn't implemented - UNSUPPORTED_KEY(0xfcu), // the cmd byte isn't implemented on this firmware - LENGTH(0xfdu), - DATA(0xfeu), - CRC(0xffu); - - /** - * True when the ring is telling us it will *never* answer this type on this firmware, so the - * transfer machine can stop asking for the rest of the session. - */ - val isPermanent: Boolean get() = this == UNSUPPORTED_COMMAND || this == UNSUPPORTED_KEY +/** Device-side rejection of a command. */ +enum class YCBTFrameError(val code: Int) { + UNSUPPORTED_COMMAND(0xfb), + UNSUPPORTED_KEY(0xfc), + LENGTH(0xfd), + DATA(0xfe), + CRC(0xff); companion object { - /** - * Detect an error frame. Must be checked *before* interpreting a payload as a - * header/record/push, because a 1-byte error payload is otherwise indistinguishable from a - * short header. - */ - fun detect(payload: List): YCBTFrameError? { + fun detect(payload: ByteArray): YCBTFrameError? { if (payload.size != 1) return null - return entries.find { it.rawValue == payload[0] } + return entries.find { it.code == (payload[0].toInt() and 0xFF) } } } + + val isPermanent: Boolean get() = this == UNSUPPORTED_COMMAND || this == UNSUPPORTED_KEY } -/** - * Reassembles GATT notifications into whole logical frames. - * - * A logical frame longer than `MTU-3` is split across notifications (and, symmetrically, several - * short frames can land in a single notification). Validation keys off the declared total length - * at bytes [2..3], exactly as `YCBTClientImpl`'s receive parser does. Garbage (a truncated tail - * after a disconnect, a stray notification) is resynced by dropping one byte at a time until a - * plausible header appears, so one bad byte can't poison the rest of a session. - */ +/** Reassembles GATT notifications into whole logical frames. */ class YCBTFrameAssembler { - /** Header + CRC with an empty payload — the shortest frame that can exist. */ private val minFrameLength = 6 - - /** No YCBT frame comes close to this; resync rather than wait forever for bytes that never arrive. */ private val maxFrameLength = 1024 + private val pending = mutableMapOf() - /** Partial frames, per characteristic UUID: the command channel and the async stream interleave. */ - private val pending = mutableMapOf>() - - /** Drop every partial frame. A fresh driver is built per connection, so this exists for reconnects. */ fun reset() { pending.clear() } /** Feed one notification; returns the complete logical frames it completed (0, 1, or several). */ - fun append(data: ByteArray, from: String): List { - val buffer = pending.getOrPut(from) { mutableListOf() } - buffer.addAll(data.map { it.toUByte() }) + fun append(data: ByteArray, fromCharacteristic: String): List { + var buffer = pending[fromCharacteristic] ?: ByteArray(0) + buffer += data val frames = mutableListOf() while (buffer.size >= 4) { - val declared = buffer[2].toInt() or (buffer[3].toInt() shl 8) + val declared = (buffer[2].toInt() and 0xFF) or ((buffer[3].toInt() and 0xFF) shl 8) if (!isPlausibleGroup(buffer[0]) || declared < minFrameLength || declared > maxFrameLength) { - buffer.removeAt(0) // resync: this can't be a frame start + buffer = buffer.copyOfRange(1, buffer.size) continue } - if (buffer.size < declared) break // still waiting on the rest of this frame - frames.add(buffer.subList(0, declared).map { it.toByte() }.toByteArray()) - repeat(declared) { buffer.removeAt(0) } + if (buffer.size < declared) break + frames.add(buffer.copyOfRange(0, declared)) + buffer = buffer.copyOfRange(declared, buffer.size) } - + pending[fromCharacteristic] = buffer return frames } - /** Only the six groups the ring ever sends us can legitimately start a frame. */ - private fun isPlausibleGroup(byte: UByte): Boolean = - byte in YCBTGroup.SETTING..YCBTGroup.REAL + private fun isPlausibleGroup(byte: Byte): Boolean { + val b = byte.toInt() and 0xFF + return b in YCBTGroup.SETTING..YCBTGroup.REAL + } } -/** - * Parser for the `02 01` **SupportFunction** reply: a variable-length bit array (bit 7 of each - * byte first) in which the firmware declares what it actually implements. Mirrors - * `DataUnpack.saveDeviceSupportFunctionData`. - * - * This is what lets one driver serve a whole *family* of rings whose SKUs disagree: a family - * declares a capability as `bitmapGatedCapabilities` (see [WearableCoordinator]) and the connected - * unit's own bitmap decides whether it is really there. - */ +/** Parser for the 02 01 SupportFunction reply: variable-length bit array. */ object YCBTSupportFunction { - /** - * One capability bit: its byte, its bit index (7 = MSB, matching `(b shr n) and 1`), and the - * payload length the SDK demands before it will read that byte at all. - */ private data class Bit(val byte: Int, val bit: Int, val minLength: Int, val capability: WearableCapability) - /** Bit -> capability, each named with the `Constants.FunctionConstant` the SDK stores it under. */ - private val bits: List = listOf( - Bit(0, 7, 14, WearableCapability.STEPS), // ISHASSTEPCOUNT - Bit(0, 6, 14, WearableCapability.SLEEP), // ISHASSLEEP + private val bits = listOf( + Bit(0, 7, 14, WearableCapability.STEPS), // ISHASSTEPCOUNT + Bit(0, 6, 14, WearableCapability.SLEEP), // ISHASSLEEP Bit(0, 3, 14, WearableCapability.HEART_RATE), // ISHASHEARTRATE Bit(0, 0, 14, WearableCapability.BLOOD_PRESSURE), // ISHASBLOOD Bit(1, 3, 14, WearableCapability.SPO2), // ISHASBLOODOXYGEN Bit(1, 1, 14, WearableCapability.HRV), // ISHASHRV Bit(8, 0, 14, WearableCapability.TEMPERATURE), // ISHASTEMP - Bit(17, 3, 18, WearableCapability.BLOOD_SUGAR), // ISHASBLOODSUGAR + Bit(17, 3, 18, WearableCapability.BLOOD_SUGAR), // ISHASBLOODSUGAR Bit(22, 6, 23, WearableCapability.STRESS), // IS_HAS_PRESSURE - // Fatigue rides the stress bit — the ring gives them one switch (see YCBTHealthRecords / - // TK5Coordinator doc comments for the full rationale). - Bit(22, 6, 23, WearableCapability.FATIGUE), // IS_HAS_PRESSURE (same record) - Bit(6, 4, 14, WearableCapability.FIND_DEVICE), // ISHASFINDDEVICE + Bit(22, 6, 23, WearableCapability.FATIGUE), // IS_HAS_PRESSURE (same record) + Bit(6, 4, 14, WearableCapability.FIND_DEVICE), // ISHASFINDDEVICE Bit(15, 1, 18, WearableCapability.MANUAL_HEART_RATE), // ISHATESTHEART Bit(15, 2, 18, WearableCapability.MANUAL_BLOOD_PRESSURE), // ISHASTESTBLOOD - Bit(15, 3, 18, WearableCapability.MANUAL_SPO2), // ISHASTESTSPO2 + Bit(15, 3, 18, WearableCapability.MANUAL_SPO2), // ISHASTESTSPO2 Bit(23, 0, 24, WearableCapability.MANUAL_HRV), // IS_HAS_HRV_MEASUREMENT ) - /** - * The capabilities this unit claims. A payload too short to clear even the SDK's own `>= 14` - * gate yields the empty set — every bit's `minLength` is at least that — which under the - * additive-only refinement means "no opinion": the family's baseline stands. - */ - fun capabilities(payload: List): Set = - bits.filter { isSet(payload, it) }.map { it.capability }.toSet() - - /** Raw bit array (MSB first within each byte) for the debug feed / diagnostics. */ - fun rawBits(payload: List): List = - payload.flatMap { byte -> (0..7).map { ((byte.toInt() shr (7 - it)) and 1) == 1 } } - - private fun isSet(payload: List, bit: Bit): Boolean { - if (payload.size < bit.minLength || bit.byte >= payload.size) return false - return ((payload[bit.byte].toInt() shr bit.bit) and 1) == 1 + fun capabilities(payload: ByteArray): Set { + val out = mutableSetOf() + for (b in bits) { + if (payload.size >= b.minLength && b.byte < payload.size) { + if (((payload[b.byte].toInt() and 0xFF) shr b.bit) and 1 == 1) { + out.add(b.capability) + } + } + } + return out + } + + fun rawBits(payload: ByteArray): List { + return payload.flatMap { byte -> + (0..7).map { ((byte.toInt() and 0xFF) shr (7 - it)) and 1 == 1 } + } } } -/** - * The `02 1b` **chipScheme** reply (`DataUnpack.unpackGetChipScheme`): one byte naming the - * chipset/OTA family. Diagnostic only — PulseLoop does no firmware updates. - */ +/** The 02 1b chipScheme reply — diagnostic only. */ object YCBTChipScheme { - /** `bArr[0] and 0xFF`, except a value >= 240 is an error status, folded to 0 = "unknown/other". */ - fun value(payload: List): Int { - val first = payload.firstOrNull() ?: return 0 - if (first.toInt() >= 240) return 0 - return first.toInt() + fun value(payload: ByteArray): Int { + val first = (payload.firstOrNull()?.toInt() ?: return 0) and 0xFF + return if (first >= 240) 0 else first } - /** `InnerUtils.isJieLiChipScheme`: 3, 4 and 5 are the JieLi families. */ fun isJieLi(value: Int): Boolean = value in 3..5 } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSettingsEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTSettingsEncoder.kt deleted file mode 100644 index df7bb3d..0000000 --- a/app/src/main/java/com/pulseloop/ring/YCBTSettingsEncoder.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.pulseloop.ring - -import java.time.Instant -import java.time.ZoneId -import java.util.Calendar -import java.util.Locale -import java.util.TimeZone - -/** Setting-group (`0x01`) keys — `Constants.DATATYPE` low bytes. */ -object YCBTSettingKey { - const val SET_TIME: UByte = 0x00u // SettingTime 256 - const val USER_INFO: UByte = 0x03u // SettingUserInfo 259 - const val UNITS: UByte = 0x04u // SettingUnit 260 - const val HEART_MONITOR: UByte = 0x0cu // SettingHeartMonitor 268 - const val LANGUAGE: UByte = 0x12u // SettingLanguage 274 - const val BLOOD_PRESSURE_MONITOR: UByte = 0x1cu // SettingBloodPressureMonitor 284 - const val TEMPERATURE_MONITOR: UByte = 0x20u // SettingTemperatureMonitor 288 - const val BLOOD_OXYGEN_MONITOR: UByte = 0x26u // SettingBloodOxygenModeMonitor 294 - const val HRV_MONITOR: UByte = 0x45u // SettingHRVMonitor 325 -} - -/** - * Ported from YCBTSettingsEncoder.swift (iOS #82). - * Byte builders for the Setting group, shared by every YCBT family. Each returns a *logical* - * command (`[type, cmd, payload...]`); the driver's `frame(_)` adds the length field and CRC. - * - * Every one of these is idempotent and individually ACKed by the ring with a 1-byte status. - */ -object YCBTSettingsEncoder { - /** - * The ring's all-day sampler refuses intervals under 30 minutes (SmartHealth clamps the same - * way for rings). The vendor default is 60. - */ - const val MINIMUM_INTERVAL_MINUTES = 30 - const val DEFAULT_INTERVAL_MINUTES = 60 - - /** - * Clamp a user-chosen cadence into what the firmware will actually accept. PulseLoop's shared - * [MeasurementSettings] default is 5 minutes (a Colmi cadence), which this floors to 30 rather - * than silently letting the ring reject the write. - */ - fun clampInterval(minutes: Int): UByte { - if (minutes <= 0) return DEFAULT_INTERVAL_MINUTES.toUByte() - return minutes.coerceIn(MINIMUM_INTERVAL_MINUTES, 255).toUByte() - } - - // MARK: - Clock - - /** - * `01 00` + `[year:u16 LE][month][day][hour][min][sec][weekday]`. - * The weekday byte is **Mon=0 ... Sun=6**, against `Calendar.DAY_OF_WEEK` where Sunday == 1. - */ - fun setTime(instant: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): List { - val cal = Calendar.getInstance(TimeZone.getTimeZone(zone)) - cal.timeInMillis = instant.toEpochMilli() - val year = cal.get(Calendar.YEAR) - val gregorianWeekday = cal.get(Calendar.DAY_OF_WEEK) // Sunday = 1 ... Saturday = 7 - val weekday = if (gregorianWeekday == 1) 6 else gregorianWeekday - 2 - return listOf( - YCBTGroup.SETTING, YCBTSettingKey.SET_TIME, - (year and 0xff).toUByte(), ((year shr 8) and 0xff).toUByte(), - (cal.get(Calendar.MONTH) + 1).toUByte(), cal.get(Calendar.DAY_OF_MONTH).toUByte(), - cal.get(Calendar.HOUR_OF_DAY).toUByte(), cal.get(Calendar.MINUTE).toUByte(), - cal.get(Calendar.SECOND).toUByte(), weekday.toUByte(), - ) - } - - // MARK: - Profile / locale - - /** - * `01 03` + `[heightCm][weightKg][sex][age]`. The ring feeds these into its step, calorie and - * BP algorithms, so a wrong profile is a wrong reading. - * - * UNVERIFIED: the sex byte's polarity. The SDK never asserts the mapping; we send 1 for male, - * 0 otherwise, which is the vendor convention. A wrong value skews calorie estimates slightly - * and nothing else. - */ - fun userInfo(profile: UserProfileValues): List = listOf( - YCBTGroup.SETTING, YCBTSettingKey.USER_INFO, - profile.heightCm, profile.weightKg, - if (profile.gender == 0x01u.toUByte()) 1u.toUByte() else 0u.toUByte(), - profile.age, - ) - - /** - * `01 04` + `[distance][weight][temp][timeFormat][bloodSugar][uricAcid]` — 0 = metric - * everywhere; `timeFormat` is 1 for 12-hour, 0 for 24-hour. - */ - fun units(metric: Boolean, is24Hour: Boolean = true): List { - val imperial: UByte = if (metric) 0u else 1u - return listOf( - YCBTGroup.SETTING, YCBTSettingKey.UNITS, - imperial, imperial, imperial, if (is24Hour) 0u else 1u, 0u, 0u, - ) - } - - /** `01 12` + `[languageCode]` (the vendor's own enum; 0 = English). */ - fun language(code: UByte = 0u): List = listOf(YCBTGroup.SETTING, YCBTSettingKey.LANGUAGE, code) - - // MARK: - All-day monitors - - /** - * The five background samplers, each `{enable, intervalMinutes}`. **These — not the `05 4x` - * burst — are what make the ring record anything between syncs.** - * - * [MeasurementSettings] has no blood-pressure flag (no YCBT family has an all-day BP sampler); - * BP rides the HR toggle. `stressEnabled` has no YCBT monitor command — the ring stores stress - * in the body-data history record (`05 33`), it doesn't sample it on its own schedule. - */ - fun monitorCommands(settings: MeasurementSettings): List> { - val interval = clampInterval(settings.hrIntervalMinutes) - return listOf( - heartMonitor(settings.hrEnabled, interval), - bloodPressureMonitor(settings.hrEnabled, interval), - temperatureMonitor(settings.temperatureEnabled, interval), - bloodOxygenMonitor(settings.spo2Enabled, interval), - hrvMonitor(settings.hrvEnabled, interval), - ) - } - - fun heartMonitor(enabled: Boolean, intervalMinutes: UByte): List = - listOf(YCBTGroup.SETTING, YCBTSettingKey.HEART_MONITOR, if (enabled) 1u else 0u, intervalMinutes) - - fun bloodPressureMonitor(enabled: Boolean, intervalMinutes: UByte): List = - listOf(YCBTGroup.SETTING, YCBTSettingKey.BLOOD_PRESSURE_MONITOR, if (enabled) 1u else 0u, intervalMinutes) - - fun temperatureMonitor(enabled: Boolean, intervalMinutes: UByte): List = - listOf(YCBTGroup.SETTING, YCBTSettingKey.TEMPERATURE_MONITOR, if (enabled) 1u else 0u, intervalMinutes) - - fun bloodOxygenMonitor(enabled: Boolean, intervalMinutes: UByte): List = - listOf(YCBTGroup.SETTING, YCBTSettingKey.BLOOD_OXYGEN_MONITOR, if (enabled) 1u else 0u, intervalMinutes) - - /** - * HRV takes a 5-byte payload. Only the first two args are named in the SDK's own call sites; - * UNVERIFIED: the trailing three (window / weekday mask / reserved). Zero-filled — a wrong - * non-zero guess could arm a schedule we didn't intend. - */ - fun hrvMonitor(enabled: Boolean, intervalMinutes: UByte): List = - listOf(YCBTGroup.SETTING, YCBTSettingKey.HRV_MONITOR, if (enabled) 1u else 0u, intervalMinutes, 0u, 0u, 0u) -} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index a803150..1c7debf 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -1,51 +1,131 @@ package com.pulseloop.ring -import java.time.Instant - /** - * Ported from YCBTSyncEngine.swift (iOS #82). - * YCBT sync engine. Connect is a parameterized handshake (clock -> device interrogation -> locale - * -> all-day monitors -> user profile -> live-status stream), followed by the history sync. - * - * History is **not** driven from here. It is a protocol state machine in [YCBTHistoryTransfer] - * (owned by the driver, the only thing that sees frames): request -> header -> data frames -> - * terminal block -> mandatory ACK -> next type. This engine only seeds the queue. - * - * `runStartup()` doubles as the periodic re-sync on Android — `RingSyncCoordinator.syncNow()` and - * the 30-minute periodic pass both just call `engine.runStartup()` again (the same convention - * jring/Colmi already use), rather than a dedicated lighter-weight "re-fetch history only" call. - * `transfer.start` is a no-op while a transfer is already in flight, so this is safe to call - * repeatedly. + * Ported from YCBTSyncEngine.swift. + * YCBT sync engine. Connect is a parameterized handshake, followed by the history sync. */ + class YCBTSyncEngine( private val writer: RingCommandWriter?, private val transfer: YCBTHistoryTransfer, + private val profile: YCBTFamilyProfile = YCBTFamilyProfile( + baselineCapabilities = YCBTCoordinator.capabilities, + bitmapGatedCapabilities = YCBTCoordinator.bitmapGatedCapabilities, + ), ) : RingSyncEngine { + private val encoder = YCBTEncoder() + + private var measurementSettings = MeasurementSettings.ALL_ON_DEFAULT + private var userProfile = UserProfileValues(metric = true, gender = 0x02u, age = 25u, heightCm = 175u, weightKg = 70u) + private var requestActivityAfterStartupHistory = false + private var historyCapabilities = profile.baselineCapabilities + + companion object { + private val HISTORY_TYPES: List = listOf( + YCBTHistoryType.SPORT, YCBTHistoryType.SLEEP, YCBTHistoryType.HEART, + YCBTHistoryType.BLOOD, YCBTHistoryType.ALL, + YCBTHistoryType.SPO2, YCBTHistoryType.TEMPERATURE, + YCBTHistoryType.COMPREHENSIVE, YCBTHistoryType.BODY_DATA, + ) + private val VITALS_TYPES: List = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL) + } - /** - * Pushed in by `RingSyncCoordinator` before [runStartup], so the handshake carries the user's - * real configuration. Defaults keep a freshly-paired ring logging until the store is read. - */ - private var measurementSettings: MeasurementSettings = MeasurementSettings.ALL_ON_DEFAULT - private var userProfile = UserProfileValues(metric = true, gender = 0x02u, age = 0u, heightCm = 0u, weightKg = 0u) - - // MARK: Startup - + @Synchronized override fun runStartup() { - for (command in YCBTEncoder.startupSequence(measurement = measurementSettings, profile = userProfile)) { - writer?.enqueue(command.toRawByteArray()) + requestActivityAfterStartupHistory = true + for (command in encoder.startupSequence( + measurement = measurementSettings, + profile = userProfile, + capabilities = historyCapabilities, + queryChipScheme = profile.queryChipSchemeAtStartup, + supportsBloodPressureMonitor = profile.supportsBloodPressureMonitor, + )) { + writer?.enqueue(command) + } + transfer.start(types = supportedHistoryTypes(HISTORY_TYPES)) + } + + @Synchronized + override fun handle(event: RingDecodedEvent) { + if (event is RingDecodedEvent.SupportFunctions) { + val previousTypes = supportedHistoryTypes(HISTORY_TYPES).toSet() + val previousCapabilities = historyCapabilities + historyCapabilities = profile.baselineCapabilities + + event.capabilities.intersect(profile.bitmapGatedCapabilities) + for (command in encoder.monitorCommands( + measurementSettings, + historyCapabilities - previousCapabilities, + profile.supportsBloodPressureMonitor, + )) { + writer?.enqueue(command) + } + val addedCapabilities = historyCapabilities - previousCapabilities + val newlySupported = supportedHistoryTypes(HISTORY_TYPES) + .filterNot(previousTypes::contains) + .toMutableList() + // ALL carries optional fields as well as baseline SpO2. If capability discovery lands + // after its first pass, fetch it once more so newly accepted values are not lost. + if (addedCapabilities.any { + it == WearableCapability.BLOOD_PRESSURE || + it == WearableCapability.HRV || + it == WearableCapability.TEMPERATURE || + it == WearableCapability.BLOOD_SUGAR + }) { + newlySupported.add(YCBTHistoryType.ALL) + } + transfer.append(newlySupported) } - // The transfer machine writes the first `05 ` query itself and advances off the - // ring's terminal blocks. History steps arrive as an activity update (a per-day max - // ratchet) and history measurements upsert by (kind, timestamp), so a re-sync is already - // idempotent. - transfer.start(YCBTHistoryType.CATALOG) + // The early startup command enables live status while the ring is still processing its + // connect handshake. Some R10M firmware acknowledges it without immediately publishing + // the current cumulative activity. Ask once more after the startup history walk, when the + // connection is settled, so reconnect updates steps without requiring pull-to-refresh. + if (event is RingDecodedEvent.HistorySyncFinished && requestActivityAfterStartupHistory) { + requestActivityAfterStartupHistory = false + writer?.enqueue(encoder.enableLiveStatus()) + } + } + + @Synchronized + override fun refresh() { + // Ask for current cumulative activity before the slower multi-type history walk. Without + // this, pull-to-refresh can leave steps stale until an unsolicited live push arrives. + writer?.enqueue(encoder.enableLiveStatus()) + transfer.start(types = supportedHistoryTypes(HISTORY_TYPES)) + } + + @Synchronized + override fun querySleep() { + transfer.start(types = supportedHistoryTypes(listOf(YCBTHistoryType.SLEEP))) + } + + @Synchronized + override fun syncVitalsHistory() { + transfer.start(types = supportedHistoryTypes(VITALS_TYPES)) } - /** History is protocol-driven — nothing here advances it. */ - override fun handle(event: RingDecodedEvent) {} + @Synchronized + override fun syncSleepNow() { + transfer.start(types = supportedHistoryTypes(listOf(YCBTHistoryType.SLEEP))) + } - // MARK: All-day measurement config (the five `01 xx {enable, interval}` monitors) + private fun supportedHistoryTypes(types: List): List = + types.filter { type -> + when (type) { + YCBTHistoryType.SPORT -> WearableCapability.STEPS in historyCapabilities + YCBTHistoryType.SLEEP -> WearableCapability.SLEEP in historyCapabilities + YCBTHistoryType.HEART -> WearableCapability.HEART_RATE in historyCapabilities + YCBTHistoryType.BLOOD -> WearableCapability.BLOOD_PRESSURE in historyCapabilities + YCBTHistoryType.ALL -> true + YCBTHistoryType.SPO2 -> WearableCapability.SPO2_HISTORY in historyCapabilities + YCBTHistoryType.TEMPERATURE -> WearableCapability.TEMPERATURE in historyCapabilities + YCBTHistoryType.COMPREHENSIVE -> WearableCapability.BLOOD_SUGAR in historyCapabilities + YCBTHistoryType.BODY_DATA -> + WearableCapability.HRV in historyCapabilities || + WearableCapability.STRESS in historyCapabilities || + WearableCapability.FATIGUE in historyCapabilities + else -> false + } + } override fun setMeasurementSettings(settings: MeasurementSettings?) { if (settings != null) measurementSettings = settings @@ -53,76 +133,77 @@ class YCBTSyncEngine( override fun applyMeasurementSettings(settings: MeasurementSettings) { measurementSettings = settings - for (command in YCBTEncoder.monitorCommands(settings)) { - writer?.enqueue(command.toRawByteArray()) + for (command in encoder.monitorCommands( + settings, + historyCapabilities, + profile.supportsBloodPressureMonitor, + )) { + writer?.enqueue(command) } } - // MARK: User profile (`01 03`) - override fun setUserProfile(profile: UserProfileValues) { userProfile = profile } override fun applyUserProfile(profile: UserProfileValues) { userProfile = profile - writer?.enqueue(YCBTEncoder.userInfo(profile).toRawByteArray()) - } - - // MARK: Clock / battery - - /** The ring's stored records are stamped from its own RTC in local wall-clock, so a timezone - * change must be pushed or every subsequent record decodes to the wrong instant. */ - override fun resyncTime() { - writer?.enqueue(YCBTEncoder.setTime(Instant.now()).toRawByteArray()) + writer?.enqueue(encoder.userInfo(profile)) } - // MARK: Live actions (proprietary 06-stream on be940003, mode-selected by 03 2f) - override fun startHeartRate() { - writer?.enqueue(YCBTEncoder.heartRateStart().toRawByteArray()) + writer?.enqueue(encoder.heartRateStart()) } override fun stopHeartRate() { - writer?.enqueue(YCBTEncoder.heartRateStop().toRawByteArray()) + writer?.enqueue(encoder.heartRateStop()) } override fun startSpO2() { - writer?.enqueue(YCBTEncoder.spo2Start().toRawByteArray()) + writer?.enqueue(encoder.spo2Start()) } override fun stopSpO2() { - writer?.enqueue(YCBTEncoder.spo2Stop().toRawByteArray()) + writer?.enqueue(encoder.spo2Stop()) + } + + override fun startHRV() { + writer?.enqueue(encoder.hrvStart()) + } + + override fun stopHRV() { + writer?.enqueue(encoder.hrvStop()) } - /** - * "Measure now" (combined) drives the blood-pressure mode: it surfaces BP plus the HR the same - * sweep measures, on the shared `06 03` live-vitals frame. Unlike Colmi's single `0x24` combined - * command — which returns HR/BP/SpO2/stress/fatigue/bloodSugar/HRV all in one packet — YCBT's - * `03 2f` mode byte gates *which* fields the ring fills (BP mode fills SBP/DBP and zeroes HRV), - * so a single YCBT sweep cannot recover every metric simultaneously the way Colmi's can. - * Standalone HRV/temperature/stress/blood-sugar spot measurement rides the ring's own all-day - * monitors and history sync instead, matching this app's product surface (no dedicated - * "Measure HRV" screen exists on Android, unlike iOS's Vitals detail screens). - */ - override fun startCombinedMeasurement() { - writer?.enqueue(YCBTEncoder.bloodPressureStart().toRawByteArray()) + override fun startBloodPressure() { + writer?.enqueue(encoder.bloodPressureStart()) } - override fun stopCombinedMeasurement() { - writer?.enqueue(YCBTEncoder.bloodPressureStop().toRawByteArray()) + override fun stopBloodPressure() { + writer?.enqueue(encoder.bloodPressureStop()) } override fun findDevice() { - writer?.enqueue(YCBTEncoder.findDevice().toRawByteArray()) + writer?.enqueue(encoder.findDevice()) } - /** `SettingGoal 01 02` exists in the SDK but its payload shape is unverified for this ring; - * PulseLoop persists the goal app-side regardless. */ - override fun setGoal(steps: Int) {} + override fun setGoal(steps: Int) { + // Unverified for this ring; goal is persisted app-side. + } - /** No YCBT power-off/factory-reset opcode is implemented — neither TK5 nor SmartHealth-Colmi - * declare these capabilities, so the buttons stay hidden and these are never invoked. */ override fun powerOff() {} override fun factoryReset() {} + override fun startCombinedMeasurement() = startBloodPressure() + override fun stopCombinedMeasurement() = stopBloodPressure() + + override fun resyncTime() { + writer?.enqueue(encoder.setTime()) + } + override fun setUserInfo(ageYears: Int, isMale: Boolean, heightCm: Int, weightKg: Int) {} + override fun setBloodPressureAdjust(systolic: Int, diastolic: Int) {} + override fun setAppId(appId: String) {} + override fun setOnMeasurementConfigSeeded(callback: (MeasurementSettings) -> Unit) {} + override fun setOnBondRequested(callback: () -> Unit) {} + + override fun handleRawNotify(data: ByteArray) {} } diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 5481f5e..6f95dfc 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -29,12 +29,6 @@ class EventPersistenceSubscriber( private var lastBatteryLogAt: Long? = null private val batteryMinIntervalMs = 30 * 60_000L - /** Identity of a history sample within one sync run — see [isDuplicateHistory]. */ - private data class HistoryKey(val kind: String, val epochSecond: Long) - /** History samples already seen this sync. Cleared when a sync completes, so it can't grow - * unbounded across a long-lived session. */ - private val seenHistoryKeys = mutableSetOf() - fun start() { if (job != null) return job = scope.launch { @@ -62,6 +56,8 @@ class EventPersistenceSubscriber( is PulseEvent.HistoryMeasurement, is PulseEvent.StressSample, is PulseEvent.HrvSample, + is PulseEvent.BloodPressureSample, + is PulseEvent.BloodSugarSample, is PulseEvent.TemperatureSample, is PulseEvent.ActivityUpdate, is PulseEvent.ActivityBucket, @@ -86,14 +82,16 @@ class EventPersistenceSubscriber( RingConnectionState.CONNECTED -> { db.measurementDao().clearDemo() db.activityDailyDao().clearDemo() - // Sessions rebuild from the ring on every connect. Blocks must be wiped - // WITH them (no FK cascade at runtime): stale blocks keyed under an id a - // rebuilt session reuses — e.g. rows from the pre-waking-day keying, which - // filed a night's pre-midnight packets under what is now a different - // night's id — would otherwise be merged into that night by - // upsertSleepSession and corrupt its span and score. - db.sleepStageBlockDao().clear() - db.sleepSessionDao().clear() + if (preservesSleepOnConnect(event.deviceType, existing?.deviceType)) { + // YCBT status packets re-emit CONNECTED while history is still arriving. + db.sleepStageBlockDao().clearDemo() + db.sleepSessionDao().clearDemo() + } else { + // Packet-based families rebuild sleep on connect. Clear blocks with + // sessions so legacy midnight-keyed blocks cannot contaminate a rebuild. + db.sleepStageBlockDao().clear() + db.sleepSessionDao().clear() + } "CONNECTED" } RingConnectionState.DISCONNECTED -> "DISCONNECTED" @@ -131,15 +129,9 @@ class EventPersistenceSubscriber( )) } is PulseEvent.DeviceForgotten -> { - // Mirror iOS: forgetting clears the stored model identity (the row itself is - // cleared by the Forget flow; this covers a row that survives, e.g. offline forget). - db.deviceDao().currentReal()?.let { device -> - db.deviceDao().upsert(device.copy( - wearableModelID = null, - advertisedName = null, - updatedAt = System.currentTimeMillis(), - )) - } + // This event is queued after all earlier ring events, so deleting here prevents a + // pre-forget CONNECTED event still in the bus from recreating the cleared row. + db.deviceDao().currentReal()?.let { db.deviceDao().deleteById(it.id) } } is PulseEvent.BatteryLevel -> { val device = db.deviceDao().currentReal() ?: DeviceEntity() @@ -167,11 +159,8 @@ class EventPersistenceSubscriber( )) } is PulseEvent.HistoryMeasurement -> { - // History rows are upserted on (kind, timestamp): a ring replays the same log - // every time we re-request a day, and the epochs it stamps are deterministic, so - // an exact-timestamp match is a valid identity. - if (isDuplicateHistory(event.kind.name, event.value, event.timestamp.toEpochMilli())) return - db.measurementDao().insert(MeasurementEntity( + db.measurementDao().upsert(MeasurementEntity( + id = historyMeasurementId(event.kind, event.timestamp.toEpochMilli()), kindRaw = event.kind.name, value = event.value, unit = event.kind.unit, timestamp = event.timestamp.toEpochMilli(), @@ -179,28 +168,84 @@ class EventPersistenceSubscriber( )) } is PulseEvent.StressSample -> { - db.measurementDao().insert(MeasurementEntity( + val measurement = MeasurementEntity( + id = if (event.isHistory) { + historyMeasurementId(MeasurementKind.STRESS, event.timestamp.toEpochMilli()) + } else { + java.util.UUID.randomUUID().toString() + }, kindRaw = MeasurementKind.STRESS.name, value = event.value.toDouble(), unit = "", timestamp = event.timestamp.toEpochMilli(), sourceRaw = "colmi", - )) + ) + if (event.isHistory) db.measurementDao().upsert(measurement) + else db.measurementDao().insert(measurement) } is PulseEvent.HrvSample -> { db.measurementDao().insert(MeasurementEntity( kindRaw = MeasurementKind.HRV.name, value = event.value.toDouble(), unit = "ms", timestamp = event.timestamp.toEpochMilli(), - sourceRaw = "colmi", + sourceRaw = "live", )) } - is PulseEvent.TemperatureSample -> { + is PulseEvent.BloodPressureSample -> { + db.withTransaction { + val systolic = MeasurementEntity( + id = if (event.isHistory) { + historyMeasurementId(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, event.timestamp.toEpochMilli()) + } else { + java.util.UUID.randomUUID().toString() + }, + kindRaw = MeasurementKind.BLOOD_PRESSURE_SYSTOLIC.name, + value = event.systolic.toDouble(), unit = "mmHg", + timestamp = event.timestamp.toEpochMilli(), + sourceRaw = if (event.isHistory) "history" else "live", + ) + val diastolic = MeasurementEntity( + id = if (event.isHistory) { + historyMeasurementId(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, event.timestamp.toEpochMilli()) + } else { + java.util.UUID.randomUUID().toString() + }, + kindRaw = MeasurementKind.BLOOD_PRESSURE_DIASTOLIC.name, + value = event.diastolic.toDouble(), unit = "mmHg", + timestamp = event.timestamp.toEpochMilli(), + sourceRaw = if (event.isHistory) "history" else "live", + ) + if (event.isHistory) { + db.measurementDao().upsert(systolic) + db.measurementDao().upsert(diastolic) + } else { + db.measurementDao().insert(systolic) + db.measurementDao().insert(diastolic) + } + } + } + is PulseEvent.BloodSugarSample -> { db.measurementDao().insert(MeasurementEntity( + kindRaw = MeasurementKind.BLOOD_SUGAR.name, + value = event.mgdl, + unit = MeasurementKind.BLOOD_SUGAR.unit, + timestamp = event.timestamp.toEpochMilli(), + sourceRaw = "live", + )) + } + is PulseEvent.TemperatureSample -> { + val measurement = MeasurementEntity( + id = if (event.isHistory) { + historyMeasurementId(MeasurementKind.TEMPERATURE, event.timestamp.toEpochMilli()) + } else { + java.util.UUID.randomUUID().toString() + }, kindRaw = MeasurementKind.TEMPERATURE.name, value = event.celsius, unit = "°C", timestamp = event.timestamp.toEpochMilli(), - sourceRaw = "colmi", - )) + sourceRaw = "live", + ) + if (event.isHistory) db.measurementDao().upsert(measurement) + else db.measurementDao().insert(measurement) } is PulseEvent.ActivityUpdate -> { upsertActivityDaily(event.timestamp.toEpochMilli(), event.steps, event.calories, event.distanceMeters) @@ -213,7 +258,7 @@ class EventPersistenceSubscriber( applyActivityBucket(event.timestamp.toEpochMilli(), event.steps, event.distanceMeters) } is PulseEvent.SleepTimeline -> { - upsertSleepSession(event.timestamp.toEpochMilli(), event.stages) + upsertSleepSession(event.timestamp.toEpochMilli(), event.stages, event.completeSession) } is PulseEvent.SyncProgress -> { // Only "done" (a history sync actually completed) stamps lastFullSyncAt — the @@ -224,13 +269,12 @@ class EventPersistenceSubscriber( if (device != null) { db.deviceDao().upsert(device.copy(lastFullSyncAt = System.currentTimeMillis())) } - // The rows are committed by now; the next sync re-checks against the database. - seenHistoryKeys.clear() reconcileRecentlyFinishedWorkouts() } } is PulseEvent.HeartRateComplete -> {} is PulseEvent.Spo2Complete -> {} + is PulseEvent.MeasurementRejected -> {} // Product orchestration only; no persistence. is PulseEvent.WearState -> {} // Product orchestration only (fast-fail a measure); not persisted. is PulseEvent.RawPacket -> { db.rawPacketDao().insert(RawPacketEntity( @@ -254,20 +298,6 @@ class EventPersistenceSubscriber( } } - /** True when this history sample is already stored (updating the existing row's value in - * place). Two tiers: an in-process key set keeps the hot sync path off the database - * entirely, and a single indexed fetch catches re-syncs across launches. */ - private suspend fun isDuplicateHistory(kind: String, value: Double, timestampMs: Long): Boolean { - val key = HistoryKey(kind, timestampMs / 1000) - if (key in seenHistoryKeys) return true - seenHistoryKeys += key - - val existing = db.measurementDao().findHistoryAt(kind, timestampMs) ?: return false - // Same slot, possibly refined value (the ring can revise an averaged block). - db.measurementDao().updateValue(existing.id, value) - return true - } - /** * Post-workout vitals backfill (iOS #57e): a ring's own HR/SpO2 log often only reaches the * phone on the *next* sync — a reconnect right after finishing, or a delayed history flush — @@ -328,6 +358,10 @@ class EventPersistenceSubscriber( * iOS (the GadgetBridge model). Calories intentionally untouched (unverified ring field). */ private suspend fun applyActivityBucket(ts: Long, steps: Int, distanceM: Double) { + db.withTransaction { applyActivityBucketAtomic(ts, steps, distanceM) } + } + + private suspend fun applyActivityBucketAtomic(ts: Long, steps: Int, distanceM: Double) { val dayStart = com.pulseloop.util.TimeUtil.startOfDayLocal(ts) db.activityBucketDao().upsert(ActivityBucketEntity( startEpoch = ts, @@ -361,30 +395,41 @@ class EventPersistenceSubscriber( ) } - private suspend fun upsertSleepSession(ts: Long, stages: List) { - if (stages.isEmpty()) return + private suspend fun upsertSleepSession(ts: Long, stages: List, completeSession: Boolean) { + if (stages.isEmpty() || stages.size > MAX_SLEEP_TIMELINE_MINUTES) return + db.withTransaction { upsertSleepSessionAtomic(ts, stages, completeSession) } + } + + private suspend fun upsertSleepSessionAtomic(ts: Long, stages: List, completeSession: Boolean) { // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so // a night that starts before midnight lands under the morning of waking instead of being // split into two sessions at midnight. Matches the iOS reference // (PulseEventBus.persistSleepTimeline + Calendar.wakingDay(forSleepStart:)). val dayStart = com.pulseloop.util.TimeUtil.wakingDayLocal(ts) - - // The ring streams sleep as many 15-minute packets (a night in 0x39, daytime naps in 0x3E) - // that must be STITCHED, not overwritten. Gather every block already stored across the - // day's sessions, add this packet's, de-dup by absolute start time, then re-split the whole - // set into distinct sessions (main night vs. naps separated by a >= 60 min gap) via - // SleepSegmentation. Matches iOS reconcileWakingDay (PR #83). - val existing = db.sleepSessionDao().ringAllByDay(dayStart) + val packetEnd = ts + stages.size * 60_000L + // Include legacy rows keyed to the wrong day if they overlap this packet. Reconciliation + // re-points their surviving blocks to the correct waking day. + val overlapping = db.sleepSessionDao().ringOverlapping(ts, packetEnd) + val existing = (db.sleepSessionDao().ringAllByDay(dayStart) + overlapping).distinctBy { it.id } val existingBlocks = if (existing.isEmpty()) emptyList() else db.sleepStageBlockDao().forSessions(existing.map { it.id }) - val byStart = LinkedHashMap() - for (b in existingBlocks) byStart[b.startAt] = b - // buildStageBlocks needs a sessionId, but reconcile re-points every block, so a placeholder - // is fine — only startAt/durationMinutes/stageRaw survive the re-point. - for (b in buildStageBlocks("", ts, stages)) byStart.putIfAbsent(b.startAt, b) - val dayBlocks = byStart.values.sortedBy { it.startAt } + // YCBT complete records are authoritative for their interval, including shortened + // revisions. Packet-based families replace only the packet interval. In both cases the + // unaffected blocks remain available for SleepSegmentation to preserve separate naps. + val replacements = buildStageBlocks("", ts, stages) + val dayBlocks = replaceOverlappingSleepBlocks( + existing = if (completeSession) { + val replacedSessionIds = overlapping.mapTo(mutableSetOf()) { it.id } + existingBlocks.filterNot { it.sessionId in replacedSessionIds } + } else { + existingBlocks + }, + replacements = replacements, + replacementStart = ts, + replacementEnd = packetEnd, + ) reconcileWakingDay(dayStart, existing, dayBlocks) } @@ -547,6 +592,60 @@ class EventPersistenceSubscriber( else -> 40 } } + + private companion object { + const val MAX_SLEEP_TIMELINE_MINUTES = 24 * 60 + } +} + +internal fun historyMeasurementId(kind: MeasurementKind, timestamp: Long): String = + "history:${kind.key}:$timestamp" + +internal fun preservesSleepOnConnect( + eventDeviceType: RingDeviceType?, + persistedDeviceType: RingDeviceType? = null, +): Boolean = when (eventDeviceType ?: persistedDeviceType) { + // All three identifiers use YCBTDriver and share its repeated status packets plus async + // history transfer. Packet-based Colmi/Jring/CRP families still clear and rebuild on connect. + RingDeviceType.YCBT, RingDeviceType.TK5, RingDeviceType.COLMI_SMART_HEALTH -> true + else -> false +} + +internal fun shouldReplaceCompleteSleep( + existingStart: Long, + existingMinutes: Int, + incomingStart: Long, + incomingMinutes: Int, +): Boolean = existingStart == incomingStart || incomingMinutes > existingMinutes + +internal fun replaceOverlappingSleepBlocks( + existing: List, + replacements: List, + replacementStart: Long, + replacementEnd: Long, +): List { + val byStart = LinkedHashMap() + for (block in existing) { + val blockEnd = block.startAt + block.durationMinutes * 60_000L + if (blockEnd <= replacementStart || block.startAt >= replacementEnd) { + byStart[block.startAt] = block + continue + } + if (block.startAt < replacementStart) { + byStart[block.startAt] = block.copy( + durationMinutes = ((replacementStart - block.startAt) / 60_000L).toInt(), + ) + } + if (blockEnd > replacementEnd) { + byStart[replacementEnd] = block.copy( + id = "${block.id}:$replacementEnd", + startAt = replacementEnd, + durationMinutes = ((blockEnd - replacementEnd) / 60_000L).toInt(), + ) + } + } + for (block in replacements) byStart[block.startAt] = block + return byStart.values.sortedBy { it.startAt } } // Extension for Set CSV from WearableCapability.kt diff --git a/app/src/main/java/com/pulseloop/service/MetricsService.kt b/app/src/main/java/com/pulseloop/service/MetricsService.kt index 0bd0b4b..e27f212 100644 --- a/app/src/main/java/com/pulseloop/service/MetricsService.kt +++ b/app/src/main/java/com/pulseloop/service/MetricsService.kt @@ -109,9 +109,9 @@ object MetricsService { MeasurementKind.TEMPERATURE -> caps.contains(WearableCapability.TEMPERATURE) MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> caps.contains(WearableCapability.BLOOD_PRESSURE) MeasurementKind.BLOOD_SUGAR -> caps.contains(WearableCapability.BLOOD_SUGAR) - // No dedicated capability gates these (YCBT history-only fields, no separate - // SupportFunction bit) — supported wherever the ring actually reports them. - MeasurementKind.RESPIRATORY_RATE, MeasurementKind.VO2MAX -> true + MeasurementKind.RESPIRATORY_RATE -> db.measurementDao().latest(kind.name) != null + MeasurementKind.VO2MAX -> WearableCapability.VO2MAX in caps || + db.measurementDao().latest(kind.name) != null } } diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index f06790a..3156fb7 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -29,6 +29,10 @@ class RingSyncCoordinator( private set var spo2State: MeasureState = MeasureState.IDLE private set + var hrvState: MeasureState = MeasureState.IDLE + private set + var bloodPressureState: MeasureState = MeasureState.IDLE + private set var combinedState: MeasureState = MeasureState.IDLE private set var lastSyncAt: Long? = null @@ -57,11 +61,17 @@ class RingSyncCoordinator( /** Latest live SpO2 %, mirrored for UI without a query. */ var latestSpO2Value: Int? = null private set + var latestHrvValue: Int? = null + private set + var latestBloodPressure: Pair? = null + private set var workoutHRActive = false private set private var hrNoReadingReported = false private var spo2NoReadingReported = false + private var hrvNoReadingReported = false + private var bloodPressureNoReadingReported = false /** The ring told us it isn't on the finger during a spot measure (CRP wear-state push). Read by * the Vitals UI to show "put the ring on" instead of the generic steadiness hint. Set only when * the not-worn signal arrives *before* any reading, so a wear-state drop right after a good @@ -80,6 +90,9 @@ class RingSyncCoordinator( val connectionState: RingConnectionState get() = client.state.value.connectionState val isConnected: Boolean get() = connectionState == RingConnectionState.CONNECTED + /** Selects the single-packet Jring measurement flow. YCBT advertises manual BP/glucose + * capabilities but measures each vital with separate AppStartMeasurement modes. */ + val supportsCombinedMeasurement: Boolean get() = engine?.supportsCombinedMeasurement == true private val hrMeasureSeconds = HR_MEASURE_SECONDS.toLong() private val spo2MeasureSeconds = SPO2_MEASURE_SECONDS.toLong() @@ -95,11 +108,13 @@ class RingSyncCoordinator( * with no result — at 40s the outcome is a coin toss where the user watches the ring's * red LED work and gets an error anyway. */ const val SPO2_MEASURE_SECONDS = 60 - /** Upper-bound for a sequential HR+SpO₂ spot measurement; drives the UI countdown. + /** Intentional UX upper bound for sequential HR + SpO₂ + BP + HRV; drives the countdown. * Derived from the legs so the countdown can't desync when one is tuned. Post-#66 the - * HR leg samples its full window by design (no early exit), so this is a real bound, - * not slack. */ - const val SPOT_MEASURE_SECONDS = HR_MEASURE_SECONDS + SPO2_MEASURE_SECONDS + 1 + * HR leg samples its full window by design, so this is a real bound, not slack. */ + const val BP_MEASURE_SECONDS = 40 + const val HRV_MEASURE_SECONDS = 40 + const val SPOT_MEASURE_SECONDS = + HR_MEASURE_SECONDS + SPO2_MEASURE_SECONDS + BP_MEASURE_SECONDS + HRV_MEASURE_SECONDS + 3 /** Max time to wait for the pre-factory-reset history sync before resetting anyway. */ const val SYNC_BEFORE_RESET_TIMEOUT_MS = 30_000L } @@ -107,6 +122,8 @@ class RingSyncCoordinator( private val engine: RingSyncEngine? get() = client.syncEngine private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var streamJob: Job? = null + private var startupEngine: RingSyncEngine? = null + private var startupJob: Job? = null fun start() { streamJob?.cancel() @@ -119,39 +136,49 @@ class RingSyncCoordinator( fun stop() { streamJob?.cancel() streamJob = null + startupJob?.cancel() + startupJob = null + startupEngine = null } // MARK: - Actions /** Canonical startup sequence run on connect. */ fun runStartupSequence() { + val targetEngine = engine ?: return + if (!isConnected || startupEngine === targetEngine) return + startupEngine = targetEngine // Begin progress here (a real sync request), NOT on DeviceStateChanged(CONNECTED): // the ring re-emits CONNECTED on every 0x0C status packet, which would otherwise // keep resetting the bar to 0%. beginSyncProgress() - scope.launch { + startupJob?.cancel() + startupJob = scope.launch { // Push the persisted measurement config + profile into the engine BEFORE // runStartup so the connect handshake reflects them (the engine emits the // commands itself, so we don't double-send here). iOS #19 parity. // No persisted config (null) ⇒ the engine seeds one from the ring's own // reported settings; persist that as the device's initial config. val persisted = loadMeasurementSettings() - engine?.setMeasurementSettings(persisted) + if (!isConnected || engine !== targetEngine) return@launch + targetEngine.setMeasurementSettings(persisted) if (persisted == null) { - engine?.setOnMeasurementConfigSeeded { seeded -> + targetEngine.setOnMeasurementConfigSeeded { seeded -> scope.launch { persistSeededMeasurementConfig(db, seeded) } } } - loadUserProfileValues()?.let { engine?.setUserProfile(it) } + val profile = loadUserProfileValues() + if (!isConnected || engine !== targetEngine) return@launch + profile?.let { targetEngine.setUserProfile(it) } // Claim the ring for this app FIRST (0x48). The ring binds to the connecting app's // id and otherwise can stay mute after another app (e.g. the official one) claimed it. - apiKeyStore?.ringAppId?.let { engine?.setAppId(it) } - engine?.runStartup() + apiKeyStore?.ringAppId?.let { targetEngine.setAppId(it) } + targetEngine.runStartup() // Push the user's profile so the ring's blood-sugar (profile-derived) and // calorie algorithms run on real inputs. BP is a direct sensor reading and // does not depend on user info. Matches the official app, which calls // setUserInfo on every connect anyway. - pushUserSettingsFromStore() + pushUserSettingsFromStore(targetEngine) lastSyncAt = System.currentTimeMillis() } } @@ -184,10 +211,12 @@ class RingSyncCoordinator( } /** Read the stored profile + BP calibration and push them to the ring. */ - private fun pushUserSettingsFromStore() { + private fun pushUserSettingsFromStore(targetEngine: RingSyncEngine) { scope.launch { val profile = try { db.userProfileDao().get() } catch (_: Exception) { null } - applyUserSettings( + if (!isConnected || engine !== targetEngine) return@launch + applyUserSettingsToEngine( + targetEngine, profile, apiKeyStore?.bpAdjustSystolic ?: 0, apiKeyStore?.bpAdjustDiastolic ?: 0, @@ -204,23 +233,35 @@ class RingSyncCoordinator( */ fun applyUserSettings(profile: UserProfileEntity?, bpSystolic: Int, bpDiastolic: Int) { if (!isConnected) return + val targetEngine = engine ?: return + applyUserSettingsToEngine(targetEngine, profile, bpSystolic, bpDiastolic) + } + + private fun applyUserSettingsToEngine( + targetEngine: RingSyncEngine, + profile: UserProfileEntity?, + bpSystolic: Int, + bpDiastolic: Int, + ) { profile?.let { p -> val age = p.age val heightCm = p.heightCm?.toInt() val weightKg = p.weightKg?.toInt() if (age != null && heightCm != null && weightKg != null) { val isMale = p.sex?.equals("male", ignoreCase = true) == true - engine?.setUserInfo(age, isMale, heightCm, weightKg) + targetEngine.setUserInfo(age, isMale, heightCm, weightKg) } } if (bpSystolic in 1..300 && bpDiastolic in 1..300) { - engine?.setBloodPressureAdjust(bpSystolic, bpDiastolic) + targetEngine.setBloodPressureAdjust(bpSystolic, bpDiastolic) } } fun syncNow() { if (!isConnected) return - runStartupSequence() + beginSyncProgress() + engine?.refresh() + lastSyncAt = System.currentTimeMillis() } /** @@ -238,7 +279,7 @@ class RingSyncCoordinator( /** Pull-to-refresh entry point. */ suspend fun pullToRefresh() { if (isConnected) { - runStartupSequence() + syncNow() } else if (client.state.value.activeDeviceType != null) { client.connectLastKnown() } else { @@ -259,6 +300,7 @@ class RingSyncCoordinator( if (!workoutHRActive) return engine?.stopHeartRate() workoutHRActive = false + engine?.syncVitalsHistory() } /** @@ -278,11 +320,12 @@ class RingSyncCoordinator( fun querySleep() { if (!isConnected) return - engine?.runStartup() + engine?.querySleep() } fun findRing() { if (!isConnected) return + if (!client.state.value.activeCapabilities.contains(WearableCapability.FIND_DEVICE)) return engine?.findDevice() } @@ -296,12 +339,18 @@ class RingSyncCoordinator( * cleanup (e.g. clearing the device row) without tying it to a screen's lifecycle. */ fun forgetRing(onCleared: suspend () -> Unit) { - // client.forget() already sends the protocol unbind, waits for the ack, removes any + // client.forgetAndWait() sends the protocol unbind, waits for the ack, removes any // OS bond, and clears the stored peripheral. That is the whole forget. scope.launch { - client.forget() stop() - onCleared() + client.forgetAndWait() + try { + onCleared() + } finally { + // The coordinator is process-long; resume its event collector so a ring paired + // without restarting the app still receives sync and measurement events. + start() + } } } @@ -316,7 +365,7 @@ class RingSyncCoordinator( scope.launch { if (isConnected) { onProgress("Syncing latest data…") - runStartupSequence() + syncNow() // Wait for the history sync to drain (progress reaches 100 or clears), capped // so a stale link can never hang the reset. kotlinx.coroutines.withTimeoutOrNull(SYNC_BEFORE_RESET_TIMEOUT_MS) { @@ -330,9 +379,13 @@ class RingSyncCoordinator( // otherwise a slow queue silently swallows the wipe the user confirmed. client.awaitOpsFlushed() } - client.forget() stop() - onCleared() + client.forgetAndWait() + try { + onCleared() + } finally { + start() + } } } @@ -364,6 +417,8 @@ class RingSyncCoordinator( val caps = client.state.value.activeCapabilities if (caps.contains(WearableCapability.MANUAL_HEART_RATE)) measureHR() if (caps.contains(WearableCapability.MANUAL_SPO2)) measureSpO2() + if (caps.contains(WearableCapability.MANUAL_BLOOD_PRESSURE)) measureBloodPressure() + if (caps.contains(WearableCapability.MANUAL_HRV)) measureHRV() } suspend fun measureHR(): Int? { @@ -431,6 +486,54 @@ class RingSyncCoordinator( return result } + suspend fun measureBloodPressure(): Pair? { + if (bloodPressureState == MeasureState.MEASURING) return null + if (!isConnected) { bloodPressureState = MeasureState.FAILED; return null } + bloodPressureState = MeasureState.MEASURING + latestBloodPressure = null + bloodPressureNoReadingReported = false + val spotToken = spot.begin(YCBTMeasurementMode.BLOOD_PRESSURE) + engine?.startBloodPressure() + var result: Pair? = null + try { + result = pollForValue( + BP_MEASURE_SECONDS.toLong(), + { latestBloodPressure }, + { bloodPressureNoReadingReported || spot.isRejected(spotToken) }, + ) + } finally { + spot.end(spotToken) + engine?.stopBloodPressure() + restartWorkoutHeartRateIfActive() + bloodPressureState = if (result != null) MeasureState.DONE else MeasureState.FAILED + } + return result + } + + suspend fun measureHRV(): Int? { + if (hrvState == MeasureState.MEASURING) return null + if (!isConnected) { hrvState = MeasureState.FAILED; return null } + hrvState = MeasureState.MEASURING + latestHrvValue = null + hrvNoReadingReported = false + val spotToken = spot.begin(YCBTMeasurementMode.HRV) + engine?.startHRV() + var result: Int? = null + try { + result = pollForValue( + HRV_MEASURE_SECONDS.toLong(), + { latestHrvValue }, + { hrvNoReadingReported || spot.isRejected(spotToken) }, + ) + } finally { + spot.end(spotToken) + engine?.stopHRV() + restartWorkoutHeartRateIfActive() + hrvState = if (result != null) MeasureState.DONE else MeasureState.FAILED + } + return result + } + /** * Trigger the combined spot measurement (0x23). The ring replies with 0x24 carrying * blood pressure, SpO₂, stress, fatigue and blood sugar in one packet; those decode @@ -451,11 +554,11 @@ class RingSyncCoordinator( } } - private suspend fun pollForValue( + private suspend fun pollForValue( windowSec: Long, - value: () -> Int?, + value: () -> T?, abort: () -> Boolean, - ): Int? { + ): T? { val steps = (windowSec * 2).toInt() repeat(steps) { value()?.let { return it } @@ -481,11 +584,29 @@ class RingSyncCoordinator( is PulseEvent.Spo2Result -> { latestSpO2Value = event.value } + is PulseEvent.HrvSample -> { + if (hrvState == MeasureState.MEASURING) latestHrvValue = event.value + } + is PulseEvent.BloodPressureSample -> { + if (bloodPressureState == MeasureState.MEASURING) { + latestBloodPressure = event.systolic to event.diastolic + } + } is PulseEvent.Spo2Complete -> { if (spo2State == MeasureState.MEASURING && latestSpO2Value == null) { spo2NoReadingReported = true } } + is PulseEvent.MeasurementRejected -> { + spot.noteRejected(event.mode) + when (event.mode) { + YCBTMeasurementMode.HEART_RATE -> hrNoReadingReported = true + YCBTMeasurementMode.SPO2 -> spo2NoReadingReported = true + YCBTMeasurementMode.BLOOD_PRESSURE -> bloodPressureNoReadingReported = true + YCBTMeasurementMode.HRV -> hrvNoReadingReported = true + } + } + // The CRP ring pushes wear state; `worn == false` means no skin contact, so an optical // spot measure can't read (issue #29). Fast-fail the in-flight measure instead of idling // out the full window, and flag *why* — but only if no reading landed first (a wear-state @@ -508,25 +629,21 @@ class RingSyncCoordinator( RingConnectionState.CONNECTED -> lastSyncAt = System.currentTimeMillis() RingConnectionState.DISCONNECTED, RingConnectionState.FAILED, - RingConnectionState.IDLE -> clearSyncProgress() + RingConnectionState.IDLE -> { + clearSyncProgress() + startupEngine = null + } else -> {} } } - // `MeasurementRejected` has no PulseEvent of its own — it is a verdict on a command, - // not data — so the raw-packet feed (which carries every decoded frame) is where a - // measurement hears the ring say no (iOS `c8969a4`). - is PulseEvent.RawPacket -> { - val decoded = event.decoded - if (event.direction == PacketDirection.INCOMING && decoded is RingDecodedEvent.MeasurementRejected) { - spot.noteRejected(decoded.mode) - } - } // History records stream in oldest→newest; advance the progress bar by mapping // each record's timestamp onto the sync window. is PulseEvent.ActivityBucket -> advanceSyncProgress(event.timestamp.toEpochMilli()) is PulseEvent.ActivityUpdate -> advanceSyncProgress(event.timestamp.toEpochMilli()) is PulseEvent.SleepTimeline -> advanceSyncProgress(event.timestamp.toEpochMilli()) - is PulseEvent.HistoryMeasurement -> advanceSyncProgress(event.timestamp.toEpochMilli()) + is PulseEvent.HistoryMeasurement -> { + advanceSyncProgress(event.timestamp.toEpochMilli()) + } is PulseEvent.SyncProgress -> if (event.stage == "done") finishSyncProgressSoon() else -> {} } diff --git a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt index 779a743..7cb68bb 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt @@ -1,11 +1,18 @@ package com.pulseloop.service import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.* import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.ring.PulseEvent +import com.pulseloop.ring.PulseEventBus import com.pulseloop.ring.RingBLEClient +import com.pulseloop.ring.RingConnectionState import com.pulseloop.settings.ApiKeyStore import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.util.concurrent.TimeUnit @@ -55,6 +62,11 @@ class RingSyncWorker( } override suspend fun doWork(): Result { + // The foreground app owns the live GATT. An overdue periodic job can start at the same + // moment as MainActivity after process launch; opening a second client interleaves both + // command queues and lets this worker's cleanup disconnect the UI-owned session. + if (isAppForeground()) return Result.success() + val keyStore = ApiKeyStore(applicationContext) val db = PulseLoopDatabase.getInstance(applicationContext) @@ -63,7 +75,7 @@ class RingSyncWorker( val device = db.deviceDao().currentReal() if (device == null) return Result.success() - val bleClient = RingBLEClient(applicationContext) + val bleClient = RingBLEClient(applicationContext, transientOwner = true) // Load the persisted measurement config + profile up front so the connect // handshake pushes the user's saved settings. Null (never saved) makes the @@ -94,22 +106,38 @@ class RingSyncWorker( while (!connected && waited < 20_000L) { delay(1000) waited += 1000 + if (isAppForeground()) return@withTimeout Result.success() } if (!connected) { - bleClient.disconnect() return@withTimeout Result.success() } - // Let data stream in for a few seconds after connect - delay(15_000L) + // Let data stream in after connect, but yield ownership promptly if the app opens. + repeat(15) { + delay(1000L) + if (isAppForeground()) return@withTimeout Result.success() + } - // Disconnect cleanly - bleClient.disconnect() Result.success() } } catch (e: Exception) { Result.retry() + } finally { + // This client is worker-owned. Cancel its watchdog as well as closing GATT so it + // cannot reconnect after doWork() has returned. + val releasedConnection = bleClient.destroy() + // A private client must not overwrite a foreground client's state during an ownership + // race. If the app remains backgrounded, persist the worker's actual teardown. + if (releasedConnection && !isAppForeground()) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) + ) + } } } + + private suspend fun isAppForeground(): Boolean = withContext(Dispatchers.Main.immediate) { + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } } diff --git a/app/src/main/java/com/pulseloop/service/SpotMeasurementGate.kt b/app/src/main/java/com/pulseloop/service/SpotMeasurementGate.kt index 802c968..56edaba 100644 --- a/app/src/main/java/com/pulseloop/service/SpotMeasurementGate.kt +++ b/app/src/main/java/com/pulseloop/service/SpotMeasurementGate.kt @@ -19,14 +19,14 @@ import java.util.concurrent.atomic.AtomicInteger class SpotMeasurementGate { /** A handle to one in-flight spot measurement. Identity is [id], **not** the mode, so two * flows that somehow ran the same mode at once still could not end or abort each other. */ - data class Token internal constructor(internal val id: Int, val mode: UByte) + data class Token internal constructor(internal val id: Int, val mode: Int) /** The measurements currently mid-poll, and whether the ring has refused each. */ private val inFlight = LinkedHashMap() private val nextId = AtomicInteger(0) /** Arm the gate for one measurement and hand back its handle. */ - fun begin(mode: UByte): Token { + fun begin(mode: Int): Token { val token = Token(nextId.getAndIncrement(), mode) inFlight[token] = false return token @@ -44,7 +44,7 @@ class SpotMeasurementGate { /** The ring refused [mode]. Honoured only by the in-flight measurement(s) actually running * it — a late reply for a mode nothing is polling is ignored. */ - fun noteRejected(mode: UByte) { + fun noteRejected(mode: Int) { for (token in inFlight.keys) { if (token.mode == mode) inFlight[token] = true } @@ -52,5 +52,5 @@ class SpotMeasurementGate { /** The modes currently mid-poll. Read by tests; the coordinator drives everything through * tokens. */ - val modesInFlight: Set get() = inFlight.keys.map { it.mode }.toSet() + val modesInFlight: Set get() = inFlight.keys.map { it.mode }.toSet() } diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index d5bed3e..513c99e 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -39,6 +39,7 @@ import com.pulseloop.ui.components.AppHeader import com.pulseloop.ui.screens.* import com.pulseloop.ui.theme.PulseLoopTheme import com.pulseloop.ui.viewmodels.* +import com.pulseloop.util.TimeUtil import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -214,6 +215,9 @@ fun PulseLoopApp() { when (event) { Lifecycle.Event.ON_START -> { bgDisconnectJob?.cancel(); bgDisconnectJob = null + todayVM.refreshCurrentDay() + activityVM.refreshCurrentDay() + sleepVM.refreshReferenceNight() if (isFirstStart) isFirstStart = false else bleClient.reconnectIfNeeded() } Lifecycle.Event.ON_STOP -> { @@ -236,6 +240,17 @@ fun PulseLoopApp() { } } + // Keep an app left open overnight on the current local-day Room query. Rechecking at most + // once a minute also catches wall-clock or timezone changes without a lifecycle edge. + LaunchedEffect(todayVM, activityVM, sleepVM) { + while (true) { + delay(TimeUtil.millisUntilNextLocalDay().coerceAtMost(60_000L)) + todayVM.refreshCurrentDay() + activityVM.refreshCurrentDay() + sleepVM.refreshReferenceNight() + } + } + // ── Navigation ─────────────────────────────────────────────────── // Tab order + icons mirror iOS MainTab (AppTheme.swift): Today (circle.circle), // Vitals (heart), Activity (waveform.path.ecg), Sleep (moon), Coach (sparkles). diff --git a/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt b/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt index 5b40b4c..ee72b86 100644 --- a/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt +++ b/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt @@ -183,6 +183,7 @@ fun DeviceHeroCard( */ private fun fallbackRingImage(type: RingDeviceType?): Int? = when (type) { RingDeviceType.JRING -> R.drawable.ring_jring + RingDeviceType.YCBT -> R.drawable.ring_colmi_r10 // No dedicated product art yet for either YCBT family or LuckRing — falls back to the generic ring. RingDeviceType.COLMI_R02, RingDeviceType.TK5, RingDeviceType.COLMI_SMART_HEALTH, RingDeviceType.LUCK_RING, RingDeviceType.CRP, null -> null diff --git a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt index 9d2f8d0..f892a1d 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt @@ -249,6 +249,9 @@ private fun labelFor(event: PulseEvent): String = when (event) { is PulseEvent.HeartRateComplete -> "HR Done" is PulseEvent.Spo2Result -> "SpO₂" is PulseEvent.Spo2Complete -> "SpO₂ Done" + is PulseEvent.MeasurementRejected -> "Measure Rejected" + is PulseEvent.BloodPressureSample -> "Blood Pressure" + is PulseEvent.BloodSugarSample -> "Glucose" is PulseEvent.WearState -> if (event.worn) "Worn" else "Not Worn" is PulseEvent.HistoryMeasurement -> when (event.kind) { com.pulseloop.ring.MeasurementKind.HEART_RATE -> "HR History" @@ -261,7 +264,7 @@ private fun labelFor(event: PulseEvent): String = when (event) { com.pulseloop.ring.MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> "BP Dia" com.pulseloop.ring.MeasurementKind.BLOOD_SUGAR -> "Glucose" com.pulseloop.ring.MeasurementKind.RESPIRATORY_RATE -> "Resp Rate" - com.pulseloop.ring.MeasurementKind.VO2MAX -> "VO2max" + com.pulseloop.ring.MeasurementKind.VO2MAX -> "VO₂ max" } is PulseEvent.BatteryLevel -> "Battery" is PulseEvent.ActivityUpdate -> "Activity" @@ -282,6 +285,7 @@ private fun labelFor(event: PulseEvent): String = when (event) { private fun detailFor(event: PulseEvent): String = when (event) { is PulseEvent.HeartRateSample -> "${event.bpm} bpm" is PulseEvent.Spo2Result -> "${event.value}%" + is PulseEvent.BloodSugarSample -> "${event.mgdl.toInt()} mg/dL" is PulseEvent.HistoryMeasurement -> "${event.value.toInt()} ${event.kind.unit}" is PulseEvent.BatteryLevel -> "${event.percent}%" is PulseEvent.ActivityUpdate -> "${event.steps} steps" @@ -308,6 +312,7 @@ private fun hexDump(data: ByteArray): String { private fun colorFor(event: PulseEvent): Color = when (event) { is PulseEvent.HeartRateSample, is PulseEvent.HeartRateComplete -> Color(0xFFE53935) is PulseEvent.Spo2Result -> Color(0xFF1E88E5) + is PulseEvent.BloodSugarSample -> Color(0xFF00BCD4) is PulseEvent.HistoryMeasurement -> when (event.kind) { com.pulseloop.ring.MeasurementKind.HEART_RATE -> Color(0xFFE53935) com.pulseloop.ring.MeasurementKind.SPO2 -> Color(0xFF1E88E5) diff --git a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt index 17509e7..60e6635 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -76,6 +75,7 @@ fun PairingScreen( var isLooking by remember { mutableStateOf(false) } var didFireConnected by remember { mutableStateOf(false) } + var showDiagnostics by remember { mutableStateOf(false) } // The adapter flag isn't part of BLEState until a scan attempt, so poll it directly. var btReady by remember { mutableStateOf(bleClient.isBluetoothEnabled) } @@ -205,6 +205,39 @@ fun PairingScreen( modifier = Modifier.fillMaxWidth(), ) } + + if (!connected && (isLooking || state.diagnostics.isNotEmpty())) { + Text( + if (showDiagnostics) "Hide connection details" else "Show connection details", + fontSize = 12.sp, + color = PulseColors.textSecondary, + modifier = Modifier.clickable { showDiagnostics = !showDiagnostics }, + ) + } + } + + // Raw transport detail remains available for device bring-up without occupying the + // normal pairing surface or delaying successful navigation. + if (showDiagnostics && !connected && (isLooking || state.diagnostics.isNotEmpty())) { + val trace = buildList { + addAll(state.diagnostics.takeLast(4)) + state.lastError?.let { if (it !in this) add(it) } + }.joinToString("\n").ifBlank { "Waiting for BLE connection events…" } + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 88.dp) + .background(PulseColors.card) + .border(1.dp, PulseColors.borderSubtle) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + trace, + fontSize = 11.sp, + color = if (state.lastError != null) PulseColors.danger else PulseColors.textMuted, + ) + } } if (showsFooter) { @@ -282,32 +315,39 @@ private fun BrandTabs( selectedBrand: String, onSelect: (String) -> Unit, ) { - Row( - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - brands.forEach { brand -> - val isSelected = brand == selectedBrand - Box( - Modifier - .heightIn(min = 44.dp) - .clickable { onSelect(brand) }, - contentAlignment = Alignment.Center, + // Never hide a brand behind an undiscoverable horizontal gesture. Three pills per row + // fits the supported labels on narrow phones and naturally grows as models are added. + brands.chunked(3).forEach { rowBrands -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - brand, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = if (isSelected) Color.White else PulseColors.textSecondary, - modifier = Modifier - .clip(CircleShape) - .background(if (isSelected) PulseColors.accent else PulseColors.card) - .border(1.dp, if (isSelected) Color.Transparent else PulseColors.borderSubtle, CircleShape) - .padding(horizontal = 14.dp, vertical = 8.dp), - ) + rowBrands.forEach { brand -> + val isSelected = brand == selectedBrand + Box( + Modifier + .heightIn(min = 44.dp) + .clickable { onSelect(brand) }, + contentAlignment = Alignment.Center, + ) { + Text( + brand, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (isSelected) Color.White else PulseColors.textSecondary, + modifier = Modifier + .clip(CircleShape) + .background(if (isSelected) PulseColors.accent else PulseColors.card) + .border(1.dp, if (isSelected) Color.Transparent else PulseColors.borderSubtle, CircleShape) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) + } + } } } } diff --git a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt index 30699a6..c138a86 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt @@ -72,8 +72,14 @@ fun VitalsScreen( // • combined (56ff/Jring): one 0x23 packet → BP + SpO₂ + stress + fatigue + blood sugar // • spot (Colmi): sequential live HR + SpO₂ via the real-time command (0x69) // Colmi has no BP/glucose hardware, so it never qualifies for the combined flow. - val combinedMode = state.supportsBP || state.supportsGlucose - val spotMode = !combinedMode && (state.supportsManualHr || state.supportsManualSpo2) + // Manual BP/glucose capability alone is not evidence of Jring's 0x23 combined packet. + // In particular YCBT/R10M advertises those vitals but requires sequential 03/2f modes; + // routing it to measureCombined() silently called a protocol no-op. + val combinedMode = coordinator?.supportsCombinedMeasurement == true && + (state.supportsBP || state.supportsGlucose) + val spotMode = !combinedMode && ( + state.supportsManualHr || state.supportsManualSpo2 || state.supportsBP + ) val measureSeconds = if (combinedMode) com.pulseloop.service.RingSyncCoordinator.COMBINED_MEASURE_SECONDS else diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index 2fcaf4f..3c607eb 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -44,6 +44,8 @@ import com.pulseloop.notifications.CoachNotifications import com.pulseloop.ring.MeasurementKind import com.pulseloop.ring.RingBLEClient import com.pulseloop.ring.RingConnectionState +import com.pulseloop.ring.RingDeviceType +import com.pulseloop.ring.WearableCapability import com.pulseloop.service.GlucoseUnit import com.pulseloop.service.MetricZone import com.pulseloop.service.RingSyncCoordinator @@ -1170,6 +1172,11 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U val db = remember { PulseLoopDatabase.getInstance(context) } val scope = rememberCoroutineScope() val device = db.deviceDao().currentFlow().collectAsState(initial = null).value + val capabilities = device?.capabilities.orEmpty() + val minimumInterval = if (device?.deviceType == RingDeviceType.YCBT) 30 else 5 + val intervalSteps = ((60 - minimumInterval) / 5 - 1).coerceAtLeast(0) + val supportsStressSetting = WearableCapability.STRESS in capabilities && + device?.deviceType != RingDeviceType.YCBT var cfgHrEnabled by remember { mutableStateOf(true) } var cfgHrInterval by remember { mutableStateOf(5) } @@ -1180,13 +1187,16 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U var cfgLoaded by remember { mutableStateOf(false) } var cfgSavedMsg by remember { mutableStateOf(null) } - LaunchedEffect(device?.id) { + LaunchedEffect(device?.id, device?.capabilitiesRaw) { val id = device?.id ?: return@LaunchedEffect - db.deviceMeasurementConfigDao().byDevice(id)?.let { c -> - cfgHrEnabled = c.hrEnabled; cfgHrInterval = c.hrIntervalMinutes - cfgSpo2 = c.spo2Enabled; cfgStress = c.stressEnabled - cfgHrv = c.hrvEnabled; cfgTemp = c.temperatureEnabled - } + cfgLoaded = false + val config = db.deviceMeasurementConfigDao().byDevice(id) + cfgHrEnabled = config?.hrEnabled ?: true + cfgHrInterval = (config?.hrIntervalMinutes ?: minimumInterval).coerceIn(minimumInterval, 60) + cfgSpo2 = (config?.spo2Enabled ?: true) && WearableCapability.SPO2 in capabilities + cfgStress = (config?.stressEnabled ?: true) && supportsStressSetting + cfgHrv = (config?.hrvEnabled ?: true) && WearableCapability.HRV in capabilities + cfgTemp = (config?.temperatureEnabled ?: true) && WearableCapability.TEMPERATURE in capabilities cfgLoaded = true } @@ -1227,16 +1237,24 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U Slider( enabled = cfgLoaded, value = cfgHrInterval.toFloat(), - onValueChange = { cfgHrInterval = ((it / 5).toInt() * 5).coerceIn(5, 60); cfgSavedMsg = null }, - valueRange = 5f..60f, - steps = 10, // 5-minute stops: 5, 10, …, 60 + onValueChange = { cfgHrInterval = ((it / 5).toInt() * 5).coerceIn(minimumInterval, 60); cfgSavedMsg = null }, + valueRange = minimumInterval.toFloat()..60f, + steps = intervalSteps, ) } HorizontalDivider(Modifier.padding(vertical = 8.dp)) - VitalToggle("Blood oxygen (SpO₂)", cfgSpo2) { cfgSpo2 = it } - VitalToggle("Stress", cfgStress) { cfgStress = it } - VitalToggle("HRV", cfgHrv) { cfgHrv = it } - VitalToggle("Temperature", cfgTemp) { cfgTemp = it } + if (WearableCapability.SPO2 in capabilities) { + VitalToggle("Blood oxygen (SpO₂)", cfgSpo2) { cfgSpo2 = it } + } + if (supportsStressSetting) { + VitalToggle("Stress", cfgStress) { cfgStress = it } + } + if (WearableCapability.HRV in capabilities) { + VitalToggle("HRV", cfgHrv) { cfgHrv = it } + } + if (WearableCapability.TEMPERATURE in capabilities) { + VitalToggle("Temperature", cfgTemp) { cfgTemp = it } + } Spacer(Modifier.height(12.dp)) Button( @@ -1246,12 +1264,12 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U db.deviceMeasurementConfigDao().upsert( com.pulseloop.data.entity.DeviceMeasurementConfigEntity( deviceId = device.id, - hrIntervalMinutes = cfgHrInterval, + hrIntervalMinutes = cfgHrInterval.coerceIn(minimumInterval, 60), hrEnabled = cfgHrEnabled, - spo2Enabled = cfgSpo2, - stressEnabled = cfgStress, - hrvEnabled = cfgHrv, - temperatureEnabled = cfgTemp, + spo2Enabled = cfgSpo2 && WearableCapability.SPO2 in capabilities, + stressEnabled = cfgStress && supportsStressSetting, + hrvEnabled = cfgHrv && WearableCapability.HRV in capabilities, + temperatureEnabled = cfgTemp && WearableCapability.TEMPERATURE in capabilities, updatedAt = System.currentTimeMillis(), ) ) @@ -1297,6 +1315,8 @@ fun WearableSettingsScreen( val isConnected = bleState.connectionState == RingConnectionState.CONNECTED val supportsFactoryReset = bleState.activeCapabilities .contains(com.pulseloop.ring.WearableCapability.FACTORY_RESET) + val supportsFindDevice = bleState.activeCapabilities + .contains(com.pulseloop.ring.WearableCapability.FIND_DEVICE) var showFactoryReset by remember { mutableStateOf(false) } var resetting by remember { mutableStateOf(false) } var resetStatus by remember { mutableStateOf("") } @@ -1359,14 +1379,16 @@ fun WearableSettingsScreen( Spacer(Modifier.width(4.dp)) Text("Sync now") } - Spacer(Modifier.height(8.dp)) - OutlinedButton( - onClick = { coordinator?.findRing() }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.NotificationsActive, null, Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text("Find ring") + if (supportsFindDevice) { + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { coordinator?.findRing() }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.NotificationsActive, null, Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Find ring") + } } Spacer(Modifier.height(8.dp)) OutlinedButton( diff --git a/app/src/main/java/com/pulseloop/ui/screens/WorkoutSummaryScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/WorkoutSummaryScreen.kt index a5a8e26..9077caa 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/WorkoutSummaryScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/WorkoutSummaryScreen.kt @@ -554,4 +554,3 @@ private fun paceLabel(secPerUnit: Double, paceUnit: String): String { val total = Math.round(secPerUnit).toInt() return "%d:%02d %s".format(total / 60, total % 60, paceUnit) } - diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/CurrentDayValues.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/CurrentDayValues.kt new file mode 100644 index 0000000..dd324a6 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/CurrentDayValues.kt @@ -0,0 +1,11 @@ +package com.pulseloop.ui.viewmodels + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal fun currentDayValues( + dayKeys: StateFlow, + valuesForDay: (K) -> Flow, +): Flow = dayKeys.flatMapLatest(valuesForDay) diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt index d6eda64..ab2d391 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -35,7 +35,8 @@ import java.time.ZoneId class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore? = null) : ViewModel() { // Local midnight, not UTC — the Today dashboard rolls over at the device's local // midnight so daily stats line up with how the rest of the app keys per-day rows. - private val todayStart = com.pulseloop.util.TimeUtil.startOfTodayLocal() + private val todayStart = MutableStateFlow(TimeUtil.startOfTodayLocal()) + private val todayDate = MutableStateFlow(java.time.LocalDate.now().toString()) data class TodayState( val steps: Int? = null, @@ -70,9 +71,14 @@ class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore private val _state = MutableStateFlow(TodayState()) val state: StateFlow = _state.asStateFlow() + fun refreshCurrentDay() { + todayStart.value = TimeUtil.startOfTodayLocal() + todayDate.value = java.time.LocalDate.now().toString() + } + init { viewModelScope.launch { - db.activityDailyDao().byDayFlow(todayStart).collect { activity -> + currentDayValues(todayStart, db.activityDailyDao()::byDayFlow).collect { activity -> _state.update { it.copy( steps = activity?.steps, calories = activity?.calories, @@ -102,9 +108,10 @@ class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore } // Today's coach summary card (kind="today", scopeKey=local date). viewModelScope.launch { - db.coachSummaryDao() - .getFlow(com.pulseloop.coach.summaries.CoachSummaryKind.TODAY.rawValue, java.time.LocalDate.now().toString()) - .collect { summary -> _state.update { it.copy(coachSummary = summary) } } + currentDayValues(todayDate) { date -> + db.coachSummaryDao() + .getFlow(com.pulseloop.coach.summaries.CoachSummaryKind.TODAY.rawValue, date) + }.collect { summary -> _state.update { it.copy(coachSummary = summary) } } } viewModelScope.launch { db.deviceDao().currentFlow().collect { device -> @@ -222,6 +229,10 @@ class SleepViewModel(private val db: PulseLoopDatabase) : ViewModel() { viewModelScope.launch { try { rebuild(range) } catch (_: Exception) {} } } + fun refreshReferenceNight() { + viewModelScope.launch { try { rebuild(_state.value.range) } catch (_: Exception) {} } + } + init { // Any sleep-table change retriggers a rebuild of the selected range. viewModelScope.launch { @@ -382,9 +393,14 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { private val _state = MutableStateFlow(ActivityState()) val state: StateFlow = _state.asStateFlow() + private val todayStart = MutableStateFlow(TimeUtil.startOfTodayLocal()) private val db = db + fun refreshCurrentDay() { + todayStart.value = TimeUtil.startOfTodayLocal() + } + init { viewModelScope.launch { db.activityDailyDao().recentFlow(7).collect { days -> @@ -392,7 +408,7 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { } } viewModelScope.launch { - db.activityDailyDao().byDayFlow(TimeUtil.startOfTodayLocal()).collect { day -> + currentDayValues(todayStart, db.activityDailyDao()::byDayFlow).collect { day -> _state.update { it.copy(today = day) } } } @@ -444,6 +460,7 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { * Ported from MetricsService.metricRange in PulseServices.swift. * Uses reactive polling so data appears as soon as the ring syncs. */ +@OptIn(kotlinx.coroutines.FlowPreview::class) class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore? = null) : ViewModel() { data class VitalsState( val hrSamples: List = emptyList(), @@ -498,6 +515,14 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore val state: StateFlow = _state.asStateFlow() init { + // Room is the display source of truth. Rebuild after inserts commit so a manual reading + // cannot lose a race between the coordinator seeing its event and the persistence + // subscriber writing it. Debounce coalesces large history batches into one rebuild. + viewModelScope.launch { + db.measurementDao().changeFlow().debounce(250).collect { + try { refresh(db) } catch (_: Exception) {} + } + } // Poll every 5 seconds so data appears as the ring syncs history viewModelScope.launch { while (true) { diff --git a/app/src/main/java/com/pulseloop/util/TimeUtil.kt b/app/src/main/java/com/pulseloop/util/TimeUtil.kt index 2f86327..c46dfdc 100644 --- a/app/src/main/java/com/pulseloop/util/TimeUtil.kt +++ b/app/src/main/java/com/pulseloop/util/TimeUtil.kt @@ -1,5 +1,6 @@ package com.pulseloop.util +import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.temporal.ChronoUnit @@ -22,6 +23,16 @@ object TimeUtil { fun startOfTodayLocal(zone: ZoneId = ZoneId.systemDefault()): Long = startOfDayLocal(System.currentTimeMillis(), zone) + /** Delay from [nowMs] until the next local midnight, accounting for 23/25-hour DST days. */ + fun millisUntilNextLocalDay( + nowMs: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + ): Long { + val now = Instant.ofEpochMilli(nowMs).atZone(zone) + val nextDay = now.toLocalDate().plusDays(1).atStartOfDay(zone) + return Duration.between(now, nextDay).toMillis().coerceAtLeast(1L) + } + /** * Hour-of-day boundary between "belongs to last night" and "belongs to the coming night." * Sleep starting at or after this hour rolls onto the *next* morning's waking day; anything diff --git a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt index 3450ddf..05f9482 100644 --- a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt +++ b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt @@ -65,6 +65,25 @@ data class WearableModel( requiresOsBond = true) val COLMI_R12 = colmi("colmi-r12", "Colmi R12", "Colmi", "^COLMI R12_.*", R.drawable.ring_colmi_r12) + /** + * YCBT / SmartHealth family — a distinct protocol from the QRing Colmi rings above, so this + * runs [RingDeviceType.YCBT]'s driver, not the Colmi one, despite the R10-ish model number. + * + * "R10M" is a white-label ODM model sold under several reseller brands (LittleMeatball, + * Anarow, JTLlink, MOMOTECH). LittleMeatball is the one hardware-validated unit and the name + * a buyer is most likely to recognise, so it leads the display name. The pattern accepts + * both separators: `R10M FCF4` (the tested unit) is also caught by the deliberately broad + * [COLMI_SMARTHEALTH] entry — which this precedes in [CATALOG], so it wins — while + * `R10M_FCF4` matches nothing else in the catalog at all. + * + * No dedicated product art: it is not a Colmi ring, so it must not borrow Colmi art. Falls + * back to the generic ring silhouette, same as [TK5] and [LUCK_RING_TK18]. + */ + val R10M = ycbt( + "r10m", "R10M (LittleMeatball)", "LittleMeatball", + "^R10M[ _][0-9A-F]{4}$", imageRes = null, + ) + /** * The **CRP-firmware** R11 — same physical ring as [COLMI_R11], but its official app is * Moyoung "Da Rings" and it speaks the proprietary `fdda` CRP protocol, not the Colmi/QRing @@ -139,11 +158,24 @@ data class WearableModel( requiresOsBond = requiresOsBond, ) + private fun ycbt( + id: String, + name: String, + brand: String, + pattern: String, + @DrawableRes imageRes: Int?, + ) = WearableModel( + id = id, displayName = name, brand = brand, family = RingDeviceType.YCBT, + tint = PulseColors.hrv, blurb = "HR · SpO₂ · BP · Sleep", + advertisedNamePatterns = listOf(pattern), + imageRes = imageRes, + ) + /** Every supported model. The pairing screen groups by brand and sorts each tab alphabetically. */ val CATALOG: List = listOf( COLMI_R02, COLMI_R06, COLMI_R10, YAWELL_R11, JRING, COLMI_R03, COLMI_R07, COLMI_R08, COLMI_R09, COLMI_R11, COLMI_R12, - YAWELL_R05, YAWELL_R10, H59, TK5, LUCK_RING_TK18, COLMI_R11_CRP, + YAWELL_R05, YAWELL_R10, H59, R10M, TK5, LUCK_RING_TK18, COLMI_R11_CRP, // Broadest pattern last: every narrower QRing-Colmi/TK5 entry above gets first shot // in modelForAdvertisedName's scan, so this can only match a name nothing else claims. COLMI_SMARTHEALTH, diff --git a/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt index 8b091dd..fe500a0 100644 --- a/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt @@ -15,6 +15,23 @@ import java.time.ZoneOffset class ColmiDecoderTest { private val zone = ZoneOffset.UTC + @Test + fun `HRV history remains distinct from a live HRV sample`() { + val frame = ColmiPacket.frame(byteArrayOf( + ColmiCommandID.SYNC_HRV.toByte(), 0x01, 0x1e, 42, + )) + + val events = ColmiDecoder.decodeHistory( + frame, + day = LocalDate.of(2026, 7, 17), + zone = zone, + ) + + val sample = events.single() as RingDecodedEvent.HistoryMeasurement + assertEquals(MeasurementKind.HRV, sample.kind_field) + assertEquals(42.0, sample.value, 0.0) + } + // MARK: Framing / checksum @Test @@ -178,6 +195,7 @@ class ColmiDecoderTest { val events = ColmiDecoder.decodeBigData(frame, zone = zone) val temps = events.filterIsInstance() assertEquals(35.0, temps.first().celsius, 0.01) + assertTrue(temps.first().isHistory) } @Test diff --git a/app/src/test/java/com/pulseloop/ring/ColmiQringParityTest.kt b/app/src/test/java/com/pulseloop/ring/ColmiQringParityTest.kt index a672cef..fe9c812 100644 --- a/app/src/test/java/com/pulseloop/ring/ColmiQringParityTest.kt +++ b/app/src/test/java/com/pulseloop/ring/ColmiQringParityTest.kt @@ -268,6 +268,7 @@ class ColmiQringParityTest { assertEquals(1, events.size) val sample = events.single() as RingDecodedEvent.TemperatureSample assertEquals(36.26, sample.celsius, 0.001) + assertTrue(sample.isHistory) val expectedDay = LocalDate.now(zone).minusDays(1).atStartOfDay(zone).toInstant() assertEquals(expectedDay, sample._timestamp) diff --git a/app/src/test/java/com/pulseloop/ring/ExistingFamilyRefreshContractTest.kt b/app/src/test/java/com/pulseloop/ring/ExistingFamilyRefreshContractTest.kt new file mode 100644 index 0000000..9bf0a71 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/ExistingFamilyRefreshContractTest.kt @@ -0,0 +1,48 @@ +package com.pulseloop.ring + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExistingFamilyRefreshContractTest { + private class FakeWriter : RingCommandWriter { + val sent = mutableListOf() + override fun enqueue(command: ByteArray) { + sent += command.copyOf() + } + } + + @Test + fun `Jring refresh and query sleep retain startup behavior`() { + val startup = capture { JringSyncEngine(it).runStartup() } + val refresh = capture { JringSyncEngine(it).refresh() } + val sleep = capture { JringSyncEngine(it).querySleep() } + + assertEquals(startup, refresh) + assertEquals(startup, sleep) + } + + @Test + fun `Colmi refresh and query sleep retain startup behavior`() { + val startup = captureColmi { it.runStartup() } + val refresh = captureColmi { it.refresh() } + val sleep = captureColmi { it.querySleep() } + + assertEquals(startup, refresh) + assertEquals(startup, sleep) + } + + private fun capture(action: (FakeWriter) -> Unit): List> { + val writer = FakeWriter() + action(writer) + return writer.sent.map(ByteArray::toList) + } + + private fun captureColmi(action: (ColmiSyncEngine) -> Unit): List> { + val writer = FakeWriter() + val engine = ColmiSyncEngine(writer, ColmiDecoder) + action(engine) + val commands = writer.sent.map(ByteArray::toList) + engine.destroy() + return commands + } +} diff --git a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt index c01f051..4e06bdf 100644 --- a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt +++ b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt @@ -22,12 +22,18 @@ class PairingMatchingTest { val types = RingDeviceType.entries.map { it.name } assertTrue(types.contains("JRING")) assertTrue(types.contains("COLMI_R02")) + assertTrue(types.contains("YCBT")) + assertTrue(types.contains("TK5")) + assertTrue(types.contains("COLMI_SMART_HEALTH")) + assertTrue(types.contains("LUCK_RING")) + assertTrue(types.contains("CRP")) } @Test fun `device type display names are meaningful`() { assertEquals("SMART_RING", RingDeviceType.JRING.displayName) assertEquals("Colmi / Yawell ring", RingDeviceType.COLMI_R02.displayName) + assertEquals("YCBT / SmartHealth ring", RingDeviceType.YCBT.displayName) } // ── Coordinator name matching (delegated to the WearableModel catalog, iOS #49) ──── @@ -46,7 +52,7 @@ class PairingMatchingTest { @Test fun `non-colmi names do not match`() { - for (name in listOf("SMART_RING", "Mi Band 5", "Galaxy Watch", "R0X_NOPE", "Random")) { + for (name in listOf("SMART_RING", "R10M FCF4", "Mi Band 5", "Galaxy Watch", "R0X_NOPE", "Random")) { assertFalse("did not expect Colmi match for $name", colmiMatches(name)) } } @@ -63,6 +69,58 @@ class PairingMatchingTest { assertTrue(JringCoordinator.matches("SMART_RING", noAdv)) } + @Test + fun `R10M is claimed only by YCBT`() { + for (name in listOf("R10M FCF4", "R10M_FCF4")) { + assertTrue(YCBTCoordinator.matches(name, noAdv)) + assertFalse(ColmiCoordinator.matches(name, noAdv)) + assertFalse(JringCoordinator.matches(name, noAdv)) + } + } + + @Test + fun `YCBT does not shadow uncataloged TK5 or SmartHealth-family names`() { + // YCBTCoordinator is registered ahead of TK5Coordinator/ColmiSmartHealthCoordinator, so it + // must claim only the R10M by name — never the other YCBT-family prefixes. An uncataloged + // TK5/SR0x/R0x unit has to fall through to its own coordinator's name/manufacturer fallback. + for (name in listOf("TK5_1234", "TK5 1234", "T50_1234", "SR09_1234", "SR08_1234", "R08 1234", "R09 1234")) { + assertFalse("YCBT should not claim $name by name", YCBTCoordinator.matches(name, noAdv)) + } + // TK5's own fallback still recognizes the uncataloged unit. + assertTrue(TK5Coordinator.matches("TK5_1234", noAdv)) + } + + @Test + fun `YCBT still claims an R10M advertising only its proprietary service`() { + val adv = AdvertisementInfo(listOf(YCBTUUIDs.SERVICE), null) + assertTrue(YCBTCoordinator.matches("Unlabeled", adv)) + } + + @Test + fun `R10M catalog entry uses the discoverable retail name`() { + assertTrue(WearableModel.R10M in WearableModel.CATALOG) + assertEquals("LittleMeatball", WearableModel.R10M.brand) + // Model number first, reseller brand in parentheses: the model is what the ring advertises + // and what every reseller shares, the brand is what the buyer recognises. + assertEquals("R10M (LittleMeatball)", WearableModel.R10M.displayName) + // Never Colmi art — the R10M is a YCBT-protocol ring, not a Colmi one. + assertNull(WearableModel.R10M.imageRes) + } + + @Test + fun `YCBT find device is enabled only by the support bitmap`() { + assertFalse(YCBTCoordinator.capabilities.contains(WearableCapability.FIND_DEVICE)) + assertTrue(YCBTCoordinator.bitmapGatedCapabilities.contains(WearableCapability.FIND_DEVICE)) + assertFalse(YCBTCoordinator.capabilities.contains(WearableCapability.SPO2_HISTORY)) + } + + @Test + fun `YCBT manufacturer marker does not override QRing service`() { + val manufacturer = byteArrayOf(0x10, 0x78) + assertFalse(YCBTCoordinator.matches("Unlabeled", AdvertisementInfo(emptyList(), manufacturer))) + assertFalse(YCBTCoordinator.matches("Unlabeled", AdvertisementInfo(listOf(ColmiUUIDs.SERVICE_V2), manufacturer))) + } + @Test fun `jring does not claim SMART_RING when the device also advertises a colmi service (issue 29)`() { // Some Colmi/Yawell R11 units advertise the generic factory name "SMART_RING" while @@ -81,7 +139,7 @@ class PairingMatchingTest { @Test fun `catalog families all have a registered coordinator`() { val registeredTypes = setOf( - JringCoordinator.deviceType, ColmiCoordinator.deviceType, + JringCoordinator.deviceType, ColmiCoordinator.deviceType, YCBTCoordinator.deviceType, TK5Coordinator.deviceType, ColmiSmartHealthCoordinator.deviceType, LuckRingCoordinator.deviceType, CRPCoordinator.deviceType, ) @@ -109,6 +167,8 @@ class PairingMatchingTest { "R10_DEAD" to "yawell-r10", "R11_BEEF" to "yawell-r11", "H59_anything" to "h59", + "R10M FCF4" to "r10m", + "R10M_FCF4" to "r10m", ) for ((name, modelID) in expected) { assertEquals(name, modelID, WearableModel.modelForAdvertisedName(name)?.id) diff --git a/app/src/test/java/com/pulseloop/ring/PulseEventBusTest.kt b/app/src/test/java/com/pulseloop/ring/PulseEventBusTest.kt new file mode 100644 index 0000000..63e11e9 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/PulseEventBusTest.kt @@ -0,0 +1,28 @@ +package com.pulseloop.ring + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Test + +class PulseEventBusTest { + @Test + fun `non-suspending publisher does not drop a history-sized burst`() = runBlocking { + val eventCount = 1_000 + val collected = async(start = CoroutineStart.UNDISPATCHED) { + PulseEventBus.events + .filterIsInstance() + .take(eventCount) + .toList() + } + + repeat(eventCount) { PulseEventBus.publishBlocking(PulseEvent.FirmwareVersion(it)) } + + assertEquals(eventCount, withTimeout(2_000) { collected.await() }.size) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt index 20e3f2f..3f50996 100644 --- a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt +++ b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt @@ -280,6 +280,65 @@ class RingEventBridgeTest { assertTrue(RingEventBridge.eventsFor(ts, now).isEmpty()) } + @Test + fun `measurement rejection reaches product orchestration`() { + val events = RingEventBridge.eventsFor( + RingDecodedEvent.MeasurementRejected(YCBTMeasurementMode.SPO2), now + ) + assertEquals(listOf(PulseEvent.MeasurementRejected(YCBTMeasurementMode.SPO2)), events) + } + + @Test + fun `live blood pressure preserves live identity`() { + val events = RingEventBridge.eventsFor( + RingDecodedEvent.BloodPressureSample(118, 79, now), now + ) + + assertEquals(listOf(PulseEvent.BloodPressureSample(118, 79, now)), events) + assertFalse(events.any { it is PulseEvent.HistoryMeasurement }) + } + + @Test + fun `historical blood pressure and HRV retain history identity`() { + val systolic = RingEventBridge.eventsFor( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, 130.0, now), now + ).single() + val hrv = RingEventBridge.eventsFor( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.HRV, 55.0, now), now + ).single() + + assertTrue(systolic is PulseEvent.HistoryMeasurement) + assertTrue(hrv is PulseEvent.HistoryMeasurement) + assertFalse(systolic is PulseEvent.BloodPressureSample) + assertFalse(hrv is PulseEvent.HrvSample) + } + + @Test + fun `implausible historical measurements are dropped`() { + assertTrue( + RingEventBridge.eventsFor( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.SPO2, 255.0, now), now + ).isEmpty() + ) + assertTrue( + RingEventBridge.eventsFor( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.TEMPERATURE, 255.0, now), now + ).isEmpty() + ) + assertTrue( + RingEventBridge.eventsFor( + RingDecodedEvent.BloodPressureSample(255, 255, now, isHistory = true), now + ).isEmpty() + ) + } + + @Test + fun `live blood sugar keeps live event identity`() { + val events = RingEventBridge.eventsFor(RingDecodedEvent.BloodSugarSample(99.0, now), now) + + assertEquals(listOf(PulseEvent.BloodSugarSample(99.0, now)), events) + } + @Test fun `band function reply produces no output`() { val bf = RingDecodedEvent.BandFunction(JringBandCapabilities(byteArrayOf(0, 0, 0))) diff --git a/app/src/test/java/com/pulseloop/ring/SubscriptionSetupGateTest.kt b/app/src/test/java/com/pulseloop/ring/SubscriptionSetupGateTest.kt new file mode 100644 index 0000000..bde79ad --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/SubscriptionSetupGateTest.kt @@ -0,0 +1,62 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test + +class SubscriptionSetupGateTest { + private val command = YCBTUUIDs.COMMAND + private val stream = YCBTUUIDs.STREAM + private val required = listOf( + RequiredSubscription(command, SubscriptionMode.INDICATION), + RequiredSubscription(stream, SubscriptionMode.INDICATION), + ) + + @Test + fun `YCBT is ready only after both declared CCCDs succeed`() { + val gate = SubscriptionSetupGate(listOf(command, stream), required) + gate.observeCharacteristic(command, localEnabled = true, hasCccd = true) + gate.observeCharacteristic(stream, localEnabled = true, hasCccd = true) + assertNull(gate.topologyFailure()) + + gate.descriptorWritten(command, successful = true) + assertFalse(gate.isReady) + gate.descriptorWritten(stream, successful = true) + assertTrue(gate.isReady) + } + + @Test + fun `missing required channel gives a useful topology failure`() { + val gate = SubscriptionSetupGate(listOf(command, stream), required) + gate.observeCharacteristic(command, localEnabled = true, hasCccd = true) + + val failure = gate.topologyFailure() + assertNotNull(failure) + assertTrue(failure!!.contains("BE940003")) + } + + @Test + fun `missing CCCD or failed local enable cannot satisfy topology`() { + val missingCccd = SubscriptionSetupGate(listOf(command, stream), required) + missingCccd.observeCharacteristic(command, localEnabled = true, hasCccd = true) + missingCccd.observeCharacteristic(stream, localEnabled = true, hasCccd = false) + assertNotNull(missingCccd.topologyFailure()) + + val failedEnable = SubscriptionSetupGate(listOf(command, stream), required) + failedEnable.observeCharacteristic(command, localEnabled = true, hasCccd = true) + failedEnable.observeCharacteristic(stream, localEnabled = false, hasCccd = true) + assertNotNull(failedEnable.topologyFailure()) + } + + @Test + fun `legacy optional channels connect after first successful notify`() { + val first = "00000001-0000-1000-8000-00805f9b34fb" + val optional = "00000002-0000-1000-8000-00805f9b34fb" + val gate = SubscriptionSetupGate(listOf(first, optional), emptyList()) + gate.observeCharacteristic(first, localEnabled = true, hasCccd = true) + gate.descriptorWritten(first, successful = true) + + assertTrue(gate.isReady) + assertNull(gate.topologyFailure()) + assertEquals(SubscriptionMode.NOTIFICATION, gate.modeFor(optional)) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt new file mode 100644 index 0000000..02b5c47 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt @@ -0,0 +1,212 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test +import java.time.Instant +import java.time.LocalDateTime +import java.util.TimeZone + +class YCBTDecoderTest { + private val decoder = YCBTDecoder() + + @Test + fun `live heart rate decodes`() { + val frame = YCBTFrame.validating(hexToBytes("06010700521a55"))!! + val events = decoder.decode(frame) + val hr = events.first() as RingDecodedEvent.HeartRateSample + assertEquals(82, hr.bpm) + } + + @Test + fun `live status decodes steps distance calories`() { + val frame = YCBTFrame.validating(hexToBytes("06000c007b0297011a00b60d"))!! + val events = decoder.decode(frame) + val activity = events.first() as RingDecodedEvent.ActivityUpdate + assertEquals(635, activity.steps) + assertEquals(407, activity.distanceMeters) + assertEquals(26, activity.calories) + } + + @Test + fun `live vitals decodes BP shape`() { + val frame = YCBTFrame.validating(hexToBytes("060314006f4a44000000000000000000000074f1"))!! + val events = decoder.decode(frame) + val bp = events[0] as RingDecodedEvent.BloodPressureSample + assertEquals(111, bp.systolic) + assertEquals(74, bp.diastolic) + val hr = events.first { it is RingDecodedEvent.HeartRateSample } as RingDecodedEvent.HeartRateSample + assertEquals(68, hr.bpm) + } + + @Test + fun `live vitals decodes HRV shape`() { + val frame = YCBTFrame.validating(hexToBytes("06031400000000b1000000000000000000001579"))!! + val events = decoder.decode(frame) + val hrv = events.first() as RingDecodedEvent.HrvSample + assertEquals(177, hrv.value) + assertEquals(1, events.size) + } + + @Test + fun `live vitals decodes SpO2 and temperature tail`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x06, 0x03, 118, 79, 70, 42, 97, 36, 4)))!! + val events = decoder.decode(frame) + val spo2 = events.first { it is RingDecodedEvent.Spo2Result } as RingDecodedEvent.Spo2Result + assertEquals(97, spo2.value) + val temp = events.first { it is RingDecodedEvent.TemperatureSample } as RingDecodedEvent.TemperatureSample + assertEquals(36.4, temp.celsius, 0.001) + } + + @Test + fun `battery push decodes`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x06, 0x15, 0x01, 0x5b)))!! + val events = decoder.decode(frame) + val battery = events.first() as RingDecodedEvent.Battery + assertEquals(91, battery.percent) + } + + @Test + fun `wearing status push decodes worn and not worn`() { + val seconds = YCBTBytes.ringSeconds(Instant.now()) + val ts = byteArrayOf( + (seconds and 0xFF).toByte(), + ((seconds shr 8) and 0xFF).toByte(), + ((seconds shr 16) and 0xFF).toByte(), + ((seconds shr 24) and 0xFF).toByte(), + ) + val worn = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x06, 0x13) + ts + byteArrayOf(0x01)))!! + val ws = decoder.decode(worn).first() as RingDecodedEvent.WearingStatus + assertTrue(ws.worn) + + val removed = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x06, 0x13) + ts + byteArrayOf(0x00)))!! + val ws2 = decoder.decode(removed).first() as RingDecodedEvent.WearingStatus + assertFalse(ws2.worn) + } + + @Test + fun `measurement status push decodes per type`() { + fun decodeStatus(payload: ByteArray): RingDecodedEvent? { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x13) + payload))!! + return decoder.decode(frame).firstOrNull() + } + + val hr = decodeStatus(byteArrayOf(0x00, 0x01, 72)) as RingDecodedEvent.HeartRateSample + assertEquals(72, hr.bpm) + + val bp = decodeStatus(byteArrayOf(0x01, 0x01, 118, 79)) as RingDecodedEvent.BloodPressureSample + assertEquals(118, bp.systolic) + assertEquals(79, bp.diastolic) + + val spo2 = decodeStatus(byteArrayOf(0x02, 0x01, 98)) as RingDecodedEvent.Spo2Result + assertEquals(98, spo2.value) + + val temp = decodeStatus(byteArrayOf(0x04, 0x01, 36, 5)) as RingDecodedEvent.TemperatureSample + assertEquals(36.5, temp.celsius, 0.001) + + val sugar = decodeStatus(byteArrayOf(0x05, 0x01, 5, 5)) as RingDecodedEvent.BloodSugarSample + assertEquals(5.5 * YCBTHealthRecords.MGDL_PER_MMOL, sugar.mgdl, 0.001) + } + + @Test + fun `measurement status push with no value acks`() { + fun decodeStatus(payload: ByteArray): RingDecodedEvent? { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x13) + payload))!! + return decoder.decode(frame).firstOrNull() + } + assertTrue(decodeStatus(byteArrayOf(0x00, 0x00, 0x00)) is RingDecodedEvent.CommandAck) + assertTrue(decodeStatus(byteArrayOf(0x03, 0x01, 16)) is RingDecodedEvent.CommandAck) + } + + @Test + fun `measurement result push remains an ack without an evidenced payload layout`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x0e, 0x01, 0x02)))!! + val events = decoder.decode(frame) + assertTrue(events.single() is RingDecodedEvent.CommandAck) + } + + @Test + fun `HRV status tail remains undecoded without a captured layout`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x13, 0x0a, 0x01, 0x2a)))!! + val events = decoder.decode(frame) + + assertTrue(events.single() is RingDecodedEvent.CommandAck) + assertFalse(events.any { it is RingDecodedEvent.HrvSample }) + } + + + @Test + fun `measurement start reply distinguishes acceptance from refusal`() { + fun reply(status: Int, startedMode: Int?): RingDecodedEvent { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, status.toByte())))!! + return decoder.decode(frame, startedMode = startedMode).first() + } + val rejected = reply(0x01, YCBTMeasurementMode.HRV) as RingDecodedEvent.MeasurementRejected + assertEquals(YCBTMeasurementMode.HRV, rejected.mode) + + assertTrue(reply(0x00, YCBTMeasurementMode.HRV) is RingDecodedEvent.CommandAck) + assertTrue(reply(0x01, null) is RingDecodedEvent.CommandAck) + } + + @Test + fun `device info decodes battery and firmware`() { + val frame = YCBTFrame.validating(hexToBytes("02001e00a30012010064000100030000000001000000010000000000ef10"))!! + val events = decoder.decode(frame) + val battery = events.first { it is RingDecodedEvent.Battery } as RingDecodedEvent.Battery + assertEquals(100, battery.percent) + val status = events.first { it is RingDecodedEvent.Status } as RingDecodedEvent.Status + assertEquals("1.18", status.firmware) + } + + @Test + fun `firmware sub version is zero padded`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x02, 0x00, 0xa3.toByte(), 0x00, 0x05, 0x01, 0x00, 0x64)))!! + val events = decoder.decode(frame) + val status = events.first { it is RingDecodedEvent.Status } as RingDecodedEvent.Status + assertEquals("1.05", status.firmware) + } + + @Test + fun `date decode uses the record instant DST offset`() { + val tz = TimeZone.getTimeZone("America/New_York") + val winter = LocalDateTime.of(2026, 1, 15, 12, 0).atZone(tz.toZoneId()).toInstant() + val summer = LocalDateTime.of(2026, 7, 15, 12, 0).atZone(tz.toZoneId()).toInstant() + + assertEquals(winter, YCBTBytes.date(YCBTBytes.ringSeconds(winter, tz), tz)) + assertEquals(summer, YCBTBytes.date(YCBTBytes.ringSeconds(summer, tz), tz)) + assertEquals(3_600L, winter.epochSecond - summer.epochSecond + 181 * 86_400L) + } + + @Test + fun `date round trips across spring DST boundary`() { + val tz = TimeZone.getTimeZone("America/New_York") + val before = Instant.parse("2026-03-08T06:30:00Z") + val after = Instant.parse("2026-03-08T07:30:00Z") + + assertEquals(before, YCBTBytes.date(YCBTBytes.ringSeconds(before, tz), tz)) + assertEquals(after, YCBTBytes.date(YCBTBytes.ringSeconds(after, tz), tz)) + } + + @Test + fun `fall DST overlap deterministically chooses the earlier offset`() { + val tz = TimeZone.getTimeZone("America/New_York") + val firstOccurrence = Instant.parse("2026-11-01T05:30:00Z") + val secondOccurrence = Instant.parse("2026-11-01T06:30:00Z") + val encoded = YCBTBytes.ringSeconds(firstOccurrence, tz) + + // The ring cannot represent which 01:30 occurrence it meant: both encode identically. + assertEquals(encoded, YCBTBytes.ringSeconds(secondOccurrence, tz)) + assertEquals(firstOccurrence, YCBTBytes.date(encoded, tz)) + } + + @Test + fun `u24 reads three bytes little endian`() { + assertEquals(72000, YCBTBytes.u24(byteArrayOf(0x40, 0x19, 0x01), 0)) + assertEquals(0, YCBTBytes.u24(byteArrayOf(0x00, 0x00), 0)) + } + + private fun hexToBytes(hex: String): ByteArray { + val clean = hex.replace(" ", "") + require(clean.length % 2 == 0) + return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt new file mode 100644 index 0000000..a9399f1 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt @@ -0,0 +1,255 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test + +class YCBTDriverTest { + + @Test + fun `command characteristic is both writable and notifiable`() { + val driver = YCBTDriver(RingCommandWriter { }) + assertTrue(driver.notifyUUIDs.contains(driver.writeUUID)) + } + + private class FakeWriter : RingCommandWriter { + val sent = mutableListOf() + override fun enqueue(command: ByteArray) { sent.add(command.copyOf()) } + } + + private val streamUUID = YCBTUUIDs.STREAM + + @Test + fun `DevControl push is acked and decoded`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + val push = YCBTFrame.frame(byteArrayOf(0x04, 0x13, 0x00, 0x01, 72)) + val events = driver.ingest(push, streamUUID) + + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x04, 0x13, 0x00), writer.sent[0]) + val hr = events.first() as RingDecodedEvent.HeartRateSample + assertEquals(72, hr.bpm) + } + + @Test + fun `unhandled DevControl push is still acked`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + val events = driver.ingest(YCBTFrame.frame(byteArrayOf(0x04, 0x00, 0x01)), streamUUID) + + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x04, 0x00, 0x00), writer.sent[0]) + assertTrue(events.first() is RingDecodedEvent.CommandAck) + } + + @Test + fun `DevControl error frame is not acked`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.ingest(YCBTFrame.frame(byteArrayOf(0x04, 0x13, 0xfc.toByte())), streamUUID) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `live stream frames are not acked`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.ingest(YCBTFrame.frame(byteArrayOf(0x06, 0x01, 82)), streamUUID) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `connectionDidEnd abandons the in flight history transfer`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + val engine = driver.makeSyncEngine() + + engine.refresh() + assertEquals(2, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[1]) + + writer.sent.clear() + engine.refresh() + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + + driver.connectionDidEnd() + writer.sent.clear() + engine.refresh() + assertEquals(2, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[1]) + } + + @Test + fun `driver pairs a refusal with the start it sent and only once`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + val refusal = YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, 0x0a)) + val events1 = driver.ingest(refusal, streamUUID) + assertTrue(events1.first() is RingDecodedEvent.MeasurementRejected) + assertEquals(0x0a, (events1.first() as RingDecodedEvent.MeasurementRejected).mode) + + val events2 = driver.ingest(refusal, streamUUID) + assertTrue(events2.first() is RingDecodedEvent.CommandAck) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x00, 0x0a)) + val events3 = driver.ingest(refusal, streamUUID) + assertTrue(events3.first() is RingDecodedEvent.CommandAck) + } + + @Test + fun `a stops reply does not consume the mode of the start queued behind it`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x00, 0x02)) // stop SpO2 + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, 0x00)) // start HR + + val ackStop = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x00)), streamUUID) + assertTrue(ackStop.first() is RingDecodedEvent.CommandAck) + + val refusal = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + assertTrue(refusal.first() is RingDecodedEvent.MeasurementRejected) + assertEquals(0x00, (refusal.first() as RingDecodedEvent.MeasurementRejected).mode) + } + + @Test + fun `a NAKed stop cannot cancel the measurement started behind it`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x00, 0x0a)) // stop HRV + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, 0x00)) // start HR + + val nak = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x02)), streamUUID) + assertTrue(nak.first() is RingDecodedEvent.CommandAck) + + val refusal = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + assertTrue(refusal.first() is RingDecodedEvent.MeasurementRejected) + assertEquals(0x00, (refusal.first() as RingDecodedEvent.MeasurementRejected).mode) + } + + @Test + fun `a reconnect drops the commands the old link never answered`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, 0x0a)) + driver.connectionDidEnd() + driver.connectionDidStart() + + val events = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + assertTrue(events.first() is RingDecodedEvent.CommandAck) + } + + @Test + fun `measurement data clears a lost start reply before the next command`() { + val driver = YCBTDriver(RingCommandWriter { }) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, YCBTMeasurementMode.HEART_RATE.toByte())) + driver.ingest( + YCBTFrame.frame(byteArrayOf(0x04, 0x13, YCBTMeasurementMode.HEART_RATE.toByte(), 0, 72)), + streamUUID, + ) + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, YCBTMeasurementMode.SPO2.toByte())) + + val refusal = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + + assertEquals(YCBTMeasurementMode.SPO2, (refusal.single() as RingDecodedEvent.MeasurementRejected).mode) + } + + @Test + fun `measurement data preserves pending replies for other modes`() { + val driver = YCBTDriver(RingCommandWriter { }) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, YCBTMeasurementMode.HEART_RATE.toByte())) + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, YCBTMeasurementMode.SPO2.toByte())) + driver.ingest( + YCBTFrame.frame(byteArrayOf(0x04, 0x13, YCBTMeasurementMode.HEART_RATE.toByte(), 0, 72)), + streamUUID, + ) + + val refusal = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + + assertEquals(YCBTMeasurementMode.SPO2, (refusal.single() as RingDecodedEvent.MeasurementRejected).mode) + } + + @Test + fun `pending measurement replies preserve deterministic FIFO pairing`() { + val replies = PendingMeasurementReplies() + replies.record(YCBTMeasurementMode.HEART_RATE) + replies.record(null) + replies.record(YCBTMeasurementMode.HRV) + + assertEquals(YCBTMeasurementMode.HEART_RATE, replies.consume()?.startedMode) + assertNull(replies.consume()?.startedMode) + assertEquals(YCBTMeasurementMode.HRV, replies.consume()?.startedMode) + assertNull(replies.consume()) + } + + @Test + fun `pending measurement replies stay bounded when callbacks are lost`() { + val replies = PendingMeasurementReplies() + repeat(10) { replies.record(it) } + + assertEquals(2, replies.consume()?.startedMode) + repeat(7) { assertNotNull(replies.consume()) } + assertNull(replies.consume()) + } + + @Test + fun `YCBT requires both command and stream indications`() { + val required = YCBTDriver(RingCommandWriter { }).requiredSubscriptionsBeforeConnected + assertEquals(setOf(YCBTUUIDs.COMMAND, YCBTUUIDs.STREAM), required.map { it.uuid }.toSet()) + assertTrue(required.all { it.mode == SubscriptionMode.INDICATION }) + } + + @Test + fun `optional live values are discarded until support function declares them`() { + val driver = YCBTDriver(RingCommandWriter { }) + val liveVitals = YCBTFrame.frame(byteArrayOf(0x06, 0x03, 120, 80, 70, 42, 98, 36, 5)) + + val beforeSupport = driver.ingest(liveVitals, streamUUID) + + assertTrue(beforeSupport.none { it is RingDecodedEvent.BloodPressureSample }) + assertTrue(beforeSupport.none { it is RingDecodedEvent.HrvSample }) + assertTrue(beforeSupport.none { it is RingDecodedEvent.TemperatureSample }) + assertTrue(beforeSupport.any { it is RingDecodedEvent.HeartRateSample }) + assertTrue(beforeSupport.any { it is RingDecodedEvent.Spo2Result }) + + val support = ByteArray(24).apply { + this[0] = 0x01 // blood pressure + this[1] = 0x02 // HRV + } + driver.ingest(YCBTFrame.frame(byteArrayOf(0x02, 0x01) + support), streamUUID) + + val afterSupport = driver.ingest(liveVitals, streamUUID) + assertTrue(afterSupport.any { it is RingDecodedEvent.BloodPressureSample }) + assertTrue(afterSupport.any { it is RingDecodedEvent.HrvSample }) + assertTrue(afterSupport.none { it is RingDecodedEvent.TemperatureSample }) + } + + @Test + fun `reconnect forgets optional capabilities learned on the old link`() { + val driver = YCBTDriver(RingCommandWriter { }) + val support = ByteArray(14).apply { this[0] = 0x01 } + val bloodPressure = YCBTFrame.frame(byteArrayOf(0x04, 0x13, 0x01, 0x00, 120, 80)) + + driver.ingest(YCBTFrame.frame(byteArrayOf(0x02, 0x01) + support), streamUUID) + assertTrue(driver.ingest(bloodPressure, streamUUID).any { it is RingDecodedEvent.BloodPressureSample }) + + driver.connectionDidEnd() + driver.connectionDidStart() + + assertTrue(driver.ingest(bloodPressure, streamUUID).none { it is RingDecodedEvent.BloodPressureSample }) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt new file mode 100644 index 0000000..a20ea67 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -0,0 +1,173 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test +import java.time.Instant +import java.util.TimeZone + +class YCBTEncoderTest { + private val encoder = YCBTEncoder() + + @Test + fun `setTime weekday byte is correct for every day`() { + val calendar = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.set(2026, java.util.Calendar.JULY, 6, 12, 34, 14) + for (offset in 0..6) { + val date = Instant.ofEpochMilli(calendar.timeInMillis + offset * 86_400_000L) + val command = encoder.setTime(date, timeZone = calendar.timeZone) + assertEquals(offset.toByte(), command.last()) + } + } + + @Test + fun `device name request matches current SmartHealth handshake vector`() { + assertArrayEquals( + byteArrayOf(0x02, 0x03, 0x08, 0x00, 0x47, 0x50, 0xef.toByte(), 0x20), + YCBTFrame.frame(encoder.deviceNameRequest()), + ) + } + + @Test + fun `setTime matches captured frame`() { + val calendar = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.set(2026, java.util.Calendar.JULY, 6, 12, 34, 14) + val date = Instant.ofEpochMilli(calendar.timeInMillis) + val command = encoder.setTime(date, timeZone = calendar.timeZone) + assertArrayEquals(byteArrayOf(0x01, 0x00, 0xea.toByte(), 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00), command) + } + + @Test + fun `monitor commands clamp interval to thirty minutes`() { + val settings = MeasurementSettings( + hrEnabled = true, hrIntervalMinutes = 5, + spo2Enabled = true, stressEnabled = true, hrvEnabled = true, temperatureEnabled = true, + ) + val commands = encoder.monitorCommands(settings, allMonitorCapabilities) + assertEquals(4, commands.size) + assertArrayEquals(byteArrayOf(0x01, 0x0c, 0x01, 30), commands[0]) + assertArrayEquals(byteArrayOf(0x01, 0x20, 0x01, 30), commands[1]) + assertArrayEquals(byteArrayOf(0x01, 0x26, 0x01, 30), commands[2]) + assertArrayEquals(byteArrayOf(0x01, 0x45, 0x01, 30, 0, 0, 0), commands[3]) + } + + @Test + fun `monitor commands honour interval above floor and disabled flags`() { + val settings = MeasurementSettings( + hrEnabled = true, hrIntervalMinutes = 60, + spo2Enabled = false, stressEnabled = false, hrvEnabled = false, temperatureEnabled = false, + ) + val commands = encoder.monitorCommands(settings, allMonitorCapabilities) + assertArrayEquals(byteArrayOf(0x01, 0x0c, 0x01, 60), commands[0]) + assertArrayEquals(byteArrayOf(0x01, 0x20, 0x00, 60), commands[1]) + assertArrayEquals(byteArrayOf(0x01, 0x26, 0x00, 60), commands[2]) + assertArrayEquals(byteArrayOf(0x01, 0x45, 0x00, 60, 0, 0, 0), commands[3]) + } + + @Test + fun `userInfo carries real profile`() { + val profile = UserProfileValues(metric = true, gender = 0x01u, age = 31u, heightCm = 183u, weightKg = 78u) + assertArrayEquals(byteArrayOf(0x01, 0x03, 183.toByte(), 78.toByte(), 1, 31.toByte()), encoder.userInfo(profile)) + + val female = UserProfileValues(metric = false, gender = 0x00u, age = 29u, heightCm = 165u, weightKg = 60u) + assertArrayEquals(byteArrayOf(0x01, 0x03, 165.toByte(), 60.toByte(), 0, 29.toByte()), encoder.userInfo(female)) + } + + @Test + fun `startup sends no health delete or retired get opcodes`() { + val sequence = encoder.startupSequence() + for (command in sequence) { + val group = command[0].toInt() and 0xFF + val cmd = command[1].toInt() and 0xFF + assertFalse("05 ${String.format("%02x", cmd)} is a Health-DELETE opcode", group == 0x05 && cmd in 0x40..0x4e) + assertFalse("handshake must not touch Health group", group == 0x05) + assertFalse("02 ${String.format("%02x", cmd)} is retired Get opcode", group == 0x02 && cmd in listOf(0x24, 0x26, 0x28)) + assertFalse("R10M disconnects on GetChipScheme", group == 0x02 && cmd == 0x1b) + } + } + + @Test + fun `startup order mirrors SmartHealth handshake`() { + val sequence = encoder.startupSequence().map { it.copyOfRange(0, 2) } + assertArrayEquals(byteArrayOf(0x02, 0x00), sequence.first()) + assertArrayEquals(byteArrayOf(0x03, 0x09), sequence.last()) + val expectedGets = listOf( + byteArrayOf(0x02, 0x00), + byteArrayOf(0x02, 0x01), + byteArrayOf(0x02, 0x07), + ) + assertEquals(expectedGets.map { it.toList() }, sequence.subList(0, 3).map { it.toList() }) + assertEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02).toList(), encoder.startupSequence().last().toList()) + val settings = sequence.subList(3, sequence.size) + val expectedSettings = listOf( + byteArrayOf(0x01, 0x12), + byteArrayOf(0x01, 0x04), + byteArrayOf(0x01, 0x0c), + byteArrayOf(0x01, 0x26), + byteArrayOf(0x01, 0x03), + byteArrayOf(0x03, 0x09), + ) + assertEquals(expectedSettings.map { it.toList() }, settings.map { it.toList() }) + } + + @Test + fun `monitor commands include only declared sensors`() { + val commands = encoder.monitorCommands( + MeasurementSettings.ALL_ON_DEFAULT, + setOf(WearableCapability.HEART_RATE, WearableCapability.SPO2, WearableCapability.BLOOD_PRESSURE), + ) + + assertEquals(listOf(0x0c, 0x26), commands.map { it[1].toInt() and 0xFF }) + } + + @Test + fun `post subscription handshake is exactly device name then time`() { + val date = Instant.parse("2026-07-06T12:34:14Z") + val sequence = encoder.postSubscriptionHandshake(date) + + assertEquals(2, sequence.size) + assertArrayEquals(byteArrayOf(0x02, 0x03, 0x47, 0x50), sequence[0]) + assertArrayEquals(byteArrayOf(0x01, 0x00), sequence[1].copyOfRange(0, 2)) + assertFalse(encoder.startupSequence().any { it.contentEquals(sequence[0]) }) + } + + @Test + fun `history request and block ack bytes`() { + assertArrayEquals(byteArrayOf(0x05, 0x06), encoder.healthHistoryRequest(YCBTHistoryType.HEART)) + assertArrayEquals(byteArrayOf(0x05, 0x09), encoder.healthHistoryRequest(YCBTHistoryType.ALL)) + assertArrayEquals(byteArrayOf(0x05, 0x04), encoder.healthHistoryRequest(YCBTHistoryType.SLEEP)) + assertArrayEquals(byteArrayOf(0x05, 0x80.toByte(), 0x00), encoder.historyBlockAck(status = 0x00)) + assertArrayEquals(byteArrayOf(0x05, 0x80.toByte(), 0x04), encoder.historyBlockAck(status = 0x04)) + } + + @Test + fun `live measurement modes use distinct sensors`() { + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x01, 0x00), encoder.heartRateStart()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x01, 0x01), encoder.bloodPressureStart()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x01, 0x02), encoder.spo2Start()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x01, 0x0a), encoder.hrvStart()) + } + + @Test + fun `live measurement stop echoes its own mode`() { + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x00, 0x00), encoder.heartRateStop()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x00, 0x01), encoder.bloodPressureStop()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x00, 0x02), encoder.spo2Stop()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x00, 0x0a), encoder.hrvStop()) + } + + @Test + fun `findDevice uses AppControl not DevControl ack`() { + assertArrayEquals(byteArrayOf(0x03, 0x00, 0x01, 0x05, 0x02), encoder.findDevice()) + assertNotEquals(0x04.toByte(), encoder.findDevice()[0]) + } + + companion object { + private val allMonitorCapabilities = setOf( + WearableCapability.HEART_RATE, + WearableCapability.BLOOD_PRESSURE, + WearableCapability.TEMPERATURE, + WearableCapability.SPO2, + WearableCapability.HRV, + ) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt index b970ae0..00417ef 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt @@ -3,191 +3,291 @@ package com.pulseloop.ring import org.junit.Assert.* import org.junit.Test -/** Tests for [YCBTHealthRecords]' pure buffer->events decoders (iOS #82). */ class YCBTHealthRecordsTest { - private fun u32le(value: Int): List = listOf( - (value and 0xff).toUByte(), ((value shr 8) and 0xff).toUByte(), - ((value shr 16) and 0xff).toUByte(), ((value shr 24) and 0xff).toUByte(), + private fun bytes(hex: String): ByteArray { + val clean = hex.replace(" ", "") + require(clean.length % 2 == 0) + return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private fun values(kind: MeasurementKind, events: List): List { + return events.mapNotNull { event -> + if (event is RingDecodedEvent.HistoryMeasurement && event.kind_field == kind) event.value else null + } + } + + private fun timestamps(events: List): List { + return events.mapNotNull { event -> + if (event is RingDecodedEvent.HistoryMeasurement) event._timestamp else null + } + } + + // Fixtures + private val capturedBodyRecord = bytes("1cf0de3103023e0505030402050530002a0c3700b00484030d000000") + private val capturedHeartRecords = bytes( + "1cf0de3100471afede310042260cdf31003f3b1adf31003e4328df3100425136df31003c6444df3100419852df31003a" + ) + private val capturedAllRecords = bytes( + "1cf0de31080d47734c620e3404000f00000033de1afede31000042704a610d2b02000f000000d24b260cdf3100003f70" + + "49610db106000f000000ce273b1adf3100003e6d49600c5f02000f00000077a54328df310000426f49610d2105000f00" + + "0000474b5136df3100003c6f47600c2104000f00000024f66444df310000416e49610d3d05000f00000015769852df31" + + "00003a6a465f0c8002000f000000d89d" + ) + private val capturedNight = bytes( + "affaa4019fe9de31bd58df31ffff971efb15733af29fe9de313c0500f1dceede312d0100f30af0de31d90100f2e4f1de31c9" + + "0400f1aef6de31320100f3e1f7de31c10100f2a3f9de31b50400f159fede319b0100f3f5ffde31b00100f2a501df31660200" + + "f10b04df313d0500f34809df31ae0800f2f611df31cc0100f1c213df31170200f3d915df317d0100f25617df31ef0000f345" + + "18df31060100f24b19df31670200f1b21bdf31910000f2431cdf31030000f3461cdf314c0000f2921cdf31f70100f3891edf" + + "31720200f2fb20df31e00000f3db21df31590100f23423df310e0100f34224df31d30100f21526df317a0000f38f26df31c1" + + "0000f25027df31be0400f10f2cdf312f0100f33f2ddf31aa0100f2ea2edf317a0500f16534df316b0100f3d135df319e0000" + + "f17036df319e0100f20e38df31450500f1543ddf318b0100f3e03edf31de0100f2bf40df31000500f1c045df315e0100f31f" + + "47df31730100f29348df31a00000f13449df319c0000f2d049df316d0500f13e4fdf31410100f38050df31d80100f25952df" + + "31050100f15f53df311e0100f27d54df31400400" ) - // MARK: - composite / score + @Test + fun `heart rate records decode every record`() { + val events = YCBTHealthRecords.heartRate(capturedHeartRecords) + assertEquals(listOf(71.0, 66.0, 63.0, 62.0, 66.0, 60.0, 65.0, 58.0), values(MeasurementKind.HEART_RATE, events)) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } @Test - fun `composite string-concatenates integer and fraction`() { - assertEquals(45.6, YCBTHealthRecords.composite(45u, 6u), 0.0001) - assertEquals(36.5, YCBTHealthRecords.composite(36u, 5u), 0.0001) - assertEquals(36.25, YCBTHealthRecords.composite(36u, 25u), 0.0001) + fun `heart rate drops zero samples`() { + val buffer = bytes("1cf0de310000" + "1afede310042") + assertEquals(1, YCBTHealthRecords.heartRate(buffer).size) } @Test - fun `score digit-concatenates onto the app's 1 to 100 scale`() { - // (5, 3) is the 53 the app shows, not 5.3. - assertEquals(53.0, YCBTHealthRecords.score(5u, 3u), 0.0001) + fun `combined vitals decodes BP SpO2 and HRV without activity`() { + val events = YCBTHealthRecords.combinedVitals(capturedAllRecords) + assertEquals(listOf(98.0, 97.0, 97.0, 96.0, 97.0, 96.0, 97.0, 95.0), values(MeasurementKind.SPO2, events)) + assertEquals(listOf(52.0, 43.0, 177.0, 95.0, 33.0, 33.0, 61.0, 128.0), values(MeasurementKind.HRV, events)) + val bloodPressure = events.filterIsInstance() + assertEquals(listOf(115, 112, 112, 109, 111, 111, 110, 106), bloodPressure.map { it.systolic }) + assertEquals(70, bloodPressure.last().diastolic) + assertTrue(bloodPressure.all { it.isHistory }) + assertFalse(events.any { it is RingDecodedEvent.ActivityUpdate }) } @Test - fun `blood sugar converts tenths of mmol to mg per dL`() { - // 55 tenths = 5.5 mmol/L ~= 99 mg/dL. - val mgdl = YCBTHealthRecords.bloodSugarMgdl(55) - assertEquals(99.088, mgdl, 0.01) + fun `combined vitals decodes respiratory rate`() { + val events = YCBTHealthRecords.combinedVitals(capturedAllRecords) + assertEquals(listOf(14.0, 13.0, 13.0, 12.0, 13.0, 12.0, 13.0, 12.0), values(MeasurementKind.RESPIRATORY_RATE, events)) } - // MARK: - Heart rate (6-byte records) + @Test + fun `combined vitals skips unmeasured temperature and blood sugar`() { + val events = YCBTHealthRecords.combinedVitals(capturedAllRecords) + assertTrue(values(MeasurementKind.TEMPERATURE, events).isEmpty()) + assertTrue(values(MeasurementKind.BLOOD_SUGAR, events).isEmpty()) + } @Test - fun `heart rate drops zero readings`() { - val buffer = u32le(1000) + listOf(0u, 0u) + // hr=0, unworn - u32le(2000) + listOf(0u, 70u) // hr=70 - val events = YCBTHealthRecords.heartRate(buffer) - assertEquals(1, events.size) - val e = events[0] as RingDecodedEvent.HistoryMeasurement - assertEquals(MeasurementKind.HEART_RATE, e.kind_field) - assertEquals(70.0, e.value, 0.0) + fun `combined vitals decodes temperature and blood sugar when present`() { + val events = YCBTHealthRecords.combinedVitals(bytes("1cf0de31721046764f610f3a0324061504370000")) + assertEquals(36.6, values(MeasurementKind.TEMPERATURE, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(99.088, values(MeasurementKind.BLOOD_SUGAR, events).firstOrNull() ?: 0.0, 0.001) } - // MARK: - Temperature filler + @Test + fun `unworn combined vitals record produces no activity`() { + val events = YCBTHealthRecords.combinedVitals(bytes("1cf0de31080d4700000000000000000000000000")) + assertTrue(events.isEmpty()) + } @Test - fun `temperature filler int0 frac15 is dropped`() { - val buffer = u32le(1000) + listOf(0u, 0u, 15u) - assertTrue(YCBTHealthRecords.temperature(buffer).isEmpty()) + fun `partial trailing record is dropped`() { + val events = YCBTHealthRecords.heartRate(bytes("1cf0de310047" + "1afede3100")) + assertEquals(1, events.size) } @Test - fun `temperature filler with stale nonzero integer is still dropped`() { - // int=36, frac=15 is the same "never measured" marker, not a real 36.15C. - val buffer = u32le(1000) + listOf(0u, 36u, 15u) - assertTrue(YCBTHealthRecords.temperature(buffer).isEmpty()) + fun `sleep decodes a full night matching the app`() { + val event = YCBTHealthRecords.sleep(capturedNight).first() as RingDecodedEvent.SleepTimeline + val deep = event.stages.count { it == SleepStage.DEEP } + val light = event.stages.count { it == SleepStage.LIGHT } + val rem = event.stages.count { it == SleepStage.REM } + assertTrue(kotlin.math.abs(93 - deep) <= 3) + assertTrue(kotlin.math.abs(249 - light) <= 3) + assertTrue(kotlin.math.abs(130 - rem) <= 3) + assertFalse(event.stages.contains(SleepStage.AWAKE)) + assertTrue(event.completeSession) } @Test - fun `real temperature reading decodes as the composite value`() { - val buffer = u32le(1000) + listOf(0u, 36u, 5u) - val events = YCBTHealthRecords.temperature(buffer) - assertEquals(1, events.size) - assertEquals(36.5, (events[0] as RingDecodedEvent.HistoryMeasurement).value, 0.0001) + fun `multiple sessions in one buffer`() { + val timelines = YCBTHealthRecords.sleep(capturedNight + capturedNight).filterIsInstance() + assertEquals(2, timelines.size) } - // MARK: - Sleep (variable-length sessions) + @Test + fun `nap segment does not truncate the night`() { + val session = sleepSession(listOf( + 0xf2 to 60 * 60, + 0xf5 to 20 * 60, + 0xf1 to 30 * 60, + )) + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + assertEquals(60, event.stages.count { it == SleepStage.LIGHT }) + assertEquals(20, event.stages.count { it == SleepStage.UNKNOWN }) + assertEquals(30, event.stages.count { it == SleepStage.DEEP }) + } - private fun sleepSegment(tag: Int, startSeconds: Int, durationSeconds: Int): List = - listOf(tag.toUByte()) + u32le(startSeconds) + - listOf( - (durationSeconds and 0xff).toUByte(), - ((durationSeconds shr 8) and 0xff).toUByte(), - ((durationSeconds shr 16) and 0xff).toUByte(), - ) + @Test + fun `segment duration is u24`() { + val session = sleepSession(listOf(0xf2 to 72_000)) + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + assertEquals(1200, event.stages.size) + } - private fun sleepSession(startSeconds: Int, segments: List>): List { - val segmentBytes = segments.flatten() - val recordLen = 20 + segmentBytes.size - val header = listOf(0u, 0u) + listOf((recordLen and 0xff).toUByte(), ((recordLen shr 8) and 0xff).toUByte()) + - u32le(startSeconds) + u32le(0) + List(8) { 0u.toUByte() } // start, end, 8 bytes of counts/totals - return header + segmentBytes + @Test + fun `malformed sleep durations are bounded to one day`() { + val session = sleepSession(listOf(0xf2 to 0xFFFFFF, 0xf1 to 60 * 60)) + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + + assertEquals(24 * 60, event.stages.size) + assertTrue(event.stages.all { it == SleepStage.LIGHT }) } @Test - fun `sleep decodes stages by tag and repeats minutes for the segment duration`() { - val session = sleepSession(1000, listOf(sleepSegment(tag = 1, startSeconds = 1000, durationSeconds = 120))) - val events = YCBTHealthRecords.sleep(session) - assertEquals(1, events.size) - val timeline = events[0] as RingDecodedEvent.SleepTimeline - assertEquals(2, timeline.stages.size) // 120s -> 2 minutes - assertTrue(timeline.stages.all { it == SleepStage.DEEP }) + fun `repeated segment is counted once`() { + var session = sleepSession(listOf(0xf2 to 30 * 60, 0xf1 to 30 * 60, 0xf3 to 30 * 60)) + val deep = session.copyOfRange(20 + 8, 20 + 16) + session[2] = (20 + 4 * 8).toByte() + session[3] = 0 + session += deep + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + assertEquals(30, event.stages.count { it == SleepStage.DEEP }) + assertEquals(90, event.stages.size) } @Test - fun `sleep classifies every documented stage tag`() { - val segments = listOf( - sleepSegment(1, 1000, 60), sleepSegment(2, 1060, 60), - sleepSegment(3, 1120, 60), sleepSegment(4, 1180, 60), - ) - val session = sleepSession(1000, segments) - val timeline = YCBTHealthRecords.sleep(session)[0] as RingDecodedEvent.SleepTimeline - assertEquals(listOf(SleepStage.DEEP, SleepStage.LIGHT, SleepStage.REM, SleepStage.AWAKE), timeline.stages) + fun `truncated session is clamped`() { + var session = sleepSession(listOf(0xf2 to 600, 0xf1 to 600)) + session = session.copyOfRange(0, session.size - 8) + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + assertEquals(10, event.stages.size) } @Test - fun `unknown tag is skipped, not terminal`() { - // A stray unrecognized tag (0) between two real segments must not truncate the session. - val segments = listOf( - sleepSegment(1, 1000, 60), - sleepSegment(0, 1060, 60), // unrecognized — high nibble only, low nibble 0 - sleepSegment(2, 1120, 60), - ) - val session = sleepSession(1000, segments) - val timeline = YCBTHealthRecords.sleep(session)[0] as RingDecodedEvent.SleepTimeline - assertEquals(listOf(SleepStage.DEEP, SleepStage.LIGHT), timeline.stages) + fun `SpO2 records decode`() { + val events = YCBTHealthRecords.spo2(bytes("1cf0de3100611afede310100260cdf31005f")) + assertEquals(listOf(97.0, 95.0), values(MeasurementKind.SPO2, events)) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) } @Test - fun `duplicate segment start times within a session are deduplicated`() { - val segments = listOf( - sleepSegment(1, 1000, 60), - sleepSegment(1, 1000, 60), // firmware repeat — same start time - sleepSegment(2, 1060, 60), - ) - val session = sleepSession(1000, segments) - val timeline = YCBTHealthRecords.sleep(session)[0] as RingDecodedEvent.SleepTimeline - // Only one DEEP minute counted, not two. - assertEquals(listOf(SleepStage.DEEP, SleepStage.LIGHT), timeline.stages) + fun `blood pressure records decode`() { + val events = YCBTHealthRecords.bloodPressure(bytes("1cf0de3101764f401afede3100000000")) + val bloodPressure = events.filterIsInstance() + assertEquals(listOf(118), bloodPressure.map { it.systolic }) + assertEquals(listOf(79), bloodPressure.map { it.diastolic }) + assertEquals(listOf(64.0), values(MeasurementKind.HEART_RATE, events)) + assertTrue(bloodPressure.all { it.isHistory }) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) } @Test - fun `multiple back-to-back sessions each decode independently`() { - val session1 = sleepSession(1000, listOf(sleepSegment(1, 1000, 60))) - val session2 = sleepSession(5000, listOf(sleepSegment(2, 5000, 60))) - val events = YCBTHealthRecords.sleep(session1 + session2) - assertEquals(2, events.size) + fun `body data decodes HRV in ms and stress fatigue on apps hundred scale`() { + val events = YCBTHealthRecords.bodyData(capturedBodyRecord) + assertEquals(62.5, values(MeasurementKind.HRV, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(53.0, values(MeasurementKind.STRESS, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(42.0, values(MeasurementKind.FATIGUE, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(42.0, values(MeasurementKind.VO2MAX, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) } - // MARK: - Blood pressure (8-byte records) + @Test + fun `stress score is digit concatenated not a decimal composite`() { + assertEquals(53.0, YCBTHealthRecords.score(5, 3), 0.001) + assertEquals(70.0, YCBTHealthRecords.score(7, 0), 0.001) + assertEquals(100.0, YCBTHealthRecords.score(10, 0), 0.001) + assertEquals(5.3, YCBTHealthRecords.composite(5, 3), 0.001) + } @Test - fun `blood pressure zero bytes produce no events`() { - val buffer = u32le(1000) + listOf(0u, 0u, 0u, 0u) - assertTrue(YCBTHealthRecords.bloodPressure(buffer).isEmpty()) + fun `short body data record is dropped not misread`() { + val events = YCBTHealthRecords.bodyData(capturedBodyRecord + capturedBodyRecord.copyOfRange(0, 17)) + assertEquals(1, values(MeasurementKind.HRV, events).size) + assertEquals(listOf(42.0), values(MeasurementKind.VO2MAX, events)) } @Test - fun `blood pressure emits systolic diastolic and incidental heart rate`() { - val buffer = u32le(1000) + listOf(0u, 120u, 80u, 65u) - val events = YCBTHealthRecords.bloodPressure(buffer) - assertEquals(3, events.size) - val kinds = events.map { (it as RingDecodedEvent.HistoryMeasurement).kind_field } - assertTrue(kinds.containsAll(listOf( - MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, MeasurementKind.HEART_RATE, - ))) + fun `sport records decode to activity buckets`() { + val events = YCBTHealthRecords.sport(bytes("1cf0de31a0f3de318002e00119001afede319e01df31000000000000")) + assertEquals(1, events.size) + val bucket = events.first() as RingDecodedEvent.ActivityBucket + assertEquals(YCBTBytes.date(836_694_044), bucket._timestamp) + assertEquals(640, bucket.steps) + assertEquals(480, bucket.distanceMeters) } - // MARK: - Body data (28-byte records) + @Test + fun `temperature records decode with string concat fraction`() { + val events = YCBTHealthRecords.temperature(bytes("1cf0de310024051afede31002419260cdf3100000f")) + val temps = values(MeasurementKind.TEMPERATURE, events) + assertEquals(2, temps.size) + assertEquals(36.5, temps[0], 0.001) + assertEquals(36.25, temps[1], 0.001) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } @Test - fun `body data reads stress as score and hrv as composite from the same shape`() { - val buffer = u32le(1000) + - listOf(0u, 0u) + // loadIdx (unused) - listOf(45u, 6u) + // hrv -> composite 45.6 - listOf(5u, 3u) + // stress (pressure) -> score 53 - listOf(2u, 0u) + // fatigue (body) -> score 20 - listOf(0u, 0u) + // sympathetic (unused) - List(2) { 0u.toUByte() } + // sdnn u16 - listOf(48u) + // vo2max @16 - List(11) { 0u.toUByte() } // pnn50, rmssd, lf, hf, lfHf padding to 28 bytes - assertEquals(28, buffer.size) - val events = YCBTHealthRecords.bodyData(buffer) - val byKind = events.associateBy { (it as RingDecodedEvent.HistoryMeasurement).kind_field } - assertEquals(45.6, (byKind[MeasurementKind.HRV] as RingDecodedEvent.HistoryMeasurement).value, 0.0001) - assertEquals(53.0, (byKind[MeasurementKind.STRESS] as RingDecodedEvent.HistoryMeasurement).value, 0.0001) - assertEquals(20.0, (byKind[MeasurementKind.FATIGUE] as RingDecodedEvent.HistoryMeasurement).value, 0.0001) - assertEquals(48.0, (byKind[MeasurementKind.VO2MAX] as RingDecodedEvent.HistoryMeasurement).value, 0.0001) + fun `temperature filler fraction is dropped even with non zero integer`() { + val events = YCBTHealthRecords.temperature(bytes("1cf0de3100240f" + "1afede3100000f")) + assertTrue(values(MeasurementKind.TEMPERATURE, events).isEmpty()) } - // MARK: - decode() dispatch + @Test + fun `comprehensive decodes blood sugar as mgdl`() { + val events = YCBTHealthRecords.comprehensive(bytes( + "1cf0de3101050500000000000000000000000000000000000000000000000000000000000000000000000000" + + "1afede31010000000000000000000000000000000000000000000000000000000000000000000000000000000000" + )) + val sugar = values(MeasurementKind.BLOOD_SUGAR, events) + assertEquals(1, sugar.size) + assertEquals(99.088, sugar[0], 0.001) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } @Test - fun `decode dispatches by history type`() { - val buffer = u32le(1000) + listOf(0u, 70u) - assertEquals(YCBTHealthRecords.heartRate(buffer), YCBTHealthRecords.decode(buffer, YCBTHistoryType.HEART)) + fun `every catalog type decodes through the type table`() { + val spo2 = bytes("1cf0de3100611afede310100260cdf31005f") + val blood = bytes("1cf0de3101764f401afede3100000000") + val sport = bytes("1cf0de31a0f3de318002e00119001afede319e01df31000000000000") + val temperature = bytes("1cf0de310024051afede31002419260cdf3100000f") + + assertEquals(2, YCBTHealthRecords.decode(spo2, YCBTHistoryType.SPO2).size) + assertEquals(2, YCBTHealthRecords.decode(blood, YCBTHistoryType.BLOOD).size) + assertEquals(1, YCBTHealthRecords.decode(sport, YCBTHistoryType.SPORT).size) + assertEquals(2, YCBTHealthRecords.decode(temperature, YCBTHistoryType.TEMPERATURE).size) + assertEquals(4, YCBTHealthRecords.decode(capturedBodyRecord, YCBTHistoryType.BODY_DATA).size) + assertEquals(8 * 4, YCBTHealthRecords.decode(capturedAllRecords, YCBTHistoryType.ALL).size) + assertFalse(YCBTHealthRecords.decode(capturedNight, YCBTHistoryType.SLEEP).isEmpty()) + assertFalse(YCBTHealthRecords.decode(capturedHeartRecords, YCBTHistoryType.HEART).isEmpty()) + } + + private fun sleepSession(segments: List>): ByteArray { + val recordLength = 20 + segments.size * 8 + val out = mutableListOf() + out.add(0xaf.toByte()) + out.add(0xfa.toByte()) + out.add((recordLength and 0xFF).toByte()) + out.add((recordLength shr 8).toByte()) + repeat(16) { out.add(0) } + for ((index, segment) in segments.withIndex()) { + val start = 0x31def01c + index * 3600 + out.add(segment.first.toByte()) + out.add((start and 0xFF).toByte()) + out.add(((start shr 8) and 0xFF).toByte()) + out.add(((start shr 16) and 0xFF).toByte()) + out.add(((start shr 24) and 0xFF).toByte()) + out.add((segment.second and 0xFF).toByte()) + out.add(((segment.second shr 8) and 0xFF).toByte()) + out.add(((segment.second shr 16) and 0xFF).toByte()) + } + return out.toByteArray() } } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt index a6bd184..bd457a1 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt @@ -1,124 +1,416 @@ package com.pulseloop.ring +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import org.junit.Assert.* import org.junit.Test -private class RecordingWriter : RingCommandWriter { - val sent = mutableListOf() - override fun enqueue(command: ByteArray) { sent.add(command) } -} - -/** Tests for the [YCBTHistoryTransfer] protocol-driven state machine (iOS #82). Uses a very long - * watchdog window so timing never interferes — these tests exercise only the deterministic, - * frame-driven transitions. */ class YCBTHistoryTransferTest { - private fun newTransfer(writer: RingCommandWriter) = - YCBTHistoryTransfer(writer, inactivityMs = 60_000, absoluteCapMs = 120_000) + private class FakeWriter : RingCommandWriter { + val sent = mutableListOf() + override fun enqueue(command: ByteArray) { sent.add(command.copyOf()) } + } + + private val heartQuery = byteArrayOf(0x05, 0x06) + private val allQuery = byteArrayOf(0x05, 0x09) + private val ackAccepted = byteArrayOf(0x05, 0x80.toByte(), 0x00) + private val ackCrcFailure = byteArrayOf(0x05, 0x80.toByte(), 0x04) - /** `[totalPackets:u16][totalBytes:u16][crc16:u16]` — only the CRC field is read by the - * transfer machine, so the packet/byte counts are zero-filled. */ - private fun terminalPayload(buffer: List): List { - val crc = YCBTFrame.crc16(buffer) - return listOf(0u, 0u, 0u, 0u, (crc and 0xff).toUByte(), ((crc shr 8) and 0xff).toUByte()) + private fun header(records: Int, packets: Int, bytes: Int): ByteArray { + return byteArrayOf( + (records and 0xFF).toByte(), (records shr 8).toByte(), + (packets and 0xFF).toByte(), (packets shr 8).toByte(), 0, 0, + (bytes and 0xFF).toByte(), (bytes shr 8).toByte(), 0, 0, + ) + } + + private fun terminal( + packets: Int, + buffer: ByteArray, + crc: Int? = null, + reportedBytes: Int = buffer.size, + ): ByteArray { + val checksum = crc ?: YCBTFrame.crc16(buffer) + return byteArrayOf( + (packets and 0xFF).toByte(), (packets shr 8).toByte(), + (reportedBytes and 0xFF).toByte(), ((reportedBytes shr 8) and 0xFF).toByte(), + (checksum and 0xFF).toByte(), ((checksum shr 8) and 0xFF).toByte(), + ) + } + + private val heartBuffer = byteArrayOf( + 0x1c, 0xf0.toByte(), 0xde.toByte(), 0x31, 0x00, 0x47, + 0x1a, 0xfe.toByte(), 0xde.toByte(), 0x31, 0x00, 0x42, + ) + + private fun heartRates(events: List): List { + return events.mapNotNull { event -> + if (event is RingDecodedEvent.HistoryMeasurement && event.kind_field == MeasurementKind.HEART_RATE) event.value else null + } + } + + private fun drainNoData(writer: FakeWriter, transfer: YCBTHistoryTransfer): List { + val requested = mutableListOf() + while (true) { + val query = writer.sent.firstOrNull { + it.size == 2 && it[0] == YCBTGroup.HEALTH.toByte() + } ?: break + writer.sent.clear() + val key = query[1].toInt() and 0xFF + requested += key + transfer.handle(cmd = key, payload = byteArrayOf(0x00)) + } + return requested } @Test - fun `full happy path requests header data terminal ack and decodes`() { - val writer = RecordingWriter() - val transfer = newTransfer(writer) + fun `full cycle acks and advances to next type`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) - transfer.start(listOf(YCBTHistoryType.HEART)) - // The query for HEART went out. + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) assertEquals(1, writer.sent.size) - assertArrayEquals(YCBTHealthCommand.historyRequest(YCBTHistoryType.HEART).toRawByteArray(), writer.sent[0]) + assertArrayEquals(heartQuery, writer.sent[0]) - // Header: recordCount=1, totalPackets=1, totalBytes=6 (one 6-byte HR record). - val header = listOf(1u, 0u, 1u, 0u, 0u, 0u, 6u, 0u, 0u, 0u) - assertTrue(transfer.handle(YCBTHistoryType.HEART.queryKey, header).any { it is RingDecodedEvent.HistorySyncProgress }) + val progress = transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + assertTrue(progress.first() is RingDecodedEvent.HistorySyncProgress) + assertEquals("Syncing heart rate…", (progress.first() as RingDecodedEvent.HistorySyncProgress).stage) - // One HR record: ts=0, mode=0, hr=70. - val record = listOf(0u, 0u, 0u, 0u, 0u, 70u) - assertTrue(transfer.handle(YCBTHistoryType.HEART.ackKey, record).isEmpty()) + assertTrue(transfer.handle(cmd = 0x15, payload = heartBuffer).isEmpty()) - val terminal = transfer.handle(YCBTHealth.TERMINAL_BLOCK, terminalPayload(record)) - assertTrue(terminal.any { it is RingDecodedEvent.HistoryMeasurement && it.kind_field == MeasurementKind.HEART_RATE }) - assertTrue(terminal.any { it is RingDecodedEvent.HistorySyncFinished }) + writer.sent.clear() + val done = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) - // The terminal ACK (accepted) went out before decoding, per the protocol contract. - val ackFrame = writer.sent.last() - assertArrayEquals(YCBTHealthCommand.historyBlockAck(YCBTHealth.ACK_ACCEPTED).toRawByteArray(), ackFrame) - assertFalse(transfer.isActive) + assertEquals(listOf(71.0, 66.0), heartRates(done)) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackAccepted, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) } @Test - fun `empty header advances without treating it as a terminal`() { - val writer = RecordingWriter() - val transfer = newTransfer(writer) - transfer.start(listOf(YCBTHistoryType.HEART, YCBTHistoryType.SPO2)) + fun `record straddling two data frames survives`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART)) + + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 2, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer.copyOfRange(0, 9)) + transfer.handle(cmd = 0x15, payload = heartBuffer.copyOfRange(9, heartBuffer.size)) + val done = transfer.handle(cmd = 0x80, payload = terminal(packets = 2, buffer = heartBuffer)) - // A <=9-byte payload on the query key means "no stored data" — advance to the next type. - val events = transfer.handle(YCBTHistoryType.HEART.queryKey, listOf(0u, 0u)) - assertTrue(events.none { it is RingDecodedEvent.HistorySyncFinished }) - // Now requesting SPO2. - assertArrayEquals(YCBTHealthCommand.historyRequest(YCBTHistoryType.SPO2).toRawByteArray(), writer.sent.last()) + assertEquals(listOf(71.0, 66.0), heartRates(done)) } @Test - fun `crc mismatch retries once then skips`() { - val writer = RecordingWriter() - val transfer = newTransfer(writer) - transfer.start(listOf(YCBTHistoryType.HEART)) - transfer.handle(YCBTHistoryType.HEART.queryKey, listOf(1u, 0u, 1u, 0u, 0u, 0u, 6u, 0u, 0u, 0u)) - transfer.handle(YCBTHistoryType.HEART.ackKey, listOf(0u, 0u, 0u, 0u, 0u, 70u)) + fun `CRC mismatch nacks and retries the type once`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) - // Wrong CRC bytes (6-byte terminal shape, deliberately bad CRC). - val badTerminal = listOf(0u, 0u, 0u, 0u, 0xDEu, 0xADu) - val firstTerminal = transfer.handle(YCBTHealth.TERMINAL_BLOCK, badTerminal) - assertTrue(firstTerminal.isEmpty()) - // A re-request for HEART must have gone out (the retry). - assertEquals(YCBTHealthCommand.historyRequest(YCBTHistoryType.HEART).toRawByteArray().toList(), writer.sent.last().toList()) - assertTrue(transfer.isActive) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) - // Header + data again, then a second bad terminal — this time it gives up (advances). - transfer.handle(YCBTHistoryType.HEART.queryKey, listOf(1u, 0u, 1u, 0u, 0u, 0u, 6u, 0u, 0u, 0u)) - transfer.handle(YCBTHistoryType.HEART.ackKey, listOf(0u, 0u, 0u, 0u, 0u, 70u)) - val secondTerminal = transfer.handle(YCBTHealth.TERMINAL_BLOCK, badTerminal) - assertTrue(secondTerminal.any { it is RingDecodedEvent.HistorySyncFinished }) + writer.sent.clear() + val first = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer, crc = 0xdead)) + assertTrue(heartRates(first).isEmpty()) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(heartQuery, writer.sent[1]) + + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + writer.sent.clear() + transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer, crc = 0xdead)) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) } @Test - fun `permanent error skips the type and is never asked again this session`() { - val writer = RecordingWriter() - val transfer = newTransfer(writer) - transfer.start(listOf(YCBTHistoryType.HEART)) + fun `missing data frame nacks and retries the type once`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val firstFrame = heartBuffer.copyOfRange(0, 6) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 2, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = firstFrame) + writer.sent.clear() + + transfer.handle( + cmd = 0x80, + payload = terminal(packets = 2, buffer = firstFrame, reportedBytes = heartBuffer.size), + ) + + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(heartQuery, writer.sent[1]) - // 0xFC = unsupported key -> permanent. - val events = transfer.handle(YCBTHistoryType.HEART.queryKey, listOf(YCBTFrameError.UNSUPPORTED_KEY.rawValue)) - assertTrue(events.any { it is RingDecodedEvent.HistorySyncFinished }) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 2, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = firstFrame) + writer.sent.clear() + transfer.handle( + cmd = 0x80, + payload = terminal(packets = 2, buffer = firstFrame, reportedBytes = heartBuffer.size), + ) + + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + } + + @Test + fun `no data header advances without acking`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) - // Starting again with the same type must skip it outright (no query re-sent). writer.sent.clear() - transfer.start(listOf(YCBTHistoryType.HEART)) + transfer.handle(cmd = 0x06, payload = byteArrayOf(0x00)) + assertEquals(1, writer.sent.size) + assertArrayEquals(allQuery, writer.sent[0]) + } + + @Test + fun `error frame advances and unsupported type is not requested again`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + + writer.sent.clear() + transfer.handle(cmd = 0x06, payload = byteArrayOf(0xfc.toByte())) + assertEquals(1, writer.sent.size) + assertArrayEquals(allQuery, writer.sent[0]) + + transfer.handle(cmd = 0x09, payload = byteArrayOf(0xfc.toByte())) + writer.sent.clear() + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) assertTrue(writer.sent.isEmpty()) } @Test - fun `a transfer already in flight wins over a second start`() { - val writer = RecordingWriter() - val transfer = newTransfer(writer) - transfer.start(listOf(YCBTHistoryType.HEART)) + fun `late terminal from skipped type causes only a bounded retry`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val allBuffer = ByteArray(20) { it.toByte() } + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + transfer.handle(cmd = 0x06, payload = byteArrayOf(0xfc.toByte())) + transfer.handle(cmd = 0x09, payload = header(records = 1, packets = 2, bytes = allBuffer.size)) + transfer.handle(cmd = 0x18, payload = allBuffer) + writer.sent.clear() + + val events = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) + + assertTrue(events.isEmpty()) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + assertTrue(transfer.isActive) + } + + @Test + fun `startup requests only baseline history until capabilities arrive`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val engine = YCBTSyncEngine(writer = writer, transfer = transfer) + engine.runStartup() + + val requested = mutableListOf() + repeat(4) { + val query = writer.sent.lastOrNull { it.size == 2 && it[0] == 0x05.toByte() } + if (query == null) return@repeat + requested.add(query[1].toInt() and 0xFF) + transfer.handle(cmd = query[1].toInt() and 0xFF, payload = byteArrayOf(0x00)) + } + assertEquals(listOf(0x02, 0x04, 0x06, 0x09), requested) + } + + @Test + fun `sync vitals history uses heart and combined records`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val engine = YCBTSyncEngine(writer = writer, transfer = transfer) + engine.syncVitalsHistory() + + val requested = mutableListOf() + while (true) { + val query = writer.sent.lastOrNull { it.size == 2 && it[0] == 0x05.toByte() } ?: break + requested.add(query[1].toInt() and 0xFF) + writer.sent.clear() + transfer.handle(cmd = query[1].toInt() and 0xFF, payload = byteArrayOf(0x00)) + } + assertEquals(listOf(0x06, 0x09), requested) + } + + @Test + fun `sync history reruns the full catalog`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val engine = YCBTSyncEngine(writer = writer, transfer = transfer) + engine.refresh() + + assertEquals(2, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[1]) + } + + @Test + fun `start is ignored while a transfer is in flight`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + assertTrue(transfer.isActive) + writer.sent.clear() + transfer.start(types = listOf(YCBTHistoryType.SLEEP)) + assertTrue(writer.sent.isEmpty()) + + transfer.handle(cmd = 0x15, payload = heartBuffer) + val done = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) + assertEquals(listOf(71.0, 66.0), heartRates(done)) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackAccepted, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + } - transfer.start(listOf(YCBTHistoryType.SPO2)) // should be ignored — HEART is in flight + @Test + fun `append preserves the active transfer and adds new types once`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + writer.sent.clear() + transfer.append( + listOf( + YCBTHistoryType.HEART, + YCBTHistoryType.ALL, + YCBTHistoryType.SLEEP, + YCBTHistoryType.SLEEP, + ), + ) assertTrue(writer.sent.isEmpty()) + + transfer.handle(cmd = 0x06, payload = byteArrayOf(0x00)) + assertEquals(listOf(0x09, 0x04), drainNoData(writer, transfer)) + } + + @Test + fun `append while idle starts the added history`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + + transfer.append(listOf(YCBTHistoryType.SLEEP)) + + assertArrayEquals(byteArrayOf(0x05, 0x04), writer.sent.single()) + assertTrue(transfer.isActive) } @Test fun `frames while idle are ignored`() { - val writer = RecordingWriter() - val transfer = newTransfer(writer) - assertTrue(transfer.handle(YCBTHistoryType.HEART.ackKey, listOf(1u, 2u, 3u)).isEmpty()) + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + assertTrue(transfer.handle(cmd = 0x15, payload = heartBuffer).isEmpty()) + assertTrue(transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = byteArrayOf())).isEmpty()) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `duplicate final terminal after completion is ignored`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val done = terminal(packets = 1, buffer = heartBuffer) + + transfer.start(types = listOf(YCBTHistoryType.HEART)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + transfer.handle(cmd = 0x80, payload = done) + + writer.sent.clear() + assertTrue(transfer.handle(cmd = 0x80, payload = done).isEmpty()) + assertTrue(writer.sent.isEmpty()) + assertFalse(transfer.isActive) + } + + @Test + fun `terminal after cancel is ignored`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + + transfer.start(types = listOf(YCBTHistoryType.HEART)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + transfer.cancel() + + writer.sent.clear() + assertTrue(transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)).isEmpty()) assertTrue(writer.sent.isEmpty()) + assertFalse(transfer.isActive) + } + + @Test + fun `watchdog skips a stalled type and never acks`() = runBlocking { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer, inactivitySeconds = 0.05, absoluteCapSeconds = 0.2) + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + + delay(300) + + assertEquals(2, writer.sent.size) + assertArrayEquals(heartQuery, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + assertFalse(writer.sent.any { it.size >= 2 && it[0] == 0x05.toByte() && it[1] == 0x80.toByte() }) + } + + @Test + fun `watchdog is not armed after completion`() = runBlocking { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer, inactivitySeconds = 0.05, absoluteCapSeconds = 0.2) + + transfer.start(types = listOf(YCBTHistoryType.HEART)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) + + writer.sent.clear() + delay(300) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `cancel stops the watchdog from walking the queue`() = runBlocking { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer, inactivitySeconds = 0.05, absoluteCapSeconds = 0.2) + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + transfer.cancel() + writer.sent.clear() + + delay(300) + assertTrue(writer.sent.isEmpty()) + assertFalse(transfer.isActive) + } + + @Test + fun `final watchdog completion is emitted through the out of band event sink`() = runBlocking { + val writer = FakeWriter() + val emitted = mutableListOf() + val transfer = YCBTHistoryTransfer( + writer = writer, + inactivitySeconds = 0.05, + absoluteCapSeconds = 0.1, + onOutOfBandEvents = { emitted.addAll(it) }, + ) + + transfer.start(types = listOf(YCBTHistoryType.HEART)) + delay(200) + + assertEquals(listOf(RingDecodedEvent.HistorySyncFinished), emitted) + assertFalse(transfer.isActive) } } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt index b068b93..188c0f5 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt @@ -3,194 +3,142 @@ package com.pulseloop.ring import org.junit.Assert.* import org.junit.Test -/** Wire-format tests for the YCBT frame/CRC/byte helpers (iOS #82). Oracle values from - * docs/YCBT-Protocol.md (ported from the decompiled vendor SDK). */ -class YCBTFrameTest { +class YCBTProtocolTest { - @Test - fun `crc16 check value matches the CCITT-FALSE oracle`() { - // Standard CRC-16/CCITT-FALSE check value: "123456789" -> 0x29B1. - val ascii = "123456789".map { it.code.toUByte() } - assertEquals(0x29B1, YCBTFrame.crc16(ascii)) - } + private val streamUUID = YCBTUUIDs.STREAM + private val commandUUID = YCBTUUIDs.COMMAND - @Test - fun `frame inserts total length and appends little-endian crc`() { - // Worked example from the protocol doc: 05 06 (health, heart-rate query, empty payload) - // frames to 05 06 06 00 83 20 (len=6 LE, crc=0x2083 LE). - val framed = YCBTFrame.frame(listOf(YCBTGroup.HEALTH, 0x06u)) - assertArrayEquals(byteArrayOf(0x05, 0x06, 0x06, 0x00, 0x83.toByte(), 0x20), framed) + private fun frameBytes(cmd: Int, payloadLength: Int, fill: Int = 0xaa): ByteArray { + return YCBTFrame.frame(byteArrayOf(YCBTGroup.HEALTH.toByte(), cmd.toByte()) + ByteArray(payloadLength) { fill.toByte() }) } @Test - fun `validating round-trips a framed command`() { - val framed = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, 0x47u, 0x43u)) - val parsed = YCBTFrame.validating(framed) - assertNotNull(parsed) - assertEquals(YCBTGroup.GET, parsed!!.type) - assertEquals(YCBTCommand.GET_DEVICE_INFO, parsed.cmd) - assertEquals(listOf(0x47u, 0x43u), parsed.payload) + fun `frame builds length and CRC matching captured setTime`() { + val logical = byteArrayOf(0x01, 0x00, 0xea.toByte(), 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00) + val framed = YCBTFrame.frame(logical) + assertEquals("01000e00ea0707060c220e0026c7", framed.toHexString()) } @Test - fun `validating rejects a length mismatch`() { - val framed = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO)).toMutableList() - framed[2] = (framed[2] + 1).toByte() // corrupt the declared length - assertNull(YCBTFrame.validating(framed.toByteArray())) + fun `CRC16 matches captured setTime body`() { + val body = hexToBytes("01000e00ea0707060c220e00") + val crc = YCBTFrame.crc16(body) + assertEquals(0xc726, crc) } @Test - fun `validating rejects a crc mismatch`() { - val framed = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO)).toMutableList() - framed[framed.size - 1] = (framed[framed.size - 1] + 1).toByte() // flip a CRC byte - assertNull(YCBTFrame.validating(framed.toByteArray())) + fun `validating rejects bad CRC`() { + val raw = hexToBytes("01000e00ea0707060c220e0026c7") + raw[raw.size - 1] = (raw[raw.size - 1].toInt() xor 0xff).toByte() + assertNull(YCBTFrame.validating(raw)) } @Test - fun `validating rejects a frame shorter than the header`() { - assertNull(YCBTFrame.validating(byteArrayOf(0x05, 0x06, 0x04))) + fun `validating rejects wrong declared length`() { + assertNull(YCBTFrame.validating(hexToBytes("0100ff00ea0707060c220e0026c7"))) } - // MARK: - YCBTBytes epoch round-trip - @Test - fun `ring seconds round-trip through date`() { - val zone = java.time.ZoneId.of("UTC") - val now = java.time.Instant.now().let { java.time.Instant.ofEpochSecond(it.epochSecond) } - val ringSeconds = YCBTBytes.ringSeconds(now, zone) - val back = YCBTBytes.date(ringSeconds, zone) - assertEquals(now, back) - } + fun `frame split across three notifications is reassembled`() { + val assembler = YCBTFrameAssembler() + val whole = frameBytes(cmd = 0x15, payloadLength = 60) - @Test - fun `u16 u24 u32 read little-endian`() { - val bytes = listOf(0x01u, 0x02u, 0x03u, 0x04u, 0x05u) - assertEquals(0x0201, YCBTBytes.u16(bytes, 0)) - assertEquals(0x030201, YCBTBytes.u24(bytes, 0)) - assertEquals(0x04030201L, YCBTBytes.u32(bytes, 0)) - } -} + assertTrue(assembler.append(whole.copyOfRange(0, 20), streamUUID).isEmpty()) + assertTrue(assembler.append(whole.copyOfRange(20, 40), streamUUID).isEmpty()) + val done = assembler.append(whole.copyOfRange(40, whole.size), streamUUID) -/** Tests for [YCBTFrameAssembler]'s fragmentation/resync behavior. */ -class YCBTFrameAssemblerTest { + assertEquals(1, done.size) + assertArrayEquals(whole, done[0]) + assertNotNull(YCBTFrame.validating(done[0])) + } @Test - fun `reassembles a frame split across two notifications`() { + fun `two frames in one notification both emerge`() { val assembler = YCBTFrameAssembler() - val whole = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, 0x47u, 0x43u)) - val part1 = whole.copyOfRange(0, 3) - val part2 = whole.copyOfRange(3, whole.size) - - assertTrue(assembler.append(part1, YCBTUUIDs.COMMAND).isEmpty()) - val completed = assembler.append(part2, YCBTUUIDs.COMMAND) - assertEquals(1, completed.size) - assertArrayEquals(whole, completed[0]) + val first = frameBytes(cmd = 0x15, payloadLength = 6) + val second = frameBytes(cmd = 0x18, payloadLength = 20) + + val done = assembler.append(first + second, streamUUID) + assertEquals(2, done.size) + assertArrayEquals(first, done[0]) + assertArrayEquals(second, done[1]) } @Test - fun `splits two short frames delivered in one notification`() { + fun `fragment then whole frame in one notification`() { val assembler = YCBTFrameAssembler() - val a = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO)) - val b = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_SUPPORT_FUNCTION)) - val completed = assembler.append(a + b, YCBTUUIDs.COMMAND) - assertEquals(2, completed.size) - assertArrayEquals(a, completed[0]) - assertArrayEquals(b, completed[1]) + val first = frameBytes(cmd = 0x15, payloadLength = 30) + val second = frameBytes(cmd = 0x18, payloadLength = 4) + + assertTrue(assembler.append(first.copyOfRange(0, 10), streamUUID).isEmpty()) + val done = assembler.append(first.copyOfRange(10, first.size) + second, streamUUID) + assertEquals(2, done.size) + assertArrayEquals(first, done[0]) + assertArrayEquals(second, done[1]) } @Test - fun `keeps command and stream channel buffers independent`() { + fun `garbage prefix resyncs to next valid frame`() { val assembler = YCBTFrameAssembler() - val whole = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, 0x47u, 0x43u)) - // Feed the command channel's first half, then a stream-channel frame — the stream frame - // must not be treated as a continuation of the command channel's partial buffer. - assembler.append(whole.copyOfRange(0, 3), YCBTUUIDs.COMMAND) - val streamFrame = YCBTFrame.frame(listOf(YCBTGroup.REAL, YCBTCommand.LIVE_HEART_RATE, 70u)) - val streamCompleted = assembler.append(streamFrame, YCBTUUIDs.STREAM) - assertArrayEquals(streamFrame, streamCompleted[0]) - - // The command channel's partial buffer is still intact and completes normally. - val commandCompleted = assembler.append(whole.copyOfRange(3, whole.size), YCBTUUIDs.COMMAND) - assertArrayEquals(whole, commandCompleted[0]) + val good = frameBytes(cmd = 0x15, payloadLength = 6) + val garbage = byteArrayOf(0xff.toByte(), 0x00, 0xff.toByte(), 0xff.toByte(), 0x7f) + + val done = assembler.append(garbage + good, streamUUID) + assertEquals(1, done.size) + assertArrayEquals(good, done[0]) } @Test - fun `resyncs by dropping one byte at a time on garbage`() { + fun `channels buffer independently`() { val assembler = YCBTFrameAssembler() - val whole = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO)) - // Two garbage bytes (not a plausible group byte) ahead of a real frame. - val garbage = byteArrayOf(0xAA.toByte(), 0xBB.toByte()) + whole - val completed = assembler.append(garbage, YCBTUUIDs.COMMAND) - assertEquals(1, completed.size) - assertArrayEquals(whole, completed[0]) + val streamFrame = frameBytes(cmd = 0x15, payloadLength = 30) + val commandFrame = YCBTFrame.frame(byteArrayOf(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64)) + + assertTrue(assembler.append(streamFrame.copyOfRange(0, 10), streamUUID).isEmpty()) + val cmdDone = assembler.append(commandFrame, commandUUID) + assertEquals(1, cmdDone.size) + assertArrayEquals(commandFrame, cmdDone[0]) + + val streamDone = assembler.append(streamFrame.copyOfRange(10, streamFrame.size), streamUUID) + assertEquals(1, streamDone.size) + assertArrayEquals(streamFrame, streamDone[0]) } @Test fun `reset drops partial frames`() { val assembler = YCBTFrameAssembler() - val whole = YCBTFrame.frame(listOf(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, 0x47u, 0x43u)) - assembler.append(whole.copyOfRange(0, 3), YCBTUUIDs.COMMAND) + val whole = frameBytes(cmd = 0x15, payloadLength = 30) + + assertTrue(assembler.append(whole.copyOfRange(0, 10), streamUUID).isEmpty()) assembler.reset() - // The old partial bytes must not be prepended to a fresh frame's tail. - val completed = assembler.append(whole.copyOfRange(3, whole.size), YCBTUUIDs.COMMAND) - assertTrue(completed.isEmpty()) + assertTrue(assembler.append(whole.copyOfRange(10, whole.size), streamUUID).isEmpty()) } -} -/** Tests for [YCBTSupportFunction]'s capability-bitmap parsing. */ -class YCBTSupportFunctionTest { - - private fun payload(size: Int, vararg setBits: Pair): List { - val bytes = MutableList(size) { 0u.toUByte() } - for ((byte, bit) in setBits) { - bytes[byte] = (bytes[byte].toInt() or (1 shl bit)).toUByte() + @Test + fun `find device capability follows its support bit`() { + val withoutFind = ByteArray(14) + val withFind = withoutFind.copyOf().apply { + this[6] = (1 shl 4).toByte() } - return bytes - } - @Test - fun `too-short payload yields no capabilities`() { - assertTrue(YCBTSupportFunction.capabilities(payload(10)).isEmpty()) + assertFalse(YCBTSupportFunction.capabilities(withoutFind).contains(WearableCapability.FIND_DEVICE)) + assertTrue(YCBTSupportFunction.capabilities(withFind).contains(WearableCapability.FIND_DEVICE)) } @Test - fun `heart rate bit maps to heart rate capability`() { - val caps = YCBTSupportFunction.capabilities(payload(14, 0 to 3)) - assertTrue(caps.contains(WearableCapability.HEART_RATE)) - } - - @Test - fun `stress bit also grants fatigue since they share one record`() { - val caps = YCBTSupportFunction.capabilities(payload(23, 22 to 6)) - assertTrue(caps.contains(WearableCapability.STRESS)) - assertTrue(caps.contains(WearableCapability.FATIGUE)) - } + fun `pressure support bit enables both body-data scores`() { + val payload = ByteArray(23).apply { + this[22] = (1 shl 6).toByte() + } + val capabilities = YCBTSupportFunction.capabilities(payload) - @Test - fun `manual heart rate bit requires the sdk's own 18-byte gate, not just physical presence`() { - // Byte 15 is physically readable in a 17-byte payload (indices 0..16), but the SDK's own - // gate (minLength 18) still refuses to read it below that — the gate is stricter than - // sheer byte-count availability. - assertTrue(YCBTSupportFunction.capabilities(payload(17, 15 to 1)).isEmpty()) - assertTrue(YCBTSupportFunction.capabilities(payload(18, 15 to 1)).contains(WearableCapability.MANUAL_HEART_RATE)) + assertTrue(capabilities.contains(WearableCapability.STRESS)) + assertTrue(capabilities.contains(WearableCapability.FATIGUE)) } - @Test - fun `every bitmap-gated capability in both coordinators is derivable from a bit`() { - // A gate no bit can ever satisfy is a dead promise (mirrors iOS's PairingMatchingTests - // invariant) — build a payload with every mapped bit set and confirm it covers each - // coordinator's gated set. - val allBitsPayload = payload( - 24, - 0 to 7, 0 to 6, 0 to 3, 0 to 0, 1 to 3, 1 to 1, 8 to 0, 17 to 3, 22 to 6, - 6 to 4, 15 to 1, 15 to 2, 15 to 3, 23 to 0, - ) - val derivable = YCBTSupportFunction.capabilities(allBitsPayload) - for (cap in TK5Coordinator.bitmapGatedCapabilities) { - assertTrue("TK5 gated capability $cap has no satisfying bit", derivable.contains(cap)) - } - for (cap in ColmiSmartHealthCoordinator.bitmapGatedCapabilities) { - assertTrue("SmartHealth-Colmi gated capability $cap has no satisfying bit", derivable.contains(cap)) - } + private fun hexToBytes(hex: String): ByteArray { + val clean = hex.replace(" ", "") + require(clean.length % 2 == 0) + return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt new file mode 100644 index 0000000..d917030 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt @@ -0,0 +1,179 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test + +class YCBTSyncEngineTest { + private class FakeWriter : RingCommandWriter { + val sent = mutableListOf() + override fun enqueue(command: ByteArray) { + sent += command.copyOf() + } + } + + private fun engine(writer: FakeWriter): YCBTSyncEngine { + val transfer = YCBTHistoryTransfer(writer) + return YCBTSyncEngine(writer, transfer) + } + + private fun drainNoData(writer: FakeWriter, transfer: YCBTHistoryTransfer): List { + val requested = mutableListOf() + while (true) { + val query = writer.sent.firstOrNull { + it.size == 2 && it[0] == YCBTGroup.HEALTH.toByte() + } ?: break + writer.sent.clear() + val key = query[1].toInt() and 0xFF + requested += key + transfer.handle(cmd = key, payload = byteArrayOf(0x00)) + } + return requested + } + + @Test + fun `full refresh requests live status before sport history`() { + val writer = FakeWriter() + engine(writer).refresh() + assertEquals(2, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[1]) + } + + @Test + fun `post workout refresh begins with recent heart history`() { + val writer = FakeWriter() + engine(writer).syncVitalsHistory() + assertArrayEquals(byteArrayOf(0x05, 0x06), writer.sent.single()) + } + + @Test + fun `sleep refresh requests only sleep history first`() { + val writer = FakeWriter() + engine(writer).syncSleepNow() + assertArrayEquals(byteArrayOf(0x05, 0x04), writer.sent.single()) + } + + @Test + fun `legacy query sleep action is history only for YCBT`() { + val writer = FakeWriter() + engine(writer).querySleep() + assertArrayEquals(byteArrayOf(0x05, 0x04), writer.sent.single()) + } + + @Test + fun `startup excludes the immediate name and time handshake`() { + val writer = FakeWriter() + engine(writer).runStartup() + val startup = writer.sent.filter { it.size >= 2 && it[0] != YCBTGroup.HEALTH.toByte() } + assertFalse(startup.any { it[0] == YCBTGroup.SETTING.toByte() && it[1] == YCBTSettingKey.SET_TIME.toByte() }) + assertFalse(startup.any { it[0] == YCBTGroup.GET.toByte() && it[1] == YCBTCommand.GET_DEVICE_NAME.toByte() }) + assertEquals( + listOf(YCBTSettingKey.HEART_MONITOR, YCBTSettingKey.BLOOD_OXYGEN_MONITOR), + startup.filter { it[0] == YCBTGroup.SETTING.toByte() } + .map { it[1].toInt() and 0xFF } + .filter { it in setOf(0x0c, 0x1c, 0x20, 0x26, 0x45) }, + ) + } + + @Test + fun `startup requests current activity again when history finishes`() { + val writer = FakeWriter() + val engine = engine(writer) + + engine.runStartup() + writer.sent.clear() + engine.handle(RingDecodedEvent.HistorySyncFinished) + + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent.single()) + + writer.sent.clear() + engine.handle(RingDecodedEvent.HistorySyncFinished) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `support bitmap appends only declared optional history`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer) + val engine = YCBTSyncEngine(writer, transfer) + + engine.runStartup() + engine.handle( + RingDecodedEvent.SupportFunctions( + setOf(WearableCapability.BLOOD_PRESSURE, WearableCapability.TEMPERATURE), + ), + ) + + val optionalMonitors = writer.sent.filter { + it.size >= 2 && it[0] == YCBTGroup.SETTING.toByte() + }.map { it[1].toInt() and 0xFF } + assertTrue(optionalMonitors.contains(YCBTSettingKey.TEMPERATURE_MONITOR)) + assertFalse(optionalMonitors.contains(YCBTSettingKey.BLOOD_PRESSURE_MONITOR)) + assertFalse(optionalMonitors.contains(YCBTSettingKey.HRV_MONITOR)) + + assertEquals( + listOf(0x02, 0x04, 0x06, 0x09, 0x08, 0x1e), + drainNoData(writer, transfer), + ) + } + + @Test + fun `repeated support bitmap does not duplicate optional history`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer) + val engine = YCBTSyncEngine(writer, transfer) + val support = RingDecodedEvent.SupportFunctions(setOf(WearableCapability.BLOOD_PRESSURE)) + + engine.runStartup() + engine.handle(support) + engine.handle(support) + + assertEquals( + listOf(0x02, 0x04, 0x06, 0x09, 0x08), + drainNoData(writer, transfer), + ) + } + + @Test + fun `late HRV capability requeues body data and combined history sources`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer) + val engine = YCBTSyncEngine(writer, transfer) + + engine.runStartup() + drainNoData(writer, transfer) + writer.sent.clear() + + engine.handle(RingDecodedEvent.SupportFunctions(setOf(WearableCapability.HRV))) + + assertEquals(listOf(0x33, 0x09), drainNoData(writer, transfer)) + } + + @Test + fun `refresh uses the complete capability-filtered history catalog`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer) + val engine = YCBTSyncEngine(writer, transfer) + + engine.runStartup() + engine.handle( + RingDecodedEvent.SupportFunctions( + setOf( + WearableCapability.BLOOD_PRESSURE, + WearableCapability.TEMPERATURE, + WearableCapability.BLOOD_SUGAR, + WearableCapability.STRESS, + ), + ), + ) + drainNoData(writer, transfer) + + writer.sent.clear() + engine.refresh() + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent.first()) + assertEquals( + listOf(0x02, 0x04, 0x06, 0x08, 0x09, 0x1e, 0x2f, 0x33), + drainNoData(writer, transfer), + ) + } +} diff --git a/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt b/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt new file mode 100644 index 0000000..d35124b --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt @@ -0,0 +1,120 @@ +package com.pulseloop.service + +import com.pulseloop.data.entity.SleepStageBlockEntity +import com.pulseloop.ring.MeasurementKind +import com.pulseloop.ring.RingDeviceType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EventPersistenceIdentityTest { + @Test + fun `only YCBT protocol families preserve sleep on connect`() { + assertTrue(preservesSleepOnConnect(RingDeviceType.YCBT)) + assertTrue(preservesSleepOnConnect(RingDeviceType.TK5)) + assertTrue(preservesSleepOnConnect(RingDeviceType.COLMI_SMART_HEALTH)) + assertTrue(preservesSleepOnConnect(null, RingDeviceType.YCBT)) + assertTrue(preservesSleepOnConnect(RingDeviceType.YCBT, RingDeviceType.JRING)) + assertFalse(preservesSleepOnConnect(RingDeviceType.JRING, RingDeviceType.YCBT)) + assertFalse(preservesSleepOnConnect(RingDeviceType.COLMI_R02)) + assertFalse(preservesSleepOnConnect(null)) + } + + @Test + fun `history identity is stable across repeated syncs`() { + val timestamp = 1_721_234_567_000L + + assertEquals( + historyMeasurementId(MeasurementKind.HEART_RATE, timestamp), + historyMeasurementId(MeasurementKind.HEART_RATE, timestamp), + ) + assertNotEquals( + historyMeasurementId(MeasurementKind.HEART_RATE, timestamp), + historyMeasurementId(MeasurementKind.SPO2, timestamp), + ) + } + + @Test + fun `revised sleep packet replaces every overlapping stale block`() { + val start = 1_721_234_000_000L + val existing = listOf( + block("old-1", start, 5, "LIGHT"), + block("old-2", start + 5 * 60_000L, 10, "DEEP"), + block("later", start + 15 * 60_000L, 5, "REM"), + ) + val revised = listOf(block("new", start, 15, "LIGHT")) + + val merged = replaceOverlappingSleepBlocks( + existing = existing, + replacements = revised, + replacementStart = start, + replacementEnd = start + 15 * 60_000L, + ) + + assertEquals(listOf("new", "later"), merged.map { it.id }) + } + + @Test + fun `packet revision preserves portions outside its interval`() { + val start = 1_721_234_000_000L + val existing = listOf(block("old", start, 60, "LIGHT")) + val revisedStart = start + 15 * 60_000L + val revisedEnd = start + 30 * 60_000L + + val merged = replaceOverlappingSleepBlocks( + existing = existing, + replacements = listOf(block("new", revisedStart, 15, "DEEP")), + replacementStart = revisedStart, + replacementEnd = revisedEnd, + ) + + assertEquals(listOf(start, revisedStart, revisedEnd), merged.map { it.startAt }) + assertEquals(listOf(15, 15, 30), merged.map { it.durationMinutes }) + assertEquals(listOf("LIGHT", "DEEP", "LIGHT"), merged.map { it.stageRaw }) + } + + @Test + fun `short nap cannot replace a longer night on the same waking day`() { + val nightStart = 1_721_234_000_000L + + assertEquals( + false, + shouldReplaceCompleteSleep( + existingStart = nightStart, + existingMinutes = 480, + incomingStart = nightStart + 12 * 60 * 60_000L, + incomingMinutes = 60, + ), + ) + assertEquals( + true, + shouldReplaceCompleteSleep( + existingStart = nightStart + 12 * 60 * 60_000L, + existingMinutes = 60, + incomingStart = nightStart, + incomingMinutes = 480, + ), + ) + assertEquals( + true, + shouldReplaceCompleteSleep( + existingStart = nightStart, + existingMinutes = 480, + incomingStart = nightStart, + incomingMinutes = 420, + ), + ) + } + + private fun block(id: String, start: Long, duration: Int, stage: String) = + SleepStageBlockEntity( + id = id, + sessionId = "sleep-session", + startAt = start, + startMinute = 0, + durationMinutes = duration, + stageRaw = stage, + ) +} diff --git a/app/src/test/java/com/pulseloop/ui/viewmodels/CurrentDayValuesTest.kt b/app/src/test/java/com/pulseloop/ui/viewmodels/CurrentDayValuesTest.kt new file mode 100644 index 0000000..3432cbd --- /dev/null +++ b/app/src/test/java/com/pulseloop/ui/viewmodels/CurrentDayValuesTest.kt @@ -0,0 +1,41 @@ +package com.pulseloop.ui.viewmodels + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CurrentDayValuesTest { + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun retainedCollectorSwitchesFromYesterdayAtMidnight() = runTest { + val sunday = 1L + val monday = 2L + val currentDay = MutableStateFlow(sunday) + val rows = mapOf( + sunday to MutableStateFlow(5_397), + monday to MutableStateFlow(null), + ) + val displayedSteps = currentDayValues(currentDay, rows::getValue) + .stateIn(backgroundScope, SharingStarted.Eagerly, null) + + runCurrent() + assertEquals(5_397, displayedSteps.value) + + currentDay.value = monday + runCurrent() + assertNull("yesterday's steps must disappear after rollover", displayedSteps.value) + + rows.getValue(sunday).value = 6_000 + runCurrent() + assertNull("updates to yesterday must stay hidden", displayedSteps.value) + + rows.getValue(monday).value = 42 + runCurrent() + assertEquals(42, displayedSteps.value) + } +} diff --git a/app/src/test/java/com/pulseloop/util/TimeUtilTest.kt b/app/src/test/java/com/pulseloop/util/TimeUtilTest.kt index ae72968..ef801ad 100644 --- a/app/src/test/java/com/pulseloop/util/TimeUtilTest.kt +++ b/app/src/test/java/com/pulseloop/util/TimeUtilTest.kt @@ -84,4 +84,22 @@ class TimeUtilTest { TimeUtil.wakingDayLocal(morningPacket, zone), ) } + + @Test + fun `delay to midnight follows a normal local day`() { + val now = millis(LocalDateTime.of(2026, 7, 5, 23, 0)) + assertEquals(3_600_000L, TimeUtil.millisUntilNextLocalDay(now, zone)) + } + + @Test + fun `delay from midnight spans the spring-forward day`() { + val now = millis(LocalDateTime.of(2026, 3, 8, 0, 0)) + assertEquals(23 * 3_600_000L, TimeUtil.millisUntilNextLocalDay(now, zone)) + } + + @Test + fun `delay from midnight spans the fall-back day`() { + val now = millis(LocalDateTime.of(2026, 11, 1, 0, 0)) + assertEquals(25 * 3_600_000L, TimeUtil.millisUntilNextLocalDay(now, zone)) + } }