diff --git a/CHANGELOG.md b/CHANGELOG.md index 069a3e9..6da76fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased — 0.1.5 +- Added the Cardio V1 persistence/synchronization foundation. Android schema + v27 owns durable timestamped heart-rate captures, samples and raw RR values; + desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion + transactionally. Exact generation membership is retained until a correlated + consumed ACK, after which those stopped captures remain local history but are + excluded from ordinary re-publication. Desktop/Web remain sensor consumers + only; Bluetooth Classic Trainlog synchronization is unchanged. + Development opened after stable v0.1.4 release. ## 0.1.4 — 2026-09-22 diff --git a/README.md b/README.md index 4de5434..a22c0b2 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import, list, detail, archive, and delete surface. Web Exercises administration, the factual Dashboard/Analyse read model, and the synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version -0.1.5 development is focused on cardio and heart-rate sensor capture. +0.1.5 development has the Android/desktop heart-rate persistence and synchronization foundation in place; Android BLE sensor acquisition is the current implementation cursor. ## Releases diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 4904edf..6f90301 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -225,7 +225,7 @@ internal class AndroidBackupService( manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..26 + manifest.getInt("schema") in 17..27 ) { "Unsupported backup." } @@ -319,7 +319,7 @@ internal class AndroidBackupService( } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..26) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..27) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index c57d15f..2489f76 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -69,6 +69,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) "program-executions" to Kind("trainlog-program-executions", 1, "program-executions-v1.json", false), "sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false), + "heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false), ) private val SUPPORTED = (ANDROID_KINDS.values + @@ -422,6 +423,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")), ) } + val heartRate = JSONObject(checkNotNull(artifacts["heart-rate"])) + val captures = heartRate.getJSONArray("captures") + for (index in 0 until captures.length()) { + db.execSQL( + "INSERT INTO heart_rate_generation_captures(generation_id,capture_id) VALUES(?,?)", + arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")), + ) + } } return CapturedSyncGeneration(generationId, runId, digest, stage) } catch (error: Exception) { @@ -1059,6 +1068,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"), ack.getString("generation_id")), ) + db.execSQL( + "UPDATE heart_rate_captures SET acknowledged_at=? WHERE " + + "acknowledged_at IS NULL AND EXISTS(" + + "SELECT 1 FROM heart_rate_generation_captures m " + + "WHERE m.generation_id=? AND " + + "m.capture_id=heart_rate_captures.capture_id)", + arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")), + ) } target } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 274e04f..e4ddaab 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -22,6 +22,10 @@ import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.ExerciseEditInput import com.labfytools.trainlog.model.ExerciseFeedback import com.labfytools.trainlog.model.DraftExerciseFeedback +import com.labfytools.trainlog.model.HeartRateCapture +import com.labfytools.trainlog.model.HeartRateContextKind +import com.labfytools.trainlog.model.HeartRateRrInterval +import com.labfytools.trainlog.model.HeartRateSample import com.labfytools.trainlog.model.LatestExerciseMax import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.PreparedSession @@ -486,6 +490,21 @@ class TrainlogRepository( data class Rejected(val reason: String) : SleepDiaryImportResult } + sealed interface StartHeartRateCaptureResult { + data class Started(val captureId: String) : StartHeartRateCaptureResult + data object Invalid : StartHeartRateCaptureResult + data object Conflict : StartHeartRateCaptureResult + data object DatabaseError : StartHeartRateCaptureResult + } + + sealed interface HeartRateMutationResult { + data class Applied(val sequence: Long? = null) : HeartRateMutationResult + data object Invalid : HeartRateMutationResult + data object NotFound : HeartRateMutationResult + data object Conflict : HeartRateMutationResult + data object DatabaseError : HeartRateMutationResult + } + private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}" private fun sleepIdentity(value: String, prefix: String): Boolean = @@ -1139,6 +1158,391 @@ class TrainlogRepository( ) } + private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean = + when (kind) { + HeartRateContextKind.SESSION, + HeartRateContextKind.CARDIO -> + Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + .matches(value) + HeartRateContextKind.SLEEP -> sleepIdentity(value, "sl") + } + + private fun heartRateContextExists( + db: SQLiteDatabase, + kind: HeartRateContextKind, + contextId: String, + ): Boolean = + when (kind) { + HeartRateContextKind.SESSION -> + db.rawQuery( + "SELECT 1 FROM active_session_draft WHERE id=1 AND session_id=? " + + "AND session_type IN('training','max_test') LIMIT 1", + arrayOf(contextId), + ).use { it.moveToFirst() } + HeartRateContextKind.CARDIO -> + db.rawQuery( + "SELECT 1 FROM active_session_draft WHERE id=1 AND session_id=? " + + "AND session_type='cardio' LIMIT 1", + arrayOf(contextId), + ).use { it.moveToFirst() } + HeartRateContextKind.SLEEP -> + db.rawQuery( + "SELECT 1 FROM sleep_diary_entries WHERE entry_id=? AND deleted=0 LIMIT 1", + arrayOf(contextId), + ).use { it.moveToFirst() } + } + + fun startHeartRateCapture( + contextKind: HeartRateContextKind, + contextId: String, + startedAt: String, + sensorName: String? = null, + ): StartHeartRateCaptureResult { + val normalizedSensor = sensorName?.trim()?.takeIf { it.isNotEmpty() } + if ( + !heartRateContextIdValid(contextKind, contextId) || + TrainlogTimestamp.parse(startedAt) == null || + (normalizedSensor != null && + normalizedSensor.toByteArray(StandardCharsets.UTF_8).size > + MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES) + ) return StartHeartRateCaptureResult.Invalid + + val db = database.writableDatabase + db.beginTransaction() + return try { + if (!heartRateContextExists(db, contextKind, contextId)) { + StartHeartRateCaptureResult.Invalid + } else if ( + db.rawQuery( + "SELECT 1 FROM heart_rate_captures WHERE active_slot=1 LIMIT 1", + null, + ).use { it.moveToFirst() } + ) { + StartHeartRateCaptureResult.Conflict + } else { + val captureId = "hrc_${UUID.randomUUID()}" + db.execSQL( + "INSERT INTO heart_rate_captures(" + + "capture_id,context_kind,context_id,started_at,ended_at,active_slot," + + "active_exercise_entry_id,sensor_name,acknowledged_at) " + + "VALUES(?,?,?,?,NULL,1,NULL,?,NULL)", + arrayOf(captureId, contextKind.wireValue, contextId, startedAt, normalizedSensor), + ) + db.setTransactionSuccessful() + StartHeartRateCaptureResult.Started(captureId) + } + } catch (_: SQLiteConstraintException) { + StartHeartRateCaptureResult.Conflict + } catch (_: android.database.sqlite.SQLiteException) { + StartHeartRateCaptureResult.DatabaseError + } finally { + db.endTransaction() + } + } + + fun setHeartRateActiveExercise( + captureId: String, + exerciseEntryId: String?, + ): HeartRateMutationResult { + if ( + !sleepIdentity(captureId, "hrc") || + (exerciseEntryId != null && !SESSION_ENTRY_ID_V4_PATTERN.matches(exerciseEntryId)) + ) return HeartRateMutationResult.Invalid + + val db = database.writableDatabase + db.beginTransaction() + return try { + val context = + db.rawQuery( + "SELECT context_kind,context_id FROM heart_rate_captures " + + "WHERE capture_id=? AND active_slot=1", + arrayOf(captureId), + ).use { cursor -> + if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) + } ?: return HeartRateMutationResult.NotFound + + if (context.first !in setOf( + HeartRateContextKind.SESSION.wireValue, + HeartRateContextKind.CARDIO.wireValue, + ) + ) { + HeartRateMutationResult.Invalid + } else if ( + exerciseEntryId != null && + !db.rawQuery( + "SELECT 1 FROM draft_session_exercises de " + + "JOIN active_session_draft d ON d.id=de.draft_id " + + "WHERE d.id=1 AND d.session_id=? AND de.entry_id=? LIMIT 1", + arrayOf(context.second, exerciseEntryId), + ).use { it.moveToFirst() } + ) { + HeartRateMutationResult.Invalid + } else { + val values = ContentValues().apply { + if (exerciseEntryId == null) putNull("active_exercise_entry_id") + else put("active_exercise_entry_id", exerciseEntryId) + } + if ( + db.update( + "heart_rate_captures", + values, + "capture_id=? AND active_slot=1", + arrayOf(captureId), + ) != 1 + ) HeartRateMutationResult.Conflict + else { + db.setTransactionSuccessful() + HeartRateMutationResult.Applied() + } + } + } catch (_: android.database.sqlite.SQLiteException) { + HeartRateMutationResult.DatabaseError + } finally { + db.endTransaction() + } + } + + fun appendHeartRateSample( + captureId: String, + observedAt: String, + bpm: Int, + sensorContactDetected: Boolean? = null, + energyExpended: Int? = null, + rrIntervals: List = emptyList(), + ): HeartRateMutationResult { + val observed = TrainlogTimestamp.parse(observedAt) + if ( + !sleepIdentity(captureId, "hrc") || + observed == null || + bpm !in 0..65535 || + (energyExpended != null && energyExpended !in 0..65535) || + rrIntervals.size > MAX_HEART_RATE_RR_PER_SAMPLE || + rrIntervals.withIndex().any { (index, rr) -> + rr.index != index || rr.value1024 !in 0..65535 + } + ) return HeartRateMutationResult.Invalid + + val db = database.writableDatabase + db.beginTransaction() + return try { + val capture = + db.rawQuery( + "SELECT started_at,active_exercise_entry_id FROM heart_rate_captures " + + "WHERE capture_id=? AND active_slot=1", + arrayOf(captureId), + ).use { cursor -> + if (!cursor.moveToFirst()) null + else cursor.getString(0) to + if (cursor.isNull(1)) null else cursor.getString(1) + } ?: return HeartRateMutationResult.NotFound + val started = TrainlogTimestamp.parse(capture.first) + ?: return HeartRateMutationResult.DatabaseError + if (observed < started) return HeartRateMutationResult.Invalid + + val sequence = + db.rawQuery( + "SELECT COALESCE(MAX(sequence),-1)+1 FROM heart_rate_samples WHERE capture_id=?", + arrayOf(captureId), + ).use { cursor -> + check(cursor.moveToFirst()) + cursor.getLong(0) + } + if (sequence >= MAX_HEART_RATE_SAMPLES_PER_CAPTURE.toLong()) { + return HeartRateMutationResult.Conflict + } + + db.execSQL( + "INSERT INTO heart_rate_samples(" + + "capture_id,sequence,observed_at,bpm,exercise_entry_id," + + "sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)", + arrayOf( + captureId, + sequence, + observedAt, + bpm, + capture.second, + sensorContactDetected?.let { if (it) 1 else 0 }, + energyExpended, + ), + ) + rrIntervals.forEach { rr -> + db.execSQL( + "INSERT INTO heart_rate_rr_intervals(" + + "capture_id,sample_sequence,rr_index,value_1024) VALUES(?,?,?,?)", + arrayOf(captureId, sequence, rr.index, rr.value1024), + ) + } + db.setTransactionSuccessful() + HeartRateMutationResult.Applied(sequence) + } catch (_: SQLiteConstraintException) { + HeartRateMutationResult.Conflict + } catch (_: android.database.sqlite.SQLiteException) { + HeartRateMutationResult.DatabaseError + } finally { + db.endTransaction() + } + } + + fun stopHeartRateCapture( + captureId: String, + endedAt: String, + ): HeartRateMutationResult { + val ended = TrainlogTimestamp.parse(endedAt) + if (!sleepIdentity(captureId, "hrc") || ended == null) + return HeartRateMutationResult.Invalid + val db = database.writableDatabase + db.beginTransaction() + return try { + val startedText = + db.rawQuery( + "SELECT started_at FROM heart_rate_captures WHERE capture_id=? AND active_slot=1", + arrayOf(captureId), + ).use { cursor -> + if (!cursor.moveToFirst()) null else cursor.getString(0) + } ?: return HeartRateMutationResult.NotFound + val started: TrainlogTimestampKey = + TrainlogTimestamp.parse(startedText) + ?: return HeartRateMutationResult.DatabaseError + if (ended < started) return HeartRateMutationResult.Invalid + val values = ContentValues().apply { + put("ended_at", endedAt) + putNull("active_slot") + putNull("active_exercise_entry_id") + } + if ( + db.update( + "heart_rate_captures", + values, + "capture_id=? AND active_slot=1", + arrayOf(captureId), + ) != 1 + ) HeartRateMutationResult.Conflict + else { + db.setTransactionSuccessful() + HeartRateMutationResult.Applied() + } + } catch (_: android.database.sqlite.SQLiteException) { + HeartRateMutationResult.DatabaseError + } finally { + db.endTransaction() + } + } + + fun activeHeartRateCapture(): HeartRateCapture? { + val db = database.readableDatabase + return db.rawQuery( + "SELECT capture_id,context_kind,context_id,started_at,ended_at," + + "active_exercise_entry_id,sensor_name FROM heart_rate_captures " + + "WHERE active_slot=1 LIMIT 1", + null, + ).use { cursor -> + if (!cursor.moveToFirst()) null + else HeartRateCapture( + captureId = cursor.getString(0), + contextKind = HeartRateContextKind.fromWire(cursor.getString(1)), + contextId = cursor.getString(2), + startedAt = cursor.getString(3), + endedAt = if (cursor.isNull(4)) null else cursor.getString(4), + activeExerciseEntryId = if (cursor.isNull(5)) null else cursor.getString(5), + sensorName = if (cursor.isNull(6)) null else cursor.getString(6), + ) + } + } + + internal fun buildHeartRateV1Json(): String { + val db = database.readableDatabase + val captures = JSONArray() + db.rawQuery( + "SELECT c.capture_id,c.context_kind,c.context_id,c.started_at,c.ended_at,c.sensor_name " + + "FROM heart_rate_captures c WHERE c.ended_at IS NOT NULL " + + "AND c.acknowledged_at IS NULL AND NOT EXISTS(" + + "SELECT 1 FROM heart_rate_generation_captures m " + + "JOIN sync_generations g ON g.generation_id=m.generation_id " + + "WHERE m.capture_id=c.capture_id AND " + + "g.status IN('captured','published','waiting_acknowledgement')) " + + "ORDER BY c.ended_at,c.capture_id LIMIT ?", + arrayOf(MAX_HEART_RATE_CAPTURES_PER_GENERATION.toString()), + ).use { cursor -> + while (cursor.moveToNext()) { + val captureId = cursor.getString(0) + val samples = JSONArray() + val rrBySequence = mutableMapOf() + db.rawQuery( + "SELECT sample_sequence,rr_index,value_1024 FROM heart_rate_rr_intervals " + + "WHERE capture_id=? ORDER BY sample_sequence,rr_index", + arrayOf(captureId), + ).use { rrCursor -> + var currentSequence: Long? = null + var expectedIndex = 0 + while (rrCursor.moveToNext()) { + val sequence = rrCursor.getLong(0) + if (sequence != currentSequence) { + currentSequence = sequence + expectedIndex = 0 + } + check(rrCursor.getInt(1) == expectedIndex++) + rrBySequence.getOrPut(sequence) { JSONArray() }.put(rrCursor.getInt(2)) + } + } + db.rawQuery( + "SELECT sequence,observed_at,bpm,exercise_entry_id," + + "sensor_contact_detected,energy_expended FROM heart_rate_samples " + + "WHERE capture_id=? ORDER BY sequence", + arrayOf(captureId), + ).use { sampleCursor -> + var count = 0 + while (sampleCursor.moveToNext()) { + check(++count <= MAX_HEART_RATE_SAMPLES_PER_CAPTURE) + val sequence = sampleCursor.getLong(0) + val rr = rrBySequence.remove(sequence) ?: JSONArray() + samples.put( + JSONObject() + .put("sequence", sequence) + .put("observed_at", sampleCursor.getString(1)) + .put("bpm", sampleCursor.getInt(2)) + .put( + "exercise_entry_id", + if (sampleCursor.isNull(3)) JSONObject.NULL + else sampleCursor.getString(3), + ) + .put( + "sensor_contact_detected", + if (sampleCursor.isNull(4)) JSONObject.NULL + else sampleCursor.getInt(4) != 0, + ) + .put( + "energy_expended", + if (sampleCursor.isNull(5)) JSONObject.NULL + else sampleCursor.getInt(5), + ) + .put("rr_intervals_1024", rr), + ) + } + } + check(rrBySequence.isEmpty()) + captures.put( + JSONObject() + .put("capture_id", captureId) + .put("context_kind", cursor.getString(1)) + .put("context_id", cursor.getString(2)) + .put("started_at", cursor.getString(3)) + .put("ended_at", cursor.getString(4)) + .put( + "sensor_name", + if (cursor.isNull(5)) JSONObject.NULL else cursor.getString(5), + ) + .put("samples", samples), + ) + } + } + return JSONObject() + .put("format", "trainlog-heart-rate") + .put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()) + .put("captures", captures) + .toString() + } + /** Create a transactionally consistent SQLite snapshot without exposing WAL files. */ internal fun backupDatabaseTo(destination: File) { require(!destination.exists()) { "Backup snapshot destination already exists." } @@ -1185,6 +1589,7 @@ class TrainlogRepository( artifacts["causal-deletions"] = buildCausalDeletionExportV1Json() artifacts["program-executions"] = buildProgramExecutionsV1Json() artifacts["sleep-diary"] = buildSleepDiaryV1Json() + artifacts["heart-rate"] = buildHeartRateV1Json() artifacts } @@ -9554,6 +9959,10 @@ private val SESSION_ENTRY_ID_V4_PATTERN = Regex( "^sxe_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", ) private const val MAX_AI_DRAFTS = 256 +private const val MAX_HEART_RATE_CAPTURES_PER_GENERATION = 256 +private const val MAX_HEART_RATE_SAMPLES_PER_CAPTURE = 200000 +private const val MAX_HEART_RATE_RR_PER_SAMPLE = 64 +private const val MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES = 160 private const val MAX_AI_DRAFT_ENTRIES = 64 private const val MAX_AI_TARGET_SETS = 99 private const val MAX_AI_TARGET_REPS = 999 @@ -9572,7 +9981,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 26, + 27, ) { override fun onConfigure( db: SQLiteDatabase, @@ -9626,6 +10035,7 @@ private class TrainlogDatabaseHelper( createSyncedProgramTables(db) createProgramExecutionProvenance(db) createSleepDiaryTables(db) + createHeartRateTables(db) seedEquipment(db) } @@ -9839,6 +10249,14 @@ private class TrainlogDatabaseHelper( createSleepDiaryTables(db) version = 26 } + if (version < 27 && newVersion >= 27) { + /* WHY: heart-rate samples are Android-owned measured field data. + * CONTRACT: v27 adds only cardio captures/samples and generation + * publication state. INVARIANT: no workout or sleep row is + * rewritten and no historical timestamp is invented. */ + createHeartRateTables(db) + version = 27 + } if (version != newVersion) { error( @@ -9977,6 +10395,71 @@ private class TrainlogDatabaseHelper( PRIMARY KEY(generation_id,entry_id))""".trimIndent()) } + private fun createHeartRateTables(db: SQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS heart_rate_captures( + capture_id TEXT PRIMARY KEY, + context_kind TEXT NOT NULL CHECK(context_kind IN('session','cardio','sleep')), + context_id TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + active_slot INTEGER UNIQUE CHECK(active_slot IS NULL OR active_slot=1), + active_exercise_entry_id TEXT, + sensor_name TEXT, + acknowledged_at TEXT, + CHECK(capture_id GLOB 'hrc_*'), + CHECK((context_kind IN('session','cardio') AND context_id GLOB 'se_*') OR + (context_kind='sleep' AND context_id GLOB 'sl_*')), + CHECK(active_exercise_entry_id IS NULL OR active_exercise_entry_id GLOB 'sxe_*'), + CHECK(sensor_name IS NULL OR length(CAST(sensor_name AS BLOB))<=160) + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS heart_rate_samples( + capture_id TEXT NOT NULL + REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE, + sequence INTEGER NOT NULL CHECK(sequence>=0), + observed_at TEXT NOT NULL, + bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535), + exercise_entry_id TEXT, + sensor_contact_detected INTEGER + CHECK(sensor_contact_detected IS NULL OR sensor_contact_detected IN(0,1)), + energy_expended INTEGER + CHECK(energy_expended IS NULL OR energy_expended BETWEEN 0 AND 65535), + PRIMARY KEY(capture_id,sequence), + CHECK(exercise_entry_id IS NULL OR exercise_entry_id GLOB 'sxe_*') + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS heart_rate_rr_intervals( + capture_id TEXT NOT NULL, + sample_sequence INTEGER NOT NULL, + rr_index INTEGER NOT NULL CHECK(rr_index BETWEEN 0 AND 63), + value_1024 INTEGER NOT NULL CHECK(value_1024 BETWEEN 0 AND 65535), + PRIMARY KEY(capture_id,sample_sequence,rr_index), + FOREIGN KEY(capture_id,sample_sequence) + REFERENCES heart_rate_samples(capture_id,sequence) ON DELETE CASCADE + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS heart_rate_generation_captures( + generation_id TEXT NOT NULL + REFERENCES sync_generations(generation_id) ON DELETE RESTRICT, + capture_id TEXT NOT NULL + REFERENCES heart_rate_captures(capture_id) ON DELETE RESTRICT, + PRIMARY KEY(generation_id,capture_id) + )""".trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS heart_rate_samples_time " + + "ON heart_rate_samples(observed_at,capture_id,sequence)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS heart_rate_captures_context " + + "ON heart_rate_captures(context_kind,context_id,started_at)", + ) + } + private fun createSyncDataLifecycleTables(db: SQLiteDatabase) { fun addColumn(table: String, name: String, declaration: String) { if (!tableHasColumn(db, table, name)) { diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/HeartRateModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/HeartRateModels.kt new file mode 100644 index 0000000..dc857dc --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/model/HeartRateModels.kt @@ -0,0 +1,46 @@ +/* + * Android HeartRateModels model. + * + * Android owns sensor acquisition. Desktop/Web only consume synchronized + * measured facts and never connect to the heart-rate sensor. + */ +package com.labfytools.trainlog.model + +enum class HeartRateContextKind(val wireValue: String) { + SESSION("session"), + CARDIO("cardio"), + SLEEP("sleep"); + + companion object { + fun fromWire(value: String): HeartRateContextKind = + entries.firstOrNull { it.wireValue == value } + ?: error("Unknown heart-rate context: $value") + } +} + +data class HeartRateRrInterval( + val index: Int, + /** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */ + val value1024: Int, +) + +data class HeartRateSample( + val sequence: Long, + val observedAt: String, + val bpm: Int, + val exerciseEntryId: String? = null, + val sensorContactDetected: Boolean? = null, + val energyExpended: Int? = null, + val rrIntervals: List = emptyList(), +) + +data class HeartRateCapture( + val captureId: String, + val contextKind: HeartRateContextKind, + val contextId: String, + val startedAt: String, + val endedAt: String?, + val activeExerciseEntryId: String?, + val sensorName: String?, + val samples: List = emptyList(), +) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index 4062a25..33d9b2f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(26, scalar(db, "PRAGMA user_version")) + assertEquals(27, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 72fcf63..699fc45 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(26, scalar(db, "PRAGMA user_version")) + assertEquals(27, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 04968e7..1b032ea 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(26, scalar(db, "PRAGMA user_version")) + assertEquals(27, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt index a1673aa..9800645 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest { reopened.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(26, scalar(db, "PRAGMA user_version")) + assertEquals(27, scalar(db, "PRAGMA user_version")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals("ok", text(db, "PRAGMA integrity_check")) db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt index 540a248..b0561ec 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt @@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(26, it.version) } + ).use { assertEquals(27, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt new file mode 100644 index 0000000..f26c990 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt @@ -0,0 +1,54 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import java.util.UUID +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidV27HeartRateMigrationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun v26AddsEmptyHeartRateDomainAndReopens() { + val name = "heart-rate-v26-${UUID.randomUUID()}.db" + TrainlogRepository(context, name).also { + JSONObject(it.buildHeartRateV1Json()) + it.close() + } + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("DROP TABLE heart_rate_generation_captures") + db.execSQL("DROP TABLE heart_rate_rr_intervals") + db.execSQL("DROP TABLE heart_rate_samples") + db.execSQL("DROP TABLE heart_rate_captures") + db.version = 26 + } + + var repository = TrainlogRepository(context, name) + try { + assertEquals(0, JSONObject(repository.buildHeartRateV1Json()) + .getJSONArray("captures").length()) + repository.close() + repository = TrainlogRepository(context, name) + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { assertEquals(27, it.version) } + } finally { + repository.close() + context.deleteDatabase(name) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index d3d57a4..dfef645 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(27, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt index 7da55de..23ae36f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -43,7 +43,7 @@ class BridgeBackupMigrationTest { null, android.database.sqlite.SQLiteDatabase.OPEN_READONLY, ) - .use { assertEquals(26, it.version) } + .use { assertEquals(27, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateRepositoryTest.kt new file mode 100644 index 0000000..97a8aa9 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateRepositoryTest.kt @@ -0,0 +1,96 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.HeartRateContextKind +import com.labfytools.trainlog.model.HeartRateRrInterval +import com.labfytools.trainlog.model.SleepDiaryDraft +import java.util.UUID +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HeartRateRepositoryTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() { + val name = "heart-rate-${UUID.randomUUID()}.db" + val repository = TrainlogRepository(context, name) + try { + val created = + repository.saveSleepDiary( + SleepDiaryDraft( + nightStartDate = "2026-09-22", + nightEndDate = "2026-09-23", + createdAt = "2026-09-22T22:00:00+02:00", + updatedAt = "2026-09-22T22:00:00+02:00", + sleepQuality = null, + wakeQuality = null, + dayForm = null, + treatmentAndNotes = "", + events = emptyList(), + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + + val started = + repository.startHeartRateCapture( + HeartRateContextKind.SLEEP, + created.entryId, + "2026-09-22T22:30:00+02:00", + "Synthetic HR sensor", + ) as TrainlogRepository.StartHeartRateCaptureResult.Started + assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId) + + val sample = + repository.appendHeartRateSample( + captureId = started.captureId, + observedAt = "2026-09-22T22:30:01+02:00", + bpm = 61, + sensorContactDetected = true, + energyExpended = 7, + rrIntervals = listOf( + HeartRateRrInterval(0, 1024), + HeartRateRrInterval(1, 1000), + ), + ) as TrainlogRepository.HeartRateMutationResult.Applied + assertEquals(0L, sample.sequence) + + assertEquals( + 0, + JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(), + ) + assertTrue( + repository.stopHeartRateCapture( + started.captureId, + "2026-09-23T06:30:00+02:00", + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertNull(repository.activeHeartRateCapture()) + + val root = JSONObject(repository.buildHeartRateV1Json()) + assertEquals("trainlog-heart-rate", root.getString("format")) + assertEquals(1, root.getInt("version")) + val capture = root.getJSONArray("captures").getJSONObject(0) + assertEquals(created.entryId, capture.getString("context_id")) + assertEquals("sleep", capture.getString("context_kind")) + assertEquals("Synthetic HR sensor", capture.getString("sensor_name")) + val stored = capture.getJSONArray("samples").getJSONObject(0) + assertEquals(61, stored.getInt("bpm")) + assertTrue(stored.getBoolean("sensor_contact_detected")) + assertEquals(7, stored.getInt("energy_expended")) + assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0)) + assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1)) + } finally { + repository.close() + context.deleteDatabase(name) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index 92ba012..5ef611c 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -360,7 +360,7 @@ class SessionPreparationExchangeTest { repository = TrainlogRepository(context, name) assertEquals(1, repository.listPreparedSessions().size) repository.close() - assertEquals("26", scalarText(name, "PRAGMA user_version")) + assertEquals("27", scalarText(name, "PRAGMA user_version")) assertEquals( "0", scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index b22d772..1d8f697 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -3,11 +3,14 @@ package com.labfytools.trainlog.data import android.content.Context import android.database.sqlite.SQLiteDatabase import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.HeartRateContextKind +import com.labfytools.trainlog.model.HeartRateRrInterval import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.SleepDiaryDraft import com.labfytools.trainlog.model.TrackingMode import java.io.File import java.nio.file.Files @@ -506,7 +509,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(26, it.getInt(0)) + assertEquals(27, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) @@ -855,7 +858,54 @@ class SyncGenerationServiceTest { ) )!! .groupValues[1] + val sleepEntry = + source.saveSleepDiary( + SleepDiaryDraft( + nightStartDate = "2026-09-22", + nightEndDate = "2026-09-23", + createdAt = "2026-09-22T22:00:00+02:00", + updatedAt = "2026-09-22T22:00:00+02:00", + sleepQuality = null, + wakeQuality = null, + dayForm = null, + treatmentAndNotes = "", + events = emptyList(), + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + val heartCapture = + source.startHeartRateCapture( + HeartRateContextKind.SLEEP, + sleepEntry.entryId, + "2026-09-22T22:30:00+02:00", + "Synthetic HR", + ) as TrainlogRepository.StartHeartRateCaptureResult.Started + assertTrue( + source.appendHeartRateSample( + heartCapture.captureId, + "2026-09-22T22:30:01+02:00", + 61, + true, + 7, + listOf(HeartRateRrInterval(0, 1024)), + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + assertTrue( + source.stopHeartRateCapture( + heartCapture.captureId, + "2026-09-22T22:31:00+02:00", + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) + val androidToDesktop = sourceService.capture(root, desktopPeer) + assertEquals( + 1, + JSONObject( + java.io.File(androidToDesktop.stagingDirectory, "heart-rate-v1.json") + .readText(), + ) + .getJSONArray("captures") + .length(), + ) val androidPublished = sourceService.publish( androidToDesktop, @@ -872,10 +922,31 @@ class SyncGenerationServiceTest { "--ack-output", androidAckFile.absolutePath, ) + val androidAck = JSONObject(androidAckFile.readText()) + assertEquals( + "desktop ACK diagnostic: ${androidAck.getString("diagnostic")}", + "consumed", + androidAck.getString("result"), + ) assertEquals( "acknowledged", sourceService.acceptAcknowledgement(androidAckFile.readBytes()), ) + assertEquals( + 0, + JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(), + ) + assertEquals( + "1|1|1", + run( + "sqlite3", + desktopConsumerDb.absolutePath, + "SELECT (SELECT count(*) FROM heart_rate_captures) || '|' || " + + "(SELECT count(*) FROM heart_rate_samples) || '|' || " + + "(SELECT count(*) FROM heart_rate_rr_intervals);", + ) + .trim(), + ) val androidSessionId = source.listSessions().single().sessionId assertEquals( "1", diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index a5d3b94..9e7ed26 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(26, cursor.getInt(0)) + assertEquals(27, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(26, cursor.getInt(0)) + assertEquals(27, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/current_state.md b/docs/current_state.md index 2ce1345..3430316 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -112,8 +112,8 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29; Cardio v30 work is implementation-started but not yet closed | -| Android SQLite | schema v26; Sleep Diary V1 is additive in v26; Cardio v27 work is implementation-started but not yet closed | +| Desktop SQLite | schema v30; synchronized Heart Rate V1 storage is additive in v30 | +| Android SQLite | schema v27; Android-owned Heart Rate V1 capture storage is additive in v27 | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` | @@ -147,7 +147,8 @@ parallel implementation of its rules. | Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | | Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` | -| Current operational cursor | `TRAINLOG_CARDIO_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`: Android-only sensor ownership, training timeline, Sleep one-tap capture, cardio session/calibration and BPM-guidance contract frozen; persistence/sync implementation is the next closeout gate | +| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK | +| Current operational cursor | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1`: standard Heart Rate Service parser, Android BLE discovery and real-sensor characterization | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | diff --git a/docs/database.md b/docs/database.md index e7f87c4..38d634c 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,25 @@ # Desktop database +## Schema v30: synchronized Heart Rate V1 + +Schema v30 additively owns Android-recorded heart-rate captures after +full-generation synchronization. `heart_rate_captures` stores one immutable +stopped capture with its stable `hrc_` identity, owner kind (`session` or +`sleep`), stable owner identity, exact start/end timestamps, optional sensor +name, and import time. + +`heart_rate_samples` stores the received timestamp, measured BPM, optional +exercise `entry_id` snapshot, optional sensor-contact state, and optional +cumulative energy value. `heart_rate_rr_intervals` preserves zero or more raw +Heart Rate Service RR values in their native 1/1024-second units. Desktop never +creates these measurements and never connects to the sensor. + +The v29→v30 migration is additive and creates no cardio row. Heart-rate data +travels Android→desktop through the separate optional `trainlog-heart-rate` +V1 generation companion. Exact replay is idempotent; a stable capture/sample +identity with different measured content is rejected. Existing workout, +Sleep Diary, and frozen mobile formats are unchanged. + ## Schema v29: Sleep Diary V1 Schema v29 additively owns `sleep_diary_entries`, immutable @@ -9,14 +29,14 @@ one night and its associated day. Point events have an offset-bearing their start. The 18:00-to-18:00 agenda is only a projection and is never stored as cells. -The same unreleased migration owns `sleep_medications`, immutable +The same stable v0.1.4 migration owns `sleep_medications`, immutable `sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`. Catalog defaults assist capture but do not own historical facts: every intake stores the displayed medication name and effective optional positive dose/unit snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a past entry. Development databases that had already opened the earlier v29 -draft are completed idempotently on open; `user_version` remains 29 and no -existing row is rewritten. +draft are completed idempotently on open before the additive v30 migration; +no existing sleep row is rewritten. Mutations advance `current_revision_id` only when the caller supplies the current parent. Logical deletion advances the revision and retains the diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b85ed4..26d0c99 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog Bluetooth synchronization transport contract. ```text -CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1 ``` The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index f25b0da..887d9f3 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -1060,7 +1060,36 @@ format. It lists bounded durable desktop AI proposals and relevant execution drafts as distinct kinds. Completed sessions remain outside this projection, and finalized or causally deleted execution drafts are excluded. -## 19. Legacy active-protocol limitations +## 19. Heart-rate companion V1 + +`trainlog-heart-rate` V1 is an optional full-generation participant produced +only by Android. Its logical name is `heart-rate` and its immutable generation +file is `heart-rate-v1.json`. Desktop never produces sensor measurements and +never connects to the heart-rate device. + +A normal Android generation includes only stopped captures that have not +already been acknowledged and are not already retained by an outstanding +captured/published/waiting-ACK generation. Capture, sample and RR identities +are respectively `capture_id`, `capture_id + sequence`, and +`capture_id + sequence + rr_index`. + +Desktop schema v30 validates and imports the whole companion inside the +containing generation transaction. Exact replay is idempotent; reusing one +stable measurement identity with different content rejects the generation. + +Android schema v27 records the exact `capture_id -> generation_id` relation +when the immutable generation is captured. Only a correlated `consumed` ACK +sets those captures' acknowledgement timestamps. Rejected generations do not +advance cardio acknowledgement. Acknowledged captures remain in Android +history but are omitted from normal later publication, preventing a full +night's samples from being retransmitted on every sync. + +The companion changes neither the frozen Bluetooth Classic RFCOMM transport, +MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics. +Heart-rate BLE/GATT acquisition remains Android-only and is a separate +implementation layer. + +## 20. Legacy active-protocol limitations The Web end-to-end tranche adds an explicit opt-in application orchestrator. `POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is diff --git a/docs/tests.md b/docs/tests.md index 609a468..e6e0d5c 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1648,3 +1648,25 @@ checks passed, both outgoing desktop generations were acknowledged, and the Android UI displayed completion. The private journal retains exact IDs and hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone was used only for non-destructive install, backup and manual sync validation. + +## Cardio persistence/synchronization V1 validation + +`TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS` is covered without requiring a +physical heart-rate sensor. Android migration coverage advances schema v26 to +v27 and verifies durable raw BPM/contact/energy/RR capture; desktop migration +coverage advances v29 to v30 and verifies the additive cardio tables/indexes. + +`heart_rate_exchange` validates the strict `trainlog-heart-rate` V1 envelope, +cardio/session/sleep ownership, timestamp bounds, raw RR preservation, exact +idempotent replay, and rejection of identity reuse with different measured +content. The Android full-generation regression captures a stopped sleep-owned +measurement, publishes it, consumes it through the production desktop +generation tool, accepts the correlated ACK, verifies one desktop +capture/sample/RR row, and proves the acknowledged Android capture is no longer +emitted. + +The 2026-09-23 closeout passes the complete native Meson inventory: +**102/102 passed, 0 failed**. The complete Android JVM inventory reports +**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips +remain optional historical/external fixture gates and are not Cardio V1 +failures. diff --git a/tests/test_heart_rate_exchange.py b/tests/test_heart_rate_exchange.py new file mode 100644 index 0000000..1e9edf9 --- /dev/null +++ b/tests/test_heart_rate_exchange.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Heart Rate V1 companion validation and idempotent desktop import tests.""" + +import json +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import heart_rate_exchange as heart_rate + + +CAPTURE = "hrc_11111111-1111-4111-8111-111111111111" +SESSION = "se_22222222-2222-4222-8222-222222222222" +ENTRY = "sxe_33333333-3333-4333-8333-333333333333" + + +def schema(db: sqlite3.Connection) -> None: + db.executescript( + """ + PRAGMA foreign_keys=ON; + CREATE TABLE heart_rate_captures( + capture_id TEXT PRIMARY KEY,context_kind TEXT,context_id TEXT, + started_at TEXT,ended_at TEXT,sensor_name TEXT,imported_at TEXT); + CREATE TABLE heart_rate_samples( + capture_id TEXT,sequence INTEGER,observed_at TEXT,bpm INTEGER, + exercise_entry_id TEXT,sensor_contact_detected INTEGER, + energy_expended INTEGER,PRIMARY KEY(capture_id,sequence), + FOREIGN KEY(capture_id) REFERENCES heart_rate_captures(capture_id)); + CREATE TABLE heart_rate_rr_intervals( + capture_id TEXT,sample_sequence INTEGER,rr_index INTEGER,value_1024 INTEGER, + PRIMARY KEY(capture_id,sample_sequence,rr_index), + FOREIGN KEY(capture_id,sample_sequence) + REFERENCES heart_rate_samples(capture_id,sequence)); + PRAGMA user_version=30; + """ + ) + + +def document() -> dict: + return { + "format": "trainlog-heart-rate", + "version": 1, + "generated_at": "2026-09-22T19:00:00+02:00", + "captures": [{ + "capture_id": CAPTURE, + "context_kind": "session", + "context_id": SESSION, + "started_at": "2026-09-22T18:00:00+02:00", + "ended_at": "2026-09-22T18:30:00+02:00", + "sensor_name": "Synthetic HR", + "samples": [{ + "sequence": 0, + "observed_at": "2026-09-22T18:01:00+02:00", + "bpm": 92, + "exercise_entry_id": ENTRY, + "sensor_contact_detected": True, + "energy_expended": 12, + "rr_intervals_1024": [700, 710], + }], + }], + } + + +class HeartRateExchangeTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.db = sqlite3.connect(Path(self.temp.name) / "heart.sqlite") + schema(self.db) + + def tearDown(self): + self.db.close() + self.temp.cleanup() + + def test_import_exact_replay_and_raw_rr(self): + payload = heart_rate.validate(document()) + self.assertEqual((1, 0), heart_rate.apply(self.db, payload)) + self.assertEqual((0, 1), heart_rate.apply(self.db, payload)) + self.assertEqual( + (92, ENTRY, 1, 12), + self.db.execute( + "SELECT bpm,exercise_entry_id,sensor_contact_detected,energy_expended " + "FROM heart_rate_samples" + ).fetchone(), + ) + self.assertEqual( + [(700,), (710,)], + self.db.execute( + "SELECT value_1024 FROM heart_rate_rr_intervals ORDER BY rr_index" + ).fetchall(), + ) + + def test_same_identity_with_different_measurement_is_rejected(self): + payload = heart_rate.validate(document()) + heart_rate.apply(self.db, payload) + changed = document() + changed["captures"][0]["samples"][0]["bpm"] = 93 + with self.assertRaisesRegex(ValueError, "different content"): + heart_rate.apply(self.db, heart_rate.validate(changed)) + + def test_cardio_context_accepts_session_identity_and_exercise_marker(self): + cardio = document() + cardio["captures"][0]["context_kind"] = "cardio" + validated = heart_rate.validate(cardio) + self.assertEqual("cardio", validated["captures"][0]["context_kind"]) + self.assertEqual(ENTRY, validated["captures"][0]["samples"][0]["exercise_entry_id"]) + + def test_rejects_sample_outside_capture_and_sleep_exercise_marker(self): + outside = document() + outside["captures"][0]["samples"][0]["observed_at"] = "2026-09-22T17:59:59+02:00" + with self.assertRaisesRegex(ValueError, "outside capture"): + heart_rate.validate(outside) + + sleep = document() + capture = sleep["captures"][0] + capture["context_kind"] = "sleep" + capture["context_id"] = "sl_44444444-4444-4444-8444-444444444444" + with self.assertRaisesRegex(ValueError, "exercise context"): + heart_rate.validate(sleep) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index c46c3bd..401dbdf 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -66,12 +66,13 @@ class DeploymentToolsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) inventory = json.loads((output / "candidate-inventory.json").read_text()) self.assertEqual(inventory["product_version"], "0.1.5") - self.assertEqual(inventory["desktop_schema"], 29) - self.assertEqual(inventory["android_schema"], 26) + self.assertEqual(inventory["desktop_schema"], 30) + self.assertEqual(inventory["android_schema"], 27) self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"]) self.assertIn("program-executions-v1", inventory["protocols"]) self.assertIn("sleep-diary-v1", inventory["protocols"]) + self.assertIn("heart-rate-v1", inventory["protocols"]) self.assertIn("bluetooth-files-v1", inventory["protocols"]) self.assertTrue((output / "bin/trainlog").is_file()) self.assertTrue((output / "bin/trainlog-sync-once").is_file()) diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 7a0c097..3984e4c 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -38,8 +38,8 @@ def digest(value) -> str: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29): - raise CausalError(f"desktop schema v20-v29 required, found v{version}") + if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30): + raise CausalError(f"desktop schema v20-v30 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 402ae71..3e68a37 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -182,8 +182,8 @@ def causal_payload(draft): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): - raise LifecycleError(f"desktop schema v19-v29 required, found v{version}") + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30): + raise LifecycleError(f"desktop schema v19-v30 required, found v{version}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 5deaaa6..d19f592 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", @@ -125,7 +125,7 @@ def build_export(connection, generated_at): """Build the raw export using only explicitly ordered SELECT statements.""" schema_version = connection.execute("PRAGMA user_version").fetchone()[0] if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - raise ValueError("schema desktop v15-v29 requis") + raise ValueError("schema desktop v15-v30 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index d163619..ec88dfe 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,9 +20,9 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v29 requis") + raise ValueError("schema desktop v18-v30 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 2d9de78..6e639e6 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,9 +28,9 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - supported_versions = tuple(range(8, 30)) + supported_versions = tuple(range(8, 31)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v29 requis") + raise ValueError("schema desktop v8 à v30 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 13f8dc1..4b43d14 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,9 +24,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(8, 30)) + supported_versions = tuple(range(8, 31)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v29 requis") + raise ValueError("schema desktop v8 à v30 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal equipment protection requires the staged artifact") equipment = [dict(row) for row in connection.execute( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 0253d48..c7d634d 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -17,8 +17,8 @@ def main(): con = connect_database(args.database) try: if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): - raise ValueError("schema desktop v12-v29 requis") + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30): + raise ValueError("schema desktop v12-v30 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index c1bb3c1..5965b15 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,9 +41,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(11, 30)) + supported_versions = tuple(range(11, 31)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v11-v16 requis") + raise ValueError("schema desktop v11-v30 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal BODY ZONES protection requires the staged artifact") exercises = [] diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index 22cddcf..c3014eb 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index fca91a3..ded77d8 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in range(8, 30): + if version not in range(8, 31): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index bcebc83..ce7e5a0 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -83,12 +83,12 @@ def main(complete_causal_envelope=False): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 30) + supported_versions = range(11, 31) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: - raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") + raise ValueError("schema desktop v11-v30 requis (v10 accepté pour export V2 explicite)") if not complete_causal_envelope and schema_version >= 20 and con.execute( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") diff --git a/tools/export_programs.py b/tools/export_programs.py index f3312a0..eb3cfa4 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,7 +16,7 @@ def nullable(row, index): def export_programs(database: Path) -> dict: with closing(connect_database(database)) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29): + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30): raise ValueError("desktop schema v26, v27, v28 or v29 required") programs = [] for row in db.execute( diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 60d54f1..618532d 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26, 27, 28, 29) + supported_versions = (24, 25, 26, 27, 28, 29, 30) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required") deliveries = [] diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index fa171a8..b8ce94a 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,9 +10,9 @@ def main(complete_causal_envelope=False): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - supported_versions = range(15, 30) + supported_versions = range(15, 31) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v29 requis") + raise ValueError("schema desktop v15-v30 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() diff --git a/tools/heart_rate_exchange.py b/tools/heart_rate_exchange.py new file mode 100644 index 0000000..1794ec9 --- /dev/null +++ b/tools/heart_rate_exchange.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Validate and import the Android-owned Heart Rate V1 companion.""" + +import argparse +import json +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + +from trainlog_sqlite import connect_database + + +FORMAT = "trainlog-heart-rate" +VERSION = 1 +MAX_BYTES = 64 * 1024 * 1024 +MAX_CAPTURES = 256 +MAX_SAMPLES = 200_000 +MAX_RR = 64 +UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$") +SESSION_ID = re.compile(rf"^se_{UUID4}$") +SLEEP_ID = re.compile(rf"^sl_{UUID4}$") +ENTRY_ID = re.compile(rf"^sxe_{UUID4}$") + + +def fail(message: str) -> None: + raise ValueError(message) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + fail("duplicate JSON field: " + key) + result[key] = value + return result + + +def timestamp(value: object) -> tuple[str, datetime]: + if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None: + fail("invalid heart-rate timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError("invalid heart-rate timestamp") from error + if parsed.utcoffset() is None: + fail("heart-rate timestamp lacks offset") + return value, parsed + +def validate(root: object) -> dict: + if not isinstance(root, dict) or set(root) != { + "format", "version", "generated_at", "captures" + }: + fail("invalid heart-rate envelope") + if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION: + fail("unsupported heart-rate format") + timestamp(root["generated_at"]) + captures = root["captures"] + if not isinstance(captures, list) or len(captures) > MAX_CAPTURES: + fail("heart-rate capture bound exceeded") + seen_captures = set() + for capture in captures: + keys = { + "capture_id", "context_kind", "context_id", "started_at", "ended_at", + "sensor_name", "samples", + } + if not isinstance(capture, dict) or set(capture) != keys: + fail("invalid heart-rate capture") + capture_id = capture["capture_id"] + if not isinstance(capture_id, str) or not CAPTURE_ID.fullmatch(capture_id) or capture_id in seen_captures: + fail("invalid or duplicate heart-rate capture identity") + seen_captures.add(capture_id) + kind = capture["context_kind"] + context_id = capture["context_id"] + if kind in ("session", "cardio"): + if not isinstance(context_id, str) or not SESSION_ID.fullmatch(context_id): + fail("invalid heart-rate session context") + elif kind == "sleep": + if not isinstance(context_id, str) or not SLEEP_ID.fullmatch(context_id): + fail("invalid heart-rate sleep context") + else: + fail("invalid heart-rate context kind") + _, started = timestamp(capture["started_at"]) + _, ended = timestamp(capture["ended_at"]) + if ended < started: + fail("invalid heart-rate capture interval") + sensor_name = capture["sensor_name"] + if sensor_name is not None and ( + not isinstance(sensor_name, str) or not sensor_name.strip() or len(sensor_name.encode()) > 160 + ): + fail("invalid heart-rate sensor name") + + samples = capture["samples"] + if not isinstance(samples, list) or len(samples) > MAX_SAMPLES: + fail("heart-rate sample bound exceeded") + for expected, sample in enumerate(samples): + sample_keys = { + "sequence", "observed_at", "bpm", "exercise_entry_id", + "sensor_contact_detected", "energy_expended", "rr_intervals_1024", + } + if not isinstance(sample, dict) or set(sample) != sample_keys: + fail("invalid heart-rate sample") + if type(sample["sequence"]) is not int or sample["sequence"] != expected: + fail("invalid heart-rate sample sequence") + _, observed = timestamp(sample["observed_at"]) + if observed < started or observed > ended: + fail("heart-rate sample outside capture") + if type(sample["bpm"]) is not int or not 0 <= sample["bpm"] <= 65535: + fail("invalid heart-rate bpm") + entry_id = sample["exercise_entry_id"] + if entry_id is not None and ( + kind not in ("session", "cardio") or + not isinstance(entry_id, str) or + not ENTRY_ID.fullmatch(entry_id) + ): + fail("invalid heart-rate exercise context") + contact = sample["sensor_contact_detected"] + if contact is not None and type(contact) is not bool: + fail("invalid sensor contact state") + energy = sample["energy_expended"] + if energy is not None and (type(energy) is not int or not 0 <= energy <= 65535): + fail("invalid heart-rate energy value") + rr = sample["rr_intervals_1024"] + if not isinstance(rr, list) or len(rr) > MAX_RR or any( + type(value) is not int or not 0 <= value <= 65535 for value in rr + ): + fail("invalid heart-rate RR interval") + return root + + +def load(path: Path) -> dict: + if path.stat().st_size > MAX_BYTES: + fail("heart-rate artifact exceeds 64 MiB") + return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)) + +def _same_capture(db: sqlite3.Connection, capture: dict) -> bool: + row = db.execute( + "SELECT context_kind,context_id,started_at,ended_at,sensor_name " + "FROM heart_rate_captures WHERE capture_id=?", + (capture["capture_id"],), + ).fetchone() + if row is None: + return False + if tuple(row) != ( + capture["context_kind"], + capture["context_id"], + capture["started_at"], + capture["ended_at"], + capture["sensor_name"], + ): + fail("heart-rate capture identity reused with different metadata") + stored_samples = db.execute( + "SELECT sequence,observed_at,bpm,exercise_entry_id,sensor_contact_detected," + "energy_expended FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence", + (capture["capture_id"],), + ).fetchall() + if len(stored_samples) != len(capture["samples"]): + fail("heart-rate capture identity reused with different samples") + rr_by_sequence = {} + for sequence, rr_index, value in db.execute( + "SELECT sample_sequence,rr_index,value_1024 FROM heart_rate_rr_intervals " + "WHERE capture_id=? ORDER BY sample_sequence,rr_index", + (capture["capture_id"],), + ): + values = rr_by_sequence.setdefault(sequence, []) + if rr_index != len(values): + fail("stored heart-rate RR sequence is not contiguous") + values.append(value) + for row, sample in zip(stored_samples, capture["samples"]): + contact = None if sample["sensor_contact_detected"] is None else int(sample["sensor_contact_detected"]) + if tuple(row) != ( + sample["sequence"], sample["observed_at"], sample["bpm"], + sample["exercise_entry_id"], contact, sample["energy_expended"], + ): + fail("heart-rate sample identity reused with different content") + if rr_by_sequence.pop(sample["sequence"], []) != sample["rr_intervals_1024"]: + fail("heart-rate RR identity reused with different content") + if rr_by_sequence: + fail("heart-rate capture has orphan RR intervals") + return True + +def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: + applied = 0 + unchanged = 0 + for capture in root["captures"]: + if _same_capture(db, capture): + unchanged += 1 + continue + db.execute( + "INSERT INTO heart_rate_captures(" + "capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at" + ") VALUES(?,?,?,?,?,?,?)", + ( + capture["capture_id"], capture["context_kind"], capture["context_id"], + capture["started_at"], capture["ended_at"], capture["sensor_name"], + root["generated_at"], + ), + ) + db.executemany( + "INSERT INTO heart_rate_samples(" + "capture_id,sequence,observed_at,bpm,exercise_entry_id," + "sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)", + [ + ( + capture["capture_id"], sample["sequence"], sample["observed_at"], + sample["bpm"], sample["exercise_entry_id"], + None if sample["sensor_contact_detected"] is None + else int(sample["sensor_contact_detected"]), + sample["energy_expended"], + ) + for sample in capture["samples"] + ], + ) + rr_rows = [] + for sample in capture["samples"]: + rr_rows.extend( + ( + capture["capture_id"], sample["sequence"], index, value + ) + for index, value in enumerate(sample["rr_intervals_1024"]) + ) + db.executemany( + "INSERT INTO heart_rate_rr_intervals(" + "capture_id,sample_sequence,rr_index,value_1024) VALUES(?,?,?,?)", + rr_rows, + ) + applied += 1 + return applied, unchanged + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path) + parser.add_argument("--database", required=True, type=Path) + args = parser.parse_args() + root = load(args.input) + with connect_database(args.database) as db: + if db.execute("PRAGMA user_version").fetchone()[0] != 30: + fail("desktop schema v30 required") + db.execute("BEGIN IMMEDIATE") + apply(db, root) + db.commit() + + +if __name__ == "__main__": + main() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index 807cd70..bb67b8a 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - supported_versions = range(18, 30) + supported_versions = range(18, 31) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v29 requis") + raise ImportFailure("schema desktop v18-v30 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 582f709..86b2153 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -206,9 +206,9 @@ def main(): payload = json.loads(args.artifact.read_text(encoding="utf-8")) connection = connect_database(args.database) try: - supported_versions = range(8, 30) + supported_versions = range(8, 31) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v21 requis") + fail("schema desktop v8 à v30 requis") proof_path = args.mobile_export if proof_path is None: candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index c1cd7a2..06d081f 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -53,9 +53,9 @@ def validate(payload, reserved): def apply_definitions(connection, definitions, complete_causal_envelope=False): """Apply validated definitions without owning the surrounding transaction.""" - supported_versions = range(8, 30) + supported_versions = range(8, 31) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v21 requis") + fail("schema desktop v8 à v30 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): fail("causal equipment protection requires the staged artifact") imported = skipped = 0 diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 87a4c4c..608763c 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -56,9 +56,9 @@ def load(path): def apply_aliases(con, aliases): """Apply parsed aliases inside the caller-owned transaction.""" - supported_versions = range(12, 30) + supported_versions = range(12, 31) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v12-v21 requis") + fail("schema desktop v12-v30 requis") for source, canonical in aliases: target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() if target is None: fail(f"cible canonique absente: {canonical}") diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index dcb2d44..f0568ee 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): """Apply validated zone rows without owning transaction boundaries.""" - supported_versions = range(11, 30) + supported_versions = range(11, 31) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v29 requis") + raise ImportFailure("schema desktop v11-v30 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") connection.execute("PRAGMA foreign_keys=ON");grouped={} # CONTRACT: resolve complete alias groups before the first write; direct diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index bae7d7c..723731a 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): - fail("schema desktop v17-v29 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30): + fail("schema desktop v17-v30 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index ea51a3a..938cd0d 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -847,9 +847,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in range(5, 30): + if version not in range(5, 31): raise ImportFailure( - f"base desktop schema v5 à v29 attendue, version trouvée: {version}" + f"base desktop schema v5 à v30 attendue, version trouvée: {version}" ) @@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en require_supported_schema(connection) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v29") + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30): + raise ImportFailure("mobile V4 exige le schéma desktop v19 à v30") if schema_version >= 20: protected = {(row[0], row[1]) for row in connection.execute( "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 943ee9a..68a7869 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): def apply_feedback(db,root,complete_causal_envelope=False): roots_added=roots_skipped=revisions_added=revisions_skipped=0 db.execute("PRAGMA foreign_keys=ON") - supported_versions = range(15, 30) + supported_versions = range(15, 31) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v29 requis") + fail("schema desktop v15-v30 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") seen=set() for index,item in enumerate(root["exercise_feedback"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index 55cc476..8563934 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -18,6 +18,7 @@ RUNTIME_TOOLS = [ "sync_drive_transport.py", "sync_generation_exchange.py", "sleep_diary_exchange.py", + "heart_rate_exchange.py", "trainlog_sqlite.py", "validate_json.py", "validate_training_knowledge.py", @@ -114,8 +115,8 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.5", - "desktop_schema": 29, - "android_schema": 26, + "desktop_schema": 30, + "android_schema": 27, "protocols": [ "mobile-export-v3", "mobile-history-v4", @@ -130,6 +131,7 @@ def main() -> int: "programs-v1", "program-executions-v1", "sleep-diary-v1", + "heart-rate-v1", ], "entry_points": [ "bin/trainlog", diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index d5a52ca..c3b938e 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -87,7 +87,7 @@ def load(path: Path) -> dict: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: - if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29): + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30): fail("desktop schema v27, v28 or v29 required") added = 0 advanced = 0 diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 49bd63a..7f550f9 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -422,8 +422,8 @@ def main() -> None: import_command.add_argument("--database", required=True, type=Path) args = parser.parse_args() with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] != 29: - fail("desktop schema v29 required") + if db.execute("PRAGMA user_version").fetchone()[0] != 30: + fail("desktop schema v30 required") if args.command == "export": args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, separators=(",", ":")), encoding="utf-8") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index 5e2e89c..6f7763b 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -21,6 +21,7 @@ from pathlib import Path, PurePosixPath from trainlog_sqlite import connect_database import causal_delete_exchange import execution_draft_exchange +import heart_rate_exchange import import_equipment_associations import import_equipment_definitions import import_exercise_aliases @@ -81,6 +82,7 @@ ARTIFACTS = ( ) SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { ("trainlog-program-executions", 1), + ("trainlog-heart-rate", 1), } @@ -209,7 +211,7 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26, 27, 28, 29) + supported_versions = (24, 25, 26, 27, 28, 29, 30) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required") @@ -667,6 +669,8 @@ def consume_desktop(database: Path, directory: Path) -> dict: feedback = import_training_feedback.load(required("feedback")) sleep_diary = (sleep_diary_exchange.load(listed["sleep-diary"]) if "sleep-diary" in listed else None) + heart_rate = (heart_rate_exchange.load(listed["heart-rate"]) + if "heart-rate" in listed else None) program_executions = ( program_execution_exchange.load(listed["program-executions"]) if "program-executions" in listed else None @@ -710,6 +714,8 @@ def consume_desktop(database: Path, directory: Path) -> dict: execution_draft_exchange.import_document(db, drafts, own_transaction=False) if sleep_diary is not None: sleep_diary_exchange.apply(db, sleep_diary) + if heart_rate is not None: + heart_rate_exchange.apply(db, heart_rate) if program_executions is not None: program_execution_exchange.apply_executions(db, program_executions) ack = record_consumed(db, manifest, manifest_digest) diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index f1285ec..2400111 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -17,7 +17,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 29 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 30 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/meson.build b/tui/meson.build index b457eb8..9bd137d 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -823,6 +823,14 @@ test_schema_v29_migration = executable( ) test('schema_v29_migration', test_schema_v29_migration) +test_schema_v30_migration = executable( + 'test_schema_v30_migration', + 'tests/test_schema_v30_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('schema_v30_migration', test_schema_v30_migration) + test_schema_v7_migration = executable( 'test_schema_v7_migration', 'tests/test_schema_v7_migration.c', @@ -902,6 +910,11 @@ test( find_program('python3'), args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'], ) +test( + 'heart_rate_exchange', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_heart_rate_exchange.py'], +) test( 'session_preparation_export', find_program('python3'), diff --git a/tui/src/database.c b/tui/src/database.c index 828a484..85d4e17 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1343,6 +1343,40 @@ static const char *const MIGRATE_V28_TO_V29_SQL = "PRIMARY KEY(generation_id,entry_id));" "PRAGMA user_version=29;COMMIT;"; +/* WHY: heart-rate is Android-owned measured field data, but desktop remains + * the canonical long-term synchronized store. CONTRACT: v30 adds only the + * immutable imported capture/sample tables. INVARIANT: no workout, Sleep + * Diary, generation or existing observation row is rewritten or inferred. */ +static const char *const MIGRATE_V29_TO_V30_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE IF NOT EXISTS heart_rate_captures(" + "capture_id TEXT PRIMARY KEY,context_kind TEXT NOT NULL CHECK(context_kind IN('session','cardio','sleep'))," + "context_id TEXT NOT NULL,started_at TEXT NOT NULL,ended_at TEXT NOT NULL,sensor_name TEXT," + "imported_at TEXT NOT NULL,CHECK(capture_id GLOB 'hrc_*')," + "CHECK((context_kind IN('session','cardio') AND context_id GLOB 'se_*') OR " + "(context_kind='sleep' AND context_id GLOB 'sl_*'))," + "CHECK(sensor_name IS NULL OR length(CAST(sensor_name AS BLOB))<=160));" + "CREATE TABLE IF NOT EXISTS heart_rate_samples(" + "capture_id TEXT NOT NULL REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE," + "sequence INTEGER NOT NULL CHECK(sequence>=0),observed_at TEXT NOT NULL," + "bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),exercise_entry_id TEXT," + "sensor_contact_detected INTEGER CHECK(sensor_contact_detected IS NULL OR " + "sensor_contact_detected IN(0,1)),energy_expended INTEGER CHECK(energy_expended IS NULL OR " + "energy_expended BETWEEN 0 AND 65535),PRIMARY KEY(capture_id,sequence)," + "CHECK(exercise_entry_id IS NULL OR exercise_entry_id GLOB 'sxe_*'));" + "CREATE TABLE IF NOT EXISTS heart_rate_rr_intervals(" + "capture_id TEXT NOT NULL,sample_sequence INTEGER NOT NULL," + "rr_index INTEGER NOT NULL CHECK(rr_index BETWEEN 0 AND 63)," + "value_1024 INTEGER NOT NULL CHECK(value_1024 BETWEEN 0 AND 65535)," + "PRIMARY KEY(capture_id,sample_sequence,rr_index)," + "FOREIGN KEY(capture_id,sample_sequence) REFERENCES " + "heart_rate_samples(capture_id,sequence) ON DELETE CASCADE);" + "CREATE INDEX IF NOT EXISTS heart_rate_samples_time ON " + "heart_rate_samples(observed_at,capture_id,sequence);" + "CREATE INDEX IF NOT EXISTS heart_rate_captures_context ON " + "heart_rate_captures(context_kind,context_id,started_at);" + "PRAGMA user_version=30;COMMIT;"; + /* WHY: schema v29 was already opened on the private 0.1.4 review installation * before medication capture joined the same unreleased migration. CONTRACT: * this additive repair is identical to the tail of v28 -> v29 and runs only @@ -2176,7 +2210,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 || version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || - version == 26 || version == 27 || version == 28 || version == 29) { + version == 26 || version == 27 || version == 28 || version == 29 || version == 30) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2324,6 +2358,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK) { status = execute_sql(database, ENSURE_V29_SLEEP_MEDICATION_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 30) { + status = execute_sql(database, MIGRATE_V29_TO_V30_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2343,7 +2380,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status != TRAINLOG_STATUS_OK) { set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v29" : "migrate database to schema v29", + version == 0 ? "create schema v30" : "migrate database to schema v30", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index 678d018..ac5fb74 100644 --- a/tui/tests/test_schema_v29_migration.c +++ b/tui/tests/test_schema_v29_migration.c @@ -49,7 +49,7 @@ int main(void) { trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 29); + CHECK(scalar(raw, "PRAGMA user_version") == 30); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," @@ -61,6 +61,9 @@ int main(void) { "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "('sleep_medications','sleep_medication_revisions'," "'sleep_medication_intakes')") == 3); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " + "('heart_rate_captures','heart_rate_samples','heart_rate_rr_intervals')") == 3); CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == 1); @@ -79,13 +82,13 @@ int main(void) { NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; - /* An unpublished v29 may already exist locally. Opening it completes the - * same v29 contract without rewriting user data or changing user_version. */ + /* A valid v29 may already exist locally. Opening it completes the historical + * v29 repair and then advances additively to the current v30 schema. */ CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 29); + CHECK(scalar(raw, "PRAGMA user_version") == 30); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE " "'sleep_medication%'") == 3); @@ -99,7 +102,7 @@ cleanup: } (void)unlink(path); if (result == EXIT_SUCCESS) { - puts("PASS schema v28 to v29 migration and reopen"); + puts("PASS schema v28 through v29 to current migration and reopen"); } return result; } diff --git a/tui/tests/test_schema_v30_migration.c b/tui/tests/test_schema_v30_migration.c new file mode 100644 index 0000000..dd7d1f0 --- /dev/null +++ b/tui/tests/test_schema_v30_migration.c @@ -0,0 +1,75 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include "trainlog/database.h" + +#define CHECK(value) \ + do { \ + if (!(value)) { \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + goto cleanup; \ + } \ + } while (0) + +static int scalar(sqlite3 *database, const char *sql) { + sqlite3_stmt *statement = NULL; + int value = -1; + if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW) { + value = sqlite3_column_int(statement, 0); + } + (void)sqlite3_finalize(statement); + return value; +} + +int main(void) { + char path[] = "/tmp/trainlog-schema-v30-XXXXXX"; + TrainlogDatabase *production = NULL; + sqlite3 *raw = NULL; + int descriptor = mkstemp(path); + int version = 0; + int result = EXIT_FAILURE; + + CHECK(descriptor >= 0 && close(descriptor) == 0); + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "DROP TABLE heart_rate_rr_intervals;" + "DROP TABLE heart_rate_samples;" + "DROP TABLE heart_rate_captures;" + "PRAGMA user_version=29;", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); + CHECK(version == 30); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " + "('heart_rate_captures','heart_rate_samples','heart_rate_rr_intervals')") == 3); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN " + "('heart_rate_samples_time','heart_rate_captures_context')") == 2); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == 1); + result = EXIT_SUCCESS; + +cleanup: + if (production != NULL) trainlog_database_close(production); + if (raw != NULL) (void)sqlite3_close(raw); + (void)unlink(path); + if (result == EXIT_SUCCESS) puts("PASS schema v29 to v30 heart-rate migration"); + return result; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index af044dc..9f9a001 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) { TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strncmp(diagnostic, - "migrate database to schema v29: SQLite rc=", - strlen("migrate database to schema v29: SQLite rc=")) == 0); + "migrate database to schema v30: SQLite rc=", + strlen("migrate database to schema v30: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) { CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=30;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=31;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 30 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 31 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 7c108f2..425bb25 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v29") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v30") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 8e39c42..70e9d78 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -308,7 +308,7 @@ static bool test_http_contract(TrainlogDatabase *database) { CHECK(strstr(response, "Access-Control-Allow-Origin") == NULL); CHECK(strstr(response, "{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\"," - "\"version\":\"0.1.4\"}") != NULL); + "\"version\":\"0.1.5\"}") != NULL); { char token[80]; CHECK(exchange(port,