Compare commits
No commits in common. "984b1e058ede8f37c07b8b3c1db4056ac5eeebc7" and "e1d637233804cf6f39e4b4218d4a2c01a214c54c" have entirely different histories.
984b1e058e
...
e1d6372338
72 changed files with 95 additions and 2848 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -2,22 +2,6 @@
|
|||
|
||||
## 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.
|
||||
- Added the Android Heart Rate Service diagnostic foundation. The independent
|
||||
`0x2A37` parser covers 8/16-bit BPM, contact, energy and raw RR flags with
|
||||
strict malformed-payload rejection. A settings diagnostic provides bounded
|
||||
HRS-filtered and expanded BLE scans, GATT service discovery and notification
|
||||
subscription without turning desktop/Web into sensor clients. Android 12+
|
||||
scan/connect permissions and the Android 11-and-older scan-location boundary
|
||||
are explicit. Real armband notification characterization remains a hardware
|
||||
validation gate before persistent acquisition.
|
||||
|
||||
Development opened after stable v0.1.4 release.
|
||||
|
||||
## 0.1.4 — 2026-09-22
|
||||
|
|
|
|||
|
|
@ -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 has the Android/desktop heart-rate persistence and synchronization foundation in place; Android BLE sensor acquisition is the current implementation cursor.
|
||||
0.1.5 development is focused on cardio and heart-rate sensor capture.
|
||||
|
||||
## Releases
|
||||
|
||||
|
|
|
|||
|
|
@ -12,14 +12,6 @@
|
|||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<!-- BLE cardio discovery does not derive physical location. Android 11 and
|
||||
lower still require fine location for direct BLE scans. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..27
|
||||
manifest.getInt("schema") in 17..26
|
||||
) {
|
||||
"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..27) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..26) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -1,482 +0,0 @@
|
|||
/*
|
||||
* Android-only BLE Heart Rate diagnostics.
|
||||
*
|
||||
* This owner is intentionally bounded and screen-scoped. Long-running capture
|
||||
* belongs to the later foreground acquisition service, not this diagnostic.
|
||||
*/
|
||||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelUuid
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.UUID
|
||||
|
||||
internal val HEART_RATE_SERVICE_UUID: UUID =
|
||||
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
|
||||
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
internal val BATTERY_SERVICE_UUID: UUID =
|
||||
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
||||
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
internal enum class HeartRateDiagnosticPhase {
|
||||
IDLE,
|
||||
SCANNING,
|
||||
CONNECTING,
|
||||
DISCOVERING,
|
||||
SUBSCRIBING,
|
||||
RECEIVING,
|
||||
DISCONNECTED,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
internal data class HeartRateDiagnosticDevice(
|
||||
val address: String,
|
||||
val name: String?,
|
||||
val rssi: Int,
|
||||
val advertisesHeartRate: Boolean,
|
||||
)
|
||||
|
||||
internal data class HeartRateDiagnosticSnapshot(
|
||||
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
|
||||
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
|
||||
val selectedAddress: String? = null,
|
||||
val serviceFound: Boolean = false,
|
||||
val measurementFound: Boolean = false,
|
||||
val batteryServiceFound: Boolean = false,
|
||||
val notificationCount: Int = 0,
|
||||
val latestMeasurement: ParsedHeartRateMeasurement? = null,
|
||||
val diagnostic: String? = null,
|
||||
)
|
||||
|
||||
internal fun heartRateBleRuntimePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
|
||||
heartRateBleRuntimePermissions().all {
|
||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
internal class HeartRateBleDiagnostics(
|
||||
context: Context,
|
||||
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
|
||||
) : AutoCloseable {
|
||||
private val appContext = context.applicationContext
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val bluetoothManager =
|
||||
appContext.getSystemService(BluetoothManager::class.java)
|
||||
private val adapter get() = bluetoothManager?.adapter
|
||||
private val devices = linkedMapOf<String, BluetoothDevice>()
|
||||
private var snapshot = HeartRateDiagnosticSnapshot()
|
||||
private var scannerCallback: ScanCallback? = null
|
||||
private var scanStop: Runnable? = null
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var closed = false
|
||||
|
||||
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
|
||||
if (closed) return
|
||||
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||
main.post { update(transform) }
|
||||
return
|
||||
}
|
||||
snapshot = transform(snapshot)
|
||||
onSnapshot(snapshot)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScan(includeAllBle: Boolean = false) {
|
||||
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
||||
return
|
||||
}
|
||||
val currentAdapter = adapter
|
||||
if (currentAdapter == null) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
|
||||
return
|
||||
}
|
||||
if (!currentAdapter.isEnabled) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
|
||||
return
|
||||
}
|
||||
val scanner = currentAdapter.bluetoothLeScanner
|
||||
if (scanner == null) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
|
||||
return
|
||||
}
|
||||
|
||||
stopScan()
|
||||
devices.clear()
|
||||
update {
|
||||
HeartRateDiagnosticSnapshot(
|
||||
phase = HeartRateDiagnosticPhase.SCANNING,
|
||||
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
|
||||
)
|
||||
}
|
||||
|
||||
val callback =
|
||||
object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
handleScanResult(result)
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||
results.forEach(::handleScanResult)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "scan_failed:" + errorCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
scannerCallback = callback
|
||||
val filters =
|
||||
if (includeAllBle) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(
|
||||
ScanFilter.Builder()
|
||||
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
scanner.startScan(
|
||||
filters,
|
||||
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
|
||||
callback,
|
||||
)
|
||||
val stopper =
|
||||
Runnable {
|
||||
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
|
||||
stopScan()
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
|
||||
}
|
||||
}
|
||||
scanStop = stopper
|
||||
main.postDelayed(stopper, SCAN_DURATION_MS)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun handleScanResult(result: ScanResult) {
|
||||
val record = result.scanRecord
|
||||
val advertised =
|
||||
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
|
||||
val device = result.device
|
||||
devices[device.address] = device
|
||||
val row =
|
||||
HeartRateDiagnosticDevice(
|
||||
address = device.address,
|
||||
name = record?.deviceName,
|
||||
rssi = result.rssi,
|
||||
advertisesHeartRate = advertised,
|
||||
)
|
||||
update { old ->
|
||||
val rows =
|
||||
(old.devices.filterNot { it.address == row.address } + row)
|
||||
.sortedWith(
|
||||
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
|
||||
.thenByDescending { it.rssi },
|
||||
)
|
||||
old.copy(devices = rows)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun connect(address: String) {
|
||||
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
||||
return
|
||||
}
|
||||
val device =
|
||||
devices[address]
|
||||
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
|
||||
?: run {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
|
||||
return
|
||||
}
|
||||
stopScan()
|
||||
closeGatt()
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.CONNECTING,
|
||||
selectedAddress = address,
|
||||
serviceFound = false,
|
||||
measurementFound = false,
|
||||
batteryServiceFound = false,
|
||||
notificationCount = 0,
|
||||
latestMeasurement = null,
|
||||
diagnostic = "connecting",
|
||||
)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
runCatching {
|
||||
device.connectGatt(
|
||||
appContext,
|
||||
false,
|
||||
gattCallback,
|
||||
BluetoothDevice.TRANSPORT_LE,
|
||||
)
|
||||
}
|
||||
.onSuccess { gatt = it }
|
||||
.onFailure { error ->
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "connect_failed:" + error.javaClass.simpleName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val gattCallback =
|
||||
object : BluetoothGattCallback() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onConnectionStateChange(
|
||||
callbackGatt: BluetoothGatt,
|
||||
status: Int,
|
||||
newState: Int,
|
||||
) {
|
||||
main.post {
|
||||
if (closed || callbackGatt !== gatt) return@post
|
||||
if (status == BluetoothGatt.GATT_SUCCESS &&
|
||||
newState == BluetoothProfile.STATE_CONNECTED
|
||||
) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.DISCOVERING,
|
||||
diagnostic = "connected",
|
||||
)
|
||||
}
|
||||
if (!callbackGatt.discoverServices()) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "discover_start_failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.DISCONNECTED,
|
||||
diagnostic = "disconnected:" + status,
|
||||
)
|
||||
}
|
||||
closeGatt()
|
||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "gatt_status:" + status,
|
||||
)
|
||||
}
|
||||
closeGatt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
||||
main.post {
|
||||
if (closed || callbackGatt !== gatt) return@post
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "service_discovery:" + status,
|
||||
)
|
||||
}
|
||||
return@post
|
||||
}
|
||||
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
|
||||
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
|
||||
update {
|
||||
it.copy(
|
||||
serviceFound = service != null,
|
||||
measurementFound = measurement != null,
|
||||
batteryServiceFound = battery,
|
||||
)
|
||||
}
|
||||
if (measurement == null) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "heart_rate_measurement_missing",
|
||||
)
|
||||
}
|
||||
return@post
|
||||
}
|
||||
subscribe(callbackGatt, measurement)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(
|
||||
callbackGatt: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
status: Int,
|
||||
) {
|
||||
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||
update {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.RECEIVING,
|
||||
diagnostic = "notifications_enabled",
|
||||
)
|
||||
} else {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "descriptor_write:" + status,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
||||
override fun onCharacteristicChanged(
|
||||
callbackGatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
val value = characteristic.value ?: return
|
||||
handleMeasurement(characteristic.uuid, value)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
callbackGatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
handleMeasurement(characteristic.uuid, value)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun subscribe(
|
||||
callbackGatt: BluetoothGatt,
|
||||
measurement: BluetoothGattCharacteristic,
|
||||
) {
|
||||
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "notification_enable_failed",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
|
||||
if (descriptor == null) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "ccc_descriptor_missing",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
|
||||
diagnostic = "enabling_notifications",
|
||||
)
|
||||
}
|
||||
val started =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
callbackGatt.writeDescriptor(
|
||||
descriptor,
|
||||
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
callbackGatt.writeDescriptor(descriptor)
|
||||
}
|
||||
}
|
||||
if (!started) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "descriptor_write_start_failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
|
||||
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||
when (val parsed = HeartRateMeasurementParser.parse(value)) {
|
||||
is HeartRateMeasurementParseResult.Invalid ->
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "measurement:" + parsed.reason,
|
||||
)
|
||||
}
|
||||
is HeartRateMeasurementParseResult.Parsed ->
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.RECEIVING,
|
||||
notificationCount = it.notificationCount + 1,
|
||||
latestMeasurement = parsed.measurement,
|
||||
diagnostic = "measurement",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stopScan() {
|
||||
val callback = scannerCallback ?: return
|
||||
scanStop?.let(main::removeCallbacks)
|
||||
scanStop = null
|
||||
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
|
||||
scannerCallback = null
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun closeGatt() {
|
||||
val current = gatt
|
||||
gatt = null
|
||||
runCatching { current?.disconnect() }
|
||||
runCatching { current?.close() }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
stopScan()
|
||||
closeGatt()
|
||||
closed = true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCAN_DURATION_MS = 10_000L
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* Bluetooth Heart Rate Measurement parser.
|
||||
*
|
||||
* This file deliberately has no Android Bluetooth dependency so protocol
|
||||
* decoding can be exhaustively unit-tested independently of GATT transport.
|
||||
*/
|
||||
package com.labfytools.trainlog.data
|
||||
|
||||
internal data class ParsedHeartRateMeasurement(
|
||||
val bpm: Int,
|
||||
val sensorContactDetected: Boolean?,
|
||||
val energyExpended: Int?,
|
||||
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
|
||||
val rrIntervals1024: List<Int>,
|
||||
)
|
||||
|
||||
internal sealed interface HeartRateMeasurementParseResult {
|
||||
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
|
||||
HeartRateMeasurementParseResult
|
||||
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
|
||||
}
|
||||
|
||||
internal object HeartRateMeasurementParser {
|
||||
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
|
||||
private const val FLAG_CONTACT_DETECTED = 1 shl 1
|
||||
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
|
||||
private const val FLAG_ENERGY_PRESENT = 1 shl 3
|
||||
private const val FLAG_RR_PRESENT = 1 shl 4
|
||||
private const val KNOWN_FLAGS =
|
||||
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
|
||||
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
|
||||
|
||||
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
|
||||
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
|
||||
val flags = payload[0].toInt() and 0xff
|
||||
if ((flags and KNOWN_FLAGS.inv()) != 0) {
|
||||
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
|
||||
}
|
||||
|
||||
var offset = 1
|
||||
fun uint8(): Int? =
|
||||
payload.getOrNull(offset)?.let {
|
||||
offset += 1
|
||||
it.toInt() and 0xff
|
||||
}
|
||||
fun uint16(): Int? {
|
||||
if (offset + 2 > payload.size) return null
|
||||
val value =
|
||||
(payload[offset].toInt() and 0xff) or
|
||||
((payload[offset + 1].toInt() and 0xff) shl 8)
|
||||
offset += 2
|
||||
return value
|
||||
}
|
||||
|
||||
val bpm =
|
||||
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
|
||||
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
|
||||
} else {
|
||||
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
|
||||
}
|
||||
|
||||
val contact =
|
||||
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
|
||||
null
|
||||
} else {
|
||||
(flags and FLAG_CONTACT_DETECTED) != 0
|
||||
}
|
||||
|
||||
val energy =
|
||||
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
|
||||
uint16()
|
||||
?: return HeartRateMeasurementParseResult.Invalid(
|
||||
"truncated energy-expended value",
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val rr = mutableListOf<Int>()
|
||||
if ((flags and FLAG_RR_PRESENT) != 0) {
|
||||
if (offset == payload.size) {
|
||||
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
|
||||
}
|
||||
if ((payload.size - offset) % 2 != 0) {
|
||||
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
|
||||
}
|
||||
while (offset < payload.size) {
|
||||
rr += checkNotNull(uint16())
|
||||
}
|
||||
} else if (offset != payload.size) {
|
||||
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
|
||||
}
|
||||
|
||||
return HeartRateMeasurementParseResult.Parsed(
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = bpm,
|
||||
sensorContactDetected = contact,
|
||||
energyExpended = energy,
|
||||
rrIntervals1024 = rr,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +69,6 @@ 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 +
|
||||
|
|
@ -423,14 +422,6 @@ 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) {
|
||||
|
|
@ -1068,14 +1059,6 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,6 @@ 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
|
||||
|
|
@ -490,21 +486,6 @@ 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 =
|
||||
|
|
@ -1158,391 +1139,6 @@ 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<HeartRateRrInterval> = 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<Any?>(
|
||||
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<Any>(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<Long, JSONArray>()
|
||||
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." }
|
||||
|
|
@ -1589,7 +1185,6 @@ class TrainlogRepository(
|
|||
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
|
||||
artifacts["program-executions"] = buildProgramExecutionsV1Json()
|
||||
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
|
||||
artifacts["heart-rate"] = buildHeartRateV1Json()
|
||||
artifacts
|
||||
}
|
||||
|
||||
|
|
@ -9959,10 +9554,6 @@ 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
|
||||
|
|
@ -9981,7 +9572,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
27,
|
||||
26,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -10035,7 +9626,6 @@ private class TrainlogDatabaseHelper(
|
|||
createSyncedProgramTables(db)
|
||||
createProgramExecutionProvenance(db)
|
||||
createSleepDiaryTables(db)
|
||||
createHeartRateTables(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -10249,14 +9839,6 @@ 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(
|
||||
|
|
@ -10395,71 +9977,6 @@ 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)) {
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* 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<HeartRateRrInterval> = 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<HeartRateSample> = emptyList(),
|
||||
)
|
||||
|
|
@ -77,7 +77,6 @@ fun AndroidAppShell(
|
|||
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
||||
AppRoute.SleepDiary -> R.string.nav_sleep
|
||||
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
|
||||
AppRoute.HeartRateDiagnostics -> R.string.route_heart_rate_diagnostics
|
||||
})
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ sealed interface AppRoute {
|
|||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
|
||||
}
|
||||
|
||||
fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||
|
|
|
|||
|
|
@ -1,180 +0,0 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
|
||||
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
|
||||
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
|
||||
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
fun HeartRateDiagnosticsScreen() {
|
||||
val context = LocalContext.current
|
||||
val strings = localizedContext()
|
||||
val colors = LocalTrainlogColors.current
|
||||
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
|
||||
var pendingExpandedScan by remember { mutableStateOf(false) }
|
||||
var permissionDenied by remember { mutableStateOf(false) }
|
||||
val diagnostics =
|
||||
remember(context) {
|
||||
HeartRateBleDiagnostics(context) { next -> snapshot = next }
|
||||
}
|
||||
|
||||
DisposableEffect(diagnostics) {
|
||||
onDispose { diagnostics.close() }
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { results ->
|
||||
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
|
||||
permissionDenied = false
|
||||
diagnostics.startScan(pendingExpandedScan)
|
||||
} else {
|
||||
permissionDenied = true
|
||||
}
|
||||
}
|
||||
|
||||
fun scan(expanded: Boolean) {
|
||||
pendingExpandedScan = expanded
|
||||
if (hasHeartRateBleRuntimePermissions(context)) {
|
||||
permissionDenied = false
|
||||
diagnostics.startScan(expanded)
|
||||
} else {
|
||||
permissionLauncher.launch(heartRateBleRuntimePermissions())
|
||||
}
|
||||
}
|
||||
|
||||
fun phaseLabel(): String =
|
||||
strings.getString(
|
||||
when (snapshot.phase) {
|
||||
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
|
||||
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
|
||||
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
|
||||
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
|
||||
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
|
||||
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
|
||||
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
|
||||
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
|
||||
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
|
||||
TrainlogInfo(
|
||||
phaseLabel(),
|
||||
when (snapshot.phase) {
|
||||
HeartRateDiagnosticPhase.RECEIVING -> colors.success
|
||||
HeartRateDiagnosticPhase.ERROR -> colors.error
|
||||
HeartRateDiagnosticPhase.CONNECTING,
|
||||
HeartRateDiagnosticPhase.DISCOVERING,
|
||||
HeartRateDiagnosticPhase.SUBSCRIBING,
|
||||
HeartRateDiagnosticPhase.SCANNING -> colors.warning
|
||||
else -> colors.muted
|
||||
},
|
||||
)
|
||||
if (permissionDenied) {
|
||||
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
|
||||
}
|
||||
snapshot.diagnostic?.let {
|
||||
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
|
||||
}
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.hr_diag_services,
|
||||
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||
),
|
||||
)
|
||||
snapshot.latestMeasurement?.let { measurement ->
|
||||
TrainlogInfo(
|
||||
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
|
||||
colors.success,
|
||||
)
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.hr_diag_measurement_meta,
|
||||
measurement.rrIntervals1024.size,
|
||||
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
|
||||
measurement.energyExpended?.toString() ?: "—",
|
||||
),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
TrainlogInfo(
|
||||
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.hr_diag_scan),
|
||||
{ scan(false) },
|
||||
Modifier.weight(1f),
|
||||
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
||||
)
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.hr_diag_scan_all),
|
||||
{ scan(true) },
|
||||
Modifier.weight(1f),
|
||||
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
||||
)
|
||||
}
|
||||
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
strings.getString(R.string.hr_diag_devices),
|
||||
active = snapshot.devices.isNotEmpty(),
|
||||
) {
|
||||
if (snapshot.devices.isEmpty()) {
|
||||
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
|
||||
}
|
||||
snapshot.devices.forEach { device ->
|
||||
val title =
|
||||
device.name?.takeIf { it.isNotBlank() }
|
||||
?: strings.getString(R.string.hr_diag_unnamed_device)
|
||||
val advertised =
|
||||
strings.getString(
|
||||
if (device.advertisesHeartRate) {
|
||||
R.string.hr_diag_hrs_advertised
|
||||
} else {
|
||||
R.string.hr_diag_hrs_not_advertised
|
||||
},
|
||||
)
|
||||
TrainlogAction(
|
||||
title,
|
||||
strings.getString(
|
||||
R.string.hr_diag_device_description,
|
||||
device.address,
|
||||
device.rssi,
|
||||
advertised,
|
||||
),
|
||||
{ diagnostics.connect(device.address) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -472,12 +472,7 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
|
|||
).joinToString(", ")
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
repository: TrainlogRepository,
|
||||
inbox: SyncCatalogInbox,
|
||||
onHeartRateDiagnostics: () -> Unit,
|
||||
onCatalogChanged: () -> Unit,
|
||||
) {
|
||||
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = LocalContext.current
|
||||
val language = LocalLanguagePresentation.current
|
||||
|
|
@ -538,13 +533,6 @@ fun SettingsScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
|
||||
TrainlogAction(
|
||||
strings.getString(R.string.settings_cardio_diagnostics),
|
||||
strings.getString(R.string.settings_cardio_diagnostics_description),
|
||||
onHeartRateDiagnostics,
|
||||
)
|
||||
}
|
||||
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
||||
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
||||
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
|
||||
|
|
|
|||
|
|
@ -195,8 +195,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
||||
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen()
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
|
||||
}}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -480,35 +480,4 @@
|
|||
<string name="sleep_unit">Unit</string>
|
||||
<string name="sleep_deactivate">Deactivate</string>
|
||||
<string name="sleep_edit">Edit</string>
|
||||
<string name="yes">Yes</string>
|
||||
<string name="no">No</string>
|
||||
<string name="route_heart_rate_diagnostics">BLE heart-rate diagnostics</string>
|
||||
<string name="settings_cardio_section">Cardio</string>
|
||||
<string name="settings_cardio_diagnostics">Heart-rate sensor diagnostics</string>
|
||||
<string name="settings_cardio_diagnostics_description">Find and verify a Bluetooth Heart Rate Service sensor.</string>
|
||||
<string name="hr_diag_status">Sensor status</string>
|
||||
<string name="hr_diag_idle">Ready</string>
|
||||
<string name="hr_diag_scanning">Scanning…</string>
|
||||
<string name="hr_diag_connecting">Connecting…</string>
|
||||
<string name="hr_diag_discovering">Discovering services…</string>
|
||||
<string name="hr_diag_subscribing">Enabling measurements…</string>
|
||||
<string name="hr_diag_receiving">Measurements received</string>
|
||||
<string name="hr_diag_disconnected">Disconnected</string>
|
||||
<string name="hr_diag_error">BLE error</string>
|
||||
<string name="hr_diag_permission_denied">Bluetooth permission denied.</string>
|
||||
<string name="hr_diag_technical">Diagnostic: %1$s</string>
|
||||
<string name="hr_diag_services">Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s</string>
|
||||
<string name="hr_diag_bpm">%1$d BPM</string>
|
||||
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · energy %3$s</string>
|
||||
<string name="hr_diag_notifications">Notifications received: %1$d</string>
|
||||
<string name="hr_diag_scan_section">BLE scan</string>
|
||||
<string name="hr_diag_scan">Find heart-rate</string>
|
||||
<string name="hr_diag_scan_all">Expanded scan</string>
|
||||
<string name="hr_diag_scan_description">Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.</string>
|
||||
<string name="hr_diag_devices">BLE devices</string>
|
||||
<string name="hr_diag_no_devices">No device found yet.</string>
|
||||
<string name="hr_diag_unnamed_device">Unnamed device</string>
|
||||
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
|
||||
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
|
||||
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -490,35 +490,4 @@
|
|||
<string name="sleep_unit">Unité</string>
|
||||
<string name="sleep_deactivate">Désactiver</string>
|
||||
<string name="sleep_edit">Modifier</string>
|
||||
<string name="yes">Oui</string>
|
||||
<string name="no">Non</string>
|
||||
<string name="route_heart_rate_diagnostics">Diagnostic cardio BLE</string>
|
||||
<string name="settings_cardio_section">Cardio</string>
|
||||
<string name="settings_cardio_diagnostics">Diagnostic du capteur cardio</string>
|
||||
<string name="settings_cardio_diagnostics_description">Rechercher et vérifier un capteur Bluetooth Heart Rate Service.</string>
|
||||
<string name="hr_diag_status">État du capteur</string>
|
||||
<string name="hr_diag_idle">Prêt</string>
|
||||
<string name="hr_diag_scanning">Recherche en cours…</string>
|
||||
<string name="hr_diag_connecting">Connexion…</string>
|
||||
<string name="hr_diag_discovering">Découverte des services…</string>
|
||||
<string name="hr_diag_subscribing">Activation des mesures…</string>
|
||||
<string name="hr_diag_receiving">Mesures reçues</string>
|
||||
<string name="hr_diag_disconnected">Déconnecté</string>
|
||||
<string name="hr_diag_error">Erreur BLE</string>
|
||||
<string name="hr_diag_permission_denied">Autorisation Bluetooth refusée.</string>
|
||||
<string name="hr_diag_technical">Diagnostic : %1$s</string>
|
||||
<string name="hr_diag_services">Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s</string>
|
||||
<string name="hr_diag_bpm">%1$d BPM</string>
|
||||
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · énergie %3$s</string>
|
||||
<string name="hr_diag_notifications">Notifications reçues : %1$d</string>
|
||||
<string name="hr_diag_scan_section">Recherche BLE</string>
|
||||
<string name="hr_diag_scan">Rechercher cardio</string>
|
||||
<string name="hr_diag_scan_all">Recherche élargie</string>
|
||||
<string name="hr_diag_scan_description">La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.</string>
|
||||
<string name="hr_diag_devices">Périphériques BLE</string>
|
||||
<string name="hr_diag_no_devices">Aucun périphérique trouvé pour l’instant.</string>
|
||||
<string name="hr_diag_unnamed_device">Périphérique sans nom</string>
|
||||
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
|
||||
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
|
||||
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(27, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, 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"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(27, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, 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") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(27, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
|||
reopened.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(27, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, 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 ->
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(27, it.version) }
|
||||
).use { assertEquals(26, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(27, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
|||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { assertEquals(27, it.version) }
|
||||
.use { assertEquals(26, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HeartRateMeasurementParserTest {
|
||||
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
|
||||
val result =
|
||||
HeartRateMeasurementParser.parse(
|
||||
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
||||
)
|
||||
return (result as HeartRateMeasurementParseResult.Parsed).measurement
|
||||
}
|
||||
|
||||
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
|
||||
HeartRateMeasurementParser.parse(
|
||||
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
||||
) as HeartRateMeasurementParseResult.Invalid
|
||||
|
||||
@Test
|
||||
fun parsesEightBitBpm() {
|
||||
val value = parsed(0x00, 72)
|
||||
assertEquals(72, value.bpm)
|
||||
assertNull(value.sensorContactDetected)
|
||||
assertNull(value.energyExpended)
|
||||
assertTrue(value.rrIntervals1024.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesSixteenBitBpmLittleEndian() {
|
||||
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
|
||||
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
|
||||
assertNull(parsed(0x02, 80).sensorContactDetected)
|
||||
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
|
||||
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesEnergyExpended() {
|
||||
val value = parsed(0x08, 90, 0x34, 0x12)
|
||||
assertEquals(0x1234, value.energyExpended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesOneAndMultipleRawRrIntervals() {
|
||||
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
|
||||
assertEquals(
|
||||
listOf(1024, 1000, 65535),
|
||||
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesCombinedKnownFlagsInWireOrder() {
|
||||
val value =
|
||||
parsed(
|
||||
0x1f,
|
||||
0x96,
|
||||
0x00,
|
||||
0x07,
|
||||
0x00,
|
||||
0xf0,
|
||||
0x03,
|
||||
0x00,
|
||||
0x04,
|
||||
)
|
||||
assertEquals(150, value.bpm)
|
||||
assertEquals(true, value.sensorContactDetected)
|
||||
assertEquals(7, value.energyExpended)
|
||||
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsTruncatedAndIncoherentPayloads() {
|
||||
assertEquals("missing flags", invalid().reason)
|
||||
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
|
||||
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
|
||||
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
|
||||
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
|
||||
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
|
||||
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesBoundaryBpmValues() {
|
||||
assertEquals(0, parsed(0x00, 0).bpm)
|
||||
assertEquals(255, parsed(0x00, 255).bpm)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("27", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("26", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -3,14 +3,11 @@ 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
|
||||
|
|
@ -509,7 +506,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(27, it.getInt(0))
|
||||
assertEquals(26, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
@ -858,54 +855,7 @@ 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,
|
||||
|
|
@ -922,31 +872,10 @@ 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",
|
||||
|
|
|
|||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(27, cursor.getInt(0))
|
||||
assertEquals(26, 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(27, cursor.getInt(0))
|
||||
assertEquals(26, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
|
|||
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
|
||||
mixed-checkout user-service execution during the controlled rollout.
|
||||
|
||||
Snapshot date: **2026-09-23**.
|
||||
Snapshot date: **2026-09-22**.
|
||||
|
||||
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
||||
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
||||
|
|
@ -112,8 +112,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| 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 |
|
||||
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
|
||||
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
|
||||
| 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,9 +147,7 @@ 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` |
|
||||
| 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 |
|
||||
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`: strict `0x2A37` parser, bounded HRS/expanded BLE scan, GATT service discovery and notification subscription are installed on Android 0.1.5 vc38; filtered real-device scan observed no advertising HRS device on 2026-09-23 |
|
||||
| Current operational cursor | `TRAINLOG_CARDIO_BLE_HARDWARE_VALIDATION_V1`: identify the real armband, confirm `0x180D/0x2A37`, notification cadence and optional RR/contact/energy fields before persistent acquisition |
|
||||
| Current operational cursor | `TRAINLOG_CARDIO_V1_NEXT_CONTRACT`: heart-rate sensor ingestion starts in 0.1.5 and remains distinct from the frozen Bluetooth synchronization transport |
|
||||
| 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` |
|
||||
|
|
|
|||
|
|
@ -1,25 +1,5 @@
|
|||
# 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
|
||||
|
|
@ -29,14 +9,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 stable v0.1.4 migration owns `sleep_medications`, immutable
|
||||
The same unreleased 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 before the additive v30 migration;
|
||||
no existing sleep row is rewritten.
|
||||
draft are completed idempotently on open; `user_version` remains 29 and no
|
||||
existing row is rewritten.
|
||||
|
||||
Mutations advance `current_revision_id` only when the caller supplies the
|
||||
current parent. Logical deletion advances the revision and retains the
|
||||
|
|
|
|||
|
|
@ -1,412 +0,0 @@
|
|||
# Cardio V1
|
||||
|
||||
Status: `TRAINLOG_CARDIO_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`
|
||||
|
||||
This document is the implementation contract for Trainlog 0.1.5 cardio,
|
||||
training-timeline correlation, and Android one-tap sleep capture. It does not
|
||||
declare the implementation complete.
|
||||
|
||||
## Ownership and hard boundaries
|
||||
|
||||
Android is the only heart-rate sensor client. It owns BLE discovery,
|
||||
connection, reconnection, Heart Rate Measurement parsing, local persistence,
|
||||
capture lifecycle, and the live BPM presentation.
|
||||
|
||||
Desktop, TUI and Web never scan, pair with, or connect directly to a
|
||||
heart-rate sensor. They consume only measurements already recorded by Android
|
||||
and synchronized through Trainlog.
|
||||
|
||||
Heart-rate BLE/GATT and Trainlog Bluetooth synchronization are independent:
|
||||
|
||||
```text
|
||||
heart-rate sensor -- BLE/GATT --> Android
|
||||
Android <-------- Trainlog full-generation --------> desktop
|
||||
over
|
||||
Bluetooth Classic RFCOMM or MTP recovery
|
||||
```
|
||||
|
||||
The cardio implementation must not reuse the frozen Bluetooth Classic service
|
||||
UUID, framing, peer identity, connection state, or transport protocol.
|
||||
|
||||
## Standard sensor target
|
||||
|
||||
Cardio V1 targets the Bluetooth LE Heart Rate Service:
|
||||
|
||||
- Heart Rate Service: `0x180D`;
|
||||
- Heart Rate Measurement: `0x2A37`.
|
||||
|
||||
The parser must support the standard 8-bit and 16-bit BPM forms, optional
|
||||
sensor-contact state, optional cumulative energy expended, and zero or more RR
|
||||
intervals. RR intervals are preserved as raw integer units of 1/1024 second.
|
||||
|
||||
Battery Service may be observed as optional device metadata but is not required
|
||||
to retain a heart-rate sample.
|
||||
|
||||
Malformed notifications are rejected. Trainlog never manufactures missing BPM
|
||||
or RR values.
|
||||
|
||||
## Global Android heart-rate indicator
|
||||
|
||||
The Android application shell exposes one compact indicator at the top right:
|
||||
|
||||
```text
|
||||
♥ 82 BPM
|
||||
```
|
||||
|
||||
Only the heart and BPM are shown. Sensor name, address, and technical state are
|
||||
not shown in the global header.
|
||||
|
||||
Heart colour describes connection freshness only:
|
||||
|
||||
- grey: known sensor unavailable/disconnected;
|
||||
- orange: connecting, reconnecting, or the last measurement is stale;
|
||||
- green: connected and receiving fresh valid measurements.
|
||||
|
||||
Colour never indicates a medical zone or whether the BPM is high or low.
|
||||
|
||||
When no fresh measurement exists, the value is `-- BPM`. A stale previous
|
||||
number must never remain visible as if it were current.
|
||||
|
||||
With a fresh BPM the heart uses a restrained pulse animation derived from
|
||||
`60 / bpm`. This is only a visual representation of the current measured
|
||||
rate; it does not claim knowledge of the exact instant of each contraction.
|
||||
|
||||
## Capture identities and contexts
|
||||
|
||||
One capture has a stable `hrc_<uuid-v4>` identity and exactly one semantic
|
||||
context:
|
||||
|
||||
- `session`: a normal training or MAX session identified by stable
|
||||
`se_<uuid-v4>`;
|
||||
- `cardio`: a dedicated cardio session identified by stable
|
||||
`se_<uuid-v4>`;
|
||||
- `sleep`: one Sleep Diary entry identified by stable `sl_<uuid-v4>`.
|
||||
|
||||
Context is never inferred from a title, clock date, exercise name, or display
|
||||
position.
|
||||
|
||||
A workout/cardio sample may additionally snapshot the stable active occurrence
|
||||
`sxe_<uuid-v4>` at the instant the sample is received.
|
||||
|
||||
## Raw measurement contract
|
||||
|
||||
Each received measurement stores at minimum:
|
||||
|
||||
- `capture_id`;
|
||||
- monotonically increasing `sequence` within that capture;
|
||||
- offset-bearing `observed_at` reception timestamp;
|
||||
- integer `bpm`.
|
||||
|
||||
When actually supplied by the sensor it may also store:
|
||||
|
||||
- sensor-contact state;
|
||||
- cumulative energy-expended value;
|
||||
- zero or more RR intervals in 1/1024-second units.
|
||||
|
||||
For workout/cardio captures it may store the active `entry_id` snapshot.
|
||||
|
||||
Raw storage preserves signal gaps. It does not interpolate, smooth, estimate,
|
||||
diagnose, infer sleep stages, infer a return to sleep, or synthesize missing
|
||||
measurements.
|
||||
|
||||
## Android durability
|
||||
|
||||
Android SQLite owns authoritative field capture. An active capture remains
|
||||
durable across application navigation and ordinary process/lifecycle
|
||||
transitions supported by Android. Long-running acquisition uses a dedicated
|
||||
foreground service distinct from synchronization.
|
||||
|
||||
Cardio V1 permits one active heart-rate capture at a time.
|
||||
|
||||
Sensor disconnect does not delete or close a capture. Reconnection resumes
|
||||
future measurements and leaves the missing interval as a real gap.
|
||||
|
||||
Explicit stop closes the capture with its actual `ended_at`.
|
||||
|
||||
## Synchronization contract
|
||||
|
||||
Recorded cardio is synchronized Android -> desktop as the separate optional
|
||||
full-generation companion:
|
||||
|
||||
```text
|
||||
logical_name = heart-rate
|
||||
format = trainlog-heart-rate
|
||||
version = 1
|
||||
filename = heart-rate-v1.json
|
||||
```
|
||||
|
||||
Desktop persists and presents these measurements but is not a sensor producer.
|
||||
|
||||
Normal publication contains completed/stopped, not-yet-acknowledged captures.
|
||||
An active capture is retained locally and is not emitted as a mutable
|
||||
full-generation snapshot.
|
||||
|
||||
Identity is idempotent:
|
||||
|
||||
- capture key: `capture_id`;
|
||||
- sample key: `capture_id + sequence`;
|
||||
- RR key: `capture_id + sequence + rr_index`.
|
||||
|
||||
Exact replay is a no-op. Reusing an identity with different measured content
|
||||
is rejected.
|
||||
|
||||
The exact captured `capture_id -> generation_id` relation is durable. Only a
|
||||
correlated consumed ACK marks those captures acknowledged. A rejected
|
||||
generation does not acknowledge them. Acknowledged captures remain in Android
|
||||
history but are excluded from ordinary future publication.
|
||||
|
||||
The companion does not alter `TRAINLOG_FORMAT_V1`, mobile-export V3/V4,
|
||||
Sleep Diary V1, generation/ACK framing, or Bluetooth Classic sync V1.
|
||||
|
||||
## Training timeline correlation
|
||||
|
||||
A training session must expose real time boundaries, never reconstructed
|
||||
timings.
|
||||
|
||||
Session start/end use the existing stable session identity and canonical
|
||||
`started_at` / `ended_at` lifecycle when those values are already the
|
||||
authoritative timestamps.
|
||||
|
||||
Each session occurrence has an explicit execution lifecycle:
|
||||
|
||||
```text
|
||||
not_started -> in_progress -> completed
|
||||
```
|
||||
|
||||
Starting an exercise records its actual start timestamp against
|
||||
`session_id + entry_id`. Ending it records its actual end timestamp.
|
||||
|
||||
Only one occurrence may be active at a time. Starting another occurrence while
|
||||
one remains active requires an explicit resolution rather than silently
|
||||
inventing an end.
|
||||
|
||||
Samples received while an occurrence is active snapshot that `entry_id`.
|
||||
Samples between occurrences remain valid session samples with no fabricated
|
||||
exercise ownership and represent rest/transition.
|
||||
|
||||
Ending a session while an occurrence is active requires an explicit UI action
|
||||
to close the occurrence at the validated finalization instant.
|
||||
|
||||
The synchronized history must permit factual curve markers for:
|
||||
|
||||
- session start;
|
||||
- exercise start;
|
||||
- exercise end;
|
||||
- session end.
|
||||
|
||||
Future set-level `set_start` / `set_end` events may extend this model, but
|
||||
Cardio V1 must not infer them from reps, duration targets, or form edits.
|
||||
|
||||
## Dedicated cardio session type
|
||||
|
||||
Trainlog 0.1.5 adds a real third session type:
|
||||
|
||||
```text
|
||||
training
|
||||
max_test
|
||||
cardio
|
||||
```
|
||||
|
||||
Every existing boundary that assumes only `training|max_test` must be audited.
|
||||
A frozen artifact may not be silently widened. If an existing wire codec
|
||||
cannot safely express `cardio`, add a separately versioned compatible
|
||||
extension while retaining legacy readers.
|
||||
|
||||
A cardio session consists of cardio exercises and optional phases. Initial
|
||||
exercise families include treadmill, bicycle, elliptical, rower, stepper,
|
||||
walking, running, and custom cardio.
|
||||
|
||||
A phase may express a type, target, duration, and exit condition. Required
|
||||
phase concepts include warm-up, work, recovery, and cool-down.
|
||||
|
||||
The motor must be able to represent fixed duration, reaching a BPM zone,
|
||||
recovering below a threshold, and bounded combinations of time and
|
||||
heart-rate conditions.
|
||||
|
||||
Machine speed, resistance, incline, and distance may be recorded, but BPM is
|
||||
the primary guidance signal.
|
||||
|
||||
## BPM-guidance motor
|
||||
|
||||
Cardio V1 guidance outputs are:
|
||||
|
||||
```text
|
||||
ACCÉLÈRE
|
||||
MAINTIENS
|
||||
RALENTIS
|
||||
```
|
||||
|
||||
Guidance is based only on fresh measurements and the phase target.
|
||||
|
||||
Hysteresis and a minimum persistence interval prevent oscillation around a
|
||||
boundary. One isolated sample must not cause rapid instruction changes.
|
||||
|
||||
When the signal becomes stale, guidance is suspended. The global heart becomes
|
||||
orange and BPM becomes unavailable rather than continuing from an old value.
|
||||
|
||||
## Calibration cardio exercise
|
||||
|
||||
The first dedicated cardio exercise is `Calibration cardio`.
|
||||
|
||||
Its purpose is to establish a personal measured reference for later guided
|
||||
exercise. Trainlog records an **observed peak BPM**, not an automatically
|
||||
asserted physiological maximum.
|
||||
|
||||
The protocol is progressive:
|
||||
|
||||
```text
|
||||
warm-up -> easy stage -> progressive stages -> user-chosen highest tolerated
|
||||
effort for that day -> stop effort -> recovery observation
|
||||
```
|
||||
|
||||
A manual stop is always immediately available. The motor never attempts to
|
||||
beat the previous observed peak automatically.
|
||||
|
||||
Each calibration is immutable/versioned and retains at minimum:
|
||||
|
||||
- stable `calibration_id`;
|
||||
- protocol version;
|
||||
- performance timestamp;
|
||||
- associated heart-rate capture;
|
||||
- observed peak BPM;
|
||||
- effort-end timestamp;
|
||||
- recovery timeline.
|
||||
|
||||
Recovery projections may expose +1, +2 and +3 minute facts while the complete
|
||||
raw curve remains authoritative.
|
||||
|
||||
A later calibration creates a new reference. It never rewrites an older
|
||||
calibration or a prior guided session.
|
||||
|
||||
Every guided cardio session snapshots the exact `calibration_id` and target
|
||||
thresholds used at that time.
|
||||
|
||||
Measured, user-configured, and calculated/estimated values remain explicitly
|
||||
distinguishable.
|
||||
|
||||
## Android Sleep entry point
|
||||
|
||||
Android adds a first-class `Sommeil` menu entry backed by the existing Sleep
|
||||
Diary domain. It must not create a parallel sleep database or incompatible
|
||||
sleep truth.
|
||||
|
||||
Night-time interaction is deliberately minimal. The primary screen exposes:
|
||||
|
||||
```text
|
||||
[ COUCHÉ ]
|
||||
|
||||
[ médicament 1 ] [ médicament 2 ]
|
||||
[ médicament 3 ] [ + ]
|
||||
|
||||
[ RÉVEIL ]
|
||||
|
||||
[ LEVÉ ]
|
||||
```
|
||||
|
||||
Buttons are large, visually consistent with Trainlog, and usable while only
|
||||
partly awake.
|
||||
|
||||
### Couché
|
||||
|
||||
One tap records the actual `bed_time` timestamp in the correct stable Sleep
|
||||
Diary entry. If heart-rate acquisition is available, the nocturnal capture is
|
||||
linked directly to that `sleep entry_id`.
|
||||
|
||||
### Médicaments
|
||||
|
||||
Active configured medications are one-tap actions. A tap stores the actual
|
||||
intake timestamp plus stable medication identity and the effective
|
||||
name/dose/unit snapshot. Full editing remains available later.
|
||||
|
||||
### Réveil
|
||||
|
||||
There is no `Rendormi` button.
|
||||
|
||||
One tap records one punctual night wake event. Nothing else is required before
|
||||
the user tries to return to sleep.
|
||||
|
||||
Trainlog does not infer a certain return-to-sleep time from BPM or RR data.
|
||||
An approximate interval may be completed later only if the user chooses to
|
||||
provide it.
|
||||
|
||||
### Levé
|
||||
|
||||
One tap records the actual `final_get_up` timestamp, closes the night, and
|
||||
stops the linked nocturnal heart-rate capture when one is active.
|
||||
|
||||
### Immediate undo and later correction
|
||||
|
||||
After Couché, medication, Réveil, or Levé, Android shows a short-lived action
|
||||
such as:
|
||||
|
||||
```text
|
||||
Réveil enregistré à 02:17 [ ANNULER ]
|
||||
```
|
||||
|
||||
Undo is scoped to that exact action and must not roll back unrelated newer
|
||||
state.
|
||||
|
||||
`Corriger la nuit` provides later editing of timestamps, medication
|
||||
intakes, accidental wake points, forgotten events, and existing subjective
|
||||
Sleep Diary fields.
|
||||
|
||||
The night-time rule is: one tap, one timestamp, then no further required
|
||||
interaction.
|
||||
|
||||
## Sleep + cardio factual timeline
|
||||
|
||||
A nocturnal capture is owned by stable `sleep entry_id`, not only a date.
|
||||
|
||||
After synchronization, Analyse may superpose factual events and measurements:
|
||||
|
||||
- Couché;
|
||||
- medication intakes;
|
||||
- wake points;
|
||||
- Levé;
|
||||
- BPM;
|
||||
- RR when supplied;
|
||||
- real sensor disconnect/reconnect gaps.
|
||||
|
||||
Trainlog does not diagnose sleep, invent sleep stages, or claim a certain
|
||||
return-to-sleep instant from heart rate alone.
|
||||
|
||||
## Web/Analyse projection
|
||||
|
||||
Web and desktop consume synchronized facts only.
|
||||
|
||||
Training views may show the BPM curve, session bounds, exercise intervals,
|
||||
rest/transition gaps, and factual min/max/mean projections.
|
||||
|
||||
Cardio views may additionally show phase targets, chronological guidance,
|
||||
calibration identity, and measured response versus target.
|
||||
|
||||
Sleep views may overlay Sleep Diary events with BPM/RR measurements.
|
||||
|
||||
Graphs are projections. Persistent measurements, identities, and timestamps
|
||||
remain the source of truth.
|
||||
|
||||
## Performance bounds
|
||||
|
||||
One night can contain tens of thousands of samples. Storage and exchange must
|
||||
use bounded artifacts, indexed temporal queries, transactional import, and
|
||||
avoid per-sample N+1 behavior on analysis paths.
|
||||
|
||||
Trainlog does not arbitrarily downsample the authoritative raw capture merely
|
||||
for transport convenience. Presentation may derive bounded projections while
|
||||
retaining the recorded source data.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. audit/freeze this contract;
|
||||
2. Android + desktop persistence and strict heart-rate companion exchange;
|
||||
3. standard Heart Rate Measurement parser and real-sensor diagnostics;
|
||||
4. Android foreground acquisition service and global ♥ BPM indicator;
|
||||
5. training start/end and exercise start/end timeline;
|
||||
6. Android one-tap Sleep UI and sleep-linked capture;
|
||||
7. true `cardio` session type;
|
||||
8. versioned Calibration cardio exercise;
|
||||
9. BPM-guided phase motor;
|
||||
10. Web factual visualization;
|
||||
11. complete regression, migration, hardware, replay, and restart validation.
|
||||
|
||||
No stage permits Web/TUI sensor connection.
|
||||
|
|
@ -59,17 +59,14 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
|
|||
Stable v0.1.4 closes the Analyse/Sleep Diary cycle and freezes the Bluetooth
|
||||
Classic synchronization transport independently from future sensor work.
|
||||
|
||||
The 0.1.5 cycle owns cardio end to end. Android alone discovers and reads the
|
||||
heart-rate sensor, exposes the global ♥ BPM state, timestamps training
|
||||
session/exercise boundaries, provides one-tap Sleep capture, and records the
|
||||
new dedicated cardio session type. Cardio starts with a versioned Calibration
|
||||
exercise and later drives phase guidance from fresh BPM. Desktop/Web consume
|
||||
only synchronized recorded measurements and never connect directly to the
|
||||
sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
||||
The next development cycle is 0.1.5 cardio. Android will own heart-rate sensor
|
||||
discovery/acquisition and timestamped local capture; synchronized desktop/Web
|
||||
surfaces will consume recorded measurements and will not connect directly to
|
||||
the sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
||||
Bluetooth synchronization transport contract.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BLE_HARDWARE_VALIDATION_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_V1_NEXT_CONTRACT
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
|
|||
|
|
@ -1060,36 +1060,7 @@ 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. 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
|
||||
## 19. 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
|
||||
|
|
|
|||
|
|
@ -1648,52 +1648,3 @@ 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.
|
||||
|
||||
## Cardio BLE diagnostics V1 validation
|
||||
|
||||
`TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`
|
||||
keeps BLE protocol parsing separate from Android GATT transport. Unit coverage
|
||||
exercises 8-bit and 16-bit BPM, nullable sensor-contact semantics, cumulative
|
||||
energy, one/multiple raw RR intervals, combined flags, boundary BPM values,
|
||||
reserved flags, missing fields, truncated RR/energy values and unexpected
|
||||
trailing bytes.
|
||||
|
||||
The Android diagnostic uses a 10-second Heart Rate Service-filtered scan with
|
||||
an explicit expanded BLE fallback, then discovers `0x180D`, locates `0x2A37`,
|
||||
checks optional Battery Service and enables the CCC notification descriptor.
|
||||
It is screen-scoped and does not implement the later persistent foreground
|
||||
acquisition owner.
|
||||
|
||||
On 2026-09-23 the diagnostic APK was installed non-destructively on the
|
||||
authorized SM_G990B as Trainlog 0.1.5 versionCode 38 using the same Android
|
||||
debug signing certificate as the existing private installation. Application
|
||||
startup completed without SQLite/crash errors and the diagnostic route opened.
|
||||
The first real 10-second HRS-filtered scan completed cleanly but found no
|
||||
device advertising Heart Rate Service. Real armband identification, GATT HRS
|
||||
confirmation and notification-field characterization therefore remain
|
||||
`HARDWARE_VALIDATION_PENDING` and are not represented as PASS.
|
||||
|
||||
The complete Android JVM inventory after this tranche reports **266 tests:
|
||||
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -66,13 +66,12 @@ 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"], 30)
|
||||
self.assertEqual(inventory["android_schema"], 27)
|
||||
self.assertEqual(inventory["desktop_schema"], 29)
|
||||
self.assertEqual(inventory["android_schema"], 26)
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -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, 30):
|
||||
raise CausalError(f"desktop schema v20-v30 required, found v{version}")
|
||||
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}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -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, 30):
|
||||
raise LifecycleError(f"desktop schema v19-v30 required, found v{version}")
|
||||
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}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -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, 30)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
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-v30 requis")
|
||||
raise ValueError("schema desktop v15-v29 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -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, 30)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v30 requis")
|
||||
raise ValueError("schema desktop v18-v29 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 31))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v30 requis")
|
||||
raise ValueError("schema desktop v8 à v29 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"))
|
||||
|
|
|
|||
|
|
@ -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, 31))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v30 requis")
|
||||
raise ValueError("schema desktop v8 à v29 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(
|
||||
|
|
|
|||
|
|
@ -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, 30):
|
||||
raise ValueError("schema desktop v12-v30 requis")
|
||||
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")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -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, 31))
|
||||
supported_versions = tuple(range(11, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v30 requis")
|
||||
raise ValueError("schema desktop v11-v16 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 = []
|
||||
|
|
|
|||
|
|
@ -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, 30):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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, 31):
|
||||
if version not in range(8, 30):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -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, 30):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 31)
|
||||
supported_versions = range(11, 30)
|
||||
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-v30 requis (v10 accepté pour export V2 explicite)")
|
||||
raise ValueError("schema desktop v11-v16 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")
|
||||
|
|
|
|||
|
|
@ -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, 30):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29):
|
||||
raise ValueError("desktop schema v26, v27, v28 or v29 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
def build_export(connection: sqlite3.Connection) -> dict:
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29)
|
||||
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 = []
|
||||
|
|
|
|||
|
|
@ -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, 31)
|
||||
supported_versions = range(15, 30)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v30 requis")
|
||||
raise ValueError("schema desktop v15-v29 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()
|
||||
|
|
|
|||
|
|
@ -1,247 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -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, 31)
|
||||
supported_versions = range(18, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v30 requis")
|
||||
raise ImportFailure("schema desktop v18-v29 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")
|
||||
|
|
|
|||
|
|
@ -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, 31)
|
||||
supported_versions = range(8, 30)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v30 requis")
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||
|
|
|
|||
|
|
@ -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, 31)
|
||||
supported_versions = range(8, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v30 requis")
|
||||
fail("schema desktop v8 à v21 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
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 31)
|
||||
supported_versions = range(12, 30)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v30 requis")
|
||||
fail("schema desktop v12-v21 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}")
|
||||
|
|
|
|||
|
|
@ -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, 31)
|
||||
supported_versions = range(11, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v30 requis")
|
||||
raise ImportFailure("schema desktop v11-v29 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
|
||||
|
|
|
|||
|
|
@ -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, 30):
|
||||
fail("schema desktop v17-v30 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
fail("schema desktop v17-v29 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -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, 31):
|
||||
if version not in range(5, 30):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v30 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v29 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, 30):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v30")
|
||||
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 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")}
|
||||
|
|
|
|||
|
|
@ -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, 31)
|
||||
supported_versions = range(15, 30)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v30 requis")
|
||||
fail("schema desktop v15-v29 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"]):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ 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",
|
||||
|
|
@ -115,8 +114,8 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.5",
|
||||
"desktop_schema": 30,
|
||||
"android_schema": 27,
|
||||
"desktop_schema": 29,
|
||||
"android_schema": 26,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
@ -131,7 +130,6 @@ def main() -> int:
|
|||
"programs-v1",
|
||||
"program-executions-v1",
|
||||
"sleep-diary-v1",
|
||||
"heart-rate-v1",
|
||||
],
|
||||
"entry_points": [
|
||||
"bin/trainlog",
|
||||
|
|
|
|||
|
|
@ -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, 30):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29):
|
||||
fail("desktop schema v27, v28 or v29 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
|
|
|
|||
|
|
@ -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] != 30:
|
||||
fail("desktop schema v30 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 29:
|
||||
fail("desktop schema v29 required")
|
||||
if args.command == "export":
|
||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")), encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ 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
|
||||
|
|
@ -82,7 +81,6 @@ ARTIFACTS = (
|
|||
)
|
||||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
||||
("trainlog-program-executions", 1),
|
||||
("trainlog-heart-rate", 1),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -211,7 +209,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, 30)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
|
||||
|
|
@ -669,8 +667,6 @@ 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
|
||||
|
|
@ -714,8 +710,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 30
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 29
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -823,14 +823,6 @@ 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',
|
||||
|
|
@ -910,11 +902,6 @@ 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'),
|
||||
|
|
|
|||
|
|
@ -1343,40 +1343,6 @@ 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
|
||||
|
|
@ -2210,7 +2176,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 == 30) {
|
||||
version == 26 || version == 27 || version == 28 || version == 29) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2358,9 +2324,6 @@ 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);
|
||||
}
|
||||
|
|
@ -2380,7 +2343,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 v30" : "migrate database to schema v30",
|
||||
version == 0 ? "create schema v29" : "migrate database to schema v29",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
|
|
@ -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") == 30);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 29);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
|
||||
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
|
||||
|
|
@ -61,9 +61,6 @@ 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);
|
||||
|
|
@ -82,13 +79,13 @@ int main(void) {
|
|||
NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
/* A valid v29 may already exist locally. Opening it completes the historical
|
||||
* v29 repair and then advances additively to the current v30 schema. */
|
||||
/* An unpublished v29 may already exist locally. Opening it completes the
|
||||
* same v29 contract without rewriting user data or changing user_version. */
|
||||
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") == 30);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 29);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
|
||||
"'sleep_medication%'") == 3);
|
||||
|
|
@ -102,7 +99,7 @@ cleanup:
|
|||
}
|
||||
(void)unlink(path);
|
||||
if (result == EXIT_SUCCESS) {
|
||||
puts("PASS schema v28 through v29 to current migration and reopen");
|
||||
puts("PASS schema v28 to v29 migration and reopen");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
|
@ -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 v30: SQLite rc=",
|
||||
strlen("migrate database to schema v30: SQLite rc=")) == 0);
|
||||
"migrate database to schema v29: SQLite rc=",
|
||||
strlen("migrate database to schema v29: 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=31;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=30;", 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 31 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 30 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -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 v30") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v29") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -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.5\"}") != NULL);
|
||||
"\"version\":\"0.1.4\"}") != NULL);
|
||||
{
|
||||
char token[80];
|
||||
CHECK(exchange(port,
|
||||
|
|
|
|||
Loading…
Reference in a new issue